Merge pull request #117 from langbot-app/feat/fumadocs-migration

feat: migrate documentation to Fumapress
This commit is contained in:
Hyu
2026-09-04 23:41:16 +08:00
committed by GitHub
22 changed files with 11979 additions and 9728 deletions
+12 -5
View File
@@ -8,14 +8,21 @@ permissions:
contents: read
jobs:
seo:
name: SEO publishing contract
docs:
name: Fumapress docs build
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Run SEO contract tests
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run documentation contract tests
run: npm test
- name: Build and verify the static site
run: npm run build
+7
View File
@@ -10,3 +10,10 @@ node_modules/
# Python build/test cache
__pycache__/
*.py[cod]
# Deterministic Fumapress generated inputs and output
/content/
/public/
/dist/
.waku/
.fumapress/
+4 -8
View File
@@ -6,12 +6,12 @@
## 参与编写
文档基于 [Mintlify](https://mintlify.com/) 生成,本地编写需要安装 Node.js。
文档使用 [Fumapress](https://press.fumadocs.dev/) 和 [Fumadocs](https://fumadocs.dev/) 生成,本地开发需要 Node.js 24 或更高版本。
Clone 本仓库,在目录下执行以下命令安装依赖:
```bash
npm install
npm ci
```
完成后即可修改文档,修改完后使用以下命令本地启动预览:
@@ -19,10 +19,6 @@ npm install
```bash
npm run dev
```
或者直接使用 `mintlify` CLI (推荐):
```bash
npx mintlify dev
```
### 使用图片
@@ -34,13 +30,13 @@ npx mintlify dev
### 部署细节
现已迁移至 Mintlify 托管,提交至 `main` 分支后自动触发部署。
文档构建为 `dist/public` 下的静态站点,并托管在 Cloudflare Pages。部署前会运行类型检查、文档契约测试及完整静态构建。
### 一些规范化标准
- 文件夹和文件的命名:**一律使用全小写,单词直接`-`隔开,如**`plugin-intro.mdx`
- 子文件(夹)的命名,**不加前缀**(即文件夹的名称),如:`deploy`文件夹下的,文件夹`langbot`,`langbot`文件夹下的`manual`文件称之为`manual.mdx`
- 文档文件格式统一使用 `.mdx` 以支持 Mintlify 组件。
- 文档文件格式统一使用 `.mdx`;构建脚本会把现有 Mintlify 兼容组件转换为 Fumadocs 可渲染格式。
- 在 `docs.json` 中配置侧边栏导航结构。
---
+4 -8
View File
@@ -6,12 +6,12 @@
## Contributing to Documentation
The documentation is generated based on [Mintlify](https://mintlify.com/). Local development requires Node.js installation.
The documentation is built with [Fumapress](https://press.fumadocs.dev/) and [Fumadocs](https://fumadocs.dev/). Local development requires Node.js 24 or later.
Clone this repository and execute the following command in the directory to install dependencies:
```bash
npm install
npm ci
```
After completion, you can modify the documentation. After modifications, use the following command to start locally:
@@ -19,10 +19,6 @@ After completion, you can modify the documentation. After modifications, use the
```bash
npm run dev
```
Or use the `mintlify` CLI directly (recommended):
```bash
npx mintlify dev
```
### Using Images
@@ -34,13 +30,13 @@ Place images in the `images` directory, then reference them using the absolute p
### Deployment Details
Now hosted on Mintlify. Commits to the `main` branch trigger automatic deployment.
The documentation is built as a static site in `dist/public` and hosted on Cloudflare Pages. Type checking, documentation contract tests, and a full static build run before deployment.
### Some Standardization Guidelines
- Folder and file naming: **Use all lowercase, separate words with `-`, such as** `plugin-intro.mdx`
- Sub-file (folder) naming: **No prefix** (i.e., the folder name), such as: in the `deploy` folder, the folder `langbot`, the `manual` file in the `langbot` folder is called `manual.mdx`
- Documentation files should use `.mdx` format to support Mintlify components.
- Documentation files should use `.mdx`; the build adapter converts existing Mintlify-compatible components into Fumadocs-renderable markup.
- Configure sidebar navigation structure in `docs.json`.
---
-20
View File
@@ -806,11 +806,6 @@
"label": "GitHub",
"href": "https://github.com/langbot-app/LangBot"
}
},
"banner": {
"content": "This documentation has moved to [langbot.app/docs](https://langbot.app/docs/en). This legacy site is no longer updated.",
"type": "warning",
"dismissible": false
}
},
{
@@ -1122,11 +1117,6 @@
"label": "GitHub",
"href": "https://github.com/langbot-app/LangBot"
}
},
"banner": {
"content": "本文档已迁移至 [langbot.app/docs](https://langbot.app/docs/zh),此旧站不再更新。",
"type": "warning",
"dismissible": false
}
},
{
@@ -1413,11 +1403,6 @@
"label": "GitHub",
"href": "https://github.com/langbot-app/LangBot"
}
},
"banner": {
"content": "ドキュメントは [langbot.app/docs](https://langbot.app/docs/ja) に移行しました。この旧サイトは今後更新されません。",
"type": "warning",
"dismissible": false
}
}
]
@@ -1427,10 +1412,5 @@
},
"metadata": {
"google-site-verification": "LBNmWbXCAXdoqrbwg5Gep-xzQ7u7lJHSdpR49N3Ttgg"
},
"banner": {
"content": "This documentation has moved to [langbot.app/docs](https://langbot.app/docs/en). This legacy site is no longer updated.",
"type": "warning",
"dismissible": false
}
}
+6988
View File
File diff suppressed because it is too large Load Diff
+40 -3
View File
@@ -1,12 +1,49 @@
{
"name": "langbot-docs",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "npx mintlify dev",
"dev": "fumapress dev",
"predev": "npm run prepare:fumapress",
"prepare:fumapress": "node scripts/prepare-fumapress.mjs",
"build": "fumapress build",
"prebuild": "npm run prepare:fumapress",
"postbuild": "npm run finalize:base-path && npm run generate:sitemap-alternates && npm run normalize:static-paths && npm run test:static",
"typecheck": "tsc --noEmit",
"normalize:static-paths": "node scripts/normalize-static-paths.mjs",
"test:static": "node --test tests/fumapress-static-build.test.mjs",
"generate:sitemap-alternates": "node scripts/generate-alternate-sitemap.mjs",
"sync:articles": "node scripts/sync-blog-articles.mjs",
"check:articles": "node scripts/sync-blog-articles.mjs --check",
"test": "node --test tests/*.test.mjs"
"test": "node --test tests/blog-articles.test.mjs tests/fumapress-migration.test.mjs tests/seo.test.mjs && python3 -m unittest discover -s tests -p 'test_*.py'",
"finalize:base-path": "node scripts/finalize-base-path.mjs"
},
"dependencies": {
"mintlify": "^4.2.375"
"@base-ui/react": "^1.8.0",
"@fumapress/mintlify": "^1.1.2",
"fumadocs-core": "^16.15.6",
"fumadocs-mdx": "^15.4.0",
"fumadocs-openapi": "^11.4.1",
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.15.6",
"fumapress": "^1.2.0",
"hono": "^4.9.9",
"lucide-react": "^1.41.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"shiki": "^3.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/mdx": "^2.0.13",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^8.2.2"
}
}
-8376
View File
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
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";
import { robotsPlugin } from "fumapress/plugins/robots";
import { update } from "fumadocs-core/source";
import { uiTranslations } from "fumadocs-ui/i18n";
import { defineDocs } from "fumadocs-mdx/macro";
import { createOpenAPI, type OpenAPIServer } from "fumadocs-openapi/server";
import { Bot } from "lucide-react";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const SITE_URL = "https://langbot.app/docs";
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 : "";
}
function renderNavigationIcon(icon: PageTreeNode["icon"]) {
if (icon === "robot") {
return <Bot aria-hidden="true" className="size-4 shrink-0" />;
}
if (typeof icon !== "string" || !icon.startsWith("/")) return icon;
return (
<img
src={icon}
alt=""
aria-hidden="true"
className="size-4 shrink-0 object-contain"
/>
);
}
function renderNavigationIcons(node: PageTreeNode): PageTreeNode {
if (node.type === "folder") {
return {
...node,
icon: renderNavigationIcon(node.icon),
index: node.index
? { ...node.index, icon: renderNavigationIcon(node.index.icon) }
: undefined,
children: node.children.map(renderNavigationIcons),
};
}
return { ...node, icon: renderNavigationIcon(node.icon) };
}
type ConfiguredNavigationGroup = {
group: string;
icon?: string;
pages?: unknown[];
};
function restoreConfiguredFolderIcons(
nodes: PageTreeNode[],
configuredPages: unknown[],
): PageTreeNode[] {
const configuredGroups = new Map<string, ConfiguredNavigationGroup>();
for (const entry of configuredPages) {
if (!entry || typeof entry !== "object" || !("group" in entry)) continue;
const group = entry as ConfiguredNavigationGroup;
configuredGroups.set(group.group, group);
}
return nodes.map((node) => {
if (node.type !== "folder") return node;
const configured = configuredGroups.get(nodeName(node));
if (!configured) return node;
return {
...node,
icon: configured.icon ?? node.icon,
children: restoreConfiguredFolderIcons(node.children, configured.pages ?? []),
};
});
}
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(...restoreConfiguredFolderIcons(
sourceGroup.children,
"pages" in group ? group.pages ?? [] : [],
));
}
const tab: Folder = { ...node, root: true, children };
return tab;
}),
};
}
const i18n = defineMintlifyI18n(mintlifyDocs, {
localeMap: { en: "en", cn: "zh", jp: "ja" },
});
const translations = i18n.translations()
.extend(uiTranslations())
.add({
en: { displayName: "English" },
zh: { displayName: "简体中文" },
ja: { displayName: "日本語" },
});
// Fumapress stores translations.config as its runtime i18n config. Preserve
// the Mintlify adapter's locale bridge there so navigation.languages selects
// the matching English, Chinese, or Japanese tree instead of always using en.
Object.assign(translations.config, {
_getMintlifyLanguage: (i18n as typeof i18n & {
_getMintlifyLanguage?: (locale: string) => string | undefined;
})._getMintlifyLanguage,
});
// Locale-only files must not inherit the English storage and create indexed
// pages in locales where no canonical source translation exists.
i18n.fallbackLanguage = null;
translations.config.fallbackLanguage = null;
function collectRouteLocales() {
const routes = new Map<string, Set<string>>();
function visit(directory: string, prefix: string, locale: string) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const relative = path.posix.join(prefix, entry.name);
if (entry.isDirectory()) visit(path.join(directory, entry.name), relative, locale);
else if (entry.isFile() && entry.name.endsWith(".mdx")) {
let route = relative.slice(0, -4);
if (route === "index") route = "";
else if (route.endsWith("/index")) route = route.slice(0, -6);
const available = routes.get(route) ?? new Set<string>();
available.add(locale);
routes.set(route, available);
}
}
}
for (const locale of LOCALES) visit(path.resolve(locale), "", locale);
return routes;
}
const routeLocales = collectRouteLocales();
function localizedNavbar(lang: string | undefined) {
const locale = LOCALES.includes(lang as (typeof LOCALES)[number]) ? lang as (typeof LOCALES)[number] : "en";
const language = mintlifyDocs.navigation.languages?.find(
(entry) => entry.language === MINTLIFY_LOCALE[locale],
);
const navbar = language?.navbar ?? mintlifyDocs.navbar;
return [
...(navbar?.links ?? []).map((link) => ({ text: link.label ?? link.href, url: link.href })),
...(navbar?.primary ? [{
type: "button" as const,
text: navbar.primary.label ?? navbar.primary.href,
url: navbar.primary.href,
}] : []),
];
}
const enOpenAPI = createOpenAPI({ input: ["openapi/service-api-en.json"] });
const zhOpenAPI = createOpenAPI({ input: ["openapi/service-api-zh.json"] });
const jaOpenAPI = createOpenAPI({ input: ["openapi/service-api-ja.json"] });
function legacyOpenApiSlug(value: string) {
return value.replace(/\s+/g, "-").toLowerCase();
}
const legacyOpenApiLayout = {
groupBy: "tag" as const,
name: (entry: { info: { title: string } }) => legacyOpenApiSlug(entry.info.title),
};
function encodeRoute(route: string) {
return route.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
const openApiAlternates = new Map<string, Partial<Record<(typeof LOCALES)[number], string>>>();
for (const locale of LOCALES) {
const spec = JSON.parse(readFileSync(`openapi/service-api-${locale}.json`, "utf8"));
for (const [apiPath, pathItem] of Object.entries(spec.paths ?? {}) as [string, Record<string, any>][]) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!/^(?:get|post|put|patch|delete|head|options)$/i.test(method) || !operation?.summary) continue;
const tag = operation.tags?.[0] ?? "unknown";
const route = encodeRoute(`api-reference/${legacyOpenApiSlug(tag)}/${legacyOpenApiSlug(operation.summary)}`);
const key = `${method.toLowerCase()} ${apiPath}`;
const alternates = openApiAlternates.get(key) ?? {};
alternates[locale] = route;
openApiAlternates.set(key, alternates);
}
}
}
const openApiAlternatesByRoute = new Map<string, Partial<Record<(typeof LOCALES)[number], string>>>();
for (const alternates of openApiAlternates.values()) {
for (const locale of LOCALES) {
const route = alternates[locale];
if (route) openApiAlternatesByRoute.set(`${locale}:${route}`, alternates);
}
}
function localizeOpenAPISource(
source: Awaited<ReturnType<OpenAPIServer["staticSource"]>>,
locale: string,
) {
return update(source)
.page((page) => ({
...page,
// Fumadocs' dot parser owns the URL locale prefix. Mark each virtual
// file with its locale and remove the same prefix from its route slugs.
path: page.path
.slice(`${locale}/`.length)
.replace(/\.mdx$/, `.${locale}.mdx`),
}))
.build();
}
const docs = defineDocs({
dir: "content/docs",
docs: {
async: true,
postprocess: { includeProcessedMarkdown: true },
},
});
export default defineConfig({
mode: "static",
site: {
baseUrl: SITE_URL,
name: "LangBot Docs",
},
content: {
docs: docs.toFumadocsSource(),
openapiEn: localizeOpenAPISource(
await enOpenAPI.staticSource({ baseDir: "en/api-reference", ...legacyOpenApiLayout }),
"en",
),
openapiZh: localizeOpenAPISource(
await zhOpenAPI.staticSource({ baseDir: "zh/api-reference", ...legacyOpenApiLayout }),
"zh",
),
openapiJa: localizeOpenAPISource(
await jaOpenAPI.staticSource({ baseDir: "ja/api-reference", ...legacyOpenApiLayout }),
"ja",
),
},
i18n,
translations,
defaultLayoutProps: ({ lang }) => ({
links: localizedNavbar(lang),
}),
meta: {
page: (page) => {
const locale = page.locale as (typeof LOCALES)[number];
const route = page.slugs.join("/");
const localizedRoutes = route.startsWith("api-reference/")
? openApiAlternatesByRoute.get(`${locale}:${route}`) ?? { [locale]: route }
: Object.fromEntries([...routeLocales.get(route) ?? new Set<string>([locale])].map((candidate) => [candidate, route]));
const alternates = LOCALES.filter((candidate) => localizedRoutes[candidate]);
const defaultLocale = alternates.includes("en") ? "en" : alternates[0];
return <>
<link rel="canonical" href={`${SITE_URL}${page.url}`} />
{alternates.map((candidate) => (
<link
key={candidate}
rel="alternate"
hrefLang={HREFLANG[candidate]}
href={`${SITE_URL}/${candidate}/${localizedRoutes[candidate]}`.replace(/\/$/, "")}
/>
))}
{defaultLocale && (
<link
rel="alternate"
hrefLang="x-default"
href={`${SITE_URL}/${defaultLocale}/${localizedRoutes[defaultLocale]}`.replace(/\/$/, "")}
/>
)}
</>;
},
},
})
.adapters(fumadocsMdx())
// One registration is intentional: its adapter and loader plugin handle all
// OpenAPI virtual pages, regardless of which content source generated them.
.plugins(
flexsearchPlugin({
async buildIndex(page) {
const title = page.data.title ?? page.path;
const description = page.data.description;
const sourceStructuredData = typeof page.data.structuredData === "function"
? await page.data.structuredData()
: page.data.structuredData;
// FlexSearch's static serialization expands multilingual documents
// considerably. Index titles, descriptions, a few headings, and the
// opening content so search remains useful without exceeding Pages'
// hard 25 MiB per-file limit. OpenAPI endpoints intentionally omit the
// schema body, which is duplicated across every generated operation.
const isOpenAPI = page.url.includes("/api-reference/");
const headings = isOpenAPI
? []
: sourceStructuredData.headings.slice(0, 4).map((heading) => ({
...heading,
content: heading.content.slice(0, 100),
}));
const openingContent = isOpenAPI
? []
: sourceStructuredData.contents.slice(0, 2).map((content) => content.content.slice(0, 300));
const structuredData = {
headings,
contents: [{
heading: undefined,
content: [title, description, ...openingContent, page.url].filter(Boolean).join(" "),
}],
};
return { id: page.url, title, description, url: page.url, structuredData };
},
}),
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 = {
...props.layoutProps.tree,
children: sectionizeMintlifyTree(props.layoutProps.tree, sourceTree, locale)
.children.map(renderNavigationIcons),
};
return props;
});
}
},
},
openapiPlugin({ server: enOpenAPI }),
robotsPlugin({
rules: [{ userAgent: "*", allow: ["/", "/_next/"] }],
sitemap: true,
additionalContent: "Sitemap: https://langbot.app/docs/sitemap-alternates.xml",
}),
);
+3 -2
View File
@@ -1,5 +1,6 @@
User-agent: *
Allow: /
Allow: /docs/
Allow: /_next/
Sitemap: https://docs.langbot.app/sitemap.xml
Sitemap: https://langbot.app/docs/sitemap.xml
Sitemap: https://langbot.app/docs/sitemap-alternates.xml
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
import { readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PUBLIC_ROOT = path.join(ROOT, "dist/public");
const ORIGIN = "https://langbot.app";
const BASE_PATH = "/docs";
const TEXT_EXTENSIONS = new Set([".html", ".md", ".txt", ".xml", ".json", ".js", ".css"]);
async function collectTextFiles(directory) {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...await collectTextFiles(target));
else if (TEXT_EXTENSIONS.has(path.extname(entry.name)) || target.endsWith("/api/search")) files.push(target);
}
return files;
}
function prefixPublicAssets(content) {
return content
.replaceAll('"/images/', `"${BASE_PATH}/images/`)
.replaceAll("'/images/", `'${BASE_PATH}/images/`)
.replaceAll('"/favicon.ico"', `"${BASE_PATH}/favicon.ico"`)
.replaceAll("'/favicon.ico'", `'${BASE_PATH}/favicon.ico'`)
.replaceAll('"/rss.xml"', `"${BASE_PATH}/rss.xml"`)
.replaceAll("'/rss.xml'", `'${BASE_PATH}/rss.xml'`);
}
export async function finalizeBasePath(publicRoot = PUBLIC_ROOT) {
const sitemapPath = path.join(publicRoot, "sitemap.xml");
const originalSitemap = await readFile(sitemapPath, "utf8");
const sourceUrls = [...originalSitemap.matchAll(/<loc>(https:\/\/langbot\.app\/[^<]+)<\/loc>/g)]
.map((match) => match[1].replace(/\/$/, ""));
const replacements = sourceUrls
.map((source) => [source, `${ORIGIN}${BASE_PATH}${source.slice(ORIGIN.length)}`])
.sort((left, right) => right[0].length - left[0].length);
let changed = 0;
for (const file of await collectTextFiles(publicRoot)) {
let content = await readFile(file, "utf8");
const before = content;
for (const [source, destination] of replacements) content = content.replaceAll(source, destination);
content = content
.replaceAll(`${ORIGIN}/sitemap.xml`, `${ORIGIN}${BASE_PATH}/sitemap.xml`)
.replaceAll(`${ORIGIN}/sitemap-alternates.xml`, `${ORIGIN}${BASE_PATH}/sitemap-alternates.xml`);
content = prefixPublicAssets(content);
if (content !== before) {
await writeFile(file, content);
changed += 1;
}
}
const redirectsPath = path.join(publicRoot, "_redirects");
const redirects = await readFile(redirectsPath, "utf8");
const scopedRedirects = redirects
.split("\n")
.map((line) => {
if (!line.trim()) return line;
const [source, destination, status, ...rest] = line.trim().split(/\s+/);
const scope = (value) => value.startsWith("/") ? `${BASE_PATH}${value === "/" ? "" : value}` : value;
return [scope(source), scope(destination), status, ...rest].filter(Boolean).join(" ");
})
.join("\n");
await writeFile(redirectsPath, scopedRedirects);
return { changed, routes: replacements.length };
}
async function main() {
const result = await finalizeBasePath();
console.log(`Finalized ${result.routes} documentation routes under ${BASE_PATH} across ${result.changed} files`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+105 -57
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env node
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const base = "https://docs.langbot.app";
const source = process.env.SITEMAP_SOURCE ?? `${base}/sitemap.xml`;
const output = new URL("../sitemap-alternates.xml", import.meta.url);
const locales = ["en", "zh", "ja"];
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const BASE = "https://langbot.app/docs";
const LOCALES = ["en", "zh", "ja"];
const HREFLANG = { en: "en", zh: "zh-CN", ja: "ja" };
function escapeXml(value) {
return value
@@ -16,60 +18,106 @@ function escapeXml(value) {
.replaceAll("'", "&apos;");
}
const response = await fetch(source, {
headers: { "user-agent": "LangBot docs alternate-sitemap generator" },
});
if (!response.ok) {
throw new Error(`Unable to fetch ${source}: ${response.status}`);
function decodeXml(value) {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'");
}
const sitemap = await response.text();
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) =>
match[1].replaceAll("&amp;", "&"),
);
const groups = new Map();
for (const url of urls) {
for (const locale of locales) {
const prefix = `${base}/${locale}/`;
if (!url.startsWith(prefix)) continue;
const suffix = url.slice(prefix.length);
const group = groups.get(suffix) ?? {};
group[locale] = url;
groups.set(suffix, group);
break;
}
}
const translatedGroups = [...groups.values()]
.filter((group) => group.en && group.zh)
.sort((left, right) => left.en.localeCompare(right.en));
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
];
for (const group of translatedGroups) {
const alternates = [
["en", group.en],
["zh-CN", group.zh],
...(group.ja ? [["ja", group.ja]] : []),
["x-default", group.zh],
];
for (const locale of locales) {
if (!group[locale]) continue;
lines.push(" <url>", ` <loc>${escapeXml(group[locale])}</loc>`);
for (const [language, href] of alternates) {
lines.push(
` <xhtml:link rel="alternate" hreflang="${language}" href="${escapeXml(href)}" />`,
);
export function renderAlternateSitemap(sitemap, base = BASE, explicitGroups = []) {
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => decodeXml(match[1]));
const groups = new Map();
for (const url of urls) {
for (const locale of LOCALES) {
const prefix = `${base}/${locale}/`;
if (!url.startsWith(prefix)) continue;
const suffix = url.slice(prefix.length).replace(/\/$/, "");
const group = groups.get(suffix) ?? {};
group[locale] = url.replace(/\/$/, "");
groups.set(suffix, group);
break;
}
lines.push(" </url>");
}
}
lines.push("</urlset>");
await writeFile(output, `${lines.join("\n")}\n`);
console.log(
`Wrote ${translatedGroups.length} reciprocal language groups to ${output.pathname}`,
);
for (const group of explicitGroups) groups.set(`explicit:${group.en}`, group);
const shared = [...groups.values()]
.filter((group) => LOCALES.every((locale) => group[locale]))
.sort((left, right) => left.en.localeCompare(right.en));
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
];
for (const group of shared) {
const alternates = [
...LOCALES.map((locale) => [HREFLANG[locale], group[locale]]),
["x-default", group.en],
];
for (const locale of LOCALES) {
lines.push(" <url>", ` <loc>${escapeXml(group[locale])}</loc>`);
for (const [language, href] of alternates) {
lines.push(` <xhtml:link rel="alternate" hreflang="${language}" href="${escapeXml(href)}" />`);
}
lines.push(" </url>");
}
}
lines.push("</urlset>");
return { xml: `${lines.join("\n")}\n`, groups: shared.length };
}
function legacyOpenApiSlug(value) {
return value.replace(/\s+/g, "-").toLowerCase();
}
function encodeRoute(route) {
return route.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
async function collectOpenApiAlternateGroups(root = ROOT, base = BASE) {
const groups = new Map();
for (const locale of LOCALES) {
const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
for (const [apiPath, pathItem] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!/^(?:get|post|put|patch|delete|head|options)$/i.test(method) || !operation?.summary) continue;
const tag = operation.tags?.[0] ?? "unknown";
const route = encodeRoute(`api-reference/${legacyOpenApiSlug(tag)}/${legacyOpenApiSlug(operation.summary)}`);
const key = `${method.toLowerCase()} ${apiPath}`;
const group = groups.get(key) ?? {};
group[locale] = `${base}/${locale}/${route}`;
groups.set(key, group);
}
}
}
return [...groups.values()];
}
async function loadSource(source) {
if (/^https?:\/\//.test(source)) {
const response = await fetch(source, { headers: { "user-agent": "LangBot docs alternate-sitemap generator" } });
if (!response.ok) throw new Error(`Unable to fetch ${source}: ${response.status}`);
return response.text();
}
return readFile(source, "utf8");
}
async function main() {
const source = process.env.SITEMAP_SOURCE ?? path.join(ROOT, "dist/public/sitemap.xml");
const openApiGroups = await collectOpenApiAlternateGroups();
const { xml, groups } = renderAlternateSitemap(await loadSource(source), BASE, openApiGroups);
const outputs = process.env.SITEMAP_OUTPUT
? [path.resolve(process.env.SITEMAP_OUTPUT)]
: [path.join(ROOT, "dist/public/sitemap-alternates.xml"), path.join(ROOT, "sitemap-alternates.xml")];
for (const output of outputs) await writeFile(output, xml);
console.log(`Wrote ${groups} reciprocal language groups to ${outputs.join(", ")}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
import { access, readdir, rename } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function decodePathSegment(segment) {
const decoded = decodeURIComponent(segment);
if (decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) {
throw new Error(`Refusing to decode unsafe output path segment: ${segment}`);
}
return decoded;
}
async function exists(target) {
try {
await access(target);
return true;
} catch {
return false;
}
}
export async function normalizeStaticPaths(directory) {
let renamed = 0;
for (const entry of await readdir(directory, { withFileTypes: true })) {
const source = path.join(directory, entry.name);
if (entry.isDirectory()) renamed += await normalizeStaticPaths(source);
const decodedName = decodePathSegment(entry.name);
if (decodedName === entry.name) continue;
const destination = path.join(directory, decodedName);
if (await exists(destination)) {
throw new Error(`Static output path collision: ${source} -> ${destination}`);
}
await rename(source, destination);
renamed += 1;
}
return renamed;
}
async function main() {
const publicRoot = path.join(ROOT, "dist/public");
const renamed = await normalizeStaticPaths(publicRoot);
console.log(`Normalized ${renamed} percent-encoded static path segments`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
import { cp, lstat, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCALES = ["en", "zh", "ja"];
const MINTLIFY_LOCALE_MAP = { en: "en", cn: "zh", jp: "ja" };
const ASSET_DIRECTORIES = ["images", "logo", "ui", "openapi"];
const ROOT_ASSET_PATTERN = /\.(?:gif|html|ico|jpe?g|png|svg|txt|webp|xml)$/i;
const MINTLIFY_CALLOUT_TYPES = { Info: "info", Note: "info", Tip: "idea", Warning: "warning" };
export function mapMintlifyLocale(locale) {
const mapped = MINTLIFY_LOCALE_MAP[locale];
if (!mapped) throw new Error(`Unsupported Mintlify locale: ${locale}`);
return mapped;
}
async function listFiles(directory, prefix = "") {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const relative = path.posix.join(prefix, entry.name);
const full = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Generated inputs must not contain symlinks: ${full}`);
if (entry.isDirectory()) files.push(...await listFiles(full, relative));
else if (entry.isFile()) files.push(relative);
}
return files.sort();
}
function rootRelativeAssetUrl(destination, documentPath) {
const suffixIndex = destination.search(/[?#]/);
const pathname = suffixIndex === -1 ? destination : destination.slice(0, suffixIndex);
const suffix = suffixIndex === -1 ? "" : destination.slice(suffixIndex);
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(documentPath), pathname));
if (resolved.startsWith("../") || path.posix.isAbsolute(resolved)) return destination;
if (!ASSET_DIRECTORIES.includes(resolved.split("/", 1)[0])) return destination;
return `/${resolved}${suffix}`;
}
export function normalizeInternalHtmlUrl(destination) {
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(destination)) return destination;
const suffixIndex = destination.search(/[?#]/);
const pathname = suffixIndex === -1 ? destination : destination.slice(0, suffixIndex);
const suffix = suffixIndex === -1 ? "" : destination.slice(suffixIndex);
return pathname.endsWith(".html") ? `${pathname.slice(0, -5)}${suffix}` : destination;
}
export function normalizeMdxContent(content, documentPath) {
let fence;
return content.split(/(?<=\n)/).map((line) => {
const body = line.replace(/\r?\n$/, "");
if (fence) {
if (new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`).test(body)) fence = undefined;
return line;
}
const opening = body.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
if (opening) {
fence = { character: opening[2][0], length: opening[2].length };
const normalizedInfo = opening[3].replace(/^(\s*)env(?=\s|$)/, "$1dotenv");
return `${opening[1]}${opening[2]}${normalizedInfo}${line.slice(body.length)}`;
}
return line
.replace(/<\/?AccordionGroup>/g, "")
.replace(/<Accordion\s+title=(["'])(.*?)\1\s*>/g, (_match, _quote, title) =>
`<details>\n<summary>${title}</summary>`)
.replace(/<\/Accordion>/g, "</details>")
.replace(/<(Info|Note|Tip|Warning)(?=\s|>)([^>]*)>/g, (_match, name, attributes) =>
`<Callout type="${MINTLIFY_CALLOUT_TYPES[name]}"${attributes}>`)
.replace(/<\/(?:Info|Note|Tip|Warning)>/g, "</Callout>")
.replace(/(!\[[^\]\n]*\]\(\s*)(<?)(\.\.?\/[^)\s>]+)(>?)([^)\n]*\))/g, (_match, prefix, open, destination, close, suffix) =>
`${prefix}${open}${rootRelativeAssetUrl(destination, documentPath)}${close}${suffix}`)
.replace(/(\]\(\s*<?)([^)\s>]+)(>?)([^)\n]*\))/g, (_match, prefix, destination, close, suffix) =>
`${prefix}${normalizeInternalHtmlUrl(destination)}${close}${suffix}`)
.replace(/(\bhref\s*=\s*["'])([^"']+)(["'])/g, (_match, prefix, destination, suffix) =>
`${prefix}${normalizeInternalHtmlUrl(destination)}${suffix}`)
.replace(/(\bsrc\s*=\s*["'])(\.\.?\/[^"']+)(["'])/g, (_match, prefix, destination, suffix) =>
`${prefix}${rootRelativeAssetUrl(destination, documentPath)}${suffix}`);
}).join("");
}
export async function collectMdxDocuments(root = ROOT) {
const pages = [];
for (const locale of LOCALES) {
const files = await listFiles(path.join(root, locale));
pages.push(...files.filter((file) => file.endsWith(".mdx")).map((file) => `${locale}/${file}`));
}
return pages.sort();
}
function visitNavigation(value, callback) {
if (Array.isArray(value)) {
for (const item of value) visitNavigation(item, callback);
return;
}
if (!value || typeof value !== "object") return;
callback(value);
for (const child of Object.values(value)) visitNavigation(child, callback);
}
export function collectOpenApiSources(docs) {
const sources = [];
visitNavigation(docs.navigation, (item) => {
if (!item.openapi || typeof item.openapi !== "object" || !item.openapi.source || !item.openapi.directory) return;
const locale = item.openapi.directory.split("/", 1)[0];
sources.push({ locale, source: item.openapi.source, directory: item.openapi.directory });
});
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" : "*");
}
export function renderCloudflareRedirects(docs) {
const lines = [
"/ /en/insight/guide 302",
"/zh/develop/adapter/discord /zh/develop/adapter/discord/README 308",
"/scripts/README-blog-articles /en/articles 308",
];
for (const redirect of docs.redirects ?? []) {
const status = redirect.permanent === false ? 307 : 308;
lines.push(`${cloudflarePattern(redirect.source)} ${cloudflarePattern(redirect.destination, true)} ${status}`);
}
return `${lines.join("\n")}\n`;
}
async function copyDirectory(source, destination) {
const info = await lstat(source);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Expected a real directory: ${source}`);
await cp(source, destination, { recursive: true, force: true, errorOnExist: false });
}
export async function prepareFumapress({ root = ROOT, outRoot = root } = {}) {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const documents = await collectMdxDocuments(root);
const openapi = collectOpenApiSources(docs);
if (openapi.length !== LOCALES.length) {
throw new Error(`Expected one nested OpenAPI source per locale, found ${openapi.length}`);
}
const contentRoot = path.join(outRoot, "content");
const publicRoot = path.join(outRoot, "public");
await rm(contentRoot, { recursive: true, force: true });
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) {
const [locale, ...relativeParts] = document.split("/");
const relative = relativeParts.join("/");
const translations = documentsByPath.get(relative) ?? new Map();
translations.set(locale, document);
documentsByPath.set(relative, translations);
}
let localeOnlyDocuments = 0;
for (const [relative, translations] of [...documentsByPath].sort(([left], [right]) => left.localeCompare(right))) {
if (!translations.has("en")) localeOnlyDocuments += 1;
for (const [locale, document] of translations) {
const extension = locale === "en" ? ".mdx" : `.${locale}.mdx`;
const destination = path.join(contentRoot, "docs", relative.replace(/\.mdx$/, extension));
await mkdir(path.dirname(destination), { recursive: true });
const source = await readFile(path.join(root, document), "utf8");
await writeFile(destination, normalizeMdxContent(source, document));
}
}
for (const directory of ASSET_DIRECTORIES) {
await copyDirectory(path.join(root, directory), path.join(publicRoot, directory));
}
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isFile() && ROOT_ASSET_PATTERN.test(entry.name) && !["robots.txt", "sitemap-alternates.xml"].includes(entry.name)) {
await cp(path.join(root, entry.name), path.join(publicRoot, entry.name), { force: true });
}
}
await cp(path.join(root, "robots.txt"), path.join(publicRoot, "robots.txt"), { force: true });
await writeFile(path.join(publicRoot, "_redirects"), renderCloudflareRedirects(docs));
return {
documents: documents.length,
fallbackDefaults: 0,
localeOnlyDocuments,
redirects: (docs.redirects?.length ?? 0) + 3,
locales: [...LOCALES],
openapi,
};
}
async function main() {
const result = await prepareFumapress();
console.log(`Prepared ${result.documents} MDX documents, ${result.openapi.length} OpenAPI specs, and ${result.redirects} redirects.`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+2757 -1245
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "fumapress/css/preset.css";
@import "fumadocs-openapi/css/preset.css";
:root {
--color-fd-primary: #4692dd;
}
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,
[data-toc-popover-content] div.relative.flex.flex-col:has(> a[href^="#"]) {
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,
#nd-toc > div > div > div.absolute,
[data-toc-popover-content] a[href^="#"] > svg,
[data-toc-popover-content] div.relative.flex.flex-col:has(> a[href^="#"]) > div.absolute {
display: none;
}
/* Keep every active segment on one x-axis, even when labels are indented. */
#nd-toc > div > div > a[data-active="true"]::before,
[data-toc-popover-content] a[href^="#"][data-active="true"]::before {
content: "";
position: absolute;
inset-block: -0.375rem;
inset-inline-start: 0.5rem;
width: 1px;
background: var(--color-fd-primary);
border-radius: 0;
z-index: 1;
}
/* 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;
}
+480
View File
@@ -0,0 +1,480 @@
/en/api-reference/bots/create-a-bot
/en/api-reference/bots/delete-a-bot
/en/api-reference/bots/get-a-bot
/en/api-reference/bots/get-bot-event-logs
/en/api-reference/bots/list-all-bots
/en/api-reference/bots/send-a-message-through-a-bot
/en/api-reference/bots/update-a-bot
/en/api-reference/embedding-models/create-an-embedding-model
/en/api-reference/embedding-models/delete-an-embedding-model
/en/api-reference/embedding-models/get-an-embedding-model
/en/api-reference/embedding-models/list-all-embedding-models
/en/api-reference/embedding-models/test-an-embedding-model
/en/api-reference/embedding-models/update-an-embedding-model
/en/api-reference/files/upload-a-document
/en/api-reference/files/upload-an-image
/en/api-reference/knowledge-bases/add-a-file-to-a-knowledge-base
/en/api-reference/knowledge-bases/create-a-knowledge-base
/en/api-reference/knowledge-bases/delete-a-knowledge-base
/en/api-reference/knowledge-bases/get-a-knowledge-base
/en/api-reference/knowledge-bases/list-all-knowledge-bases
/en/api-reference/knowledge-bases/list-knowledge-base-files
/en/api-reference/knowledge-bases/remove-a-file-from-a-knowledge-base
/en/api-reference/knowledge-bases/retrieve-from-a-knowledge-base
/en/api-reference/knowledge-bases/update-a-knowledge-base
/en/api-reference/knowledge-engines/get-the-knowledge-engine-creation-schema
/en/api-reference/knowledge-engines/get-the-knowledge-engine-retrieval-schema
/en/api-reference/knowledge-engines/list-available-knowledge-engines
/en/api-reference/llm-models/create-an-llm-model
/en/api-reference/llm-models/delete-an-llm-model
/en/api-reference/llm-models/get-an-llm-model
/en/api-reference/llm-models/list-all-llm-models
/en/api-reference/llm-models/test-an-llm-model
/en/api-reference/llm-models/update-an-llm-model
/en/api-reference/model-providers/create-a-model-provider
/en/api-reference/model-providers/delete-a-model-provider
/en/api-reference/model-providers/get-a-model-provider
/en/api-reference/model-providers/list-all-model-providers
/en/api-reference/model-providers/update-a-model-provider
/en/api-reference/parsers/list-available-parsers
/en/api-reference/pipelines/copy-a-pipeline
/en/api-reference/pipelines/create-a-pipeline
/en/api-reference/pipelines/delete-a-pipeline
/en/api-reference/pipelines/get-a-pipeline
/en/api-reference/pipelines/get-pipeline-extension-configuration
/en/api-reference/pipelines/get-pipeline-metadata
/en/api-reference/pipelines/list-all-pipelines
/en/api-reference/pipelines/update-a-pipeline
/en/api-reference/pipelines/update-pipeline-extension-configuration
/en/api-reference/plugins/delete-a-plugin
/en/api-reference/plugins/get-plugin-configuration
/en/api-reference/plugins/get-plugin-details
/en/api-reference/plugins/install-a-plugin-from-github
/en/api-reference/plugins/install-a-plugin-from-marketplace
/en/api-reference/plugins/list-installed-plugins
/en/api-reference/plugins/update-plugin-configuration
/en/api-reference/plugins/upgrade-a-plugin
/en/api-reference/system/get-system-information
/en/api-reference/tools/get-tool-detail
/en/api-reference/tools/list-all-available-tools
/en/articles
/en/articles/connect-deepseek-to-wechat
/en/articles/deploy-ai-bot-in-5-minutes
/en/articles/dify-agent-discord-telegram-slack
/en/articles/dify-langbot-rag-knowledge-base
/en/articles/langbot-cloud-multi-user-workspaces
/en/articles/langbot-plugin-system-deep-dive
/en/articles/langbot-v4100-sandbox-skills
/en/articles/langbot-v490-rag-plugin-architecture
/en/articles/langflow-drag-and-drop-ai-bot
/en/articles/langtars-remote-pc-control-dify-n8n
/en/articles/n8n-multi-platform-ai-chatbot
/en/articles/welcome
/en/deploy/command
/en/deploy/langbot/docker
/en/deploy/langbot/kubernetes
/en/deploy/langbot/manual
/en/deploy/langbot/one-click/1panel
/en/deploy/langbot/one-click/bt
/en/deploy/langbot/package
/en/deploy/settings
/en/deploy/update
/en/develop/agent-context
/en/develop/comp-arch
/en/develop/dev-config
/en/develop/plugin-runtime
/en/insight/community
/en/insight/data-collection-policy
/en/insight/features
/en/insight/guide
/en/insight/platform-features
/en/insight/troubleshooting
/en/plugin/compatibility
/en/plugin/dev/apis/common
/en/plugin/dev/apis/messages
/en/plugin/dev/apis/pipeline-events
/en/plugin/dev/apis/tech-details
/en/plugin/dev/basic-info
/en/plugin/dev/components/add
/en/plugin/dev/components/command
/en/plugin/dev/components/event-listener
/en/plugin/dev/components/knowledge-engine
/en/plugin/dev/components/knowledge-retriever
/en/plugin/dev/components/page
/en/plugin/dev/components/parser
/en/plugin/dev/components/tool
/en/plugin/dev/directory-structure
/en/plugin/dev/migration
/en/plugin/dev/publish/github
/en/plugin/dev/publish/market
/en/plugin/dev/style
/en/plugin/dev/tutor
/en/plugin/plugin-intro
/en/tags/readme
/en/tags/webhook
/en/usage/knowledge/readme
/en/usage/mcp/readme
/en/usage/models/readme
/en/usage/pipelines/coze
/en/usage/pipelines/deerflow
/en/usage/pipelines/dify
/en/usage/pipelines/langflow
/en/usage/pipelines/n8n
/en/usage/pipelines/readme
/en/usage/pipelines/weknora
/en/usage/platforms/dingtalk
/en/usage/platforms/discord
/en/usage/platforms/http-bot
/en/usage/platforms/kook
/en/usage/platforms/lark
/en/usage/platforms/line
/en/usage/platforms/qq/aiocqhttp/lagrange
/en/usage/platforms/qq/aiocqhttp/llonebot
/en/usage/platforms/qq/aiocqhttp/napcat
/en/usage/platforms/qq/official_webhook
/en/usage/platforms/readme
/en/usage/platforms/satori/llonebot
/en/usage/platforms/slack
/en/usage/platforms/telegram
/en/usage/platforms/webpage
/en/usage/platforms/wechat/weixin
/en/usage/platforms/wecom/wecom
/en/usage/platforms/wecom/wecombot
/en/usage/platforms/wecom/wecomcs
/en/usage/platforms/wxoa
/en/usage/sandbox/config
/en/usage/sandbox/readme
/en/usage/sandbox/runtime-relationships
/en/usage/skills/readme
/en/workshop/302ai-integration
/en/workshop/impl-platform-adapter
/en/workshop/mcp-details
/en/workshop/network-details
/en/workshop/newapi-integration
/en/workshop/ppio-integration
/en/workshop/production/proxy-and-ssl
/ja/api-reference/bots/创建机器人
/ja/api-reference/bots/删除机器人
/ja/api-reference/bots/更新机器人
/ja/api-reference/bots/获取单个机器人
/ja/api-reference/bots/获取所有机器人
/ja/api-reference/bots/获取机器人事件日志
/ja/api-reference/bots/通过机器人发送消息
/ja/api-reference/embedding-models/创建向量模型
/ja/api-reference/embedding-models/删除向量模型
/ja/api-reference/embedding-models/更新向量模型
/ja/api-reference/embedding-models/测试向量模型
/ja/api-reference/embedding-models/获取单个向量模型
/ja/api-reference/embedding-models/获取所有向量模型
/ja/api-reference/files/上传图片
/ja/api-reference/files/上传文档
/ja/api-reference/knowledge-bases/从知识库删除文件
/ja/api-reference/knowledge-bases/创建知识库
/ja/api-reference/knowledge-bases/删除知识库
/ja/api-reference/knowledge-bases/将文件存入知识库
/ja/api-reference/knowledge-bases/更新知识库
/ja/api-reference/knowledge-bases/检索知识库
/ja/api-reference/knowledge-bases/获取单个知识库
/ja/api-reference/knowledge-bases/获取所有知识库
/ja/api-reference/knowledge-bases/获取知识库文件列表
/ja/api-reference/knowledge-engines/获取可用知识引擎列表
/ja/api-reference/knowledge-engines/获取知识引擎创建配置模式
/ja/api-reference/knowledge-engines/获取知识引擎检索配置模式
/ja/api-reference/llm-models/创建-llm-模型
/ja/api-reference/llm-models/删除-llm-模型
/ja/api-reference/llm-models/更新-llm-模型
/ja/api-reference/llm-models/测试-llm-模型
/ja/api-reference/llm-models/获取单个-llm-模型
/ja/api-reference/llm-models/获取所有-llm-模型
/ja/api-reference/model-providers/创建供应商
/ja/api-reference/model-providers/删除供应商
/ja/api-reference/model-providers/更新供应商
/ja/api-reference/model-providers/获取单个供应商
/ja/api-reference/model-providers/获取所有供应商
/ja/api-reference/parsers/获取可用解析器列表
/ja/api-reference/pipelines/创建流水线
/ja/api-reference/pipelines/删除流水线
/ja/api-reference/pipelines/复制流水线
/ja/api-reference/pipelines/更新流水线
/ja/api-reference/pipelines/更新流水线扩展配置
/ja/api-reference/pipelines/获取单个流水线
/ja/api-reference/pipelines/获取所有流水线
/ja/api-reference/pipelines/获取流水线元数据
/ja/api-reference/pipelines/获取流水线扩展配置
/ja/api-reference/plugins/从-github-安装插件
/ja/api-reference/plugins/从-marketplace-安装插件
/ja/api-reference/plugins/删除插件
/ja/api-reference/plugins/升级插件
/ja/api-reference/plugins/更新插件配置
/ja/api-reference/plugins/获取已安装插件列表
/ja/api-reference/plugins/获取插件详情
/ja/api-reference/plugins/获取插件配置
/ja/api-reference/system/获取系统信息
/ja/api-reference/ツール/ツール詳細を取得
/ja/api-reference/ツール/利用可能なツール一覧を取得
/ja/articles
/ja/articles/connect-deepseek-to-wechat
/ja/articles/deploy-ai-bot-in-5-minutes
/ja/articles/dify-agent-discord-telegram-slack
/ja/articles/dify-langbot-rag-knowledge-base
/ja/articles/langbot-cloud-multi-user-workspaces
/ja/articles/langbot-plugin-system-deep-dive
/ja/articles/langbot-v4100-sandbox-skills
/ja/articles/langbot-v490-rag-plugin-architecture
/ja/articles/langflow-drag-and-drop-ai-bot
/ja/articles/langtars-remote-pc-control-dify-n8n
/ja/articles/n8n-multi-platform-ai-chatbot
/ja/articles/welcome
/ja/deploy/command
/ja/deploy/langbot/docker
/ja/deploy/langbot/kubernetes
/ja/deploy/langbot/manual
/ja/deploy/langbot/one-click/1panel
/ja/deploy/langbot/one-click/bt
/ja/deploy/langbot/package
/ja/deploy/settings
/ja/deploy/update
/ja/develop/agent-context
/ja/develop/comp-arch
/ja/develop/dev-config
/ja/develop/plugin-runtime
/ja/insight/community
/ja/insight/data-collection-policy
/ja/insight/features
/ja/insight/guide
/ja/insight/platform-features
/ja/insight/troubleshooting
/ja/plugin/compatibility
/ja/plugin/dev/apis/common
/ja/plugin/dev/apis/messages
/ja/plugin/dev/apis/pipeline-events
/ja/plugin/dev/apis/tech-details
/ja/plugin/dev/basic-info
/ja/plugin/dev/components/add
/ja/plugin/dev/components/command
/ja/plugin/dev/components/event-listener
/ja/plugin/dev/components/knowledge-engine
/ja/plugin/dev/components/knowledge-retriever
/ja/plugin/dev/components/page
/ja/plugin/dev/components/parser
/ja/plugin/dev/components/tool
/ja/plugin/dev/directory-structure
/ja/plugin/dev/migration
/ja/plugin/dev/publish/github
/ja/plugin/dev/publish/market
/ja/plugin/dev/style
/ja/plugin/dev/tutor
/ja/plugin/plugin-intro
/ja/tags/readme
/ja/tags/webhook
/ja/usage/knowledge/readme
/ja/usage/mcp/readme
/ja/usage/models/readme
/ja/usage/pipelines/coze
/ja/usage/pipelines/deerflow
/ja/usage/pipelines/dify
/ja/usage/pipelines/langflow
/ja/usage/pipelines/n8n
/ja/usage/pipelines/readme
/ja/usage/pipelines/weknora
/ja/usage/platforms/dingtalk
/ja/usage/platforms/discord
/ja/usage/platforms/http-bot
/ja/usage/platforms/kook
/ja/usage/platforms/lark
/ja/usage/platforms/line
/ja/usage/platforms/qq/aiocqhttp/lagrange
/ja/usage/platforms/qq/aiocqhttp/llonebot
/ja/usage/platforms/qq/aiocqhttp/napcat
/ja/usage/platforms/qq/official_webhook
/ja/usage/platforms/readme
/ja/usage/platforms/satori/llonebot
/ja/usage/platforms/slack
/ja/usage/platforms/telegram
/ja/usage/platforms/webpage
/ja/usage/platforms/wechat/weixin
/ja/usage/platforms/wecom/wecom
/ja/usage/platforms/wecom/wecombot
/ja/usage/platforms/wecom/wecomcs
/ja/usage/platforms/wxoa
/ja/usage/sandbox/config
/ja/usage/sandbox/readme
/ja/usage/sandbox/runtime-relationships
/ja/usage/skills/readme
/ja/workshop/302ai-integration
/ja/workshop/impl-platform-adapter
/ja/workshop/mcp-details
/ja/workshop/network-details
/ja/workshop/newapi-integration
/ja/workshop/ppio-integration
/ja/workshop/production/proxy-and-ssl
/scripts/README-blog-articles
/zh/api-reference/向量模型/创建向量模型
/zh/api-reference/向量模型/删除向量模型
/zh/api-reference/向量模型/更新向量模型
/zh/api-reference/向量模型/测试向量模型
/zh/api-reference/向量模型/获取单个向量模型
/zh/api-reference/向量模型/获取所有向量模型
/zh/api-reference/大语言模型/创建-llm-模型
/zh/api-reference/大语言模型/删除-llm-模型
/zh/api-reference/大语言模型/更新-llm-模型
/zh/api-reference/大语言模型/测试-llm-模型
/zh/api-reference/大语言模型/获取单个-llm-模型
/zh/api-reference/大语言模型/获取所有-llm-模型
/zh/api-reference/工具/获取工具详情
/zh/api-reference/工具/获取所有可用工具
/zh/api-reference/插件/从-github-安装插件
/zh/api-reference/插件/从-marketplace-安装插件
/zh/api-reference/插件/删除插件
/zh/api-reference/插件/升级插件
/zh/api-reference/插件/更新插件配置
/zh/api-reference/插件/获取已安装插件列表
/zh/api-reference/插件/获取插件详情
/zh/api-reference/插件/获取插件配置
/zh/api-reference/文件/上传图片
/zh/api-reference/文件/上传文档
/zh/api-reference/机器人/创建机器人
/zh/api-reference/机器人/删除机器人
/zh/api-reference/机器人/更新机器人
/zh/api-reference/机器人/获取单个机器人
/zh/api-reference/机器人/获取所有机器人
/zh/api-reference/机器人/获取机器人事件日志
/zh/api-reference/机器人/通过机器人发送消息
/zh/api-reference/模型供应商/创建供应商
/zh/api-reference/模型供应商/删除供应商
/zh/api-reference/模型供应商/更新供应商
/zh/api-reference/模型供应商/获取单个供应商
/zh/api-reference/模型供应商/获取所有供应商
/zh/api-reference/流水线/创建流水线
/zh/api-reference/流水线/删除流水线
/zh/api-reference/流水线/复制流水线
/zh/api-reference/流水线/更新流水线
/zh/api-reference/流水线/更新流水线扩展配置
/zh/api-reference/流水线/获取单个流水线
/zh/api-reference/流水线/获取所有流水线
/zh/api-reference/流水线/获取流水线元数据
/zh/api-reference/流水线/获取流水线扩展配置
/zh/api-reference/知识库/从知识库删除文件
/zh/api-reference/知识库/创建知识库
/zh/api-reference/知识库/删除知识库
/zh/api-reference/知识库/将文件存入知识库
/zh/api-reference/知识库/更新知识库
/zh/api-reference/知识库/检索知识库
/zh/api-reference/知识库/获取单个知识库
/zh/api-reference/知识库/获取所有知识库
/zh/api-reference/知识库/获取知识库文件列表
/zh/api-reference/知识引擎/获取可用知识引擎列表
/zh/api-reference/知识引擎/获取知识引擎创建配置模式
/zh/api-reference/知识引擎/获取知识引擎检索配置模式
/zh/api-reference/系统/获取系统信息
/zh/api-reference/解析器/获取可用解析器列表
/zh/articles
/zh/articles/connect-deepseek-to-wechat
/zh/articles/deploy-ai-bot-in-5-minutes
/zh/articles/dify-agent-discord-telegram-slack
/zh/articles/dify-langbot-rag-knowledge-base
/zh/articles/langbot-cloud-multi-user-workspaces
/zh/articles/langbot-plugin-system-deep-dive
/zh/articles/langbot-v4100-sandbox-skills
/zh/articles/langbot-v490-rag-plugin-architecture
/zh/articles/langflow-drag-and-drop-ai-bot
/zh/articles/langtars-remote-pc-control-dify-n8n
/zh/articles/n8n-multi-platform-ai-chatbot
/zh/articles/welcome
/zh/deploy/command
/zh/deploy/langbot/docker
/zh/deploy/langbot/kubernetes
/zh/deploy/langbot/manual
/zh/deploy/langbot/one-click/1panel
/zh/deploy/langbot/one-click/bt
/zh/deploy/langbot/package
/zh/deploy/settings
/zh/deploy/update
/zh/develop/adapter/discord/README
/zh/develop/adapter/discord/api_reference
/zh/develop/adapter/discord/design
/zh/develop/adapter/discord/quick_start
/zh/develop/adapter/discord/troubleshooting
/zh/develop/agent-context
/zh/develop/comp-arch
/zh/develop/dev-config
/zh/develop/plugin-runtime
/zh/insight/community
/zh/insight/data-collection-policy
/zh/insight/features
/zh/insight/guide
/zh/insight/platform-features
/zh/insight/troubleshooting
/zh/plugin/compatibility
/zh/plugin/dev/apis/common
/zh/plugin/dev/apis/messages
/zh/plugin/dev/apis/pipeline-events
/zh/plugin/dev/apis/tech-details
/zh/plugin/dev/basic-info
/zh/plugin/dev/components/add
/zh/plugin/dev/components/command
/zh/plugin/dev/components/event-listener
/zh/plugin/dev/components/knowledge-engine
/zh/plugin/dev/components/knowledge-retriever
/zh/plugin/dev/components/page
/zh/plugin/dev/components/parser
/zh/plugin/dev/components/tool
/zh/plugin/dev/directory-structure
/zh/plugin/dev/migration
/zh/plugin/dev/publish/github
/zh/plugin/dev/publish/market
/zh/plugin/dev/style
/zh/plugin/dev/tutor
/zh/plugin/plugin-intro
/zh/tags/readme
/zh/tags/webhook
/zh/tob
/zh/tob/contact
/zh/tob/platform
/zh/tob/provider
/zh/usage/knowledge/readme
/zh/usage/mcp/readme
/zh/usage/models/readme
/zh/usage/pipelines/coze
/zh/usage/pipelines/deerflow
/zh/usage/pipelines/dify
/zh/usage/pipelines/langflow
/zh/usage/pipelines/n8n
/zh/usage/pipelines/readme
/zh/usage/pipelines/tbox
/zh/usage/pipelines/weknora
/zh/usage/platforms/dingtalk
/zh/usage/platforms/discord
/zh/usage/platforms/http-bot
/zh/usage/platforms/kook
/zh/usage/platforms/lark
/zh/usage/platforms/line
/zh/usage/platforms/qq/aiocqhttp/lagrange
/zh/usage/platforms/qq/aiocqhttp/llonebot
/zh/usage/platforms/qq/aiocqhttp/napcat
/zh/usage/platforms/qq/official
/zh/usage/platforms/qq/official_webhook
/zh/usage/platforms/readme
/zh/usage/platforms/satori/llonebot
/zh/usage/platforms/slack
/zh/usage/platforms/telegram
/zh/usage/platforms/webpage
/zh/usage/platforms/wechat/wechatpad
/zh/usage/platforms/wechat/weixin
/zh/usage/platforms/wecom/wecom
/zh/usage/platforms/wecom/wecombot
/zh/usage/platforms/wecom/wecomcs
/zh/usage/platforms/wxoa
/zh/usage/sandbox/config
/zh/usage/sandbox/readme
/zh/usage/sandbox/runtime-relationships
/zh/usage/skills/readme
/zh/workshop/302ai-integration
/zh/workshop/impl-platform-adapter
/zh/workshop/mcp-details
/zh/workshop/network-details
/zh/workshop/newapi-integration
/zh/workshop/ppio-integration
/zh/workshop/production/proxy-and-ssl
/zh/workshop/shengsuanyun-integration
/zh/workshop/tokenpony-integration
+252
View File
@@ -0,0 +1,252 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import {
collectMdxDocuments,
collectOpenApiSources,
mapMintlifyLocale,
normalizeMintlifyNavigationForFumapress,
normalizeMdxContent,
prepareFumapress,
renderCloudflareRedirects,
} from "../scripts/prepare-fumapress.mjs";
const root = path.resolve(import.meta.dirname, "..");
test("Mintlify locales are mapped to public URL locales", () => {
assert.equal(mapMintlifyLocale("en"), "en");
assert.equal(mapMintlifyLocale("cn"), "zh");
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(
normalizeMdxContent(source, "en/insight/troubleshooting.mdx"),
"```dotenv\nTOKEN=value\n```\n\n~~~dotenv title=\"example\"\nOTHER=value\n~~~\n",
);
});
test("MDX compatibility normalization maps Mintlify callouts to Fumadocs", () => {
const source = [
"<Info title=\"Details\">",
"Useful context.",
"</Info>",
"<Warning>Careful.</Warning>",
"<Note>Remember this.</Note>",
"<Tip>Try this.</Tip>",
"```mdx",
"<Info>Example only.</Info>",
"```",
].join("\n");
assert.equal(
normalizeMdxContent(source, "en/example.mdx"),
source
.replace("<Info title=\"Details\">", "<Callout type=\"info\" title=\"Details\">")
.replace("</Info>", "</Callout>")
.replace("<Warning>Careful.</Warning>", "<Callout type=\"warning\">Careful.</Callout>")
.replace("<Note>Remember this.</Note>", "<Callout type=\"info\">Remember this.</Callout>")
.replace("<Tip>Try this.</Tip>", "<Callout type=\"idea\">Try this.</Callout>"),
);
});
test("MDX compatibility normalization maps Mintlify accordions to details", () => {
const source = [
"<AccordionGroup>",
"<Accordion title=\"Schema Changes\">",
"Migration instructions.",
"</Accordion>",
"</AccordionGroup>",
"```mdx",
"<Accordion title=\"Example only\">content</Accordion>",
"```",
].join("\n");
assert.equal(
normalizeMdxContent(source, "en/example.mdx"),
[
"",
"<details>",
"<summary>Schema Changes</summary>",
"Migration instructions.",
"</details>",
"",
"```mdx",
"<Accordion title=\"Example only\">content</Accordion>",
"```",
].join("\n"),
);
});
test("MDX compatibility normalization makes local image assets root-relative", () => {
const source = [
"![local](../../../images/zh/example.png)",
"![local with title](../../../images/zh/example.png \"Example\")",
"![external](https://example.com/image.png)",
"![anchor](#preview)",
"![already root](/images/zh/example.png)",
"<img src=\"../../../images/zh/inline.png\" alt=\"inline\" />",
"<img src=\"https://example.com/inline.png\" alt=\"external\" />",
"[local doc](../models/readme)",
"```markdown",
"![example](../../../images/zh/do-not-rewrite.png)",
"```",
].join("\n");
assert.equal(
normalizeMdxContent(source, "en/usage/platforms/example.mdx"),
source.replace(
"![local](../../../images/zh/example.png)",
"![local](/images/zh/example.png)",
).replace(
"![local with title](../../../images/zh/example.png \"Example\")",
"![local with title](/images/zh/example.png \"Example\")",
).replace(
"<img src=\"../../../images/zh/inline.png\" alt=\"inline\" />",
"<img src=\"/images/zh/inline.png\" alt=\"inline\" />",
),
);
});
test("MDX compatibility normalization removes internal .html route suffixes", () => {
const source = [
"[absolute](/en/deploy/settings.html)",
"[query](../settings.html?tab=bot#advanced)",
'<a href="./basic-info.html#metadata">relative</a>',
"[external](https://example.com/archive.html)",
"```markdown",
"[example](/en/keep-example.html)",
"```",
].join("\n");
assert.equal(
normalizeMdxContent(source, "en/example.mdx"),
source
.replace("/en/deploy/settings.html", "/en/deploy/settings")
.replace("../settings.html?tab=bot#advanced", "../settings?tab=bot#advanced")
.replace("./basic-info.html#metadata", "./basic-info#metadata"),
);
});
test("canonical MDX and nested OpenAPI sources are discovered", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const pages = await collectMdxDocuments(root);
assert.equal(pages.length, 302);
assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 96, zh: 110, ja: 96 });
assert.deepEqual(collectOpenApiSources(docs), [
{ locale: "en", source: "openapi/service-api-en.json", directory: "en/api-reference" },
{ locale: "zh", source: "openapi/service-api-zh.json", directory: "zh/api-reference" },
{ locale: "ja", source: "openapi/service-api-ja.json", directory: "ja/api-reference" },
]);
});
test("Cloudflare redirects preserve all Mintlify routes and add root", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const lines = renderCloudflareRedirects(docs).trim().split("\n");
assert.equal(lines.length, docs.redirects.length + 3);
assert.equal(lines[0], "/ /en/insight/guide 302");
assert.ok(lines.includes("/README_EN /en/insight/guide 308"));
assert.ok(lines.includes("/zh/develop/adapter/discord /zh/develop/adapter/discord/README 308"));
assert.ok(lines.includes("/scripts/README-blog-articles /en/articles 308"));
assert.ok(lines.some((line) => line.startsWith("/en/deploy/platforms/* /en/usage/platforms/:splat ")));
assert.ok(lines.includes("/en/api-reference/system/获取系统信息 /en/api-reference/system/get-system-information 308"));
assert.ok(lines.includes("/en/api-reference/model-providers/创建供应商 /en/api-reference/model-providers/create-a-model-provider 308"));
});
test("Fumapress config wires static localized docs, OpenAPI, and Mintlify support", async () => {
const config = await readFile(path.join(root, "press.config.tsx"), "utf8");
assert.match(config, /mode:\s*[""]static[""]/);
assert.match(config, /defineDocs\(\{[\s\S]*dir:\s*[""]content\/docs[""]/);
assert.match(config, /docs:\s*\{[\s\S]*postprocess:\s*\{[\s\S]*includeProcessedMarkdown:\s*true/);
assert.match(config, /localeMap:\s*\{\s*en:\s*[""]en[""],\s*cn:\s*[""]zh[""],\s*jp:\s*[""]ja[""]/);
assert.match(config, /createOpenAPI\(/);
for (const [locale, filename] of [["en", "service-api-en.json"], ["zh", "service-api-zh.json"], ["ja", "service-api-ja.json"]]) {
assert.match(config, new RegExp(`input:[\\s\\S]*openapi/${filename}`));
assert.match(config, new RegExp(`staticSource\\(\\{\\s*baseDir:\\s*["']${locale}/api-reference["']`));
}
assert.match(config, /groupBy:\s*["']tag["']/);
assert.match(config, /legacyOpenApiSlug/);
assert.match(config, /mintlifyPlugin\(/);
assert.match(config, /openapiPlugin\(\{\s*server:\s*\w+OpenAPI\s*\}\)/);
assert.match(config, /fumadocsMdx\(/);
});
test("prebuild is deterministic and preserves local assets and SEO files", async () => {
const temp = await mkdtemp(path.join(tmpdir(), "langbot-fumapress-"));
try {
const first = await prepareFumapress({ root, outRoot: temp });
const second = await prepareFumapress({ root, outRoot: temp });
assert.deepEqual(first, second);
assert.equal(first.documents, 302);
assert.equal(first.fallbackDefaults, 0);
assert.equal(first.localeOnlyDocuments, 14);
assert.equal(first.redirects, 104);
assert.deepEqual(first.locales, ["en", "zh", "ja"]);
await readFile(path.join(temp, "content/docs/insight/guide.mdx"), "utf8");
await readFile(path.join(temp, "content/docs/insight/guide.zh.mdx"), "utf8");
await readFile(path.join(temp, "content/docs/insight/guide.ja.mdx"), "utf8");
await assert.rejects(
readFile(path.join(temp, "content/docs/en/insight/guide.mdx"), "utf8"),
{ code: "ENOENT" },
);
await readFile(path.join(temp, "content/docs/develop/adapter/discord/README.zh.mdx"), "utf8");
for (const file of ["README.mdx", "README.ja.mdx"]) {
await assert.rejects(
readFile(path.join(temp, "content/docs/develop/adapter/discord", file), "utf8"),
{ code: "ENOENT" },
);
}
const troubleshooting = await readFile(path.join(temp, "content/docs/insight/troubleshooting.mdx"), "utf8");
assert.match(troubleshooting, /```dotenv\nLANGBOT_BOX_ROOT=/);
assert.doesNotMatch(troubleshooting, /```env(?:\s|$)/m);
const dingtalk = await readFile(path.join(temp, "content/docs/usage/platforms/dingtalk.mdx"), "utf8");
assert.match(dingtalk, /!\[Don.t modify\]\(\/images\/zh\/deploy\/bots\/dingtalk\/dingtalk13\.png\)/);
assert.doesNotMatch(dingtalk, /\]\(\.\.\/\.\.\/\.\.\/images\//);
await readFile(path.join(temp, "public/images/platforms/qq.svg"));
await readFile(path.join(temp, "public/images/zh/plugin/dev/dist/github_release.png"));
await readFile(path.join(temp, "public/openapi/service-api-en.json"));
await readFile(path.join(temp, "public/robots.txt"), "utf8");
await readFile(path.join(temp, "public/_redirects"), "utf8");
} finally {
await rm(temp, { recursive: true, force: true });
}
});
+457
View File
@@ -0,0 +1,457 @@
import assert from "node:assert/strict";
import { access, readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
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/);
assert.match(css, /#nd-toc > div > div > div\.absolute/);
assert.match(css, /\[data-toc-popover-content\].*> div\.absolute/s);
assert.match(css, /a\[data-active="true"\]::before/);
assert.match(css, /\[data-toc-popover-content\] a\[href\^="#"\]\[data-active="true"\]::before/);
assert.match(css, /inset-inline-start: 0\.5rem/);
assert.match(css, /border-radius: 0/);
});
async function collectHtmlRoutes(directory) {
const routes = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) routes.push(...await collectHtmlRoutes(target));
else if (entry.name === "index.html") routes.push(target);
}
return routes;
}
function staticHtmlForCanonicalDocument(document) {
const parts = document.replace(/\.mdx$/, "").split("/");
if (parts.at(-1) === "index") parts.pop();
return path.join(publicRoot, ...parts, "index.html");
}
function staticHtmlForUrlPath(urlPath) {
const parts = urlPath.replace(/^\/docs(?:\/|$)/, "").replace(/^\//, "").replace(/\/$/, "").split("/")
.map((segment) => decodeURIComponent(segment));
return path.join(publicRoot, ...parts, "index.html");
}
test("all 302 canonical localized documents have static HTML", async () => {
const documents = await collectMdxDocuments(root);
assert.equal(documents.length, 302);
const missing = [];
for (const document of documents) {
try {
await access(staticHtmlForCanonicalDocument(document));
} catch {
missing.push(document);
}
}
assert.deepEqual(missing, []);
});
test("every fixed redirect destination resolves to generated output", async () => {
const redirects = (await readFile(path.join(publicRoot, "_redirects"), "utf8"))
.trim().split("\n").map((line) => line.split(/\s+/));
for (const [source, destination] of redirects) {
if (destination.includes(":splat")) continue;
await access(staticHtmlForUrlPath(destination), undefined, `${source} -> ${destination}`);
}
});
test("static output uses decoded filesystem paths for edge hosting", async () => {
const encodedComponents = [];
async function walk(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (/%[0-9A-Fa-f]{2}/.test(entry.name)) encodedComponents.push(path.join(directory, entry.name));
if (entry.isDirectory()) await walk(path.join(directory, entry.name));
}
}
await walk(publicRoot);
assert.deepEqual(encodedComponents, []);
});
test("static routes never repeat their locale prefix", async () => {
for (const locale of ["en", "zh", "ja"]) {
await assert.rejects(access(path.join(publicRoot, locale, locale)), { code: "ENOENT" });
}
});
test("representative localized guides and canonical host are emitted", async () => {
for (const locale of ["en", "zh", "ja"]) {
await access(path.join(publicRoot, locale, "insight/guide/index.html"));
}
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
const robots = await readFile(path.join(publicRoot, "robots.txt"), "utf8");
assert.match(sitemap, /https:\/\/langbot\.app\/docs\//);
assert.doesNotMatch(sitemap, /https:\/\/docs\.langbot\.app\//);
assert.match(robots, /https:\/\/langbot\.app\/docs\/sitemap\.xml/);
assert.doesNotMatch(robots, /https:\/\/docs\.langbot\.app\//);
});
test("platform navigation renders logo assets instead of icon path text", async () => {
for (const locale of ["en", "zh", "ja"]) {
const html = await readFile(
path.join(publicRoot, locale, "usage/platforms/discord/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,
/<img[^>]+src="\/docs\/images\/platforms\/discord\.svg"[^>]*>/,
`${locale} sidebar is missing the Discord logo`,
);
assert.match(sidebar, />Discord</, `${locale} sidebar is missing the platform title`);
assert.doesNotMatch(
sidebar,
/>\/images\/platforms\/discord\.svg(?:<!-- -->)?Discord</,
`${locale} sidebar exposes the icon path as text`,
);
}
});
test("nested bot groups preserve the logos declared in docs.json", async () => {
const html = await readFile(
path.join(publicRoot, "zh/usage/platforms/discord/index.html"),
"utf8",
);
const asideStart = html.indexOf('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
for (const [title, logo] of [
["企业微信", "wecom.svg"],
["微信", "wechat.svg"],
["QQ 官方机器人", "qq.svg"],
["QQ (OneBot v11)", "qq.svg"],
]) {
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedLogo = logo.replace(".", "\\.");
assert.match(
sidebar,
new RegExp(`<img[^>]+src="/docs/images/platforms/${escapedLogo}"[^>]*>\\s*${escapedTitle}<svg`),
`zh sidebar is missing the ${logo} logo for ${title}`,
);
}
assert.doesNotMatch(
sidebar,
/>robot(?:<!-- -->)?Satori/,
"zh sidebar exposes the named robot icon as text",
);
assert.match(
sidebar,
/<svg[^>]+class="[^"]*lucide-bot[^"]*"[^>]*>[\s\S]*?<\/svg>Satori/,
"zh sidebar is missing the Satori robot icon",
);
});
test("all locales emit the OpenAPI surface with endpoint semantics", async () => {
const representatives = {
en: ["system/get-system-information", "Get system information", "GET", "/api/v1/system/info"],
zh: ["系统/获取系统信息", "获取系统信息", "GET", "/api/v1/system/info"],
ja: ["system/获取系统信息", "获取系统信息", "GET", "/api/v1/system/info"],
};
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
for (const [locale, [representativePath, ...semantics]] of Object.entries(representatives)) {
const apiRoot = path.join(publicRoot, locale, "api-reference");
const routes = await collectHtmlRoutes(apiRoot);
assert.ok(routes.length >= 50, `${locale} emitted only ${routes.length} API routes`);
const encodedRepresentativePath = representativePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
const representative = path.join(apiRoot, representativePath, "index.html");
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://langbot.app/docs/${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;
}
}
});
test("canonical sitemap routes match the legacy Mintlify directory structure", async () => {
const legacy = new Set((await readFile(path.join(root, "tests/fixtures/legacy-sitemap-routes.txt"), "utf8"))
.trim().split("\n").map((route) => route.replace(/\/$/, "")));
// Mintlify accidentally indexed this repository maintenance note; preserve
// its URL as a redirect without treating it as public documentation.
legacy.delete("/scripts/README-blog-articles");
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
const actual = new Set([...sitemap.matchAll(/<loc>https:\/\/langbot\.app\/docs(\/[^<]+)<\/loc>/g)]
.map((match) => decodeURIComponent(match[1]).replace(/\/$/, "")));
assert.deepEqual([...actual].sort(), [...legacy].sort());
});
const locales = ["en", "zh", "ja"];
const hreflangByLocale = { en: "en", zh: "zh-CN", ja: "ja" };
const zhOnlyRoutes = [
"develop/adapter/discord/README",
"develop/adapter/discord/api_reference",
"develop/adapter/discord/design",
"develop/adapter/discord/quick_start",
"develop/adapter/discord/troubleshooting",
"tob/contact",
"tob/index",
"tob/platform",
"tob/provider",
"usage/pipelines/tbox",
"usage/platforms/qq/official",
"usage/platforms/wechat/wechatpad",
"workshop/shengsuanyun-integration",
"workshop/tokenpony-integration",
];
function routeFromHtml(file) {
const relative = path.relative(publicRoot, path.dirname(file)).split(path.sep).join("/");
return `/${relative}`.replace(/\/$/, "");
}
function extractHeadLinks(html, rel) {
return [...html.matchAll(/<link\s+([^>]+)>/g)]
.map((match) => match[1])
.filter((attributes) => new RegExp(`(?:^|\\s)rel="${rel}"(?:\\s|$)`).test(attributes))
.map((attributes) => ({
href: attributes.match(/(?:^|\s)href="([^"]+)"/)?.[1]?.replaceAll("&amp;", "&"),
hreflang: attributes.match(/(?:^|\s)hrefLang="([^"]+)"/i)?.[1],
}));
}
test("rendered navbar and documentation tree use each locale's docs.json labels", async () => {
const expected = {
en: ["Home", "https://langbot.app/en", "Roadmap", "https://langbot.app/en/roadmap", "Guides", "Quick Start", "Installation", "Developers", "Articles", "API Reference"],
zh: ["首页", "https://langbot.app/zh", "路线图", "https://langbot.app/zh/roadmap", "指南", "快速开始", "安装部署", "开发者", "文章", "API 参考"],
ja: ["ホーム", "https://langbot.app/ja", "ロードマップ", "https://langbot.app/ja/roadmap", "ガイド", "クイックスタート", "インストール", "開発者", "記事", "API リファレンス"],
};
for (const locale of locales) {
const html = await readFile(path.join(publicRoot, locale, "insight/guide/index.html"), "utf8");
for (const value of expected[locale]) assert.ok(html.includes(value), `${locale} navbar missing ${value}`);
for (const languageName of ["English", "简体中文", "日本語"]) {
assert.ok(html.includes(languageName), `${locale} language selector missing ${languageName}`);
}
for (const other of locales.filter((item) => item !== locale)) {
assert.ok(!html.includes(expected[other][1]), `${locale} navbar includes ${other} home link`);
}
}
});
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="/docs/${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="\/docs\/zh\/insight\/troubleshooting"/);
assert.doesNotMatch(sidebar, /href="\/docs\/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) => [
locale,
new Set(documents.filter((item) => item.startsWith(`${locale}/`)).map((item) => item.slice(3).replace(/\.mdx$/, ""))),
]));
const actual = [...byLocale.zh].filter((route) => !byLocale.en.has(route) && !byLocale.ja.has(route)).sort();
assert.deepEqual(actual, zhOnlyRoutes);
const searchableOutputs = await Promise.all(
["sitemap.xml", "llms.txt", "llms-full.txt", "api/search"].map((file) => readFile(path.join(publicRoot, file), "utf8")),
);
for (const sourceRoute of zhOnlyRoutes) {
const route = sourceRoute === "index" ? "" : sourceRoute.replace(/\/index$/, "");
await access(path.join(publicRoot, "zh", route, "index.html"));
for (const locale of ["en", "ja"]) {
await assert.rejects(access(path.join(publicRoot, locale, route, "index.html")), { code: "ENOENT" });
for (const output of searchableOutputs) {
assert.doesNotMatch(
output,
new RegExp(`/${locale}/${route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_-])`),
`${locale}/${route} leaked into an index`,
);
}
}
}
});
test("every page emits a canonical and reciprocal available hreflangs", async () => {
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
const routes = new Set([...sitemap.matchAll(/<loc>https:\/\/langbot\.app\/docs(\/[^<]+)<\/loc>/g)].map((match) => match[1].replace(/\/$/, "")));
const allFiles = await collectHtmlRoutes(publicRoot);
const localized = allFiles.filter((file) => routes.has(routeFromHtml(file)));
for (const file of localized) {
const route = routeFromHtml(file);
const html = await readFile(file, "utf8");
const canonical = extractHeadLinks(html, "canonical");
assert.deepEqual(canonical, [{ href: `https://langbot.app/docs${route}`, hreflang: undefined }], route);
const [, locale, ...suffixParts] = route.split("/");
const suffix = suffixParts.join("/");
const actual = Object.fromEntries(
extractHeadLinks(html, "alternate")
.filter((item) => item.hreflang)
.map((item) => [item.hreflang, item.href]),
);
if (suffix.startsWith("api-reference/")) {
assert.deepEqual(Object.keys(actual).sort(), ["en", "ja", "x-default", "zh-CN"]);
assert.equal(actual["x-default"], actual.en);
for (const href of Object.values(actual)) {
const target = new URL(href).pathname.replace(/^\/docs/, "").replace(/\/$/, "");
assert.ok(routes.has(target), `${route} links to missing alternate ${target}`);
}
} else {
const expected = {};
for (const candidate of locales) {
const candidateRoute = `/${candidate}/${suffix}`.replace(/\/$/, "");
if (routes.has(candidateRoute)) expected[hreflangByLocale[candidate]] = `https://langbot.app/docs${candidateRoute}`;
}
expected["x-default"] = expected.en ?? expected["zh-CN"] ?? expected.ja;
assert.deepEqual(actual, expected, route);
}
}
});
test("alternate sitemap is complete, reciprocal, and advertised", async () => {
const sitemap = await readFile(path.join(publicRoot, "sitemap-alternates.xml"), "utf8");
const robots = await readFile(path.join(publicRoot, "robots.txt"), "utf8");
assert.match(robots, /^Sitemap: https:\/\/langbot\.app\/docs\/sitemap\.xml$/m);
assert.match(robots, /^Sitemap: https:\/\/langbot\.app\/docs\/sitemap-alternates\.xml$/m);
const blocks = [...sitemap.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((match) => match[1]);
const groups = new Map();
for (const block of blocks) {
const loc = block.match(/<loc>([^<]+)<\/loc>/)?.[1];
const links = Object.fromEntries([...block.matchAll(/hreflang="([^"]+)" href="([^"]+)"/g)].map((match) => [match[1], match[2]]));
assert.ok(loc && links.en && links["zh-CN"] && links.ja && links["x-default"]);
const key = [links.en, links["zh-CN"], links.ja].join("\n");
groups.set(key, [...(groups.get(key) ?? []), loc]);
}
assert.ok(groups.size >= 155, `only ${groups.size} shared groups`);
for (const [key, locations] of groups) assert.deepEqual(locations.sort(), key.split("\n").sort());
for (const slug of [
"articles",
"articles/langbot-cloud-multi-user-workspaces",
"articles/langbot-v4100-sandbox-skills",
"articles/langbot-v490-rag-plugin-architecture",
"articles/langbot-plugin-system-deep-dive",
"articles/langtars-remote-pc-control-dify-n8n",
"articles/connect-deepseek-to-wechat",
"articles/deploy-ai-bot-in-5-minutes",
"articles/dify-agent-discord-telegram-slack",
"articles/langflow-drag-and-drop-ai-bot",
"articles/n8n-multi-platform-ai-chatbot",
"articles/dify-langbot-rag-knowledge-base",
"articles/welcome",
]) assert.ok(sitemap.includes(`https://langbot.app/docs/en/${slug}`), `alternate sitemap missing ${slug}`);
});
test("generated pages contain no internal .html route links", async () => {
const offenders = [];
for (const file of await collectHtmlRoutes(publicRoot)) {
const html = await readFile(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1].replaceAll("&amp;", "&");
if (/^(?:https?:|mailto:|tel:|#|\/\/)/.test(href)) continue;
const pathname = href.split(/[?#]/, 1)[0];
if (pathname.endsWith(".html")) offenders.push([routeFromHtml(file), href]);
}
for (const match of html.matchAll(/href="(\/[^"?#]+\.html)(?:[?#][^"]*)?"/g)) offenders.push([routeFromHtml(file), match[1]]);
}
assert.deepEqual(offenders, []);
});
test("static search stays within Cloudflare Pages' 25 MiB file limit", async () => {
const search = await stat(path.join(publicRoot, "api/search"));
assert.ok(search.size <= 25 * 1024 * 1024, `search artifact is ${search.size} bytes`);
});
+7 -4
View File
@@ -74,12 +74,13 @@ test("custom robots policy is served from the Mintlify project root", async () =
assert.match(robots, /^Allow: \/_next\/$/m);
assert.match(
robots,
/^Sitemap: https:\/\/docs\.langbot\.app\/sitemap\.xml$/m,
/^Sitemap: https:\/\/langbot\.app\/docs\/sitemap\.xml$/m,
);
assert.doesNotMatch(
assert.match(
robots,
/^Sitemap: https:\/\/docs\.langbot\.app\/sitemap-alternates\.xml$/m,
/^Sitemap: https:\/\/langbot\.app\/docs\/sitemap-alternates\.xml$/m,
);
assert.doesNotMatch(robots, /https:\/\/docs\.langbot\.app/);
});
test("alternate sitemap declares reciprocal en, zh-CN, ja and x-default links", async () => {
@@ -91,6 +92,8 @@ test("alternate sitemap declares reciprocal en, zh-CN, ja and x-default links",
(match) => match[1],
);
assert.ok(blocks.length > 0);
assert.match(sitemap, /https:\/\/langbot\.app\/docs\//);
assert.doesNotMatch(sitemap, /https:\/\/docs\.langbot\.app\//);
const groups = new Map();
for (const block of blocks) {
@@ -103,7 +106,7 @@ test("alternate sitemap declares reciprocal en, zh-CN, ja and x-default links",
);
assert.ok(links.en);
assert.ok(links["zh-CN"]);
assert.equal(links["x-default"], links["zh-CN"]);
assert.equal(links["x-default"], links.en);
const reciprocalUrls = [links.en, links["zh-CN"], links.ja].filter(Boolean);
const key = reciprocalUrls.join("\n");
groups.set(key, [...(groups.get(key) ?? []), loc]);
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"types": [
"node"
]
},
"include": [
"press.config.tsx",
"vite.config.ts",
"src/**/*.ts",
"src/**/*.tsx",
".fumapress/**/*.ts",
".fumapress/**/*.tsx"
]
}
+8
View File
@@ -0,0 +1,8 @@
import tailwindcss from "@tailwindcss/vite";
import press from "fumapress/vite";
import { fumadocsMdx } from "fumadocs-mdx/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [press({ basePath: "/docs/" }), fumadocsMdx(), tailwindcss()],
});