From fb0b21b36afe5e812b883a640a48252c79929f8f Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:20:44 +0000 Subject: [PATCH] fix: preserve Mintlify navigation hierarchy --- press.config.tsx | 104 ++++++++++++++++++++++++- scripts/prepare-fumapress.mjs | 20 +++++ src/app.css | 23 ++++++ tests/fumapress-migration.test.mjs | 37 +++++++++ tests/fumapress-static-build.test.mjs | 106 ++++++++++++++++++++++++++ 5 files changed, 289 insertions(+), 1 deletion(-) diff --git a/press.config.tsx b/press.config.tsx index c19b28c30..f2811f3f1 100644 --- a/press.config.tsx +++ b/press.config.tsx @@ -1,5 +1,6 @@ import { defineMintlifyI18n, mintlifyPlugin, readMintlifyDocs } from "@fumapress/mintlify"; import { defineConfig } from "fumapress"; +import type { Folder, Node as PageTreeNode, Root, Separator } from "fumadocs-core/page-tree"; import { fumadocsMdx } from "fumapress/adapters/mdx"; import { flexsearchPlugin } from "fumapress/plugins/flexsearch"; import { openapiPlugin } from "fumapress/plugins/openapi"; @@ -16,6 +17,86 @@ const LOCALES = ["en", "zh", "ja"] as const; const MINTLIFY_LOCALE = { en: "en", zh: "cn", ja: "jp" } as const; const HREFLANG = { en: "en", zh: "zh-CN", ja: "ja" } as const; const mintlifyDocs = readMintlifyDocs(); + +function nodeName(node: PageTreeNode) { + return typeof node.name === "string" ? node.name : ""; +} + +const openApiTagOrder = Object.fromEntries(LOCALES.map((locale) => { + const spec = JSON.parse(readFileSync(`openapi/service-api-${locale}.json`, "utf8")); + const tags: string[] = []; + for (const pathItem of Object.values(spec.paths ?? {}) as Record[]) { + for (const operation of Object.values(pathItem)) { + if (!operation || typeof operation !== "object") continue; + for (const tag of operation.tags ?? []) if (!tags.includes(tag)) tags.push(tag); + } + } + return [locale, tags]; +})) as Record<(typeof LOCALES)[number], string[]>; + +function generatedOpenApiGroups(tree: Root, locale: (typeof LOCALES)[number]) { + const apiRoot = tree.children.find( + (node) => node.type === "folder" && node.$ref?.folder === "api-reference", + ); + if (!apiRoot || apiRoot.type !== "folder") return []; + const order = openApiTagOrder[locale]; + const rank = (name: string) => order.findIndex((tag) => tag.toLocaleLowerCase() === name.toLocaleLowerCase()); + return [...apiRoot.children] + .sort((left, right) => { + const leftIndex = rank(nodeName(left)); + const rightIndex = rank(nodeName(right)); + return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) + - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex); + }) + .map((node) => { + const index = rank(nodeName(node)); + return index >= 0 ? { ...node, name: order[index] } : node; + }); +} + +function sectionizeMintlifyTree( + tree: Root, + sourceTree: Root, + locale: (typeof LOCALES)[number], +): Root { + const language = mintlifyDocs.navigation.languages?.find( + (entry) => entry.language === MINTLIFY_LOCALE[locale], + ); + const generatedApiGroups = generatedOpenApiGroups(sourceTree, locale); + return { + ...tree, + children: tree.children.map((node): PageTreeNode => { + if (node.type !== "folder") return node; + const configuredTab = language?.tabs?.find((tab) => tab.tab === nodeName(node)); + const sourceGroups = new Map( + node.children + .filter((child): child is Folder => child.type === "folder") + .map((child) => [nodeName(child), child]), + ); + const children: PageTreeNode[] = []; + for (const group of configuredTab?.groups ?? []) { + if (group.hidden) continue; + const separator: Separator = { + $id: `${node.$id}:${group.group}:section`, + type: "separator", + name: group.group, + }; + children.push(separator); + if ("openapi" in group && group.openapi) { + children.push(...generatedApiGroups); + continue; + } + const sourceGroup = sourceGroups.get(group.group); + if (!sourceGroup) continue; + if (sourceGroup.index) children.push(sourceGroup.index); + children.push(...sourceGroup.children); + } + const tab: Folder = { ...node, root: true, children }; + return tab; + }), + }; +} + const i18n = defineMintlifyI18n(mintlifyDocs, { localeMap: { en: "en", cn: "zh", jp: "ja" }, }); @@ -232,7 +313,28 @@ export default defineConfig({ return { id: page.url, title, description, url: page.url, structuredData }; }, }), - mintlifyPlugin({ features: { navbar: false } }), + mintlifyPlugin({ path: "content/fumapress-docs.json", features: { navbar: false } }), + { + name: "langbot:sectioned-navigation", + enforce: "post", + init() { + const context = this; + const sourceTrees = new Map(); + for (const key of ["core:docs-layout", "core:notebook-layout"] as const) { + const data = (this.data[key] ??= {}); + (data.transformers ??= []).push(async ({ data: props, page }) => { + const locale = page.locale as (typeof LOCALES)[number]; + let sourceTree = sourceTrees.get(locale); + if (!sourceTree) { + sourceTree = (await context.getLoader()).getPageTree(page.locale); + sourceTrees.set(locale, sourceTree); + } + props.layoutProps.tree = sectionizeMintlifyTree(props.layoutProps.tree, sourceTree, locale); + return props; + }); + } + }, + }, openapiPlugin({ server: enOpenAPI }), robotsPlugin({ rules: [{ userAgent: "*", allow: ["/", "/_next/"] }], diff --git a/scripts/prepare-fumapress.mjs b/scripts/prepare-fumapress.mjs index 1fe97827b..e7b2ce3bb 100644 --- a/scripts/prepare-fumapress.mjs +++ b/scripts/prepare-fumapress.mjs @@ -111,6 +111,22 @@ export function collectOpenApiSources(docs) { return sources.sort((left, right) => LOCALES.indexOf(left.locale) - LOCALES.indexOf(right.locale)); } +export function normalizeMintlifyNavigationForFumapress(docs) { + const normalized = structuredClone(docs); + for (const language of normalized.navigation?.languages ?? []) { + const locale = mapMintlifyLocale(language.language); + visitNavigation(language, (item) => { + if (!Array.isArray(item.pages)) return; + item.pages = item.pages.map((entry) => + typeof entry === "string" && entry.startsWith(`${locale}/`) + ? entry.slice(locale.length + 1) + : entry, + ); + }); + } + return normalized; +} + function cloudflarePattern(value, destination = false) { return value.replace(/:([A-Za-z][A-Za-z0-9_]*)\*/g, destination ? ":splat" : "*"); } @@ -147,6 +163,10 @@ export async function prepareFumapress({ root = ROOT, outRoot = root } = {}) { await rm(publicRoot, { recursive: true, force: true }); await mkdir(path.join(contentRoot, "docs"), { recursive: true }); await mkdir(publicRoot, { recursive: true }); + await writeFile( + path.join(contentRoot, "fumapress-docs.json"), + `${JSON.stringify(normalizeMintlifyNavigationForFumapress(docs), null, 2)}\n`, + ); const documentsByPath = new Map(); for (const document of documents) { diff --git a/src/app.css b/src/app.css index 418594987..1738a3e43 100644 --- a/src/app.css +++ b/src/app.css @@ -11,3 +11,26 @@ body { font-family: "Noto Sans SC", "Noto Sans", system-ui, sans-serif; } + + +/* Keep the page outline visually quiet: one rail, no stepped connectors. */ +#nd-toc > div > div { + border-inline-start: 1px solid color-mix(in oklab, var(--color-fd-foreground) 10%, transparent); + margin-inline-start: 0.53125rem; +} + +#nd-toc > div > div > a > svg { + display: none; +} + +/* Mintlify-style section labels: headings, not expandable folder controls. */ +#nd-sidebar p.inline-flex.items-center.gap-2 { + margin-block: 1.15rem 0.35rem; + padding-inline: 0.5rem; + color: var(--color-fd-muted-foreground); + font-size: 0.6875rem; + font-weight: 650; + letter-spacing: 0.065em; + line-height: 1.25rem; + text-transform: uppercase; +} diff --git a/tests/fumapress-migration.test.mjs b/tests/fumapress-migration.test.mjs index aa37904d8..74b530ad5 100644 --- a/tests/fumapress-migration.test.mjs +++ b/tests/fumapress-migration.test.mjs @@ -7,6 +7,7 @@ import { collectMdxDocuments, collectOpenApiSources, mapMintlifyLocale, + normalizeMintlifyNavigationForFumapress, normalizeMdxContent, prepareFumapress, renderCloudflareRedirects, @@ -20,6 +21,42 @@ test("Mintlify locales are mapped to public URL locales", () => { assert.equal(mapMintlifyLocale("jp"), "ja"); }); +test("Fumapress navigation preserves docs.json hierarchy while removing storage locale prefixes", async () => { + const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8")); + const original = structuredClone(docs); + const normalized = normalizeMintlifyNavigationForFumapress(docs); + assert.deepEqual(docs, original, "normalization must not mutate canonical docs.json"); + + const localeMap = { en: "en", cn: "zh", jp: "ja" }; + for (const language of normalized.navigation.languages) { + const locale = localeMap[language.language]; + const originalLanguage = original.navigation.languages.find((item) => item.language === language.language); + assert.deepEqual( + language.tabs.map((tab) => tab.tab), + originalLanguage.tabs.map((tab) => tab.tab), + `${locale} tab order changed`, + ); + for (let tabIndex = 0; tabIndex < language.tabs.length; tabIndex += 1) { + const actualTab = language.tabs[tabIndex]; + const expectedTab = originalLanguage.tabs[tabIndex]; + assert.deepEqual(actualTab.groups.map((group) => group.group), expectedTab.groups.map((group) => group.group)); + for (let groupIndex = 0; groupIndex < actualTab.groups.length; groupIndex += 1) { + const actualPages = actualTab.groups[groupIndex].pages ?? []; + const normalizePages = (value) => { + if (typeof value === "string") return value.startsWith(`${locale}/`) ? value.slice(locale.length + 1) : value; + if (Array.isArray(value)) return value.map(normalizePages); + if (value && typeof value === "object") return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, key === "pages" ? normalizePages(child) : child]), + ); + return value; + }; + const expectedPages = normalizePages(expectedTab.groups[groupIndex].pages ?? []); + assert.deepEqual(actualPages, expectedPages, `${locale} navigation pages changed`); + } + } + } +}); + test("MDX compatibility normalization rewrites unsupported env fences", () => { const source = "```env\nTOKEN=value\n```\n\n~~~env title=\"example\"\nOTHER=value\n~~~\n"; assert.equal( diff --git a/tests/fumapress-static-build.test.mjs b/tests/fumapress-static-build.test.mjs index 29271a572..52b13653f 100644 --- a/tests/fumapress-static-build.test.mjs +++ b/tests/fumapress-static-build.test.mjs @@ -7,6 +7,14 @@ import { collectMdxDocuments } from "../scripts/prepare-fumapress.mjs"; const root = path.resolve(import.meta.dirname, ".."); const publicRoot = path.join(root, "dist/public"); +test("the documentation keeps a neutral base with custom section and TOC treatments", async () => { + const css = await readFile(path.join(root, "src/app.css"), "utf8"); + assert.match(css, /fumadocs-ui\/css\/neutral\.css/); + assert.doesNotMatch(css, /fumadocs-ui\/css\/ocean\.css/); + assert.match(css, /#nd-toc > div > div/); + assert.match(css, /#nd-toc > div > div > a > svg/); +}); + async function collectHtmlRoutes(directory) { const routes = []; for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -83,6 +91,25 @@ test("all locales emit the OpenAPI surface with endpoint semantics", async () => const html = await readFile(representative, "utf8"); for (const semantic of semantics) assert.ok(html.includes(semantic), `${locale} page is missing ${semantic}`); assert.ok(sitemap.includes(`https://docs.langbot.dev/${locale}/api-reference/${encodedRepresentativePath}`)); + + const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8")); + const tagOrder = []; + for (const pathItem of Object.values(spec.paths ?? {})) { + for (const operation of Object.values(pathItem)) { + if (!operation || typeof operation !== "object") continue; + for (const tag of operation.tags ?? []) if (!tagOrder.includes(tag)) tagOrder.push(tag); + } + } + const asideStart = html.indexOf('