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,148 @@
1
+ ---
2
+ title: 写文章与随想
3
+ navTitle: 写文章与随想
4
+ description: 长文与随想的 Front Matter 字段、代码块对比、提示框、外链卡片、公式与流程图写法。
5
+ section: writing-and-reading
6
+ order: 10
7
+ ---
8
+
9
+ Garden 的内容分为两类:**长文**(放在 `content/posts/`)和**随想**(放在 `content/thoughts/`)。两者都使用 UTF-8 编码的 Markdown 文件,文件名只能包含小写字母、数字和连字符(`a-z`、`0-9`、`-`)。
10
+
11
+ ## 1. 长文 Front Matter(`content/posts/*.md`)
12
+
13
+ 用 `make new DIR=systems SLUG=wal-checkpoint TITLE="深入理解 WAL丨检查点与恢复流程"` 创建文章,或者手动新建 `.md` 文件:
14
+
15
+ ```yaml
16
+ ---
17
+ title: 深入理解 WAL丨检查点与恢复流程
18
+ date: 2026-10-18
19
+ updated: 2026-10-19
20
+ description: 拆解预写日志在并发读写下的页面可见性规则,以及后台检查点如何控制日志文件体积。
21
+ tags:
22
+ - 数据库
23
+ - 存储
24
+ pinned: false
25
+ draft: false
26
+ cover: windowsill-herbs.svg
27
+ original: https://example.com/original-post
28
+ references:
29
+ - title: SQLite Write-Ahead Logging
30
+ url: https://www.sqlite.org/wal.html
31
+ - title: 《Database Internals》
32
+ ---
33
+ ```
34
+
35
+ | 字段 | 必填 | 类型 | 说明 |
36
+ | :--- | :--- | :--- | :--- |
37
+ | `title` | 是 | 字符串 | 文章标题。中间写 `丨`、`|` 或 ` \| ` 会分成两行并按字数缩放字号 |
38
+ | `date` | 是 | 日期字符串 | 发布日期(如 `2026-10-18` 或带时区的 ISO 时间) |
39
+ | `description` | 是 | 字符串 | 文章摘要,显示在正文开头导语、列表页与搜索引擎摘要中 |
40
+ | `updated` | 否 | 日期字符串 | 最后更新日期,显示在文章元信息栏并用于 RSS 排序 |
41
+ | `publishAt` | 否 | 日期字符串 | 定时发布时间;早于当前构建时间(或设置 `CONTENT_INCLUDE_DRAFTS=1`)才会发布 |
42
+ | `tags` | 否 | 字符串数组 | 文章标签列表,点击标签会跳转到 `/blog?t=<标签>` 筛选 |
43
+ | `pinned` | 否 | 布尔值 | 设为 `true` 时显示在站点首页「置顶」区域 |
44
+ | `draft` | 否 | 布尔值 | 设为 `true` 时视为草稿,正式构建会自动跳过 |
45
+ | `cover` | 否 | 路径字符串 | 封面图路径,相对 `content/covers/` 目录 |
46
+ | `original` | 否 | 完整链接 | 原文链接,设置后在标题下方元信息栏显示 `原文 ↗` |
47
+ | `references` | 否 | `{ title, url? }[]` | 文末参考资料列表,构建期自动提取域名并渲染在正文底部的「参考」卡片中 |
48
+
49
+ > [!CAUTION]
50
+ > 不要在 Front Matter 里手写 `slug` 字段(`npx garden check` 会报 `unexpected-slug` 错误:`不要写 slug,文件名就是标识`)。文章也没有 `topic` 分类字段。
51
+
52
+ ## 2. 代码块与「代码 / 对比上一版」切换
53
+
54
+ 普通代码块在右上角带有语言名称(如 `RUST`、`TS`、`PYTHON`、`SHELL`)和复制按钮。
55
+
56
+ 如果你想展示某段代码修改前后的对比,**先放一段完整代码块,紧接着放一段 `diff` 代码块**。渲染器会自动把它们合并成一个带 **「代码」** 和 **「对比上一版」** 两个切换按钮的代码框:
57
+
58
+ ````markdown
59
+ ```go
60
+ func openStore(path string) (*Store, error) {
61
+ return openWithMode(path, ModeWAL)
62
+ }
63
+ ```
64
+
65
+ ```diff
66
+ func openStore(path string) (*Store, error) {
67
+ - return openWithMode(path, ModeJournal)
68
+ + return openWithMode(path, ModeWAL)
69
+ }
70
+ ```
71
+ ````
72
+
73
+ 实际渲染效果如下(点击右上角的 **「代码」** 与 **「对比上一版」** 可以切换,点击复制按钮始终复制前面的完整代码):
74
+
75
+ ```go
76
+ func openStore(path string) (*Store, error) {
77
+ return openWithMode(path, ModeWAL)
78
+ }
79
+ ```
80
+
81
+ ```diff
82
+ func openStore(path string) (*Store, error) {
83
+ - return openWithMode(path, ModeJournal)
84
+ + return openWithMode(path, ModeWAL)
85
+ }
86
+ ```
87
+
88
+ > [!IMPORTANT]
89
+ > `diff` 代码块前面必须紧挨着一段完整代码块(中间只能隔空行);如果单独放一段 `diff` 而前面没有完整代码块,`npx garden check` 会报 `diff-orphan` 错误。
90
+
91
+ ## 3. 中文加粗、荧光笔高亮与提示框
92
+
93
+ - **中文标点旁加粗**:内置 `remark-cjk-friendly`,紧挨中文括号、引号或顿号写 `**像「这样」的加粗**` 能正常解析,不需要在星号两侧加空格。
94
+ - **荧光笔高亮**:用 `<u>重点句子</u>` 或 `<mark>重点句子</mark>` 可以给文字加上暖黄色荧光笔底纹,效果如:<u>这一句带有荧光笔下划线底色</u>。
95
+ - **提示框(Callouts)**:支持 `> [!NOTE]`、`> [!TIP]`、`> [!IMPORTANT]`、`> [!WARNING]`、`> [!CAUTION]`(以及 `INFO`、`SUCCESS`、`DANGER`、`FAILURE`)。在标记同行末尾写文字可替换默认标题:
96
+
97
+ ```markdown
98
+ > [!WARNING] 升级前先备份数据目录
99
+ > 修改页大小后需要重建索引文件,不能直接原地回退。
100
+ ```
101
+
102
+ > [!WARNING] 升级前先备份数据目录
103
+ > 修改页大小后需要重建索引文件,不能直接原地回退。
104
+
105
+ ## 4. 本地图片与外部链接卡片
106
+
107
+ - **本地图片与随文目录**:如果文章路径是 `content/posts/my-post.md`,把配图放在同名的 `content/posts/my-post/` 文件夹下,在文中以 `./my-post/diagram.svg` 相对路径引用。构建时会自动复制图片、读取像素宽高,并把 `alt` 文字(以 `image`、`截图`、`img` 开头的默认占位词除外)渲染成图注 `<figcaption>`。
108
+ - **行内外部链接**:正文里的外部链接会自动加上目标站点的 Favicon 小图标,并保证图标与链接第一个字同行不折断。
109
+ - **链接卡片**:有两种写法可以把外部链接渲染成带网站名、标题和简介的链接卡片:
110
+ 1. 把完整网址单独写成一行段落(前后空行):`https://spec.commonmark.org/`
111
+ 2. 在段落内的链接末尾紧跟 `{card}`(中间不加空格):`参见 [CommonMark 规范](https://spec.commonmark.org/){card}。`
112
+ 也可以运行 `make embed URL="https://example.com"` 抓取目标网页的 Open Graph 标题、摘要和封面图。
113
+
114
+ ## 5. 数学公式与 Mermaid 图表
115
+
116
+ - **LaTeX 数学公式**:使用 KaTeX 在构建与渲染期直接排版。行内公式写 `$L = \lambda W$`,独立公式块写 `$$...$$`。公式里带下划线的多字母变量名(如 `group_num`、`max_batch`)会自动保护为正体标识符 `\mathrm{group\_num}`,而 `W_q`、`H_{kv}` 等单字母带下标仍保持标准数学下标。
117
+ - **Mermaid 图表**:使用 ```` ```mermaid ```` 代码块直接绘制流程图或时序图,图表会自动适配浅色与深色主题:
118
+
119
+ ```mermaid
120
+ graph LR
121
+ A[Markdown 源文件] --> B[garden sync]
122
+ B --> C[生成搜索与静态资源]
123
+ C --> D[页面渲染]
124
+ ```
125
+
126
+ ## 6. 写随想(`content/thoughts/*.md`)
127
+
128
+ 随想适合记录短文字、多张配图、本地音视频或外部网页链接卡片:
129
+
130
+ ```yaml
131
+ ---
132
+ title: 雨夜调试记录
133
+ date: 2026-10-19 21:30
134
+ tags:
135
+ - 日常
136
+ media:
137
+ - type: image
138
+ src: ./rainy-night/terminal.png
139
+ alt: 终端里的压测输出
140
+ - type: link
141
+ src: https://sqlite.org/wal.html
142
+ title: SQLite Write-Ahead Logging
143
+ ---
144
+
145
+ 记一段简短的想法,支持完整的 Markdown 语法。
146
+ ```
147
+
148
+ `media` 数组支持四类条目:`image`(多张图片自动拼成宫格图集)、`audio`(原生 `<audio>` 音频播放器)、`video`(原生 `<video>` 视频播放器,指向视频文件地址,可配 `poster` 封面)和 `link`(渲染为外部链接卡片,支持 `src`、`title`、`description`、`image`)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "app",
27
27
  "bin",
28
28
  "build",
29
+ "docs/handbook",
29
30
  "public/favicon.svg",
30
31
  "public/apple-touch-icon.png",
31
32
  "public/og.png",
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import matter from "gray-matter";
5
6
  import { marked } from "marked";
6
7
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
@@ -19,6 +20,7 @@ import { coverImageSet, loadSharp } from "./cover-images.mjs";
19
20
  import { readFileSizeCache } from "./file-sizes.mjs";
20
21
  import { previewDocument, previewFileName, renderFilePreview } from "./file-preview.mjs";
21
22
  import { parseGardenBlock, parseGardenInfo, scanFences } from "../ui/garden/parse.mjs";
23
+ import { checkGardenMarkdown } from "../ui/garden/check.mjs";
22
24
  import {
23
25
  fileIcon,
24
26
  fileName,
@@ -31,6 +33,8 @@ import {
31
33
  import { resolveRepoFile } from "../ui/garden/file-paths.mjs";
32
34
  import { trailingSlashEnabled, withTrailingSlash } from "../ui/garden/site-href.mjs";
33
35
 
36
+ const gardenPackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
+
34
38
  function resolveSiteRoot() {
35
39
  if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
36
40
  const yaml = path.join(process.cwd(), "site.config.yaml");
@@ -141,10 +145,12 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
141
145
  if (!value || isExternalAsset(value)) return value;
142
146
 
143
147
  const baseDir = options.baseDir ?? path.dirname(markdownPath);
148
+ const allowedRoot = options.allowedRoot ?? contentRoot;
149
+ const mediaPrefix = options.mediaPrefix ?? "";
144
150
  const [assetPath, suffix = ""] = value.split(/(?=[?#])/u, 2);
145
151
  const decodedPath = decodeURIComponent(assetPath.replace(/^<|>$/g, ""));
146
152
  const absoluteSource = path.resolve(baseDir, decodedPath);
147
- const relativeSource = path.relative(contentRoot, absoluteSource);
153
+ const relativeSource = path.relative(allowedRoot, absoluteSource);
148
154
 
149
155
  if (
150
156
  relativeSource.startsWith("..") ||
@@ -158,7 +164,8 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
158
164
  if (!assetCache.has(absoluteSource)) {
159
165
  const extension = path.extname(relativeSource);
160
166
  const stem = relativeSource.slice(0, Math.max(0, relativeSource.length - extension.length));
161
- const safeRelative = `${stem
167
+ const prefixedStem = mediaPrefix ? path.join(mediaPrefix, stem) : stem;
168
+ const safeRelative = `${prefixedStem
162
169
  .split(path.sep)
163
170
  .map((segment) => segment.replace(/[^\w\u3400-\u9fff.-]+/gu, "-"))
164
171
  .join(path.sep)}${extension.toLowerCase()}`;
@@ -173,17 +180,17 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
173
180
  return `${assetCache.get(absoluteSource)}${suffix}`;
174
181
  }
175
182
 
176
- function rewriteMarkdownAssets(content, markdownPath, sourcePath) {
183
+ function rewriteMarkdownAssets(content, markdownPath, sourcePath, options = {}) {
177
184
  // `![alt](<a b (1).png>)` keeps its angle brackets: without them the space
178
185
  // and the parentheses would no longer be one destination.
179
186
  const markdownImages = replaceInlineLinks(content, (link) =>
180
- link.image ? formatInlineLink(link, copyLocalAsset(link.url, markdownPath, sourcePath)) : undefined,
187
+ link.image ? formatInlineLink(link, copyLocalAsset(link.url, markdownPath, sourcePath, options)) : undefined,
181
188
  );
182
189
 
183
190
  return markdownImages.replace(
184
191
  /(<(?:img|audio|video|source)\b[^>]*\bsrc=["'])([^"']+)(["'])/giu,
185
192
  (_, prefix, src, suffix) =>
186
- `${prefix}${copyLocalAsset(src, markdownPath, sourcePath)}${suffix}`,
193
+ `${prefix}${copyLocalAsset(src, markdownPath, sourcePath, options)}${suffix}`,
187
194
  );
188
195
  }
189
196
 
@@ -284,7 +291,9 @@ function rewriteWidgetFences(content, markdownPath, sourcePath) {
284
291
  const remoteFileSizes = readFileSizeCache(root).files;
285
292
 
286
293
  /** One file of a `garden file` fence, resolved for the page. */
