fix: preserve Mintlify navigation hierarchy

This commit is contained in:
dadachann
2026-09-04 13:20:44 +00:00
parent 8b052d9ab0
commit fb0b21b36a
5 changed files with 289 additions and 1 deletions
+103 -1
View File
@@ -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<string, any>[]) {
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<string, Root>();
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/"] }],
+20
View File
@@ -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) {
+23
View File
@@ -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;
}
+37
View File
@@ -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(
+106
View File
@@ -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('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
assert.match(sidebar, /<p[^>]*>HTTP API Reference<\/p>/, `${locale} OpenAPI section is missing`);
let previousTag = -1;
for (const tag of tagOrder) {
const position = sidebar.indexOf(`>${tag}<`);
assert.ok(position > previousTag, `${locale} OpenAPI tag order changed at ${tag}`);
previousTag = position;
}
}
});
@@ -150,6 +177,85 @@ test("rendered navbar and documentation tree use each locale's docs.json labels"
}
});
function collectNavigationPages(value, output = []) {
if (typeof value === "string") output.push(value);
else if (Array.isArray(value)) value.forEach((item) => collectNavigationPages(item, output));
else if (value && typeof value === "object") {
if (value.hidden) return output;
for (const key of ["tabs", "groups", "pages"]) {
if (Array.isArray(value[key])) collectNavigationPages(value[key], output);
}
}
return output;
}
function firstNavigationPage(value) {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
for (const item of value) {
const page = firstNavigationPage(item);
if (page) return page;
}
} else if (value && typeof value === "object" && !value.hidden) {
return firstNavigationPage(value.pages ?? value.groups ?? []);
}
}
function escapeHtml(value) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
test("docs.json tabs and groups render as root selectors and non-collapsible sections", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const localeMap = { en: "en", cn: "zh", jp: "ja" };
for (const language of docs.navigation.languages) {
const locale = localeMap[language.language];
for (const tab of language.tabs.filter((item) => !item.hidden)) {
const sourcePage = firstNavigationPage(tab.groups);
assert.ok(sourcePage, `${locale} ${tab.tab} has no representative page`);
const route = sourcePage.replace(/\/index$/, "");
const html = await readFile(path.join(publicRoot, route, "index.html"), "utf8");
const asideStart = html.indexOf('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
assert.ok(sidebar.includes(`>${escapeHtml(tab.tab)}</p>`), `${locale} ${tab.tab} is not the root selector`);
for (const group of tab.groups.filter((item) => !item.hidden)) {
assert.ok(sidebar.includes(`>${escapeHtml(group.group)}</p>`), `${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('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
if (!sidebar.includes(`href="/${route}"`)) failures.push({ page, htmlPath });
}
}
assert.deepEqual(failures, []);
});
test("the Chinese quick-start navigation keeps the incumbent troubleshooting page", async () => {
const html = await readFile(path.join(publicRoot, "zh/insight/guide/index.html"), "utf8");
const asideStart = html.indexOf('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
assert.match(sidebar, /href="\/zh\/insight\/troubleshooting"/);
assert.doesNotMatch(sidebar, /href="\/zh\/develop\/adapter\/discord\/troubleshooting"/);
});
test("the exact 14 zh-only sources publish no fallback en or ja routes", async () => {
const documents = await collectMdxDocuments(root);
const byLocale = Object.fromEntries(locales.map((locale) => [