mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 19:35:27 +08:00
feat: prepare docs for langbot.app subpath
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user