mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-29 11:56:02 +08:00
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`.
|
||||
|
||||
---
|
||||
|
||||
Generated
+6987
File diff suppressed because it is too large
Load Diff
+37
-3
@@ -1,12 +1,46 @@
|
||||
{
|
||||
"name": "langbot-wiki",
|
||||
"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 generate:sitemap-alternates && npm run test:static",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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'"
|
||||
},
|
||||
"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",
|
||||
"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,155 @@
|
||||
import { defineMintlifyI18n, mintlifyPlugin, readMintlifyDocs } from "@fumapress/mintlify";
|
||||
import { defineConfig } from "fumapress";
|
||||
import { fumadocsMdx } from "fumapress/adapters/mdx";
|
||||
import { openapiPlugin } from "fumapress/plugins/openapi";
|
||||
import { robotsPlugin } from "fumapress/plugins/robots";
|
||||
import { update } from "fumadocs-core/source";
|
||||
import { defineDocs } from "fumadocs-mdx/macro";
|
||||
import { createOpenAPI, type OpenAPIServer } from "fumadocs-openapi/server";
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SITE_URL = "https://docs.langbot.dev";
|
||||
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();
|
||||
const translations = defineMintlifyI18n(mintlifyDocs, {
|
||||
localeMap: { en: "en", cn: "zh", jp: "ja" },
|
||||
}).translations();
|
||||
// Locale-only files must not inherit the English storage and create indexed
|
||||
// pages in locales where no canonical source translation exists.
|
||||
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 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" }),
|
||||
"en",
|
||||
),
|
||||
openapiZh: localizeOpenAPISource(
|
||||
await zhOpenAPI.staticSource({ baseDir: "zh/api-reference" }),
|
||||
"zh",
|
||||
),
|
||||
openapiJa: localizeOpenAPISource(
|
||||
await jaOpenAPI.staticSource({ baseDir: "ja/api-reference" }),
|
||||
"ja",
|
||||
),
|
||||
},
|
||||
translations,
|
||||
defaultLayoutProps: ({ lang }) => ({
|
||||
links: localizedNavbar(lang),
|
||||
}),
|
||||
meta: {
|
||||
page: (page) => {
|
||||
const locale = page.locale as (typeof LOCALES)[number];
|
||||
const route = page.slugs.join("/");
|
||||
const available = route.startsWith("api-reference/")
|
||||
? new Set<string>(LOCALES)
|
||||
: routeLocales.get(route) ?? new Set<string>([locale]);
|
||||
const alternates = LOCALES.filter((candidate) => available.has(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}/${route}`.replace(/\/$/, "")}
|
||||
/>
|
||||
))}
|
||||
{defaultLocale && (
|
||||
<link
|
||||
rel="alternate"
|
||||
hrefLang="x-default"
|
||||
href={`${SITE_URL}/${defaultLocale}/${route}`.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(
|
||||
mintlifyPlugin({ features: { navbar: false } }),
|
||||
openapiPlugin({ server: enOpenAPI }),
|
||||
robotsPlugin({
|
||||
rules: [{ userAgent: "*", allow: ["/", "/_next/"] }],
|
||||
sitemap: true,
|
||||
additionalContent: "Sitemap: https://docs.langbot.dev/sitemap-alternates.xml",
|
||||
}),
|
||||
);
|
||||
+2
-1
@@ -2,4 +2,5 @@ User-agent: *
|
||||
Allow: /
|
||||
Allow: /_next/
|
||||
|
||||
Sitemap: https://docs.langbot.app/sitemap.xml
|
||||
Sitemap: https://docs.langbot.dev/sitemap.xml
|
||||
Sitemap: https://docs.langbot.dev/sitemap-alternates.xml
|
||||
|
||||
@@ -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://docs.langbot.dev";
|
||||
const LOCALES = ["en", "zh", "ja"];
|
||||
const HREFLANG = { en: "en", zh: "zh-CN", ja: "ja" };
|
||||
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
@@ -16,60 +18,76 @@ 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) {
|
||||
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}`,
|
||||
);
|
||||
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 };
|
||||
}
|
||||
|
||||
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 { xml, groups } = renderAlternateSitemap(await loadSource(source));
|
||||
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,202 @@
|
||||
#!/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));
|
||||
}
|
||||
|
||||
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"];
|
||||
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 });
|
||||
|
||||
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) + 1,
|
||||
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
+13
@@ -0,0 +1,13 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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,
|
||||
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("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 + 1);
|
||||
assert.equal(lines[0], "/ /en/insight/guide 302");
|
||||
assert.ok(lines.includes("/README_EN /en/insight/guide 308"));
|
||||
assert.ok(lines.some((line) => line.startsWith("/en/deploy/platforms/* /en/usage/platforms/:splat ")));
|
||||
});
|
||||
|
||||
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, /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, 102);
|
||||
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,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readdir, readFile } 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");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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("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:\/\/docs\.langbot\.dev\//);
|
||||
assert.doesNotMatch(sitemap, /https:\/\/docs\.langbot\.app\//);
|
||||
assert.match(robots, /https:\/\/docs\.langbot\.dev\/sitemap\.xml/);
|
||||
assert.doesNotMatch(robots, /https:\/\/docs\.langbot\.app\//);
|
||||
});
|
||||
|
||||
test("all locales emit the OpenAPI surface with endpoint semantics", async () => {
|
||||
const representatives = {
|
||||
en: ["Get system information", "GET", "/api/v1/system/info"],
|
||||
zh: ["获取系统信息", "GET", "/api/v1/system/info"],
|
||||
ja: ["获取系统信息", "GET", "/api/v1/system/info"],
|
||||
};
|
||||
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
|
||||
|
||||
for (const [locale, 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 representative = path.join(apiRoot, "api/v1/system/info/get/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://docs.langbot.dev/${locale}/api-reference/api/v1/system/info/get`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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 uses each locale's docs.json labels and hrefs", async () => {
|
||||
const expected = {
|
||||
en: ["Home", "https://langbot.app/en", "Roadmap", "https://langbot.app/en/roadmap"],
|
||||
zh: ["首页", "https://langbot.app/zh", "路线图", "https://langbot.app/zh/roadmap"],
|
||||
ja: ["ホーム", "https://langbot.app/ja", "ロードマップ", "https://langbot.app/ja/roadmap"],
|
||||
};
|
||||
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 other of locales.filter((item) => item !== locale)) {
|
||||
assert.ok(!html.includes(expected[other][1]), `${locale} navbar includes ${other} home link`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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:\/\/docs\.langbot\.dev(\/[^<]+)<\/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://docs.langbot.dev${route}`, hreflang: undefined }], route);
|
||||
|
||||
const [, locale, ...suffixParts] = route.split("/");
|
||||
const suffix = suffixParts.join("/");
|
||||
const expected = {};
|
||||
for (const candidate of locales) {
|
||||
const candidateRoute = `/${candidate}/${suffix}`.replace(/\/$/, "");
|
||||
if (routes.has(candidateRoute)) expected[hreflangByLocale[candidate]] = `https://docs.langbot.dev${candidateRoute}`;
|
||||
}
|
||||
expected["x-default"] = expected.en ?? expected["zh-CN"] ?? expected.ja;
|
||||
const actual = Object.fromEntries(
|
||||
extractHeadLinks(html, "alternate")
|
||||
.filter((item) => item.hreflang)
|
||||
.map((item) => [item.hreflang, item.href]),
|
||||
);
|
||||
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:\/\/docs\.langbot\.dev\/sitemap\.xml$/m);
|
||||
assert.match(robots, /^Sitemap: https:\/\/docs\.langbot\.dev\/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://docs.langbot.dev/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, []);
|
||||
});
|
||||
+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:\/\/docs\.langbot\.dev\/sitemap\.xml$/m,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
robots,
|
||||
/^Sitemap: https:\/\/docs\.langbot\.app\/sitemap-alternates\.xml$/m,
|
||||
/^Sitemap: https:\/\/docs\.langbot\.dev\/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:\/\/docs\.langbot\.dev\//);
|
||||
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(), fumadocsMdx(), tailwindcss()],
|
||||
});
|
||||
Reference in New Issue
Block a user