Files
LangBot/scripts/sync-blog-articles.mjs
T

430 lines
26 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
const ROOT = path.resolve(import.meta.dirname, "..");
const ARTICLE_LOCALES = ["en", "zh", "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" } },
zh: { mintlify: "cn", tab: "文章", introTitle: "LangBot 文章", introDescription: "同步自 LangBot Blog 的产品动态、技术解析、教程、集成实践与公告。", sourceLabel: "本文同步自 LangBot Blog。", sourceAction: "阅读 Blog 原文", fallbackLabel: "", overviewGroup: "浏览", groups: { updates: "产品动态", engineering: "技术解析", tutorials: "教程与集成", announcements: "公告" } },
ja: { mintlify: "jp", tab: "記事", introTitle: "LangBot 記事", introDescription: "LangBot Blog の製品アップデート、技術解説、チュートリアル、連携事例、お知らせをまとめています。", sourceLabel: "この記事は LangBot Blog から同期されています。", sourceAction: "Blog の原文を読む", fallbackLabel: "この記事の日本語版はまだ公開されていないため、英語版を掲載しています。", overviewGroup: "一覧", groups: { updates: "製品アップデート", engineering: "エンジニアリング", tutorials: "チュートリアルと連携", announcements: "お知らせ" } },
};
const JAPANESE_TITLES = {
"connect-deepseek-to-wechat": "DeepSeek R1 を WeChat・Discord・Telegram に5分で接続する方法",
"deploy-ai-bot-in-5-minutes": "AI Bot を Discord・Telegram・WeChat に5分でデプロイ",
"dify-agent-discord-telegram-slack": "Dify Agent を Discord・Telegram・Slack で動かす",
"dify-langbot-rag-knowledge-base": "LangBot 4.6.0 外部ナレッジベース入門:Dify と連携した RAG 会話",
"langbot-plugin-system-deep-dive": "LangBot プラグインシステム詳解:プロセス分離・イベント駆動・コンポーネント設計",
"langbot-v4100-sandbox-skills": "LangBot v4.10.0:Agent Sandbox と Skills",
"langbot-v490-rag-plugin-architecture": "LangBot v4.9.0:完全プラグイン化された RAG アーキテクチャ",
"langflow-drag-and-drop-ai-bot": "Langflow のドラッグ&ドロップでマルチプラットフォーム AI Bot を構築",
"langtars-remote-pc-control-dify-n8n": "LangTARS:Dify・n8n と連携するオープンソース PC 操作 Agent",
"n8n-multi-platform-ai-chatbot": "n8n と LangBot でマルチプラットフォーム AI Chatbot を構築",
welcome: "LangBot Blog のご紹介",
};
function parseArgs(argv) {
const result = { check: false, source: process.env.LANGBOT_LANDING_REPO || "" };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--check") result.check = true;
else if (argv[i] === "--source") result.source = argv[++i];
else throw new Error(`Unknown argument: ${argv[i]}`);
}
if (result.source) result.source = path.resolve(result.source);
return result;
}
function parseScalar(value) {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1).replaceAll('\\"', '"');
return trimmed;
}
function parseFrontmatter(markdown, file) {
if (!markdown.startsWith("---\n")) throw new Error(`${file}: missing frontmatter`);
const end = markdown.indexOf("\n---\n", 4);
if (end < 0) throw new Error(`${file}: unterminated frontmatter`);
const raw = markdown.slice(4, end);
const body = markdown.slice(end + 5).trimStart();
const lines = raw.split("\n");
const data = {};
for (let i = 0; i < lines.length; i += 1) {
const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i]);
if (!match) continue;
const [, key, value] = match;
if (key === "cover") {
const cover = {};
while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
const nested = /^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[++i]);
if (nested) cover[nested[1]] = parseScalar(nested[2]);
}
data.cover = cover;
} else if (value.startsWith("[") && value.endsWith("]")) {
data[key] = [...value.matchAll(/"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/g)].map((item) => item[1] ?? item[2]);
} else data[key] = parseScalar(value);
}
for (const required of ["title", "date", "description", "author"]) if (!data[required]) throw new Error(`${file}: missing ${required}`);
return { data, body };
}
function yamlQuote(value) { return JSON.stringify(String(value)); }
function classify(post) {
const category = String(post.data.categories?.[0] || "").toLowerCase();
if (category.includes("product") || category.includes("产品") || category.includes("製品")) return "updates";
if (category.includes("engineering") || category.includes("technical") || category.includes("技术") || category.includes("技術")) return "engineering";
if (category.includes("announcement") || category.includes("公告") || category.includes("お知らせ")) return "announcements";
return "tutorials";
}
function canonicalUrl(locale, slug) { return `https://langbot.app/${locale}/blog/${slug}`; }
function attribution(locale, post) {
if (locale === "zh") return `发布于 ${post.data.date} · 作者:${post.data.author}`;
if (locale === "ja") return `公開日:${post.data.date} · 著者:${post.data.author}`;
return `Published ${post.data.date} · Author: ${post.data.author}`;
}
function escapeMdxProse(markdown) {
const lines = markdown.split("\n");
let fenced = false;
return lines.map((line) => {
if (/^\s*```/.test(line) || /^\s*~~~/.test(line)) { fenced = !fenced; return line; }
if (fenced) return line.trimEnd();
return line.split(/(`[^`]*`)/g).map((piece, index) => index % 2 === 1 ? piece : piece.replaceAll("{", "&#123;").replaceAll("}", "&#125;")).join("").trimEnd();
}).join("\n");
}
function imageReferences(markdown) { return [...markdown.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)].map((match) => ({ alt: match[1], url: match[2], full: match[0] })); }
function contentTypeExtension(contentType) {
if (contentType.includes("image/png")) return ".png";
if (contentType.includes("image/jpeg")) return ".jpg";
if (contentType.includes("image/gif")) return ".gif";
if (contentType.includes("image/svg")) return ".svg";
if (contentType.includes("image/webp")) return ".webp";
return "";
}
function localImageTarget(sourceUrl) {
if (sourceUrl.startsWith("/")) return `/images/articles/source${sourceUrl}`;
const parsed = new URL(sourceUrl);
let base = path.posix.basename(parsed.pathname) || "image";
const digest = createHash("sha256").update(sourceUrl).digest("hex").slice(0, 12);
base = base.replace(/[^A-Za-z0-9._-]/g, "-");
return `/images/articles/external/${digest}-${base}`;
}
function assertWithin(base, candidate, label) {
const normalizedBase = path.resolve(base);
const normalizedCandidate = path.resolve(candidate);
if (!normalizedCandidate.startsWith(`${normalizedBase}${path.sep}`)) throw new Error(`${label} escapes ${normalizedBase}: ${normalizedCandidate}`);
}
async function assertDirectoryWithoutSymlinks(directory, label) {
if (!existsSync(directory)) throw new Error(`${label} does not exist: ${directory}`);
const info = await lstat(directory);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} must be a real non-symlink directory: ${directory}`);
const resolved = await realpath(directory);
if (resolved !== path.resolve(directory)) throw new Error(`${label} path must not traverse symlinks: ${directory}`);
return resolved;
}
async function readRegularFileWithin(base, file, label, encoding = null) {
const baseReal = await assertDirectoryWithoutSymlinks(base, `${label} directory`);
if (!existsSync(file)) throw new Error(`${label} does not exist: ${file}`);
const info = await lstat(file);
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular non-symlink file: ${file}`);
const fileReal = await realpath(file);
assertWithin(baseReal, fileReal, label);
return encoding ? readFile(fileReal, encoding) : readFile(fileReal);
}
function validateImageBytes(bytes, label) {
const ascii = bytes.subarray(0, 16).toString("ascii");
const trimmed = bytes.toString("utf8", 0, Math.min(bytes.length, 1024)).trimStart();
const valid =
bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ||
(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) ||
ascii.startsWith("GIF87a") || ascii.startsWith("GIF89a") ||
(ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") ||
((trimmed.startsWith("<svg") || trimmed.startsWith("<?xml")) && trimmed.includes("<svg"));
if (!valid) throw new Error(`Invalid image signature: ${label}`);
}
async function prepareImageTarget(assetRoot, target) {
const imageRoot = path.join(assetRoot, "images/articles");
assertWithin(imageRoot, target, "Article image target");
await mkdir(path.dirname(target), { recursive: true });
const imageRootReal = await realpath(imageRoot);
const targetParentReal = await realpath(path.dirname(target));
if (targetParentReal !== path.resolve(path.dirname(target))) throw new Error(`Article image target directory must not be a symlink: ${path.dirname(target)}`);
assertWithin(imageRootReal, path.join(targetParentReal, path.basename(target)), "Resolved article image target");
if (existsSync(target) && (await lstat(target)).isSymbolicLink()) throw new Error(`Article image target must not be a symlink: ${target}`);
}
async function materializeImage(sourceRoot, sourceUrl, assetRoot) {
let targetUrl = localImageTarget(sourceUrl);
let target = path.join(assetRoot, targetUrl.slice(1));
await prepareImageTarget(assetRoot, target);
if (sourceUrl.startsWith("/")) {
const publicRoot = path.join(sourceRoot, "public");
const source = path.join(publicRoot, sourceUrl.slice(1));
assertWithin(publicRoot, source, "Blog image source");
if (!existsSync(source)) throw new Error(`Missing source image: ${sourceUrl} (${source})`);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`Blog image source must be a regular non-symlink file: ${source}`);
const publicRootReal = await realpath(publicRoot);
const sourceReal = await realpath(source);
assertWithin(publicRootReal, sourceReal, "Resolved Blog image source");
const bytes = await readFile(sourceReal);
validateImageBytes(bytes, sourceUrl);
await writeFile(target, bytes);
return targetUrl;
}
const parsedSource = new URL(sourceUrl);
if (parsedSource.protocol !== "https:") throw new Error(`External images must use HTTPS: ${sourceUrl}`);
const response = await fetch(sourceUrl, { headers: { "User-Agent": "LangBot-Wiki-Article-Sync/1.0" }, redirect: "follow" });
if (!response.ok) throw new Error(`Failed to fetch image ${sourceUrl}: HTTP ${response.status}`);
const declaredSize = Number(response.headers.get("content-length") || 0);
if (declaredSize > MAX_IMAGE_BYTES) throw new Error(`Image exceeds ${MAX_IMAGE_BYTES} bytes: ${sourceUrl}`);
const bytes = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get("content-type") || "";
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || !contentType.toLowerCase().startsWith("image/")) throw new Error(`Invalid image ${sourceUrl}: ${contentType}, ${bytes.length} bytes`);
validateImageBytes(bytes, sourceUrl);
if (!path.extname(target)) {
const extension = contentTypeExtension(contentType);
if (!extension) throw new Error(`Unsupported image content type for extensionless URL: ${contentType}`);
target += extension;
targetUrl += extension;
await prepareImageTarget(assetRoot, target);
}
if (contentType.toLowerCase().includes("image/svg")) {
await writeFile(target, bytes.toString("utf8").replace(/[ \t]+$/gm, ""));
} else {
await writeFile(target, bytes);
}
return targetUrl;
}
async function loadPosts(sourceRoot) {
const byLocale = new Map();
const blogRoot = path.join(sourceRoot, "src/content/blog");
const blogRootReal = await assertDirectoryWithoutSymlinks(blogRoot, "Blog content root");
for (const locale of ARTICLE_LOCALES) {
const dir = path.join(blogRoot, locale);
const files = existsSync(dir) ? await readdir(dir) : [];
if (existsSync(dir)) {
const localeRootReal = await assertDirectoryWithoutSymlinks(dir, `Blog ${locale} content root`);
assertWithin(blogRootReal, localeRootReal, `Blog ${locale} content root`);
}
const posts = new Map();
for (const name of files.filter((item) => item.endsWith(".md")).sort()) {
const file = path.join(dir, name);
const parsed = parseFrontmatter(await readRegularFileWithin(dir, file, `Blog ${locale} article`, "utf8"), file);
const slug = name.slice(0, -3);
posts.set(slug, { ...parsed, slug, locale, file });
}
byLocale.set(locale, posts);
}
const enSlugs = [...byLocale.get("en").keys()].sort();
const zhSlugs = [...byLocale.get("zh").keys()].sort();
if (JSON.stringify(enSlugs) !== JSON.stringify(zhSlugs)) throw new Error("English and Chinese blog slug sets differ");
return { byLocale, slugs: enSlugs };
}
async function renderArticle(sourceRoot, post, locale, fallback, assetRoot, imageCache) {
const config = LANGUAGE_CONFIG[locale];
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[post.slug] || post.data.title) : post.data.title;
let body = post.body;
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));
for (const ref of refs) body = body.replaceAll(ref.full, `![${ref.alt}](${imageCache.get(ref.url)})`);
body = escapeMdxProse(body);
const cover = post.data.cover?.image ? imageCache.get(post.data.cover.image) : "";
const bodyHasCover = cover && body.includes(`](${cover})`);
const notice = fallback ? `\n> ${config.fallbackLabel}\n` : "";
const coverBlock = cover && !bodyHasCover ? `\n![${post.data.cover.alt || title}](${cover})\n` : "";
return `---\ntitle: ${yamlQuote(title)}\ndescription: ${yamlQuote(post.data.description)}\n---\n\n${config.sourceLabel} [${config.sourceAction}](${canonicalUrl(post.locale, post.slug)}).\n\n${attribution(locale, post)}\n${notice}${coverBlock}\n${body.trim()}\n`;
}
function articlePage(locale, slug) { return `${locale}/articles/${slug}`; }
function renderIndex(locale, posts) {
const config = LANGUAGE_CONFIG[locale];
const lines = ["---", `title: ${yamlQuote(config.introTitle)}`, `description: ${yamlQuote(config.introDescription)}`, "---", "", config.introDescription, ""];
for (const key of ["updates", "engineering", "tutorials", "announcements"]) {
const inGroup = posts.filter((post) => post.group === key);
if (!inGroup.length) continue;
lines.push(`## ${config.groups[key]}`, "");
for (const post of inGroup) lines.push(`- [${post.title}](/${articlePage(locale, post.slug)}) — ${post.date}`);
lines.push("");
}
return `${lines.join("\n").trim()}\n`;
}
function navigationTab(locale, posts) {
const config = LANGUAGE_CONFIG[locale];
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 (pages.length) groups.push({ group: config.groups[key], pages });
}
return { tab: config.tab, groups };
}
async function validateGenerated(manifest) {
const docs = JSON.parse(await readFile(path.join(ROOT, "docs.json"), "utf8"));
const failures = [];
const referencedImages = new Set();
for (const locale of ARTICLE_LOCALES) {
const config = LANGUAGE_CONFIG[locale];
const articleRoot = path.join(ROOT, locale, "articles");
await assertDirectoryWithoutSymlinks(articleRoot, `${locale} generated article root`);
const language = docs.navigation.languages.find((item) => item.language === config.mintlify);
const tabs = language?.tabs.filter((item) => item.tab === config.tab) || [];
if (tabs.length !== 1) failures.push(`${locale}: expected exactly one ${config.tab} navigation tab, found ${tabs.length}`);
for (const article of manifest.articles.filter((item) => item.locale === locale)) {
const file = path.join(ROOT, `${article.page}.mdx`);
if (!existsSync(file)) failures.push(`missing article: ${article.page}.mdx`);
else {
const content = await readRegularFileWithin(articleRoot, file, `${locale} generated article`, "utf8");
for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/[^)]+)\)/g)) {
referencedImages.add(match[1]);
const image = path.join(ROOT, match[1].slice(1));
if (!existsSync(image) || (await stat(image)).size === 0) failures.push(`${article.page}: missing image ${match[1]}`);
}
}
}
}
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}`);
for (const image of referencedImages) if (!mirroredImages.has(image)) failures.push(`referenced image is not mirrored: ${image}`);
const expectedImages = new Map((manifest.images || []).map((image) => [image.path, image]));
for (const image of mirroredMetadata) {
const expected = expectedImages.get(image.path);
if (!expected) failures.push(`mirrored image missing from manifest: ${image.path}`);
else if (expected.sha256 !== image.sha256 || expected.bytes !== image.bytes) failures.push(`mirrored image digest mismatch: ${image.path}`);
}
for (const image of expectedImages.keys()) if (!mirroredImages.has(image)) failures.push(`manifest image is missing: ${image}`);
if (failures.length) throw new Error(`Generated article validation failed:\n${failures.join("\n")}`);
}
async function listFiles(directory) {
if (!existsSync(directory)) return [];
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const full = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Generated content tree must not contain symlinks: ${full}`);
if (entry.isDirectory()) files.push(...await listFiles(full));
else if (entry.isFile()) files.push(full);
else throw new Error(`Generated content tree must contain only directories and regular files: ${full}`);
}
return files;
}
async function imageMetadata(assetRoot) {
const metadata = [];
for (const file of await listFiles(path.join(assetRoot, "images/articles"))) {
const bytes = await readFile(file);
validateImageBytes(bytes, file);
metadata.push({
path: `/${path.relative(assetRoot, file).split(path.sep).join("/")}`,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
});
}
return metadata.sort((a, b) => a.path.localeCompare(b.path));
}
async function referencedImagesIn(directory) {
const images = new Set();
for (const file of await listFiles(directory)) {
if (!file.endsWith(".mdx")) continue;
const content = await readFile(file, "utf8");
for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) images.add(match[1]);
}
return images;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const manifestPath = path.join(ROOT, "scripts/blog-articles-manifest.json");
if (args.check && !args.source) {
await validateGenerated(JSON.parse(await readFile(manifestPath, "utf8")));
console.log("Generated article pages, navigation, and local images are valid.");
return;
}
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);
const outputRoot = await mkdtemp(path.join(tmpdir(), "langbot-docs-article-sync-"));
const assetRoot = args.check ? outputRoot : ROOT;
const imageCache = new Map();
const localePosts = new Map();
const manifest = { source: "https://github.com/langbot-app/langbot-landing-page/tree/main/src/content/blog", articles: [] };
for (const locale of ARTICLE_LOCALES) {
const renderedPosts = [];
for (const slug of slugs) {
const localized = byLocale.get(locale).get(slug);
const post = localized || byLocale.get("en").get(slug);
const fallback = !localized;
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[slug] || post.data.title) : post.data.title;
renderedPosts.push({ slug, title, date: post.data.date, group: classify(byLocale.get("en").get(slug)), fallback });
const target = path.join(outputRoot, locale, "articles", `${slug}.mdx`);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, await renderArticle(args.source, post, locale, fallback, assetRoot, imageCache));
manifest.articles.push({ locale, slug, page: articlePage(locale, slug), sourceLocale: post.locale, fallback });
}
renderedPosts.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug));
localePosts.set(locale, renderedPosts);
await writeFile(path.join(outputRoot, locale, "articles", "index.mdx"), renderIndex(locale, renderedPosts));
}
if (!args.check) {
const referencedImages = await referencedImagesIn(outputRoot);
for (const file of await listFiles(path.join(ROOT, "images/articles"))) {
const publicPath = `/${path.relative(ROOT, file).split(path.sep).join("/")}`;
if (!referencedImages.has(publicPath)) await rm(file, { force: true });
}
}
manifest.images = await imageMetadata(assetRoot);
if (args.check) {
const mismatches = [];
for (const locale of ARTICLE_LOCALES) {
const generatedDir = path.join(outputRoot, locale, "articles");
const currentDir = path.join(ROOT, locale, "articles");
const generatedNames = (await readdir(generatedDir)).sort();
const currentEntries = existsSync(currentDir) ? await readdir(currentDir, { withFileTypes: true }) : [];
if (currentEntries.some((entry) => !entry.isFile() || entry.isSymbolicLink())) mismatches.push(`${locale}/articles contains non-regular files`);
const currentNames = currentEntries.map((entry) => entry.name).sort();
if (JSON.stringify(generatedNames) !== JSON.stringify(currentNames)) mismatches.push(`${locale}/articles file set`);
for (const name of generatedNames) {
const generated = await readFile(path.join(generatedDir, name), "utf8");
const currentFile = path.join(currentDir, name);
const current = existsSync(currentFile) ? await readRegularFileWithin(currentDir, currentFile, `${locale} generated article`, "utf8") : "";
if (generated !== current) mismatches.push(`${locale}/articles/${name}`);
}
}
const currentManifest = existsSync(manifestPath) ? JSON.parse(await readFile(manifestPath, "utf8")) : null;
if (JSON.stringify(currentManifest) !== JSON.stringify(manifest)) mismatches.push("scripts/blog-articles-manifest.json");
const currentDocs = JSON.parse(await readFile(path.join(ROOT, "docs.json"), "utf8"));
for (const locale of ARTICLE_LOCALES) {
const config = LANGUAGE_CONFIG[locale];
const language = currentDocs.navigation.languages.find((item) => item.language === config.mintlify);
const matchingTabs = language?.tabs.filter((item) => item.tab === config.tab) || [];
const currentTab = matchingTabs.length === 1 ? matchingTabs[0] : null;
const expectedTab = navigationTab(locale, localePosts.get(locale));
if (matchingTabs.length !== 1 || JSON.stringify(currentTab) !== JSON.stringify(expectedTab)) mismatches.push(`docs.json ${config.tab} tab`);
}
await rm(outputRoot, { recursive: true, force: true });
if (mismatches.length) throw new Error(`Blog articles are out of sync:\n${mismatches.join("\n")}`);
await validateGenerated(JSON.parse(await readFile(manifestPath, "utf8")));
console.log(`All ${manifest.articles.length} localized article pages are synchronized.`);
return;
}
for (const locale of ARTICLE_LOCALES) {
const targetDir = path.join(ROOT, locale, "articles");
await rm(targetDir, { recursive: true, force: true });
await mkdir(path.dirname(targetDir), { recursive: true });
await cp(path.join(outputRoot, locale, "articles"), targetDir, { recursive: true });
}
await rm(outputRoot, { recursive: true, force: true });
const docsPath = path.join(ROOT, "docs.json");
const docs = JSON.parse(await readFile(docsPath, "utf8"));
for (const locale of ARTICLE_LOCALES) {
const config = LANGUAGE_CONFIG[locale];
const language = docs.navigation.languages.find((item) => item.language === config.mintlify);
if (!language) throw new Error(`docs.json missing language ${config.mintlify}`);
language.tabs = language.tabs.filter((item) => item.tab !== config.tab);
const apiIndex = language.tabs.findIndex((item) => /API/.test(item.tab));
language.tabs.splice(apiIndex >= 0 ? apiIndex : language.tabs.length, 0, navigationTab(locale, localePosts.get(locale)));
}
await writeFile(docsPath, `${JSON.stringify(docs, null, 2)}\n`);
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
await validateGenerated(manifest);
console.log(`Synchronized ${slugs.length} posts into ${manifest.articles.length} localized Wiki pages.`);
}
main().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; });