hd-garden 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.agents/skills/garden-ui/SKILL.md +2 -2
  2. package/.env.en.example +67 -0
  3. package/README.en.md +2 -2
  4. package/README.md +2 -2
  5. package/app/a2/[slug]/page.tsx +4 -3
  6. package/app/a2/page.tsx +4 -3
  7. package/app/about/page.tsx +8 -9
  8. package/app/admin/page.tsx +7 -6
  9. package/app/admin/preview/[...path]/page.tsx +2 -1
  10. package/app/admin/ui/blocks/page.tsx +2 -1
  11. package/app/admin/ui/catalog/page.tsx +2 -1
  12. package/app/admin/ui/evals/page.tsx +2 -1
  13. package/app/admin/ui/page.tsx +2 -1
  14. package/app/blog/[...path]/page.tsx +7 -6
  15. package/app/blog/page.tsx +4 -2
  16. package/app/columns/[column]/[...path]/page.tsx +8 -7
  17. package/app/columns/page.tsx +12 -8
  18. package/app/components/AdminUiShell.tsx +4 -3
  19. package/app/components/ArticleMeasure.tsx +6 -3
  20. package/app/components/ArticleReferences.tsx +4 -1
  21. package/app/components/ArticleTags.tsx +3 -2
  22. package/app/components/BlogArchive.tsx +20 -16
  23. package/app/components/Comments.tsx +16 -10
  24. package/app/components/ContentEmptyState.tsx +29 -28
  25. package/app/components/ExternalEmbed.tsx +15 -14
  26. package/app/components/GardenContentGuide.tsx +10 -2
  27. package/app/components/GardenDocsNav.tsx +4 -2
  28. package/app/components/GiscusComments.tsx +2 -1
  29. package/app/components/GithubLiveOverview.tsx +10 -8
  30. package/app/components/GithubProfile.tsx +31 -21
  31. package/app/components/HandbookLangSwitch.tsx +8 -4
  32. package/app/components/HandbookView.tsx +92 -9
  33. package/app/components/MarkdownArticle.tsx +31 -7
  34. package/app/components/MermaidDiagram.tsx +4 -3
  35. package/app/components/SiteChrome.tsx +3 -3
  36. package/app/components/SiteFooter.tsx +1 -1
  37. package/app/components/SiteHeader.tsx +13 -4
  38. package/app/components/ThoughtCard.tsx +16 -14
  39. package/app/components/UiBlock.tsx +3 -2
  40. package/app/components/Webmentions.tsx +11 -11
  41. package/app/components/WidgetChart.tsx +8 -6
  42. package/app/components/garden/FilePreview.tsx +2 -2
  43. package/app/components/garden/GardenBlock.tsx +7 -0
  44. package/app/components/garden/GardenFile.tsx +2 -2
  45. package/app/components/garden/RefDemo.tsx +62 -0
  46. package/app/components/garden/blocks.tsx +56 -8
  47. package/app/components/garden/patterns.tsx +497 -0
  48. package/app/garden/[...slug]/page.tsx +3 -2
  49. package/app/garden/en/page.tsx +7 -7
  50. package/app/garden/page.tsx +3 -2
  51. package/app/globals.css +49 -1
  52. package/app/lib/a2-meta.ts +26 -3
  53. package/app/lib/content.ts +2 -2
  54. package/app/lib/garden-docs-meta.ts +24 -0
  55. package/app/lib/github-id.mjs +8 -0
  56. package/app/lib/handbook-switch.mjs +12 -0
  57. package/app/lib/i18n.ts +401 -2
  58. package/app/lib/rehype-field-tables.mjs +47 -0
  59. package/app/lib/remark-a2.mjs +18 -8
  60. package/app/lib/remark-link-card.mjs +22 -0
  61. package/app/lib/remark-ref-demos.mjs +42 -0
  62. package/app/lib/tag-filter.ts +9 -2
  63. package/app/not-found.tsx +32 -4
  64. package/app/page.tsx +17 -14
  65. package/app/rss.xml/route.ts +2 -1
  66. package/app/styles/a2.css +338 -0
  67. package/app/styles/shell.css +15 -2
  68. package/app/tags/page.tsx +7 -5
  69. package/app/thoughts/[slug]/page.tsx +2 -1
  70. package/app/thoughts/page/[n]/page.tsx +1 -1
  71. package/app/thoughts/page.tsx +2 -1
  72. package/app/ui-parity/page.tsx +38 -22
  73. package/bin/garden.mjs +129 -19
  74. package/docs/handbook/ai-writing.md +2 -2
  75. package/docs/handbook/assets/reading-layout-en.svg +26 -0
  76. package/docs/handbook/components/card.md +43 -22
  77. package/docs/handbook/components/chart.md +10 -26
  78. package/docs/handbook/components/choice.md +42 -0
  79. package/docs/handbook/components/compare.md +32 -17
  80. package/docs/handbook/components/figure.md +33 -46
  81. package/docs/handbook/components/file.md +10 -15
  82. package/docs/handbook/components/html.md +10 -30
  83. package/docs/handbook/components/interactive.md +9 -35
  84. package/docs/handbook/components/list.md +65 -0
  85. package/docs/handbook/components/relations.md +95 -0
  86. package/docs/handbook/components/stat.md +10 -28
  87. package/docs/handbook/components/timeline.md +13 -28
  88. package/docs/handbook/components.md +210 -19
  89. package/docs/handbook/config.md +5 -3
  90. package/docs/handbook/deploy.md +1 -1
  91. package/docs/handbook/design.md +20 -12
  92. package/docs/handbook/en/ai-writing.md +2 -2
  93. package/docs/handbook/en/components/card.md +42 -21
  94. package/docs/handbook/en/components/chart.md +10 -26
  95. package/docs/handbook/en/components/choice.md +42 -0
  96. package/docs/handbook/en/components/compare.md +30 -15
  97. package/docs/handbook/en/components/figure.md +29 -42
  98. package/docs/handbook/en/components/file.md +9 -14
  99. package/docs/handbook/en/components/html.md +9 -29
  100. package/docs/handbook/en/components/interactive.md +8 -34
  101. package/docs/handbook/en/components/list.md +65 -0
  102. package/docs/handbook/en/components/relations.md +95 -0
  103. package/docs/handbook/en/components/stat.md +9 -27
  104. package/docs/handbook/en/components/timeline.md +9 -24
  105. package/docs/handbook/en/components.md +211 -19
  106. package/docs/handbook/en/config.md +32 -30
  107. package/docs/handbook/en/deploy.md +12 -12
  108. package/docs/handbook/en/design.md +19 -12
  109. package/docs/handbook/en/index.md +2 -2
  110. package/docs/handbook/en/reference/card.md +85 -0
  111. package/docs/handbook/en/reference/chart.md +68 -0
  112. package/docs/handbook/en/reference/choice.md +69 -0
  113. package/docs/handbook/en/reference/compare.md +59 -0
  114. package/docs/handbook/en/reference/figure.md +84 -0
  115. package/docs/handbook/en/reference/file/checklist.md +17 -0
  116. package/docs/handbook/en/reference/file.md +50 -0
  117. package/docs/handbook/en/reference/html.md +62 -0
  118. package/docs/handbook/en/reference/interactive.md +71 -0
  119. package/docs/handbook/en/reference/list.md +72 -0
  120. package/docs/handbook/en/reference/relations.md +113 -0
  121. package/docs/handbook/en/reference/stat.md +60 -0
  122. package/docs/handbook/en/reference/timeline.md +56 -0
  123. package/docs/handbook/en/reference.md +26 -0
  124. package/docs/handbook/en/start.md +2 -2
  125. package/docs/handbook/en/workflow.md +6 -5
  126. package/docs/handbook/en/writing.md +3 -2
  127. package/docs/handbook/index.md +1 -1
  128. package/docs/handbook/reference/card.md +85 -0
  129. package/docs/handbook/reference/chart.md +68 -0
  130. package/docs/handbook/reference/choice.md +69 -0
  131. package/docs/handbook/reference/compare.md +59 -0
  132. package/docs/handbook/reference/figure.md +84 -0
  133. package/docs/handbook/reference/file/checklist.md +17 -0
  134. package/docs/handbook/reference/file.md +50 -0
  135. package/docs/handbook/reference/html.md +62 -0
  136. package/docs/handbook/reference/interactive.md +71 -0
  137. package/docs/handbook/reference/list.md +72 -0
  138. package/docs/handbook/reference/relations.md +113 -0
  139. package/docs/handbook/reference/stat.md +60 -0
  140. package/docs/handbook/reference/timeline.md +56 -0
  141. package/docs/handbook/reference.md +26 -0
  142. package/docs/handbook/start.md +1 -1
  143. package/docs/handbook/workflow.md +6 -5
  144. package/docs/handbook/writing.md +3 -2
  145. package/next.config.ts +6 -0
  146. package/package.json +2 -1
  147. package/public/images/garden/ref-card-book.svg +1 -0
  148. package/public/images/garden/ref-card-lamp.svg +1 -0
  149. package/scripts/build-content.mjs +37 -16
  150. package/scripts/favicon-ico.mjs +9 -6
  151. package/scripts/file-preview.mjs +10 -7
  152. package/scripts/handbook-allowlist.mjs +18 -0
  153. package/scripts/handbook-facts.mjs +305 -11
  154. package/scripts/link-assets.mjs +7 -0
  155. package/scripts/new-post.mjs +18 -9
  156. package/scripts/prepare-pages-output.mjs +30 -18
  157. package/scripts/redirects.mjs +17 -6
  158. package/templates/garden-blocks.md +67 -0
  159. package/templates/garden-snippets.plist +61 -0
  160. package/templates/site/Makefile.en +45 -0
  161. package/templates/site/README.en.md +15 -0
  162. package/templates/site/site.config.en.yaml +55 -0
  163. package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
  164. package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
  165. package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
  166. package/templates/skills-en/garden-post-review/SKILL.md +63 -0
  167. package/templates/skills-en/garden-ui/SKILL.md +118 -0
  168. package/templates/workflows/pages.en.yml +195 -0
  169. package/templates/workflows/pages.yml +195 -0
  170. package/ui/garden/catalog.mjs +205 -4
  171. package/ui/garden/check.mjs +6 -0
  172. package/ui/garden/file-block.mjs +8 -7
  173. package/ui/garden/html-lang.mjs +73 -1
  174. package/ui/garden/markdown-links.mjs +7 -4
  175. package/ui/garden/parse.mjs +45 -0
  176. package/ui/garden/relations.mjs +252 -0
  177. package/ui/garden/search-index.mjs +6 -4
  178. package/ui/garden/source-icon.mjs +54 -0
  179. package/ui/parity-fixtures.mjs +77 -0
  180. package/ui/preview/dist/garden-ui.esm.js +41 -4
  181. package/ui/preview/dist/garden-ui.iife.js +41 -4
  182. package/ui/prompt/rules.md +28 -1
  183. package/ui/react/components/chart.tsx +1 -1
  184. package/ui/react/components/display.tsx +1 -1
  185. package/ui/react/lang.tsx +4 -0
  186. package/ui/react/parity.tsx +8 -6
  187. package/worker/index.ts +8 -2
