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.
- 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 +10 -2
- 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/templates/site/README.en.md +2 -0
- package/templates/site/README.md +2 -0
- package/ui/garden/markdown-syntax.mjs +1 -1
- package/vite.config.ts +32 -7
|
@@ -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(),
|
|
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
|
+
}
|
|
@@ -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~~`.
|
package/templates/site/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|