mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
Merge pull request #117 from langbot-app/feat/fumadocs-migration
feat: migrate documentation to Fumapress
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -10,3 +10,10 @@ node_modules/
|
||||
# Python build/test cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Deterministic Fumapress generated inputs and output
|
||||
/content/
|
||||
/public/
|
||||
/dist/
|
||||
.waku/
|
||||
.fumapress/
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+6988
File diff suppressed because it is too large
Load Diff
+40
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-8376
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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("'", "'");
|
||||
}
|
||||
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
const sitemap = await response.text();
|
||||
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) =>
|
||||
match[1].replaceAll("&", "&"),
|
||||
);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+53
@@ -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
@@ -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
|
||||
@@ -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 = [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"<img src=\"../../../images/zh/inline.png\" alt=\"inline\" />",
|
||||
"<img src=\"https://example.com/inline.png\" alt=\"external\" />",
|
||||
"[local doc](../models/readme)",
|
||||
"```markdown",
|
||||
"",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
assert.equal(
|
||||
normalizeMdxContent(source, "en/usage/platforms/example.mdx"),
|
||||
source.replace(
|
||||
"",
|
||||
"",
|
||||
).replace(
|
||||
"",
|
||||
"",
|
||||
).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 });
|
||||
}
|
||||
});
|
||||
@@ -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("&", "&"),
|
||||
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("&", "&").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="/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("&", "&");
|
||||
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
@@ -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]);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
Reference in New Issue
Block a user