mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 21:35:25 +08:00
272 lines
13 KiB
JavaScript
272 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { access, readdir, readFile, stat } 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("every fixed redirect destination resolves to generated output", async () => {
|
|
const redirects = (await readFile(path.join(publicRoot, "_redirects"), "utf8"))
|
|
.trim().split("\n").map((line) => line.split(/\s+/));
|
|
for (const [source, destination] of redirects) {
|
|
if (destination.includes(":splat")) continue;
|
|
const target = destination.replace(/^\//, "").replace(/\/$/, "");
|
|
await access(path.join(publicRoot, target, "index.html"), undefined, `${source} -> ${destination}`);
|
|
}
|
|
});
|
|
|
|
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: ["system/get-system-information", "Get system information", "GET", "/api/v1/system/info"],
|
|
zh: ["系统/获取系统信息", "获取系统信息", "GET", "/api/v1/system/info"],
|
|
ja: ["system/获取系统信息", "获取系统信息", "GET", "/api/v1/system/info"],
|
|
};
|
|
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
|
|
|
|
for (const [locale, [representativePath, ...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 encodedRepresentativePath = representativePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
const representative = path.join(apiRoot, encodedRepresentativePath, "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/${encodedRepresentativePath}`));
|
|
}
|
|
});
|
|
|
|
test("canonical sitemap routes match the legacy Mintlify directory structure", async () => {
|
|
const legacy = new Set((await readFile(path.join(root, "tests/fixtures/legacy-sitemap-routes.txt"), "utf8"))
|
|
.trim().split("\n").map((route) => route.replace(/\/$/, "")));
|
|
// Mintlify accidentally indexed this repository maintenance note; preserve
|
|
// its URL as a redirect without treating it as public documentation.
|
|
legacy.delete("/scripts/README-blog-articles");
|
|
const sitemap = await readFile(path.join(publicRoot, "sitemap.xml"), "utf8");
|
|
const actual = new Set([...sitemap.matchAll(/<loc>https:\/\/docs\.langbot\.dev(\/[^<]+)<\/loc>/g)]
|
|
.map((match) => decodeURIComponent(match[1]).replace(/\/$/, "")));
|
|
assert.deepEqual([...actual].sort(), [...legacy].sort());
|
|
});
|
|
|
|
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 and documentation tree use each locale's docs.json labels", async () => {
|
|
const expected = {
|
|
en: ["Home", "https://langbot.app/en", "Roadmap", "https://langbot.app/en/roadmap", "Guides", "Quick Start", "Installation", "Developers", "Articles", "API Reference"],
|
|
zh: ["首页", "https://langbot.app/zh", "路线图", "https://langbot.app/zh/roadmap", "指南", "快速开始", "安装部署", "开发者", "文章", "API 参考"],
|
|
ja: ["ホーム", "https://langbot.app/ja", "ロードマップ", "https://langbot.app/ja/roadmap", "ガイド", "クイックスタート", "インストール", "開発者", "記事", "API リファレンス"],
|
|
};
|
|
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 languageName of ["English", "简体中文", "日本語"]) {
|
|
assert.ok(html.includes(languageName), `${locale} language selector missing ${languageName}`);
|
|
}
|
|
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 actual = Object.fromEntries(
|
|
extractHeadLinks(html, "alternate")
|
|
.filter((item) => item.hreflang)
|
|
.map((item) => [item.hreflang, item.href]),
|
|
);
|
|
if (suffix.startsWith("api-reference/")) {
|
|
assert.deepEqual(Object.keys(actual).sort(), ["en", "ja", "x-default", "zh-CN"]);
|
|
assert.equal(actual["x-default"], actual.en);
|
|
for (const href of Object.values(actual)) {
|
|
const target = new URL(href).pathname.replace(/\/$/, "");
|
|
assert.ok(routes.has(target), `${route} links to missing alternate ${target}`);
|
|
}
|
|
} else {
|
|
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;
|
|
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, []);
|
|
});
|
|
|
|
|
|
test("static search stays within Cloudflare Pages' 25 MiB file limit", async () => {
|
|
const search = await stat(path.join(publicRoot, "api/search"));
|
|
assert.ok(search.size <= 25 * 1024 * 1024, `search artifact is ${search.size} bytes`);
|
|
});
|