feat: prepare docs for langbot.app subpath

This commit is contained in:
dadachann
2026-09-04 15:39:04 +00:00
parent dfcec6b7db
commit d553369332
10 changed files with 2440 additions and 2357 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "langbot-wiki",
"name": "langbot-docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langbot-wiki",
"name": "langbot-docs",
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fumapress/mintlify": "^1.1.2",
+4 -3
View File
@@ -1,5 +1,5 @@
{
"name": "langbot-wiki",
"name": "langbot-docs",
"private": true,
"type": "module",
"engines": {
@@ -11,14 +11,15 @@
"prepare:fumapress": "node scripts/prepare-fumapress.mjs",
"build": "fumapress build",
"prebuild": "npm run prepare:fumapress",
"postbuild": "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 test:static",
"typecheck": "tsc --noEmit",
"normalize:static-paths": "node scripts/normalize-static-paths.mjs",
"test:static": "node --test tests/fumapress-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/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": {
"@base-ui/react": "^1.8.0",
+2 -2
View File
@@ -13,7 +13,7 @@ import { Bot } from "lucide-react";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const SITE_URL = "https://docs.langbot.dev";
const SITE_URL = "https://langbot.app/docs";
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;
@@ -405,6 +405,6 @@ export default defineConfig({
robotsPlugin({
rules: [{ userAgent: "*", allow: ["/", "/_next/"] }],
sitemap: true,
additionalContent: "Sitemap: https://docs.langbot.dev/sitemap-alternates.xml",
additionalContent: "Sitemap: https://langbot.app/docs/sitemap-alternates.xml",
}),
);
+3 -3
View File
@@ -1,6 +1,6 @@
User-agent: *
Allow: /
Allow: /docs/
Allow: /_next/
Sitemap: https://docs.langbot.dev/sitemap.xml
Sitemap: https://docs.langbot.dev/sitemap-alternates.xml
Sitemap: https://langbot.app/docs/sitemap.xml
Sitemap: https://langbot.app/docs/sitemap-alternates.xml
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
import { readFile, readdir, 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 PUBLIC_ROOT = path.join(ROOT, "dist/public");
const ORIGIN = "https://langbot.app";
const BASE_PATH = "/docs";
const TEXT_EXTENSIONS = new Set([".html", ".md", ".txt", ".xml", ".json", ".js", ".css"]);
async function collectTextFiles(directory) {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...await collectTextFiles(target));
else if (TEXT_EXTENSIONS.has(path.extname(entry.name)) || target.endsWith("/api/search")) files.push(target);
}
return files;
}
function prefixPublicAssets(content) {
return content
.replaceAll('"/images/', `"${BASE_PATH}/images/`)
.replaceAll("'/images/", `'${BASE_PATH}/images/`)
.replaceAll('"/favicon.ico"', `"${BASE_PATH}/favicon.ico"`)
.replaceAll("'/favicon.ico'", `'${BASE_PATH}/favicon.ico'`)
.replaceAll('"/rss.xml"', `"${BASE_PATH}/rss.xml"`)
.replaceAll("'/rss.xml'", `'${BASE_PATH}/rss.xml'`);
}
export async function finalizeBasePath(publicRoot = PUBLIC_ROOT) {
const sitemapPath = path.join(publicRoot, "sitemap.xml");
const originalSitemap = await readFile(sitemapPath, "utf8");
const sourceUrls = [...originalSitemap.matchAll(/<loc>(https:\/\/langbot\.app\/[^<]+)<\/loc>/g)]
.map((match) => match[1].replace(/\/$/, ""));
const replacements = sourceUrls
.map((source) => [source, `${ORIGIN}${BASE_PATH}${source.slice(ORIGIN.length)}`])
.sort((left, right) => right[0].length - left[0].length);
let changed = 0;
for (const file of await collectTextFiles(publicRoot)) {
let content = await readFile(file, "utf8");
const before = content;
for (const [source, destination] of replacements) content = content.replaceAll(source, destination);
content = content
.replaceAll(`${ORIGIN}/sitemap.xml`, `${ORIGIN}${BASE_PATH}/sitemap.xml`)
.replaceAll(`${ORIGIN}/sitemap-alternates.xml`, `${ORIGIN}${BASE_PATH}/sitemap-alternates.xml`);
content = prefixPublicAssets(content);
if (content !== before) {
await writeFile(file, content);
changed += 1;
}
}
const redirectsPath = path.join(publicRoot, "_redirects");
const redirects = await readFile(redirectsPath, "utf8");
const scopedRedirects = redirects
.split("\n")
.map((line) => {
if (!line.trim()) return line;
const [source, destination, status, ...rest] = line.trim().split(/\s+/);
const scope = (value) => value.startsWith("/") ? `${BASE_PATH}${value === "/" ? "" : value}` : value;
return [scope(source), scope(destination), status, ...rest].filter(Boolean).join(" ");
})
.join("\n");
await writeFile(redirectsPath, scopedRedirects);
return { changed, routes: replacements.length };
}
async function main() {
const result = await finalizeBasePath();
console.log(`Finalized ${result.routes} documentation routes under ${BASE_PATH} across ${result.changed} files`);
}
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;
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const BASE = "https://docs.langbot.dev";
const BASE = "https://langbot.app/docs";
const LOCALES = ["en", "zh", "ja"];
const HREFLANG = { en: "en", zh: "zh-CN", ja: "ja" };
+2325 -2325
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -38,7 +38,7 @@ function staticHtmlForCanonicalDocument(document) {
}
function staticHtmlForUrlPath(urlPath) {
const parts = urlPath.replace(/^\//, "").replace(/\/$/, "").split("/")
const parts = urlPath.replace(/^\/docs(?:\/|$)/, "").replace(/^\//, "").replace(/\/$/, "").split("/")
.map((segment) => decodeURIComponent(segment));
return path.join(publicRoot, ...parts, "index.html");
}
@@ -90,9 +90,9 @@ test("representative localized guides and canonical host are emitted", async ()
}
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.match(sitemap, /https:\/\/langbot\.app\/docs\//);
assert.doesNotMatch(sitemap, /https:\/\/docs\.langbot\.app\//);
assert.match(robots, /https:\/\/docs\.langbot\.dev\/sitemap\.xml/);
assert.match(robots, /https:\/\/langbot\.app\/docs\/sitemap\.xml/);
assert.doesNotMatch(robots, /https:\/\/docs\.langbot\.app\//);
});
@@ -107,7 +107,7 @@ test("platform navigation renders logo assets instead of icon path text", async
const sidebar = html.slice(asideStart, asideEnd);
assert.match(
sidebar,
/<img[^>]+src="\/images\/platforms\/discord\.svg"[^>]*>/,
/<img[^>]+src="\/docs\/images\/platforms\/discord\.svg"[^>]*>/,
`${locale} sidebar is missing the Discord logo`,
);
assert.match(sidebar, />Discord</, `${locale} sidebar is missing the platform title`);
@@ -137,7 +137,7 @@ test("nested bot groups preserve the logos declared in docs.json", async () => {
const escapedLogo = logo.replace(".", "\\.");
assert.match(
sidebar,
new RegExp(`<img[^>]+src="/images/platforms/${escapedLogo}"[^>]*>\\s*${escapedTitle}<svg`),
new RegExp(`<img[^>]+src="/docs/images/platforms/${escapedLogo}"[^>]*>\\s*${escapedTitle}<svg`),
`zh sidebar is missing the ${logo} logo for ${title}`,
);
}
@@ -170,7 +170,7 @@ test("all locales emit the OpenAPI surface with endpoint semantics", async () =>
const representative = path.join(apiRoot, representativePath, "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}`));
assert.ok(sitemap.includes(`https://langbot.app/docs/${locale}/api-reference/${encodedRepresentativePath}`));
const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
const tagOrder = [];
@@ -200,7 +200,7 @@ test("canonical sitemap routes match the legacy Mintlify directory structure", a
// 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)]
const actual = new Set([...sitemap.matchAll(/<loc>https:\/\/langbot\.app\/docs(\/[^<]+)<\/loc>/g)]
.map((match) => decodeURIComponent(match[1]).replace(/\/$/, "")));
assert.deepEqual([...actual].sort(), [...legacy].sort());
});
@@ -321,7 +321,7 @@ test("every legacy navigation entry resolves to its exact sidebar URL", async ()
const asideStart = html.indexOf('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
if (!sidebar.includes(`href="/${route}"`)) failures.push({ page, htmlPath });
if (!sidebar.includes(`href="/docs/${route}"`)) failures.push({ page, htmlPath });
}
}
assert.deepEqual(failures, []);
@@ -332,8 +332,8 @@ test("the Chinese quick-start navigation keeps the incumbent troubleshooting pag
const asideStart = html.indexOf('<aside id="nd-sidebar"');
const asideEnd = html.indexOf("</aside>", asideStart);
const sidebar = html.slice(asideStart, asideEnd);
assert.match(sidebar, /href="\/zh\/insight\/troubleshooting"/);
assert.doesNotMatch(sidebar, /href="\/zh\/develop\/adapter\/discord\/troubleshooting"/);
assert.match(sidebar, /href="\/docs\/zh\/insight\/troubleshooting"/);
assert.doesNotMatch(sidebar, /href="\/docs\/zh\/develop\/adapter\/discord\/troubleshooting"/);
});
test("the exact 14 zh-only sources publish no fallback en or ja routes", async () => {
@@ -366,14 +366,14 @@ test("the exact 14 zh-only sources publish no fallback en or ja routes", async (
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 routes = new Set([...sitemap.matchAll(/<loc>https:\/\/langbot\.app\/docs(\/[^<]+)<\/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);
assert.deepEqual(canonical, [{ href: `https://langbot.app/docs${route}`, hreflang: undefined }], route);
const [, locale, ...suffixParts] = route.split("/");
const suffix = suffixParts.join("/");
@@ -386,14 +386,14 @@ test("every page emits a canonical and reciprocal available hreflangs", async ()
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(/\/$/, "");
const target = new URL(href).pathname.replace(/^\/docs/, "").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}`;
if (routes.has(candidateRoute)) expected[hreflangByLocale[candidate]] = `https://langbot.app/docs${candidateRoute}`;
}
expected["x-default"] = expected.en ?? expected["zh-CN"] ?? expected.ja;
assert.deepEqual(actual, expected, route);
@@ -404,8 +404,8 @@ test("every page emits a canonical and reciprocal available hreflangs", async ()
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);
assert.match(robots, /^Sitemap: https:\/\/langbot\.app\/docs\/sitemap\.xml$/m);
assert.match(robots, /^Sitemap: https:\/\/langbot\.app\/docs\/sitemap-alternates\.xml$/m);
const blocks = [...sitemap.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((match) => match[1]);
const groups = new Map();
@@ -432,7 +432,7 @@ test("alternate sitemap is complete, reciprocal, and advertised", async () => {
"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}`);
]) assert.ok(sitemap.includes(`https://langbot.app/docs/en/${slug}`), `alternate sitemap missing ${slug}`);
});
test("generated pages contain no internal .html route links", async () => {
+3 -3
View File
@@ -74,11 +74,11 @@ test("custom robots policy is served from the Mintlify project root", async () =
assert.match(robots, /^Allow: \/_next\/$/m);
assert.match(
robots,
/^Sitemap: https:\/\/docs\.langbot\.dev\/sitemap\.xml$/m,
/^Sitemap: https:\/\/langbot\.app\/docs\/sitemap\.xml$/m,
);
assert.match(
robots,
/^Sitemap: https:\/\/docs\.langbot\.dev\/sitemap-alternates\.xml$/m,
/^Sitemap: https:\/\/langbot\.app\/docs\/sitemap-alternates\.xml$/m,
);
assert.doesNotMatch(robots, /https:\/\/docs\.langbot\.app/);
});
@@ -92,7 +92,7 @@ test("alternate sitemap declares reciprocal en, zh-CN, ja and x-default links",
(match) => match[1],
);
assert.ok(blocks.length > 0);
assert.match(sitemap, /https:\/\/docs\.langbot\.dev\//);
assert.match(sitemap, /https:\/\/langbot\.app\/docs\//);
assert.doesNotMatch(sitemap, /https:\/\/docs\.langbot\.app\//);
const groups = new Map();
+1 -1
View File
@@ -4,5 +4,5 @@ import { fumadocsMdx } from "fumadocs-mdx/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [press(), fumadocsMdx(), tailwindcss()],
plugins: [press({ basePath: "/docs/" }), fumadocsMdx(), tailwindcss()],
});