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
+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("'", "'");
}
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("&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;
});
}