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.
- package/.agents/skills/garden-ui/SKILL.md +2 -2
- package/.env.en.example +67 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/app/a2/[slug]/page.tsx +4 -3
- package/app/a2/page.tsx +4 -3
- package/app/about/page.tsx +8 -9
- package/app/admin/page.tsx +7 -6
- package/app/admin/preview/[...path]/page.tsx +2 -1
- package/app/admin/ui/blocks/page.tsx +2 -1
- package/app/admin/ui/catalog/page.tsx +2 -1
- package/app/admin/ui/evals/page.tsx +2 -1
- package/app/admin/ui/page.tsx +2 -1
- package/app/blog/[...path]/page.tsx +7 -6
- package/app/blog/page.tsx +4 -2
- package/app/columns/[column]/[...path]/page.tsx +8 -7
- package/app/columns/page.tsx +12 -8
- package/app/components/AdminUiShell.tsx +4 -3
- package/app/components/ArticleMeasure.tsx +6 -3
- package/app/components/ArticleReferences.tsx +4 -1
- package/app/components/ArticleTags.tsx +3 -2
- package/app/components/BlogArchive.tsx +20 -16
- package/app/components/Comments.tsx +16 -10
- package/app/components/ContentEmptyState.tsx +29 -28
- package/app/components/ExternalEmbed.tsx +15 -14
- package/app/components/GardenContentGuide.tsx +10 -2
- package/app/components/GardenDocsNav.tsx +4 -2
- package/app/components/GiscusComments.tsx +2 -1
- package/app/components/GithubLiveOverview.tsx +10 -8
- package/app/components/GithubProfile.tsx +31 -21
- package/app/components/HandbookLangSwitch.tsx +8 -4
- package/app/components/HandbookView.tsx +92 -9
- package/app/components/MarkdownArticle.tsx +31 -7
- package/app/components/MermaidDiagram.tsx +4 -3
- package/app/components/SiteChrome.tsx +3 -3
- package/app/components/SiteFooter.tsx +1 -1
- package/app/components/SiteHeader.tsx +13 -4
- package/app/components/ThoughtCard.tsx +16 -14
- package/app/components/UiBlock.tsx +3 -2
- package/app/components/Webmentions.tsx +11 -11
- package/app/components/WidgetChart.tsx +8 -6
- package/app/components/garden/FilePreview.tsx +2 -2
- package/app/components/garden/GardenBlock.tsx +7 -0
- package/app/components/garden/GardenFile.tsx +2 -2
- package/app/components/garden/RefDemo.tsx +62 -0
- package/app/components/garden/blocks.tsx +56 -8
- package/app/components/garden/patterns.tsx +497 -0
- package/app/garden/[...slug]/page.tsx +3 -2
- package/app/garden/en/page.tsx +7 -7
- package/app/garden/page.tsx +3 -2
- package/app/globals.css +49 -1
- package/app/lib/a2-meta.ts +26 -3
- package/app/lib/content.ts +2 -2
- package/app/lib/garden-docs-meta.ts +24 -0
- package/app/lib/github-id.mjs +8 -0
- package/app/lib/handbook-switch.mjs +12 -0
- package/app/lib/i18n.ts +401 -2
- package/app/lib/rehype-field-tables.mjs +47 -0
- package/app/lib/remark-a2.mjs +18 -8
- package/app/lib/remark-link-card.mjs +22 -0
- package/app/lib/remark-ref-demos.mjs +42 -0
- package/app/lib/tag-filter.ts +9 -2
- package/app/not-found.tsx +32 -4
- package/app/page.tsx +17 -14
- package/app/rss.xml/route.ts +2 -1
- package/app/styles/a2.css +338 -0
- package/app/styles/shell.css +15 -2
- package/app/tags/page.tsx +7 -5
- package/app/thoughts/[slug]/page.tsx +2 -1
- package/app/thoughts/page/[n]/page.tsx +1 -1
- package/app/thoughts/page.tsx +2 -1
- package/app/ui-parity/page.tsx +38 -22
- package/bin/garden.mjs +129 -19
- package/docs/handbook/ai-writing.md +2 -2
- package/docs/handbook/assets/reading-layout-en.svg +26 -0
- package/docs/handbook/components/card.md +43 -22
- package/docs/handbook/components/chart.md +10 -26
- package/docs/handbook/components/choice.md +42 -0
- package/docs/handbook/components/compare.md +32 -17
- package/docs/handbook/components/figure.md +33 -46
- package/docs/handbook/components/file.md +10 -15
- package/docs/handbook/components/html.md +10 -30
- package/docs/handbook/components/interactive.md +9 -35
- package/docs/handbook/components/list.md +65 -0
- package/docs/handbook/components/relations.md +95 -0
- package/docs/handbook/components/stat.md +10 -28
- package/docs/handbook/components/timeline.md +13 -28
- package/docs/handbook/components.md +210 -19
- package/docs/handbook/config.md +5 -3
- package/docs/handbook/deploy.md +1 -1
- package/docs/handbook/design.md +20 -12
- package/docs/handbook/en/ai-writing.md +2 -2
- package/docs/handbook/en/components/card.md +42 -21
- package/docs/handbook/en/components/chart.md +10 -26
- package/docs/handbook/en/components/choice.md +42 -0
- package/docs/handbook/en/components/compare.md +30 -15
- package/docs/handbook/en/components/figure.md +29 -42
- package/docs/handbook/en/components/file.md +9 -14
- package/docs/handbook/en/components/html.md +9 -29
- package/docs/handbook/en/components/interactive.md +8 -34
- package/docs/handbook/en/components/list.md +65 -0
- package/docs/handbook/en/components/relations.md +95 -0
- package/docs/handbook/en/components/stat.md +9 -27
- package/docs/handbook/en/components/timeline.md +9 -24
- package/docs/handbook/en/components.md +211 -19
- package/docs/handbook/en/config.md +32 -30
- package/docs/handbook/en/deploy.md +12 -12
- package/docs/handbook/en/design.md +19 -12
- package/docs/handbook/en/index.md +2 -2
- package/docs/handbook/en/reference/card.md +85 -0
- package/docs/handbook/en/reference/chart.md +68 -0
- package/docs/handbook/en/reference/choice.md +69 -0
- package/docs/handbook/en/reference/compare.md +59 -0
- package/docs/handbook/en/reference/figure.md +84 -0
- package/docs/handbook/en/reference/file/checklist.md +17 -0
- package/docs/handbook/en/reference/file.md +50 -0
- package/docs/handbook/en/reference/html.md +62 -0
- package/docs/handbook/en/reference/interactive.md +71 -0
- package/docs/handbook/en/reference/list.md +72 -0
- package/docs/handbook/en/reference/relations.md +113 -0
- package/docs/handbook/en/reference/stat.md +60 -0
- package/docs/handbook/en/reference/timeline.md +56 -0
- package/docs/handbook/en/reference.md +26 -0
- package/docs/handbook/en/start.md +2 -2
- package/docs/handbook/en/workflow.md +6 -5
- package/docs/handbook/en/writing.md +3 -2
- package/docs/handbook/index.md +1 -1
- package/docs/handbook/reference/card.md +85 -0
- package/docs/handbook/reference/chart.md +68 -0
- package/docs/handbook/reference/choice.md +69 -0
- package/docs/handbook/reference/compare.md +59 -0
- package/docs/handbook/reference/figure.md +84 -0
- package/docs/handbook/reference/file/checklist.md +17 -0
- package/docs/handbook/reference/file.md +50 -0
- package/docs/handbook/reference/html.md +62 -0
- package/docs/handbook/reference/interactive.md +71 -0
- package/docs/handbook/reference/list.md +72 -0
- package/docs/handbook/reference/relations.md +113 -0
- package/docs/handbook/reference/stat.md +60 -0
- package/docs/handbook/reference/timeline.md +56 -0
- package/docs/handbook/reference.md +26 -0
- package/docs/handbook/start.md +1 -1
- package/docs/handbook/workflow.md +6 -5
- package/docs/handbook/writing.md +3 -2
- package/next.config.ts +6 -0
- package/package.json +2 -1
- package/public/images/garden/ref-card-book.svg +1 -0
- package/public/images/garden/ref-card-lamp.svg +1 -0
- package/scripts/build-content.mjs +37 -16
- package/scripts/favicon-ico.mjs +9 -6
- package/scripts/file-preview.mjs +10 -7
- package/scripts/handbook-allowlist.mjs +18 -0
- package/scripts/handbook-facts.mjs +305 -11
- package/scripts/link-assets.mjs +7 -0
- package/scripts/new-post.mjs +18 -9
- package/scripts/prepare-pages-output.mjs +30 -18
- package/scripts/redirects.mjs +17 -6
- package/templates/garden-blocks.md +67 -0
- package/templates/garden-snippets.plist +61 -0
- package/templates/site/Makefile.en +45 -0
- package/templates/site/README.en.md +15 -0
- package/templates/site/site.config.en.yaml +55 -0
- package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
- package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
- package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
- package/templates/skills-en/garden-post-review/SKILL.md +63 -0
- package/templates/skills-en/garden-ui/SKILL.md +118 -0
- package/templates/workflows/pages.en.yml +195 -0
- package/templates/workflows/pages.yml +195 -0
- package/ui/garden/catalog.mjs +205 -4
- package/ui/garden/check.mjs +6 -0
- package/ui/garden/file-block.mjs +8 -7
- package/ui/garden/html-lang.mjs +73 -1
- package/ui/garden/markdown-links.mjs +7 -4
- package/ui/garden/parse.mjs +45 -0
- package/ui/garden/relations.mjs +252 -0
- package/ui/garden/search-index.mjs +6 -4
- package/ui/garden/source-icon.mjs +54 -0
- package/ui/parity-fixtures.mjs +77 -0
- package/ui/preview/dist/garden-ui.esm.js +41 -4
- package/ui/preview/dist/garden-ui.iife.js +41 -4
- package/ui/prompt/rules.md +28 -1
- package/ui/react/components/chart.tsx +1 -1
- package/ui/react/components/display.tsx +1 -1
- package/ui/react/lang.tsx +4 -0
- package/ui/react/parity.tsx +8 -6
- 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
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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"),
|
package/scripts/favicon-ico.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/scripts/file-preview.mjs
CHANGED
|
@@ -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:
|
|
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="
|
|
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(
|
|
214
|
+
<title>${escapeText(docTitle)}</title>${icon ? `\n<link rel="icon" href="${escapeText(icon).replace(/"/gu, """)}"${/\.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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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>(?:"|")全部(?:"|")<\/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
|
}
|