hd-garden 0.13.2 → 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.
@@ -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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.13.2",
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
+ }
@@ -17,3 +17,5 @@ Running `npx garden dev` for the first time fills in any missing `Makefile` or s
17
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`.
18
18
 
19
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~~`.
@@ -17,3 +17,5 @@ make dev
17
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`。
18
18
 
19
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
  };