hd-garden 0.3.6 → 0.3.8
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/.env.example +8 -5
- package/Makefile +0 -1
- package/README.md +2 -2
- package/app/components/Comments.tsx +12 -26
- package/app/components/GiscusComments.tsx +3 -6
- package/app/globals.css +11 -0
- package/app/lib/giscus-config.ts +39 -0
- package/bin/garden.mjs +40 -24
- package/package.json +1 -1
- package/scripts/check-post.mjs +282 -0
- package/templates/site/README.md +3 -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/.env.example
CHANGED
|
@@ -8,11 +8,14 @@ ADMIN_URL=https://admin.example.com
|
|
|
8
8
|
# 可选:反向代理或多域名后台时,显式指定 GitHub OAuth 回调地址。
|
|
9
9
|
# GITHUB_OAUTH_REDIRECT_URI=https://admin.example.com/api/auth/github/callback
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
# 评论默认关闭。打开 https://giscus.app/zh-CN 安装后,去掉注释并填入真实值。
|
|
12
|
+
# 仓库必须是 owner/name 这种公开仓库,且已开启 Discussions。
|
|
13
|
+
# 线上还要把同名四项写进 GitHub Actions Variables。留空会让发布失败。
|
|
14
|
+
# 确定不开放评论时,把 Actions Variable GISCUS_DISABLED 设为 1。
|
|
15
|
+
# GISCUS_REPO=
|
|
16
|
+
# GISCUS_REPO_ID=
|
|
17
|
+
# GISCUS_CATEGORY=
|
|
18
|
+
# GISCUS_CATEGORY_ID=
|
|
16
19
|
|
|
17
20
|
# Webmention.io:注册正式域名后填写域名,不含 https://。
|
|
18
21
|
WEBMENTION_IO_DOMAIN=example.com
|
package/Makefile
CHANGED
package/README.md
CHANGED
|
@@ -55,11 +55,11 @@ make update # 检查、提交并触发自动发布
|
|
|
55
55
|
make update MESSAGE="新增一篇文章"
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
`make dev` 会在首次运行时自动安装依赖并创建 `.env.local`。`make update` 会先检查,再提交并推送工作区中的所有改动。不想发布的文件请先移出仓库或加入 `.gitignore`。
|
|
58
|
+
`make dev` 会在首次运行时自动安装依赖并创建 `.env.local`,同时说明如何接上评论。线上发布要求 Actions Variables 里有 `GISCUS_REPO`、`GISCUS_REPO_ID`、`GISCUS_CATEGORY`、`GISCUS_CATEGORY_ID`;留空会让构建失败。确定不开放评论时设置 `GISCUS_DISABLED=1`。`make update` 会先检查,再提交并推送工作区中的所有改动。不想发布的文件请先移出仓库或加入 `.gitignore`。
|
|
59
59
|
|
|
60
60
|
## 进一步配置
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
评论接法见[从零开始](docs/quick-start.md)。跑通以后,再按需查看:
|
|
63
63
|
|
|
64
64
|
- [评论、Webmentions 与内容分发](docs/integrations.md)
|
|
65
65
|
- [随想管理后台](docs/admin-service.md)
|
|
@@ -1,29 +1,9 @@
|
|
|
1
1
|
import { ChatCircle, GithubLogo } from "@phosphor-icons/react/ssr";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type GiscusConfig,
|
|
5
|
-
} from "./GiscusComments";
|
|
6
|
-
|
|
7
|
-
function giscusConfig(): GiscusConfig | undefined {
|
|
8
|
-
const config = {
|
|
9
|
-
repo:
|
|
10
|
-
process.env.GISCUS_REPO ?? process.env.NEXT_PUBLIC_GISCUS_REPO,
|
|
11
|
-
repoId:
|
|
12
|
-
process.env.GISCUS_REPO_ID ?? process.env.NEXT_PUBLIC_GISCUS_REPO_ID,
|
|
13
|
-
category:
|
|
14
|
-
process.env.GISCUS_CATEGORY ??
|
|
15
|
-
process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
|
|
16
|
-
categoryId:
|
|
17
|
-
process.env.GISCUS_CATEGORY_ID ??
|
|
18
|
-
process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
|
|
19
|
-
};
|
|
20
|
-
return Object.values(config).every(Boolean)
|
|
21
|
-
? (config as GiscusConfig)
|
|
22
|
-
: undefined;
|
|
23
|
-
}
|
|
2
|
+
import { readGiscusConfig } from "../lib/giscus-config";
|
|
3
|
+
import { GiscusComments } from "./GiscusComments";
|
|
24
4
|
|
|
25
5
|
export function Comments({ slug }: { slug: string }) {
|
|
26
|
-
const config =
|
|
6
|
+
const config = readGiscusConfig();
|
|
27
7
|
|
|
28
8
|
return (
|
|
29
9
|
<section className="comments" aria-labelledby={`comments-heading-${slug}`}>
|
|
@@ -34,7 +14,7 @@ export function Comments({ slug }: { slug: string }) {
|
|
|
34
14
|
<p>
|
|
35
15
|
{config
|
|
36
16
|
? "评论由 GitHub Discussions 保存和管理。"
|
|
37
|
-
: "
|
|
17
|
+
: "评论还没开放。配好下面四项后,这里才会出现评论框。"}
|
|
38
18
|
</p>
|
|
39
19
|
</div>
|
|
40
20
|
</div>
|
|
@@ -45,8 +25,14 @@ export function Comments({ slug }: { slug: string }) {
|
|
|
45
25
|
<div className="integration-notice">
|
|
46
26
|
<GithubLogo size={20} weight="fill" />
|
|
47
27
|
<p>
|
|
48
|
-
|
|
49
|
-
|
|
28
|
+
打开{" "}
|
|
29
|
+
<a href="https://giscus.app/zh-CN" target="_blank" rel="noreferrer">
|
|
30
|
+
Giscus
|
|
31
|
+
</a>
|
|
32
|
+
,安装到一个已开启 Discussions 的公开仓库,把仓库、仓库 ID、分类、分类 ID
|
|
33
|
+
写入 <code>.env.local</code> 和 GitHub Actions Variables:
|
|
34
|
+
<code>GISCUS_REPO</code>、<code>GISCUS_REPO_ID</code>、
|
|
35
|
+
<code>GISCUS_CATEGORY</code>、<code>GISCUS_CATEGORY_ID</code>。
|
|
50
36
|
</p>
|
|
51
37
|
</div>
|
|
52
38
|
)}
|
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
category: string;
|
|
9
|
-
categoryId: string;
|
|
10
|
-
};
|
|
5
|
+
import type { GiscusConfig } from "../lib/giscus-config";
|
|
6
|
+
|
|
7
|
+
export type { GiscusConfig };
|
|
11
8
|
|
|
12
9
|
function currentGiscusTheme() {
|
|
13
10
|
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
package/app/globals.css
CHANGED
|
@@ -2934,6 +2934,17 @@ button {
|
|
|
2934
2934
|
font-size: 11px;
|
|
2935
2935
|
}
|
|
2936
2936
|
|
|
2937
|
+
.integration-notice a,
|
|
2938
|
+
.integration-notice code {
|
|
2939
|
+
text-decoration: underline;
|
|
2940
|
+
text-underline-offset: 0.18em;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
.integration-notice code {
|
|
2944
|
+
text-decoration: none;
|
|
2945
|
+
font-family: var(--font-mono), ui-monospace, monospace;
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2937
2948
|
.integration-status {
|
|
2938
2949
|
margin: 22px 0 0;
|
|
2939
2950
|
color: var(--muted);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type GiscusConfig = {
|
|
2
|
+
repo: string;
|
|
3
|
+
repoId: string;
|
|
4
|
+
category: string;
|
|
5
|
+
categoryId: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDERS = new Set([
|
|
9
|
+
"owner/repository",
|
|
10
|
+
"R_xxxxxxxxxx",
|
|
11
|
+
"DIC_xxxxxxxxxx",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function configuredValue(
|
|
15
|
+
env: Record<string, string | undefined>,
|
|
16
|
+
primary: string,
|
|
17
|
+
fallback: string,
|
|
18
|
+
) {
|
|
19
|
+
const value = (env[primary] ?? env[fallback] ?? "").trim();
|
|
20
|
+
if (!value || PLACEHOLDERS.has(value)) return undefined;
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readGiscusConfig(
|
|
25
|
+
env: Record<string, string | undefined> = process.env,
|
|
26
|
+
): GiscusConfig | undefined {
|
|
27
|
+
const repo = configuredValue(env, "GISCUS_REPO", "NEXT_PUBLIC_GISCUS_REPO");
|
|
28
|
+
const repoId = configuredValue(env, "GISCUS_REPO_ID", "NEXT_PUBLIC_GISCUS_REPO_ID");
|
|
29
|
+
const category = configuredValue(env, "GISCUS_CATEGORY", "NEXT_PUBLIC_GISCUS_CATEGORY");
|
|
30
|
+
const categoryId = configuredValue(
|
|
31
|
+
env,
|
|
32
|
+
"GISCUS_CATEGORY_ID",
|
|
33
|
+
"NEXT_PUBLIC_GISCUS_CATEGORY_ID",
|
|
34
|
+
);
|
|
35
|
+
if (!repo || !repoId || !category || !categoryId) return undefined;
|
|
36
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return undefined;
|
|
37
|
+
if (!repoId.startsWith("R_") || !categoryId.startsWith("DIC_")) return undefined;
|
|
38
|
+
return { repo, repoId, category, categoryId };
|
|
39
|
+
}
|
package/bin/garden.mjs
CHANGED
|
@@ -88,11 +88,25 @@ function ensurePublic() {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function printGiscusGuide() {
|
|
92
|
+
process.stdout.write(`评论默认关闭。发布前要接上自己的 Giscus,留空会让 GitHub Actions 构建失败:
|
|
93
|
+
1. 准备一个公开 GitHub 仓库,并开启 Discussions。公开源码仓库可以直接用;私有源码要另建一个公开仓库。
|
|
94
|
+
2. 打开 https://giscus.app/zh-CN ,安装 Giscus App,只授权这个仓库,并选一个分类。
|
|
95
|
+
3. 把仓库、仓库 ID、分类、分类 ID 写入 .env.local 的 GISCUS_REPO、GISCUS_REPO_ID、GISCUS_CATEGORY、GISCUS_CATEGORY_ID。
|
|
96
|
+
4. 在源码仓库 Settings → Secrets and variables → Actions → Variables 写入同样四项。
|
|
97
|
+
示例值不会被当成已配置。确定不开放评论时,把 Actions Variable GISCUS_DISABLED 设为 1。
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
function ensureEnv() {
|
|
102
|
+
// CI injects the real values. Copying .env.example there would fill any
|
|
103
|
+
// unset key with a sample, which used to turn comments on for owner/repository.
|
|
104
|
+
if (process.env.GITHUB_ACTIONS === "true") return;
|
|
92
105
|
const example = path.join(gardenPackageRoot, ".env.example");
|
|
93
106
|
const local = path.join(siteRoot, ".env.local");
|
|
94
107
|
if (!fs.existsSync(local) && fs.existsSync(example)) {
|
|
95
108
|
fs.copyFileSync(example, local);
|
|
109
|
+
printGiscusGuide();
|
|
96
110
|
}
|
|
97
111
|
}
|
|
98
112
|
|
|
@@ -174,14 +188,7 @@ function ensureSiteKit(destinationRoot = siteRoot) {
|
|
|
174
188
|
process.stdout.write(`已补齐站点文件:${added.join("、")}\n`);
|
|
175
189
|
}
|
|
176
190
|
|
|
177
|
-
|
|
178
|
-
destinationRoot,
|
|
179
|
-
".agents",
|
|
180
|
-
"skills",
|
|
181
|
-
"garden-interactive-chart",
|
|
182
|
-
"SKILL.md",
|
|
183
|
-
);
|
|
184
|
-
if (!fs.existsSync(skillFile)) installSkill(destinationRoot);
|
|
191
|
+
installSkill(destinationRoot, { onlyMissing: true });
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
function prepare() {
|
|
@@ -248,28 +255,33 @@ function initSite() {
|
|
|
248
255
|
process.stdout.write("下一步:\n");
|
|
249
256
|
process.stdout.write(` cd ${destination}\n`);
|
|
250
257
|
process.stdout.write(" npm install\n");
|
|
258
|
+
process.stdout.write(" 按下面四步接上评论,再写文章\n");
|
|
251
259
|
process.stdout.write(" make new TITLE=\"你好,世界\" SLUG=hello\n");
|
|
252
260
|
process.stdout.write(" make dev\n");
|
|
261
|
+
printGiscusGuide();
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
"
|
|
260
|
-
|
|
261
|
-
);
|
|
264
|
+
function packagedSkills() {
|
|
265
|
+
const root = path.join(gardenPackageRoot, ".agents", "skills");
|
|
266
|
+
if (!fs.existsSync(root)) return [];
|
|
267
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
268
|
+
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, "SKILL.md")))
|
|
269
|
+
.map((entry) => entry.name);
|
|
262
270
|
}
|
|
263
271
|
|
|
264
|
-
function installSkill(destinationRoot = siteRoot) {
|
|
265
|
-
const
|
|
266
|
-
if (!
|
|
267
|
-
|
|
272
|
+
function installSkill(destinationRoot = siteRoot, { onlyMissing = false } = {}) {
|
|
273
|
+
const names = packagedSkills();
|
|
274
|
+
if (!names.length) fail("Garden 包里没有 skill。");
|
|
275
|
+
const installed = [];
|
|
276
|
+
for (const name of names) {
|
|
277
|
+
const to = path.join(destinationRoot, ".agents", "skills", name);
|
|
278
|
+
if (onlyMissing && fs.existsSync(path.join(to, "SKILL.md"))) continue;
|
|
279
|
+
fs.cpSync(path.join(gardenPackageRoot, ".agents", "skills", name), to, { recursive: true });
|
|
280
|
+
installed.push(name);
|
|
281
|
+
}
|
|
282
|
+
if (installed.length) {
|
|
283
|
+
process.stdout.write(`已安装 skill:${installed.join("、")}\n`);
|
|
268
284
|
}
|
|
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
285
|
}
|
|
274
286
|
|
|
275
287
|
function help() {
|
|
@@ -281,7 +293,8 @@ function help() {
|
|
|
281
293
|
garden new 创建文章草稿
|
|
282
294
|
garden embed 拉取外链摘要
|
|
283
295
|
garden init [目录] 创建只含稿和配置的新站点
|
|
284
|
-
garden
|
|
296
|
+
garden check <文章.md> 检查一篇博文的硬性结构
|
|
297
|
+
garden skill 把引擎自带的 skill 装进当前站点
|
|
285
298
|
garden pages:validate 检查 dist/client
|
|
286
299
|
garden pages:prepare 写入公开产物仓库
|
|
287
300
|
garden webmentions 发送 Webmentions
|
|
@@ -312,6 +325,9 @@ switch (command) {
|
|
|
312
325
|
case "embed":
|
|
313
326
|
runNode("scripts/fetch-embed.mjs", rest);
|
|
314
327
|
break;
|
|
328
|
+
case "check":
|
|
329
|
+
runNode("scripts/check-post.mjs", rest);
|
|
330
|
+
break;
|
|
315
331
|
case "init":
|
|
316
332
|
initSite();
|
|
317
333
|
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,8 @@ make new TITLE="你好,世界" SLUG=hello
|
|
|
8
8
|
make dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
第一次 `npx garden dev` 会补齐 Makefile
|
|
11
|
+
第一次 `npx garden dev` 会补齐 Makefile 和 skill,并创建 `.env.local`。Cursor / Codex 打开这个仓库后会读到 `.agents/skills/`。要更新 skill,再运行 `npx garden skill`。
|
|
12
|
+
|
|
13
|
+
评论默认关闭。发布前打开 [Giscus](https://giscus.app/zh-CN),把仓库、仓库 ID、分类、分类 ID 写入 `.env.local`,并在 GitHub Actions Variables 设置同名的 `GISCUS_REPO`、`GISCUS_REPO_ID`、`GISCUS_CATEGORY`、`GISCUS_CATEGORY_ID`。留空会让发布失败。确定不开放评论时,把 `GISCUS_DISABLED` 设为 `1`。
|
|
12
14
|
|
|
13
15
|
升级引擎:把 `package.json` 里的 `hd-garden` 改到新版本,例如 `^0.4.0`,再运行 `npm install`。
|