hd-garden 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/garden-post-review/SKILL.md +61 -0
- package/bin/garden.mjs +24 -24
- package/package.json +1 -1
- package/scripts/check-post.mjs +282 -0
- package/templates/site/README.md +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: garden-post-review
|
|
3
|
+
description: >-
|
|
4
|
+
Reviews one specified Garden post in three steps: fix script-reported
|
|
5
|
+
structure errors, fix widgets with the chart skill, then suggest prose
|
|
6
|
+
improvements without editing. Use when the user asks to 审查 / 审稿 /
|
|
7
|
+
review a Garden blog post.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# 审查一篇 Garden 博文
|
|
11
|
+
|
|
12
|
+
只审用户点名的稿。三步按顺序做,后一步看到的是前一步改完的稿。
|
|
13
|
+
|
|
14
|
+
硬性检查命令(在站点根目录):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx garden check content/posts/路径.md
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
stdout 是 JSON。`ok` 为 true 才算通过。不要自己再查一遍标题、日期或参考列表。
|
|
21
|
+
|
|
22
|
+
## 1. 脚本报错,模型只修这些
|
|
23
|
+
|
|
24
|
+
运行 `garden check`。有 `errors` 就只改这些条目,改完再跑同一条命令,直到 `ok` 为 true。
|
|
25
|
+
|
|
26
|
+
- 只动报错指出的位置。
|
|
27
|
+
- 脚本没报的结构问题不要顺手改。
|
|
28
|
+
- `references` 是推荐额外阅读,不必和正文里的外链一一对应。不要因为正文有链接就补进表,也不要因为表里的材料正文没提到就删掉。
|
|
29
|
+
- `reference-heading`:把 `## 参考` / `## 参考资料` 收进 `references`,删掉这个标题。`## 参考文献` 留在正文里。
|
|
30
|
+
- 缺 widget 文件时,只修正写错的 `src`。不要在这一步新做图表。
|
|
31
|
+
|
|
32
|
+
## 2. 图表按图表 skill 审并修
|
|
33
|
+
|
|
34
|
+
这一步只在稿里有 `widget` 时做。先读 [garden-interactive-chart](../garden-interactive-chart/SKILL.md)。
|
|
35
|
+
|
|
36
|
+
可以改同名目录里的 HTML,以及对应围栏的 `caption`。周围正文不动。`caption` 必须是一句判断。
|
|
37
|
+
|
|
38
|
+
改完再跑一次 `garden check`,直到通过。
|
|
39
|
+
|
|
40
|
+
没有 `widget` 就跳过这一步。
|
|
41
|
+
|
|
42
|
+
## 3. 通读整篇,只出建议
|
|
43
|
+
|
|
44
|
+
`garden check` 已经通过,并且图表该改的已经改完。通读全文。不要改文件。
|
|
45
|
+
|
|
46
|
+
目标只有一个:这篇是不是从第一性原理出发,自洽,并且把一个点讲到闭环。读者读完应能说出这是什么、为什么必然如此,以及这个「为什么」怎样落到一段实践上。实践是代码、故障、测量或一次推导的落地,用来验证同一条链,不是另起一篇教程。
|
|
47
|
+
|
|
48
|
+
第一性原理指:从拆不开的事实、约束或不变量推上去,而不是从「别人都这么做」或一个比喻出发。拆完还要装回去;只列出现有方案的洞、没有推出结论,不算讲完。类比和业界做法可以出现,但只能当作这条推导的对照,不能代替推导。
|
|
49
|
+
|
|
50
|
+
只在下面的情况写出建议:
|
|
51
|
+
|
|
52
|
+
- 开头站在名词、惯例或比喻上,读者看不到这件事被什么事实卡住
|
|
53
|
+
- 后文的机制、术语或结论,不能从前面的事实推出来,或同一个词前后不是同一个意思
|
|
54
|
+
- 一个点只停在「是什么」:没有为什么,或者为什么没有落到可核对的实践
|
|
55
|
+
- 实践和前面的原理各讲各的,对不上同一条链
|
|
56
|
+
- 铺开多个相邻话题,没有把其中一点收回来。讲透一个点,好过点到许多点
|
|
57
|
+
- 数字、比较、「通常如此」没有从推导、实践或来源里长出来
|
|
58
|
+
|
|
59
|
+
每条建议包含:所在标题、闭环断在哪里、可以怎么补。没有就写没有。不要把稿子改成「问题—方案」模板,也不要要求每段都加不适用说明。译文若已经在复述原文的推导,不要另要求作者发明一套练习。
|
|
60
|
+
|
|
61
|
+
这一步禁止修改博文、图表和 front matter。
|
package/bin/garden.mjs
CHANGED
|
@@ -174,14 +174,7 @@ function ensureSiteKit(destinationRoot = siteRoot) {
|
|
|
174
174
|
process.stdout.write(`已补齐站点文件:${added.join("、")}\n`);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
destinationRoot,
|
|
179
|
-
".agents",
|
|
180
|
-
"skills",
|
|
181
|
-
"garden-interactive-chart",
|
|
182
|
-
"SKILL.md",
|
|
183
|
-
);
|
|
184
|
-
if (!fs.existsSync(skillFile)) installSkill(destinationRoot);
|
|
177
|
+
installSkill(destinationRoot, { onlyMissing: true });
|
|
185
178
|
}
|
|
186
179
|
|
|
187
180
|
function prepare() {
|
|
@@ -252,24 +245,27 @@ function initSite() {
|
|
|
252
245
|
process.stdout.write(" make dev\n");
|
|
253
246
|
}
|
|
254
247
|
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
"
|
|
260
|
-
|
|
261
|
-
);
|
|
248
|
+
function packagedSkills() {
|
|
249
|
+
const root = path.join(gardenPackageRoot, ".agents", "skills");
|
|
250
|
+
if (!fs.existsSync(root)) return [];
|
|
251
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
252
|
+
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, "SKILL.md")))
|
|
253
|
+
.map((entry) => entry.name);
|
|
262
254
|
}
|
|
263
255
|
|
|
264
|
-
function installSkill(destinationRoot = siteRoot) {
|
|
265
|
-
const
|
|
266
|
-
if (!
|
|
267
|
-
|
|
256
|
+
function installSkill(destinationRoot = siteRoot, { onlyMissing = false } = {}) {
|
|
257
|
+
const names = packagedSkills();
|
|
258
|
+
if (!names.length) fail("Garden 包里没有 skill。");
|
|
259
|
+
const installed = [];
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
const to = path.join(destinationRoot, ".agents", "skills", name);
|
|
262
|
+
if (onlyMissing && fs.existsSync(path.join(to, "SKILL.md"))) continue;
|
|
263
|
+
fs.cpSync(path.join(gardenPackageRoot, ".agents", "skills", name), to, { recursive: true });
|
|
264
|
+
installed.push(name);
|
|
265
|
+
}
|
|
266
|
+
if (installed.length) {
|
|
267
|
+
process.stdout.write(`已安装 skill:${installed.join("、")}\n`);
|
|
268
268
|
}
|
|
269
|
-
const to = path.join(destinationRoot, ".agents", "skills", "garden-interactive-chart");
|
|
270
|
-
fs.cpSync(from, to, { recursive: true });
|
|
271
|
-
const shown = path.relative(destinationRoot, to) || to;
|
|
272
|
-
process.stdout.write(`已安装图表 skill:${shown}\n`);
|
|
273
269
|
}
|
|
274
270
|
|
|
275
271
|
function help() {
|
|
@@ -281,7 +277,8 @@ function help() {
|
|
|
281
277
|
garden new 创建文章草稿
|
|
282
278
|
garden embed 拉取外链摘要
|
|
283
279
|
garden init [目录] 创建只含稿和配置的新站点
|
|
284
|
-
garden
|
|
280
|
+
garden check <文章.md> 检查一篇博文的硬性结构
|
|
281
|
+
garden skill 把引擎自带的 skill 装进当前站点
|
|
285
282
|
garden pages:validate 检查 dist/client
|
|
286
283
|
garden pages:prepare 写入公开产物仓库
|
|
287
284
|
garden webmentions 发送 Webmentions
|
|
@@ -312,6 +309,9 @@ switch (command) {
|
|
|
312
309
|
case "embed":
|
|
313
310
|
runNode("scripts/fetch-embed.mjs", rest);
|
|
314
311
|
break;
|
|
312
|
+
case "check":
|
|
313
|
+
runNode("scripts/check-post.mjs", rest);
|
|
314
|
+
break;
|
|
315
315
|
case "init":
|
|
316
316
|
initSite();
|
|
317
317
|
break;
|
package/package.json
CHANGED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { canonicalSiteDate } from "./site-date.mjs";
|
|
6
|
+
import { gardenSiteRoot } from "./garden-paths.mjs";
|
|
7
|
+
|
|
8
|
+
const siteRoot = gardenSiteRoot();
|
|
9
|
+
const calloutTypes = new Set([
|
|
10
|
+
"NOTE",
|
|
11
|
+
"INFO",
|
|
12
|
+
"TIP",
|
|
13
|
+
"SUCCESS",
|
|
14
|
+
"IMPORTANT",
|
|
15
|
+
"WARNING",
|
|
16
|
+
"CAUTION",
|
|
17
|
+
"DANGER",
|
|
18
|
+
"FAILURE",
|
|
19
|
+
]);
|
|
20
|
+
const authorDate = /^(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})$/u;
|
|
21
|
+
const matterOptions = {
|
|
22
|
+
engines: {
|
|
23
|
+
yaml: {
|
|
24
|
+
parse: (input) => parseYaml(input) ?? {},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const error = (errors, code, line, message) => {
|
|
30
|
+
errors.push({ code, line, message });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isExternalAsset = (value) =>
|
|
34
|
+
/^(?:[a-z]+:)?\/\//iu.test(value) ||
|
|
35
|
+
value.startsWith("/") ||
|
|
36
|
+
value.startsWith("data:") ||
|
|
37
|
+
value.startsWith("#");
|
|
38
|
+
|
|
39
|
+
const normalizeUrl = (value) => {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(value);
|
|
42
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
43
|
+
if (key.startsWith("utm_")) url.searchParams.delete(key);
|
|
44
|
+
}
|
|
45
|
+
url.hash = "";
|
|
46
|
+
if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/u, "");
|
|
47
|
+
return url.toString();
|
|
48
|
+
} catch {
|
|
49
|
+
return value.trim();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const markdownStem = (markdownPath) => path.basename(markdownPath, path.extname(markdownPath));
|
|
54
|
+
|
|
55
|
+
const assertLocalFile = (errors, value, baseDir, sourcePath, line, label) => {
|
|
56
|
+
if (!value || isExternalAsset(value)) return;
|
|
57
|
+
const [assetPath] = value.split(/(?=[?#])/u, 2);
|
|
58
|
+
const decoded = decodeURIComponent(assetPath.replace(/^<|>$/gu, ""));
|
|
59
|
+
const absolute = path.resolve(baseDir, decoded);
|
|
60
|
+
if (!fs.existsSync(absolute)) {
|
|
61
|
+
error(errors, "missing-asset", line, `${sourcePath}: 找不到${label} ${value}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const checkReferences = (data, sourcePath, errors) => {
|
|
66
|
+
if (data.references === undefined) return [];
|
|
67
|
+
if (!Array.isArray(data.references)) {
|
|
68
|
+
error(errors, "references-type", 1, `${sourcePath}: references 必须是数组`);
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
data.references.forEach((item, index) => {
|
|
73
|
+
const where = `references[${index}]`;
|
|
74
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
75
|
+
error(errors, "references-type", 1, `${sourcePath}: ${where} 必须是包含 title 的对象`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const key of Object.keys(item)) {
|
|
79
|
+
if (key !== "title" && key !== "url") {
|
|
80
|
+
error(errors, "reference-key", 1, `${sourcePath}: ${where} 不支持 ${key}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (typeof item.title !== "string" || !item.title.trim()) {
|
|
84
|
+
error(errors, "reference-title", 1, `${sourcePath}: ${where} 缺少有效的 title`);
|
|
85
|
+
}
|
|
86
|
+
if (item.url === undefined || item.url === null || item.url === "") return;
|
|
87
|
+
if (typeof item.url !== "string") {
|
|
88
|
+
error(errors, "reference-url", 1, `${sourcePath}: ${where}.url 必须是字符串`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(item.url.trim());
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = null;
|
|
96
|
+
}
|
|
97
|
+
if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
98
|
+
error(errors, "reference-url", 1, `${sourcePath}: ${where}.url 必须是 http 或 https 链接`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const normalized = normalizeUrl(item.url.trim());
|
|
102
|
+
if (seen.has(normalized)) {
|
|
103
|
+
error(errors, "reference-duplicate", 1, `${sourcePath}: ${where}.url 与前面的参考重复`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
seen.add(normalized);
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const checkWidget = (errors, body, markdownPath, sourcePath, line) => {
|
|
111
|
+
let data;
|
|
112
|
+
try {
|
|
113
|
+
data = parseYaml(body);
|
|
114
|
+
} catch {
|
|
115
|
+
error(errors, "widget-yaml", line, `${sourcePath}: widget 围栏不是有效的 YAML`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (data == null || typeof data !== "object" || Array.isArray(data)) {
|
|
119
|
+
error(errors, "widget-yaml", line, `${sourcePath}: widget 围栏必须是包含 src 与 caption 的 YAML 对象`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const caption = typeof data.caption === "string" ? data.caption.trim() : "";
|
|
123
|
+
const src = typeof data.src === "string" ? data.src.trim() : "";
|
|
124
|
+
if (!caption) error(errors, "widget-caption", line, `${sourcePath}: widget 缺少 caption`);
|
|
125
|
+
if (!src) {
|
|
126
|
+
error(errors, "widget-src", line, `${sourcePath}: widget 缺少 src`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const [assetPath] = src.split(/(?=[?#])/u, 2);
|
|
130
|
+
const decoded = decodeURIComponent(assetPath.replace(/^<|>$/gu, ""));
|
|
131
|
+
if (isExternalAsset(decoded)) {
|
|
132
|
+
error(errors, "widget-src", line, `${sourcePath}: widget src 必须是同名目录里的本地 .html 或 .pdf`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const absolute = path.resolve(path.dirname(markdownPath), decoded);
|
|
136
|
+
const sidecar = path.join(path.dirname(markdownPath), markdownStem(markdownPath));
|
|
137
|
+
const relative = path.relative(sidecar, absolute);
|
|
138
|
+
const extension = path.extname(absolute).toLowerCase();
|
|
139
|
+
if (relative.startsWith("..") || path.isAbsolute(relative) || ![".html", ".pdf"].includes(extension)) {
|
|
140
|
+
error(
|
|
141
|
+
errors,
|
|
142
|
+
"widget-src",
|
|
143
|
+
line,
|
|
144
|
+
`${sourcePath}: widget src 必须位于同名目录 ${markdownStem(markdownPath)}/ 内的 .html 或 .pdf`,
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!fs.existsSync(absolute)) {
|
|
149
|
+
error(errors, "widget-missing", line, `${sourcePath}: 找不到 widget 文件 ${src}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (data.kind == null || data.kind === "") return;
|
|
153
|
+
if (typeof data.kind !== "string" || !["chart", "html", "pdf"].includes(data.kind.trim())) {
|
|
154
|
+
error(errors, "widget-kind", line, `${sourcePath}: widget kind 必须是 chart、html 或 pdf`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const kind = data.kind.trim();
|
|
158
|
+
if (kind === "pdf" && extension !== ".pdf") {
|
|
159
|
+
error(errors, "widget-kind", line, `${sourcePath}: kind: pdf 只能用于 .pdf`);
|
|
160
|
+
}
|
|
161
|
+
if ((kind === "chart" || kind === "html") && extension !== ".html") {
|
|
162
|
+
error(errors, "widget-kind", line, `${sourcePath}: kind: ${kind} 只能用于 .html`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const checkEmbed = (errors, body, sourcePath, line) => {
|
|
167
|
+
let data;
|
|
168
|
+
try {
|
|
169
|
+
data = parseYaml(body);
|
|
170
|
+
} catch {
|
|
171
|
+
error(errors, "embed-url", line, `${sourcePath}: embed 围栏不是有效的 YAML`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const url = data && typeof data === "object" && !Array.isArray(data) ? data.url : undefined;
|
|
175
|
+
if (typeof url !== "string" || !url.trim()) {
|
|
176
|
+
error(errors, "embed-url", line, `${sourcePath}: embed 缺少 url`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const parsed = new URL(url.trim());
|
|
181
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("protocol");
|
|
182
|
+
} catch {
|
|
183
|
+
error(errors, "embed-url", line, `${sourcePath}: embed url 必须是 http 或 https 链接`);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const scanBody = (errors, content, markdownPath, sourcePath) => {
|
|
188
|
+
let fence = null;
|
|
189
|
+
let previousDepth = 1;
|
|
190
|
+
const lines = content.split(/\r?\n/u);
|
|
191
|
+
lines.forEach((line, index) => {
|
|
192
|
+
const lineNumber = index + 1;
|
|
193
|
+
const opener = line.match(/^(`{3,}|~{3,})(.*)$/u);
|
|
194
|
+
if (fence) {
|
|
195
|
+
if (opener && opener[1][0] === fence.marker && opener[1].length >= fence.marker.length && !opener[2].trim()) {
|
|
196
|
+
if (fence.lang === "widget") checkWidget(errors, fence.body.join("\n"), markdownPath, sourcePath, fence.line);
|
|
197
|
+
if (fence.lang === "embed") checkEmbed(errors, fence.body.join("\n"), sourcePath, fence.line);
|
|
198
|
+
fence = null;
|
|
199
|
+
} else {
|
|
200
|
+
fence.body.push(line);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (opener) {
|
|
205
|
+
fence = { marker: opener[1], lang: opener[2].trim().split(/\s+/u)[0]?.toLowerCase() ?? "", body: [], line: lineNumber };
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/u);
|
|
210
|
+
if (heading) {
|
|
211
|
+
const depth = heading[1].length;
|
|
212
|
+
const text = heading[2].trim();
|
|
213
|
+
if (depth === 1) error(errors, "heading-h1", lineNumber, `${sourcePath}: 正文不要用一级标题,页面标题写在 front matter`);
|
|
214
|
+
if (depth > previousDepth + 1) {
|
|
215
|
+
error(errors, "heading-skip", lineNumber, `${sourcePath}: 标题从 ${previousDepth} 级跳到了 ${depth} 级`);
|
|
216
|
+
}
|
|
217
|
+
if (depth === 2 && (text === "参考" || text === "参考资料")) {
|
|
218
|
+
error(errors, "reference-heading", lineNumber, `${sourcePath}: 「${text}」不要写成标题,改放到 front matter 的 references`);
|
|
219
|
+
}
|
|
220
|
+
previousDepth = depth;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const callout = line.match(/^>\s*\[!([A-Za-z]+)\]/u);
|
|
224
|
+
if (callout && !calloutTypes.has(callout[1].toUpperCase())) {
|
|
225
|
+
error(errors, "callout-type", lineNumber, `${sourcePath}: 不支持的警告框类型 ${callout[1]}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const image of line.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gu)) {
|
|
229
|
+
assertLocalFile(errors, image[2], path.dirname(markdownPath), sourcePath, lineNumber, "图片");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (fence?.lang === "widget") checkWidget(errors, fence.body.join("\n"), markdownPath, sourcePath, fence.line);
|
|
234
|
+
if (fence?.lang === "embed") checkEmbed(errors, fence.body.join("\n"), sourcePath, fence.line);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const checkFile = (filePath) => {
|
|
238
|
+
const absolute = path.resolve(siteRoot, filePath);
|
|
239
|
+
const sourcePath = path.relative(siteRoot, absolute);
|
|
240
|
+
const errors = [];
|
|
241
|
+
if (!fs.existsSync(absolute)) {
|
|
242
|
+
error(errors, "missing-file", 1, `${sourcePath}: 找不到文件`);
|
|
243
|
+
return { file: sourcePath, errors };
|
|
244
|
+
}
|
|
245
|
+
const parsed = matter(fs.readFileSync(absolute, "utf8"), matterOptions);
|
|
246
|
+
const data = parsed.data ?? {};
|
|
247
|
+
if (typeof data.title !== "string" || !data.title.trim()) {
|
|
248
|
+
error(errors, "missing-title", 1, `${sourcePath}: front matter 缺少有效的 title`);
|
|
249
|
+
}
|
|
250
|
+
if (typeof data.description !== "string" || !data.description.trim()) {
|
|
251
|
+
error(errors, "missing-description", 1, `${sourcePath}: front matter 缺少有效的 description`);
|
|
252
|
+
}
|
|
253
|
+
if (typeof data.topic !== "string" || !data.topic.trim()) {
|
|
254
|
+
error(errors, "missing-topic", 1, `${sourcePath}: front matter 缺少有效的 topic`);
|
|
255
|
+
}
|
|
256
|
+
if (data.slug !== undefined) {
|
|
257
|
+
error(errors, "unexpected-slug", 1, `${sourcePath}: 不要写 slug,文件名就是标识`);
|
|
258
|
+
}
|
|
259
|
+
if (typeof data.date !== "string" || !authorDate.test(data.date.trim()) || !canonicalSiteDate(data.date)) {
|
|
260
|
+
error(errors, "date-format", 1, `${sourcePath}: date 必须是 2026-09-18 或 2026-09-18 14:39:00`);
|
|
261
|
+
}
|
|
262
|
+
if (typeof data.cover === "string" && data.cover.trim() && !isExternalAsset(data.cover.trim())) {
|
|
263
|
+
const coverPath = path.join(siteRoot, "content", "covers", data.cover.trim());
|
|
264
|
+
if (!fs.existsSync(coverPath)) {
|
|
265
|
+
error(errors, "missing-cover", 1, `${sourcePath}: 找不到封面 ${data.cover}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
checkReferences(data, sourcePath, errors);
|
|
269
|
+
scanBody(errors, parsed.content, absolute, sourcePath);
|
|
270
|
+
return { file: sourcePath, errors };
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const files = process.argv.slice(2);
|
|
274
|
+
if (!files.length) {
|
|
275
|
+
process.stderr.write("用法:garden check content/posts/文章.md\n");
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const results = files.map((file) => checkFile(file));
|
|
280
|
+
const ok = results.every((result) => result.errors.length === 0);
|
|
281
|
+
process.stdout.write(`${JSON.stringify({ ok, results }, null, 2)}\n`);
|
|
282
|
+
process.exit(ok ? 0 : 1);
|
package/templates/site/README.md
CHANGED
|
@@ -8,6 +8,6 @@ make new TITLE="你好,世界" SLUG=hello
|
|
|
8
8
|
make dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
第一次 `npx garden dev` 会补齐 Makefile
|
|
11
|
+
第一次 `npx garden dev` 会补齐 Makefile 和 skill。Cursor / Codex 打开这个仓库后会读到 `.agents/skills/`。要更新 skill,再运行 `npx garden skill`。
|
|
12
12
|
|
|
13
13
|
升级引擎:把 `package.json` 里的 `hd-garden` 改到新版本,例如 `^0.4.0`,再运行 `npm install`。
|