hd-garden 0.13.1 → 0.13.3
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/README.en.md +1 -1
- package/README.md +1 -1
- package/app/components/MarkdownArticle.tsx +4 -47
- package/app/components/TableOfContents.tsx +0 -1
- package/app/lib/content.ts +5 -22
- package/app/lib/heading-number.ts +2 -7
- package/bin/garden.mjs +14 -2
- package/docs/handbook/components/file/checklist.md +1 -1
- package/docs/handbook/en/components/file/checklist.md +1 -1
- package/docs/handbook/en/reference/file/checklist.md +1 -1
- package/docs/handbook/en/start.md +3 -3
- package/docs/handbook/reference/file/checklist.md +1 -1
- package/docs/handbook/start.md +3 -3
- package/editors/dist/vscode/garden-ui-0.1.1.vsix +0 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +22 -11
- package/scripts/dev-server.mjs +114 -0
- package/scripts/new-post.mjs +2 -2
- package/templates/site/README.en.md +4 -0
- package/templates/site/README.md +4 -0
- package/ui/garden/markdown-syntax.mjs +1 -1
- package/vite.config.ts +32 -7
package/README.en.md
CHANGED
|
@@ -73,7 +73,7 @@ Ask your Agent to compile the post using the bundled `garden-ui` skill: it runs
|
|
|
73
73
|
```bash
|
|
74
74
|
make new SLUG=writing-pace TITLE="Writing Pace" # Create a draft post in content/posts/
|
|
75
75
|
make dev # Sync content and start the local server
|
|
76
|
-
CONTENT_INCLUDE_DRAFTS=
|
|
76
|
+
CONTENT_INCLUDE_DRAFTS=0 make dev # Preview published posts only (optional)
|
|
77
77
|
make check # Run garden sync and garden build
|
|
78
78
|
make update MESSAGE="Add writing-pace post" # Check, commit, and push to origin
|
|
79
79
|
npx garden check content/posts/writing-pace.md # Validate front matter, components, and assets
|
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ Obsidian 插件这一轮不随包安装。
|
|
|
75
75
|
```bash
|
|
76
76
|
make new SLUG=writing-pace TITLE="慢慢写" # 在 content/posts/ 下创建文章草稿
|
|
77
77
|
make dev # 同步内容并启动本地预览
|
|
78
|
-
CONTENT_INCLUDE_DRAFTS=
|
|
78
|
+
CONTENT_INCLUDE_DRAFTS=0 make dev # 只预览已发布文章(可选)
|
|
79
79
|
make check # 运行 garden sync 与 garden build 验证全站
|
|
80
80
|
make update MESSAGE="新增一篇文章" # 检查通过后提交并推送到远端仓库
|
|
81
81
|
npx garden check content/posts/writing-pace.md # 单独检查一篇文章的标题、组件与资源路径
|
|
@@ -5,12 +5,11 @@ import { remarkA2 } from "../lib/remark-a2.mjs";
|
|
|
5
5
|
import { remarkLinkCard } from "../lib/remark-link-card.mjs";
|
|
6
6
|
import {
|
|
7
7
|
Children,
|
|
8
|
-
cloneElement,
|
|
9
8
|
isValidElement,
|
|
10
9
|
type HTMLAttributes,
|
|
11
10
|
type ReactNode,
|
|
12
11
|
} from "react";
|
|
13
|
-
import { articleHeadingBase,
|
|
12
|
+
import { articleHeadingBase, displayHeadingLevel } from "../lib/content";
|
|
14
13
|
import Link from "next/link";
|
|
15
14
|
import ReactMarkdown from "react-markdown";
|
|
16
15
|
import rehypeRaw from "rehype-raw";
|
|
@@ -150,31 +149,7 @@ function CodeBlock({
|
|
|
150
149
|
);
|
|
151
150
|
}
|
|
152
151
|
|
|
153
|
-
|
|
154
|
-
let rest = prefix;
|
|
155
|
-
const walk = (node: ReactNode): ReactNode => {
|
|
156
|
-
if (!rest) return node;
|
|
157
|
-
if (typeof node === "string" || typeof node === "number") {
|
|
158
|
-
const value = String(node);
|
|
159
|
-
if (value.startsWith(rest)) {
|
|
160
|
-
const kept = value.slice(rest.length);
|
|
161
|
-
rest = "";
|
|
162
|
-
return kept;
|
|
163
|
-
}
|
|
164
|
-
if (rest.startsWith(value)) {
|
|
165
|
-
rest = rest.slice(value.length);
|
|
166
|
-
return "";
|
|
167
|
-
}
|
|
168
|
-
return value;
|
|
169
|
-
}
|
|
170
|
-
if (Array.isArray(node)) return node.map(walk);
|
|
171
|
-
if (isValidElement<{ children?: ReactNode }>(node)) {
|
|
172
|
-
return cloneElement(node, undefined, walk(node.props.children));
|
|
173
|
-
}
|
|
174
|
-
return node;
|
|
175
|
-
};
|
|
176
|
-
return walk(children);
|
|
177
|
-
}
|
|
152
|
+
|
|
178
153
|
|
|
179
154
|
|
|
180
155
|
/**
|
|
@@ -227,36 +202,18 @@ const headingText = (children: ReactNode) => {
|
|
|
227
202
|
return parts.join("");
|
|
228
203
|
};
|
|
229
204
|
|
|
230
|
-
export function MarkdownArticle({ content,
|
|
205
|
+
export function MarkdownArticle({ content, lang, fieldTables, refDemos, handbook }: { content: string; numbering?: boolean; lang?: UiLang; fieldTables?: boolean; refDemos?: boolean; handbook?: boolean }) {
|
|
231
206
|
const internalRoute = handbook ? handbookRoute : siteRoute;
|
|
232
207
|
const t = ui(lang);
|
|
233
208
|
// Optional layer 2: a Worker that refreshes card text. Without it, cards stay as built.
|
|
234
209
|
const cardWorker = content.includes("{card}") ? siteConfig.linkPreview?.worker : undefined;
|
|
235
|
-
const numbers = { h2: 0, h3: 0 };
|
|
236
|
-
const autoNumber = autoNumbersHeadings(content, numbering);
|
|
237
210
|
const headingBase = articleHeadingBase(content);
|
|
238
|
-
const section = (depth: 2 | 3, children: ReactNode) => {
|
|
239
|
-
const raw = headingText(children).trim();
|
|
240
|
-
const split = splitHeadingNumber(raw);
|
|
241
|
-
if (split) {
|
|
242
|
-
if (depth === 2) { numbers.h2 += 1; numbers.h3 = 0; }
|
|
243
|
-
else numbers.h3 += 1;
|
|
244
|
-
const prefix = raw.slice(0, raw.length - split.text.length);
|
|
245
|
-
return { no: split.no, children: stripPrefix(children, prefix) };
|
|
246
|
-
}
|
|
247
|
-
if (!autoNumber) return { no: "", children };
|
|
248
|
-
if (depth === 2) { numbers.h2 += 1; numbers.h3 = 0; return { no: String(numbers.h2), children }; }
|
|
249
|
-
numbers.h3 += 1;
|
|
250
|
-
return { no: numbers.h2 ? `${numbers.h2}.${numbers.h3}` : String(numbers.h3), children };
|
|
251
|
-
};
|
|
252
211
|
const heading = (source: 2 | 3 | 4, children: ReactNode, props: HTMLAttributes<HTMLHeadingElement> & { node?: unknown }) => {
|
|
253
212
|
const level = displayHeadingLevel(source, headingBase);
|
|
254
213
|
const Tag = level === 2 ? "h2" : level === 3 ? "h3" : "h4";
|
|
255
214
|
const domProps = { ...props };
|
|
256
215
|
Reflect.deleteProperty(domProps, "node");
|
|
257
|
-
|
|
258
|
-
const sectioned = section(level as 2 | 3, children);
|
|
259
|
-
return <Tag {...domProps}>{sectioned.no ? <span className="sec-no">{sectioned.no}</span> : null}{sectioned.children}</Tag>;
|
|
216
|
+
return <Tag {...domProps}>{children}</Tag>;
|
|
260
217
|
};
|
|
261
218
|
const gardenComponents = {
|
|
262
219
|
"garden-linkcard": (props: Record<string, string | undefined>) => (
|
|
@@ -365,7 +365,6 @@ export function TableOfContents({
|
|
|
365
365
|
onFocus={() => prefetchHeadingLayout(node.id)}
|
|
366
366
|
aria-current={activeId === node.id ? "location" : undefined}
|
|
367
367
|
>
|
|
368
|
-
{node.no ? <span className="t-no">{node.no}</span> : null}
|
|
369
368
|
{node.text}
|
|
370
369
|
</a>
|
|
371
370
|
{node.children.length > 0 ? (
|
package/app/lib/content.ts
CHANGED
|
@@ -80,39 +80,22 @@ export { splitHeadingNumber };
|
|
|
80
80
|
* Parses the same Markdown heading text that ReactMarkdown sends to
|
|
81
81
|
* rehype-slug. Every heading depth advances the slugger so headings outside
|
|
82
82
|
* the H2-H5 TOC range cannot shift the IDs shown in the directory.
|
|
83
|
-
*
|
|
84
|
-
* auto-number h2/h3 only when the article has no numbered headings and its
|
|
85
|
-
* front matter does not say `numbering: false`.
|
|
83
|
+
* Heading text is literal: no synthetic numbers and no extraction of authored numbers.
|
|
86
84
|
*/
|
|
87
|
-
export const extractHeadings = (markdown: string,
|
|
85
|
+
export const extractHeadings = (markdown: string, _options: { numbering?: boolean } = {}) => {
|
|
86
|
+
// Accept the legacy option without changing authored heading text.
|
|
87
|
+
void _options;
|
|
88
88
|
const slugger = new GithubSlugger();
|
|
89
|
-
const autoNumber = autoNumbersHeadings(markdown, numbering);
|
|
90
89
|
const base = articleHeadingBase(markdown);
|
|
91
90
|
const headings: Array<{ depth: number; text: string; id: string; no?: string }> = [];
|
|
92
91
|
const tree = unified().use(remarkParse).use(markdownPlugins).parse(markdown);
|
|
93
|
-
let h2 = 0;
|
|
94
|
-
let h3 = 0;
|
|
95
92
|
|
|
96
93
|
visit(tree, "heading", (node) => {
|
|
97
94
|
const raw = toString(node).trim();
|
|
98
95
|
const id = slugger.slug(raw);
|
|
99
96
|
if (!(raw && node.depth >= 2 && node.depth <= 5)) return;
|
|
100
97
|
const level = displayHeadingLevel(node.depth, base);
|
|
101
|
-
|
|
102
|
-
let no: string | undefined;
|
|
103
|
-
if (level === 2 || level === 3) {
|
|
104
|
-
const split = splitHeadingNumber(raw);
|
|
105
|
-
if (split) {
|
|
106
|
-
no = split.no;
|
|
107
|
-
text = split.text;
|
|
108
|
-
if (level === 2) { h2 += 1; h3 = 0; }
|
|
109
|
-
else h3 += 1;
|
|
110
|
-
} else if (autoNumber) {
|
|
111
|
-
if (level === 2) { h2 += 1; h3 = 0; no = String(h2); }
|
|
112
|
-
else { h3 += 1; no = h2 ? `${h2}.${h3}` : String(h3); }
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
headings.push({ depth: level, text, id, ...(no ? { no } : {}) });
|
|
98
|
+
headings.push({ depth: level, text: raw, id });
|
|
116
99
|
});
|
|
117
100
|
|
|
118
101
|
return headings;
|
|
@@ -13,10 +13,5 @@ const NUMBERED_ARTICLE = /^#{2,3}[ \t]+\d/m;
|
|
|
13
13
|
/** True when the article writes its own section numbers (`## 2. …`). */
|
|
14
14
|
export const articleUsesHeadingNumbers = (markdown: string) => NUMBERED_ARTICLE.test(markdown);
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
* when the front matter says `numbering: false`. Author-written numbers are
|
|
19
|
-
* always kept.
|
|
20
|
-
*/
|
|
21
|
-
export const autoNumbersHeadings = (markdown: string, numbering?: boolean) =>
|
|
22
|
-
numbering !== false && !articleUsesHeadingNumbers(markdown);
|
|
16
|
+
/** Compatibility export: headings are now always rendered literally. */
|
|
17
|
+
export const autoNumbersHeadings = () => false;
|
package/bin/garden.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { startDev } from "../scripts/dev-server.mjs";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
@@ -312,12 +313,19 @@ function ensureDevGitHubLogin() {
|
|
|
312
313
|
if (!/^GITHUB_SESSION_SECRET=\S+/m.test(text)) {
|
|
313
314
|
text = upsertEnv(text, "GITHUB_SESSION_SECRET", randomBytes(32).toString("base64url"));
|
|
314
315
|
}
|
|
315
|
-
|
|
316
|
+
const next = text.endsWith("\n") ? text : `${text}\n`;
|
|
317
|
+
if (!fs.existsSync(local) || fs.readFileSync(local, "utf8") !== next) fs.writeFileSync(local, next);
|
|
316
318
|
}
|
|
317
319
|
|
|
318
320
|
async function runVinext(vinextArgs, { copyDist = false } = {}) {
|
|
319
|
-
await sync();
|
|
320
321
|
const cwd = vinextRoot();
|
|
322
|
+
if (vinextArgs[0] === "dev") {
|
|
323
|
+
const { finished } = await startDev({ cwd, executable: resolveVinext(), args: vinextArgs.slice(1), env: siteEnv(), prepare: sync });
|
|
324
|
+
const result = await finished;
|
|
325
|
+
if (result.status !== 0) process.exit(result.status || 1);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
await sync();
|
|
321
329
|
const result = spawnSync(process.execPath, [resolveVinext(), ...vinextArgs], {
|
|
322
330
|
cwd,
|
|
323
331
|
env: siteEnv(),
|
|
@@ -522,6 +530,10 @@ async function main() {
|
|
|
522
530
|
await sync();
|
|
523
531
|
break;
|
|
524
532
|
case "dev":
|
|
533
|
+
// Preview drafts by default, without overriding an explicit opt-out.
|
|
534
|
+
// Scoped to this CLI process: sync, Vinext and its watcher inherit it;
|
|
535
|
+
// later build/check commands do not.
|
|
536
|
+
process.env.CONTENT_INCLUDE_DRAFTS ??= "1";
|
|
525
537
|
ensureDevGitHubLogin();
|
|
526
538
|
await runVinext(["dev", ...rest]);
|
|
527
539
|
break;
|
|
@@ -14,4 +14,4 @@ Before running `make update` to push changes, verify the following items:
|
|
|
14
14
|
| :--- | :--- |
|
|
15
15
|
| `npx garden check content/posts/<slug>.md` | Checks a single post's component fields, code diffs, and local asset paths |
|
|
16
16
|
| `make check` | Runs `npx garden sync && npx garden build` to validate the full site build |
|
|
17
|
-
| `
|
|
17
|
+
| `make dev` | Previews drafts and scheduled posts locally |
|
|
@@ -14,4 +14,4 @@ Before running `make update` to push changes, verify the following items:
|
|
|
14
14
|
| :--- | :--- |
|
|
15
15
|
| `npx garden check content/posts/<slug>.md` | Checks a single post's component fields, code diffs, and local asset paths |
|
|
16
16
|
| `make check` | Runs `npx garden sync && npx garden build` to validate the full site build |
|
|
17
|
-
| `
|
|
17
|
+
| `make dev` | Previews drafts and scheduled posts locally |
|
|
@@ -59,10 +59,10 @@ make new DIR=network SLUG=tcp-bbr TITLE="TCP BBR Notes"
|
|
|
59
59
|
> [!NOTE] Note
|
|
60
60
|
> `SLUG` may only contain lowercase letters, digits, and hyphens (`a-z`, `0-9`, `-`), never `/`; pass subdirectories via `DIR`. `make new` reads only `SLUG`, `TITLE`, and `DIR`.
|
|
61
61
|
|
|
62
|
-
Newly created files default to `draft: true`.
|
|
62
|
+
Newly created files default to `draft: true`. `make dev` previews drafts and future-dated (`publishAt`) posts by default. Use `CONTENT_INCLUDE_DRAFTS=0 make dev` to opt out. Production builds still exclude them by default:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
|
|
65
|
+
make dev
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
### Creating a Thought
|
|
@@ -76,7 +76,7 @@ The site `Makefile` and `garden` CLI provide these commands:
|
|
|
76
76
|
| Command | What It Runs |
|
|
77
77
|
| :--- | :--- |
|
|
78
78
|
| `make dev` | Runs `make prepare` to check dependencies, then runs `npx garden dev` (which syncs content and starts the dev server) |
|
|
79
|
-
| `CONTENT_INCLUDE_DRAFTS=
|
|
79
|
+
| `CONTENT_INCLUDE_DRAFTS=0 make dev` | Optional: preview published posts only, excluding drafts and future posts |
|
|
80
80
|
| `make new SLUG=xxx TITLE="Title" DIR=subdir` | Calls `npx garden new` to create a draft post under `content/posts/` (`DIR` is optional) |
|
|
81
81
|
| `make check` | Runs `npx garden sync && npx garden build` in the site repo to verify content sync and the production build |
|
|
82
82
|
| `npx garden check content/posts/xxx.md` | Checks a single Markdown post's title, component fields, code diffs, and local asset paths |
|
package/docs/handbook/start.md
CHANGED
|
@@ -59,10 +59,10 @@ make new DIR=network SLUG=tcp-bbr TITLE="TCP BBR 拥塞控制笔记"
|
|
|
59
59
|
> [!NOTE]
|
|
60
60
|
> `SLUG` 只能包含小写字母、数字和连字符(`a-z`、`0-9`、`-`),不能包含 `/`;子目录必须用 `DIR` 参数传入。`make new` 只读取 `SLUG`、`TITLE` 和 `DIR` 三个变量。
|
|
61
61
|
|
|
62
|
-
新建的文件默认带有 `draft: true
|
|
62
|
+
新建的文件默认带有 `draft: true`。`make dev` 默认预览草稿和未来定时文章(`publishAt`);用 `CONTENT_INCLUDE_DRAFTS=0 make dev` 可关闭。正式构建仍默认排除它们:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
|
|
65
|
+
make dev
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
### 新建随想
|
|
@@ -76,7 +76,7 @@ CONTENT_INCLUDE_DRAFTS=1 make dev
|
|
|
76
76
|
| 命令 | 实际执行的操作 |
|
|
77
77
|
| :--- | :--- |
|
|
78
78
|
| `make dev` | 先执行 `make prepare` 检查依赖,再运行 `npx garden dev`(内部自动执行同步并启动开发服务器) |
|
|
79
|
-
| `CONTENT_INCLUDE_DRAFTS=
|
|
79
|
+
| `CONTENT_INCLUDE_DRAFTS=0 make dev` | 可选:只预览已发布文章,排除草稿和未来定时文章 |
|
|
80
80
|
| `make new SLUG=xxx TITLE="标题" DIR=子目录` | 调用 `npx garden new` 在 `content/posts/` 下生成新文章草稿(`DIR` 可省略) |
|
|
81
81
|
| `make check` | 在站点仓库中运行 `npx garden sync && npx garden build`,验证全站同步与生产构建 |
|
|
82
82
|
| `npx garden check content/posts/xxx.md` | 单独检查某篇 Markdown 文章的标题、组件字段、代码对比与本地附件路径 |
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
// Preserve mtimes of unchanged generated output to avoid dev reload storms.
|
|
5
|
+
function writeChanged(file, value) {
|
|
6
|
+
const next = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
7
|
+
if (fs.existsSync(file) && fs.readFileSync(file).equals(next)) return;
|
|
8
|
+
fs.writeFileSync(file, next);
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
import path from "node:path";
|
|
4
12
|
import { fileURLToPath } from "node:url";
|
|
5
13
|
import matter from "gray-matter";
|
|
@@ -201,7 +209,7 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
|
|
|
201
209
|
.join(path.sep)}${extension.toLowerCase()}`;
|
|
202
210
|
const destination = path.join(generatedMediaRoot, safeRelative);
|
|
203
211
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
204
|
-
fs.
|
|
212
|
+
writeChanged(destination, fs.readFileSync(absoluteSource));
|
|
205
213
|
const url = `${basePath}/media/${safeRelative.split(path.sep).map(encodeURIComponent).join("/")}`;
|
|
206
214
|
assetCache.set(absoluteSource, url);
|
|
207
215
|
assetFiles.set(url, { source: absoluteSource, destination });
|
|
@@ -363,7 +371,7 @@ function resolveFileEntry(file, markdownPath, sourcePath, options = {}) {
|
|
|
363
371
|
const { content } = matter(fs.readFileSync(resolved.path, "utf8"));
|
|
364
372
|
const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath, assetOptions), { lang });
|
|
365
373
|
const { destination } = assetFiles.get(url.split(/[?#]/u, 1)[0]);
|
|
366
|
-
|
|
374
|
+
writeChanged(previewFileName(destination), previewDocument(html, { title: entry.title, lang, icon: previewIcon() }));
|
|
367
375
|
next.previewSrc = previewFileName(url.split(/[?#]/u, 1)[0]);
|
|
368
376
|
}
|
|
369
377
|
return next;
|
|
@@ -748,7 +756,10 @@ function readHandbookDocs() {
|
|
|
748
756
|
return walk(handbookRoot).sort((a, b) => a.order - b.order || a.path.localeCompare(b.path));
|
|
749
757
|
}
|
|
750
758
|
|
|
751
|
-
|
|
759
|
+
// Keep existing preview assets while regenerating; deleting the whole tree
|
|
760
|
+
// triggers browser reloads before validation finishes (and destroys last-good
|
|
761
|
+
// output on an invalid save). Public builds still clean stale assets.
|
|
762
|
+
if (uiMode !== "preview") fs.rmSync(generatedMediaRoot, { recursive: true, force: true });
|
|
752
763
|
|
|
753
764
|
const posts = readMarkdownTree(path.join(contentRoot, "posts"), "post").sort(
|
|
754
765
|
(a, b) => Date.parse(b.date) - Date.parse(a.date),
|
|
@@ -924,9 +935,9 @@ export const imageSizes: Record<string, { width: number; height: number }> = ${J
|
|
|
924
935
|
`;
|
|
925
936
|
|
|
926
937
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
927
|
-
|
|
938
|
+
writeChanged(output, source);
|
|
928
939
|
const fullIndex = buildFullIndex({ posts, columns, thoughts });
|
|
929
|
-
|
|
940
|
+
writeChanged(
|
|
930
941
|
siteConfigOutput,
|
|
931
942
|
`// Generated by garden sync. Edit site.config.yaml instead.
|
|
932
943
|
export const siteConfig = ${JSON.stringify(siteConfig, null, 2)} as const;
|
|
@@ -942,11 +953,11 @@ export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath, trai
|
|
|
942
953
|
`,
|
|
943
954
|
);
|
|
944
955
|
fs.mkdirSync(path.join(root, "public"), { recursive: true });
|
|
945
|
-
|
|
956
|
+
writeChanged(path.join(root, "public", "search-index.json"), JSON.stringify(buildSearchIndex({ posts, columns, thoughts }, { lang: siteLang })));
|
|
946
957
|
for (const name of fs.readdirSync(path.join(root, "public"))) {
|
|
947
958
|
if (/^search-full\.[0-9a-f]+\.json$/u.test(name) && name !== fullIndex.fileName) fs.rmSync(path.join(root, "public", name));
|
|
948
959
|
}
|
|
949
|
-
|
|
960
|
+
writeChanged(path.join(root, "public", fullIndex.fileName), fullIndex.json);
|
|
950
961
|
|
|
951
962
|
const escapeXml = (value) =>
|
|
952
963
|
value
|
|
@@ -988,7 +999,7 @@ function writeRssFeed(fileName, entries) {
|
|
|
988
999
|
<content:encoded><![CDATA[${cdata(marked.parse(contentForFeed(entry.content, { pageUrl: `${publicBaseUrl}${entry.path}`, siteUrl: publicBaseUrl })))}]]></content:encoded>
|
|
989
1000
|
</item>`).join("");
|
|
990
1001
|
|
|
991
|
-
|
|
1002
|
+
writeChanged(path.join(publicRoot, fileName), `<?xml version="1.0" encoding="UTF-8" ?>
|
|
992
1003
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|
993
1004
|
<channel>
|
|
994
1005
|
<title>${escapeXml(siteName)}</title>
|
|
@@ -1019,16 +1030,16 @@ const sitemapEntries = [
|
|
|
1019
1030
|
].map(pageAddress)),
|
|
1020
1031
|
];
|
|
1021
1032
|
// Card URLs in published entries: the only pages the link-card Worker may read.
|
|
1022
|
-
|
|
1033
|
+
writeChanged(
|
|
1023
1034
|
path.join(publicRoot, "link-allowlist.json"),
|
|
1024
1035
|
`${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)))].sort() }, null, 2)}\n`,
|
|
1025
1036
|
);
|
|
1026
|
-
|
|
1037
|
+
writeChanged(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
|
|
1027
1038
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
|
|
1028
1039
|
<url><loc>${escapeXml(`${publicBaseUrl}${entry}`)}</loc></url>`).join("")}
|
|
1029
1040
|
</urlset>
|
|
1030
1041
|
`);
|
|
1031
|
-
|
|
1042
|
+
writeChanged(path.join(publicRoot, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${publicBaseUrl}/sitemap.xml\n`);
|
|
1032
1043
|
console.log(
|
|
1033
1044
|
`Synced ${posts.length} posts, ${columns.length} column entries, and ${thoughts.length} thoughts (${contentHash}).`,
|
|
1034
1045
|
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
|
|
7
|
+
const alive = (pid) => {
|
|
8
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
9
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; }
|
|
10
|
+
};
|
|
11
|
+
const read = (file) => {
|
|
12
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
13
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
14
|
+
};
|
|
15
|
+
const ps = (pid, field) => execFileSync("ps", ["-p", String(pid), "-o", `${field}=`], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } }).trim();
|
|
16
|
+
function identity(pid, executable) {
|
|
17
|
+
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("Automatic dev replacement requires Linux or macOS process inspection.");
|
|
18
|
+
if (Number(ps(pid, "uid")) !== process.getuid()) throw new Error("Dev process belongs to another user; nothing was stopped.");
|
|
19
|
+
const start = Date.parse(ps(pid, "lstart"));
|
|
20
|
+
let cwd;
|
|
21
|
+
let matches;
|
|
22
|
+
if (process.platform === "linux") {
|
|
23
|
+
cwd = fs.realpathSync(`/proc/${pid}/cwd`);
|
|
24
|
+
const args = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
|
|
25
|
+
matches = args[1] === executable && args[2] === "dev";
|
|
26
|
+
} else {
|
|
27
|
+
const entries = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { encoding: "utf8" });
|
|
28
|
+
cwd = fs.realpathSync(entries.split("\n").find((line) => line.startsWith("n"))?.slice(1) ?? "");
|
|
29
|
+
const command = ps(pid, "command");
|
|
30
|
+
// Exact script path and subcommand, not a substring such as `node -e ...`.
|
|
31
|
+
const node = ps(pid, "comm");
|
|
32
|
+
matches = command.startsWith(`${node} ${executable} dev`) && /^(?:\s|$)/u.test(command.slice(`${node} ${executable} dev`.length));
|
|
33
|
+
}
|
|
34
|
+
if (!matches || !Number.isFinite(start)) throw new Error(`PID ${pid} is not the expected Vinext dev process.`);
|
|
35
|
+
return { cwd, start };
|
|
36
|
+
}
|
|
37
|
+
async function portFree(lock) {
|
|
38
|
+
if (!Number.isInteger(lock.port) || lock.port < 1 || lock.port > 65535) return true;
|
|
39
|
+
// Only probe loopback; never connect to a target supplied by lock metadata.
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const socket = net.connect({ host: "127.0.0.1", port: lock.port });
|
|
42
|
+
socket.setTimeout(200);
|
|
43
|
+
socket.once("connect", () => { socket.destroy(); resolve(false); });
|
|
44
|
+
socket.once("error", () => { socket.destroy(); resolve(true); });
|
|
45
|
+
socket.once("timeout", () => { socket.destroy(); resolve(false); });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serialize same-runtime replacement/startup. Never infer ownership from a port. */
|
|
50
|
+
export async function startDev({ cwd, executable, args = [], env = process.env, prepare = async () => {}, timeoutMs = 15000, stdio = "inherit" }) {
|
|
51
|
+
cwd = fs.realpathSync(cwd);
|
|
52
|
+
executable = fs.realpathSync(executable);
|
|
53
|
+
const guard = path.join(cwd, ".garden-dev-start");
|
|
54
|
+
const lockPath = path.join(cwd, ".vinext/dev/lock.json");
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
while (true) {
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(guard);
|
|
59
|
+
fs.writeFileSync(path.join(guard, "owner.json"), JSON.stringify({ pid: process.pid }));
|
|
60
|
+
break;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error.code !== "EEXIST") throw error;
|
|
63
|
+
const owner = read(path.join(guard, "owner.json"));
|
|
64
|
+
if (owner && !alive(owner.pid)) throw new Error(`Interrupted Garden startup guard at ${guard}; no process was stopped. Remove this guard only after confirming no startup is in progress.`);
|
|
65
|
+
if (Date.now() >= deadline) throw new Error("Another Garden dev start is in progress; ownership could not be safely resolved.");
|
|
66
|
+
await delay(100);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let child;
|
|
70
|
+
try {
|
|
71
|
+
const lock = read(lockPath);
|
|
72
|
+
if (lock && alive(lock.pid)) {
|
|
73
|
+
if (lock.pid === process.pid) throw new Error("Refusing to replace this process.");
|
|
74
|
+
const previous = identity(lock.pid, executable);
|
|
75
|
+
if (previous.cwd !== cwd || fs.realpathSync(lock.cwd) !== cwd || !Number.isFinite(lock.startedAt) || previous.start > lock.startedAt + 1000 || lock.startedAt - previous.start > 120000) {
|
|
76
|
+
throw new Error("Dev lock ownership is unproven (runtime or process start time mismatch); nothing was stopped.");
|
|
77
|
+
}
|
|
78
|
+
// Recheck identity immediately before signalling to reduce PID-reuse races.
|
|
79
|
+
const current = identity(lock.pid, executable);
|
|
80
|
+
if (current.start !== previous.start || current.cwd !== cwd || read(lockPath)?.startedAt !== lock.startedAt) throw new Error("Dev owner changed during replacement; nothing was stopped.");
|
|
81
|
+
process.kill(lock.pid, "SIGTERM");
|
|
82
|
+
while (alive(lock.pid) || !(await portFree(lock))) {
|
|
83
|
+
if (Date.now() >= deadline) throw new Error("Previous dev server did not release its process/port in time; no force-kill attempted.");
|
|
84
|
+
await delay(100);
|
|
85
|
+
}
|
|
86
|
+
const remaining = read(lockPath);
|
|
87
|
+
if (remaining && remaining.pid !== lock.pid) throw new Error("Dev lock changed during shutdown; refusing to replace another owner.");
|
|
88
|
+
}
|
|
89
|
+
// Vinext itself takes over dead-PID locks. Do not unlink someone else's lock.
|
|
90
|
+
await prepare();
|
|
91
|
+
child = spawn(process.execPath, [executable, "dev", ...args], { cwd, env, stdio });
|
|
92
|
+
const exited = new Promise((resolve, reject) => { child.once("error", reject); child.once("exit", (code, signal) => resolve({ status: code, signal })); });
|
|
93
|
+
const forward = (signal) => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
|
|
94
|
+
const interrupt = () => forward("SIGINT");
|
|
95
|
+
const terminate = () => forward("SIGTERM");
|
|
96
|
+
process.on("SIGINT", interrupt);
|
|
97
|
+
process.on("SIGTERM", terminate);
|
|
98
|
+
const finished = exited.finally(() => { process.off("SIGINT", interrupt); process.off("SIGTERM", terminate); });
|
|
99
|
+
// The guard only covers startup, not the server's whole lifetime.
|
|
100
|
+
const startupDeadline = Date.now() + timeoutMs;
|
|
101
|
+
while (child.exitCode === null && child.signalCode === null) {
|
|
102
|
+
if (read(lockPath)?.pid === child.pid) break;
|
|
103
|
+
if (Date.now() >= startupDeadline) {
|
|
104
|
+
child.kill("SIGTERM");
|
|
105
|
+
await Promise.race([finished, delay(1000)]);
|
|
106
|
+
throw new Error("Dev server did not establish its runtime lock in time; graceful stop requested (no force kill).");
|
|
107
|
+
}
|
|
108
|
+
await delay(100);
|
|
109
|
+
}
|
|
110
|
+
return { child, finished };
|
|
111
|
+
} finally {
|
|
112
|
+
fs.rmSync(guard, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
package/scripts/new-post.mjs
CHANGED
|
@@ -67,12 +67,12 @@ async function main() {
|
|
|
67
67
|
if (lang === "en") {
|
|
68
68
|
console.log(`Created draft: ${path.relative(root, destination)}`);
|
|
69
69
|
console.log(`Post URL: /blog/${[...segments, slug].join("/")}/`);
|
|
70
|
-
console.log("Preview:
|
|
70
|
+
console.log("Preview: make dev");
|
|
71
71
|
console.log("Before publishing, fill in description, tags, and body, set draft to false, then run make update.");
|
|
72
72
|
} else {
|
|
73
73
|
console.log(`已创建草稿:${path.relative(root, destination)}`);
|
|
74
74
|
console.log(`文章路径:/blog/${[...segments, slug].join("/")}/`);
|
|
75
|
-
console.log("预览:
|
|
75
|
+
console.log("预览:make dev");
|
|
76
76
|
console.log("发布前请完善摘要、标签与正文,并将 draft 改为 false,再运行 make update。");
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -8,6 +8,8 @@ make new TITLE="Hello, World" SLUG=hello
|
|
|
8
8
|
make dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
`make dev`, `npm run dev`, and `npx garden dev` preview drafts and future scheduled posts by default. Opt out with `CONTENT_INCLUDE_DRAFTS=0 make dev`. Production builds exclude them by default.
|
|
12
|
+
|
|
11
13
|
Running `npx garden dev` for the first time fills in any missing `Makefile` or skills and creates `.env.local`. Cursor and Codex read `.agents/skills/` automatically when opening this repository. Run `npx garden skill` anytime to update the bundled skills.
|
|
12
14
|
|
|
13
15
|
`npx hd-garden init` copies the VS Code / Cursor and Typora Intelligent UI plugins into `.garden/editors/` and asks whether to install them when the terminal is interactive. Run `npx garden editors install` later (`--yes` installs both, `--vscode` or `--typora` installs one). Typora needs typora-community-plugin first; then enable the plugin under Settings, Installed plugins. Use the plugins to insert a plain `garden` brief for the `garden-ui` skill to compile, preview a component, and open the catalog. In Typora, type `/` and choose `/garden` to list each component with a small preview image. Hand `Compile this post:` to the skill when you want it compiled.
|
|
@@ -15,3 +17,5 @@ Running `npx garden dev` for the first time fills in any missing `Makefile` or s
|
|
|
15
17
|
Comments are disabled by default. Before publishing, open [Giscus](https://giscus.app), write the repository, repository ID, category, and category ID into `.env.local`, and set the matching `GISCUS_REPO`, `GISCUS_REPO_ID`, `GISCUS_CATEGORY`, and `GISCUS_CATEGORY_ID` under GitHub Actions Variables. Leaving them empty fails the publish workflow. To publish without comments, set `GISCUS_DISABLED` to `1`.
|
|
16
18
|
|
|
17
19
|
To upgrade the engine, update `hd-garden` in `package.json` to the new version and run `npm install`.
|
|
20
|
+
|
|
21
|
+
Running `make dev` again gracefully replaces a verified Vinext process from the same project, never an unrelated port owner. Markdown edits, additions and deletions update the preview. Headings stay literal; ranges like `4~6` are plain text and strikethrough requires `~~text~~`.
|
package/templates/site/README.md
CHANGED
|
@@ -8,6 +8,8 @@ make new TITLE="你好,世界" SLUG=hello
|
|
|
8
8
|
make dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
`make dev`、`npm run dev` 和 `npx garden dev` 默认预览草稿及未来定时文章。可用 `CONTENT_INCLUDE_DRAFTS=0 make dev` 关闭;正式构建默认排除这些内容。
|
|
12
|
+
|
|
11
13
|
第一次 `npx garden dev` 会补齐 Makefile 和 skill,并创建 `.env.local`。Cursor / Codex 打开这个仓库后会读到 `.agents/skills/`。要更新 skill,再运行 `npx garden skill`。
|
|
12
14
|
|
|
13
15
|
`npx hd-garden init` 会把 VS Code / Cursor 与 Typora 的 Intelligent UI 插件放到 `.garden/editors/`,并在交互终端里询问要不要安装。之后可以运行 `npx garden editors install`(加 `--yes` 则直接安装,`--vscode` / `--typora` 只装一个)。Typora 需要先装 typora-community-plugin,再在「设置 → 已安装插件」里勾选。插件用来插入想法(一段给 `garden-ui` skill 编译的 `garden` 围栏,不是写好的 YAML)、预览组件、打开清单,并把「编译这篇」交给 skill。Typora 里输入 `/` 选 `/garden`,再从带小预览图的列表里选。
|
|
@@ -15,3 +17,5 @@ make dev
|
|
|
15
17
|
评论默认关闭。发布前打开 [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`。
|
|
16
18
|
|
|
17
19
|
升级引擎:把 `package.json` 里的 `hd-garden` 改到新版本,例如 `^0.4.0`,再运行 `npm install`。
|
|
20
|
+
|
|
21
|
+
再次运行 `make dev` 会先温和停止经验证属于同一项目的旧 Vinext,再启动新服务;不会按端口号杀死无关进程。修改、新建或删除 Markdown 会自动更新预览。标题按原文显示;范围 `4~6` 不作删除线,显式删除线用 `~~删除线~~`。
|
|
@@ -9,4 +9,4 @@ import remarkMath from "remark-math";
|
|
|
9
9
|
* (app/lib/markdown-plugins.ts); a previewed Markdown file uses this list as
|
|
10
10
|
* is (scripts/file-preview.mjs), so both read the same Markdown the same way.
|
|
11
11
|
*/
|
|
12
|
-
export const markdownSyntaxPlugins = [remarkGfm, remarkCjkFriendly, remarkBreaks, remarkMath];
|
|
12
|
+
export const markdownSyntaxPlugins = [[remarkGfm, { singleTilde: false }], remarkCjkFriendly, remarkBreaks, remarkMath];
|
package/vite.config.ts
CHANGED
|
@@ -46,7 +46,7 @@ const localBindingConfig = {
|
|
|
46
46
|
|
|
47
47
|
function gardenContent() {
|
|
48
48
|
const sync = (mode?: "preview") => {
|
|
49
|
-
spawnSync(process.execPath, [path.join(gardenPackageRoot, "scripts", "build-content.mjs")], {
|
|
49
|
+
return spawnSync(process.execPath, [path.join(gardenPackageRoot, "scripts", "build-content.mjs")], {
|
|
50
50
|
cwd: siteRoot,
|
|
51
51
|
env: { ...process.env, GARDEN_SITE: siteRoot, ...(mode ? { GARDEN_CONTENT_MODE: mode } : {}) },
|
|
52
52
|
stdio: "inherit",
|
|
@@ -78,12 +78,37 @@ function gardenContent() {
|
|
|
78
78
|
const contentDir = path.join(siteRoot, "content");
|
|
79
79
|
const configFile = path.join(siteRoot, "site.config.yaml");
|
|
80
80
|
server.watcher.add([contentDir, configFile]);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
let pending: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
const onContentEvent = (file: string) => {
|
|
83
|
+
const relative = path.relative(contentDir, file);
|
|
84
|
+
if (file !== configFile && (relative.startsWith("..") || path.isAbsolute(relative))) return;
|
|
85
|
+
clearTimeout(pending);
|
|
86
|
+
// Coalesce editor atomic writes/renames; regenerate before asking browsers
|
|
87
|
+
// to reload, including newly created and deleted Markdown routes.
|
|
88
|
+
pending = setTimeout(() => {
|
|
89
|
+
const result = sync("preview");
|
|
90
|
+
if (result.status === 0) {
|
|
91
|
+
// The generator is synchronous; filesystem notifications have not
|
|
92
|
+
// necessarily reached Vite yet. Invalidate generated imports before
|
|
93
|
+
// any browser can request the new document.
|
|
94
|
+
for (const environment of Object.values(server.environments)) {
|
|
95
|
+
for (const generated of [generatedContent, generatedSiteConfig]) {
|
|
96
|
+
const modules = environment.moduleGraph.getModulesByFile(generated);
|
|
97
|
+
for (const generatedModule of modules ?? []) environment.moduleGraph.invalidateModule(generatedModule);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
server.ws.send({ type: "full-reload" });
|
|
101
|
+
}
|
|
102
|
+
}, 100);
|
|
103
|
+
};
|
|
104
|
+
server.watcher.on("add", onContentEvent);
|
|
105
|
+
server.watcher.on("change", onContentEvent);
|
|
106
|
+
server.watcher.on("unlink", onContentEvent);
|
|
107
|
+
server.httpServer?.once("close", () => {
|
|
108
|
+
clearTimeout(pending);
|
|
109
|
+
server.watcher.off("add", onContentEvent);
|
|
110
|
+
server.watcher.off("change", onContentEvent);
|
|
111
|
+
server.watcher.off("unlink", onContentEvent);
|
|
87
112
|
});
|
|
88
113
|
},
|
|
89
114
|
};
|