Skip to content

Commit ddac6e2

Browse files
authored
fix(editor): support navigation changes to sites with products and versions (#4848)
1 parent 5801b92 commit ddac6e2

File tree

7 files changed

+729
-67
lines changed

7 files changed

+729
-67
lines changed

packages/fern-docs/components/src/navigation/NavigationStore.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,21 +271,36 @@ export class NavigationStore {
271271
/** Renames a section and tracks the change in docs.yml */
272272
renameSection(sectionId: FernNavigation.NodeId, newTitle: string): void {
273273
if (!this._rootNode) {
274-
console.warn("Cannot rename section: rootNode not available");
274+
console.warn("[NavigationStore.renameSection] Cannot rename section: rootNode not available");
275275
return;
276276
}
277277

278278
// Find section in the tree
279279
const searchResult = findSectionById(this._rootNode, sectionId);
280280

281281
if (!searchResult) {
282-
console.warn(`Cannot rename section: node ${sectionId} not found or not a section`);
282+
console.warn(
283+
`[NavigationStore.renameSection] Cannot rename section: node ${sectionId} not found or not a section`
284+
);
283285
return;
284286
}
285287

286288
const { section: sectionNode, tabSlug, product, version } = searchResult;
287289
const oldTitle = sectionNode.title;
288290

291+
console.log("[NavigationStore.renameSection] Renaming section:", {
292+
sectionId,
293+
oldTitle,
294+
newTitle,
295+
tabSlug,
296+
hasProduct: !!product,
297+
hasVersion: !!version,
298+
productSlug: product && FernNavigation.isInternalProductNode(product) ? product.slug : undefined,
299+
versionSlug: version?.slug,
300+
slugMapSize: this._slugToDocsYmlFilePath?.size,
301+
slugMapKeys: this._slugToDocsYmlFilePath ? Array.from(this._slugToDocsYmlFilePath.keys()) : []
302+
});
303+
289304
// Update the section title in rootNode
290305
const updatedRootNode = updateSectionTitle(this._rootNode, sectionId, newTitle);
291306
this._rootNode = updatedRootNode;
@@ -310,6 +325,8 @@ export class NavigationStore {
310325

311326
const docsYmlFilePath = extractDocsYmlFilePathFromFoundNode(contextForExtraction, this._slugToDocsYmlFilePath);
312327

328+
console.log("[NavigationStore.renameSection] Determined docsYmlFilePath:", docsYmlFilePath);
329+
313330
// Update all existing add_page changes that reference the old section title
314331
// This ensures new pages added to a renamed section use the correct section title in docs.yml
315332
this._navigationChanges.forEach((change, key) => {
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildSlugToDocsYmlFilePath } from "../types";
3+
4+
describe("buildSlugToDocsYmlFilePath", () => {
5+
it("returns empty map when docsYmlContent is null", () => {
6+
const result = buildSlugToDocsYmlFilePath(null);
7+
expect(result).toBeInstanceOf(Map);
8+
expect(result.size).toBe(0);
9+
});
10+
11+
it("returns empty map when docs.yml is not in the content", () => {
12+
const content = new Map([["other.yml", "some content"]]);
13+
const result = buildSlugToDocsYmlFilePath(content);
14+
expect(result).toBeInstanceOf(Map);
15+
expect(result.size).toBe(0);
16+
});
17+
18+
it("parses top-level products with slugs", () => {
19+
const docsYml = `
20+
products:
21+
- display-name: API Reference
22+
slug: api
23+
path: ./api.yml
24+
- display-name: Guide
25+
slug: guide
26+
path: ./guide.yml
27+
`;
28+
const content = new Map([["docs.yml", docsYml]]);
29+
const result = buildSlugToDocsYmlFilePath(content);
30+
31+
expect(result.size).toBe(2);
32+
expect(result.get("api")).toBe("api.yml");
33+
expect(result.get("guide")).toBe("guide.yml");
34+
});
35+
36+
it("parses top-level versions", () => {
37+
const docsYml = `
38+
versions:
39+
- display-name: v2
40+
slug: v2
41+
path: ./versions/v2.yml
42+
- display-name: v1
43+
slug: v1
44+
path: ./versions/v1.yml
45+
`;
46+
const content = new Map([["docs.yml", docsYml]]);
47+
const result = buildSlugToDocsYmlFilePath(content);
48+
49+
expect(result.size).toBe(2);
50+
expect(result.get("v2")).toBe("versions/v2.yml");
51+
expect(result.get("v1")).toBe("versions/v1.yml");
52+
});
53+
54+
it("parses products with nested versions", () => {
55+
const docsYml = `
56+
products:
57+
- display-name: Platform
58+
slug: platform
59+
path: docs/products/platform/v2.yml
60+
versions:
61+
- display-name: v2
62+
slug: platform-v2
63+
path: docs/products/platform/v2.yml
64+
availability: stable
65+
- display-name: v1
66+
slug: platform-v1
67+
path: docs/products/platform/v1.yml
68+
- display-name: Wiki
69+
slug: wiki
70+
path: docs/products/wiki.yml
71+
`;
72+
const content = new Map([["docs.yml", docsYml]]);
73+
const result = buildSlugToDocsYmlFilePath(content);
74+
75+
expect(result.size).toBe(4);
76+
// Product slugs
77+
expect(result.get("platform")).toBe("docs/products/platform/v2.yml");
78+
expect(result.get("wiki")).toBe("docs/products/wiki.yml");
79+
// Nested version slugs
80+
expect(result.get("platform-v2")).toBe("docs/products/platform/v2.yml");
81+
expect(result.get("platform-v1")).toBe("docs/products/platform/v1.yml");
82+
});
83+
84+
it("parses products with nested versions without explicit product slug", () => {
85+
const docsYml = `
86+
products:
87+
- display-name: Platform
88+
path: docs/products/platform/v2.yml
89+
versions:
90+
- display-name: v2
91+
slug: v2
92+
path: docs/products/platform/v2.yml
93+
- display-name: v1
94+
slug: v1
95+
path: docs/products/platform/v1.yml
96+
`;
97+
const content = new Map([["docs.yml", docsYml]]);
98+
const result = buildSlugToDocsYmlFilePath(content);
99+
100+
expect(result.size).toBe(2);
101+
// Product slug derived from path "v2" collides with explicit version slug "v2"
102+
// The version slug wins (last one to be processed)
103+
expect(result.get("v2")).toBe("docs/products/platform/v2.yml");
104+
// Explicit version slugs
105+
expect(result.get("v1")).toBe("docs/products/platform/v1.yml");
106+
});
107+
108+
it("derives slugs from paths when not explicitly provided", () => {
109+
const docsYml = `
110+
products:
111+
- display-name: API
112+
path: docs/api.yml
113+
- display-name: Platform
114+
path: docs/products/platform.yml
115+
versions:
116+
- display-name: Version 2
117+
path: ./versions/v2.yml
118+
tabs:
119+
- display-name: Home
120+
path: ./home.yml
121+
`;
122+
const content = new Map([["docs.yml", docsYml]]);
123+
const result = buildSlugToDocsYmlFilePath(content);
124+
125+
expect(result.size).toBe(4);
126+
// All slugs derived from filenames
127+
expect(result.get("api")).toBe("docs/api.yml");
128+
expect(result.get("platform")).toBe("docs/products/platform.yml");
129+
expect(result.get("v2")).toBe("versions/v2.yml");
130+
expect(result.get("home")).toBe("home.yml");
131+
});
132+
133+
it("parses tabs with file references", () => {
134+
const docsYml = `
135+
tabs:
136+
- display-name: Home
137+
slug: home
138+
path: ./tabs/home.yml
139+
- display-name: Guides
140+
slug: guides
141+
path: ./tabs/guides.yml
142+
`;
143+
const content = new Map([["docs.yml", docsYml]]);
144+
const result = buildSlugToDocsYmlFilePath(content);
145+
146+
expect(result.size).toBe(2);
147+
expect(result.get("home")).toBe("tabs/home.yml");
148+
expect(result.get("guides")).toBe("tabs/guides.yml");
149+
});
150+
151+
it("normalizes paths by removing ./ prefix", () => {
152+
const docsYml = `
153+
products:
154+
- slug: api
155+
path: ./api.yml
156+
versions:
157+
- slug: v2
158+
path: ./versions/v2.yml
159+
tabs:
160+
- slug: home
161+
path: ./tabs/home.yml
162+
`;
163+
const content = new Map([["docs.yml", docsYml]]);
164+
const result = buildSlugToDocsYmlFilePath(content);
165+
166+
expect(result.get("api")).toBe("api.yml");
167+
expect(result.get("v2")).toBe("versions/v2.yml");
168+
expect(result.get("home")).toBe("tabs/home.yml");
169+
});
170+
171+
it("handles mixed configuration with products, versions, and tabs", () => {
172+
const docsYml = `
173+
products:
174+
- display-name: Platform
175+
slug: platform
176+
path: docs/products/platform/v2.yml
177+
versions:
178+
- slug: platform-v2
179+
path: docs/products/platform/v2.yml
180+
- slug: platform-v1
181+
path: docs/products/platform/v1.yml
182+
versions:
183+
- slug: v3
184+
path: versions/v3.yml
185+
tabs:
186+
- slug: home
187+
path: tabs/home.yml
188+
`;
189+
const content = new Map([["docs.yml", docsYml]]);
190+
const result = buildSlugToDocsYmlFilePath(content);
191+
192+
expect(result.size).toBe(5);
193+
expect(result.get("platform")).toBe("docs/products/platform/v2.yml");
194+
expect(result.get("platform-v2")).toBe("docs/products/platform/v2.yml");
195+
expect(result.get("platform-v1")).toBe("docs/products/platform/v1.yml");
196+
expect(result.get("v3")).toBe("versions/v3.yml");
197+
expect(result.get("home")).toBe("tabs/home.yml");
198+
});
199+
200+
it("ignores products without slug or path", () => {
201+
const docsYml = `
202+
products:
203+
- display-name: Platform
204+
# Missing slug and path
205+
- slug: valid
206+
path: ./valid.yml
207+
`;
208+
const content = new Map([["docs.yml", docsYml]]);
209+
const result = buildSlugToDocsYmlFilePath(content);
210+
211+
expect(result.size).toBe(1);
212+
expect(result.get("valid")).toBe("valid.yml");
213+
});
214+
215+
it("ignores versions without slug or path", () => {
216+
const docsYml = `
217+
versions:
218+
- display-name: v2
219+
# Missing slug and path
220+
- slug: v1
221+
path: ./v1.yml
222+
`;
223+
const content = new Map([["docs.yml", docsYml]]);
224+
const result = buildSlugToDocsYmlFilePath(content);
225+
226+
expect(result.size).toBe(1);
227+
expect(result.get("v1")).toBe("v1.yml");
228+
});
229+
230+
it("handles invalid yaml gracefully", () => {
231+
const docsYml = `
232+
invalid yaml: [
233+
this is not valid
234+
`;
235+
const content = new Map([["docs.yml", docsYml]]);
236+
const result = buildSlugToDocsYmlFilePath(content);
237+
238+
// Should return empty map without throwing
239+
expect(result).toBeInstanceOf(Map);
240+
expect(result.size).toBe(0);
241+
});
242+
243+
it("parses real-world example with products containing versions (Plant Store)", () => {
244+
// This is the actual configuration from the user's bug report that was failing
245+
const docsYml = `
246+
instances:
247+
- url: acmeco.docs.buildwithfern.com
248+
title: Plant Store
249+
layout:
250+
searchbar-placement: header
251+
page-width: full
252+
tabs-placement: header
253+
products:
254+
- display-name: Platform
255+
path: docs/products/platform/v2.yml
256+
versions:
257+
- display-name: v2
258+
path: docs/products/platform/v2.yml
259+
availability: stable
260+
- display-name: v1
261+
path: docs/products/platform/v1.yml
262+
- display-name: Wiki
263+
path: docs/products/wiki.yml
264+
colors:
265+
accentPrimary:
266+
dark: '#81C784'
267+
light: '#1B5E20'
268+
logo:
269+
dark: docs/assets/logo-dark.svg
270+
light: docs/assets/logo-light.svg
271+
height: 20
272+
href: https://buildwithfern.com/?utm_campaign=demo&utm_medium=plantstore&utm_source=logo
273+
favicon: docs/assets/favicon.svg
274+
`;
275+
const content = new Map([["docs.yml", docsYml]]);
276+
const result = buildSlugToDocsYmlFilePath(content);
277+
278+
// Should not throw and return a valid Map with derived slugs
279+
expect(result).toBeInstanceOf(Map);
280+
281+
// Products and versions don't have explicit slugs, so slugs are derived from paths
282+
// Derived slugs: "v2" (from platform/v2.yml), "v1" (from platform/v1.yml), "wiki" (from wiki.yml)
283+
expect(result.size).toBe(3);
284+
expect(result.get("v2")).toBe("docs/products/platform/v2.yml");
285+
expect(result.get("v1")).toBe("docs/products/platform/v1.yml");
286+
expect(result.get("wiki")).toBe("docs/products/wiki.yml");
287+
});
288+
});

packages/fern-docs/components/src/navigation/__tests__/ymlUtils.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,5 +421,47 @@ describe("buildDocsYmlContentFromChanges", () => {
421421
expect(docsYmlResult).toContain("New Page");
422422
expect(docsYmlResult).toContain("./pages/new.mdx");
423423
});
424+
425+
it("should remove pages with paths relative to nested yml files", () => {
426+
// Setup: products/platform/v2.yml with pages at ../../pages/platform/
427+
const v2Yml = `navigation:
428+
- section: Overview
429+
contents:
430+
- page: Getting Started
431+
path: ../../pages/platform/getting-started.mdx
432+
- page: Introduction
433+
path: ../../pages/platform/introduction.mdx`;
434+
435+
const snapshot: NavigationSnapshot = {
436+
...createEmptyNavigationSnapshot("test-branch", "test-org", "test-docs-url"),
437+
docsYmlBaseContent: new Map([["docs/products/platform/v2.yml", v2Yml]]),
438+
navigationChanges: new Map()
439+
};
440+
441+
// Remove the introduction page
442+
// Stored path is root-relative: "docs/pages/platform/introduction.mdx"
443+
// YAML has it as: "../../pages/platform/introduction.mdx" (relative to docs/products/platform/v2.yml)
444+
// Both should resolve to the same absolute path: "docs/pages/platform/introduction.mdx"
445+
const changes = new Map<string, NavigationChange>();
446+
changes.set("docs/pages/platform/introduction.mdx", {
447+
type: "remove_page",
448+
sectionTitle: "Overview",
449+
pageEntry: { page: "Introduction", path: "docs/pages/platform/introduction.mdx" },
450+
docsYmlFilePath: "docs/products/platform/v2.yml",
451+
createdAt: Date.now()
452+
});
453+
454+
snapshot.navigationChanges = changes;
455+
456+
const result = buildDocsYmlContentFromChanges(snapshot);
457+
const v2YmlResult = result.get("docs/products/platform/v2.yml") ?? "";
458+
459+
// The introduction page should be removed
460+
expect(v2YmlResult).not.toContain("Introduction");
461+
expect(v2YmlResult).not.toContain("../../pages/platform/introduction.mdx");
462+
// But the getting started page should still be there
463+
expect(v2YmlResult).toContain("Getting Started");
464+
expect(v2YmlResult).toContain("../../pages/platform/getting-started.mdx");
465+
});
424466
});
425467
});

0 commit comments

Comments
 (0)