From a2f3d2e03caa41dfc59ecb6e405c733a98e2246b Mon Sep 17 00:00:00 2001 From: Chin Rock Date: Tue, 27 Oct 2026 19:03:03 +0800 Subject: [PATCH 1/2] fix(docs): preserve local articles during Blog synchronization Co-authored-by: multica-agent --- docs/scripts/sync-blog-articles.mjs | 34 ++++++++++++++++++++++ docs/tests/blog-articles.test.mjs | 15 ++++++++++ docs/tests/fumapress-migration.test.mjs | 10 +++++-- docs/tests/fumapress-static-build.test.mjs | 6 +++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/docs/scripts/sync-blog-articles.mjs b/docs/scripts/sync-blog-articles.mjs index 7a13deaba..a3c90d024 100755 --- a/docs/scripts/sync-blog-articles.mjs +++ b/docs/scripts/sync-blog-articles.mjs @@ -9,6 +9,12 @@ import process from "node:process"; const ROOT = path.resolve(import.meta.dirname, ".."); const ARTICLE_LOCALES = ["en", "zh", "ja"]; +// These documentation-only articles are not owned by the Blog synchronizer. +const LOCAL_ARTICLES = { + en: [], + zh: ["langbot-telegram-sandbox-agent", "langbot-wechat-official-account", "langbot-wecom-customer-service-knowledge-base"], + ja: [], +}; const MAX_IMAGE_BYTES = 25 * 1024 * 1024; const LANGUAGE_CONFIG = { en: { mintlify: "en", tab: "Articles", introTitle: "LangBot Articles", introDescription: "Product updates, engineering deep dives, tutorials, integrations, and announcements from the LangBot Blog.", sourceLabel: "This article is synchronized from the LangBot Blog.", sourceAction: "Read the canonical version", fallbackLabel: "", overviewGroup: "Browse", groups: { updates: "Product Updates", engineering: "Engineering", tutorials: "Tutorials & Integrations", announcements: "Announcements" } }, @@ -254,6 +260,7 @@ function navigationTab(locale, posts) { const groups = [{ group: config.overviewGroup, pages: [`${locale}/articles/index`] }]; for (const key of ["updates", "engineering", "tutorials", "announcements"]) { const pages = posts.filter((post) => post.group === key).map((post) => articlePage(locale, post.slug)); + if (key === "tutorials") pages.push(...LOCAL_ARTICLES[locale].map((slug) => articlePage(locale, slug))); if (pages.length) groups.push({ group: config.groups[key], pages }); } return { tab: config.tab, groups }; @@ -282,6 +289,13 @@ async function validateGenerated(manifest) { } } } + for (const locale of ARTICLE_LOCALES) { + for (const slug of LOCAL_ARTICLES[locale]) { + const file = path.join(ROOT, locale, "articles", `${slug}.mdx`); + await readRegularFileWithin(path.join(ROOT, locale, "articles"), file, "local article", "utf8"); + } + for (const image of await referencedImagesIn(path.join(ROOT, locale, "articles"))) referencedImages.add(image); + } const mirroredMetadata = await imageMetadata(ROOT); const mirroredImages = new Set(mirroredMetadata.map((image) => image.path)); for (const image of mirroredImages) if (!referencedImages.has(image)) failures.push(`unreferenced mirrored image: ${image}`); @@ -340,6 +354,9 @@ async function main() { if (!args.source) throw new Error("Pass --source /path/to/langbot-landing-page or set LANGBOT_LANDING_REPO"); if (!existsSync(path.join(args.source, "src/content/blog"))) throw new Error(`Not a LangBot landing repository: ${args.source}`); const { byLocale, slugs } = await loadPosts(args.source); + for (const slug of Object.values(LOCAL_ARTICLES).flat()) { + if (slugs.includes(slug)) throw new Error(`Blog slug collides with a local article: ${slug}`); + } const outputRoot = await mkdtemp(path.join(tmpdir(), "langbot-docs-article-sync-")); const assetRoot = args.check ? outputRoot : ROOT; const imageCache = new Map(); @@ -362,6 +379,23 @@ async function main() { localePosts.set(locale, renderedPosts); await writeFile(path.join(outputRoot, locale, "articles", "index.mdx"), renderIndex(locale, renderedPosts)); } + // Preserve local articles and their assets before replacing generated directories. + for (const locale of ARTICLE_LOCALES) { + for (const slug of LOCAL_ARTICLES[locale]) { + const source = path.join(ROOT, locale, "articles", `${slug}.mdx`); + const content = await readRegularFileWithin(path.join(ROOT, locale, "articles"), source, "local article", "utf8"); + await writeFile(path.join(outputRoot, locale, "articles", `${slug}.mdx`), content); + if (args.check) { + for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) { + const image = path.join(ROOT, match[1].slice(1)); + const bytes = await readRegularFileWithin(path.join(ROOT, "images/articles"), image, "local article image"); + const target = path.join(outputRoot, match[1].slice(1)); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, bytes); + } + } + } + } if (!args.check) { const referencedImages = await referencedImagesIn(outputRoot); for (const file of await listFiles(path.join(ROOT, "images/articles"))) { diff --git a/docs/tests/blog-articles.test.mjs b/docs/tests/blog-articles.test.mjs index 9aa7ead4a..3fe853c4a 100644 --- a/docs/tests/blog-articles.test.mjs +++ b/docs/tests/blog-articles.test.mjs @@ -72,3 +72,18 @@ test("generated articles use only non-empty Wiki-local images", () => { } } }); + +test("documentation-only articles and their images survive Blog synchronization", () => { + for (const pages of Object.values(extraArticlePages)) { + for (const page of pages) { + const content = readFileSync(path.join(root, `${page}.mdx`), "utf8"); + assert.ok(content.length > 100, `${page} is empty`); + for (const [, url] of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) { + const bytes = readFileSync(path.join(root, url.slice(1))); + const image = manifest.images.find((item) => item.path === url); + assert.ok(image, `${page}: ${url} missing from image manifest`); + assert.equal(createHash("sha256").update(bytes).digest("hex"), image.sha256); + } + } + } +}); diff --git a/docs/tests/fumapress-migration.test.mjs b/docs/tests/fumapress-migration.test.mjs index e10cbb516..ca922eedc 100644 --- a/docs/tests/fumapress-migration.test.mjs +++ b/docs/tests/fumapress-migration.test.mjs @@ -14,6 +14,10 @@ import { } from "../scripts/prepare-fumapress.mjs"; const root = path.resolve(import.meta.dirname, ".."); +const articleManifest = JSON.parse(await readFile(path.join(root, "scripts/blog-articles-manifest.json"), "utf8")); +// The fixed documentation baseline excludes synchronized Blog pages. +const expectedDocuments = 299 + articleManifest.articles.length; + test("Mintlify locales are mapped to public URL locales", () => { assert.equal(mapMintlifyLocale("en"), "en"); @@ -170,8 +174,8 @@ test("MDX compatibility normalization removes internal .html route suffixes", () test("canonical MDX and nested OpenAPI sources are discovered", async () => { const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8")); const pages = await collectMdxDocuments(root); - assert.equal(pages.length, 335); - assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 106, zh: 123, ja: 106 }); + assert.equal(pages.length, expectedDocuments); + assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), Object.fromEntries(Object.entries({ en: 94, zh: 111, ja: 94 }).map(([locale, count]) => [locale, count + articleManifest.articles.filter((article) => article.locale === locale).length]))); assert.deepEqual(collectOpenApiSources(docs), [ { locale: "en", source: "openapi/service-api-en.json", directory: "en/api-reference" }, { locale: "zh", source: "openapi/service-api-zh.json", directory: "zh/api-reference" }, @@ -216,7 +220,7 @@ test("prebuild is deterministic and preserves local assets and SEO files", async const first = await prepareFumapress({ root, outRoot: temp }); const second = await prepareFumapress({ root, outRoot: temp }); assert.deepEqual(first, second); - assert.equal(first.documents, 335); + assert.equal(first.documents, expectedDocuments); assert.equal(first.fallbackDefaults, 0); assert.equal(first.localeOnlyDocuments, 17); assert.equal(first.redirects, 103); diff --git a/docs/tests/fumapress-static-build.test.mjs b/docs/tests/fumapress-static-build.test.mjs index b1060887f..3d5dd7b1d 100644 --- a/docs/tests/fumapress-static-build.test.mjs +++ b/docs/tests/fumapress-static-build.test.mjs @@ -6,6 +6,10 @@ import { collectMdxDocuments } from "../scripts/prepare-fumapress.mjs"; import { zhOnlyArticleRoutes } from "./article-routes.mjs"; const root = path.resolve(import.meta.dirname, ".."); +const articleManifest = JSON.parse(await readFile(path.join(root, "scripts/blog-articles-manifest.json"), "utf8")); +// The fixed documentation baseline excludes synchronized Blog pages. +const expectedDocuments = 299 + articleManifest.articles.length; + const publicRoot = path.join(root, "dist/public"); test("the documentation keeps a neutral base with custom section and TOC treatments", async () => { @@ -46,7 +50,7 @@ function staticHtmlForUrlPath(urlPath) { test("all canonical localized documents have static HTML", async () => { const documents = await collectMdxDocuments(root); - assert.equal(documents.length, 335); + assert.equal(documents.length, expectedDocuments); const missing = []; for (const document of documents) { try { From 07e49364046b61e17732776f3283f591c543466a Mon Sep 17 00:00:00 2001 From: Chin Rock Date: Tue, 27 Oct 2026 19:12:41 +0800 Subject: [PATCH 2/2] fix(docs): preserve canonical Blog links and validate changing page counts Co-authored-by: multica-agent --- docs/scripts/blog-articles-manifest.json | 30 ++++++++++++++++++++++++ docs/scripts/sync-blog-articles.mjs | 2 ++ docs/tests/agent-static-build.test.mjs | 3 ++- docs/tests/blog-articles.test.mjs | 1 + docs/tests/locale-static-build.test.mjs | 5 ++-- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/scripts/blog-articles-manifest.json b/docs/scripts/blog-articles-manifest.json index db0513f9a..252d9521e 100644 --- a/docs/scripts/blog-articles-manifest.json +++ b/docs/scripts/blog-articles-manifest.json @@ -445,6 +445,11 @@ "sha256": "1e2ee21c697ab9c2320094874889e2ac0bd2fc44efb529eafa4d895393ca5691", "bytes": 161907 }, + { + "path": "/images/articles/langbot-wechat-official-account-qr.png", + "sha256": "233aede79ed71fd721f1f77c839d726b9dd8ed667f5e951d994c19f962c6bff5", + "bytes": 14931 + }, { "path": "/images/articles/source/images/article-mcp/01-init-page.png", "sha256": "b392e42d582c8c1723a2eb63596801b9d459df0465377246d9a4015edcae1ca0", @@ -589,6 +594,31 @@ "path": "/images/articles/source/images/sandbox-skills/skills.png", "sha256": "6ad86d972c7438fbef898d9d339f3f17a30277bf1626e8a9dd11b0b46583c817", "bytes": 362173 + }, + { + "path": "/images/articles/telegram-sandbox-qr.png", + "sha256": "8b4ead3dacb357fd28b650d2a6761b265effdeecc8e6a3f1f9feed10122da04b", + "bytes": 5583 + }, + { + "path": "/images/articles/telegram-sandbox.jpg", + "sha256": "0dab8f9c9c6846f8ac3b8848d118bca3528868254a7249d0d26772175eabae84", + "bytes": 349163 + }, + { + "path": "/images/articles/wecom-customer-service-qr.png", + "sha256": "3d2c2be11af63528a1b09871157ba58b4f3b64d7ddcbdf4420b99ecd41984c3b", + "bytes": 202053 + }, + { + "path": "/images/articles/wecom-customer-service.jpg", + "sha256": "2b94049e6265ffb1ac67cde661c596780d7fcfaeae46517ebd82f4dbfe3a1e19", + "bytes": 310163 + }, + { + "path": "/images/articles/wecom-knowledge-base-config.png", + "sha256": "0e4b12cec9bccf7c8a4958e9a53d312707ad818ac4376b707a20e8793af8f8be", + "bytes": 112097 } ] } diff --git a/docs/scripts/sync-blog-articles.mjs b/docs/scripts/sync-blog-articles.mjs index a3c90d024..76c1f7f29 100755 --- a/docs/scripts/sync-blog-articles.mjs +++ b/docs/scripts/sync-blog-articles.mjs @@ -231,6 +231,8 @@ async function renderArticle(sourceRoot, post, locale, fallback, assetRoot, imag const config = LANGUAGE_CONFIG[locale]; const title = locale === "ja" && fallback ? (JAPANESE_TITLES[post.slug] || post.data.title) : post.data.title; let body = post.body; + // Root-relative Blog links belong to the landing site, not the embedded docs. + body = body.replace(/(\]\()\/(en|zh|ja)\/blog\//g, "$1https://langbot.app/$2/blog/"); const refs = imageReferences(body); for (const ref of refs) if (!imageCache.has(ref.url)) imageCache.set(ref.url, await materializeImage(sourceRoot, ref.url, assetRoot)); if (post.data.cover?.image && !imageCache.has(post.data.cover.image)) imageCache.set(post.data.cover.image, await materializeImage(sourceRoot, post.data.cover.image, assetRoot)); diff --git a/docs/tests/agent-static-build.test.mjs b/docs/tests/agent-static-build.test.mjs index a03bf5ee1..c477e5971 100644 --- a/docs/tests/agent-static-build.test.mjs +++ b/docs/tests/agent-static-build.test.mjs @@ -23,7 +23,8 @@ function links(node, result = []) { } test("every sitemap page has meaningful canonical Markdown and HTML discovery", async () => { - assert.equal(urls.length, 509 + zhOnlyArticleRoutes.length); + const manifest = JSON.parse(await readFile(path.join(root, "scripts/blog-articles-manifest.json"), "utf8")); + assert.equal(urls.length, 473 + zhOnlyArticleRoutes.length + manifest.articles.length); for (const url of urls) { const route = relative(url); const text = await read(`${route}.md`); diff --git a/docs/tests/blog-articles.test.mjs b/docs/tests/blog-articles.test.mjs index 3fe853c4a..1b5f68cda 100644 --- a/docs/tests/blog-articles.test.mjs +++ b/docs/tests/blog-articles.test.mjs @@ -58,6 +58,7 @@ test("generated articles use only non-empty Wiki-local images", () => { const file = path.join(root, `${article.page}.mdx`); assert.ok(existsSync(file), `missing ${article.page}.mdx`); const content = readFileSync(file, "utf8"); + assert.doesNotMatch(content, /\]\(\/(?:en|zh|ja)\/blog\//, `${article.page} has a Blog link relative to the docs mount`); const imageUrls = [...content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1]); for (const imageUrl of imageUrls) { assert.match(imageUrl, /^\/images\/articles\//, `${article.page} hotlinks ${imageUrl}`); diff --git a/docs/tests/locale-static-build.test.mjs b/docs/tests/locale-static-build.test.mjs index 91ebc66db..6ececb51b 100644 --- a/docs/tests/locale-static-build.test.mjs +++ b/docs/tests/locale-static-build.test.mjs @@ -35,6 +35,7 @@ test("every localized HTML payload includes the preference client provider", asy } } for (const locale of ["en", "zh", "ja"]) await visit(path.join(root, locale)); - // 509 canonical content pages plus one framework 404 page per locale. - assert.equal(count, 515); + // Every sitemap page plus one framework 404 page per locale. + const sitemap = await readFile(path.join(root, "sitemap.xml"), "utf8"); + assert.equal(count, [...sitemap.matchAll(//g)].length + 3); });