Merge pull request #2786 from langbot-app/agent/hk-dev-codex/lang-168-blog-fix

fix(docs): preserve local articles during Blog sync (LANG-168)
This commit is contained in:
RockChinQ
2026-10-27 19:21:00 +08:00
committed by GitHub
7 changed files with 99 additions and 7 deletions
+30
View File
@@ -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
}
]
}
+36
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" } },
@@ -225,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));
@@ -254,6 +262,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 +291,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 +356,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 +381,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"))) {
+2 -1
View File
@@ -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`);
+16
View File
@@ -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}`);
@@ -72,3 +73,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 {
+3 -2
View File
@@ -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(/<loc>/g)].length + 3);
});