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 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=1 make dev # Preview drafts and future publishAt posts
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=1 make dev # 连同草稿和未来定时文章一起预览
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, autoNumbersHeadings, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
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
- function stripPrefix(children: ReactNode, prefix: string): ReactNode {
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, numbering, lang, fieldTables, refDemos, handbook }: { content: string; numbering?: boolean; lang?: UiLang; fieldTables?: boolean; refDemos?: boolean; handbook?: boolean }) {
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
- if (level > 3) return <Tag {...domProps}>{children}</Tag>;
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 ? (
@@ -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
- * `no` follows the prototype: keep an author-written number, otherwise
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, { numbering }: { numbering?: boolean } = {}) => {
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
- let text = raw;
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
- * Whether h2/h3 get automatic numbers. Off when the author writes numbers, or
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
- fs.writeFileSync(local, text.endsWith("\n") ? text : `${text}\n`);
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 @@
14
14
  | :--- | :--- |
15
15
  | `npx garden check content/posts/<slug>.md` | 检查单篇文章的组件字段、代码对比与图片附件路径 |
16
16
  | `make check` | 运行 `npx garden sync && npx garden build` 校验全站构建 |
17
- | `CONTENT_INCLUDE_DRAFTS=1 make dev` | 在本地连同草稿和定时文章一起预览 |
17
+ | `make dev` | 在本地连同草稿和定时文章一起预览 |
@@ -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
- | `CONTENT_INCLUDE_DRAFTS=1 make dev` | Previews drafts and scheduled posts locally |
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
- | `CONTENT_INCLUDE_DRAFTS=1 make dev` | Previews drafts and scheduled posts locally |
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`. Set `CONTENT_INCLUDE_DRAFTS=1` to preview drafts or future-dated (`publishAt`) posts locally:
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
- CONTENT_INCLUDE_DRAFTS=1 make dev
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=1 make dev` | Previews `draft: true` drafts and future `publishAt` posts alongside published content |
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 |
@@ -14,4 +14,4 @@
14
14
  | :--- | :--- |
15
15
  | `npx garden check content/posts/<slug>.md` | 检查单篇文章的组件字段、代码对比与图片附件路径 |
16
16
  | `make check` | 运行 `npx garden sync && npx garden build` 校验全站构建 |
17
- | `CONTENT_INCLUDE_DRAFTS=1 make dev` | 在本地连同草稿和定时文章一起预览 |
17
+ | `make dev` | 在本地连同草稿和定时文章一起预览 |
@@ -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`。本地预览草稿或未来定时文章(`publishAt`)时,加上环境变量 `CONTENT_INCLUDE_DRAFTS=1`:
62
+ 新建的文件默认带有 `draft: true`。`make dev` 默认预览草稿和未来定时文章(`publishAt`);用 `CONTENT_INCLUDE_DRAFTS=0 make dev` 可关闭。正式构建仍默认排除它们:
63
63
 
64
64
  ```bash
65
- CONTENT_INCLUDE_DRAFTS=1 make dev
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=1 make dev` | 连同 `draft: true` 草稿和未到 `publishAt` 时间的文章一起预览 |
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 文章的标题、组件字段、代码对比与本地附件路径 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "private": false,
5
5
  "description": "\u7528 Markdown \u5199\u4f5c\u3001\u7528 GitHub Pages \u53d1\u5e03\u7684\u4e2a\u4eba\u535a\u5ba2\u5f15\u64ce\u3002",
6
6
  "license": "MIT",
@@ -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.copyFileSync(absoluteSource, destination);
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
- fs.writeFileSync(previewFileName(destination), previewDocument(html, { title: entry.title, lang, icon: previewIcon() }));
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
- fs.rmSync(generatedMediaRoot, { recursive: true, force: true });
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
- fs.writeFileSync(output, source);
938
+ writeChanged(output, source);
928
939
  const fullIndex = buildFullIndex({ posts, columns, thoughts });
929
- fs.writeFileSync(
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
- fs.writeFileSync(path.join(root, "public", "search-index.json"), JSON.stringify(buildSearchIndex({ posts, columns, thoughts }, { lang: siteLang })));
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
- fs.writeFileSync(path.join(root, "public", fullIndex.fileName), fullIndex.json);
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
- fs.writeFileSync(path.join(publicRoot, fileName), `<?xml version="1.0" encoding="UTF-8" ?>
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
- fs.writeFileSync(
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
- fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
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
- fs.writeFileSync(path.join(publicRoot, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${publicBaseUrl}/sitemap.xml\n`);
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
+ }
@@ -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: CONTENT_INCLUDE_DRAFTS=1 make dev");
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("预览:CONTENT_INCLUDE_DRAFTS=1 make dev");
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~~`.
@@ -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
- server.watcher.on("change", (file) => {
82
- if (file.startsWith(contentDir) || file === configFile) {
83
- // While editing, a broken ui block shows as an error card instead of stopping the server.
84
- sync("preview");
85
- server.ws.send({ type: "full-reload" });
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
  };