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.
- package/app/admin/preview/[...path]/page.tsx +1 -1
- package/app/blog/[...path]/page.tsx +2 -2
- package/app/columns/[column]/[...path]/page.tsx +2 -2
- package/app/components/ArticleMeasure.tsx +39 -4
- package/app/components/GardenDocsNav.tsx +184 -0
- package/app/components/MarkdownArticle.tsx +7 -6
- package/app/components/SiteChrome.tsx +1 -1
- package/app/components/SiteFooter.tsx +10 -3
- package/app/garden/[...slug]/page.tsx +148 -0
- package/app/garden/page.tsx +61 -128
- package/app/garden-modules.d.ts +15 -0
- package/app/globals.css +10 -10
- package/app/lib/content.ts +16 -10
- package/app/lib/garden-docs-meta.ts +83 -0
- package/app/lib/heading-number.ts +13 -0
- package/app/styles/a2.css +531 -0
- package/docs/handbook/assets/reading-layout.svg +26 -0
- package/docs/handbook/components/card.md +44 -0
- package/docs/handbook/components/chart.md +52 -0
- package/docs/handbook/components/compare.md +40 -0
- package/docs/handbook/components/figure.md +70 -0
- package/docs/handbook/components/file/checklist.md +17 -0
- package/docs/handbook/components/file.md +32 -0
- package/docs/handbook/components/html.md +67 -0
- package/docs/handbook/components/interactive.md +65 -0
- package/docs/handbook/components/stat.md +55 -0
- package/docs/handbook/components/timeline.md +50 -0
- package/docs/handbook/components.md +30 -0
- package/docs/handbook/config.md +125 -0
- package/docs/handbook/deploy.md +273 -0
- package/docs/handbook/design.md +81 -0
- package/docs/handbook/index.md +92 -0
- package/docs/handbook/start.md +92 -0
- package/docs/handbook/workflow.md +81 -0
- package/docs/handbook/writing.md +148 -0
- package/package.json +2 -1
- package/scripts/build-content.mjs +124 -15
- package/scripts/handbook-allowlist.mjs +68 -0
- package/scripts/handbook-facts.mjs +1104 -0
- package/scripts/prepare-pages-output.mjs +32 -9
- package/site.config.yaml +2 -2
- package/templates/garden-blocks.md +1 -1
- package/templates/site/package.json +1 -3
- package/ui/garden/catalog.mjs +1 -1
- package/ui/preview/dist/garden-ui.esm.js +1 -1
- 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.
|
|
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(
|
|
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
|
|
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
|
// `.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,
|
|
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
|
+
};
|