mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-29 20:05:25 +08:00
fix: preserve Mintlify navigation hierarchy
This commit is contained in:
+103
-1
@@ -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/"] }],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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) => [
|
||||
|
||||
Reference in New Issue
Block a user