mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-03 22:32:27 +08:00
feat: migrate documentation to Fumapress
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user