feat: migrate documentation to Fumapress

This commit is contained in:
dadachann
2026-09-04 10:51:00 +00:00
parent 2e88ffb975
commit 02773bae16
18 changed files with 10739 additions and 9707 deletions
+12 -5
View File
@@ -8,14 +8,21 @@ permissions:
contents: read
jobs:
seo:
name: SEO publishing contract
docs:
name: Fumapress docs build
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Run SEO contract tests
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run documentation contract tests
run: npm test
- name: Build and verify the static site
run: npm run build
+7
View File
@@ -10,3 +10,10 @@ node_modules/
# Python build/test cache
__pycache__/
*.py[cod]
# Deterministic Fumapress generated inputs and output
/content/
/public/
/dist/
.waku/
.fumapress/
+4 -8
View File
@@ -6,12 +6,12 @@
## 参与编写
文档基于 [Mintlify](https://mintlify.com/) 生成,本地编写需要安装 Node.js。
文档使用 [Fumapress](https://press.fumadocs.dev/) 和 [Fumadocs](https://fumadocs.dev/) 生成,本地开发需要 Node.js 24 或更高版本。
Clone 本仓库,在目录下执行以下命令安装依赖:
```bash
npm install
npm ci
```
完成后即可修改文档,修改完后使用以下命令本地启动预览:
@@ -19,10 +19,6 @@ npm install
```bash
npm run dev
```
或者直接使用 `mintlify` CLI (推荐):
```bash
npx mintlify dev
```
### 使用图片
@@ -34,13 +30,13 @@ npx mintlify dev
### 部署细节
现已迁移至 Mintlify 托管,提交至 `main` 分支后自动触发部署。
文档构建为 `dist/public` 下的静态站点,并托管在 Cloudflare Pages。部署前会运行类型检查、文档契约测试及完整静态构建。
### 一些规范化标准
- 文件夹和文件的命名:**一律使用全小写,单词直接`-`隔开,如**`plugin-intro.mdx`
- 子文件(夹)的命名,**不加前缀**(即文件夹的名称),如:`deploy`文件夹下的,文件夹`langbot`,`langbot`文件夹下的`manual`文件称之为`manual.mdx`
- 文档文件格式统一使用 `.mdx` 以支持 Mintlify 组件。
- 文档文件格式统一使用 `.mdx`;构建脚本会把现有 Mintlify 兼容组件转换为 Fumadocs 可渲染格式。
- 在 `docs.json` 中配置侧边栏导航结构。
---
+4 -8
View File
@@ -6,12 +6,12 @@
## Contributing to Documentation
The documentation is generated based on [Mintlify](https://mintlify.com/). Local development requires Node.js installation.
The documentation is built with [Fumapress](https://press.fumadocs.dev/) and [Fumadocs](https://fumadocs.dev/). Local development requires Node.js 24 or later.
Clone this repository and execute the following command in the directory to install dependencies:
```bash
npm install
npm ci
```
After completion, you can modify the documentation. After modifications, use the following command to start locally:
@@ -19,10 +19,6 @@ After completion, you can modify the documentation. After modifications, use the
```bash
npm run dev
```
Or use the `mintlify` CLI directly (recommended):
```bash
npx mintlify dev
```
### Using Images
@@ -34,13 +30,13 @@ Place images in the `images` directory, then reference them using the absolute p
### Deployment Details
Now hosted on Mintlify. Commits to the `main` branch trigger automatic deployment.
The documentation is built as a static site in `dist/public` and hosted on Cloudflare Pages. Type checking, documentation contract tests, and a full static build run before deployment.
### Some Standardization Guidelines
- Folder and file naming: **Use all lowercase, separate words with `-`, such as** `plugin-intro.mdx`
- Sub-file (folder) naming: **No prefix** (i.e., the folder name), such as: in the `deploy` folder, the folder `langbot`, the `manual` file in the `langbot` folder is called `manual.mdx`
- Documentation files should use `.mdx` format to support Mintlify components.
- Documentation files should use `.mdx`; the build adapter converts existing Mintlify-compatible components into Fumadocs-renderable markup.
- Configure sidebar navigation structure in `docs.json`.
---
+6987
View File
File diff suppressed because it is too large Load Diff
+37 -3
View File
@@ -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"
}
}
-8376
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -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
View File
@@ -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
+75 -57
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env node
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const base = "https://docs.langbot.app";
const source = process.env.SITEMAP_SOURCE ?? `${base}/sitemap.xml`;
const output = new URL("../sitemap-alternates.xml", import.meta.url);
const locales = ["en", "zh", "ja"];
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const BASE = "https://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("'", "&apos;");
}
const response = await fetch(source, {
headers: { "user-agent": "LangBot docs alternate-sitemap generator" },
});
if (!response.ok) {
throw new Error(`Unable to fetch ${source}: ${response.status}`);
function decodeXml(value) {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'");
}
const sitemap = await response.text();
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) =>
match[1].replaceAll("&amp;", "&"),
);
const groups = new Map();
for (const url of urls) {
for (const locale of locales) {
const prefix = `${base}/${locale}/`;
if (!url.startsWith(prefix)) continue;
const suffix = url.slice(prefix.length);
const group = groups.get(suffix) ?? {};
group[locale] = url;
groups.set(suffix, group);
break;
}
}
const translatedGroups = [...groups.values()]
.filter((group) => group.en && group.zh)
.sort((left, right) => left.en.localeCompare(right.en));
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
];
for (const group of translatedGroups) {
const alternates = [
["en", group.en],
["zh-CN", group.zh],
...(group.ja ? [["ja", group.ja]] : []),
["x-default", group.zh],
];
for (const locale of locales) {
if (!group[locale]) continue;
lines.push(" <url>", ` <loc>${escapeXml(group[locale])}</loc>`);
for (const [language, href] of alternates) {
lines.push(
` <xhtml:link rel="alternate" hreflang="${language}" href="${escapeXml(href)}" />`,
);
export function renderAlternateSitemap(sitemap, base = BASE) {
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;
});
}
+202
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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;
}
+209
View File
@@ -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 = [
"![local](../../../images/zh/example.png)",
"![local with title](../../../images/zh/example.png \"Example\")",
"![external](https://example.com/image.png)",
"![anchor](#preview)",
"![already root](/images/zh/example.png)",
"<img src=\"../../../images/zh/inline.png\" alt=\"inline\" />",
"<img src=\"https://example.com/inline.png\" alt=\"external\" />",
"[local doc](../models/readme)",
"```markdown",
"![example](../../../images/zh/do-not-rewrite.png)",
"```",
].join("\n");
assert.equal(
normalizeMdxContent(source, "en/usage/platforms/example.mdx"),
source.replace(
"![local](../../../images/zh/example.png)",
"![local](/images/zh/example.png)",
).replace(
"![local with title](../../../images/zh/example.png \"Example\")",
"![local with title](/images/zh/example.png \"Example\")",
).replace(
"<img src=\"../../../images/zh/inline.png\" alt=\"inline\" />",
"<img src=\"/images/zh/inline.png\" alt=\"inline\" />",
),
);
});
test("MDX compatibility normalization removes internal .html route suffixes", () => {
const source = [
"[absolute](/en/deploy/settings.html)",
"[query](../settings.html?tab=bot#advanced)",
'<a href="./basic-info.html#metadata">relative</a>',
"[external](https://example.com/archive.html)",
"```markdown",
"[example](/en/keep-example.html)",
"```",
].join("\n");
assert.equal(
normalizeMdxContent(source, "en/example.mdx"),
source
.replace("/en/deploy/settings.html", "/en/deploy/settings")
.replace("../settings.html?tab=bot#advanced", "../settings?tab=bot#advanced")
.replace("./basic-info.html#metadata", "./basic-info#metadata"),
);
});
test("canonical MDX and nested OpenAPI sources are discovered", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const pages = await collectMdxDocuments(root);
assert.equal(pages.length, 302);
assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 96, zh: 110, ja: 96 });
assert.deepEqual(collectOpenApiSources(docs), [
{ locale: "en", source: "openapi/service-api-en.json", directory: "en/api-reference" },
{ locale: "zh", source: "openapi/service-api-zh.json", directory: "zh/api-reference" },
{ locale: "ja", source: "openapi/service-api-ja.json", directory: "ja/api-reference" },
]);
});
test("Cloudflare redirects preserve all Mintlify routes and add root", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const lines = renderCloudflareRedirects(docs).trim().split("\n");
assert.equal(lines.length, docs.redirects.length + 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 });
}
});
+231
View File
@@ -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("&amp;", "&"),
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("&amp;", "&");
if (/^(?:https?:|mailto:|tel:|#|\/\/)/.test(href)) continue;
const pathname = href.split(/[?#]/, 1)[0];
if (pathname.endsWith(".html")) offenders.push([routeFromHtml(file), href]);
}
for (const match of html.matchAll(/href="(\/[^"?#]+\.html)(?:[?#][^"]*)?"/g)) offenders.push([routeFromHtml(file), match[1]]);
}
assert.deepEqual(offenders, []);
});
+7 -4
View File
@@ -74,12 +74,13 @@ test("custom robots policy is served from the Mintlify project root", async () =
assert.match(robots, /^Allow: \/_next\/$/m);
assert.match(
robots,
/^Sitemap: https:\/\/docs\.langbot\.app\/sitemap\.xml$/m,
/^Sitemap: https:\/\/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]);
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"types": [
"node"
]
},
"include": [
"press.config.tsx",
"vite.config.ts",
"src/**/*.ts",
"src/**/*.tsx",
".fumapress/**/*.ts",
".fumapress/**/*.tsx"
]
}
+8
View File
@@ -0,0 +1,8 @@
import tailwindcss from "@tailwindcss/vite";
import press from "fumapress/vite";
import { fumadocsMdx } from "fumadocs-mdx/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [press(), fumadocsMdx(), tailwindcss()],
});