From a2f3d2e03caa41dfc59ecb6e405c733a98e2246b Mon Sep 17 00:00:00 2001 From: Chin Rock Date: Tue, 27 Oct 2026 19:03:03 +0800 Subject: [PATCH] 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 {