From 136c97df0a8d7507546d12fcae49d5417c8f6f92 Mon Sep 17 00:00:00 2001 From: "ryan.stephen" Date: Fri, 14 Aug 2026 15:37:51 +0000 Subject: [PATCH 1/4] feat(cli): render Doxygen group pages for C++ library docs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../unreleased/cpp-library-docs-groups.yml | 7 + .../cpp/src/renderers/GroupPageRenderer.ts | 162 ++++++++++++++++++ .../src/CppDocsGenerator.ts | 85 ++++++++- .../src/__test__/CppDocsGenerator.test.ts | 133 +++++++++++++- .../src/types/CppLibraryDocsIr.ts | 7 + 5 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/cpp-library-docs-groups.yml create mode 100644 packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts diff --git a/packages/cli/cli/changes/unreleased/cpp-library-docs-groups.yml b/packages/cli/cli/changes/unreleased/cpp-library-docs-groups.yml new file mode 100644 index 000000000000..41a205d06386 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/cpp-library-docs-groups.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + C++ library docs now render Doxygen groups (`@defgroup` / `@ingroup` / `@addtogroup`). + Each group, including nested subgroups, gets a page under `groups/` that links to the + generated symbol pages, so a library's authored organization shows up in the navigation. + type: feat diff --git a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts new file mode 100644 index 000000000000..768061821d8f --- /dev/null +++ b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts @@ -0,0 +1,162 @@ +/** + * Renders pages for Doxygen groups (the "Modules" of Doxygen's own HTML output). + * + * Groups are authored in the source comments with `@defgroup` / `@ingroup` / + * `@addtogroup` and describe how the library's author organizes the API. They + * are rendered as index pages that link to the per-symbol pages produced from + * the namespace tree, so a symbol's documentation lives in exactly one place. + * + * Two page types are produced: + * 1. Group page — the group's docs plus links to its members and subgroups + * 2. Groups index — a bulleted list of the top-level groups + */ + +import type { CppFunctionIr, CppGroupIr } from "../../../src/types/CppLibraryDocsIr.js"; +import { buildLinkPath, stripTemplateArgs } from "../context.js"; +import { + renderDescriptionBlocksDeduped, + renderSegmentsPlainText, + renderSegmentsTrimmed +} from "./DescriptionRenderer.js"; +import { renderFrontmatter, trimTrailingBlankLines } from "./shared.js"; + +export interface GroupMemberEntry { + displayName: string; + linkPath: string | undefined; +} + +export interface GroupSection { + heading: string; + entries: GroupMemberEntry[]; +} + +export interface GroupListEntry { + displayName: string; + linkPath: string; +} + +function collectEntries(items: T[]): GroupMemberEntry[] { + return items + .map((item) => ({ displayName: item.path, linkPath: buildLinkPath(item.path) })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +function collectFunctionEntries(functions: CppFunctionIr[]): GroupMemberEntry[] { + // Deduplicate by path — overloads share one page + const seen = new Set(); + const entries: GroupMemberEntry[] = []; + for (const func of functions) { + const stripped = stripTemplateArgs(func.path); + if (seen.has(stripped)) { + continue; + } + seen.add(stripped); + entries.push({ displayName: func.path, linkPath: buildLinkPath(stripped) }); + } + return entries.sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +/** + * Collect a group's inlined members into rendered sections, skipping empty ones. + * + * Must be called with the group's page as the current page (see + * `setCurrentPageSlugPath`) so that member links resolve to relative paths. + */ +export function collectGroupSections(group: CppGroupIr): GroupSection[] { + const classes = group.classes ?? []; + const sections: GroupSection[] = [ + { heading: "Classes", entries: collectEntries(classes.filter((cls) => cls.kind !== "struct")) }, + { heading: "Structs", entries: collectEntries(classes.filter((cls) => cls.kind === "struct")) }, + { heading: "Functions", entries: collectFunctionEntries(group.functions ?? []) }, + { heading: "Enumerations", entries: collectEntries(group.enums ?? []) }, + { heading: "Type Definitions", entries: collectEntries(group.typedefs ?? []) }, + { heading: "Variables", entries: collectEntries(group.variables ?? []) } + ]; + return sections.filter((section) => section.entries.length > 0); +} + +/** + * Whether a group (or any of its subgroups) has anything to render. + */ +export function groupHasContent(group: CppGroupIr): boolean { + const hasMembers = [group.classes, group.functions, group.enums, group.typedefs, group.variables].some( + (members) => members != null && members.length > 0 + ); + return hasMembers || group.subgroups.some((subgroup) => groupHasContent(subgroup)); +} + +function renderEntries(entries: GroupMemberEntry[], lines: string[]): void { + for (const entry of entries) { + if (entry.linkPath) { + lines.push(`- [\`${entry.displayName}\`](${entry.linkPath})`); + } else { + lines.push(`- \`${entry.displayName}\``); + } + } +} + +/** + * Render a single group page. + * + * @param group - The group to render + * @param sections - Pre-computed non-empty member sections + * @param subgroupEntries - Pre-computed links to nested groups + */ +export function renderGroupPage( + group: CppGroupIr, + sections: GroupSection[], + subgroupEntries: GroupListEntry[] +): string { + const lines: string[] = []; + const docstring = group.docstring; + + const title = group.title || group.name; + const description = docstring ? renderSegmentsPlainText(docstring.summary) : `Members of the ${title} group.`; + lines.push(...renderFrontmatter(title, description)); + + if (docstring?.summary && docstring.summary.length > 0) { + const summary = renderSegmentsTrimmed(docstring.summary); + if (summary) { + lines.push("", summary); + } + } + + if (docstring?.description && docstring.description.length > 0) { + const desc = renderDescriptionBlocksDeduped(docstring.description, docstring.summary); + if (desc) { + lines.push("", desc); + } + } + + for (const section of sections) { + lines.push("", `## ${section.heading}`, ""); + renderEntries(section.entries, lines); + } + + if (subgroupEntries.length > 0) { + lines.push("", "## Subgroups", ""); + for (const entry of subgroupEntries) { + lines.push(`- [${entry.displayName}](${entry.linkPath})`); + } + } + + trimTrailingBlankLines(lines); + return lines.join("\n") + "\n"; +} + +/** + * Render the groups/index.mdx page listing the top-level groups. + */ +export function renderGroupsIndexPage(entries: GroupListEntry[], libraryTitle: string): string { + const lines: string[] = []; + + lines.push(...renderFrontmatter(`${libraryTitle} — Groups`, `Documentation groups in ${libraryTitle}.`)); + lines.push(""); + + for (const entry of entries) { + lines.push(`- [${entry.displayName}](${entry.linkPath})`); + } + + trimTrailingBlankLines(lines); + return lines.join("\n") + "\n"; +} diff --git a/packages/cli/library-docs-generator/src/CppDocsGenerator.ts b/packages/cli/library-docs-generator/src/CppDocsGenerator.ts index 76b74fc95839..2f4fa8ecf0ae 100644 --- a/packages/cli/library-docs-generator/src/CppDocsGenerator.ts +++ b/packages/cli/library-docs-generator/src/CppDocsGenerator.ts @@ -6,6 +6,7 @@ * 2. Compute page keys, resolving filename collisions for template specializations * 3. Render each compound page and stream to disk via MdxFileWriter * 4. Generate hierarchical index pages (namespace → category folders → entity pages) + * 5. Generate group pages from the library's Doxygen groups, linking to the entity pages * * Designed for sequential rendering: global state in the renderers (nameToPathMap, * currentPagePath) requires that pages are rendered one at a time. @@ -24,6 +25,13 @@ import { import type { CppCompoundIr } from "../cpp/src/renderers/CompoundPageRenderer.js"; import { renderCompoundPage } from "../cpp/src/renderers/CompoundPageRenderer.js"; import { renderSegmentsPlainText } from "../cpp/src/renderers/DescriptionRenderer.js"; +import type { GroupListEntry } from "../cpp/src/renderers/GroupPageRenderer.js"; +import { + collectGroupSections, + groupHasContent, + renderGroupPage, + renderGroupsIndexPage +} from "../cpp/src/renderers/GroupPageRenderer.js"; import type { CategoryDefinition, CategoryWithEntries, @@ -37,7 +45,13 @@ import { renderNamespacesIndexPage } from "../cpp/src/renderers/IndexPageRenderer.js"; import { groupFunctionsByName, methodAnchorId } from "../cpp/src/renderers/MethodRenderer.js"; -import type { CppClassIr, CppDocstringIr, CppLibraryDocsIr, CppNamespaceIr } from "./types/CppLibraryDocsIr.js"; +import type { + CppClassIr, + CppDocstringIr, + CppGroupIr, + CppLibraryDocsIr, + CppNamespaceIr +} from "./types/CppLibraryDocsIr.js"; import { MdxFileWriter } from "./writers/MdxFileWriter.js"; export interface CppGenerateOptions { @@ -115,6 +129,9 @@ export function generateCpp(options: CppGenerateOptions): CppGenerateResult { generateIndexPages(libraryNs, title, writer, rootNsName, outputFolderSlug); } + // Stage 5: Generate pages for the library's Doxygen groups + generateGroupPages(ir.groups ?? [], writer, repo); + return writer.result(); } finally { clearEntityRegistry(); @@ -573,3 +590,69 @@ function generateIndexPages( generateIndexPages(child, `Namespace ${child.path}`, writer, rootNsName, outputFolderSlug); } } + +// --------------------------------------------------------------------------- +// Group page generation (Stage 5) +// --------------------------------------------------------------------------- + +const GROUPS_FOLDER = "groups"; + +function groupFolderName(group: CppGroupIr): string { + return sanitizeForFilename(group.name || group.title); +} + +function groupDisplayName(group: CppGroupIr): string { + return group.title || group.name; +} + +/** + * Generate a page per Doxygen group, plus a groups/index.mdx listing them. + * + * Group pages link to the entity pages written in Stage 3 rather than + * re-rendering their members, so each symbol is documented in one place. + * Members that have no page (for example symbols the parser skipped) are + * listed without a link. + */ +function generateGroupPages(groups: CppGroupIr[], writer: MdxFileWriter, libraryTitle: string): void { + const renderable = groups.filter((group) => groupHasContent(group)); + if (renderable.length === 0) { + return; + } + + const indexPageKey = `${GROUPS_FOLDER}/index.mdx`; + setCurrentPageSlugPath(pageKeyToSlugPath(indexPageKey)); + const entries: GroupListEntry[] = renderable.map((group) => ({ + displayName: groupDisplayName(group), + linkPath: `${GROUPS_FOLDER}/${slugifySegment(groupFolderName(group))}` + })); + writer.writePage(indexPageKey, renderGroupsIndexPage(entries, libraryTitle)); + + for (const group of renderable) { + writeGroupPage(group, `${GROUPS_FOLDER}/${groupFolderName(group)}`, writer); + } +} + +/** + * Write one group page at `/index.mdx` and recurse into its subgroups. + * + * Links are relative to the group's own folder, matching how Fern resolves + * links on a folder index page (the `/index` suffix is stripped from the URL). + */ +function writeGroupPage(group: CppGroupIr, dir: string, writer: MdxFileWriter): void { + const pageKey = `${dir}/index.mdx`; + setCurrentPageSlugPath(pageKeyToSlugPath(pageKey)); + + const sections = collectGroupSections(group); + const subgroups = group.subgroups.filter((subgroup) => groupHasContent(subgroup)); + const dirSegment = slugifySegment(dir.split("/").pop() ?? ""); + const subgroupEntries: GroupListEntry[] = subgroups.map((subgroup) => ({ + displayName: groupDisplayName(subgroup), + linkPath: `${dirSegment}/${slugifySegment(groupFolderName(subgroup))}` + })); + + writer.writePage(pageKey, renderGroupPage(group, sections, subgroupEntries)); + + for (const subgroup of subgroups) { + writeGroupPage(subgroup, `${dir}/${groupFolderName(subgroup)}`, writer); + } +} diff --git a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts index 57d6d48d5c64..4f9aef2ddbee 100644 --- a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts +++ b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts @@ -7,6 +7,8 @@ import type { CppClassIr, CppConceptIr, CppDocstringIr, + CppFunctionIr, + CppGroupIr, CppLibraryDocsIr, CppNamespaceIr, IrMetadata @@ -81,6 +83,51 @@ function makeConcept(overrides: Partial): CppConceptIr { }; } +function makeFunction(overrides: Partial): CppFunctionIr { + return { + name: "my_func", + path: "cub::my_func", + signature: "void my_func()", + templateParams: [], + parameters: [], + returnType: undefined, + docstring: makeDocstring(), + isStatic: false, + isConst: false, + isConstexpr: false, + isVolatile: false, + isInline: false, + isExplicit: false, + isNoexcept: false, + noexceptExpression: undefined, + isNoDiscard: false, + virtuality: "non-virtual", + refQualifier: undefined, + requiresClause: undefined, + isDeleted: false, + ...overrides + }; +} + +function makeGroup(overrides: Partial): CppGroupIr { + return { + id: "group__my__group", + name: "my_group", + title: "My Group", + docstring: undefined, + memberRefs: [], + innerClassRefs: [], + innerNamespaceRefs: [], + classes: [], + functions: [], + enums: [], + typedefs: [], + variables: [], + subgroups: [], + ...overrides + }; +} + function makeNamespace(overrides: Partial): CppNamespaceIr { return { name: "", @@ -97,11 +144,15 @@ function makeNamespace(overrides: Partial): CppNamespaceIr { }; } -function makeIr(rootNamespace: CppNamespaceIr, metadata?: Partial): CppLibraryDocsIr { +function makeIr( + rootNamespace: CppNamespaceIr, + metadata?: Partial, + groups: CppGroupIr[] = [] +): CppLibraryDocsIr { return { metadata: { ...DEFAULT_METADATA, ...metadata }, rootNamespace, - groups: [] + groups }; } @@ -295,4 +346,82 @@ describe("generateCpp()", () => { expect(content).toContain("concept random_access_range"); expect(content).toContain("std::ranges::random_access_range"); }); + + // ------------------------------------------------------------------ + // 6. Doxygen group pages + // ------------------------------------------------------------------ + it("generates group pages linking to entity pages, including nested subgroups", () => { + const blockScan = makeClass({ name: "BlockScan", path: "cub::BlockScan" }); + const deviceScan = makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" }); + const tune = makeFunction({ name: "Tune", path: "cub::Tune" }); + + const ir = makeIr( + makeNamespace({ + name: "cub", + path: "cub", + classes: [blockScan], + functions: [deviceScan, tune] + }), + undefined, + [ + makeGroup({ + id: "group__scan", + name: "scan", + title: "Scan", + docstring: makeDocstring({ summary: [{ type: "text", text: "Prefix scan primitives." }] }), + classes: [blockScan], + functions: [deviceScan], + subgroups: [ + makeGroup({ + id: "group__scan__advanced", + name: "scan_advanced", + title: "Advanced scan", + functions: [tune] + }) + ] + }) + ] + ); + + const result = generateCpp({ ir, outputDir: tmpDir, slug: "reference/cub" }); + + const relativePaths = collectMdxFiles(tmpDir).map((f) => f.substring(tmpDir.length + 1)); + expect(relativePaths).toContain("groups/index.mdx"); + expect(relativePaths).toContain("groups/scan/index.mdx"); + expect(relativePaths).toContain("groups/scan/scan_advanced/index.mdx"); + expect(result.pageCount).toBe(relativePaths.length); + + const groupsIndex = readFileSync(join(tmpDir, "groups/index.mdx"), "utf-8"); + expect(groupsIndex).toContain("- [Scan](groups/scan)"); + + const scanPage = readFileSync(join(tmpDir, "groups/scan/index.mdx"), "utf-8"); + expect(scanPage).toContain("title: Scan"); + expect(scanPage).toContain("Prefix scan primitives."); + expect(scanPage).toContain("## Classes"); + expect(scanPage).toContain("- [`cub::BlockScan`](../classes/blockscan)"); + expect(scanPage).toContain("## Functions"); + expect(scanPage).toContain("- [`cub::DeviceScan`](../functions/devicescan)"); + expect(scanPage).toContain("## Subgroups"); + expect(scanPage).toContain("- [Advanced scan](scan/scanadvanced)"); + + const subgroupPage = readFileSync(join(tmpDir, "groups/scan/scan_advanced/index.mdx"), "utf-8"); + expect(subgroupPage).toContain("title: Advanced scan"); + expect(subgroupPage).toContain("- [`cub::Tune`](../../functions/tune)"); + }); + + it("writes no group pages when the IR has no groups with members", () => { + const ir = makeIr( + makeNamespace({ + name: "cub", + path: "cub", + classes: [makeClass({ name: "BlockScan", path: "cub::BlockScan" })] + }), + undefined, + [makeGroup({ id: "group__empty", name: "empty", title: "Empty" })] + ); + + generateCpp({ ir, outputDir: tmpDir, slug: "reference/cub" }); + + expect(existsSync(join(tmpDir, "groups"))).toBe(false); + }); }); diff --git a/packages/cli/library-docs-generator/src/types/CppLibraryDocsIr.ts b/packages/cli/library-docs-generator/src/types/CppLibraryDocsIr.ts index 47c7ab9dffb2..143214757487 100644 --- a/packages/cli/library-docs-generator/src/types/CppLibraryDocsIr.ts +++ b/packages/cli/library-docs-generator/src/types/CppLibraryDocsIr.ts @@ -306,6 +306,13 @@ export interface CppGroupIr { memberRefs: string[]; innerClassRefs: string[]; innerNamespaceRefs: string[]; + // Members are inlined by the parser. Optional because an IR produced before + // inlining shipped omits them entirely. + classes?: CppClassIr[]; + functions?: CppFunctionIr[]; + enums?: CppEnumIr[]; + typedefs?: CppTypedefIr[]; + variables?: CppVariableIr[]; subgroups: CppGroupIr[]; } From 8448683259f96458cdf4a483963ca2f166eaa6f5 Mon Sep 17 00:00:00 2001 From: "ryan.stephen" Date: Fri, 14 Aug 2026 15:43:36 +0000 Subject: [PATCH 2/4] fix(cli): guard group traversal against cycles and empty group descriptions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../cpp/src/renderers/GroupPageRenderer.ts | 14 +++++++--- .../src/CppDocsGenerator.ts | 17 +++++++++--- .../src/__test__/CppDocsGenerator.test.ts | 27 +++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts index 768061821d8f..ceebc1b7257a 100644 --- a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts +++ b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts @@ -77,12 +77,19 @@ export function collectGroupSections(group: CppGroupIr): GroupSection[] { /** * Whether a group (or any of its subgroups) has anything to render. + * + * A group tree that references one of its ancestors would otherwise recurse + * forever, so already-visited groups are treated as having no content. */ -export function groupHasContent(group: CppGroupIr): boolean { +export function groupHasContent(group: CppGroupIr, visited: Set = new Set()): boolean { + if (visited.has(group.id)) { + return false; + } + visited.add(group.id); const hasMembers = [group.classes, group.functions, group.enums, group.typedefs, group.variables].some( (members) => members != null && members.length > 0 ); - return hasMembers || group.subgroups.some((subgroup) => groupHasContent(subgroup)); + return hasMembers || group.subgroups.some((subgroup) => groupHasContent(subgroup, visited)); } function renderEntries(entries: GroupMemberEntry[], lines: string[]): void { @@ -111,7 +118,8 @@ export function renderGroupPage( const docstring = group.docstring; const title = group.title || group.name; - const description = docstring ? renderSegmentsPlainText(docstring.summary) : `Members of the ${title} group.`; + const summaryText = docstring ? renderSegmentsPlainText(docstring.summary) : ""; + const description = summaryText.length > 0 ? summaryText : `Members of the ${title} group.`; lines.push(...renderFrontmatter(title, description)); if (docstring?.summary && docstring.summary.length > 0) { diff --git a/packages/cli/library-docs-generator/src/CppDocsGenerator.ts b/packages/cli/library-docs-generator/src/CppDocsGenerator.ts index 2f4fa8ecf0ae..1181badbc55a 100644 --- a/packages/cli/library-docs-generator/src/CppDocsGenerator.ts +++ b/packages/cli/library-docs-generator/src/CppDocsGenerator.ts @@ -627,8 +627,9 @@ function generateGroupPages(groups: CppGroupIr[], writer: MdxFileWriter, library })); writer.writePage(indexPageKey, renderGroupsIndexPage(entries, libraryTitle)); + const written = new Set(); for (const group of renderable) { - writeGroupPage(group, `${GROUPS_FOLDER}/${groupFolderName(group)}`, writer); + writeGroupPage(group, `${GROUPS_FOLDER}/${groupFolderName(group)}`, writer, written); } } @@ -637,13 +638,21 @@ function generateGroupPages(groups: CppGroupIr[], writer: MdxFileWriter, library * * Links are relative to the group's own folder, matching how Fern resolves * links on a folder index page (the `/index` suffix is stripped from the URL). + * + * `written` tracks the groups already emitted so a group tree that references + * one of its ancestors terminates instead of recursing forever. */ -function writeGroupPage(group: CppGroupIr, dir: string, writer: MdxFileWriter): void { +function writeGroupPage(group: CppGroupIr, dir: string, writer: MdxFileWriter, written: Set): void { + if (written.has(group.id)) { + return; + } + written.add(group.id); + const pageKey = `${dir}/index.mdx`; setCurrentPageSlugPath(pageKeyToSlugPath(pageKey)); const sections = collectGroupSections(group); - const subgroups = group.subgroups.filter((subgroup) => groupHasContent(subgroup)); + const subgroups = group.subgroups.filter((subgroup) => !written.has(subgroup.id) && groupHasContent(subgroup)); const dirSegment = slugifySegment(dir.split("/").pop() ?? ""); const subgroupEntries: GroupListEntry[] = subgroups.map((subgroup) => ({ displayName: groupDisplayName(subgroup), @@ -653,6 +662,6 @@ function writeGroupPage(group: CppGroupIr, dir: string, writer: MdxFileWriter): writer.writePage(pageKey, renderGroupPage(group, sections, subgroupEntries)); for (const subgroup of subgroups) { - writeGroupPage(subgroup, `${dir}/${groupFolderName(subgroup)}`, writer); + writeGroupPage(subgroup, `${dir}/${groupFolderName(subgroup)}`, writer, written); } } diff --git a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts index 4f9aef2ddbee..5cd9e6612847 100644 --- a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts +++ b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts @@ -424,4 +424,31 @@ describe("generateCpp()", () => { expect(existsSync(join(tmpDir, "groups"))).toBe(false); }); + + it("terminates on a group tree whose subgroup references an ancestor", () => { + const scan = makeGroup({ + id: "group__scan", + name: "scan", + title: "Scan", + functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })] + }); + // A malformed IR: the subgroup points back at its parent + scan.subgroups.push(makeGroup({ id: "group__nested", name: "nested", subgroups: [scan] })); + + const ir = makeIr( + makeNamespace({ + name: "cub", + path: "cub", + functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })] + }), + undefined, + [scan] + ); + + const result = generateCpp({ ir, outputDir: tmpDir, slug: "reference/cub" }); + + expect(result.writtenFiles.filter((file) => file.includes("/groups/")).length).toBe(3); + expect(existsSync(join(tmpDir, "groups/scan/nested/index.mdx"))).toBe(true); + expect(existsSync(join(tmpDir, "groups/scan/nested/scan"))).toBe(false); + }); }); From 619b9d635dc138aca616a685958a31a6dcacca69 Mon Sep 17 00:00:00 2001 From: "ryan.stephen" Date: Mon, 17 Aug 2026 13:30:49 +0000 Subject: [PATCH 3/4] fix(cli): skip anonymous group members and link groups from the library index Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../cpp/src/renderers/GroupPageRenderer.ts | 21 ++++- .../cpp/src/renderers/IndexPageRenderer.ts | 8 +- .../src/CppDocsGenerator.ts | 18 ++-- .../src/__test__/CppDocsGenerator.test.ts | 84 +++++++++++++++++++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts index ceebc1b7257a..588728e41e84 100644 --- a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts +++ b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts @@ -35,8 +35,19 @@ export interface GroupListEntry { linkPath: string; } +/** + * Whether a member can be listed on a group page. + * + * Doxygen emits anonymous members (an unnamed `enum`, for example) with an + * empty path; they have no page and nothing to display. + */ +function isNamedMember(member: { path: string }): boolean { + return member.path.trim().length > 0; +} + function collectEntries(items: T[]): GroupMemberEntry[] { return items + .filter(isNamedMember) .map((item) => ({ displayName: item.path, linkPath: buildLinkPath(item.path) })) .sort((a, b) => a.displayName.localeCompare(b.displayName)); } @@ -46,6 +57,9 @@ function collectFunctionEntries(functions: CppFunctionIr[]): GroupMemberEntry[] const seen = new Set(); const entries: GroupMemberEntry[] = []; for (const func of functions) { + if (!isNamedMember(func)) { + continue; + } const stripped = stripTemplateArgs(func.path); if (seen.has(stripped)) { continue; @@ -87,7 +101,7 @@ export function groupHasContent(group: CppGroupIr, visited: Set = new Se } visited.add(group.id); const hasMembers = [group.classes, group.functions, group.enums, group.typedefs, group.variables].some( - (members) => members != null && members.length > 0 + (members) => members != null && members.some(isNamedMember) ); return hasMembers || group.subgroups.some((subgroup) => groupHasContent(subgroup, visited)); } @@ -158,7 +172,10 @@ export function renderGroupPage( export function renderGroupsIndexPage(entries: GroupListEntry[], libraryTitle: string): string { const lines: string[] = []; - lines.push(...renderFrontmatter(`${libraryTitle} — Groups`, `Documentation groups in ${libraryTitle}.`)); + const library = libraryTitle.trim(); + const title = library.length > 0 ? `${library} — Groups` : "Groups"; + const description = library.length > 0 ? `Documentation groups in ${library}.` : "Documentation groups in this library."; + lines.push(...renderFrontmatter(title, description)); lines.push(""); for (const entry of entries) { diff --git a/packages/cli/library-docs-generator/cpp/src/renderers/IndexPageRenderer.ts b/packages/cli/library-docs-generator/cpp/src/renderers/IndexPageRenderer.ts index e352d5e3aea7..ab66de13f606 100644 --- a/packages/cli/library-docs-generator/cpp/src/renderers/IndexPageRenderer.ts +++ b/packages/cli/library-docs-generator/cpp/src/renderers/IndexPageRenderer.ts @@ -151,12 +151,14 @@ export function namespaceHasEntities(ns: CppNamespaceIr): boolean { * @param categories - Pre-computed non-empty categories with their entries * @param hasChildNamespaces - Whether child namespaces with entities exist * @param nsLastSegment - Last segment of the namespace directory path (used as link prefix) + * @param hasGroups - Whether the library has Doxygen group pages to link to */ export function renderNamespaceIndexPage( title: string, categories: CategoryWithEntries[], hasChildNamespaces: boolean, - nsLastSegment: string + nsLastSegment: string, + hasGroups = false ): string { const lines: string[] = []; @@ -175,6 +177,10 @@ export function renderNamespaceIndexPage( lines.push(`- [Namespaces](${nsLastSegment}/namespaces)`); } + if (hasGroups) { + lines.push(`- [Groups](${nsLastSegment}/groups)`); + } + trimTrailingBlankLines(lines); return lines.join("\n") + "\n"; } diff --git a/packages/cli/library-docs-generator/src/CppDocsGenerator.ts b/packages/cli/library-docs-generator/src/CppDocsGenerator.ts index 1181badbc55a..f392ffd0ac32 100644 --- a/packages/cli/library-docs-generator/src/CppDocsGenerator.ts +++ b/packages/cli/library-docs-generator/src/CppDocsGenerator.ts @@ -120,17 +120,19 @@ export function generateCpp(options: CppGenerateOptions): CppGenerateResult { writer.writePage(entry.pageKey, content); } + const groups = (ir.groups ?? []).filter((group) => groupHasContent(group)); + // Stage 4: Generate index pages for namespaces const slugBaseName = slug.includes("/") ? (slug.split("/").pop() ?? slug) : slug; const libraryNs = ir.rootNamespace.namespaces.find((child) => child.name === slugBaseName); if (libraryNs) { const title = LIBRARY_TITLES[libraryNs.name] ?? `${libraryNs.name} API Reference`; const outputFolderSlug = slugifySegment(outputDir.split("/").pop() || slug); - generateIndexPages(libraryNs, title, writer, rootNsName, outputFolderSlug); + generateIndexPages(libraryNs, title, writer, rootNsName, outputFolderSlug, groups.length > 0); } // Stage 5: Generate pages for the library's Doxygen groups - generateGroupPages(ir.groups ?? [], writer, repo); + generateGroupPages(groups, writer, repo.trim() || (rootNsName ?? slug)); return writer.result(); } finally { @@ -511,7 +513,8 @@ function generateIndexPages( title: string, writer: MdxFileWriter, rootNsName: string | undefined, - outputFolderSlug: string + outputFolderSlug: string, + hasGroups: boolean ): void { if (!namespaceHasEntities(ns)) { return; @@ -548,7 +551,8 @@ function generateIndexPages( title, categoriesForNsIndex, childrenWithEntities.length > 0, - nsLastSegment + nsLastSegment, + hasGroups ); writer.writePage(nsIndexPageKey, indexContent); @@ -587,7 +591,8 @@ function generateIndexPages( // 4. Recurse into child namespaces for (const child of ns.namespaces) { - generateIndexPages(child, `Namespace ${child.path}`, writer, rootNsName, outputFolderSlug); + // Groups are listed on the library's index page only, not on every namespace + generateIndexPages(child, `Namespace ${child.path}`, writer, rootNsName, outputFolderSlug, false); } } @@ -613,8 +618,7 @@ function groupDisplayName(group: CppGroupIr): string { * Members that have no page (for example symbols the parser skipped) are * listed without a link. */ -function generateGroupPages(groups: CppGroupIr[], writer: MdxFileWriter, libraryTitle: string): void { - const renderable = groups.filter((group) => groupHasContent(group)); +function generateGroupPages(renderable: CppGroupIr[], writer: MdxFileWriter, libraryTitle: string): void { if (renderable.length === 0) { return; } diff --git a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts index 5cd9e6612847..61b97f0f37bb 100644 --- a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts +++ b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts @@ -425,6 +425,90 @@ describe("generateCpp()", () => { expect(existsSync(join(tmpDir, "groups"))).toBe(false); }); + it("skips anonymous group members, which have no page to link to", () => { + const ir = makeIr( + makeNamespace({ + name: "cub", + path: "cub", + functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })] + }), + undefined, + [ + makeGroup({ + id: "group__scan", + name: "scan", + title: "Scan", + functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })], + // Doxygen emits an unnamed enum with no name and no path + enums: [{ name: "", path: "", isScoped: false, underlyingType: undefined, values: [], docstring: undefined }] + }), + makeGroup({ id: "group__anon__only", name: "anon_only", title: "Anonymous only", enums: [ + { name: "", path: "", isScoped: false, underlyingType: undefined, values: [], docstring: undefined } + ] }) + ] + ); + + generateCpp({ ir, outputDir: tmpDir, slug: "reference/cub" }); + + const scanPage = readFileSync(join(tmpDir, "groups/scan/index.mdx"), "utf-8"); + expect(scanPage).not.toContain("## Enumerations"); + expect(scanPage).not.toContain("[``]"); + + // A group whose only members are anonymous has nothing to render + expect(existsSync(join(tmpDir, "groups/anon_only"))).toBe(false); + expect(readFileSync(join(tmpDir, "groups/index.mdx"), "utf-8")).not.toContain("Anonymous only"); + }); + + it("titles the groups index from the library name, falling back when the IR has none", () => { + const makeGroupedIr = (packageName: string) => + makeIr( + makeNamespace({ + name: "cub", + path: "cub", + functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })] + }), + { packageName }, + [ + makeGroup({ + id: "group__scan", + name: "scan", + title: "Scan", + functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })] + }) + ] + ); + + generateCpp({ ir: makeGroupedIr("CUB"), outputDir: tmpDir, slug: "reference/cub" }); + expect(readFileSync(join(tmpDir, "groups/index.mdx"), "utf-8")).toContain("title: CUB — Groups"); + + const fallbackDir = mkdtempSync(join(tmpdir(), "cpp-gen-test-")); + try { + generateCpp({ ir: makeGroupedIr(""), outputDir: fallbackDir, slug: "reference/cub" }); + const index = readFileSync(join(fallbackDir, "groups/index.mdx"), "utf-8"); + expect(index).toContain("title: cub — Groups"); + expect(index).not.toContain("Documentation groups in ."); + } finally { + rmSync(fallbackDir, { recursive: true, force: true }); + } + }); + + it("links the groups folder from the library index page", () => { + const deviceScan = makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" }); + const ir = makeIr( + makeNamespace({ + namespaces: [makeNamespace({ name: "cub", path: "cub", functions: [deviceScan] })] + }), + undefined, + [makeGroup({ id: "group__scan", name: "scan", title: "Scan", functions: [deviceScan] })] + ); + + generateCpp({ ir, outputDir: join(tmpDir, "cub"), slug: "cub" }); + + const libraryIndex = readFileSync(join(tmpDir, "cub/index.mdx"), "utf-8"); + expect(libraryIndex).toContain("- [Functions](cub/functions)"); + expect(libraryIndex).toContain("- [Groups](cub/groups)"); + }); + it("terminates on a group tree whose subgroup references an ancestor", () => { const scan = makeGroup({ id: "group__scan", From 884198994c376d4470c6260760eee142157cae82 Mon Sep 17 00:00:00 2001 From: "ryan.stephen" Date: Mon, 17 Aug 2026 13:43:20 +0000 Subject: [PATCH 4/4] chore(cli): apply biome formatting to group renderer and tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../cpp/src/renderers/GroupPageRenderer.ts | 3 +- .../src/__test__/CppDocsGenerator.test.ts | 29 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts index 588728e41e84..2ae73dc7fd09 100644 --- a/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts +++ b/packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts @@ -174,7 +174,8 @@ export function renderGroupsIndexPage(entries: GroupListEntry[], libraryTitle: s const library = libraryTitle.trim(); const title = library.length > 0 ? `${library} — Groups` : "Groups"; - const description = library.length > 0 ? `Documentation groups in ${library}.` : "Documentation groups in this library."; + const description = + library.length > 0 ? `Documentation groups in ${library}.` : "Documentation groups in this library."; lines.push(...renderFrontmatter(title, description)); lines.push(""); diff --git a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts index 61b97f0f37bb..08b1a7b62a61 100644 --- a/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts +++ b/packages/cli/library-docs-generator/src/__test__/CppDocsGenerator.test.ts @@ -440,11 +440,32 @@ describe("generateCpp()", () => { title: "Scan", functions: [makeFunction({ name: "DeviceScan", path: "cub::DeviceScan" })], // Doxygen emits an unnamed enum with no name and no path - enums: [{ name: "", path: "", isScoped: false, underlyingType: undefined, values: [], docstring: undefined }] + enums: [ + { + name: "", + path: "", + isScoped: false, + underlyingType: undefined, + values: [], + docstring: undefined + } + ] }), - makeGroup({ id: "group__anon__only", name: "anon_only", title: "Anonymous only", enums: [ - { name: "", path: "", isScoped: false, underlyingType: undefined, values: [], docstring: undefined } - ] }) + makeGroup({ + id: "group__anon__only", + name: "anon_only", + title: "Anonymous only", + enums: [ + { + name: "", + path: "", + isScoped: false, + underlyingType: undefined, + values: [], + docstring: undefined + } + ] + }) ] );