hd-garden 0.6.0 → 0.7.0

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 (46) hide show
  1. package/app/admin/preview/[...path]/page.tsx +1 -1
  2. package/app/blog/[...path]/page.tsx +2 -2
  3. package/app/columns/[column]/[...path]/page.tsx +2 -2
  4. package/app/components/ArticleMeasure.tsx +39 -4
  5. package/app/components/GardenDocsNav.tsx +184 -0
  6. package/app/components/MarkdownArticle.tsx +7 -6
  7. package/app/components/SiteChrome.tsx +1 -1
  8. package/app/components/SiteFooter.tsx +10 -3
  9. package/app/garden/[...slug]/page.tsx +148 -0
  10. package/app/garden/page.tsx +61 -128
  11. package/app/garden-modules.d.ts +15 -0
  12. package/app/globals.css +10 -10
  13. package/app/lib/content.ts +16 -10
  14. package/app/lib/garden-docs-meta.ts +83 -0
  15. package/app/lib/heading-number.ts +13 -0
  16. package/app/styles/a2.css +531 -0
  17. package/docs/handbook/assets/reading-layout.svg +26 -0
  18. package/docs/handbook/components/card.md +44 -0
  19. package/docs/handbook/components/chart.md +52 -0
  20. package/docs/handbook/components/compare.md +40 -0
  21. package/docs/handbook/components/figure.md +70 -0
  22. package/docs/handbook/components/file/checklist.md +17 -0
  23. package/docs/handbook/components/file.md +32 -0
  24. package/docs/handbook/components/html.md +67 -0
  25. package/docs/handbook/components/interactive.md +65 -0
  26. package/docs/handbook/components/stat.md +55 -0
  27. package/docs/handbook/components/timeline.md +50 -0
  28. package/docs/handbook/components.md +30 -0
  29. package/docs/handbook/config.md +125 -0
  30. package/docs/handbook/deploy.md +273 -0
  31. package/docs/handbook/design.md +81 -0
  32. package/docs/handbook/index.md +92 -0
  33. package/docs/handbook/start.md +92 -0
  34. package/docs/handbook/workflow.md +81 -0
  35. package/docs/handbook/writing.md +148 -0
  36. package/package.json +2 -1
  37. package/scripts/build-content.mjs +124 -15
  38. package/scripts/handbook-allowlist.mjs +68 -0
  39. package/scripts/handbook-facts.mjs +1104 -0
  40. package/scripts/prepare-pages-output.mjs +32 -9
  41. package/site.config.yaml +2 -2
  42. package/templates/garden-blocks.md +1 -1
  43. package/templates/site/package.json +1 -3
  44. package/ui/garden/catalog.mjs +1 -1
  45. package/ui/preview/dist/garden-ui.esm.js +1 -1
  46. package/ui/preview/dist/garden-ui.iife.js +1 -1
