mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
243 lines
9.4 KiB
TypeScript
243 lines
9.4 KiB
TypeScript
import { defineMintlifyI18n, mintlifyPlugin, readMintlifyDocs } from "@fumapress/mintlify";
|
|
import { defineConfig } from "fumapress";
|
|
import { fumadocsMdx } from "fumapress/adapters/mdx";
|
|
import { flexsearchPlugin } from "fumapress/plugins/flexsearch";
|
|
import { openapiPlugin } from "fumapress/plugins/openapi";
|
|
import { robotsPlugin } from "fumapress/plugins/robots";
|
|
import { update } from "fumadocs-core/source";
|
|
import { uiTranslations } from "fumadocs-ui/i18n";
|
|
import { defineDocs } from "fumadocs-mdx/macro";
|
|
import { createOpenAPI, type OpenAPIServer } from "fumadocs-openapi/server";
|
|
import { readFileSync, 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 i18n = defineMintlifyI18n(mintlifyDocs, {
|
|
localeMap: { en: "en", cn: "zh", jp: "ja" },
|
|
});
|
|
const translations = i18n.translations()
|
|
.extend(uiTranslations())
|
|
.add({
|
|
en: { displayName: "English" },
|
|
zh: { displayName: "简体中文" },
|
|
ja: { displayName: "日本語" },
|
|
});
|
|
// Fumapress stores translations.config as its runtime i18n config. Preserve
|
|
// the Mintlify adapter's locale bridge there so navigation.languages selects
|
|
// the matching English, Chinese, or Japanese tree instead of always using en.
|
|
Object.assign(translations.config, {
|
|
_getMintlifyLanguage: (i18n as typeof i18n & {
|
|
_getMintlifyLanguage?: (locale: string) => string | undefined;
|
|
})._getMintlifyLanguage,
|
|
});
|
|
// Locale-only files must not inherit the English storage and create indexed
|
|
// pages in locales where no canonical source translation exists.
|
|
i18n.fallbackLanguage = null;
|
|
translations.config.fallbackLanguage = null;
|
|
|
|
function collectRouteLocales() {
|
|
const routes = new Map<string, Set<string>>();
|
|
function visit(directory: string, prefix: string, locale: string) {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const relative = path.posix.join(prefix, entry.name);
|
|
if (entry.isDirectory()) visit(path.join(directory, entry.name), relative, locale);
|
|
else if (entry.isFile() && entry.name.endsWith(".mdx")) {
|
|
let route = relative.slice(0, -4);
|
|
if (route === "index") route = "";
|
|
else if (route.endsWith("/index")) route = route.slice(0, -6);
|
|
const available = routes.get(route) ?? new Set<string>();
|
|
available.add(locale);
|
|
routes.set(route, available);
|
|
}
|
|
}
|
|
}
|
|
for (const locale of LOCALES) visit(path.resolve(locale), "", locale);
|
|
return routes;
|
|
}
|
|
|
|
const routeLocales = collectRouteLocales();
|
|
|
|
function localizedNavbar(lang: string | undefined) {
|
|
const locale = LOCALES.includes(lang as (typeof LOCALES)[number]) ? lang as (typeof LOCALES)[number] : "en";
|
|
const language = mintlifyDocs.navigation.languages?.find(
|
|
(entry) => entry.language === MINTLIFY_LOCALE[locale],
|
|
);
|
|
const navbar = language?.navbar ?? mintlifyDocs.navbar;
|
|
return [
|
|
...(navbar?.links ?? []).map((link) => ({ text: link.label ?? link.href, url: link.href })),
|
|
...(navbar?.primary ? [{
|
|
type: "button" as const,
|
|
text: navbar.primary.label ?? navbar.primary.href,
|
|
url: navbar.primary.href,
|
|
}] : []),
|
|
];
|
|
}
|
|
const enOpenAPI = createOpenAPI({ input: ["openapi/service-api-en.json"] });
|
|
const zhOpenAPI = createOpenAPI({ input: ["openapi/service-api-zh.json"] });
|
|
const jaOpenAPI = createOpenAPI({ input: ["openapi/service-api-ja.json"] });
|
|
|
|
function legacyOpenApiSlug(value: string) {
|
|
return value.replace(/\s+/g, "-").toLowerCase();
|
|
}
|
|
|
|
const legacyOpenApiLayout = {
|
|
groupBy: "tag" as const,
|
|
name: (entry: { info: { title: string } }) => legacyOpenApiSlug(entry.info.title),
|
|
};
|
|
|
|
function encodeRoute(route: string) {
|
|
return route.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
}
|
|
|
|
const openApiAlternates = new Map<string, Partial<Record<(typeof LOCALES)[number], string>>>();
|
|
for (const locale of LOCALES) {
|
|
const spec = JSON.parse(readFileSync(`openapi/service-api-${locale}.json`, "utf8"));
|
|
for (const [apiPath, pathItem] of Object.entries(spec.paths ?? {}) as [string, Record<string, any>][]) {
|
|
for (const [method, operation] of Object.entries(pathItem)) {
|
|
if (!/^(?:get|post|put|patch|delete|head|options)$/i.test(method) || !operation?.summary) continue;
|
|
const tag = operation.tags?.[0] ?? "unknown";
|
|
const route = encodeRoute(`api-reference/${legacyOpenApiSlug(tag)}/${legacyOpenApiSlug(operation.summary)}`);
|
|
const key = `${method.toLowerCase()} ${apiPath}`;
|
|
const alternates = openApiAlternates.get(key) ?? {};
|
|
alternates[locale] = route;
|
|
openApiAlternates.set(key, alternates);
|
|
}
|
|
}
|
|
}
|
|
const openApiAlternatesByRoute = new Map<string, Partial<Record<(typeof LOCALES)[number], string>>>();
|
|
for (const alternates of openApiAlternates.values()) {
|
|
for (const locale of LOCALES) {
|
|
const route = alternates[locale];
|
|
if (route) openApiAlternatesByRoute.set(`${locale}:${route}`, alternates);
|
|
}
|
|
}
|
|
|
|
function localizeOpenAPISource(
|
|
source: Awaited<ReturnType<OpenAPIServer["staticSource"]>>,
|
|
locale: string,
|
|
) {
|
|
return update(source)
|
|
.page((page) => ({
|
|
...page,
|
|
// Fumadocs' dot parser owns the URL locale prefix. Mark each virtual
|
|
// file with its locale and remove the same prefix from its route slugs.
|
|
path: page.path
|
|
.slice(`${locale}/`.length)
|
|
.replace(/\.mdx$/, `.${locale}.mdx`),
|
|
}))
|
|
.build();
|
|
}
|
|
|
|
const docs = defineDocs({
|
|
dir: "content/docs",
|
|
docs: {
|
|
async: true,
|
|
postprocess: { includeProcessedMarkdown: true },
|
|
},
|
|
});
|
|
|
|
export default defineConfig({
|
|
mode: "static",
|
|
site: {
|
|
baseUrl: SITE_URL,
|
|
name: "LangBot Docs",
|
|
},
|
|
content: {
|
|
docs: docs.toFumadocsSource(),
|
|
openapiEn: localizeOpenAPISource(
|
|
await enOpenAPI.staticSource({ baseDir: "en/api-reference", ...legacyOpenApiLayout }),
|
|
"en",
|
|
),
|
|
openapiZh: localizeOpenAPISource(
|
|
await zhOpenAPI.staticSource({ baseDir: "zh/api-reference", ...legacyOpenApiLayout }),
|
|
"zh",
|
|
),
|
|
openapiJa: localizeOpenAPISource(
|
|
await jaOpenAPI.staticSource({ baseDir: "ja/api-reference", ...legacyOpenApiLayout }),
|
|
"ja",
|
|
),
|
|
},
|
|
i18n,
|
|
translations,
|
|
defaultLayoutProps: ({ lang }) => ({
|
|
links: localizedNavbar(lang),
|
|
}),
|
|
meta: {
|
|
page: (page) => {
|
|
const locale = page.locale as (typeof LOCALES)[number];
|
|
const route = page.slugs.join("/");
|
|
const localizedRoutes = route.startsWith("api-reference/")
|
|
? openApiAlternatesByRoute.get(`${locale}:${route}`) ?? { [locale]: route }
|
|
: Object.fromEntries([...routeLocales.get(route) ?? new Set<string>([locale])].map((candidate) => [candidate, route]));
|
|
const alternates = LOCALES.filter((candidate) => localizedRoutes[candidate]);
|
|
const defaultLocale = alternates.includes("en") ? "en" : alternates[0];
|
|
return <>
|
|
<link rel="canonical" href={`${SITE_URL}${page.url}`} />
|
|
{alternates.map((candidate) => (
|
|
<link
|
|
key={candidate}
|
|
rel="alternate"
|
|
hrefLang={HREFLANG[candidate]}
|
|
href={`${SITE_URL}/${candidate}/${localizedRoutes[candidate]}`.replace(/\/$/, "")}
|
|
/>
|
|
))}
|
|
{defaultLocale && (
|
|
<link
|
|
rel="alternate"
|
|
hrefLang="x-default"
|
|
href={`${SITE_URL}/${defaultLocale}/${localizedRoutes[defaultLocale]}`.replace(/\/$/, "")}
|
|
/>
|
|
)}
|
|
</>;
|
|
},
|
|
},
|
|
})
|
|
.adapters(fumadocsMdx())
|
|
// One registration is intentional: its adapter and loader plugin handle all
|
|
// OpenAPI virtual pages, regardless of which content source generated them.
|
|
.plugins(
|
|
flexsearchPlugin({
|
|
async buildIndex(page) {
|
|
const title = page.data.title ?? page.path;
|
|
const description = page.data.description;
|
|
const sourceStructuredData = typeof page.data.structuredData === "function"
|
|
? await page.data.structuredData()
|
|
: page.data.structuredData;
|
|
// FlexSearch's static serialization expands multilingual documents
|
|
// considerably. Index titles, descriptions, a few headings, and the
|
|
// opening content so search remains useful without exceeding Pages'
|
|
// hard 25 MiB per-file limit. OpenAPI endpoints intentionally omit the
|
|
// schema body, which is duplicated across every generated operation.
|
|
const isOpenAPI = page.url.includes("/api-reference/");
|
|
const headings = isOpenAPI
|
|
? []
|
|
: sourceStructuredData.headings.slice(0, 4).map((heading) => ({
|
|
...heading,
|
|
content: heading.content.slice(0, 100),
|
|
}));
|
|
const openingContent = isOpenAPI
|
|
? []
|
|
: sourceStructuredData.contents.slice(0, 2).map((content) => content.content.slice(0, 300));
|
|
const structuredData = {
|
|
headings,
|
|
contents: [{
|
|
heading: undefined,
|
|
content: [title, description, ...openingContent, page.url].filter(Boolean).join(" "),
|
|
}],
|
|
};
|
|
return { id: page.url, title, description, url: page.url, structuredData };
|
|
},
|
|
}),
|
|
mintlifyPlugin({ features: { navbar: false } }),
|
|
openapiPlugin({ server: enOpenAPI }),
|
|
robotsPlugin({
|
|
rules: [{ userAgent: "*", allow: ["/", "/_next/"] }],
|
|
sitemap: true,
|
|
additionalContent: "Sitemap: https://docs.langbot.dev/sitemap-alternates.xml",
|
|
}),
|
|
);
|