hd-garden 0.10.2 → 0.10.4
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 +52 -4
- package/README.md +52 -4
- package/app/components/ArticleMeasure.tsx +3 -2
- package/app/styles/a2.css +13 -0
- package/bin/garden.mjs +150 -112
- package/docs/handbook/ai-writing.md +30 -4
- package/docs/handbook/components/image.md +10 -0
- package/docs/handbook/en/ai-writing.md +24 -4
- package/docs/handbook/en/components/image.md +10 -0
- package/docs/handbook/en/index.md +1 -0
- package/docs/handbook/en/start.md +13 -0
- package/docs/handbook/en/workflow.md +39 -1
- package/docs/handbook/en/writing.md +1 -0
- package/docs/handbook/index.md +1 -0
- package/docs/handbook/start.md +13 -0
- package/docs/handbook/workflow.md +39 -1
- package/docs/handbook/writing.md +1 -0
- package/editors/dist/typora/main.js +384 -0
- package/editors/dist/typora/manifest.json +12 -0
- package/editors/dist/typora/style.css +1215 -0
- package/editors/dist/vscode/garden-ui-0.1.0.vsix +0 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -2
- package/scripts/build-content.mjs +41 -16
- package/scripts/editors-kit.mjs +352 -0
- package/scripts/excalidraw.mjs +372 -0
- package/scripts/pack-editors.mjs +241 -0
- package/templates/site/README.en.md +2 -0
- package/templates/site/README.md +2 -0
- package/ui/core/transform.mjs +9 -1
- package/ui/garden/excalidraw-inline.mjs +175 -0
- package/ui/garden/snippets.mjs +89 -2
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time export of `.excalidraw` scenes to a sibling flat SVG.
|
|
3
|
+
*
|
|
4
|
+
* Authors reference the source (``). Content
|
|
5
|
+
* sync calls `ensureExcalidrawSvg`, which deletes a missing or stale sibling
|
|
6
|
+
* `foo.svg` and writes a new one. The SVG is what gets published.
|
|
7
|
+
*
|
|
8
|
+
* Rendering goes through the pinned `excalidraw-image` binary (the official
|
|
9
|
+
* `@excalidraw/excalidraw` export path, fonts inlined as data URIs). One light
|
|
10
|
+
* SVG is written: `--no-background` drops the canvas plate, and scene strokes
|
|
11
|
+
* and fills stay as drawn. There is no dark twin and no Chromium step. The
|
|
12
|
+
* article bundle never loads Excalidraw. The first export downloads that
|
|
13
|
+
* binary from GitHub Releases into the user cache.
|
|
14
|
+
* `GARDEN_EXCALIDRAW_IMAGE` points at a local binary instead.
|
|
15
|
+
* `GARDEN_OFFLINE=1` refuses to download.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
export const EXCALIDRAW_IMAGE_VERSION = "0.2.0";
|
|
24
|
+
|
|
25
|
+
const RELEASE_BASE = `https://github.com/rickardp/excalidraw-image/releases/download/v${EXCALIDRAW_IMAGE_VERSION}`;
|
|
26
|
+
|
|
27
|
+
/** @type {Record<string, { file: string, sha256: string }>} */
|
|
28
|
+
const ASSETS = {
|
|
29
|
+
"darwin-arm64": {
|
|
30
|
+
file: "excalidraw-image-aarch64-apple-darwin.tar.gz",
|
|
31
|
+
sha256: "387f7ef44a75528e4c6cca9f0af4bba34e017986f34581d983bb2a96afb5d9ba",
|
|
32
|
+
},
|
|
33
|
+
"darwin-x64": {
|
|
34
|
+
file: "excalidraw-image-x86_64-apple-darwin.tar.gz",
|
|
35
|
+
sha256: "bef1198f6237123ecc7f63c1fc6a456413e5f6cc91ae4c548dc26d661066fc21",
|
|
36
|
+
},
|
|
37
|
+
"linux-arm64": {
|
|
38
|
+
file: "excalidraw-image-aarch64-unknown-linux-gnu.tar.gz",
|
|
39
|
+
sha256: "c6b222e128965568e281af96edfa437393b2ddb4e3ba360ec2b9b583f273143e",
|
|
40
|
+
},
|
|
41
|
+
"linux-x64": {
|
|
42
|
+
file: "excalidraw-image-x86_64-unknown-linux-gnu.tar.gz",
|
|
43
|
+
sha256: "6a86f5188b55aef092d9cedda786565bfdb399bb58e57a1bea26335ed8f19ac6",
|
|
44
|
+
},
|
|
45
|
+
"win32-x64": {
|
|
46
|
+
file: "excalidraw-image-x86_64-pc-windows-msvc.zip",
|
|
47
|
+
sha256: "19b387b2322d3b52f8003d2e8bea9ab5693086c5886986f86b5ac6c36009aeef",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const REASONS = {
|
|
52
|
+
"missing-source": "找不到 Excalidraw 源文件",
|
|
53
|
+
"export-failed": "导出 Excalidraw 失败",
|
|
54
|
+
offline: "离线构建找不到 excalidraw-image",
|
|
55
|
+
"download-failed": "下载 excalidraw-image 失败",
|
|
56
|
+
checksum: "excalidraw-image 校验和不匹配",
|
|
57
|
+
"embedded-scene": "导出的 SVG 仍包含可编辑场景",
|
|
58
|
+
"remote-asset": "导出的 SVG 引用了外部地址",
|
|
59
|
+
"not-svg": "导出结果不是 SVG",
|
|
60
|
+
"canvas-background": "导出的 SVG 仍带画布底色",
|
|
61
|
+
"unsupported-platform": "当前系统没有 excalidraw-image 预编译包",
|
|
62
|
+
"binary-missing": "GARDEN_EXCALIDRAW_IMAGE 指向的文件不存在",
|
|
63
|
+
"no-output": "导出程序没有写出 SVG",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const EXPORT_POLICY = "light-transparent";
|
|
67
|
+
const STAMP = /<!--\s*garden-excalidraw-source:([0-9a-f]{64})(?:\s+([a-z0-9-]+))?\s*-->/u;
|
|
68
|
+
const LOCK_WAIT_MS = 5 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
export class ExcalidrawExportError extends Error {
|
|
71
|
+
/**
|
|
72
|
+
* @param {keyof typeof REASONS} code
|
|
73
|
+
* @param {string} message
|
|
74
|
+
* @param {string} file
|
|
75
|
+
*/
|
|
76
|
+
constructor(code, message, file) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "ExcalidrawExportError";
|
|
79
|
+
this.code = code;
|
|
80
|
+
this.file = file;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {keyof typeof REASONS} code
|
|
86
|
+
* @param {string} file
|
|
87
|
+
* @param {string} [detail]
|
|
88
|
+
*/
|
|
89
|
+
function failExport(code, file, detail) {
|
|
90
|
+
const lead = REASONS[code];
|
|
91
|
+
if (!lead) {
|
|
92
|
+
const unknown = /** @type {never} */ (code);
|
|
93
|
+
throw new Error(`未知的 Excalidraw 错误码 ${unknown}`);
|
|
94
|
+
}
|
|
95
|
+
const tail = detail ? `(${detail})` : "";
|
|
96
|
+
return new ExcalidrawExportError(code, `[excalidraw:${code}] ${lead} ${file}${tail}`, file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** sha256 of the `.excalidraw` bytes. Any edit changes the stamp. */
|
|
100
|
+
export function sourceHash(contents) {
|
|
101
|
+
return crypto.createHash("sha256").update(contents).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `flow.excalidraw` → `flow.svg` beside it. The extension match is case-insensitive. */
|
|
105
|
+
export function siblingSvgPath(excalidrawPath) {
|
|
106
|
+
return String(excalidrawPath).replace(/\.excalidraw$/iu, ".svg");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True for a Markdown path whose file is an `.excalidraw` source, not an `.excalidraw.svg`. */
|
|
110
|
+
export function isExcalidrawPath(value) {
|
|
111
|
+
const clean = String(value ?? "").split(/[?#]/u, 1)[0];
|
|
112
|
+
return /\.excalidraw$/iu.test(clean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stampSvg(svg, hash) {
|
|
116
|
+
const stripped = svg.replace(/<!--\s*garden-excalidraw-source:[0-9a-f]{64}(?:\s+[a-z0-9-]+)?\s*-->/gu, "");
|
|
117
|
+
const at = stripped.search(/<svg[\s>]/iu);
|
|
118
|
+
if (at < 0) throw failExport("not-svg", "");
|
|
119
|
+
return `${stripped.slice(0, at)}<!-- garden-excalidraw-source:${hash} ${EXPORT_POLICY} -->${stripped.slice(at)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A full-viewport `<rect>` is the exporter's canvas plate, not a scene shape. */
|
|
123
|
+
function hasCanvasBackground(svg) {
|
|
124
|
+
const svgTag = /<svg\b[^>]*>/iu.exec(svg)?.[0] ?? "";
|
|
125
|
+
const attr = (tag, name) => new RegExp(`\\b${name}="([^"]*)"`, "iu").exec(tag)?.[1];
|
|
126
|
+
const width = Number(attr(svgTag, "width"));
|
|
127
|
+
const height = Number(attr(svgTag, "height"));
|
|
128
|
+
if (!(width > 0) || !(height > 0)) return false;
|
|
129
|
+
const same = (left, right) => Math.abs(left - right) < 0.01;
|
|
130
|
+
for (const match of svg.matchAll(/<rect\b[^>]*>/giu)) {
|
|
131
|
+
const tag = match[0];
|
|
132
|
+
const x = Number(attr(tag, "x") ?? 0);
|
|
133
|
+
const y = Number(attr(tag, "y") ?? 0);
|
|
134
|
+
const rectWidth = Number(attr(tag, "width"));
|
|
135
|
+
const rectHeight = Number(attr(tag, "height"));
|
|
136
|
+
if (same(x, 0) && same(y, 0) && same(rectWidth, width) && same(rectHeight, height)) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A published figure must be a flat SVG: no embedded scene, no host the
|
|
143
|
+
* reader's browser would have to call.
|
|
144
|
+
* @param {string} svg
|
|
145
|
+
* @param {string} svgPath
|
|
146
|
+
*/
|
|
147
|
+
function assertFlatSvg(svg, svgPath) {
|
|
148
|
+
if (!/<svg[\s>]/iu.test(svg)) throw failExport("not-svg", svgPath);
|
|
149
|
+
if (/payload-start|application\/vnd\.excalidraw\+json/iu.test(svg)) {
|
|
150
|
+
throw failExport("embedded-scene", svgPath);
|
|
151
|
+
}
|
|
152
|
+
if (hasCanvasBackground(svg)) throw failExport("canvas-background", svgPath);
|
|
153
|
+
const urls = [...svg.matchAll(/https?:\/\/[^"')\s]+/giu)]
|
|
154
|
+
.map((match) => match[0])
|
|
155
|
+
.filter((url) => !/^https?:\/\/www\.w3\.org\//iu.test(url));
|
|
156
|
+
if (urls.length) throw failExport("remote-asset", svgPath, urls.slice(0, 3).join(" "));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function svgIsCurrent(svg, hash) {
|
|
160
|
+
const stamp = STAMP.exec(svg);
|
|
161
|
+
if (!stamp || stamp[1] !== hash || stamp[2] !== EXPORT_POLICY) return false;
|
|
162
|
+
try {
|
|
163
|
+
assertFlatSvg(svg, "");
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function platformKey() {
|
|
171
|
+
return `${process.platform}-${process.arch}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function platformAsset() {
|
|
175
|
+
const asset = ASSETS[platformKey()];
|
|
176
|
+
if (!asset) {
|
|
177
|
+
throw failExport(
|
|
178
|
+
"unsupported-platform",
|
|
179
|
+
`${process.platform} ${process.arch}`,
|
|
180
|
+
"设置 GARDEN_EXCALIDRAW_IMAGE 指向本机的 excalidraw-image",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return asset;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {{ homedir?: string }} [options]
|
|
188
|
+
*/
|
|
189
|
+
function cachePath(options = {}) {
|
|
190
|
+
const home = options.homedir ?? os.homedir();
|
|
191
|
+
const exe = process.platform === "win32" ? "excalidraw-image.exe" : "excalidraw-image";
|
|
192
|
+
return path.join(home, ".cache", "garden", "excalidraw-image", EXCALIDRAW_IMAGE_VERSION, platformKey(), exe);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sleep(ms) {
|
|
196
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function withLock(lockDir, fn) {
|
|
200
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
201
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
202
|
+
for (;;) {
|
|
203
|
+
try {
|
|
204
|
+
fs.mkdirSync(lockDir);
|
|
205
|
+
break;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error?.code !== "EEXIST") throw error;
|
|
208
|
+
let stale = false;
|
|
209
|
+
try {
|
|
210
|
+
stale = Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_WAIT_MS;
|
|
211
|
+
} catch {
|
|
212
|
+
stale = true;
|
|
213
|
+
}
|
|
214
|
+
if (stale) {
|
|
215
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (Date.now() > deadline) throw failExport("download-failed", lockDir, "等待下载锁超时");
|
|
219
|
+
sleep(100);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return fn();
|
|
224
|
+
} finally {
|
|
225
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function run(command, args, { timeout = 120_000 } = {}) {
|
|
230
|
+
return spawnSync(command, args, {
|
|
231
|
+
encoding: "utf8",
|
|
232
|
+
timeout,
|
|
233
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function commandFailure(result, fallback) {
|
|
239
|
+
if (result.error) return result.error.message;
|
|
240
|
+
return (result.stderr || result.stdout || fallback).trim();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function findExportedBinary(dir) {
|
|
244
|
+
const names = new Set(["excalidraw-image", "excalidraw-image.exe"]);
|
|
245
|
+
const found = [];
|
|
246
|
+
const stack = [dir];
|
|
247
|
+
while (stack.length) {
|
|
248
|
+
const current = stack.pop();
|
|
249
|
+
if (!current) break;
|
|
250
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
251
|
+
const full = path.join(current, entry.name);
|
|
252
|
+
if (entry.isDirectory()) stack.push(full);
|
|
253
|
+
else if (names.has(entry.name)) found.push(full);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (found.length !== 1) {
|
|
257
|
+
throw failExport("download-failed", dir, `压缩包里应有一个 excalidraw-image,实际 ${found.length} 个`);
|
|
258
|
+
}
|
|
259
|
+
return found[0];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {{ homedir?: string, env?: NodeJS.ProcessEnv }} options
|
|
264
|
+
*/
|
|
265
|
+
function installBinary(options) {
|
|
266
|
+
const asset = platformAsset();
|
|
267
|
+
const cached = cachePath(options);
|
|
268
|
+
return withLock(`${cached}.lock`, () => {
|
|
269
|
+
if (fs.existsSync(cached)) return cached;
|
|
270
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "garden-excalidraw-"));
|
|
271
|
+
try {
|
|
272
|
+
const archive = path.join(work, asset.file);
|
|
273
|
+
const url = `${RELEASE_BASE}/${asset.file}`;
|
|
274
|
+
console.error(`正在下载 excalidraw-image ${EXCALIDRAW_IMAGE_VERSION}(${asset.file})…`);
|
|
275
|
+
const curl = run("curl", ["-fsSL", "--retry", "3", "--retry-delay", "2", "-o", archive, url]);
|
|
276
|
+
if (curl.status !== 0) throw failExport("download-failed", url, commandFailure(curl, "curl 失败"));
|
|
277
|
+
const got = crypto.createHash("sha256").update(fs.readFileSync(archive)).digest("hex");
|
|
278
|
+
if (got !== asset.sha256) throw failExport("checksum", asset.file, `期望 ${asset.sha256},实际 ${got}`);
|
|
279
|
+
const unpacked = path.join(work, "unpacked");
|
|
280
|
+
fs.mkdirSync(unpacked, { recursive: true });
|
|
281
|
+
const extracted = run("tar", ["-xf", archive, "-C", unpacked]);
|
|
282
|
+
if (extracted.status !== 0) {
|
|
283
|
+
throw failExport("download-failed", archive, commandFailure(extracted, "tar 无法解压"));
|
|
284
|
+
}
|
|
285
|
+
const bin = findExportedBinary(unpacked);
|
|
286
|
+
fs.mkdirSync(path.dirname(cached), { recursive: true });
|
|
287
|
+
const partial = `${cached}.partial`;
|
|
288
|
+
fs.copyFileSync(bin, partial);
|
|
289
|
+
fs.chmodSync(partial, 0o755);
|
|
290
|
+
const version = run(partial, ["--version"]);
|
|
291
|
+
if (version.status !== 0 || !/excalidraw-image/u.test(version.stdout || "")) {
|
|
292
|
+
fs.rmSync(partial, { force: true });
|
|
293
|
+
throw failExport("download-failed", partial, commandFailure(version, "excalidraw-image --version 失败"));
|
|
294
|
+
}
|
|
295
|
+
fs.renameSync(partial, cached);
|
|
296
|
+
return cached;
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {{ homedir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
305
|
+
*/
|
|
306
|
+
function resolveBinary(options = {}) {
|
|
307
|
+
const env = options.env ?? process.env;
|
|
308
|
+
const override = env.GARDEN_EXCALIDRAW_IMAGE?.trim();
|
|
309
|
+
if (override) {
|
|
310
|
+
if (!fs.existsSync(override)) throw failExport("binary-missing", override);
|
|
311
|
+
return override;
|
|
312
|
+
}
|
|
313
|
+
platformAsset();
|
|
314
|
+
const cached = cachePath(options);
|
|
315
|
+
if (fs.existsSync(cached)) return cached;
|
|
316
|
+
if (env.GARDEN_OFFLINE === "1") throw failExport("offline", cached);
|
|
317
|
+
return installBinary(options);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let defaultBinary = "";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {string} input
|
|
324
|
+
* @param {string} output
|
|
325
|
+
* @param {{ homedir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
326
|
+
*/
|
|
327
|
+
function renderWithBinary(input, output, options = {}) {
|
|
328
|
+
const custom = options.env !== undefined || options.homedir !== undefined;
|
|
329
|
+
const bin = !custom && defaultBinary && fs.existsSync(defaultBinary) ? defaultBinary : resolveBinary(options);
|
|
330
|
+
if (!custom) defaultBinary = bin;
|
|
331
|
+
const result = run(bin, [input, "--non-editable", "--no-background", "--format", "svg", "-o", output]);
|
|
332
|
+
if (result.status !== 0) {
|
|
333
|
+
throw failExport("export-failed", input, commandFailure(result, `退出码 ${result.status}`).slice(0, 800));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Write a current flat SVG next to `sourcePath`.
|
|
339
|
+
* A sibling SVG is reused only when it carries this source's hash and is flat.
|
|
340
|
+
* Otherwise it is deleted, then exported again. Export failure leaves no SVG.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} sourcePath
|
|
343
|
+
* @param {{ render?: (input: string, output: string) => void, homedir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
344
|
+
* @returns {string} absolute path of the sibling SVG
|
|
345
|
+
*/
|
|
346
|
+
export function ensureExcalidrawSvg(sourcePath, options = {}) {
|
|
347
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
|
|
348
|
+
throw failExport("missing-source", sourcePath);
|
|
349
|
+
}
|
|
350
|
+
const svgPath = siblingSvgPath(sourcePath);
|
|
351
|
+
if (svgPath === sourcePath) throw failExport("not-svg", sourcePath, "无法把源文件路径换成 .svg");
|
|
352
|
+
const hash = sourceHash(fs.readFileSync(sourcePath));
|
|
353
|
+
if (fs.existsSync(svgPath)) {
|
|
354
|
+
const current = fs.readFileSync(svgPath, "utf8");
|
|
355
|
+
if (svgIsCurrent(current, hash)) return svgPath;
|
|
356
|
+
fs.rmSync(svgPath, { force: true });
|
|
357
|
+
}
|
|
358
|
+
const render = options.render ?? ((input, output) => renderWithBinary(input, output, options));
|
|
359
|
+
try {
|
|
360
|
+
render(sourcePath, svgPath);
|
|
361
|
+
if (!fs.existsSync(svgPath) || !fs.statSync(svgPath).isFile()) throw failExport("no-output", svgPath);
|
|
362
|
+
const raw = fs.readFileSync(svgPath, "utf8");
|
|
363
|
+
assertFlatSvg(raw, svgPath);
|
|
364
|
+
fs.writeFileSync(svgPath, stampSvg(raw, hash));
|
|
365
|
+
} catch (error) {
|
|
366
|
+
fs.rmSync(svgPath, { force: true });
|
|
367
|
+
if (error instanceof ExcalidrawExportError) throw error;
|
|
368
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
369
|
+
throw failExport("export-failed", sourcePath, reason);
|
|
370
|
+
}
|
|
371
|
+
return svgPath;
|
|
372
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import zlib from "node:zlib";
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const distDir = path.join(root, "editors", "dist");
|
|
9
|
+
const lockDir = path.join(root, "editors", ".pack-lock");
|
|
10
|
+
|
|
11
|
+
function sleepSync(ms) {
|
|
12
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function distReady() {
|
|
16
|
+
const vsixDir = path.join(distDir, "vscode");
|
|
17
|
+
const manifest = path.join(distDir, "typora", "manifest.json");
|
|
18
|
+
if (!fs.existsSync(manifest) || !fs.existsSync(vsixDir)) return false;
|
|
19
|
+
return fs.readdirSync(vsixDir).some((name) => name.endsWith(".vsix"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withPackLock(fn) {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(lockDir);
|
|
27
|
+
break;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code !== "EEXIST") throw error;
|
|
30
|
+
if (Date.now() - started > 180_000) {
|
|
31
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
sleepSync(200);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return fn();
|
|
39
|
+
} finally {
|
|
40
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runBuild(script) {
|
|
45
|
+
const result = spawnSync(process.execPath, [script], { cwd: root, stdio: "inherit" });
|
|
46
|
+
if (result.status !== 0) process.exit(result.status || 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function xmlEscape(value) {
|
|
50
|
+
return String(value)
|
|
51
|
+
.replaceAll("&", "&")
|
|
52
|
+
.replaceAll("<", "<")
|
|
53
|
+
.replaceAll(">", ">")
|
|
54
|
+
.replaceAll('"', """);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dosDate(date) {
|
|
58
|
+
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1);
|
|
59
|
+
const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
60
|
+
return { time, day };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Store-method ZIP. VSIX is a ZIP with a manifest at the archive root. */
|
|
64
|
+
function zipStore(entries) {
|
|
65
|
+
const { time, day } = dosDate(new Date());
|
|
66
|
+
const locals = [];
|
|
67
|
+
const centrals = [];
|
|
68
|
+
let offset = 0;
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const name = Buffer.from(entry.name, "utf8");
|
|
71
|
+
const data = entry.data;
|
|
72
|
+
const crc = zlib.crc32(data);
|
|
73
|
+
const local = Buffer.alloc(30 + name.length);
|
|
74
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
75
|
+
local.writeUInt16LE(20, 4);
|
|
76
|
+
local.writeUInt16LE(0x0800, 6);
|
|
77
|
+
local.writeUInt16LE(0, 8);
|
|
78
|
+
local.writeUInt16LE(time, 10);
|
|
79
|
+
local.writeUInt16LE(day, 12);
|
|
80
|
+
local.writeUInt32LE(crc, 14);
|
|
81
|
+
local.writeUInt32LE(data.length, 18);
|
|
82
|
+
local.writeUInt32LE(data.length, 22);
|
|
83
|
+
local.writeUInt16LE(name.length, 26);
|
|
84
|
+
local.writeUInt16LE(0, 28);
|
|
85
|
+
name.copy(local, 30);
|
|
86
|
+
locals.push(local, data);
|
|
87
|
+
|
|
88
|
+
const central = Buffer.alloc(46 + name.length);
|
|
89
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
90
|
+
central.writeUInt16LE(20, 4);
|
|
91
|
+
central.writeUInt16LE(20, 6);
|
|
92
|
+
central.writeUInt16LE(0x0800, 8);
|
|
93
|
+
central.writeUInt16LE(0, 10);
|
|
94
|
+
central.writeUInt16LE(time, 12);
|
|
95
|
+
central.writeUInt16LE(day, 14);
|
|
96
|
+
central.writeUInt32LE(crc, 16);
|
|
97
|
+
central.writeUInt32LE(data.length, 20);
|
|
98
|
+
central.writeUInt32LE(data.length, 24);
|
|
99
|
+
central.writeUInt16LE(name.length, 28);
|
|
100
|
+
central.writeUInt16LE(0, 30);
|
|
101
|
+
central.writeUInt16LE(0, 32);
|
|
102
|
+
central.writeUInt16LE(0, 34);
|
|
103
|
+
central.writeUInt16LE(0, 36);
|
|
104
|
+
central.writeUInt32LE(0, 38);
|
|
105
|
+
central.writeUInt32LE(offset, 42);
|
|
106
|
+
name.copy(central, 46);
|
|
107
|
+
centrals.push(central);
|
|
108
|
+
offset += local.length + data.length;
|
|
109
|
+
}
|
|
110
|
+
const centralDirectory = Buffer.concat(centrals);
|
|
111
|
+
const end = Buffer.alloc(22);
|
|
112
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
113
|
+
end.writeUInt16LE(entries.length, 8);
|
|
114
|
+
end.writeUInt16LE(entries.length, 10);
|
|
115
|
+
end.writeUInt32LE(centralDirectory.length, 12);
|
|
116
|
+
end.writeUInt32LE(offset, 16);
|
|
117
|
+
return Buffer.concat([...locals, centralDirectory, end]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function addTree(entries, directory, prefix) {
|
|
121
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
122
|
+
const absolute = path.join(directory, entry.name);
|
|
123
|
+
const name = `${prefix}/${entry.name}`;
|
|
124
|
+
if (entry.isDirectory()) addTree(entries, absolute, name);
|
|
125
|
+
else if (entry.isFile()) entries.push({ name, data: fs.readFileSync(absolute) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function contentTypes(entries) {
|
|
130
|
+
const defaults = new Map([
|
|
131
|
+
[".json", "application/json"],
|
|
132
|
+
[".js", "application/javascript"],
|
|
133
|
+
[".css", "text/css"],
|
|
134
|
+
[".md", "text/markdown"],
|
|
135
|
+
[".txt", "text/plain"],
|
|
136
|
+
[".vsixmanifest", "text/xml"],
|
|
137
|
+
[".xml", "text/xml"],
|
|
138
|
+
[".svg", "image/svg+xml"],
|
|
139
|
+
[".png", "image/png"],
|
|
140
|
+
]);
|
|
141
|
+
const lines = [
|
|
142
|
+
`<?xml version="1.0" encoding="utf-8"?>`,
|
|
143
|
+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`,
|
|
144
|
+
];
|
|
145
|
+
const used = new Set(entries.map((entry) => path.posix.extname(entry.name).toLowerCase()));
|
|
146
|
+
used.add(".vsixmanifest");
|
|
147
|
+
used.add(".xml");
|
|
148
|
+
for (const [extension, type] of defaults) {
|
|
149
|
+
if (used.has(extension)) {
|
|
150
|
+
lines.push(` <Default Extension="${extension.slice(1)}" ContentType="${type}"/>`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (entries.some((entry) => entry.name === "extension/LICENSE")) {
|
|
154
|
+
lines.push(` <Override PartName="/extension/LICENSE" ContentType="text/plain"/>`);
|
|
155
|
+
}
|
|
156
|
+
lines.push(`</Types>`, ``);
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function vsixManifest(manifest) {
|
|
161
|
+
const tags = (manifest.keywords ?? []).join(",");
|
|
162
|
+
const categories = (manifest.categories ?? []).join(",");
|
|
163
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
164
|
+
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
|
|
165
|
+
<Metadata>
|
|
166
|
+
<Identity Language="en-US" Id="${xmlEscape(manifest.name)}" Version="${xmlEscape(manifest.version)}" Publisher="${xmlEscape(manifest.publisher)}"/>
|
|
167
|
+
<DisplayName>${xmlEscape(manifest.displayName)}</DisplayName>
|
|
168
|
+
<Description xml:space="preserve">${xmlEscape(manifest.description)}</Description>
|
|
169
|
+
<Tags>${xmlEscape(tags)}</Tags>
|
|
170
|
+
<Categories>${xmlEscape(categories)}</Categories>
|
|
171
|
+
<GalleryFlags>Public</GalleryFlags>
|
|
172
|
+
<Properties>
|
|
173
|
+
<Property Id="Microsoft.VisualStudio.Code.Engine" Value="${xmlEscape(manifest.engines.vscode)}"/>
|
|
174
|
+
<Property Id="Microsoft.VisualStudio.Code.ExtensionKind" Value="workspace"/>
|
|
175
|
+
</Properties>
|
|
176
|
+
</Metadata>
|
|
177
|
+
<Installation>
|
|
178
|
+
<InstallationTarget Id="Microsoft.VisualStudio.Code"/>
|
|
179
|
+
</Installation>
|
|
180
|
+
<Dependencies/>
|
|
181
|
+
<Assets>
|
|
182
|
+
<Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true"/>
|
|
183
|
+
</Assets>
|
|
184
|
+
</PackageManifest>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function packageVsix() {
|
|
189
|
+
const source = path.join(root, "editors", "vscode");
|
|
190
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
|
|
191
|
+
const entries = [];
|
|
192
|
+
const license = path.join(root, "LICENSE");
|
|
193
|
+
if (fs.existsSync(license)) entries.push({ name: "extension/LICENSE", data: fs.readFileSync(license) });
|
|
194
|
+
entries.push({ name: "extension/package.json", data: fs.readFileSync(path.join(source, "package.json")) });
|
|
195
|
+
entries.push({ name: "extension/README.md", data: fs.readFileSync(path.join(source, "README.md")) });
|
|
196
|
+
addTree(entries, path.join(source, "dist"), "extension/dist");
|
|
197
|
+
addTree(entries, path.join(source, "snippets"), "extension/snippets");
|
|
198
|
+
addTree(entries, path.join(source, "syntaxes"), "extension/syntaxes");
|
|
199
|
+
entries.unshift(
|
|
200
|
+
{ name: "[Content_Types].xml", data: Buffer.from(contentTypes(entries)) },
|
|
201
|
+
{ name: "extension.vsixmanifest", data: Buffer.from(vsixManifest(manifest)) },
|
|
202
|
+
);
|
|
203
|
+
const destination = path.join(distDir, "vscode");
|
|
204
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
205
|
+
const filename = `${manifest.name}-${manifest.version}.vsix`;
|
|
206
|
+
fs.writeFileSync(path.join(destination, filename), zipStore(entries));
|
|
207
|
+
return filename;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function packageTypora() {
|
|
211
|
+
const from = path.join(root, "editors", "typora", "dist");
|
|
212
|
+
const to = path.join(distDir, "typora");
|
|
213
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
214
|
+
fs.cpSync(from, to, { recursive: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildArtifacts() {
|
|
218
|
+
runBuild(path.join(root, "editors", "vscode", "build.mjs"));
|
|
219
|
+
runBuild(path.join(root, "editors", "typora", "build.mjs"));
|
|
220
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
221
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
222
|
+
const vsix = packageVsix();
|
|
223
|
+
packageTypora();
|
|
224
|
+
process.stdout.write(`编辑器插件已打包:editors/dist/vscode/${vsix}、editors/dist/typora/\n`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Builds the VS Code VSIX and Typora plugin into `editors/dist/` when they are missing.
|
|
229
|
+
* `npm pack` calls this with `force` so the published tarball is freshly built.
|
|
230
|
+
*/
|
|
231
|
+
export function packEditors({ force = false } = {}) {
|
|
232
|
+
if (!force && distReady()) return distDir;
|
|
233
|
+
return withPackLock(() => {
|
|
234
|
+
if (!force && distReady()) return distDir;
|
|
235
|
+
buildArtifacts();
|
|
236
|
+
return distDir;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
241
|
+
if (invokedDirectly) packEditors({ force: true });
|
|
@@ -10,6 +10,8 @@ make dev
|
|
|
10
10
|
|
|
11
11
|
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
12
|
|
|
13
|
+
`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.
|
|
14
|
+
|
|
13
15
|
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`.
|
|
14
16
|
|
|
15
17
|
To upgrade the engine, update `hd-garden` in `package.json` to the new version and run `npm install`.
|
package/templates/site/README.md
CHANGED
|
@@ -10,6 +10,8 @@ make dev
|
|
|
10
10
|
|
|
11
11
|
第一次 `npx garden dev` 会补齐 Makefile 和 skill,并创建 `.env.local`。Cursor / Codex 打开这个仓库后会读到 `.agents/skills/`。要更新 skill,再运行 `npx garden skill`。
|
|
12
12
|
|
|
13
|
+
`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`,再从带小预览图的列表里选。
|
|
14
|
+
|
|
13
15
|
评论默认关闭。发布前打开 [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`。
|
|
14
16
|
|
|
15
17
|
升级引擎:把 `package.json` 里的 `hd-garden` 改到新版本,例如 `^0.4.0`,再运行 `npm install`。
|
package/ui/core/transform.mjs
CHANGED
|
@@ -86,9 +86,17 @@ const absolute = (src, siteUrl) => (src && siteUrl && src.startsWith("/") ? `${s
|
|
|
86
86
|
const escapeHtml = (value) =>
|
|
87
87
|
String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
88
88
|
|
|
89
|
+
/** Text a reader can select inside an inlined Excalidraw SVG; path data and fonts stay out of search. */
|
|
90
|
+
function excalidrawVisibleText(svg) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
for (const match of svg.matchAll(/<(?:text|title)\b[^>]*>([^<]*)<\/(?:text|title)>/giu)) parts.push(match[1]);
|
|
93
|
+
return parts.join(" ");
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
/** Readable text for search and word count: captions and snapshot text, never intent. */
|
|
90
97
|
export function contentToSearchText(content) {
|
|
91
|
-
|
|
98
|
+
const withoutDiagramMarkup = String(content ?? "").replace(/<svg\b[^>]*class="[^"]*\bexcalidraw\b[^"]*"[\s\S]*?<\/svg>/giu, (svg) => ` ${excalidrawVisibleText(svg)} `);
|
|
99
|
+
return replaceFences(stripCardMarkers(withoutDiagramMarkup), (fence) => {
|
|
92
100
|
if (fence.lang === "ui") {
|
|
93
101
|
const payload = readPayload(fence.body);
|
|
94
102
|
if (!payload) return " ";
|