@@ -0,0 +1,1104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Derives ground-truth CLI commands, flags, Makefile targets/variables,
4
+ * environment variables, site.config.yaml keys, diagnostic check codes
5
+ * (with severity), and CSS custom property colours directly from the
6
+ * repository source files, then checks docs/handbook/ and app/garden/
7
+ * against those derived facts.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { parse as parseYaml } from "yaml";
13
+ import { CATALOG } from "../ui/garden/catalog.mjs";
14
+ import {
15
+ ALLOWED_EXTERNAL_FLAGS,
16
+ ALLOWED_NON_ENV_UPPER_SNAKE,
17
+ } from "./handbook-allowlist.mjs";
18
+
19
+ const ROOT = path.resolve(import.meta.dirname, "..");
20
+
21
+ function walkFiles(dir, predicate = () => true) {
22
+ if (!fs.existsSync(dir)) return [];
23
+ const results = [];
24
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
25
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name === ".next") {
26
+ continue;
27
+ }
28
+ const full = path.join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ results.push(...walkFiles(full, predicate));
31
+ } else if (predicate(full)) {
32
+ results.push(full);
33
+ }
34
+ }
35
+ return results;
36
+ }
37
+
38
+ /**
39
+ * 1. Derive CLI commands & flags from package.json, bin/garden.mjs, and scripts/ui-cli.mjs
40
+ */
41
+ function deriveCliFacts(root = ROOT) {
42
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
43
+ const binNames = new Set(Object.keys(pkg.bin ?? {}));
44
+ const npmScripts = new Set(Object.keys(pkg.scripts ?? {}));
45
+
46
+ const gardenBinSource = fs.readFileSync(path.join(root, "bin/garden.mjs"), "utf8");
47
+ const gardenCommands = new Set();
48
+ const gardenFlags = new Set();
49
+
50
+ // Extract switch cases from bin/garden.mjs
51
+ for (const match of gardenBinSource.matchAll(/\bcase\s+["']([^"']+)["']\s*:/gu)) {
52
+ const value = match[1];
53
+ if (value.startsWith("-")) {
54
+ gardenFlags.add(value);
55
+ } else {
56
+ gardenCommands.add(value);
57
+ }
58
+ }
59
+ // Extract flags checked via .includes("--...") in bin/garden.mjs
60
+ for (const match of gardenBinSource.matchAll(/\.includes\(\s*["'](--?[a-zA-Z0-9-]+)["']\s*\)/gu)) {
61
+ gardenFlags.add(match[1]);
62
+ }
63
+
64
+ // Extract `garden ui` subcommands and flags from scripts/ui-cli.mjs
65
+ const uiCliSource = fs.readFileSync(path.join(root, "scripts/ui-cli.mjs"), "utf8");
66
+ const uiSubcommands = new Set();
67
+ const uiFlags = new Set();
68
+
69
+ for (const match of uiCliSource.matchAll(/\bcase\s+["']([^"']+)["']\s*:/gu)) {
70
+ const value = match[1];
71
+ if (value.startsWith("-")) {
72
+ uiFlags.add(value);
73
+ } else {
74
+ uiSubcommands.add(value);
75
+ }
76
+ }
77
+ for (const match of uiCliSource.matchAll(/\b(?:flag|option)\(\s*["']([a-zA-Z0-9-]+)["']\s*\)/gu)) {
78
+ uiFlags.add(`--${match[1]}`);
79
+ }
80
+
81
+ const commandArity = deriveCommandArity(root, gardenBinSource);
82
+ const npmScriptCommands = deriveNpmScriptCommands(root, gardenCommands);
83
+
84
+ return {
85
+ binNames,
86
+ npmScripts,
87
+ commandArity,
88
+ npmScriptCommands,
89
+ gardenCommands,
90
+ gardenFlags,
91
+ uiSubcommands,
92
+ uiFlags,
93
+ allFlags: new Set([...gardenFlags, ...uiFlags, ...Object.keys(ALLOWED_EXTERNAL_FLAGS)]),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Derive how many positional arguments each `garden <command>` accepts, from the
99
+ * `case "<command>": runNode("<script>", <args>)` blocks in bin/garden.mjs and,
100
+ * when every argument is forwarded (`rest`), from the script's own
101
+ * `const [, , a, b] = process.argv` destructuring and `if (!a ...)` guard.
102
+ */
103
+ function deriveCommandArity(root, gardenBinSource) {
104
+ /** @type {Map<string, { min: number, max: number }>} */
105
+ const arity = new Map();
106
+ const caseRe = /\bcase\s+["']([a-z][a-z0-9:-]*)["']\s*:\s*runNode\(\s*["']([^"']+)["']\s*,\s*(rest|\[[\s\S]*?\])\s*\)/gu;
107
+ for (const match of gardenBinSource.matchAll(caseRe)) {
108
+ const [, command, script, args] = match;
109
+ if (args === "rest") {
110
+ const file = path.join(root, script);
111
+ if (!fs.existsSync(file)) continue;
112
+ const source = fs.readFileSync(file, "utf8");
113
+ const destructure = /const\s*\[\s*,\s*,\s*([^\]]+)\]\s*=\s*process\.argv/u.exec(source);
114
+ if (!destructure) continue;
115
+ const names = destructure[1].split(",").map((n) => n.trim()).filter(Boolean);
116
+ if (names.some((n) => n.startsWith("..."))) continue;
117
+ const required = names.filter((n) => new RegExp(`if\\s*\\(\\s*!${n}\\b`, "u").test(source));
118
+ arity.set(command, { min: required.length, max: names.length });
119
+ } else {
120
+ const indices = [...args.matchAll(/\brest\[(\d+)\](\s*\?\?)?/gu)];
121
+ if (/\.\.\.rest\b/u.test(args)) continue;
122
+ const max = indices.reduce((m, i) => Math.max(m, Number(i[1]) + 1), 0);
123
+ const min = indices.filter((i) => !i[2]).reduce((m, i) => Math.max(m, Number(i[1]) + 1), 0);
124
+ arity.set(command, { min, max });
125
+ }
126
+ }
127
+ return arity;
128
+ }
129
+
130
+ /**
131
+ * Map npm scripts in package.json and templates/site/package.json that call a
132
+ * garden command (`garden <cmd>` or `node bin/garden.mjs <cmd>`) to that command,
133
+ * so `npm run <script> -- <args>` gets the same argument checks.
134
+ */
135
+ function deriveNpmScriptCommands(root, gardenCommands) {
136
+ /** @type {Map<string, string>} */
137
+ const map = new Map();
138
+ for (const rel of ["package.json", "templates/site/package.json"]) {
139
+ const file = path.join(root, rel);
140
+ if (!fs.existsSync(file)) continue;
141
+ const scripts = JSON.parse(fs.readFileSync(file, "utf8")).scripts ?? {};
142
+ for (const [name, command] of Object.entries(scripts)) {
143
+ const match = /^(?:garden|node\s+bin\/garden\.mjs)\s+([a-z][a-z0-9:-]*)\s*$/u.exec(String(command));
144
+ if (match && gardenCommands.has(match[1])) map.set(name, match[1]);
145
+ }
146
+ }
147
+ return map;
148
+ }
149
+
150
+ /**
151
+ * 2. Derive Makefile targets and variables from templates/site/Makefile (and root Makefile)
152
+ */
153
+ function deriveMakefileFacts(root = ROOT) {
154
+ const targets = new Set();
155
+ const variables = new Set(["MAKE"]);
156
+
157
+ for (const rel of ["templates/site/Makefile", "Makefile"]) {
158
+ const file = path.join(root, rel);
159
+ if (!fs.existsSync(file)) continue;
160
+ const text = fs.readFileSync(file, "utf8");
161
+ for (const line of text.split(/\r?\n/u)) {
162
+ const phony = /^\.PHONY:\s*(.+)$/u.exec(line);
163
+ if (phony) {
164
+ for (const token of phony[1].trim().split(/\s+/u)) {
165
+ if (token) targets.add(token);
166
+ }
167
+ continue;
168
+ }
169
+ const targetMatch = /^([a-zA-Z0-9_-]+)\s*:/u.exec(line);
170
+ if (targetMatch && targetMatch[1] !== ".PHONY") {
171
+ targets.add(targetMatch[1]);
172
+ }
173
+ const varDef = /^([A-Z][A-Z0-9_]*)\s*[?:]?=/u.exec(line);
174
+ if (varDef) {
175
+ variables.add(varDef[1]);
176
+ }
177
+ for (const ref of line.matchAll(/\$\(([A-Z][A-Z0-9_]*)\)/gu)) {
178
+ variables.add(ref[1]);
179
+ }
180
+ }
181
+ }
182
+ return { targets, variables };
183
+ }
184
+
185
+ /**
186
+ * 3. Derive environment variables read anywhere in app/, scripts/, ui/, bin/,
187
+ * worker/, next.config.ts, .env.example, templates/, and .github/workflows/
188
+ */
189
+ function deriveEnvVars(root = ROOT) {
190
+ const envVars = new Set();
191
+ const codeDirs = ["app", "scripts", "ui", "bin", "worker"];
192
+ const codeFiles = [
193
+ ...codeDirs.flatMap((dir) =>
194
+ walkFiles(
195
+ path.join(root, dir),
196
+ (f) => /\.(?:mjs|cjs|js|ts|tsx)$/u.test(f) && path.basename(f) !== "handbook-facts.mjs" && path.basename(f) !== "handbook-allowlist.mjs",
197
+ ),
198
+ ),
199
+ path.join(root, "next.config.ts"),
200
+ ].filter((f) => fs.existsSync(f));
201
+
202
+ for (const file of codeFiles) {
203
+ const text = fs.readFileSync(file, "utf8");
204
+ for (const match of text.matchAll(/\b(?:process\.env|env)\.([A-Z][A-Z0-9_]*)\b/gu)) {
205
+ envVars.add(match[1]);
206
+ }
207
+ for (const match of text.matchAll(/\b(?:process\.env|env)\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/gu)) {
208
+ envVars.add(match[1]);
209
+ }
210
+ // Dynamic prefix patterns such as `process.env[\`GARDEN_NEW_${name}\`]` + `option("TITLE")`
211
+ const prefixMatch = /process\.env\[\s*`([A-Z0-9_]+)\$\{/u.exec(text);
212
+ if (prefixMatch) {
213
+ const prefix = prefixMatch[1];
214
+ for (const opt of text.matchAll(/\boption\(\s*["']([A-Z0-9_]+)["']\s*\)/gu)) {
215
+ envVars.add(`${prefix}${opt[1]}`);
216
+ }
217
+ }
218
+ }
219
+
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]);
226
+ }
227
+ }
228
+
229
+ // GitHub workflows and template Makefiles
230
+ const workflowFiles = [
231
+ ...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)),
233
+ ];
234
+ for (const file of workflowFiles) {
235
+ const text = fs.readFileSync(file, "utf8");
236
+ for (const match of text.matchAll(/\b(?:vars|secrets|env)\.([A-Z][A-Z0-9_]*)\b/gu)) {
237
+ envVars.add(match[1]);
238
+ }
239
+ for (const match of text.matchAll(/^\s*([A-Z][A-Z0-9_]+)\s*:/gmu)) {
240
+ envVars.add(match[1]);
241
+ }
242
+ for (const match of text.matchAll(/\b([A-Z][A-Z0-9_]+)=/gu)) {
243
+ envVars.add(match[1]);
244
+ }
245
+ }
246
+
247
+ return envVars;
248
+ }
249
+
250
+ /**
251
+ * 4. Derive site.config.yaml keys from app/site.config.ts, app/lib/fallback-site-config.ts,
252
+ * scripts/brand.mjs, scripts/redirects.mjs, ui/garden/same-site.mjs,
253
+ * templates/site/site.config.yaml, and site.config.yaml
254
+ */
255
+ function deriveSiteConfigKeys(root = ROOT) {
256
+ const keyPaths = new Set();
257
+ const leafKeys = new Set();
258
+
259
+ function walkYamlObject(obj, prefix = "") {
260
+ if (!obj || typeof obj !== "object") return;
261
+ if (Array.isArray(obj)) {
262
+ for (const item of obj) walkYamlObject(item, prefix);
263
+ return;
264
+ }
265
+ for (const [k, v] of Object.entries(obj)) {
266
+ // Do not treat arbitrary redirect path keys (like `/2024/...`) as schema keys
267
+ if (prefix === "redirects" && k.startsWith("/")) continue;
268
+ const next = prefix ? `${prefix}.${k}` : k;
269
+ keyPaths.add(next);
270
+ leafKeys.add(k);
271
+ walkYamlObject(v, next);
272
+ }
273
+ }
274
+
275
+ for (const rel of ["templates/site/site.config.yaml", "site.config.yaml"]) {
276
+ const file = path.join(root, rel);
277
+ if (!fs.existsSync(file)) continue;
278
+ const text = fs.readFileSync(file, "utf8");
279
+ walkYamlObject(parseYaml(text));
280
+ // Optional keys the template documents as comments (`# url: ...`, `# brand:` + `# logo: ...`).
281
+ const uncommented = text
282
+ .split(/\r?\n/u)
283
+ .filter((line) => /^#\s?(\s*)[a-zA-Z][a-zA-Z0-9_]*:/u.test(line))
284
+ .map((line) => line.replace(/^#\s?/u, ""))
285
+ .join("\n");
286
+ try {
287
+ walkYamlObject(parseYaml(uncommented));
288
+ } catch {
289
+ // Commented lines that do not form valid YAML on their own are skipped.
290
+ }
291
+ }
292
+
293
+ // Also parse TypeScript interfaces & property accesses in app/site.config.ts, scripts/brand.mjs, scripts/redirects.mjs
294
+ const configLoaderSource = fs.readFileSync(path.join(root, "app/site.config.ts"), "utf8");
295
+ for (const match of configLoaderSource.matchAll(/^\s*([a-zA-Z][a-zA-Z0-9_]*)\??\s*:/gmu)) {
296
+ leafKeys.add(match[1]);
297
+ }
298
+ const brandSource = fs.readFileSync(path.join(root, "scripts/brand.mjs"), "utf8");
299
+ for (const match of brandSource.matchAll(/\bbrand\.([a-zA-Z][a-zA-Z0-9_]*)\b/gu)) {
300
+ leafKeys.add("brand");
301
+ leafKeys.add(match[1]);
302
+ keyPaths.add(`brand.${match[1]}`);
303
+ }
304
+ const redirectsSource = fs.readFileSync(path.join(root, "ui/garden/internal-links.mjs"), "utf8");
305
+ if (redirectsSource.includes("config?.redirects") || redirectsSource.includes("config.redirects")) {
306
+ leafKeys.add("redirects");
307
+ keyPaths.add("redirects");
308
+ }
309
+
310
+ return { keyPaths, leafKeys };
311
+ }
312
+
313
+ /**
314
+ * 5. Derive check diagnostic codes (and their severities) from scripts/check-post.mjs,
315
+ * ui/garden/check.mjs, ui/garden/file-paths.mjs, ui/garden/parse.mjs,
316
+ * ui/core/validate.mjs, and ui/core/parse.mjs
317
+ */
318
+ function deriveCheckCodes(root = ROOT) {
319
+ /** @type {Map<string, Set<"error" | "warning">>} */
320
+ const codes = new Map();
321
+ const record = (code, severity = "error") => {
322
+ if (!codes.has(code)) codes.set(code, new Set());
323
+ codes.get(code).add(severity);
324
+ };
325
+
326
+ // scripts/check-post.mjs: error(errors, "code", ...) and warnings.push({ code: "..." })
327
+ const checkPostSource = fs.readFileSync(path.join(root, "scripts/check-post.mjs"), "utf8");
328
+ for (const match of checkPostSource.matchAll(/\berror\(\s*errors\s*,\s*["']([a-z0-9-]+)["']/gu)) {
329
+ record(match[1], "error");
330
+ }
331
+ for (const match of checkPostSource.matchAll(/\bwarnings\.push\(\s*\{\s*code:\s*["']([a-z0-9-]+)["']/gu)) {
332
+ record(match[1], "warning");
333
+ }
334
+
335
+ // ui/garden/check.mjs: push(line, "code", message, suggestion, severity?)
336
+ const gardenCheckSource = fs.readFileSync(path.join(root, "ui/garden/check.mjs"), "utf8");
337
+ for (const match of gardenCheckSource.matchAll(/\bpush\(\s*[^,\n]+\s*,\s*["']([a-z0-9-]+)["']([\s\S]*?)\)\s*;/gu)) {
338
+ const rest = match[2];
339
+ const sev = /,\s*["'](warning|error)["']\s*$/u.exec(rest.trim())?.[1] ?? "error";
340
+ record(match[1], sev);
341
+ }
342
+
343
+ // ui/garden/file-paths.mjs: code: "..." (with optional severity: "warning" nearby)
344
+ const filePathsSource = fs.readFileSync(path.join(root, "ui/garden/file-paths.mjs"), "utf8");
345
+ for (const match of filePathsSource.matchAll(/\bcode:\s*["']([a-z0-9-]+)["']([\s\S]{0,220})/gu)) {
346
+ const tail = match[2];
347
+ const sev = /severity:\s*["'](warning|error)["']/u.exec(tail)?.[1] ?? "error";
348
+ record(match[1], sev);
349
+ }
350
+
351
+ // ui/garden/parse.mjs: error: { code: "..." }
352
+ const gardenParseSource = fs.readFileSync(path.join(root, "ui/garden/parse.mjs"), "utf8");
353
+ for (const match of gardenParseSource.matchAll(/\bcode:\s*["']([a-z0-9-]+)["']/gu)) {
354
+ record(match[1], "error");
355
+ }
356
+
357
+ // ui/core/validate.mjs & ui/core/parse.mjs
358
+ for (const rel of ["ui/core/validate.mjs", "ui/core/parse.mjs"]) {
359
+ 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)) {
361
+ record(match[1], match[2] === "warning" ? "warning" : "error");
362
+ }
363
+ }
364
+
365
+ return codes;
366
+ }
367
+
368
+ /**
369
+ * 6. Derive every CSS custom property with its light and dark value from app/styles/tokens.css
370
+ */
371
+ function deriveCssTokens(root = ROOT) {
372
+ const source = fs.readFileSync(path.join(root, "app/styles/tokens.css"), "utf8");
373
+ /** @type {Map<string, { light?: string, dark?: string }>} */
374
+ const tokens = new Map();
375
+
376
+ const setToken = (name, mode, value) => {
377
+ if (!tokens.has(name)) tokens.set(name, {});
378
+ tokens.get(name)[mode] = value.trim();
379
+ };
380
+
381
+ // Match top-level rule blocks in tokens.css
382
+ const lightBlockMatch = /:root,\s*:root\[data-theme="light"\]\s*\{([\s\S]*?)\n\}/u.exec(source);
383
+ if (lightBlockMatch) {
384
+ for (const prop of lightBlockMatch[1].matchAll(/^\s*(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/gmu)) {
385
+ setToken(prop[1], "light", prop[2]);
386
+ }
387
+ }
388
+
389
+ const darkBlockMatch = /:root\[data-theme="dark"\](?:,\s*html\[data-theme="dark"\])?\s*\{([\s\S]*?)\n\}/u.exec(source);
390
+ if (darkBlockMatch) {
391
+ for (const prop of darkBlockMatch[1].matchAll(/^\s*(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/gmu)) {
392
+ setToken(prop[1], "dark", prop[2]);
393
+ }
394
+ }
395
+
396
+ // Also record layout custom property names defined or set in app/styles/*.css and app/components/*.tsx
397
+ for (const cssFile of [
398
+ ...walkFiles(path.join(root, "app/styles"), (f) => f.endsWith(".css")),
399
+ ...walkFiles(path.join(root, "app/components"), (f) => /\.(?:ts|tsx)$/u.test(f)),
400
+ ]) {
401
+ const text = fs.readFileSync(cssFile, "utf8");
402
+ for (const prop of text.matchAll(/(?:--|setProperty\(\s*["']--)[a-zA-Z0-9-]+/gu)) {
403
+ const name = prop[0].startsWith("setProperty")
404
+ ? /--[a-zA-Z0-9-]+/u.exec(prop[0])[0]
405
+ : prop[0];
406
+ if (!tokens.has(name)) tokens.set(name, {});
407
+ }
408
+ }
409
+
410
+ return tokens;
411
+ }
412
+
413
+ /**
414
+ * Build the complete derived fact set from the repository source.
415
+ */
416
+ export function deriveHandbookFacts(root = ROOT) {
417
+ const cli = deriveCliFacts(root);
418
+ const makefile = deriveMakefileFacts(root);
419
+ const envVars = deriveEnvVars(root);
420
+ const siteConfig = deriveSiteConfigKeys(root);
421
+ const checkCodes = deriveCheckCodes(root);
422
+ const cssTokens = deriveCssTokens(root);
423
+ const gardenComponents = new Set(Object.keys(CATALOG));
424
+
425
+ return {
426
+ cli,
427
+ makefile,
428
+ envVars,
429
+ siteConfig,
430
+ checkCodes,
431
+ cssTokens,
432
+ gardenComponents,
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Parse a markdown document into lines and fenced code blocks (supporting 3+ backticks/tildes).
438
+ */
439
+ function parseMarkdownRegions(markdown) {
440
+ const lines = String(markdown).split(/\r?\n/u);
441
+ const fences = [];
442
+ const lineInFence = new Array(lines.length).fill(null);
443
+
444
+ let open = null;
445
+ for (let i = 0; i < lines.length; i += 1) {
446
+ const line = lines[i];
447
+ const match = /^( {0,3})(`{3,}|~{3,})(.*)$/u.exec(line);
448
+ if (open) {
449
+ const closer =
450
+ match &&
451
+ match[2][0] === open.marker[0] &&
452
+ match[2].length >= open.marker.length &&
453
+ !match[3].trim();
454
+ if (closer) {
455
+ open.endLine = i + 1;
456
+ fences.push(open);
457
+ lineInFence[i] = open;
458
+ open = null;
459
+ } else {
460
+ open.bodyLines.push({ lineNumber: i + 1, text: line });
461
+ lineInFence[i] = open;
462
+ }
463
+ continue;
464
+ }
465
+ if (match) {
466
+ const info = match[3].trim();
467
+ const lang = (info.split(/\s+/u)[0] ?? "").toLowerCase();
468
+ open = {
469
+ marker: match[2],
470
+ info,
471
+ lang,
472
+ startLine: i + 1,
473
+ endLine: lines.length,
474
+ bodyLines: [],
475
+ };
476
+ lineInFence[i] = open;
477
+ }
478
+ }
479
+ return { lines, fences, lineInFence };
480
+ }
481
+
482
+ /**
483
+ * Extract inline `code` spans from a single non-fence line.
484
+ */
485
+ function extractInlineCodeSpans(line) {
486
+ const spans = [];
487
+ const re = /(`+)([^`]+?)\1/gu;
488
+ let match;
489
+ while ((match = re.exec(line)) !== null) {
490
+ spans.push(match[2].trim());
491
+ }
492
+ return spans;
493
+ }
494
+
495
+ /**
496
+ * Compare the positional arguments of a garden command with the derived arity.
497
+ */
498
+ function checkArity(command, args, segment, file, lineNumber, facts, issues) {
499
+ const arity = facts.cli.commandArity.get(command);
500
+ if (!arity) return;
501
+ const positional = args.filter((arg) => !arg.startsWith("-") && !arg.startsWith("#") && !/^[<>|]/u.test(arg));
502
+ if (positional.length < arity.min || positional.length > arity.max) {
503
+ const range = arity.min === arity.max ? `${arity.max}` : `${arity.min}-${arity.max}`;
504
+ issues.push({
505
+ file,
506
+ line: lineNumber,
507
+ kind: "cli-arity",
508
+ message: `"garden ${command}" takes ${range} positional argument(s), got ${positional.length} in "${segment}"`,
509
+ });
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Validate a shell command string (from a bash/sh fence or an inline code span).
515
+ */
516
+ function checkCommandString(cmdText, file, lineNumber, facts, issues) {
517
+ // Split compound commands on &&, ||, ;, or newline
518
+ const segments = cmdText
519
+ .split(/(?:&&|\|\||;|\r?\n)/u)
520
+ .map((s) => s.trim())
521
+ .filter((s) => s && !s.startsWith("#"));
522
+
523
+ for (const segment of segments) {
524
+ // Strip leading VAR=val assignments (e.g. STATIC_EXPORT=1 npm run build)
525
+ const tokens = segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/gu) ?? [];
526
+ let idx = 0;
527
+ const assignedVars = [];
528
+ while (idx < tokens.length && /^([A-Z][A-Z0-9_]*)=/u.test(tokens[idx])) {
529
+ const varName = /^([A-Z][A-Z0-9_]*)=/u.exec(tokens[idx])[1];
530
+ assignedVars.push(varName);
531
+ idx += 1;
532
+ }
533
+ if (idx >= tokens.length) continue;
534
+
535
+ const head = tokens[idx];
536
+ const rest = tokens.slice(idx + 1);
537
+
538
+ // Check leading env var assignments
539
+ for (const varName of assignedVars) {
540
+ if (!facts.envVars.has(varName) && !facts.makefile.variables.has(varName)) {
541
+ issues.push({
542
+ file,
543
+ line: lineNumber,
544
+ kind: "env-var",
545
+ message: `Unknown environment/Makefile variable "${varName}" in command "${segment}"`,
546
+ });
547
+ }
548
+ }
549
+
550
+ // Check `npx garden ...` / `npx hd-garden ...` / `garden ...` / `hd-garden ...`
551
+ let gardenArgs = null;
552
+ if (head === "npx" && rest.length > 0) {
553
+ const pkgOrBin = rest[0].replace(/@[0-9a-zA-Z._-]+$/u, "");
554
+ if (facts.cli.binNames.has(pkgOrBin)) {
555
+ gardenArgs = rest.slice(1);
556
+ }
557
+ } else if (facts.cli.binNames.has(head)) {
558
+ gardenArgs = rest;
559
+ }
560
+
561
+ if (gardenArgs !== null) {
562
+ const sub = gardenArgs[0];
563
+ if (!sub) continue;
564
+ // In inline prose, `garden stat` / `garden file` / `garden <name>` etc. (without `npx`)
565
+ // can be a Markdown fence header reference (` ```garden stat `) when `sub` is a known component name
566
+ // or a `<name>` placeholder and there are no extra args or only `#id`.
567
+ if (
568
+ head === "garden" &&
569
+ (facts.gardenComponents.has(sub) || /^(?:<[^>]+>|\$\{[^}]+\})$/u.test(sub)) &&
570
+ (gardenArgs.length === 1 || (gardenArgs.length === 2 && gardenArgs[1].startsWith("#")))
571
+ ) {
572
+ continue;
573
+ }
574
+ if (sub.startsWith("-")) {
575
+ if (!facts.cli.gardenFlags.has(sub)) {
576
+ issues.push({
577
+ file,
578
+ line: lineNumber,
579
+ kind: "cli-flag",
580
+ message: `Unknown garden CLI flag "${sub}" in "${segment}"`,
581
+ });
582
+ }
583
+ continue;
584
+ }
585
+ if (!facts.cli.gardenCommands.has(sub)) {
586
+ issues.push({
587
+ file,
588
+ line: lineNumber,
589
+ kind: "cli-command",
590
+ message: `Unknown garden CLI subcommand "${sub}" in "${segment}"`,
591
+ });
592
+ continue;
593
+ }
594
+ if (sub === "ui") {
595
+ const uiSub = gardenArgs[1];
596
+ if (uiSub && !uiSub.startsWith("-") && !facts.cli.uiSubcommands.has(uiSub)) {
597
+ issues.push({
598
+ file,
599
+ line: lineNumber,
600
+ kind: "cli-command",
601
+ message: `Unknown "garden ui" subcommand "${uiSub}" in "${segment}"`,
602
+ });
603
+ }
604
+ for (const arg of gardenArgs.slice(2)) {
605
+ if (arg.startsWith("-")) {
606
+ const flagName = arg.split("=", 1)[0];
607
+ if (!facts.cli.uiFlags.has(flagName)) {
608
+ issues.push({
609
+ file,
610
+ line: lineNumber,
611
+ kind: "cli-flag",
612
+ message: `Unknown "garden ui" flag "${flagName}" in "${segment}"`,
613
+ });
614
+ }
615
+ }
616
+ }
617
+ } else {
618
+ checkArity(sub, gardenArgs.slice(1), segment, file, lineNumber, facts, issues);
619
+ for (const arg of gardenArgs.slice(1)) {
620
+ if (arg === "--") break;
621
+ if (arg.startsWith("-")) {
622
+ const flagName = arg.split("=", 1)[0];
623
+ if (!facts.cli.gardenFlags.has(flagName)) {
624
+ issues.push({
625
+ file,
626
+ line: lineNumber,
627
+ kind: "cli-flag",
628
+ message: `Unknown "garden ${sub}" flag "${flagName}" in "${segment}"`,
629
+ });
630
+ }
631
+ }
632
+ }
633
+ }
634
+ continue;
635
+ }
636
+
637
+ // Check `make ...`
638
+ if (head === "make") {
639
+ for (const rawArg of rest) {
640
+ const arg = rawArg.replace(/^\[|\]$/gu, "");
641
+ if (!arg || arg.startsWith("-")) continue;
642
+ const varAssign = /^([A-Za-z0-9_]+)=/u.exec(arg);
643
+ if (varAssign) {
644
+ const varName = varAssign[1];
645
+ if (!facts.makefile.variables.has(varName) && !facts.envVars.has(varName)) {
646
+ issues.push({
647
+ file,
648
+ line: lineNumber,
649
+ kind: "make-var",
650
+ message: `Unknown Makefile variable "${varName}" in "${segment}"`,
651
+ });
652
+ }
653
+ } else if (!facts.makefile.targets.has(arg)) {
654
+ issues.push({
655
+ file,
656
+ line: lineNumber,
657
+ kind: "make-target",
658
+ message: `Unknown Makefile target "make ${arg}" in "${segment}"`,
659
+ });
660
+ }
661
+ }
662
+ continue;
663
+ }
664
+
665
+ // Check `npm run <script>`
666
+ if (head === "npm" && rest[0] === "run" && rest[1]) {
667
+ const scriptName = rest[1];
668
+ const gardenCommand = facts.cli.npmScriptCommands.get(scriptName);
669
+ if (gardenCommand) {
670
+ const dashIdx = rest.indexOf("--");
671
+ const extra = dashIdx === -1 ? [] : rest.slice(dashIdx + 1);
672
+ checkArity(gardenCommand, extra, segment, file, lineNumber, facts, issues);
673
+ }
674
+ if (!facts.cli.npmScripts.has(scriptName) && !gardenCommand) {
675
+ issues.push({
676
+ file,
677
+ line: lineNumber,
678
+ kind: "npm-script",
679
+ message: `Unknown npm script "npm run ${scriptName}" in "${segment}"`,
680
+ });
681
+ }
682
+ }
683
+ }
684
+ }
685
+
686
+ const SHELL_FENCE_LANGS = new Set(["bash", "sh", "shell", "zsh", "console", "text", "txt", "plaintext", ""]);
687
+
688
+ /**
689
+ * Check every shell line under a `run:` key in a GitHub Actions workflow fence.
690
+ */
691
+ function checkWorkflowRunLines(fence, relFile, facts, issues) {
692
+ let runIndent = null;
693
+ for (const item of fence.bodyLines) {
694
+ const text = item.text;
695
+ const indent = /^\s*/u.exec(text)[0].length;
696
+ if (runIndent !== null) {
697
+ if (!text.trim()) continue;
698
+ if (indent > runIndent) {
699
+ const trimmed = text.trim();
700
+ if (!trimmed.startsWith("#")) checkCommandString(trimmed, relFile, item.lineNumber, facts, issues);
701
+ continue;
702
+ }
703
+ runIndent = null;
704
+ }
705
+ const run = /^(\s*)(?:-\s+)?run:\s*(.*)$/u.exec(text);
706
+ if (!run) continue;
707
+ const value = run[2].trim();
708
+ if (/^[|>][+-]?$/u.test(value)) {
709
+ runIndent = indent + (text.trimStart().startsWith("-") ? 2 : 0);
710
+ } else if (value && !value.startsWith("#")) {
711
+ checkCommandString(value.replace(/^(["'])(.*)\1$/u, "$2"), relFile, item.lineNumber, facts, issues);
712
+ }
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Validate a single markdown or source file against derived handbook facts.
718
+ */
719
+ export function checkFileFacts(filePath, content, facts, root = ROOT) {
720
+ const relFile = path.relative(root, filePath).split(path.sep).join("/");
721
+ const issues = [];
722
+ const { lines, fences, lineInFence } = parseMarkdownRegions(content);
723
+
724
+ // 1. Check fenced blocks
725
+ for (const fence of fences) {
726
+ const lang = fence.lang;
727
+ const bodyText = fence.bodyLines.map((l) => l.text).join("\n");
728
+
729
+ // Shell fences (bash, sh, shell, zsh) and terminal transcripts (console, text, plain fences)
730
+ if (SHELL_FENCE_LANGS.has(lang)) {
731
+ for (const item of fence.bodyLines) {
732
+ const trimmed = item.text.trim().replace(/^[$>%]\s+/u, "");
733
+ if (!trimmed || trimmed.startsWith("#")) continue;
734
+ checkCommandString(trimmed, relFile, item.lineNumber, facts, issues);
735
+ }
736
+ }
737
+
738
+ // YAML fences that represent site.config.yaml (not GitHub Actions workflows, not frontmatter)
739
+ if (lang === "yaml" || lang === "yml") {
740
+ const isWorkflow =
741
+ /\b(?:runs-on|steps|jobs|workflow_dispatch|actions\/checkout)\b/u.test(bodyText);
742
+ const isColumnsYaml = /^columns:\s*$/mu.test(bodyText) && /^\s*-\s+slug:/mu.test(bodyText);
743
+ const isFrontmatter = /^\s*---\s*$/mu.test(bodyText) || (/\bdraft:/u.test(bodyText) && /\btags:/u.test(bodyText));
744
+
745
+ if (isWorkflow) {
746
+ checkWorkflowRunLines(fence, relFile, facts, issues);
747
+ }
748
+
749
+ if (!isWorkflow && !isColumnsYaml && !isFrontmatter) {
750
+ try {
751
+ const parsed = parseYaml(bodyText);
752
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
753
+ const checkYamlObj = (obj, prefix = "") => {
754
+ for (const [k, v] of Object.entries(obj)) {
755
+ if (prefix === "redirects" && k.startsWith("/")) continue;
756
+ const keyPath = prefix ? `${prefix}.${k}` : k;
757
+ if (!facts.siteConfig.keyPaths.has(keyPath)) {
758
+ const keyLine =
759
+ fence.bodyLines.find((l) => new RegExp(`^\\s*${k}\\s*:`, "u").test(l.text))?.lineNumber ??
760
+ fence.startLine;
761
+ issues.push({
762
+ file: relFile,
763
+ line: keyLine,
764
+ kind: "config-key",
765
+ message: `Unknown site.config.yaml key "${keyPath}"`,
766
+ });
767
+ }
768
+ if (v && typeof v === "object" && !Array.isArray(v)) {
769
+ checkYamlObj(v, keyPath);
770
+ }
771
+ }
772
+ };
773
+ checkYamlObj(parsed);
774
+ }
775
+ } catch {
776
+ // Non-object YAML snippet; ignore here
777
+ }
778
+ }
779
+ }
780
+ }
781
+
782
+ // 2. Scan lines outside fences (and inside markdown fences only when appropriate)
783
+ let inCheckTable = false;
784
+ let inTokenColorTable = false;
785
+ let tokenTableCols = null;
786
+
787
+ for (let i = 0; i < lines.length; i += 1) {
788
+ const lineNumber = i + 1;
789
+ const line = lines[i];
790
+ const activeFence = lineInFence[i];
791
+
792
+ // Skip lines inside fenced code blocks for prose/table checks
793
+ if (activeFence) continue;
794
+
795
+ const spans = extractInlineCodeSpans(line);
796
+
797
+ // Check inline shell commands and standalone flags
798
+ for (const span of spans) {
799
+ if (
800
+ /^(?:npx\s+(?:hd-)?garden|garden|hd-garden|make|npm\s+run)\b/u.test(span) ||
801
+ /^[A-Z][A-Z0-9_]*=\S+\s+(?:npx|garden|make|npm)\b/u.test(span)
802
+ ) {
803
+ checkCommandString(span, relFile, lineNumber, facts, issues);
804
+ } else if (/^--[a-z][a-z0-9-]*$/u.test(span)) {
805
+ // Ignore CSS custom properties (--paper, --ink, --wl, --wr, etc.)
806
+ if (!facts.cssTokens.has(span) && !["--wl", "--wr", "--col"].includes(span)) {
807
+ if (!facts.cli.allFlags.has(span)) {
808
+ issues.push({
809
+ file: relFile,
810
+ line: lineNumber,
811
+ kind: "cli-flag",
812
+ message: `Unknown CLI flag "${span}"`,
813
+ });
814
+ }
815
+ }
816
+ }
817
+
818
+ // Check UPPER_SNAKE environment variable names in inline code
819
+ if (/^[A-Z][A-Z0-9_]{2,}$/u.test(span) && span.includes("_")) {
820
+ if (
821
+ !facts.envVars.has(span) &&
822
+ !facts.makefile.variables.has(span) &&
823
+ !Object.hasOwn(ALLOWED_NON_ENV_UPPER_SNAKE, span)
824
+ ) {
825
+ issues.push({
826
+ file: relFile,
827
+ line: lineNumber,
828
+ kind: "env-var",
829
+ message: `Unknown UPPER_SNAKE environment variable "${span}"`,
830
+ });
831
+ }
832
+ }
833
+ }
834
+
835
+ // Detect table headers
836
+ if (/^\s*\|/u.test(line)) {
837
+ const cells = line
838
+ .split("|")
839
+ .slice(1, -1)
840
+ .map((c) => c.trim());
841
+
842
+ if (cells.some((c) => /诊断码|代码|Code/iu.test(c)) && cells.some((c) => /级别|触发|Severity/iu.test(c))) {
843
+ inCheckTable = true;
844
+ inTokenColorTable = false;
845
+ continue;
846
+ }
847
+ if (
848
+ cells.some((c) => /变量|Token/iu.test(c)) &&
849
+ cells.some((c) => /浅色|亮色|Light/iu.test(c)) &&
850
+ cells.some((c) => /深色|暗色|Dark/iu.test(c))
851
+ ) {
852
+ inTokenColorTable = true;
853
+ inCheckTable = false;
854
+ tokenTableCols = {
855
+ tokenIdx: cells.findIndex((c) => /变量|Token/iu.test(c)),
856
+ lightIdx: cells.findIndex((c) => /浅色|亮色|Light/iu.test(c)),
857
+ darkIdx: cells.findIndex((c) => /深色|暗色|Dark/iu.test(c)),
858
+ };
859
+ continue;
860
+ }
861
+ // Separator row `| --- | --- |`
862
+ if (cells.every((c) => /^:?-+:?$/u.test(c))) {
863
+ continue;
864
+ }
865
+
866
+ if (inCheckTable && cells.length >= 2) {
867
+ const codeSpans = extractInlineCodeSpans(cells[0]);
868
+ const severityText = cells[1];
869
+ for (const code of codeSpans) {
870
+ if (!facts.checkCodes.has(code)) {
871
+ issues.push({
872
+ file: relFile,
873
+ line: lineNumber,
874
+ kind: "check-code",
875
+ message: `Unknown check diagnostic code "${code}"`,
876
+ });
877
+ } else {
878
+ const actualSeverities = facts.checkCodes.get(code);
879
+ if (/错误|error/iu.test(severityText) && !actualSeverities.has("error")) {
880
+ issues.push({
881
+ file: relFile,
882
+ line: lineNumber,
883
+ kind: "check-severity",
884
+ message: `Check code "${code}" is documented as error ("${severityText}"), but source emits ${[...actualSeverities].join("/")}`,
885
+ });
886
+ } else if (/警告|warning/iu.test(severityText) && !actualSeverities.has("warning")) {
887
+ issues.push({
888
+ file: relFile,
889
+ line: lineNumber,
890
+ kind: "check-severity",
891
+ message: `Check code "${code}" is documented as warning ("${severityText}"), but source emits ${[...actualSeverities].join("/")}`,
892
+ });
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ if (inTokenColorTable && tokenTableCols) {
899
+ const tokenCell = cells[tokenTableCols.tokenIdx] ?? "";
900
+ const lightCell = cells[tokenTableCols.lightIdx] ?? "";
901
+ const darkCell = cells[tokenTableCols.darkIdx] ?? "";
902
+ const tokenNames = [...tokenCell.matchAll(/--[a-zA-Z0-9-]+/gu)].map((m) => m[0]);
903
+ const lightHexes = [...lightCell.matchAll(/#[0-9a-fA-F]{6}\b/gu)].map((m) => m[0].toLowerCase());
904
+ const darkHexes = [...darkCell.matchAll(/#[0-9a-fA-F]{6}\b/gu)].map((m) => m[0].toLowerCase());
905
+
906
+ tokenNames.forEach((tokenName, idx) => {
907
+ const actual = facts.cssTokens.get(tokenName);
908
+ if (!actual) {
909
+ issues.push({
910
+ file: relFile,
911
+ line: lineNumber,
912
+ kind: "css-token",
913
+ message: `Unknown CSS custom property "${tokenName}" (not in app/styles/tokens.css)`,
914
+ });
915
+ return;
916
+ }
917
+ const lightHex = lightHexes[idx] ?? lightHexes[0];
918
+ const darkHex = darkHexes[idx] ?? darkHexes[0];
919
+ if (lightHex && actual.light?.toLowerCase() !== lightHex) {
920
+ issues.push({
921
+ file: relFile,
922
+ line: lineNumber,
923
+ kind: "css-color",
924
+ message: `CSS token "${tokenName}" light value documented as "${lightHex}", but tokens.css has "${actual.light}"`,
925
+ });
926
+ }
927
+ if (darkHex && actual.dark?.toLowerCase() !== darkHex) {
928
+ issues.push({
929
+ file: relFile,
930
+ line: lineNumber,
931
+ kind: "css-color",
932
+ message: `CSS token "${tokenName}" dark value documented as "${darkHex}", but tokens.css has "${actual.dark}"`,
933
+ });
934
+ }
935
+ });
936
+ }
937
+ } else {
938
+ inCheckTable = false;
939
+ inTokenColorTable = false;
940
+ tokenTableCols = null;
941
+ }
942
+
943
+ // Also check prose lines that mention check codes or `--token` + `#hex` pairs
944
+ if (/(?:报|提示|触发|警告|错误|拦截|check)\b|报\s*`|`[a-z]+-[a-z-]+`\s*(?:错误|警告)/u.test(line)) {
945
+ for (const span of spans) {
946
+ if (/^[a-z]+-[a-z0-9-]+$/u.test(span)) {
947
+ // Ignore known non-check hyphenated terms (like github-pages, hd-garden, prefers-color-scheme, etc.)
948
+ const nonCheckTerms = new Set([
949
+ "hd-garden",
950
+ "github-pages",
951
+ "prefers-color-scheme",
952
+ "color-scheme",
953
+ "font-family",
954
+ "line-height",
955
+ "max-width",
956
+ "min-width",
957
+ "margin-left",
958
+ "margin-right",
959
+ "data-theme",
960
+ "data-p",
961
+ "data-sel",
962
+ "aria-label",
963
+ "content-type",
964
+ "user-agent",
965
+ ]);
966
+ if (nonCheckTerms.has(span) || facts.cli.allFlags.has(`--${span}`)) continue;
967
+ // Only treat as check code when explicitly paired with 报/警告/错误/拦截 or known prefix
968
+ const isExplicitCheckMention =
969
+ new RegExp(`(?:报|触发|抛出|返回|拦截)\\s*\`${span}\`|\`${span}\`\\s*(?:错误|警告)`, "u").test(line);
970
+ if (isExplicitCheckMention) {
971
+ if (!facts.checkCodes.has(span)) {
972
+ issues.push({
973
+ file: relFile,
974
+ line: lineNumber,
975
+ kind: "check-code",
976
+ message: `Unknown check diagnostic code "${span}" in prose`,
977
+ });
978
+ } else {
979
+ const actualSeverities = facts.checkCodes.get(span);
980
+ if (new RegExp(`\`${span}\`\\s*错误`, "u").test(line) && !actualSeverities.has("error")) {
981
+ issues.push({
982
+ file: relFile,
983
+ line: lineNumber,
984
+ kind: "check-severity",
985
+ message: `Check code "${span}" called "错误" in prose, but source emits ${[...actualSeverities].join("/")}`,
986
+ });
987
+ }
988
+ if (new RegExp(`\`${span}\`\\s*警告`, "u").test(line) && !actualSeverities.has("warning")) {
989
+ issues.push({
990
+ file: relFile,
991
+ line: lineNumber,
992
+ kind: "check-severity",
993
+ message: `Check code "${span}" called "警告" in prose, but source emits ${[...actualSeverities].join("/")}`,
994
+ });
995
+ }
996
+ }
997
+ }
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ // `--token: #hex` written inside a single code span
1003
+ for (const span of spans) {
1004
+ const pair = /^(--[a-zA-Z0-9-]+)\s*[::=]\s*(#[0-9a-fA-F]{6})\b/u.exec(span);
1005
+ if (!pair) continue;
1006
+ const actual = facts.cssTokens.get(pair[1]);
1007
+ const hex = pair[2].toLowerCase();
1008
+ if (!actual) {
1009
+ issues.push({ file: relFile, line: lineNumber, kind: "css-token", message: `Unknown CSS custom property "${pair[1]}"` });
1010
+ } else if (![actual.light, actual.dark].filter(Boolean).map((v) => v.toLowerCase()).includes(hex)) {
1011
+ issues.push({
1012
+ file: relFile,
1013
+ line: lineNumber,
1014
+ kind: "css-color",
1015
+ message: `CSS token "${pair[1]}" written as "${hex}", which matches neither light (${actual.light}) nor dark (${actual.dark}) in tokens.css`,
1016
+ });
1017
+ }
1018
+ }
1019
+
1020
+ // Prose `--token` + `#hex` on the same line (when a single token is paired with a hex value)
1021
+ if (!inTokenColorTable) {
1022
+ const tokenMatches = [...line.matchAll(/`(--[a-zA-Z0-9-]+)`/gu)].map((m) => m[1]);
1023
+ const hexMatches = [...line.matchAll(/`(#[0-9a-fA-F]{6})`/gu)].map((m) => m[1].toLowerCase());
1024
+ if (tokenMatches.length === 1 && hexMatches.length >= 1) {
1025
+ const tokenName = tokenMatches[0];
1026
+ if (!["--wl", "--wr", "--col"].includes(tokenName)) {
1027
+ const actual = facts.cssTokens.get(tokenName);
1028
+ if (!actual) {
1029
+ issues.push({
1030
+ file: relFile,
1031
+ line: lineNumber,
1032
+ kind: "css-token",
1033
+ message: `Unknown CSS custom property "${tokenName}"`,
1034
+ });
1035
+ } else {
1036
+ const validHexes = new Set(
1037
+ [actual.light?.toLowerCase(), actual.dark?.toLowerCase()].filter(Boolean),
1038
+ );
1039
+ for (const hex of hexMatches) {
1040
+ if (!validHexes.has(hex)) {
1041
+ issues.push({
1042
+ file: relFile,
1043
+ line: lineNumber,
1044
+ kind: "css-color",
1045
+ message: `CSS token "${tokenName}" paired with "${hex}", which matches neither light (${actual.light}) nor dark (${actual.dark}) in tokens.css`,
1046
+ });
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ return issues;
1056
+ }
1057
+
1058
+ /**
1059
+ * Scan all docs/handbook/ and app/garden/ files and return any fact-check failures.
1060
+ */
1061
+ export function checkAllHandbookFacts(root = ROOT) {
1062
+ const facts = deriveHandbookFacts(root);
1063
+ const files = [
1064
+ ...walkFiles(path.join(root, "docs/handbook"), (f) => f.endsWith(".md")),
1065
+ ...walkFiles(path.join(root, "app/garden"), (f) => /\.(?:ts|tsx|js|mjs|md)$/u.test(f)),
1066
+ ];
1067
+ const issues = [];
1068
+ for (const file of files) {
1069
+ const content = fs.readFileSync(file, "utf8");
1070
+ issues.push(...checkFileFacts(file, content, facts, root));
1071
+ }
1072
+ const svgFiles = walkFiles(path.join(root, "docs/handbook"), (f) => f.endsWith(".svg"));
1073
+ for (const svgFile of svgFiles) {
1074
+ const svgContent = fs.readFileSync(svgFile, "utf8");
1075
+ const relSvg = path.relative(root, svgFile).replace(/\\/g, "/");
1076
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffd]/u.test(svgContent)) {
1077
+ issues.push({
1078
+ file: relSvg,
1079
+ line: 1,
1080
+ kind: "svg-encoding",
1081
+ message: "SVG file contains control characters or invalid UTF-8 replacement characters",
1082
+ });
1083
+ }
1084
+ }
1085
+ return { facts, issues };
1086
+ }
1087
+
1088
+ const invokedDirectly =
1089
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
1090
+
1091
+ if (invokedDirectly) {
1092
+ const { facts, issues } = checkAllHandbookFacts(ROOT);
1093
+ console.log(
1094
+ `Derived facts: ${facts.cli.gardenCommands.size} garden commands, ${facts.cli.uiSubcommands.size} ui subcommands, ${facts.cli.allFlags.size} flags, ${facts.makefile.targets.size} make targets, ${facts.envVars.size} env vars, ${facts.siteConfig.keyPaths.size} site.config paths, ${facts.checkCodes.size} check codes, ${facts.cssTokens.size} CSS tokens.`,
1095
+ );
1096
+ if (issues.length > 0) {
1097
+ for (const issue of issues) {
1098
+ console.error(`${issue.file}:${issue.line} [${issue.kind}] ${issue.message}`);
1099
+ }
1100
+ process.exitCode = 1;
1101
+ } else {
1102
+ console.log("Handbook fact-check passed: 0 issues.");
1103
+ }
1104
+ }