/ docs / .vitepress / scripts / docs-manifest.mjs
docs-manifest.mjs
  1  import fs from "node:fs";
  2  import path from "node:path";
  3  import { fileURLToPath } from "node:url";
  4  
  5  const __filename = fileURLToPath(import.meta.url);
  6  const __dirname = path.dirname(__filename);
  7  const repoRoot = path.resolve(__dirname, "../../../");
  8  const docsRoot = path.join(repoRoot, "docs");
  9  const generatedRoot = path.join(docsRoot, "generated");
 10  const manifestPath = path.join(docsRoot, ".vitepress", "generated", "manifest.json");
 11  
 12  const blobBaseUrl = "https://github.com/alibaba/OpenSandbox/blob/main";
 13  const treeBaseUrl = "https://github.com/alibaba/OpenSandbox/tree/main";
 14  const rawBaseUrl = "https://raw.githubusercontent.com/alibaba/OpenSandbox/main";
 15  
 16  const ignoredDirNames = new Set([
 17    ".git",
 18    ".github",
 19    "node_modules",
 20    ".vitepress",
 21    ".pytest_cache",
 22    "generated",
 23    ".venv",
 24    "venv",
 25    "__pycache__",
 26    "dist",
 27    "build",
 28    "target",
 29    "bin",
 30  ]);
 31  const zhReadmePattern = /^README(?:[-_](?:zh|zh-cn|zh_cn))?\.md$/i;
 32  const standardReadmePattern = /^README\.md$/i;
 33  
 34  const sectionDefinitions = [
 35    {
 36      id: "modules",
 37      scanRoots: ["server", "components", "sandboxes", "kubernetes", "specs", "sdks"],
 38      includeDevelopment: true,
 39    },
 40    {
 41      id: "examples",
 42      scanRoots: ["examples"],
 43      includeDevelopment: false,
 44    },
 45    {
 46      id: "community",
 47      scanRoots: ["oseps"],
 48      includeDevelopment: false,
 49    },
 50  ];
 51  
 52  const manualEntries = [
 53    {
 54      key: "guide-home",
 55      sectionId: "overview",
 56      slug: "overview/home",
 57      enPath: "README.md",
 58      zhPath: "docs/README_zh.md",
 59      titleEn: "OpenSandbox",
 60      titleZh: "OpenSandbox",
 61    },
 62    {
 63      key: "guide-architecture",
 64      sectionId: "overview",
 65      slug: "overview/architecture",
 66      enPath: "docs/architecture.md",
 67      zhPath: null,
 68      titleEn: "Architecture",
 69      titleZh: "架构设计",
 70    },
 71    {
 72      key: "guide-network",
 73      sectionId: "modules",
 74      slug: "design/single-host-network",
 75      enPath: "docs/single_host_network.md",
 76      zhPath: null,
 77      titleEn: "Single Host Network",
 78      titleZh: "单机场景网络设计",
 79    },
 80    {
 81      key: "community-contributing",
 82      sectionId: "community",
 83      slug: "community/contributing",
 84      enPath: "CONTRIBUTING.md",
 85      zhPath: null,
 86      titleEn: "Contributing",
 87      titleZh: "参与贡献",
 88    },
 89    {
 90      key: "community-code-of-conduct",
 91      sectionId: "community",
 92      slug: "community/code-of-conduct",
 93      enPath: "CODE_OF_CONDUCT.md",
 94      zhPath: null,
 95      titleEn: "Code of Conduct",
 96      titleZh: "行为准则",
 97    },
 98  ];
 99  
