diff --git a/README.md b/README.md
index a8bcda8ed..53b361462 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,24 @@ npm run dev
文档构建为 `dist/public` 下的静态站点,并托管在 Cloudflare Pages。部署前会运行类型检查、文档契约测试及完整静态构建。
+### Agent 可读导出
+
+`npm run build` 会在 `/docs` 基路径和站点地图处理完成后生成 Agent 文本。
+每个已发布页面都有 `.md` 副本,HTML 使用 `rel="alternate" type="text/markdown"`
+声明该地址,不改变可视界面。
+
+- `/docs/llms.txt`、`/docs/llms-full.txt`:全部语言的索引与完整正文。
+- `/docs/{en,zh,ja}/llms.txt`、`llms-full.txt`:对应语言的索引与正文。
+- 各目录的 `llm.txt` 是 `llms.txt` 的兼容别名。
+- `/docs/openapi/service-api-{en,zh,ja}.json`:完整 API 规范。
+
+生成器直接解析源 MDX 的 Markdown AST,保留提示标题和图片地址;每个 API
+操作包含请求、响应、鉴权及递归引用的 schema。遇到未支持的语义 JSX 或可执行
+表达式会中止构建,避免静默丢失正文。请修改 MDX/OpenAPI 源文件,不要编辑
+`dist/public` 中的生成结果。`npm test` 检查转换逻辑,构建后的
+`npm run test:static` 检查站点地图覆盖、HTML 发现链接、内部链接与 API 契约。
+已有完成基路径处理的构建可使用 `npm run generate:agent-docs` 单独更新文本。
+
### 一些规范化标准
- 文件夹和文件的命名:**一律使用全小写,单词直接`-`隔开,如**`plugin-intro.mdx`
diff --git a/README_EN.md b/README_EN.md
index 048cdbfb9..90372fb2c 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -32,6 +32,27 @@ Place images in the `images` directory, then reference them using the absolute p
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.
+### Agent-readable exports
+
+`npm run build` generates agent text after finalizing the `/docs` base path and
+sitemaps. Every published page has a canonical `.md` companion, advertised by an
+HTML `rel="alternate" type="text/markdown"` link without changing the visible UI.
+
+- `/docs/llms.txt` and `/docs/llms-full.txt`: all-language index and complete text.
+- `/docs/{en,zh,ja}/llms.txt` and `llms-full.txt`: language-specific exports.
+- `llm.txt` is an identical compatibility alias for each `llms.txt` index.
+- `/docs/openapi/service-api-{en,zh,ja}.json`: complete API specifications.
+
+The generator reads canonical MDX with a Markdown AST (not compiled image
+variables), preserves callout titles and literal image URLs, and exports every
+API operation with its request/response contract, security and transitive schema
+references. Unknown semantic JSX or executable expressions fail the build rather
+than silently losing content. Generated files stay in `dist/public`; edit source
+MDX/OpenAPI, not generated Markdown. `npm test` covers conversion, and
+`npm run test:static` verifies sitemap coverage, discovery, links and API contracts
+after a build. Run `npm run generate:agent-docs` to regenerate text in an existing
+finalized build.
+
### Some Standardization Guidelines
- Folder and file naming: **Use all lowercase, separate words with `-`, such as** `plugin-intro.mdx`
diff --git a/package-lock.json b/package-lock.json
index c8b69da64..af49897aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,9 +26,15 @@
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
+ "remark-gfm": "^4.0.1",
+ "remark-mdx": "^3.1.1",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
- "vite": "^8.2.2"
+ "unified": "^11.0.5",
+ "vite": "^8.2.2",
+ "yaml": "^2.9.0"
},
"engines": {
"node": ">=24"
diff --git a/package.json b/package.json
index ac80748e8..74ae513ab 100644
--- a/package.json
+++ b/package.json
@@ -11,14 +11,15 @@
"prepare:fumapress": "node scripts/prepare-fumapress.mjs",
"build": "fumapress build",
"prebuild": "npm run prepare:fumapress",
- "postbuild": "npm run finalize:base-path && npm run generate:sitemap-alternates && npm run normalize:static-paths && npm run test:static",
+ "postbuild": "npm run finalize:base-path && npm run generate:sitemap-alternates && npm run normalize:static-paths && npm run generate:agent-docs && npm run test:static",
+ "generate:agent-docs": "node scripts/generate-agent-docs.mjs",
"typecheck": "tsc --noEmit",
"normalize:static-paths": "node scripts/normalize-static-paths.mjs",
- "test:static": "node --test tests/fumapress-static-build.test.mjs",
+ "test:static": "node --test tests/fumapress-static-build.test.mjs tests/agent-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/blog-articles.test.mjs tests/fumapress-migration.test.mjs tests/seo.test.mjs && python3 -m unittest discover -s tests -p 'test_*.py'",
+ "test": "node --test tests/agent-docs.test.mjs tests/blog-articles.test.mjs tests/fumapress-migration.test.mjs tests/seo.test.mjs && python3 -m unittest discover -s tests -p 'test_*.py'",
"finalize:base-path": "node scripts/finalize-base-path.mjs"
},
"dependencies": {
@@ -42,8 +43,14 @@
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
+ "remark-gfm": "^4.0.1",
+ "remark-mdx": "^3.1.1",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
- "vite": "^8.2.2"
+ "unified": "^11.0.5",
+ "vite": "^8.2.2",
+ "yaml": "^2.9.0"
}
}
diff --git a/press.config.tsx b/press.config.tsx
index 962cad66a..1011d87f8 100644
--- a/press.config.tsx
+++ b/press.config.tsx
@@ -379,6 +379,7 @@ export default defineConfig({
const defaultLocale = alternates.includes("en") ? "en" : alternates[0];
return <>
+
{alternates.map((candidate) => (
value.split("/").map(encodeURIComponent).join("/");
+const slug = (value) => value.replace(/\s+/g, "-").toLowerCase();
+const pageRoute = (file) => file.replace(/\.mdx$/, "").replace(/\/index$/, "");
+const text = (value) => ({ type: "text", value });
+const paragraph = (children) => ({ type: "paragraph", children });
+const parser = unified().use(remarkParse).use(remarkMdx).use(remarkGfm);
+const printer = unified().use(remarkStringify, { fences: true, bullet: "-" }).use(remarkGfm);
+
+export function splitFrontmatter(source) {
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
+ if (!match) throw new Error("Documentation source requires frontmatter");
+ return { metadata: parseYaml(match[1]), body: source.slice(match[0].length) };
+}
+
+export function canonicalLink(value, document, routes, redirects = [], seen = new Set()) {
+ if (!value || value.startsWith("#")) return value;
+ let local = value;
+ const absolute = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value);
+ if (absolute) {
+ // Example URLs can contain prose punctuation; only parse our docs hosts.
+ if (!/^(?:https?:)?\/\/(?:docs\.langbot\.app|langbot\.app)(?:\/|$)/i.test(value)) return value;
+ const url = new URL(value, BASE);
+ if (url.hostname === "docs.langbot.app") local = `${url.pathname}${url.search}${url.hash}`;
+ else if (url.origin === "https://langbot.app" && (url.pathname === "/docs" || url.pathname.startsWith("/docs/"))) {
+ local = `${url.pathname.slice(5) || "/"}${url.search}${url.hash}`;
+ } else return value; // Marketing, API hosts, mailto and other external links.
+ }
+ local = local.replace(/^\/docs(?=\/|$)/, "");
+ const suffixAt = local.search(/[?#]/);
+ const suffix = suffixAt < 0 ? "" : local.slice(suffixAt);
+ let pathname = decodeURIComponent(suffixAt < 0 ? local : local.slice(0, suffixAt));
+ pathname = pathname.startsWith("/") ? pathname.slice(1) : path.posix.join(path.posix.dirname(document), pathname);
+ pathname = path.posix.normalize(pathname).replace(/^\.\//, "").replace(/\/$/, "");
+ if (pathname === "." || pathname === "" || LOCALES.includes(pathname)) pathname = `${LOCALES.includes(pathname) ? pathname : document.split("/")[0]}/insight/guide`;
+ const candidate = pathname.replace(/\.(?:html|mdx|md)$/, "").replace(/\/index$/, "");
+ if (routes.has(candidate)) return `${BASE}/${encode(candidate)}.md${suffix}`;
+ // Honor the same legacy aliases as the human site, without inventing
+ // locale fallbacks for documents that have no translation.
+ for (const redirect of redirects) {
+ const wildcard = redirect.source.match(/^(.*\/):([A-Za-z][A-Za-z0-9_]*)\*$/);
+ const matches = redirect.source === `/${candidate}` || (wildcard && `/${candidate}`.startsWith(wildcard[1]));
+ if (matches && redirect.destination !== redirect.source) {
+ if (seen.has(candidate)) throw new Error(`Redirect cycle: ${candidate}`);
+ seen.add(candidate);
+ const destination = wildcard
+ ? redirect.destination.replace(`:${wildcard[2]}*`, `/${candidate}`.slice(wildcard[1].length))
+ : redirect.destination;
+ return canonicalLink(`${destination}${suffix}`, document, routes, redirects, seen);
+ }
+ }
+ return `${BASE}/${encode(pathname)}${suffix}`;
+}
+
+export function renderMdx(source, document, routes, redirects = []) {
+ const { metadata, body } = splitFrontmatter(source);
+ const tree = parser.parse(normalizeMdxContent(body, document));
+ const link = (value) => canonicalLink(value, document, routes, redirects);
+ function transform(node) {
+ if (["mdxjsEsm", "mdxFlowExpression", "mdxTextExpression"].includes(node.type)) {
+ if (/^\s*\/\*[\s\S]*\*\/\s*$/.test(node.value)) return [];
+ // Never silently drop an executable source value that might contain docs.
+ throw new Error(`${document}: unsupported ${node.type}: ${node.value}`);
+ }
+ if (node.children) node.children = node.children.flatMap(transform);
+ if (["link", "image", "definition"].includes(node.type)) node.url = link(node.url);
+ if (!node.type.startsWith("mdxJsx")) return [node];
+ const attributes = Object.fromEntries((node.attributes ?? [])
+ .filter((attribute) => attribute.type === "mdxJsxAttribute")
+ .map((attribute) => [attribute.name, attribute.value]));
+ const children = node.children ?? [];
+ if (node.name === "img") {
+ if (typeof attributes.src !== "string") throw new Error(`${document}: image requires a literal src`);
+ const image = { type: "image", url: link(attributes.src), alt: attributes.alt ?? "", title: attributes.title ?? null };
+ return [node.type === "mdxJsxFlowElement" ? paragraph([image]) : image];
+ }
+ if (node.name === "a" && typeof attributes.href === "string") {
+ const anchor = { type: "link", url: link(attributes.href), children };
+ return [node.type === "mdxJsxFlowElement" ? paragraph([anchor]) : anchor];
+ }
+ if (node.name === "br") return [node.type === "mdxJsxFlowElement" ? paragraph([text("")]) : { type: "break" }];
+ if (node.name === "summary") return [paragraph([{ type: "strong", children }])];
+ if (node.name === "Callout") {
+ return [{ type: "blockquote", children: [paragraph([text(`${attributes.title ?? attributes.type ?? "Note"}:`)]), ...children] }];
+ }
+ if (["Card", "Step", "Tab"].includes(node.name)) {
+ if (typeof attributes.title !== "string") throw new Error(`${document}: ${node.name} requires a literal title`);
+ const title = typeof attributes.href === "string"
+ ? { type: "link", url: link(attributes.href), children: [text(attributes.title)] }
+ : { type: "strong", children: [text(attributes.title)] };
+ return [paragraph([title]), ...children];
+ }
+ // Audited source wrappers; fail closed when new semantic components appear.
+ if (["div", "details", "span", "Steps", "Tabs", "CardGroup"].includes(node.name)) return children;
+ throw new Error(`${document}: unsupported JSX component ${node.name}`);
+ }
+ tree.children = tree.children.flatMap(transform);
+ const title = metadata.title ?? pageRoute(document);
+ const url = `${BASE}/${encode(pageRoute(document))}`;
+ const description = metadata.description ? `${metadata.description}\n\n` : "";
+ return { title, description: metadata.description ?? "", url, markdown: `# ${title}\n\nSource: ${url}\n\n${description}${printer.stringify(tree)}` };
+}
+
+export function operationBundle(spec, apiPath, method) {
+ const item = spec.paths[apiPath];
+ const operation = item[method];
+ const common = Object.fromEntries(Object.entries(item).filter(([key]) => !METHODS.test(key)));
+ const bundle = {
+ openapi: spec.openapi,
+ info: spec.info,
+ servers: operation.servers ?? item.servers ?? spec.servers ?? [],
+ security: operation.security ?? spec.security ?? [],
+ paths: { [apiPath]: { ...common, [method]: operation } },
+ components: {},
+ };
+ const visited = new Set();
+ function include(ref) {
+ if (visited.has(ref)) return;
+ visited.add(ref);
+ if (!ref.startsWith("#/components/")) throw new Error(`Unsupported OpenAPI reference: ${ref}`);
+ const keys = ref.slice(2).split("/").map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~"));
+ const value = keys.reduce((node, key) => node?.[key], spec);
+ if (value === undefined) throw new Error(`Missing OpenAPI reference: ${ref}`);
+ let parent = bundle;
+ for (const key of keys.slice(0, -1)) parent = parent[key] ??= {};
+ parent[keys.at(-1)] = value;
+ visit(value);
+ }
+ function visit(value) {
+ if (!value || typeof value !== "object") return;
+ if (value.$ref) include(value.$ref);
+ for (const child of Object.values(value)) visit(child);
+ }
+ visit(bundle);
+ for (const alternative of bundle.security) for (const name of Object.keys(alternative)) {
+ include(`#/components/securitySchemes/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`);
+ }
+ return bundle;
+}
+
+export function renderOperation(spec, apiPath, method, locale) {
+ const operation = spec.paths[apiPath][method];
+ if (!operation.summary) throw new Error(`Missing summary for ${method} ${apiPath}`);
+ const route = `${locale}/api-reference/${slug(operation.tags?.[0] ?? "unknown")}/${slug(operation.summary)}`;
+ const url = `${BASE}/${encode(route)}`;
+ const bundle = operationBundle(spec, apiPath, method);
+ const auth = bundle.security.length === 0 ? "No authentication required." :
+ `Authentication (OpenAPI security alternatives; OR between entries, AND within each entry): ${JSON.stringify(bundle.security)}. See components.securitySchemes below for header names, schemes and scopes.`;
+ const markdown = `# ${operation.summary}\n\nSource: ${url}\n\n## ${method.toUpperCase()} ${apiPath}\n\n${operation.description ?? ""}\n\n${auth}\n\nServers: ${bundle.servers.map((server) => server.url).join(", ") || "Relative to your LangBot instance"}. Use your own LangBot instance, not the documentation host.\n\n[Complete ${locale} OpenAPI specification](${BASE}/openapi/service-api-${locale}.json)\n\n## Operation contract\n\nThe OpenAPI subset below preserves path/operation parameters, request bodies, response codes, media types, examples and schemas. All referenced components are included transitively; resolve local JSON pointers against this document. The operation is preserved verbatim, including security overrides.\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`\n`;
+ return { route, title: operation.summary, description: `${method.toUpperCase()} ${apiPath}`, url, markdown };
+}
+
+function indexText(pages, locale) {
+ const languages = locale ? [locale] : LOCALES;
+ const prefix = locale ? `${locale}/` : "";
+ const lines = ["# LangBot Documentation", "", "> Canonical documentation for LangBot: deployment, messaging integrations, plugins, development and HTTP API reference.", "", "## Agent resources", "", `- [Complete documentation text](${BASE}/${prefix}llms-full.txt)`, `- [All-language index](${BASE}/llms.txt)`, ""];
+ for (const language of languages) {
+ lines.push(`## ${language}`, "", `- [${language} documentation index](${BASE}/${language}/llms.txt)`, `- [${language} complete documentation text](${BASE}/${language}/llms-full.txt)`, `- [${language} OpenAPI specification](${BASE}/openapi/service-api-${language}.json)`, "");
+ for (const page of pages.filter((page) => page.route.startsWith(`${language}/`))) {
+ const label = page.title.replace(/[\[\]\\]/g, "\\$&").replace(/\s+/g, " ");
+ lines.push(`- [${label}](${page.url}.md)${page.description ? `: ${page.description.replace(/\s+/g, " ")}` : ""}`);
+ }
+ lines.push("");
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+export async function generateAgentDocs({ root = ROOT, output = path.join(root, "dist/public") } = {}) {
+ const documents = await collectMdxDocuments(root);
+ const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
+ const pages = [];
+ for (const locale of LOCALES) {
+ const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
+ for (const [apiPath, item] of Object.entries(spec.paths)) for (const method of Object.keys(item).filter((key) => METHODS.test(key))) {
+ pages.push(renderOperation(spec, apiPath, method, locale));
+ }
+ }
+ const routes = new Set([...documents.map(pageRoute), ...pages.map((page) => page.route)]);
+ for (const document of documents) {
+ pages.push({ route: pageRoute(document), ...renderMdx(await readFile(path.join(root, document), "utf8"), document, routes, docs.redirects) });
+ }
+ pages.sort((a, b) => a.route.localeCompare(b.route, "en"));
+ if (new Set(pages.map((page) => page.route)).size !== pages.length) throw new Error("Duplicate agent page routes");
+ // Use the real static sitemap as the acceptance boundary, not a guessed count.
+ const sitemap = await readFile(path.join(output, "sitemap.xml"), "utf8");
+ const actual = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((match) => decodeURIComponent(match[1].replace(/\/$/, ""))).sort();
+ const expected = pages.map((page) => decodeURIComponent(page.url)).sort();
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("Agent sources do not match the exported sitemap");
+ for (const page of pages) {
+ const target = path.join(output, `${page.route}.md`);
+ await mkdir(path.dirname(target), { recursive: true });
+ await writeFile(target, page.markdown);
+ }
+ for (const locale of [null, ...LOCALES]) {
+ const prefix = locale ? `${locale}/` : "";
+ const selected = pages.filter((page) => !locale || page.route.startsWith(prefix));
+ const index = indexText(selected, locale);
+ await writeFile(path.join(output, `${prefix}llms.txt`), index);
+ await writeFile(path.join(output, `${prefix}llm.txt`), index);
+ await writeFile(path.join(output, `${prefix}llms-full.txt`), `${selected.map((page) => page.markdown.trim()).join("\n\n---\n\n")}\n`);
+ }
+ return { pages: pages.length, documents: documents.length, operations: pages.length - documents.length };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ console.log(await generateAgentDocs());
+}
diff --git a/tests/agent-docs.test.mjs b/tests/agent-docs.test.mjs
new file mode 100644
index 000000000..0f283d1e0
--- /dev/null
+++ b/tests/agent-docs.test.mjs
@@ -0,0 +1,58 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { canonicalLink, renderMdx, operationBundle, BASE } from "../scripts/generate-agent-docs.mjs";
+import { unified } from "unified";
+import remarkParse from "remark-parse";
+
+const routes = new Set(["zh/insight/guide", "zh/usage/models/readme"]);
+const document = "zh/insight/guide.mdx";
+test("canonical links preserve external/example URLs without trying to repair them", () => {
+ for (const url of ["http://127.0.0.1:5300。", "mailto:user@example.org", "https://space.langbot.app/cloud", "https://langbot.app/zh/roadmap"]) {
+ assert.equal(canonicalLink(url, document, routes), url);
+ }
+});
+test("canonical links handle base paths, Unicode, legacy hosts, relative routes and images", () => {
+ for (const url of ["/zh/usage/models/readme", "/docs/zh/usage/models/readme", "../usage/models/readme.html", "https://docs.langbot.app/zh/usage/models/readme.html", `${BASE}/zh/usage/models/readme.md`]) {
+ assert.equal(canonicalLink(`${url}?q=1#model`, document, routes), `${BASE}/zh/usage/models/readme.md?q=1#model`);
+ }
+ assert.equal(canonicalLink("../../images/测试.png", document, routes), `${BASE}/images/%E6%B5%8B%E8%AF%95.png`);
+ assert.equal(canonicalLink("#local", document, routes), "#local");
+});
+test("MDX exports preserve prose, wrapper content, literal images and code samples", () => {
+ const source = '---\ntitle: "Test"\ndescription: "Description"\n---\n\n\nDanger **now**.\n\n\n\nMore text.\n\n\n
\n\n[Models](/zh/usage/models/readme.html)\n\n```python\nprint("/images/foo", "/zh/usage/models/readme.html", "")\n```\n';
+ const result = renderMdx(source, document, routes).markdown;
+ assert.match(result, /Danger \*\*now\*\*/);
+ assert.match(result, /\*\*More\*\*/);
+ assert.match(result, /More text/);
+ assert.ok(result.includes(``));
+ assert.ok(result.includes(`[Models](${BASE}/zh/usage/models/readme.md)`));
+ const tree = unified().use(remarkParse).parse(result);
+ assert.equal(tree.children.find((node) => node.type === "code").value, 'print("/images/foo", "/zh/usage/models/readme.html", "")');
+ assert.doesNotMatch(result, /<\/?(?:Warning|Accordion)|__img/);
+});
+test("semantic JSX preserves titles and card links; unknown components fail closed", () => {
+ const source = '---\ntitle: Test\n---\n\nRead this.\n\n\n\nConfigure models.\n\n\n\nRun setup.\n\n\n\nUse shell.\n\n';
+ const result = renderMdx(source, document, routes).markdown;
+ for (const title of ["Important", "Install", "Linux"]) assert.ok(result.includes(title));
+ assert.ok(result.includes(`[Models](${BASE}/zh/usage/models/readme.md)`));
+ assert.throws(() => renderMdx('---\ntitle: Test\n---\n', document, routes), /unsupported JSX/);
+});
+test("fixed redirect chains resolve with cycle detection", () => {
+ const redirects = [{ source: "/old", destination: "/older" }, { source: "/older", destination: "/zh/usage/models/readme" }];
+ assert.equal(canonicalLink("/old#section", document, routes, redirects), `${BASE}/zh/usage/models/readme.md#section`);
+ assert.throws(() => canonicalLink("/old", document, routes, [...redirects.slice(0, 1), { source: "/older", destination: "/old" }]), /Redirect cycle/);
+});
+test("legacy wildcard redirects resolve through multiple rules", () => {
+ const redirects = [{ source: "/deploy/:slug*", destination: "/zh/deploy/:slug*" }, { source: "/zh/deploy/:slug*", destination: "/zh/usage/:slug*" }];
+ assert.equal(canonicalLink("/deploy/models/readme#model", document, routes, redirects), `${BASE}/zh/usage/models/readme.md#model`);
+});
+test("OpenAPI closure preserves recursive refs, path parameters and security alternatives", () => {
+ const spec = { openapi: "3.0.3", info: { title: "Test", version: "1" }, security: [{ Key: [] }], components: { securitySchemes: { Key: { type: "apiKey", in: "header", name: "X-Key" } }, schemas: { Node: { type: "object", properties: { next: { $ref: "#/components/schemas/Node" } } } } }, paths: { "/items/{id}": { parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], get: { responses: { 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } } } } } } } };
+ const bundle = operationBundle(spec, "/items/{id}", "get");
+ assert.deepEqual(bundle.paths, spec.paths);
+ assert.deepEqual(bundle.components, spec.components);
+ assert.deepEqual(bundle.security, spec.security);
+ const bad = structuredClone(spec);
+ delete bad.components.schemas.Node;
+ assert.throws(() => operationBundle(bad, "/items/{id}", "get"), /Missing OpenAPI reference/);
+});
diff --git a/tests/agent-static-build.test.mjs b/tests/agent-static-build.test.mjs
new file mode 100644
index 000000000..d38caa413
--- /dev/null
+++ b/tests/agent-static-build.test.mjs
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import { readFile, stat } from "node:fs/promises";
+import path from "node:path";
+import test from "node:test";
+import { unified } from "unified";
+import remarkParse from "remark-parse";
+
+const root = path.resolve(import.meta.dirname, "..");
+const output = process.env.AGENT_TEST_OUTPUT ?? path.join(root, "dist/public");
+const base = "https://langbot.app/docs";
+const locales = ["en", "zh", "ja"];
+const read = (file) => readFile(path.join(output, file), "utf8");
+const slug = (value) => value.replace(/\s+/g, "-").toLowerCase();
+const encode = (value) => value.split("/").map(encodeURIComponent).join("/");
+const sitemap = await read("sitemap.xml");
+const urls = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1].replace(/\/$/, ""));
+const relative = (url) => decodeURIComponent(url.slice(base.length + 1));
+function links(node, result = []) {
+ if (["link", "image", "definition"].includes(node.type)) result.push(node.url);
+ for (const child of node.children ?? []) links(child, result);
+ return result;
+}
+
+test("every sitemap page has meaningful canonical Markdown and HTML discovery", async () => {
+ assert.equal(urls.length, 479);
+ for (const url of urls) {
+ const route = relative(url);
+ const text = await read(`${route}.md`);
+ assert.ok(text.length > 100, `${route}: empty Markdown`);
+ assert.ok(text.includes(url), `${route}: missing canonical URL`);
+ assert.doesNotMatch(text, /__img\d+/, route);
+ const html = await read(`${route}/index.html`);
+ assert.ok(html.includes(`rel="alternate" type="text/markdown" href="${url}.md"`), `${route}: no Markdown discovery`);
+ }
+});
+
+test("all index and full-text exports cover exactly the published pages", async () => {
+ for (const locale of [null, ...locales]) {
+ const prefix = locale ? `${locale}/` : "";
+ const index = await read(`${prefix}llms.txt`);
+ const full = await read(`${prefix}llms-full.txt`);
+ assert.equal(await read(`${prefix}llm.txt`), index);
+ const expected = urls.filter((url) => !locale || url.startsWith(`${base}/${locale}/`));
+ const actual = links(unified().use(remarkParse).parse(index)).filter((url) => url.endsWith(".md"));
+ assert.deepEqual(actual.sort(), expected.map((url) => `${url}.md`).sort());
+ for (const url of expected) assert.ok(full.includes((await read(`${relative(url)}.md`)).trim()), `${prefix}full export missing ${url}`);
+ for (const lang of locale ? [locale] : locales) assert.ok(index.includes(`${base}/openapi/service-api-${lang}.json`));
+ assert.doesNotMatch(index, /https:\/\/docs\.langbot\.app/);
+ assert.ok((await stat(path.join(output, `${prefix}llms-full.txt`))).size < 25 * 1024 * 1024);
+ }
+});
+
+test("every API operation preserves the exact OpenAPI contract and auth override", async () => {
+ let count = 0;
+ for (const locale of locales) {
+ const source = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
+ assert.deepEqual(JSON.parse(await read(`openapi/service-api-${locale}.json`)), source);
+ for (const [apiPath, pathItem] of Object.entries(source.paths)) {
+ for (const [method, operation] of Object.entries(pathItem)) {
+ if (!/^(get|put|post|delete|patch|options|head|trace)$/.test(method)) continue;
+ count++;
+ const route = `${locale}/api-reference/${slug(operation.tags?.[0] ?? "unknown")}/${slug(operation.summary)}`;
+ const text = await read(`${route}.md`);
+ assert.ok(text.includes(`${method.toUpperCase()} ${apiPath}`), route);
+ assert.ok(text.includes(`${base}/${encode(route)}`), route);
+ assert.ok(text.includes(`${base}/openapi/service-api-${locale}.json`), route);
+ const bundle = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)?.[1] ?? "null");
+ assert.ok(bundle, `${route}: missing operation bundle`);
+ assert.deepEqual(bundle.paths[apiPath][method], operation, route);
+ assert.deepEqual(bundle.security, operation.security ?? source.security ?? [], route);
+ assert.deepEqual(bundle.servers, operation.servers ?? pathItem.servers ?? source.servers ?? [], route);
+ if (bundle.security.length === 0) assert.match(text, /No authentication required/);
+ else for (const alternative of bundle.security) for (const scheme of Object.keys(alternative)) {
+ assert.deepEqual(bundle.components.securitySchemes[scheme], source.components.securitySchemes[scheme]);
+ }
+ function checkRefs(value) {
+ if (!value || typeof value !== "object") return;
+ if (value.$ref?.startsWith("#/")) {
+ const keys = value.$ref.slice(2).split("/").map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~"));
+ const resolve = (document) => keys.reduce((node, key) => node?.[key], document);
+ assert.ok(resolve(bundle), `${route}: unresolved ${value.$ref}`);
+ assert.deepEqual(resolve(bundle), resolve(source));
+ }
+ for (const child of Object.values(value)) checkRefs(child);
+ }
+ checkRefs(bundle);
+ }
+ }
+ }
+ assert.equal(count, 177);
+});
+
+test("Markdown links and images resolve inside the embedded output", async () => {
+ const failures = [];
+ for (const url of urls) {
+ const text = await read(`${relative(url)}.md`);
+ for (const link of links(unified().use(remarkParse).parse(text))) {
+ if (!link.startsWith(base) && !/^\/(?:en|zh|ja|images|docs)\//.test(link)) continue;
+ if (!link.startsWith(base)) { failures.push([url, link, "unscoped URL"]); continue; }
+ const target = new URL(link);
+ const file = decodeURIComponent(target.pathname.replace(/^\/docs\//, ""));
+ try { await stat(path.join(output, file.includes(".") ? file : `${file}/index.html`)); }
+ catch { failures.push([url, link, "missing target"]); }
+ }
+ }
+ assert.deepEqual(failures, []);
+});