Skip to content

Commit 317d943

Browse files
dslovinskyclaude
andauthored
feat: include hidden items in nav tree with hidden flag (#1028)
* feat: emit hidden nav items with `hidden: true` instead of omitting them Previously, hidden pages/sections/APIs were excluded from the navigation tree stored in Redis. Now they are included with `hidden: true` so the docs-site can conditionally show them when the user is browsing a hidden page. Algolia records are still skipped for hidden items. Co-Authored-By: Claude <noreply@anthropic.com> * fix: mark section hidden when all children are hidden Prevents empty section headers from appearing when consumers filter hidden items. Also fixes misleading comment ("no visible children" → "no children"). Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: dslovinsky <dslovinsky@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b63439 commit 317d943

9 files changed

Lines changed: 136 additions & 39 deletions

File tree

src/content-indexer/types/navigation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
interface BaseNavItem {
44
title: string;
5+
hidden?: boolean;
56
}
67

78
interface PageNavItem extends BaseNavItem {

src/content-indexer/visitors/__tests__/visit-api-reference.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe("visitApiReference", () => {
182182
expect(firstPath).toBe("reference/get-balance");
183183
});
184184

185-
test("should return no nav for hidden API", () => {
185+
test("should mark nav as hidden for hidden API", () => {
186186
const context = new ProcessingContext("docs");
187187
const cache = new ContentCache();
188188

@@ -215,7 +215,8 @@ describe("visitApiReference", () => {
215215
navigationAncestors: [],
216216
});
217217

218-
expect(result.navItem).toBeUndefined();
218+
expect(result.navItem).toBeDefined();
219+
expect(result.navItem).toHaveProperty("hidden", true);
219220
expect(Object.keys(result.indexEntries).length).toBeGreaterThan(0); // Index still created
220221
});
221222

src/content-indexer/visitors/__tests__/visit-page.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe("visitPage", () => {
5656
});
5757
});
5858

59-
test("should skip nav item for hidden page", () => {
59+
test("should mark nav item as hidden for hidden page", () => {
6060
const context = new ProcessingContext("docs");
6161
const cache = new ContentCache();
6262

@@ -74,7 +74,12 @@ describe("visitPage", () => {
7474
navigationAncestors: [],
7575
});
7676

77-
expect(result.navItem).toBeUndefined();
77+
expect(result.navItem).toEqual({
78+
title: "Hidden Page",
79+
path: "/guides/hidden-page",
80+
type: "page",
81+
hidden: true,
82+
});
7883
expect(result.indexEntries["guides/hidden-page"]).toBeDefined(); // Index still created
7984
});
8085

src/content-indexer/visitors/__tests__/visit-section.test.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ describe("visitSection", () => {
181181
expect(result.indexEntries["guides/page"]).toBeDefined();
182182
});
183183

184-
test("should handle hidden section", () => {
184+
test("should mark hidden section with hidden flag", () => {
185185
const context = new ProcessingContext("docs");
186186
const cache = new ContentCache();
187187

@@ -207,11 +207,100 @@ describe("visitSection", () => {
207207
visitNavigationItem,
208208
);
209209

210-
expect(result.navItem).toBeUndefined();
210+
expect(result.navItem).toBeDefined();
211+
expect(result.navItem).toHaveProperty("hidden", true);
212+
expect(result.navItem).toHaveProperty("type", "section");
213+
expect(result.navItem).toHaveProperty("title", "Hidden Section");
214+
// Children should still be present
215+
if (
216+
result.navItem &&
217+
!Array.isArray(result.navItem) &&
218+
"children" in result.navItem
219+
) {
220+
expect(result.navItem.children).toHaveLength(1);
221+
}
211222
// Index entries should still be created
212223
expect(Object.keys(result.indexEntries).length).toBeGreaterThan(0);
213224
});
214225

226+
test("should mark section hidden when all children are hidden", () => {
227+
const context = new ProcessingContext("docs");
228+
const cache = new ContentCache();
229+
230+
const result = visitSection(
231+
{
232+
item: {
233+
section: "Looks Visible",
234+
contents: [
235+
{
236+
page: "Hidden A",
237+
path: "fern/guides/a.mdx",
238+
hidden: true,
239+
},
240+
{
241+
page: "Hidden B",
242+
path: "fern/guides/b.mdx",
243+
hidden: true,
244+
},
245+
],
246+
},
247+
parentPath: PathBuilder.init("guides"),
248+
tab: "guides",
249+
stripPathPrefix: undefined,
250+
contentCache: cache,
251+
context,
252+
navigationAncestors: [],
253+
},
254+
visitNavigationItem,
255+
);
256+
257+
// Section itself should be marked hidden since all children are hidden
258+
expect(result.navItem).toBeDefined();
259+
expect(result.navItem).toHaveProperty("hidden", true);
260+
// Children should still be present
261+
if (
262+
result.navItem &&
263+
!Array.isArray(result.navItem) &&
264+
"children" in result.navItem
265+
) {
266+
expect(result.navItem.children).toHaveLength(2);
267+
}
268+
});
269+
270+
test("should not mark section hidden when some children are visible", () => {
271+
const context = new ProcessingContext("docs");
272+
const cache = new ContentCache();
273+
274+
const result = visitSection(
275+
{
276+
item: {
277+
section: "Mixed Section",
278+
contents: [
279+
{
280+
page: "Hidden Page",
281+
path: "fern/guides/hidden.mdx",
282+
hidden: true,
283+
},
284+
{
285+
page: "Visible Page",
286+
path: "fern/guides/visible.mdx",
287+
},
288+
],
289+
},
290+
parentPath: PathBuilder.init("guides"),
291+
tab: "guides",
292+
stripPathPrefix: undefined,
293+
contentCache: cache,
294+
context,
295+
navigationAncestors: [],
296+
},
297+
visitNavigationItem,
298+
);
299+
300+
expect(result.navItem).toBeDefined();
301+
expect(result.navItem).not.toHaveProperty("hidden");
302+
});
303+
215304
test("should recursively process nested sections", () => {
216305
const context = new ProcessingContext("docs");
217306
const cache = new ContentCache();

src/content-indexer/visitors/processors/__tests__/process-openrpc.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ describe("processOpenRpcSpec", () => {
158158
expect(Array.isArray(result.navItem)).toBe(true);
159159
});
160160

161-
test("should not create nav if hidden", () => {
161+
test("should mark nav as hidden if hidden", () => {
162162
const context = new ProcessingContext("docs");
163163

164164
const result = processOpenRpcSpec({
@@ -187,7 +187,8 @@ describe("processOpenRpcSpec", () => {
187187
isFlattened: false,
188188
});
189189

190-
expect(result.navItem).toBeUndefined();
190+
expect(result.navItem).toBeDefined();
191+
expect(result.navItem).toHaveProperty("hidden", true);
191192
expect(Object.keys(result.indexEntries).length).toBeGreaterThan(0); // Index still created
192193
});
193194

src/content-indexer/visitors/processors/process-openapi.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -184,15 +184,11 @@ export const processOpenApiSpec = ({
184184
tab,
185185
});
186186

187-
// Return early if hidden (index only, no navigation)
188-
if (isHidden) {
189-
return { indexEntries, navItem: undefined };
190-
}
191-
192-
// Create breadcrumb for Algolia
193-
const apiSectionBreadcrumb = isFlattened
194-
? undefined
195-
: createBreadcrumbNavItem(apiTitle, "api-section");
187+
// Create breadcrumb for Algolia (skip for hidden APIs)
188+
const apiSectionBreadcrumb =
189+
isHidden || isFlattened
190+
? undefined
191+
: createBreadcrumbNavItem(apiTitle, "api-section");
196192

197193
// Build navigation items
198194
const tagSections = buildOpenApiNavigation({
@@ -205,13 +201,14 @@ export const processOpenApiSpec = ({
205201
isHidden,
206202
});
207203

208-
// Return flattened or wrapped navigation
204+
// Return flattened or wrapped navigation (marked hidden if applicable)
209205
const navItem: NavItem | NavItem[] = isFlattened
210-
? tagSections
206+
? tagSections.map((item) => (isHidden ? { ...item, hidden: true } : item))
211207
: {
212208
title: apiTitle,
213209
type: "api-section",
214210
children: tagSections,
211+
...(isHidden && { hidden: true }),
215212
};
216213

217214
return { indexEntries, navItem };

src/content-indexer/visitors/processors/process-openrpc.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,16 @@ export const processOpenRpcSpec = ({
104104
});
105105
});
106106

107-
// Return early if hidden
108-
if (isHidden) {
109-
return { indexEntries, navItem: undefined };
110-
}
111-
112-
// Return flattened or wrapped navigation
107+
// Return flattened or wrapped navigation (marked hidden if applicable)
113108
const navItem: NavItem | NavItem[] = isFlattened
114-
? endpointNavItems
109+
? endpointNavItems.map((item) =>
110+
isHidden ? { ...item, hidden: true } : item,
111+
)
115112
: {
116113
title: apiTitle,
117114
type: "api-section",
118115
children: endpointNavItems,
116+
...(isHidden && { hidden: true }),
119117
};
120118

121119
return { indexEntries, navItem };

src/content-indexer/visitors/visit-page.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,22 @@ export const visitPage = ({
5454
},
5555
};
5656

57-
// Build nav item (skip if hidden)
57+
// Build nav item (marked hidden if applicable)
5858
const descriptionRaw =
5959
cached?.frontmatter.description || cached?.frontmatter.subtitle;
6060
const description =
6161
typeof descriptionRaw === "string" ? descriptionRaw : undefined;
6262

63-
const navItem: NavItem | undefined = pageItem.hidden
64-
? undefined
65-
: {
66-
title: pageItem.page,
67-
path: `/${finalPath}`,
68-
type: "page",
69-
description,
70-
};
63+
const navItem: NavItem = {
64+
title: pageItem.page,
65+
path: `/${finalPath}`,
66+
type: "page",
67+
description,
68+
...(pageItem.hidden && { hidden: true }),
69+
};
7170

7271
// Build Algolia record (if content available and not hidden)
73-
if (cached && navItem) {
72+
if (cached && !pageItem.hidden) {
7473
const title = cached.frontmatter.title || pageItem.page;
7574
context.addAlgoliaRecord({
7675
pageType: "Guide",

src/content-indexer/visitors/visit-section.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,19 @@ export const visitSection = (
138138
.flat()
139139
.filter((child): child is NavItem => child !== undefined);
140140

141-
// Only include section in nav if it has children and is not hidden
142-
if (children.length === 0 || sectionItem.hidden) {
141+
// Skip section from nav if it has no children
142+
if (children.length === 0) {
143143
return { indexEntries, navItem: undefined };
144144
}
145145

146-
// Update section nav item with children
146+
// Update section nav item with children.
147+
// Mark hidden if explicitly hidden OR if every child is hidden
148+
// (prevents empty section headers when consumers filter hidden items).
147149
sectionNavItem.children = children;
150+
const allChildrenHidden = children.every((child) => child.hidden === true);
151+
if (sectionItem.hidden || allChildrenHidden) {
152+
sectionNavItem.hidden = true;
153+
}
148154

149155
return { indexEntries, navItem: sectionNavItem };
150156
};

0 commit comments

Comments
 (0)