fix(docs): preserve local articles during Blog synchronization

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Chin Rock
2026-10-27 19:03:03 +08:00
co-authored by multica-agent
parent 569ab92ea2
commit a2f3d2e03c
4 changed files with 61 additions and 4 deletions
+34
View File
@@ -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"))) {
+15
View File
@@ -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);
}
}
}
});
+7 -3
View File
@@ -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);
+5 -1
View File
@@ -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 {