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('
`), `${locale} ${tab.tab}/${group.group} is not a section label`);
+ }
+ }
+ }
+});
+
+test("every legacy navigation entry resolves to its exact sidebar URL", async () => {
+ const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
+ const localeMap = { en: "en", cn: "zh", jp: "ja" };
+ const failures = [];
+ for (const language of docs.navigation.languages) {
+ const locale = localeMap[language.language];
+ const pages = collectNavigationPages(language.tabs ?? []);
+ for (const page of pages) {
+ if (!page.startsWith(`${locale}/`)) continue;
+ const route = page.replace(/\/index$/, "");
+ const htmlPath = path.join(publicRoot, route, "index.html");
+ const html = await readFile(htmlPath, "utf8");
+ const asideStart = html.indexOf('