100  const moduleGroupLabels = {
101    en: {
102      sdks: "SDKs",
103      specs: "Specs & API",
104      server: "Server",
105      components: "Components",
106      sandboxes: "Sandboxes",
107      kubernetes: "Kubernetes",
108      design: "Design",
109    },
110    zh: {
111      sdks: "SDKs",
112      specs: "Specs & API",
113      server: "Server",
114      components: "Components",
115      sandboxes: "Sandboxes",
116      kubernetes: "Kubernetes",
117      design: "设计",
118    },
119  };
120  
121  const communityGroupLabels = {
122    en: {
123      community: "Community",
124      oseps: "OSEPs",
125    },
126    zh: {
127      community: "社区",
128      oseps: "OSEPs",
129    },
130  };
131  
132  const shortTitleByPath = {
133    "sdks/code-interpreter/javascript/README.md": "Code Interpreter JS SDK",
134    "sdks/code-interpreter/kotlin/README.md": "Code Interpreter Kotlin SDK",
135    "sdks/code-interpreter/python/README.md": "Code Interpreter Python SDK",
136    "sdks/code-interpreter/csharp/README.md": "Code Interpreter C# SDK",
137    "sdks/sandbox/javascript/README.md": "Sandbox JS SDK",
138    "sdks/sandbox/kotlin/README.md": "Sandbox Kotlin SDK",
139    "sdks/sandbox/python/README.md": "Sandbox Python SDK",
140    "sdks/sandbox/csharp/README.md": "Sandbox C# SDK",
141    "sdks/mcp/sandbox/python/README.md": "MCP Sandbox Python SDK",
142    "cli/README.md": "CLI (Python)",
143    "sdks/sandbox/kotlin/sandbox-api/build/generated/api/execd/README.md": "Sandbox Execd API (Kotlin)",
144    "sdks/sandbox/kotlin/sandbox-api/build/generated/api/lifecycle/README.md": "Sandbox Lifecycle API (Kotlin)",
145  
146    "examples/agent-sandbox/README.md": "Agent Sandbox",
147    "examples/aio-sandbox/README.md": "AIO Sandbox",
148    "examples/chrome/README.md": "Chrome",
149    "examples/claude-code/README.md": "Claude Code",
150    "examples/code-interpreter/README.md": "Code Interpreter",
151    "examples/codex-cli/README.md": "Codex CLI",
152    "examples/desktop/README.md": "Desktop (VNC)",
153    "examples/gemini-cli/README.md": "Gemini CLI",
154    "examples/google-adk/README.md": "Google ADK",
155    "examples/host-volume-mount/README.md": "Host Volume Mount",
156    "examples/langgraph/README.md": "LangGraph",
157    "examples/playwright/README.md": "Playwright",
158    "examples/README.md": "Examples Overview",
159    "examples/rl-training/README.md": "RL Training",
160    "examples/vscode/README.md": "VS Code",
161  
162    "server/README.md": "Server",
163    "server/DEVELOPMENT.md": "Server Development",
164    "components/ingress/README.md": "Ingress",
165    "components/ingress/DEVELOPMENT.md": "Ingress Development",
166    "components/egress/README.md": "Egress Sidecar",
167    "components/execd/README.md": "execd",
168    "components/execd/DEVELOPMENT.md": "execd Development",
169    "sandboxes/code-interpreter/README.md": "Code Interpreter Runtime",
170    "kubernetes/README.md": "Kubernetes Controller",
171    "kubernetes/examples/task-executor/README.md": "Task Executor",
172    "kubernetes/examples/controller/README.md": "Controller Example",
173    "oseps/README.md": "OSEP Overview",
174  };
175  
176  const shortTitleByPathZh = {
177    "sdks/code-interpreter/javascript/README.md": "代码解释器 JS SDK",
178    "sdks/code-interpreter/kotlin/README.md": "代码解释器 Kotlin SDK",
179    "sdks/code-interpreter/python/README.md": "代码解释器 Python SDK",
180    "sdks/code-interpreter/csharp/README.md": "代码解释器 C# SDK",
181    "sdks/sandbox/javascript/README.md": "沙箱 JS SDK",
182    "sdks/sandbox/kotlin/README.md": "沙箱 Kotlin SDK",
183    "sdks/sandbox/python/README.md": "沙箱 Python SDK",
184    "sdks/sandbox/csharp/README.md": "沙箱 C# SDK",
185    "sdks/mcp/sandbox/python/README.md": "MCP 沙箱 Python SDK",
186    "cli/README.md": "CLI(Python)",
187    "sdks/sandbox/kotlin/sandbox-api/build/generated/api/execd/README.md": "沙箱 Execd API(Kotlin)",
188    "sdks/sandbox/kotlin/sandbox-api/build/generated/api/lifecycle/README.md": "沙箱生命周期 API(Kotlin)",
189  
190    "examples/agent-sandbox/README.md": "Agent Sandbox",
191    "examples/aio-sandbox/README.md": "AIO 沙箱",
192    "examples/chrome/README.md": "Chrome",
193    "examples/claude-code/README.md": "Claude Code",
194    "examples/code-interpreter/README.md": "代码解释器",
195    "examples/codex-cli/README.md": "Codex CLI",
196    "examples/desktop/README.md": "桌面环境(VNC)",
197    "examples/gemini-cli/README.md": "Gemini CLI",
198    "examples/google-adk/README.md": "Google ADK",
199    "examples/host-volume-mount/README.md": "宿主机目录挂载",
200    "examples/langgraph/README.md": "LangGraph",
201    "examples/playwright/README.md": "Playwright",
202    "examples/README.md": "示例总览",
203    "examples/rl-training/README.md": "强化学习训练",
204    "examples/vscode/README.md": "VS Code",
205  
206    "server/README.md": "Server",
207    "server/DEVELOPMENT.md": "Server 开发指南",
208    "components/ingress/README.md": "Ingress",
209    "components/ingress/DEVELOPMENT.md": "Ingress 开发指南",
210    "components/egress/README.md": "Egress Sidecar",
211    "components/execd/README.md": "execd",
212    "components/execd/DEVELOPMENT.md": "execd 开发指南",
213    "sandboxes/code-interpreter/README.md": "代码解释器运行时",
214    "kubernetes/README.md": "Kubernetes 控制器",
215    "kubernetes/examples/task-executor/README.md": "Task Executor",
216    "kubernetes/examples/controller/README.md": "Controller 示例",
217    "oseps/README.md": "OSEP 总览",
218  };
219  
220  function ensureDir(dirPath) {
221    fs.mkdirSync(dirPath, { recursive: true });
222  }
223  
224  function rmIfExists(targetPath) {
225    if (fs.existsSync(targetPath)) {
226      fs.rmSync(targetPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 80 });
227    }
228  }
229  
230  function walkMarkdownFiles(absDirPath, acc = []) {
231    const entries = fs.readdirSync(absDirPath, { withFileTypes: true });
232    for (const entry of entries) {
233      if (ignoredDirNames.has(entry.name)) {
234        continue;
235      }
236      const absPath = path.join(absDirPath, entry.name);
237      if (entry.isDirectory()) {
238        walkMarkdownFiles(absPath, acc);
239        continue;
240      }
241      if (!entry.isFile()) {
242        continue;
243      }
244      if (entry.name.endsWith(".md")) {
245        acc.push(absPath);
246      }
247    }
248    return acc;
249  }
250  
251  function shouldIgnoreRepoPath(repoRelPath) {
252    const normalized = repoRelPath.replaceAll("\\", "/");
253    const denylistFragments = [
254      "/.venv/",
255      "/venv/",
256      "/node_modules/",
257      "/docs/.vitepress/",
258      "/docs/generated/",
259      "/.pytest_cache/",
260      "/__pycache__/",
261      "/dist/",
262      "/build/",
263      "/target/",
264      "/bin/",
265    ];
266    return denylistFragments.some((fragment) => normalized.includes(fragment));
267  }
268  
269  function toRepoRelative(absPath) {
270    return path.relative(repoRoot, absPath).replaceAll(path.sep, "/");
271  }
272  
273  function readHeadingTitle(absPath, fallbackTitle) {
274    if (!fs.existsSync(absPath)) {
275      return fallbackTitle;
276    }
277    const content = fs.readFileSync(absPath, "utf8");
278    const lines = content.split(/\r?\n/);
279    let inFence = false;
280    for (const line of lines) {
281      const trimmed = line.trimStart();
282      if (trimmed.startsWith("```")) {
283        inFence = !inFence;
284        continue;
285      }
286      if (inFence) {
287        continue;
288      }
289      const matched = trimmed.match(/^#{1,3}\s+(.+)$/);
290      if (matched) {
291        return matched[1].trim();
292      }
293    }
294    return fallbackTitle;
295  }
296  
297  function normalizeTitleWhitespace(title) {
298    return title.replace(/\s+/g, " ").trim();
299  }
300  
301  function shortenOsepTitle(repoRelPath, title, locale = "en") {
302    const match = repoRelPath.match(/^oseps\/(0\d{3})-(.+)\.md$/i);
303    if (!match) {
304      return title;
305    }
306    const number = match[1];
307    const slug = match[2].toLowerCase();
308    if (locale === "zh") {
309      if (slug.includes("fqdn") && slug.includes("egress")) {
310        return `OSEP-${number}: FQDN 出口访问控制`;
311      }
312      if (slug.includes("agent-sandbox") || slug.includes("kubernetes-sigs")) {
313        return `OSEP-${number}: Kubernetes Agent Sandbox 支持`;
314      }
315      if (slug.includes("volume")) {
316        return `OSEP-${number}: Volume 与 VolumeBinding 支持`;
317      }
318    }
319    if (slug.includes("fqdn") && slug.includes("egress")) {
320      return `OSEP-${number}: FQDN Egress Control`;
321    }
322    if (slug.includes("agent-sandbox") || slug.includes("kubernetes-sigs")) {
323      return `OSEP-${number}: Agent Sandbox on Kubernetes`;
324    }
325    if (slug.includes("volume")) {
326      return `OSEP-${number}: Volume & VolumeBinding Support`;
327    }
328    const readable = slug
329      .split("-")
330      .map((part) => (part.length <= 3 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)))
331      .join(" ");
332    return `OSEP-${number}: ${readable}`;
333  }
334  
335  function shortenTitleByRule(title) {
336    let next = normalizeTitleWhitespace(title);
337    next = next.replace(/^Alibaba\s+/i, "");
338    next = next.replace(/^OpenSandbox\s+/i, "");
339    next = next.replace(/\bJavaScript\/TypeScript\b/g, "JS");
340    next = next.replace(/\bJava\/Kotlin\b/g, "Kotlin");
341    next = next.replace(/\s+Example$/i, "");
342    next = next.replace(/\s+SDK for /i, " ");
343    return normalizeTitleWhitespace(next);
344  }
345  
346  function shortenTitleByRuleZh(title) {
347    let next = normalizeTitleWhitespace(title);
348    next = next.replace(/^Alibaba\s+/i, "");
349    next = next.replace(/^OpenSandbox\s+/i, "");
350    next = next.replace(/\bJavaScript\/TypeScript\b/g, "JS");
351    next = next.replace(/\bJava\/Kotlin\b/g, "Kotlin");
352    next = next.replace(/\s+Example$/i, " 示例");
353    next = next.replace(/\s+SDK for /i, " ");
354    return normalizeTitleWhitespace(next);
355  }
356  
357  function getShortTitle(repoRelPath, currentTitle, locale = "en") {
358    if (locale === "zh" && shortTitleByPathZh[repoRelPath]) {
359      return shortTitleByPathZh[repoRelPath];
360    }
361    if (locale !== "zh" && shortTitleByPath[repoRelPath]) {
362      return shortTitleByPath[repoRelPath];
363    }
364    if (/^oseps\/0\d{3}-.+\.md$/i.test(repoRelPath)) {
365      return shortenOsepTitle(repoRelPath, currentTitle, locale);
366    }
367    if (locale === "zh") {
368      return shortenTitleByRuleZh(currentTitle);
369    }
370    return shortenTitleByRule(currentTitle);
371  }
372  
373  function toYamlString(value) {
374    return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
375  }
376  
377  function normalizeSlugFromPath(relPath) {
378    const normalized = relPath.replaceAll("\\", "/");
379    const dirName = path.posix.dirname(normalized);
380    const baseName = path.posix.basename(normalized);
381    const lowerBase = baseName.toLowerCase();
382  
383    if (lowerBase === "readme.md" || zhReadmePattern.test(baseName)) {
384      return dirName === "." ? "overview/home" : `${dirName}/readme`;
385    }
386    if (lowerBase === "development.md") {
387      return `${dirName}/development`;
388    }
389    return normalized.replace(/\.md$/i, "");
390  }
391  
392  function resolveZhCandidate(repoRelPath, readmeCandidatesByDir) {
393    const dir = path.posix.dirname(repoRelPath);
394    const candidates = readmeCandidatesByDir.get(dir) ?? [];
395    for (const candidate of candidates) {
396      if (candidate.toLowerCase() !== "readme.md") {
397        return `${dir}/${candidate}`;
398      }
399    }
400    return null;
401  }
402  
403  function buildGeneratedAssetPath(locale, routeSlug, resolvedRepoPath) {
404    const normalized = resolvedRepoPath.replaceAll("\\", "/");
405    if (!normalized.startsWith("docs/assets/")) {
406      return null;
407    }
408    const generatedDir = path.posix.dirname(`generated/${locale}/${routeSlug}.md`);
409    const assetPath = normalized.replace(/^docs\//, "");
410    let relativePath = path.posix.relative(generatedDir, assetPath);
411    if (!relativePath || relativePath === "") {
412      relativePath = "./";
413    }
414    if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) {
415      relativePath = `./${relativePath}`;
416    }
417    return relativePath;
418  }
419  
420  function normalizeLinkTarget(target, sourceDirRel, isImage, routeSlug, locale) {
421    if (
422      target.startsWith("http://") ||
423      target.startsWith("https://") ||
424      target.startsWith("mailto:") ||
425      target.startsWith("#") ||
426      target.startsWith("data:") ||
427      target.startsWith("/")
428    ) {
429      return target;
430    }
431  
432    const [rawPath, hashFragment] = target.split("#");
433    const resolvedPath = path.posix.normalize(path.posix.join(sourceDirRel, rawPath));
434    const localAssetPath = isImage ? buildGeneratedAssetPath(locale, routeSlug, resolvedPath) : null;
435    if (localAssetPath) {
436      if (hashFragment) {
437        return `${localAssetPath}#${hashFragment}`;
438      }
439      return localAssetPath;
440    }
441  
442    const urlBase = isImage
443      ? `${rawBaseUrl}/${resolvedPath}`
444      : fs.existsSync(path.join(repoRoot, resolvedPath)) &&
445        fs.statSync(path.join(repoRoot, resolvedPath)).isDirectory()
446        ? `${treeBaseUrl}/${resolvedPath}`
447        : `${blobBaseUrl}/${resolvedPath}`;
448  
449    if (hashFragment) {
450      return `${urlBase}#${hashFragment}`;
451    }
452    return urlBase;
453  }
454  
455  function rewriteRelativeLinks(markdown, sourceRelPath, routeSlug, locale) {
456    const sourceDirRel = path.posix.dirname(sourceRelPath);
457  
458    const withMarkdownLinks = markdown.replace(
459      /(!?)\[([^\]]*?)\]\(([^)]+)\)/g,
460      (_match, imageMark, text, linkValue) => {
461        const trimmed = linkValue.trim();
462        if (!trimmed) {
463          return _match;
464        }
465        const firstSpace = trimmed.search(/\s/);
466        const target = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
467        const trailing = firstSpace === -1 ? "" : trimmed.slice(firstSpace);
468        const rewrittenTarget = normalizeLinkTarget(target, sourceDirRel, imageMark === "!", routeSlug, locale);
469        return `${imageMark}[${text}](${rewrittenTarget}${trailing})`;
470      },
471    );
472  
473    return withMarkdownLinks.replace(
474      /<img([^>]*?)src=(["'])([^"']+)\2([^>]*)>/gi,
475      (matched, before, quote, src, after) => {
476        const rewritten = normalizeLinkTarget(src, sourceDirRel, true, routeSlug, locale);
477        return `<img${before}src=${quote}${rewritten}${quote}${after}>`;
478      },
479    );
480  }
481  
482  function renderPageSource({ locale, title, sourceRelPath, routeSlug, passthrough = false }) {
483    const sourceAbsPath = path.join(repoRoot, sourceRelPath);
484    const sourceMarkdown = fs.readFileSync(sourceAbsPath, "utf8");
485    const displayTitle = title || readHeadingTitle(sourceAbsPath, path.posix.basename(sourceRelPath, ".md"));
486  
487    let body = sourceMarkdown;
488    if (!passthrough) {
489      body = rewriteRelativeLinks(sourceMarkdown, sourceRelPath, routeSlug, locale);
490    }
491  
492    const sourceUrl = `${blobBaseUrl}/${sourceRelPath}`;
493    const sourceNotice =
494      locale === "zh"
495        ? `> 此页内容来自仓库源文件:[\`${sourceRelPath}\`](${sourceUrl})`
496        : `> This page is sourced from: [\`${sourceRelPath}\`](${sourceUrl})`;
497  
498  
499    return `---\ntitle: ${toYamlString(displayTitle)}\n---\n\n${body}\n\n---\n\n${sourceNotice}\n`;
500  }
501  
502  function prettifyPathTitle(repoRelPath) {
503    const dirPath = path.posix.dirname(repoRelPath);
504    if (dirPath === "." || dirPath === "docs") {
505      return "Overview";
506    }
507    return dirPath
508      .split("/")
509      .map((part) =>
510        part
511          .replaceAll("-", " ")
512          .replaceAll("_", " ")
513          .replace(/\b\w/g, (ch) => ch.toUpperCase()),
514      )
515      .join(" / ");
516  }
517  
518  function collectAutoEntries() {
519    const readmeCandidatesByDir = new Map();
520    const entries = [];
521  
522    for (const section of sectionDefinitions) {
523      for (const scanRoot of section.scanRoots) {
524        const absScanRoot = path.join(repoRoot, scanRoot);
525        if (!fs.existsSync(absScanRoot)) {
526          continue;
527        }
528        const files = walkMarkdownFiles(absScanRoot);
529        for (const absPath of files) {
530          const repoRelPath = toRepoRelative(absPath);
531          if (shouldIgnoreRepoPath(repoRelPath)) {
532            continue;
533          }
534          const fileName = path.posix.basename(repoRelPath);
535          const dirName = path.posix.dirname(repoRelPath);
536  
537          if (zhReadmePattern.test(fileName)) {
538            const arr = readmeCandidatesByDir.get(dirName) ?? [];
539            arr.push(fileName);
540            readmeCandidatesByDir.set(dirName, arr);
541          }
542        }
543      }
544    }
545  
546    for (const section of sectionDefinitions) {
547      for (const scanRoot of section.scanRoots) {
548        const absScanRoot = path.join(repoRoot, scanRoot);
549        if (!fs.existsSync(absScanRoot)) {
550          continue;
551        }
552        const files = walkMarkdownFiles(absScanRoot);
553        for (const absPath of files) {
554          const repoRelPath = toRepoRelative(absPath);
555          if (shouldIgnoreRepoPath(repoRelPath)) {
556            continue;
557          }
558          const fileName = path.posix.basename(repoRelPath);
559          if (zhReadmePattern.test(fileName) && !standardReadmePattern.test(fileName)) {
560            continue;
561          }
562  
563          const isReadme = standardReadmePattern.test(fileName);
564          const isDevelopment = fileName === "DEVELOPMENT.md";
565          const isOsepDoc = section.id === "community" && /^0\d{3}-.+\.md$/i.test(fileName);
566          if (!isReadme && !(section.includeDevelopment && isDevelopment) && !isOsepDoc) {
567            continue;
568          }
569  
570          const zhCandidate = isReadme ? resolveZhCandidate(repoRelPath, readmeCandidatesByDir) : null;
571          const entryKey = `auto:${section.id}:${repoRelPath}`;
572          const slug = normalizeSlugFromPath(repoRelPath);
573          const titleFallback = isDevelopment ? `${prettifyPathTitle(repoRelPath)} Development` : prettifyPathTitle(repoRelPath);
574          entries.push({
575            key: entryKey,
576            sectionId: section.id,
577            slug,
578            enPath: repoRelPath,
579            zhPath: zhCandidate,
580            titleEn: getShortTitle(repoRelPath, readHeadingTitle(absPath, titleFallback), "en"),
581            titleZh: getShortTitle(
582              repoRelPath,
583              readHeadingTitle(
584              zhCandidate ? path.join(repoRoot, zhCandidate) : absPath,
585              readHeadingTitle(absPath, titleFallback),
586              ),
587              "zh",
588            ),
589          });
590        }
591      }
592    }
593  
594    const unique = new Map();
595    for (const item of entries) {
596      if (!unique.has(item.key)) {
597        unique.set(item.key, item);
598      }
599    }
600    return [...unique.values()].sort((a, b) => a.slug.localeCompare(b.slug));
601  }
602  
603  function buildEntries() {
604    const autoEntries = collectAutoEntries();
605    const all = [...manualEntries, ...autoEntries];
606    const uniqueBySlug = new Map();
607  
608    for (const item of all) {
609      if (uniqueBySlug.has(item.slug)) {
610        continue;
611      }
612      uniqueBySlug.set(item.slug, item);
613    }
614    return [...uniqueBySlug.values()];
615  }
616  
617  function toSidebarItems(entries, locale) {
618    return entries
619      .map((entry) => ({
620        text: locale === "zh" ? entry.titleZh || entry.titleEn : entry.titleEn,
621        link: locale === "zh" ? `/zh/${entry.slug}` : `/${entry.slug}`,
622      }))
623      .sort((a, b) => a.link.localeCompare(b.link));
624  }
625  
626  function buildOverviewSidebar(entries, locale) {
627    const overviewEntries = entries.filter((entry) => entry.sectionId === "overview");
628    const slugOrder = ["overview/home", "overview/architecture"];
629    const items = overviewEntries
630      .sort((a, b) => {
631        const ai = slugOrder.indexOf(a.slug);
632        const bi = slugOrder.indexOf(b.slug);
633        if (ai === -1 && bi === -1) return a.slug.localeCompare(b.slug);
634        if (ai === -1) return 1;
635        if (bi === -1) return -1;
636        return ai - bi;
637      })
638      .map((entry) => ({
639        text: locale === "zh" ? entry.titleZh || entry.titleEn : entry.titleEn,
640        link: locale === "zh" ? `/zh/${entry.slug}` : `/${entry.slug}`,
641      }));
642    if (items.length === 0) {
643      return [];
644    }
645    return [{ text: locale === "zh" ? "Overview" : "Overview", items }];
646  }
647  
648  function buildModulesSidebar(entries, locale) {
649    const modules = entries.filter((entry) => entry.sectionId === "modules");
650    const byPrefix = new Map();
651    for (const entry of modules) {
652      const prefix = entry.slug.split("/")[0];
653      const arr = byPrefix.get(prefix) ?? [];
654      arr.push(entry);
655      byPrefix.set(prefix, arr);
656    }
657  
658    const order = ["sdks", "specs", "design", "server", "components", "sandboxes", "kubernetes"];
659    const blocks = [];
660    for (const prefix of order) {
661      const groupEntries = byPrefix.get(prefix);
662      if (!groupEntries || groupEntries.length === 0) {
663        continue;
664      }
665      blocks.push({
666        text: moduleGroupLabels[locale][prefix],
667        items: toSidebarItems(groupEntries, locale),
668      });
669    }
670    return blocks;
671  }
672  
673  function buildExamplesSidebar(entries, locale) {
674    const items = toSidebarItems(entries.filter((entry) => entry.sectionId === "examples"), locale);
675    if (items.length === 0) {
676      return [];
677    }
678    return [{ text: locale === "zh" ? "示例" : "Examples", items }];
679  }
680  
681  function buildCommunitySidebar(entries, locale) {
682    const blocks = [];
683    const communityEntries = entries.filter(
684      (entry) => entry.sectionId === "community" && entry.slug.startsWith("community/"),
685    );
686    if (communityEntries.length > 0) {
687      blocks.push({
688        text: communityGroupLabels[locale].community,
689        items: toSidebarItems(communityEntries, locale),
690      });
691    }
692  
693    const osepReadmeEntries = entries.filter((entry) => entry.sectionId === "community" && entry.slug === "oseps/readme");
694    const osepDocEntries = entries.filter(
695      (entry) => entry.sectionId === "community" && entry.slug.startsWith("oseps/") && entry.slug !== "oseps/readme",
696    );
697    const sortedOsepDocs = osepDocEntries.sort((a, b) => a.slug.localeCompare(b.slug));
698    const osepItems = [...toSidebarItems(osepReadmeEntries, locale), ...toSidebarItems(sortedOsepDocs, locale)];
699    if (osepItems.length > 0) {
700      blocks.push({
701        text: communityGroupLabels[locale].oseps,
702        items: osepItems,
703      });
704    }
705  
706    return blocks;
707  }
708  
709  function buildSidebarByPath(entries, locale) {
710    const prefix = locale === "zh" ? "/zh" : "";
711    const overviewSidebar = buildOverviewSidebar(entries, locale);
712    const modulesSidebar = buildModulesSidebar(entries, locale);
713    const examplesSidebar = buildExamplesSidebar(entries, locale);
714    const communitySidebar = buildCommunitySidebar(entries, locale);
715  
716    const sidebar = {
717      [`${prefix}/`]: overviewSidebar,
718      [`${prefix}/overview/`]: overviewSidebar,
719      [`${prefix}/examples/`]: examplesSidebar,
720      [`${prefix}/community/`]: communitySidebar,
721      [`${prefix}/oseps/`]: communitySidebar,
722    };
723  
724    for (const modulesPrefix of ["server", "components", "sandboxes", "kubernetes", "specs", "sdks", "design"]) {
725      sidebar[`${prefix}/${modulesPrefix}/`] = modulesSidebar;
726    }
727    return sidebar;
728  }
729  
730  function writeGeneratedPages(entries) {
731    rmIfExists(generatedRoot);
732    ensureDir(path.join(generatedRoot, "en"));
733    ensureDir(path.join(generatedRoot, "zh"));
734  
735    const rewrites = {};
736    const pages = [];
737  
738    for (const entry of entries) {
739      const enSourcePath = entry.enPath;
740      const zhSourcePath = entry.zhPath || entry.enPath;
741      const enGeneratedRel = `generated/en/${entry.slug}.md`;
742      const zhGeneratedRel = `generated/zh/${entry.slug}.md`;
743      const enGeneratedAbs = path.join(docsRoot, enGeneratedRel);
744      const zhGeneratedAbs = path.join(docsRoot, zhGeneratedRel);
745      ensureDir(path.dirname(enGeneratedAbs));
746      ensureDir(path.dirname(zhGeneratedAbs));
747  
748      fs.writeFileSync(
749        enGeneratedAbs,
750        renderPageSource({
751          locale: "en",
752          title: entry.titleEn,
753          sourceRelPath: enSourcePath,
754          routeSlug: entry.slug,
755          passthrough: entry.passthrough === true,
756        }),
757        "utf8",
758      );
759  
760      fs.writeFileSync(
761        zhGeneratedAbs,
762        renderPageSource({
763          locale: "zh",
764          title: entry.titleZh || entry.titleEn,
765          sourceRelPath: zhSourcePath,
766          routeSlug: entry.slug,
767          passthrough: entry.passthrough === true,
768        }),
769        "utf8",
770      );
771  
772      rewrites[enGeneratedRel] = `${entry.slug}.md`;
773      rewrites[zhGeneratedRel] = `zh/${entry.slug}.md`;
774  
775      pages.push({
776        key: entry.key,
777        slug: entry.slug,
778        en: enSourcePath,
779        zh: zhSourcePath,
780      });
781    }
782  
783    return { rewrites, pages };
784  }
785  
786  export function buildManifest() {
787    const entries = buildEntries();
788    const { rewrites, pages } = writeGeneratedPages(entries);
789    const manifest = {
790      generatedAt: new Date().toISOString(),
791      pages,
792      nav: {
793        en: [
794          { text: "Overview", link: "/overview/home" },
795          { text: "Project", link: "/sdks/sandbox/python/readme" },
796          { text: "Examples", link: "/examples/readme" },
797          { text: "Community", link: "/community/contributing" },
798        ],
799        zh: [
800          { text: "Overview", link: "/zh/overview/home" },
801          { text: "Project", link: "/zh/sdks/sandbox/python/readme" },
802          { text: "Examples", link: "/zh/examples/readme" },
803          { text: "Community", link: "/zh/community/contributing" },
804        ],
805      },
806      sidebar: {
807        en: buildSidebarByPath(entries, "en"),
808        zh: buildSidebarByPath(entries, "zh"),
809      },
810      rewrites,
811    };
812  
813    ensureDir(path.dirname(manifestPath));
814    fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
815    return manifest;
816  }
817  
818  export function loadManifest() {
819    try {
820      if (!fs.existsSync(manifestPath)) {
821        return buildManifest();
822      }
823      const data = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
824      if (!data || !data.generatedAt || !data.nav || !data.sidebar || !data.rewrites) {
825        return buildManifest();
826      }
827      return buildManifest();
828    } catch (_error) {
829      return buildManifest();
830    }
831  }
832  
833  if (process.argv[1] === fileURLToPath(import.meta.url)) {
834    const manifest = buildManifest();
835    // Keep logging terse for CI output.
836    console.log(`docs manifest generated (${manifest.pages.length} pages)`);
837  }