@@ -35,6 +35,14 @@ import { trailingSlashEnabled, withTrailingSlash } from "../ui/garden/site-href.
35
35
 
36
36
  const gardenPackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
37
 
38
+ /** Column status is a framework enum. Older sites wrote the Chinese label, so map those too. */
39
+ const COLUMN_STATUS = { ongoing: "ongoing", completed: "completed", default: "default", 持续更新: "default", 连载中: "ongoing", 更新中: "ongoing", 已完结: "completed", 完结: "completed" };
40
+ function columnStatusOf(raw) {
41
+ if (raw === undefined) return "default";
42
+ const key = raw.trim().toLowerCase();
43
+ return Object.hasOwn(COLUMN_STATUS, key) ? COLUMN_STATUS[key] : raw.trim();
44
+ }
45
+
38
46
  function resolveSiteRoot() {
39
47
  if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
40
48
  const yaml = path.join(process.cwd(), "site.config.yaml");
@@ -56,6 +64,9 @@ const includeDrafts = process.env.CONTENT_INCLUDE_DRAFTS === "1";
56
64
  const uiMode = includeDrafts || process.env.GARDEN_CONTENT_MODE === "preview" ? "preview" : "publish";
57
65
  const now = Date.now();
58
66
  const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
67
+ let previewIconCache;
68
+ /** The site's base-path-aware icon for file previews, so they never fall back to /favicon.ico. */
69
+ const previewIcon = () => (previewIconCache ??= resolveBrand(root, siteConfig, basePath, () => {}).favicon ?? "");
59
70
  const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com")
60
71
  .replace(/\/+$/u, "");
61
72
  const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
@@ -66,6 +77,7 @@ const siteAddress = configuredSiteAddress(siteConfig, process.env);
66
77
  const sameSite = sameSiteMatcher(siteAddress, basePath);
67
78
  const siteName = process.env.SITE_NAME ?? siteConfig.name;
68
79
  const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
80
+ const siteLang = /^en/iu.test(String(siteConfig.locale ?? "")) ? "en" : "zh";
69
81
 
70
82
  const fail = (sourcePath, message) => {
71
83
  throw new Error(`${sourcePath}: ${message}`);
@@ -296,11 +308,12 @@ function resolveFileEntry(file, markdownPath, sourcePath, options = {}) {
296
308
  const assetOptions = options.assetOptions ?? {};
297
309
  const src = file.src;
298
310
  const name = fileName(src);
311
+ const lang = options.lang ?? siteLang;
299
312
  const entry = {
300
313
  name,
301
314
  title: file.title || name,
302
315
  ...(file.note ? { note: file.note } : {}),
303
- format: formatLabel(src),
316
+ format: formatLabel(src, lang),
304
317
  icon: fileIcon(src),
305
318
  };
306
319
  if (isRemoteSrc(src)) {
@@ -324,9 +337,9 @@ function resolveFileEntry(file, markdownPath, sourcePath, options = {}) {
324
337
  // Rendered now into its own file next to the copy; the page only links to it and the
325
338
  // preview fetches it on demand. Images next to the Markdown file are copied too.
326
339
  const { content } = matter(fs.readFileSync(resolved.path, "utf8"));
327
- const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath, assetOptions));
340
+ const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath, assetOptions), { lang });
328
341
  const { destination } = assetFiles.get(url.split(/[?#]/u, 1)[0]);
329
- fs.writeFileSync(previewFileName(destination), previewDocument(html, { title: entry.title }));
342
+ fs.writeFileSync(previewFileName(destination), previewDocument(html, { title: entry.title, lang, icon: previewIcon() }));
330
343
  next.previewSrc = previewFileName(url.split(/[?#]/u, 1)[0]);
331
344
  }
332
345
  return next;
@@ -351,6 +364,9 @@ function rewriteFileFences(content, markdownPath, sourcePath, options = {}) {
351
364
  layout: read.layout,
352
365
  ...(read.title ? { title: read.title } : {}),
353
366
  files: read.files.map((file) => resolveFileEntry(file, markdownPath, sourcePath, options)),
367
+ // The YAML as written, so a page that shows its own source (the
368
+ // component reference) shows what the author typed, not this data.
369
+ source: fence.body.replace(/\n$/u, ""),
354
370
  };
355
371
  const indent = " ".repeat(fence.indent);
356
372
  lines.splice(
@@ -505,6 +521,7 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath, lin
505
521
  ),
506
522
  markdownPath,
507
523
  sourcePath,
524
+ { lang: siteLang },
508
525
  ),
509
526
  };
510
527
  }
@@ -619,7 +636,7 @@ function readColumnReferences(posts) {
619
636
  column: slug,
620
637
  columnTitle: title,
621
638
  columnDescription: description,
622
- columnStatus: optionalString(value, "status", sourcePath) ?? "持续更新",
639
+ columnStatus: columnStatusOf(optionalString(value, "status", sourcePath)),
623
640
  columnCover: optionalString(value, "cover", sourcePath),
624
641
  columnCoverAlt: optionalString(value, "coverAlt", sourcePath),
625
642
  order: order + 1,
@@ -674,6 +691,8 @@ function readHandbookDocs() {
674
691
  );
675
692
  }
676
693
 
694
+ const docLang = docPath === "en" || docPath.startsWith("en/") ? "en" : "zh";
695
+ if (siteLang === "en" && docLang === "zh") return [];
677
696
  const content = rewriteFileFences(
678
697
  rewriteMarkdownAssets(
679
698
  rewriteUiFences(parsed.content.trim(), fullPath, sourcePath, frontMatterLines + leadingLines(parsed.content), assetOptions),
@@ -683,7 +702,7 @@ function readHandbookDocs() {
683
702
  ),
684
703
  fullPath,
685
704
  sourcePath,
686
- fileOptions,
705
+ { ...fileOptions, lang: docLang },
687
706
  );
688
707
 
689
708
  return [{
@@ -724,8 +743,8 @@ const allHandbookDocs = readHandbookDocs().map((doc) =>
724
743
  ? { ...doc, lang: "en", path: doc.path === "en" ? "index" : doc.path.slice(3) }
725
744
  : { ...doc, lang: "zh" },
726
745
  );
727
- const zhHandbookDocs = allHandbookDocs.filter((doc) => doc.lang === "zh");
728
746
  const enHandbookDocs = allHandbookDocs.filter((doc) => doc.lang === "en");
747
+ const zhHandbookDocs = siteLang === "en" ? enHandbookDocs : allHandbookDocs.filter((doc) => doc.lang === "zh");
729
748
  const gardenIndexDoc = zhHandbookDocs.find((doc) => doc.path === "index") ?? null;
730
749
  const gardenDocs = zhHandbookDocs.filter((doc) => doc.path !== "index");
731
750
  const gardenIndexDocEn = enHandbookDocs.find((doc) => doc.path === "index") ?? null;
@@ -899,7 +918,7 @@ export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath, trai
899
918
  `,
900
919
  );
901
920
  fs.mkdirSync(path.join(root, "public"), { recursive: true });
902
- fs.writeFileSync(path.join(root, "public", "search-index.json"), JSON.stringify(buildSearchIndex({ posts, columns, thoughts })));
921
+ fs.writeFileSync(path.join(root, "public", "search-index.json"), JSON.stringify(buildSearchIndex({ posts, columns, thoughts }, { lang: siteLang })));
903
922
  for (const name of fs.readdirSync(path.join(root, "public"))) {
904
923
  if (/^search-full\.[0-9a-f]+\.json$/u.test(name) && name !== fullIndex.fileName) fs.rmSync(path.join(root, "public", name));
905
924
  }
@@ -952,7 +971,7 @@ function writeRssFeed(fileName, entries) {
952
971
  <link>${escapeXml(publicBaseUrl)}</link>
953
972
  <atom:link href="${escapeXml(`${publicBaseUrl}/${fileName}`)}" rel="self" type="application/rss+xml" />
954
973
  <description>${escapeXml(siteDescription)}</description>
955
- <language>zh-CN</language>
974
+ <language>${siteLang === "en" ? "en-US" : "zh-CN"}</language>
956
975
  <lastBuildDate>${feedLastBuildDate(entries)}</lastBuildDate>${rssItems}
957
976
  </channel>
958
977
  </rss>
@@ -965,14 +984,16 @@ writeRssFeed(
965
984
  posts.map((entry) => feedEntry(entry, `/blog/${entry.path}/`)),
966
985
  );
967
986
  const sitemapEntries = [
968
- "/", "/blog/", "/thoughts/", "/columns/", "/about/", "/garden/",
969
- ...gardenDocs.map((entry) => `/garden/${entry.path}/`),
970
- ...(gardenIndexDocEn ? ["/garden/en/"] : []),
971
- ...gardenDocsEn.map((entry) => `/garden/en/${entry.path}/`),
972
- ...posts.map((entry) => `/blog/${entry.path}/`),
973
- ...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
974
- ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
975
- ].map(pageAddress);
987
+ ...new Set([
988
+ "/", "/blog/", "/thoughts/", "/columns/", "/about/", "/garden/",
989
+ ...gardenDocs.map((entry) => `/garden/${entry.path}/`),
990
+ ...(gardenIndexDocEn ? ["/garden/en/"] : []),
991
+ ...gardenDocsEn.map((entry) => `/garden/en/${entry.path}/`),
992
+ ...posts.map((entry) => `/blog/${entry.path}/`),
993
+ ...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
994
+ ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
995
+ ].map(pageAddress)),
996
+ ];
976
997
  // Card URLs in published entries: the only pages the link-card Worker may read.
977
998
  fs.writeFileSync(
978
999
  path.join(publicRoot, "link-allowlist.json"),
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { parse as parseYaml } from "yaml";
16
16
  import { resolveBrand } from "./brand.mjs";
17
+ import { siteCliLang } from "../ui/core/cli-locale.mjs";
17
18
 
18
19
  export const ICO_SIZES = [16, 32, 48];
19
20
 
@@ -80,9 +81,10 @@ export async function writeFaviconIco({ source, outDir, siteRoot }) {
80
81
 
81
82
  export async function runFaviconStep({ siteRoot, env = process.env, out = process.stdout }) {
82
83
  const outDir = path.join(siteRoot, "dist", "client");
83
- const warn = (line) => out.write(`警告:${line}\n`);
84
+ const en = siteCliLang(siteRoot) === "en";
85
+ const warn = (line) => out.write(en ? `Warning: ${line}\n` : `警告:${line}\n`);
84
86
  if (!fs.existsSync(outDir)) {
85
- warn(`找不到构建产物 ${path.relative(siteRoot, outDir)},没有生成 favicon.ico。`);
87
+ warn(en ? `No build output at ${path.relative(siteRoot, outDir)}; favicon.ico was not generated.` : `找不到构建产物 ${path.relative(siteRoot, outDir)},没有生成 favicon.ico。`);
86
88
  return 0;
87
89
  }
88
90
  const configFile = path.join(siteRoot, "site.config.yaml");
@@ -91,19 +93,20 @@ export async function runFaviconStep({ siteRoot, env = process.env, out = proces
91
93
  const { favicon } = resolveBrand(siteRoot, config, basePath);
92
94
  const source = faviconSource(siteRoot, outDir, favicon, basePath);
93
95
  if (source === null) {
94
- warn(`brand.favicon 是外部地址 ${favicon},没有生成 favicon.ico。`);
96
+ warn(en ? `brand.favicon is an external URL (${favicon}); favicon.ico was not generated.` : `brand.favicon 是外部地址 ${favicon},没有生成 favicon.ico。`);
95
97
  return 0;
96
98
  }
97
99
  if (!source) {
98
- warn(`找不到图标文件 ${favicon},没有生成 favicon.ico。`);
100
+ warn(en ? `Icon file ${favicon} not found; favicon.ico was not generated.` : `找不到图标文件 ${favicon},没有生成 favicon.ico。`);
99
101
  return 0;
100
102
  }
101
103
  try {
102
104
  const result = await writeFaviconIco({ source, outDir, siteRoot });
103
- if (result.status === "kept") out.write("favicon.ico:使用 public/favicon.ico。\n");
105
+ if (result.status === "kept") out.write(en ? "favicon.ico: using public/favicon.ico.\n" : "favicon.ico:使用 public/favicon.ico。\n");
106
+ else if (en) out.write(`favicon.ico: generated from ${path.relative(siteRoot, source)}${result.sizes ? ` (${result.sizes.join(", ")} px)` : ""}.\n`);
104
107
  else out.write(`favicon.ico:由 ${path.relative(siteRoot, source)} 生成${result.sizes ? `(${result.sizes.join("、")} px)` : ""}。\n`);
105
108
  } catch (error) {
106
- warn(`favicon.ico 生成失败(${error.message}),页面仍使用 ${favicon}。`);
109
+ warn(en ? `favicon.ico generation failed (${error.message}); pages still use ${favicon}.` : `favicon.ico 生成失败(${error.message}),页面仍使用 ${favicon}。`);
107
110
  }
108
111
  return 0;
109
112
  }
@@ -68,14 +68,15 @@ export function rehypePreviewSafety() {
68
68
  * their language, outside links carry .ext. (Copy buttons, link cards and
69
69
  * favicons are article-only.)
70
70
  */
71
- export function rehypePreviewShape() {
71
+ export function rehypePreviewShape({ lang = "zh" } = {}) {
72
+ const tableAriaLabel = lang === "en" ? "Scrollable table" : "表格,可横向滚动";
72
73
  return (tree) => {
73
74
  visit(tree, "element", (node, index, parent) => {
74
75
  if (node.tagName === "table" && !(parent?.type === "element" && parent.properties?.className?.includes?.("table-wrap"))) {
75
76
  parent.children[index] = {
76
77
  type: "element",
77
78
  tagName: "div",
78
- properties: { className: ["table-wrap"], role: "region", ariaLabel: "表格,可横向滚动", tabIndex: 0 },
79
+ properties: { className: ["table-wrap"], role: "region", ariaLabel: tableAriaLabel, tabIndex: 0 },
79
80
  children: [node],
80
81
  };
81
82
  return "skip";
@@ -95,7 +96,7 @@ export function rehypePreviewShape() {
95
96
  }
96
97
 
97
98
  /** Markdown → the preview's HTML. `idPrefix` keeps heading ids apart from the article's own. */
98
- export function renderFilePreview(markdown, { idPrefix = "gf-" } = {}) {
99
+ export function renderFilePreview(markdown, { idPrefix = "gf-", lang = "zh" } = {}) {
99
100
  return String(
100
101
  unified()
101
102
  .use(remarkParse)
@@ -106,7 +107,7 @@ export function renderFilePreview(markdown, { idPrefix = "gf-" } = {}) {
106
107
  .use(rehypeSlug, { prefix: idPrefix })
107
108
  .use(rehypeHighlight)
108
109
  .use(rehypePreviewSafety)
109
- .use(rehypePreviewShape)
110
+ .use(rehypePreviewShape, { lang })
110
111
  .use(rehypeStringify)
111
112
  .processSync(markdown),
112
113
  );
@@ -201,14 +202,16 @@ export function previewStyleSheet() {
201
202
  * JavaScript who follows the 预览 link gets something readable. The drawer
202
203
  * takes only what is inside <main>.
203
204
  */
204
- export function previewDocument(html, { title = "预览" } = {}) {
205
+ export function previewDocument(html, { title = "", lang = "zh", icon = "" } = {}) {
206
+ const docTitle = title || (lang === "en" ? "Preview" : "预览");
207
+ const htmlLang = lang === "en" ? "en" : "zh-CN";
205
208
  return `<!doctype html>
206
- <html lang="zh-CN">
209
+ <html lang="${htmlLang}">
207
210
  <head>
208
211
  <meta charset="utf-8">
209
212
  <meta name="viewport" content="width=device-width, initial-scale=1">
210
213
  <meta name="robots" content="noindex">
211
- <title>${escapeText(title)}</title>
214
+ <title>${escapeText(docTitle)}</title>${icon ? `\n<link rel="icon" href="${escapeText(icon).replace(/"/gu, "&quot;")}"${/\.svg(?:[?#]|$)/iu.test(icon) ? ' type="image/svg+xml"' : ""}>` : ""}
212
215
  <style>${previewStyleSheet()}</style>
213
216
  </head>
214
217
  <body><main class="gf-preview-doc">
@@ -66,3 +66,21 @@ export const ALLOWED_NON_ENV_UPPER_SNAKE = {
66
66
  KB: "Kilobyte unit",
67
67
  GB: "Gigabyte unit",
68
68
  };
69
+
70
+ /**
71
+ * Explicit allowlist of CJK characters permitted on English handbook pages:
72
+ * - "中": the "中 / EN" language switcher in the header
73
+ * - "丨" / "|": the two-line title split delimiter character where explained in prose
74
+ * - figureAllKeyword: the literal config value `"全部"` ONLY in
75
+ * docs/handbook/en/components/figure.md on the `parts[].id` field row
76
+ */
77
+ export const ALLOWED_EN_HANDBOOK_CJK = {
78
+ switcher: "中",
79
+ titleSplitChars: new Set(["丨", "|"]),
80
+ figureAllKeyword: {
81
+ file: "docs/handbook/en/components/figure.md",
82
+ routePattern: /(?:^|\/)garden\/en\/components\/figure(?:\/index\.html|\/)?$/u,
83
+ linePattern: /^\|\s*`parts\[\]\.id`\s*\|/u,
84
+ literal: '"全部"',
85
+ },
86
+ };
@@ -12,6 +12,7 @@ import { pathToFileURL } from "node:url";
12
12
  import { parse as parseYaml } from "yaml";
13
13
  import { CATALOG } from "../ui/garden/catalog.mjs";
14
14
  import {
15
+ ALLOWED_EN_HANDBOOK_CJK,
15
16
  ALLOWED_EXTERNAL_FLAGS,
16
17
  ALLOWED_NON_ENV_UPPER_SNAKE,
17
18
  } from "./handbook-allowlist.mjs";
@@ -154,7 +155,7 @@ function deriveMakefileFacts(root = ROOT) {
154
155
  const targets = new Set();
155
156
  const variables = new Set(["MAKE"]);
156
157
 
157
- for (const rel of ["templates/site/Makefile", "Makefile"]) {
158
+ for (const rel of ["templates/site/Makefile", "templates/site/Makefile.en", "Makefile"]) {
158
159
  const file = path.join(root, rel);
159
160
  if (!fs.existsSync(file)) continue;
160
161
  const text = fs.readFileSync(file, "utf8");
@@ -217,19 +218,21 @@ function deriveEnvVars(root = ROOT) {
217
218
  }
218
219
  }
219
220
 
220
- // .env.example
221
- const envExample = path.join(root, ".env.example");
222
- if (fs.existsSync(envExample)) {
223
- const text = fs.readFileSync(envExample, "utf8");
224
- for (const match of text.matchAll(/^#?\s*([A-Z][A-Z0-9_]+)=/gmu)) {
225
- envVars.add(match[1]);
221
+ // .env.example & .env.en.example
222
+ for (const envRel of [".env.example", ".env.en.example"]) {
223
+ const envExample = path.join(root, envRel);
224
+ if (fs.existsSync(envExample)) {
225
+ const text = fs.readFileSync(envExample, "utf8");
226
+ for (const match of text.matchAll(/^#?\s*([A-Z][A-Z0-9_]+)=/gmu)) {
227
+ envVars.add(match[1]);
228
+ }
226
229
  }
227
230
  }
228
231
 
229
232
  // GitHub workflows and template Makefiles
230
233
  const workflowFiles = [
231
234
  ...walkFiles(path.join(root, ".github/workflows"), (f) => /\.ya?ml$/u.test(f)),
232
- ...walkFiles(path.join(root, "templates"), (f) => /\.ya?ml$|Makefile$/u.test(f)),
235
+ ...walkFiles(path.join(root, "templates"), (f) => /\.ya?ml$|Makefile(?:\.en)?$/u.test(f)),
233
236
  ];
234
237
  for (const file of workflowFiles) {
235
238
  const text = fs.readFileSync(file, "utf8");
@@ -272,7 +275,7 @@ function deriveSiteConfigKeys(root = ROOT) {
272
275
  }
273
276
  }
274
277
 
275
- for (const rel of ["templates/site/site.config.yaml", "site.config.yaml"]) {
278
+ for (const rel of ["templates/site/site.config.yaml", "templates/site/site.config.en.yaml", "site.config.yaml"]) {
276
279
  const file = path.join(root, rel);
277
280
  if (!fs.existsSync(file)) continue;
278
281
  const text = fs.readFileSync(file, "utf8");
@@ -357,7 +360,7 @@ function deriveCheckCodes(root = ROOT) {
357
360
  // ui/core/validate.mjs & ui/core/parse.mjs
358
361
  for (const rel of ["ui/core/validate.mjs", "ui/core/parse.mjs"]) {
359
362
  const text = fs.readFileSync(path.join(root, rel), "utf8");
360
- for (const match of text.matchAll(/\b(?:add|report)\(\s*["']([a-z0-9-]+)["']\s*,\s*[^,]+,\s*[^,)]+(?:,\s*["'](error|warning)["'])?\s*\)/gu)) {
363
+ for (const match of text.matchAll(/\b(?:add|report)\(\s*["']([a-z0-9-]+)["']\s*,\s*(?:[^,()]|\([^()]*\))+,\s*(?:[^,()]|\([^()]*\))+(?:,\s*["'](error|warning)["'])?\s*\)/gu)) {
361
364
  record(match[1], match[2] === "warning" ? "warning" : "error");
362
365
  }
363
366
  }
@@ -1088,6 +1091,294 @@ export function checkHandbookPairs(root = ROOT) {
1088
1091
  return issues;
1089
1092
  }
1090
1093
 
1094
+ const CJK_RE = /[\u3000-\u303f\u3400-\u9fff\uf900-\ufaff\uff00-\uffef]/gu;
1095
+
1096
+ function findDisallowedEnCjk(line, { allowTitleSplit = false } = {}) {
1097
+ const disallowed = [];
1098
+ for (const match of line.matchAll(CJK_RE)) {
1099
+ const ch = match[0];
1100
+ if (allowTitleSplit && ALLOWED_EN_HANDBOOK_CJK.titleSplitChars.has(ch)) continue;
1101
+ disallowed.push(ch);
1102
+ }
1103
+ return disallowed;
1104
+ }
1105
+
1106
+ /**
1107
+ * Extract local relative file paths included or embedded by an English handbook Markdown page
1108
+ * (Markdown images, HTML media tags, and `garden file` / `garden figure` / `garden html` fences).
1109
+ */
1110
+ function extractIncludedHandbookFiles(markdown, mdFile, root = ROOT) {
1111
+ const handbookRoot = path.join(root, "docs/handbook");
1112
+ const baseDir = path.dirname(mdFile);
1113
+ const included = new Set();
1114
+
1115
+ const addIfLocal = (rawRef) => {
1116
+ if (!rawRef || typeof rawRef !== "string") return;
1117
+ const cleaned = rawRef.trim().replace(/^<|>$/gu, "").split(/[?#]/u, 1)[0];
1118
+ if (!cleaned || /^(?:[a-z]+:)?\/\//iu.test(cleaned) || cleaned.startsWith("/") || cleaned.startsWith("#") || cleaned.startsWith("data:")) {
1119
+ return;
1120
+ }
1121
+ const resolved = path.resolve(baseDir, decodeURIComponent(cleaned));
1122
+ const relToHandbook = path.relative(handbookRoot, resolved);
1123
+ if (!relToHandbook.startsWith("..") && !path.isAbsolute(relToHandbook) && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
1124
+ included.add(resolved);
1125
+ }
1126
+ };
1127
+
1128
+ for (const match of markdown.matchAll(/!\[[^\]]*\]\(([^)]+)\)/gu)) {
1129
+ addIfLocal(match[1].trim().split(/\s+/u, 1)[0]);
1130
+ }
1131
+ for (const match of markdown.matchAll(/<(?:img|audio|video|source)\b[^>]*\bsrc=["']([^"']+)["']/giu)) {
1132
+ addIfLocal(match[1]);
1133
+ }
1134
+ const { fences } = parseMarkdownRegions(markdown);
1135
+ for (const fence of fences) {
1136
+ if (!fence.info.startsWith("garden ")) continue;
1137
+ const bodyText = fence.bodyLines.map((l) => l.text).join("\n");
1138
+ for (const srcMatch of bodyText.matchAll(/^\s*(?:-\s*)?(?:src|image)\s*:\s*(.+)$/gmu)) {
1139
+ addIfLocal(srcMatch[1].replace(/^(["'])(.*)\1$/u, "$2"));
1140
+ }
1141
+ }
1142
+ return [...included];
1143
+ }
1144
+
1145
+ /**
1146
+ * Strip the narrow, line-specific `"全部"` config value exception when checking
1147
+ * `docs/handbook/en/components/figure.md` on the `parts[].id` field row.
1148
+ */
1149
+ function stripAllowedFigureAllKeywordInLine(relFile, line) {
1150
+ const rule = ALLOWED_EN_HANDBOOK_CJK.figureAllKeyword;
1151
+ if (relFile === rule.file && rule.linePattern.test(line)) {
1152
+ return line.replaceAll(`\`${rule.literal}\``, "").replaceAll(rule.literal, "");
1153
+ }
1154
+ return line;
1155
+ }
1156
+
1157
+ /**
1158
+ * Strip the narrow, row-specific `<code>"全部"</code>` config value exception when
1159
+ * checking built HTML for `/garden/en/components/figure` on the `parts[].id` table row.
1160
+ */
1161
+ export function stripAllowedFigureAllKeywordInHtml(html, pageLabel = "") {
1162
+ const rule = ALLOWED_EN_HANDBOOK_CJK.figureAllKeyword;
1163
+ if (!rule.routePattern.test(String(pageLabel))) return String(html);
1164
+ return String(html).replace(/<tr\b[^>]*>[\s\S]*?<\/tr>/giu, (tr) => {
1165
+ if (!/<code>parts\[\]\.id<\/code>/u.test(tr)) return tr;
1166
+ return tr.replace(/<code>(?:"|&quot;)全部(?:"|&quot;)<\/code>/gu, '<code>"all"</code>');
1167
+ });
1168
+ }
1169
+
1170
+ /**
1171
+ * Every English handbook page under docs/handbook/en/ (including fenced code blocks and
1172
+ * any included/embedded files) must not contain CJK characters, except the explicit allowlist.
1173
+ * Note: This static check inspects Markdown source, embedded assets, and pre-rendered HTML files;
1174
+ * it does not cover client-side runtime rendering or hydration (which is verified by browser tests).
1175
+ */
1176
+ export function checkEnglishHandbookNoCjk(root = ROOT) {
1177
+ const enRoot = path.join(root, "docs/handbook/en");
1178
+ if (!fs.existsSync(enRoot)) return [];
1179
+ const issues = [];
1180
+ const checkedAssets = new Set();
1181
+
1182
+ const checkFileLines = (filePath, allowTitleSplit = false) => {
1183
+ const relFile = path.relative(root, filePath).replace(/\\/g, "/");
1184
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/u);
1185
+ for (let i = 0; i < lines.length; i += 1) {
1186
+ const line = lines[i];
1187
+ const lineAllowSplit = allowTitleSplit && /(?:title|split|delimiter|heading|two-line|` \| `)/iu.test(line);
1188
+ const scannedLine = stripAllowedFigureAllKeywordInLine(relFile, line);
1189
+ const bad = findDisallowedEnCjk(scannedLine, { allowTitleSplit: lineAllowSplit });
1190
+ if (bad.length > 0) {
1191
+ const sample = [...new Set(bad)].slice(0, 8).join("");
1192
+ issues.push({
1193
+ file: relFile,
1194
+ line: i + 1,
1195
+ kind: "en-cjk",
1196
+ message: `English handbook contains CJK character(s) "${sample}": ${line.trim().slice(0, 80)}`,
1197
+ });
1198
+ }
1199
+ }
1200
+ };
1201
+
1202
+ const enFiles = walkFiles(enRoot);
1203
+ for (const file of enFiles) {
1204
+ checkedAssets.add(file);
1205
+ checkFileLines(file, file.endsWith(".md"));
1206
+ if (file.endsWith(".md")) {
1207
+ const content = fs.readFileSync(file, "utf8");
1208
+ for (const asset of extractIncludedHandbookFiles(content, file, root)) {
1209
+ if (checkedAssets.has(asset)) continue;
1210
+ checkedAssets.add(asset);
1211
+ if (/\.(?:md|markdown|svg|html|htm|txt|csv|tsv|json|ya?ml)$/iu.test(asset)) {
1212
+ checkFileLines(asset, false);
1213
+ }
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ const builtPreviewDir = path.join(root, "public/media/garden-docs/en");
1219
+ for (const file of walkFiles(builtPreviewDir)) {
1220
+ if (checkedAssets.has(file)) continue;
1221
+ checkedAssets.add(file);
1222
+ if (/\.(?:md|markdown|svg|html|htm|txt|csv|tsv|json|ya?ml)$/iu.test(file)) {
1223
+ checkFileLines(file, false);
1224
+ }
1225
+ }
1226
+
1227
+ return issues;
1228
+ }
1229
+
1230
+ /**
1231
+ * Inspect a single built HTML document for an English handbook route (or the
1232
+ * English 404 output), covering <head> meta tags, <title>, visible text, and
1233
+ * user-facing attributes (`content`, `alt`, `title`, `aria-label`, `placeholder`).
1234
+ * Only the `中` language switcher, the `丨` / `|` title-split delimiter explanation,
1235
+ * and the narrow `"全部"` config value on `/garden/en/components/figure` (`parts[].id` row)
1236
+ * are permitted.
1237
+ * Note: This check inspects static HTML markup only and does not cover runtime rendering
1238
+ * or client-side hydration in a real browser.
1239
+ */
1240
+ export function findBuiltEnglishHtmlCjk(html, pageLabel = "/garden/en") {
1241
+ const issues = [];
1242
+ const cleaned = stripAllowedFigureAllKeywordInHtml(
1243
+ String(html)
1244
+ .replace(/<script[\s\S]*?<\/script>/giu, "")
1245
+ .replace(/<style[\s\S]*?<\/style>/giu, ""),
1246
+ pageLabel,
1247
+ );
1248
+
1249
+ const recordIfCjk = (value, context, allowTitleSplit = false) => {
1250
+ const text = String(value ?? "").trim();
1251
+ if (!text) return;
1252
+ if (text === ALLOWED_EN_HANDBOOK_CJK.switcher) return;
1253
+ const bad = findDisallowedEnCjk(text, { allowTitleSplit });
1254
+ if (bad.length > 0) {
1255
+ const sample = [...new Set(bad)].slice(0, 8).join("");
1256
+ issues.push({
1257
+ file: pageLabel,
1258
+ line: 1,
1259
+ kind: "en-built-cjk",
1260
+ message: `Built English HTML (${context}) contains CJK character(s) "${sample}": ${text.slice(0, 100)}`,
1261
+ });
1262
+ }
1263
+ };
1264
+
1265
+ // 1. Check <head> <title> and <meta> tags
1266
+ const headMatch = /<head\b[^>]*>([\s\S]*?)<\/head>/iu.exec(cleaned);
1267
+ const headHtml = headMatch ? headMatch[1] : "";
1268
+ for (const m of headHtml.matchAll(/<title\b[^>]*>([\s\S]*?)<\/title>/giu)) {
1269
+ recordIfCjk(m[1], "<title>", false);
1270
+ }
1271
+ for (const m of headHtml.matchAll(/<meta\b[^>]*>/giu)) {
1272
+ const tag = m[0];
1273
+ const nameOrProp =
1274
+ /\b(?:name|property)=["']([^"']*)["']/iu.exec(tag)?.[1] ?? "<meta>";
1275
+ const content = /\bcontent=["']([^"']*)["']/iu.exec(tag)?.[1] ?? "";
1276
+ recordIfCjk(content, `meta[${nameOrProp}]`, false);
1277
+ }
1278
+
1279
+ // 2. Check user-facing attributes across the entire document
1280
+ for (const m of cleaned.matchAll(/\s(aria-label|title|placeholder|alt)=["']([^"']*)["']/giu)) {
1281
+ recordIfCjk(m[2], `attr[${m[1]}]`, false);
1282
+ }
1283
+
1284
+ // 3. Check all text nodes across the entire document
1285
+ for (const m of cleaned.matchAll(/>([^<]+)</gu)) {
1286
+ const text = m[1].trim();
1287
+ if (!text) continue;
1288
+ recordIfCjk(text, "text", true);
1289
+ }
1290
+
1291
+ return issues;
1292
+ }
1293
+
1294
+ /**
1295
+ * Check any static-export HTML files already present under dist/client/garden/en/.
1296
+ * Note: Static CJK checks do not cover runtime rendering or hydration of routes such as
1297
+ * the shared root 404.html; runtime rendering is verified separately in a real browser.
1298
+ */
1299
+ export function checkBuiltEnglishHandbookHtmlFiles(root = ROOT) {
1300
+ const clientDir = path.join(root, "dist/client");
1301
+ const clientEnDir = path.join(clientDir, "garden/en");
1302
+ const issues = [];
1303
+ if (fs.existsSync(clientEnDir)) {
1304
+ for (const file of walkFiles(clientEnDir, (f) => f.endsWith(".html"))) {
1305
+ const rel = path.relative(root, file).replace(/\\/g, "/");
1306
+ const html = fs.readFileSync(file, "utf8");
1307
+ issues.push(...findBuiltEnglishHtmlCjk(html, rel));
1308
+ }
1309
+ }
1310
+ return issues;
1311
+ }
1312
+
1313
+ const HANDBOOK_EMBEDDED_TEMPLATES = [
1314
+ {
1315
+ handbookFile: "docs/handbook/config.md",
1316
+ templateFile: "templates/site/site.config.yaml",
1317
+ label: "site.config.yaml",
1318
+ },
1319
+ {
1320
+ handbookFile: "docs/handbook/en/config.md",
1321
+ templateFile: "templates/site/site.config.en.yaml",
1322
+ label: "site.config.en.yaml",
1323
+ },
1324
+ {
1325
+ handbookFile: "docs/handbook/deploy.md",
1326
+ templateFile: "templates/workflows/pages.yml",
1327
+ label: "pages.yml",
1328
+ },
1329
+ {
1330
+ handbookFile: "docs/handbook/en/deploy.md",
1331
+ templateFile: "templates/workflows/pages.en.yml",
1332
+ label: "pages.en.yml",
1333
+ },
1334
+ ];
1335
+
1336
+ /**
1337
+ * Verify that the Chinese and English handbook config/deploy pages embed their
1338
+ * corresponding site.config.yaml and pages.yml templates verbatim.
1339
+ */
1340
+ export function checkHandbookTemplates(root = ROOT) {
1341
+ const issues = [];
1342
+ for (const { handbookFile, templateFile, label } of HANDBOOK_EMBEDDED_TEMPLATES) {
1343
+ const fullHandbook = path.join(root, handbookFile);
1344
+ const fullTemplate = path.join(root, templateFile);
1345
+ if (!fs.existsSync(fullHandbook) || !fs.existsSync(fullTemplate)) continue;
1346
+ const expected = fs.readFileSync(fullTemplate, "utf8").replace(/\r\n/gu, "\n").trimEnd();
1347
+ const { fences } = parseMarkdownRegions(fs.readFileSync(fullHandbook, "utf8"));
1348
+ const yamlFence = fences.find((f) => f.lang === "yaml" || f.lang === "yml");
1349
+ if (!yamlFence) {
1350
+ issues.push({
1351
+ file: handbookFile,
1352
+ line: 1,
1353
+ kind: "template-drift",
1354
+ message: `Missing YAML fence for template ${templateFile} (${label})`,
1355
+ });
1356
+ continue;
1357
+ }
1358
+ const actual = yamlFence.bodyLines.map((l) => l.text).join("\n").replace(/\r\n/gu, "\n").trimEnd();
1359
+ if (actual !== expected) {
1360
+ const actualLines = actual.split("\n");
1361
+ const expectedLines = expected.split("\n");
1362
+ let diffIdx = 0;
1363
+ while (
1364
+ diffIdx < actualLines.length &&
1365
+ diffIdx < expectedLines.length &&
1366
+ actualLines[diffIdx] === expectedLines[diffIdx]
1367
+ ) {
1368
+ diffIdx += 1;
1369
+ }
1370
+ const line = yamlFence.bodyLines[diffIdx]?.lineNumber ?? yamlFence.startLine;
1371
+ issues.push({
1372
+ file: handbookFile,
1373
+ line,
1374
+ kind: "template-drift",
1375
+ message: `Embedded ${label} fence does not match ${templateFile} verbatim (first difference at template line ${diffIdx + 1})`,
1376
+ });
1377
+ }
1378
+ }
1379
+ return issues;
1380
+ }
1381
+
1091
1382
  /**
1092
1383
  * Scan all docs/handbook/ and app/garden/ files and return any fact-check failures.
1093
1384
  */
@@ -1103,6 +1394,9 @@ export function checkAllHandbookFacts(root = ROOT) {
1103
1394
  issues.push(...checkFileFacts(file, content, facts, root));
1104
1395
  }
1105
1396
  issues.push(...checkHandbookPairs(root));
1397
+ issues.push(...checkHandbookTemplates(root));
1398
+ issues.push(...checkEnglishHandbookNoCjk(root));
1399
+ issues.push(...checkBuiltEnglishHandbookHtmlFiles(root));
1106
1400
  const svgFiles = walkFiles(path.join(root, "docs/handbook"), (f) => f.endsWith(".svg"));
1107
1401
  for (const svgFile of svgFiles) {
1108
1402
  const svgContent = fs.readFileSync(svgFile, "utf8");
@@ -1133,6 +1427,6 @@ if (invokedDirectly) {
1133
1427
  }
1134
1428
  process.exitCode = 1;
1135
1429
  } else {
1136
- console.log("Handbook fact-check passed: 0 issues.");
1430
+ console.log("Handbook fact-check passed: 0 issues (static source/HTML check; does not cover runtime rendering).");
1137
1431
  }
1138
1432
  }