287
- function resolveFileEntry(file, markdownPath, sourcePath) {
294
+ function resolveFileEntry(file, markdownPath, sourcePath, options = {}) {
295
+ const allowedRoot = options.allowedRoot ?? contentRoot;
296
+ const assetOptions = options.assetOptions ?? {};
288
297
  const src = file.src;
289
298
  const name = fileName(src);
290
299
  const entry = {
@@ -304,18 +313,18 @@ function resolveFileEntry(file, markdownPath, sourcePath) {
304
313
  const size = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.statSync(file).size : 0;
305
314
  return { ...entry, url: `${basePath}${src}`, remote: false, preview: "", ...(size > 0 ? { size } : {}) };
306
315
  }
307
- const resolved = resolveRepoFile(src, markdownPath, contentRoot);
316
+ const resolved = resolveRepoFile(src, markdownPath, allowedRoot);
308
317
  if (resolved.error) fail(sourcePath, `garden file:${resolved.error}`);
309
318
  const missingMessage = `garden file 找不到 ${src};src 相对这篇 Markdown 所在的目录`;
310
319
  if (!fs.existsSync(resolved.path) || !fs.statSync(resolved.path).isFile()) fail(sourcePath, missingMessage);
311
- const url = copyLocalAsset(src, markdownPath, sourcePath, { missingMessage });
320
+ const url = copyLocalAsset(src, markdownPath, sourcePath, { ...assetOptions, missingMessage });
312
321
  const preview = previewKind(src, { remote: false });
313
322
  const next = { ...entry, url, size: fs.statSync(resolved.path).size, remote: false, preview };
314
323
  if (preview === "md") {
315
324
  // Rendered now into its own file next to the copy; the page only links to it and the
316
325
  // preview fetches it on demand. Images next to the Markdown file are copied too.
317
326
  const { content } = matter(fs.readFileSync(resolved.path, "utf8"));
318
- const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath));
327
+ const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath, assetOptions));
319
328
  const { destination } = assetFiles.get(url.split(/[?#]/u, 1)[0]);
320
329
  fs.writeFileSync(previewFileName(destination), previewDocument(html, { title: entry.title }));
321
330
  next.previewSrc = previewFileName(url.split(/[?#]/u, 1)[0]);
@@ -328,7 +337,7 @@ function resolveFileEntry(file, markdownPath, sourcePath) {
328
337
  * files to the public folder, and replace the YAML with `resolved:` data the
329
338
  * page renders without reading the file system.
330
339
  */
331
- function rewriteFileFences(content, markdownPath, sourcePath) {
340
+ function rewriteFileFences(content, markdownPath, sourcePath, options = {}) {
332
341
  const fences = scanFences(maskHtmlComments(content)).filter((fence) => parseGardenInfo(fence.info)?.name === "file" && !fence.unclosed);
333
342
  if (!fences.length) return content;
334
343
  const lines = content.split("\n");
@@ -341,7 +350,7 @@ function rewriteFileFences(content, markdownPath, sourcePath) {
341
350
  const resolved = {
342
351
  layout: read.layout,
343
352
  ...(read.title ? { title: read.title } : {}),
344
- files: read.files.map((file) => resolveFileEntry(file, markdownPath, sourcePath)),
353
+ files: read.files.map((file) => resolveFileEntry(file, markdownPath, sourcePath, options)),
345
354
  };
346
355
  const indent = " ".repeat(fence.indent);
347
356
  lines.splice(
@@ -355,7 +364,7 @@ function rewriteFileFences(content, markdownPath, sourcePath) {
355
364
  return lines.join("\n");
356
365
  }
357
366
 
358
- function rewriteUiFences(content, markdownPath, sourcePath, lineOffset) {
367
+ function rewriteUiFences(content, markdownPath, sourcePath, lineOffset, assetOptions = {}) {
359
368
  const { content: transformed, errors } = transformUiContent(content, {
360
369
  mode: uiMode,
361
370
  markdownPath: sourcePath.split(path.sep).join("/"),
@@ -364,7 +373,7 @@ function rewriteUiFences(content, markdownPath, sourcePath, lineOffset) {
364
373
  const absolute = path.resolve(path.dirname(markdownPath), src);
365
374
  return fs.existsSync(absolute) && fs.statSync(absolute).isFile() ? fs.readFileSync(absolute, "utf8") : undefined;
366
375
  },
367
- resolveAsset: (_sidecarPath, written) => copyLocalAsset(written, markdownPath, sourcePath),
376
+ resolveAsset: (_sidecarPath, written) => copyLocalAsset(written, markdownPath, sourcePath, assetOptions),
368
377
  });
369
378
  if (errors.length) {
370
379
  fail(
@@ -436,6 +445,9 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath, lin
436
445
  if (data.draft !== undefined && typeof data.draft !== "boolean") {
437
446
  fail(sourcePath, "draft 必须是布尔值");
438
447
  }
448
+ if (data.numbering !== undefined && typeof data.numbering !== "boolean") {
449
+ fail(sourcePath, "numbering 必须是布尔值");
450
+ }
439
451
 
440
452
  if (kind === "post") {
441
453
  requireString(data, "description", sourcePath);
@@ -616,6 +628,83 @@ function readColumnReferences(posts) {
616
628
  });
617
629
  }
618
630
 
631
+ const handbookRoot = path.join(gardenPackageRoot, "docs", "handbook");
632
+
633
+ function readHandbookDocs() {
634
+ if (!fs.existsSync(handbookRoot)) return [];
635
+ const assetOptions = { allowedRoot: handbookRoot, mediaPrefix: "garden-docs" };
636
+ const fileOptions = { allowedRoot: handbookRoot, assetOptions };
637
+
638
+ function walk(dir) {
639
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
640
+ const fullPath = path.join(dir, entry.name);
641
+ if (entry.isDirectory()) return walk(fullPath);
642
+ if (!entry.name.endsWith(".md")) return [];
643
+
644
+ const raw = fs.readFileSync(fullPath, "utf8");
645
+ const parsed = matter(raw, {
646
+ engines: {
647
+ yaml: {
648
+ parse: (input) => parseYaml(input) ?? {},
649
+ },
650
+ },
651
+ });
652
+ // Sidecar Markdown attachments (such as docs/handbook/components/file/checklist.md)
653
+ // do not carry handbook front matter (`section`) and are served as downloadable files.
654
+ if (!parsed.data || typeof parsed.data.section !== "string") return [];
655
+ const relativeMd = path.relative(handbookRoot, fullPath).split(path.sep).join("/");
656
+ const sourcePath = `docs/handbook/${relativeMd}`;
657
+ const docPath = relativeMd.replace(/\.md$/u, "");
658
+ const slug = path.basename(fullPath, ".md");
659
+ const frontMatterLines = raw.slice(0, raw.length - parsed.content.length).split("\n").length - 1;
660
+ const title = requireString(parsed.data, "title", sourcePath);
661
+ const description = requireString(parsed.data, "description", sourcePath);
662
+ const section = requireString(parsed.data, "section", sourcePath);
663
+ const navTitle = optionalString(parsed.data, "navTitle", sourcePath);
664
+ const badge = optionalString(parsed.data, "badge", sourcePath);
665
+ const order = typeof parsed.data.order === "number" ? parsed.data.order : 999;
666
+ const references = normalizeReferences(parsed.data.references, sourcePath);
667
+
668
+ const diagnostics = checkGardenMarkdown(parsed.content.trim(), { title });
669
+ const blocking = diagnostics.filter((item) => item.severity === "error");
670
+ if (blocking.length) {
671
+ fail(
672
+ sourcePath,
673
+ blocking.map((item) => `第 ${item.line + frontMatterLines} 行 ${item.code}:${item.message}`).join(";"),
674
+ );
675
+ }
676
+
677
+ const content = rewriteFileFences(
678
+ rewriteMarkdownAssets(
679
+ rewriteUiFences(parsed.content.trim(), fullPath, sourcePath, frontMatterLines + leadingLines(parsed.content), assetOptions),
680
+ fullPath,
681
+ sourcePath,
682
+ assetOptions,
683
+ ),
684
+ fullPath,
685
+ sourcePath,
686
+ fileOptions,
687
+ );
688
+
689
+ return [{
690
+ title,
691
+ slug,
692
+ ...(navTitle ? { navTitle } : {}),
693
+ description,
694
+ path: docPath,
695
+ section,
696
+ order,
697
+ ...(badge ? { badge } : {}),
698
+ ...(references ? { references } : {}),
699
+ sourcePath,
700
+ content,
701
+ }];
702
+ });
703
+ }
704
+
705
+ return walk(handbookRoot).sort((a, b) => a.order - b.order || a.path.localeCompare(b.path));
706
+ }
707
+
619
708
  fs.rmSync(generatedMediaRoot, { recursive: true, force: true });
620
709
 
621
710
  const posts = readMarkdownTree(path.join(contentRoot, "posts"), "post").sort(
@@ -627,6 +716,9 @@ const columns = readColumnReferences(posts).sort(
627
716
  const thoughts = readMarkdownTree(path.join(contentRoot, "thoughts"), "thought").sort(
628
717
  (a, b) => Date.parse(b.date) - Date.parse(a.date),
629
718
  );
719
+ const allHandbookDocs = readHandbookDocs();
720
+ const gardenIndexDoc = allHandbookDocs.find((doc) => doc.path === "index") ?? null;
721
+ const gardenDocs = allHandbookDocs.filter((doc) => doc.path !== "index");
630
722
 
631
723
  // Intrinsic image sizes for width/height on <img>: local files are read now,
632
724
  // remote ones come from image-sizes.json (written by scripts/image-sizes.mjs).
@@ -647,7 +739,7 @@ function localImageFile(src) {
647
739
  }
648
740
  const remoteImageSizes = readImageCache(root).images;
649
741
  const imageSizes = {};
650
- for (const entry of [...posts, ...columns, ...thoughts]) {
742
+ for (const entry of [...posts, ...columns, ...thoughts, ...allHandbookDocs]) {
651
743
  for (const src of imageSources(entry.content)) {
652
744
  if (imageSizes[src]) continue;
653
745
  if (/^https?:\/\//iu.test(src)) {
@@ -670,6 +762,7 @@ assertUnique(
670
762
  "专栏文章顺序",
671
763
  );
672
764
  assertUnique(thoughts, (item) => item.slug, "随想 slug");
765
+ assertUnique(gardenDocs, (item) => item.path, "Garden 文档路径");
673
766
 
674
767
  const parityDir = path.join(root, "parity", "articles");
675
768
  const a2WordCounts = fs.existsSync(parityDir)
@@ -721,6 +814,7 @@ export type ContentEntry = {
721
814
  tags?: string[];
722
815
  references?: { title: string; url?: string; domain?: string }[];
723
816
  pinned?: boolean;
817
+ numbering?: boolean;
724
818
  readingTime?: string;
725
819
  kind: "post" | "column" | "thought";
726
820
  sourcePath: string;
@@ -742,6 +836,18 @@ export type ContentEntry = {
742
836
  linkDescription?: string;
743
837
  };
744
838
 
839
+ export type GardenDocEntry = {
840
+ title: string;
841
+ navTitle?: string;
842
+ description: string;
843
+ path: string;
844
+ section: string;
845
+ order: number;
846
+ badge?: string;
847
+ references?: { title: string; url?: string; domain?: string }[];
848
+ content: string;
849
+ };
850
+
745
851
  export type LinkCardMeta = { title: string; description?: string; siteName?: string; author?: string; publishedTime?: string; image?: string };
746
852
  export type LinkAssets = { favicons: Record<string, string>; cards: Record<string, LinkCardMeta> };
747
853
 
@@ -749,6 +855,8 @@ export const contentHash = "${contentHash}";
749
855
  export const posts = ${JSON.stringify(posts, null, 2)} as ContentEntry[];
750
856
  export const columns = ${JSON.stringify(columns, null, 2)} as ContentEntry[];
751
857
  export const thoughts = ${JSON.stringify(thoughts, null, 2)} as ContentEntry[];
858
+ export const gardenDocs = ${JSON.stringify(gardenDocs, null, 2)} as GardenDocEntry[];
859
+ export const gardenIndexDoc = ${JSON.stringify(gardenIndexDoc, null, 2)} as GardenDocEntry | null;
752
860
  export const a2WordCounts = ${JSON.stringify(a2WordCounts, null, 2)} as Record<string, number>;
753
861
  export const linkAssets = ${JSON.stringify(linkAssetsForRender(root, basePath), null, 2)} as LinkAssets;
754
862
  export const imageSizes: Record<string, { width: number; height: number }> = ${JSON.stringify(imageSizes)};
@@ -839,7 +947,8 @@ writeRssFeed(
839
947
  posts.map((entry) => feedEntry(entry, `/blog/${entry.path}/`)),
840
948
  );
841
949
  const sitemapEntries = [
842
- "/", "/blog/", "/thoughts/", "/columns/", "/about/",
950
+ "/", "/blog/", "/thoughts/", "/columns/", "/about/", "/garden/",
951
+ ...gardenDocs.map((entry) => `/garden/${entry.path}/`),
843
952
  ...posts.map((entry) => `/blog/${entry.path}/`),
844
953
  ...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
845
954
  ...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Small explicit allowlist for the handbook fact-check gate.
3
+ * Only external tools or standard non-Garden tokens that cannot be derived
4
+ * from repository source files belong here. Every entry requires a comment.
5
+ */
6
+
7
+ export const ALLOWED_EXTERNAL_BINARIES = {
8
+ // Node.js package manager used to install hd-garden and run package scripts
9
+ npm: "Node.js package manager CLI",
10
+ // Node.js package runner used to invoke `npx garden` / `npx hd-garden`
11
+ npx: "Node.js package runner CLI",
12
+ // Node.js runtime CLI
13
+ node: "Node.js runtime CLI",
14
+ // Git version control CLI used in authoring and deployment workflows
15
+ git: "Git CLI",
16
+ // POSIX shell builtin for changing the working directory
17
+ cd: "POSIX shell directory builtin",
18
+ // OpenSSH key generator used when creating a GitHub Pages deploy key
19
+ "ssh-keygen": "OpenSSH key generation tool for deploy keys",
20
+ };
21
+
22
+ export const ALLOWED_EXTERNAL_FLAGS = {
23
+ // `npm install --save-exact` pins the exact hd-garden version in a site repo
24
+ "--save-exact": "npm install flag to pin exact dependency version",
25
+ // `ssh-keygen -t ed25519 -C ... -f ...` standard OpenSSH flags
26
+ "-t": "ssh-keygen key type flag",
27
+ "-C": "ssh-keygen comment flag",
28
+ "-f": "ssh-keygen output file flag",
29
+ // `git commit -m` / `git add -A` standard git flags
30
+ "-m": "git commit message flag",
31
+ "-A": "git add all tracked/untracked files flag",
32
+ };
33
+
34
+ export const ALLOWED_NON_ENV_UPPER_SNAKE = {
35
+ // Placeholder token shown in ssh-keygen / GitHub Actions prose or HTTP/DOM acronyms
36
+ GFM: "GitHub Flavored Markdown acronym",
37
+ YAML: "YAML Ain't Markup Language acronym",
38
+ HTML: "HyperText Markup Language acronym",
39
+ CSS: "Cascading Style Sheets acronym",
40
+ SVG: "Scalable Vector Graphics acronym",
41
+ JSON: "JavaScript Object Notation acronym",
42
+ CSV: "Comma-Separated Values acronym",
43
+ TSV: "Tab-Separated Values acronym",
44
+ URL: "Uniform Resource Locator acronym / Makefile URL parameter",
45
+ RSS: "Really Simple Syndication acronym",
46
+ TOC: "Table of Contents acronym",
47
+ CJK: "Chinese, Japanese, Korean typography acronym",
48
+ LIGHT: "Theme label in prose/diagrams",
49
+ DARK: "Theme label in prose/diagrams",
50
+ UTF: "Unicode Transformation Format prefix",
51
+ DOM: "Document Object Model acronym",
52
+ HTTP: "Hypertext Transfer Protocol acronym",
53
+ HTTPS: "Hypertext Transfer Protocol Secure acronym",
54
+ SSH: "Secure Shell acronym",
55
+ PAT: "Personal Access Token acronym",
56
+ OAuth: "Open Authorization acronym",
57
+ API: "Application Programming Interface acronym",
58
+ CDN: "Content Delivery Network acronym",
59
+ DNS: "Domain Name System acronym",
60
+ CI: "Continuous Integration acronym",
61
+ PR: "Pull Request acronym",
62
+ ID: "Identifier acronym",
63
+ UI: "User Interface acronym",
64
+ AI: "Artificial Intelligence acronym",
65
+ MB: "Megabyte unit",
66
+ KB: "Kilobyte unit",
67
+ GB: "Gigabyte unit",
68
+ };