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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Turn an exported Excalidraw SVG into article HTML.
3
+ *
4
+ * The Markdown image stays the author's syntax until content sync. This walks
5
+ * the Markdown AST, swaps each `.excalidraw` image for the sanitized SVG, and
6
+ * leaves every other byte of the post alone. rehype-raw then parses that SVG
7
+ * into the article tree, so the text is part of the page rather than an
8
+ * `<img src>`.
9
+ */
10
+ import crypto from "node:crypto";
11
+ import { fromMarkdown } from "mdast-util-from-markdown";
12
+ import { fromHtml } from "hast-util-from-html";
13
+ import { toHtml } from "hast-util-to-html";
14
+ import { visit } from "unist-util-visit";
15
+ import { figureLayout } from "./enhance.mjs";
16
+ import { mapOutsideCode } from "./markdown-links.mjs";
17
+
18
+ /** Same rule as scripts/excalidraw.mjs: a source file, not an `.excalidraw.svg`. */
19
+ function isExcalidrawPath(value) {
20
+ const clean = String(value ?? "").split(/[?#]/u, 1)[0];
21
+ return /\.excalidraw$/iu.test(clean);
22
+ }
23
+
24
+ const DROP_TAGS = new Set([
25
+ "script",
26
+ "noscript",
27
+ "iframe",
28
+ "object",
29
+ "embed",
30
+ "foreignobject",
31
+ "audio",
32
+ "video",
33
+ "canvas",
34
+ "link",
35
+ "meta",
36
+ "base",
37
+ "form",
38
+ ]);
39
+
40
+ const escapeHtml = (value) =>
41
+ String(value)
42
+ .replaceAll("&", "&amp;")
43
+ .replaceAll("<", "&lt;")
44
+ .replaceAll(">", "&gt;")
45
+ .replaceAll('"', "&quot;");
46
+
47
+ function meaningfulChildren(node) {
48
+ return (node?.children ?? []).filter((child) => !(child.type === "text" && !String(child.value).trim()));
49
+ }
50
+
51
+ /** Same caption rule as article figures: title, otherwise alt, unless the alt is a placeholder. */
52
+ function imageCaption(image) {
53
+ const title = String(image.title ?? "").trim();
54
+ const alt = String(image.alt ?? "").trim();
55
+ if (title) return title;
56
+ if (alt && !/^image|^截图|^img/iu.test(alt)) return alt;
57
+ return "";
58
+ }
59
+
60
+ function unsafeUrl(value) {
61
+ const url = String(value ?? "").trim().replace(/[\u0000-\u001f\s]+/gu, "");
62
+ if (!url || url.startsWith("#") || url.startsWith("/")) return false;
63
+ if (/^javascript:/iu.test(url)) return true;
64
+ if (/^https?:/iu.test(url)) return false;
65
+ if (/^data:image\/(?:png|gif|jpe?g|webp|svg\+xml);/iu.test(url)) return false;
66
+ if (/^data:font\//iu.test(url)) return false;
67
+ if (/^data:/iu.test(url)) return true;
68
+ return false;
69
+ }
70
+
71
+ function sanitizeNode(node) {
72
+ if (!node || node.type === "text" || node.type === "comment") return node;
73
+ if (node.type !== "element") return null;
74
+ if (DROP_TAGS.has(String(node.tagName).toLowerCase())) return null;
75
+ const properties = { ...(node.properties ?? {}) };
76
+ for (const key of Object.keys(properties)) {
77
+ const value = properties[key];
78
+ if (/^on/iu.test(key)) {
79
+ delete properties[key];
80
+ continue;
81
+ }
82
+ if (typeof value === "string" && /^(?:href|src|xlinkHref)$/iu.test(key) && unsafeUrl(value)) {
83
+ delete properties[key];
84
+ continue;
85
+ }
86
+ if (key === "style" && typeof value === "string" && /javascript:/iu.test(value)) delete properties[key];
87
+ }
88
+ node.properties = properties;
89
+ node.children = (node.children ?? []).map(sanitizeNode).filter(Boolean);
90
+ return node;
91
+ }
92
+
93
+ /**
94
+ * Sanitized SVG markup for the page. Alt or title becomes `<title>` and
95
+ * `aria-labelledby`. Scene `<text>` nodes stay.
96
+ * @param {string} svgText
97
+ * @param {{ alt?: string, title?: string }} [labels]
98
+ */
99
+ export function inlineSvgDocument(svgText, { alt = "", title = "" } = {}) {
100
+ const stripped = String(svgText).replace(/<!--\s*garden-excalidraw-source:[^>]*-->/gu, "");
101
+ const root = fromHtml(stripped, { fragment: true });
102
+ const svg = root.children.find((node) => node.type === "element" && node.tagName === "svg");
103
+ if (!svg) throw new Error("导出结果不是 SVG");
104
+ sanitizeNode(svg);
105
+ const label = String(title || alt || "").trim();
106
+ if (label) {
107
+ const id = `garden-excalidraw-${crypto.createHash("sha256").update(`${label}\0${stripped}`).digest("hex").slice(0, 10)}`;
108
+ svg.properties.ariaLabelledby = id;
109
+ svg.children.unshift({
110
+ type: "element",
111
+ tagName: "title",
112
+ properties: { id },
113
+ children: [{ type: "text", value: label }],
114
+ });
115
+ }
116
+ const classes = Array.isArray(svg.properties.className)
117
+ ? svg.properties.className
118
+ : svg.properties.className
119
+ ? [svg.properties.className]
120
+ : [];
121
+ if (!classes.includes("excalidraw")) classes.push("excalidraw");
122
+ svg.properties.className = classes;
123
+ const width = Number(svg.properties.width) || 0;
124
+ const height = Number(svg.properties.height) || 0;
125
+ if (width > 0) svg.properties.dataNw = String(width);
126
+ if (width > 0) svg.properties.dataAw = String(width);
127
+ return { html: toHtml(svg), width, height };
128
+ }
129
+
130
+ function figureHtml(svgHtml, caption, width) {
131
+ const side = figureLayout({ naturalWidth: width }).side;
132
+ const classes = side > 0 ? "fig fig-wide" : "fig";
133
+ const style = side > 0 ? ` style="--wl:${side}px;--wr:${side}px"` : "";
134
+ const cap = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "";
135
+ return `<figure class="${classes}"${style}><div class="fig-card">${svgHtml}</div>${cap}</figure>`;
136
+ }
137
+
138
+ /**
139
+ * Replace `.excalidraw` Markdown images with inline SVG HTML.
140
+ * `readSvg(url)` returns the exported SVG text or throws.
141
+ * @param {string} markdown
142
+ * @param {(url: string) => string} readSvg
143
+ */
144
+ export function embedExcalidrawImages(markdown, readSvg) {
145
+ const source = String(markdown ?? "");
146
+ const tree = fromMarkdown(source);
147
+ const edits = [];
148
+ visit(tree, "image", (node, _index, parent) => {
149
+ const url = String(node.url ?? "");
150
+ if (!isExcalidrawPath(url)) return;
151
+ const start = node.position?.start?.offset;
152
+ const end = node.position?.end?.offset;
153
+ if (start == null || end == null) return;
154
+ const { html, width } = inlineSvgDocument(readSvg(url), { alt: node.alt, title: node.title });
155
+ const alone = parent?.type === "paragraph" && meaningfulChildren(parent).length === 1 && meaningfulChildren(parent)[0] === node;
156
+ const caption = imageCaption(node);
157
+ edits.push(alone && parent.position?.start?.offset != null && parent.position?.end?.offset != null
158
+ ? { start: parent.position.start.offset, end: parent.position.end.offset, replacement: figureHtml(html, caption, width) }
159
+ : { start, end, replacement: html });
160
+ });
161
+ edits.sort((left, right) => right.start - left.start);
162
+ let output = source;
163
+ for (const edit of edits) output = `${output.slice(0, edit.start)}${edit.replacement}${output.slice(edit.end)}`;
164
+ return output;
165
+ }
166
+
167
+ /** Raw `<img src="….excalidraw">` outside code becomes the same inline SVG. */
168
+ export function embedExcalidrawImgTags(markdown, readSvg) {
169
+ return mapOutsideCode(markdown, (text) => text.replace(/<img\b[^>]*>/giu, (tag) => {
170
+ const src = /\bsrc\s*=\s*["']([^"']+)["']/iu.exec(tag)?.[1];
171
+ if (!src || !isExcalidrawPath(src)) return tag;
172
+ const alt = /\balt\s*=\s*["']([^"']*)["']/iu.exec(tag)?.[1] ?? "";
173
+ return inlineSvgDocument(readSvg(src), { alt }).html;
174
+ }));
175
+ }
@@ -95,13 +95,100 @@ ${dicts.join("\n")}
95
95
  `;
96
96
  }
97
97
 
98
+ /** Facts the author should mention. Insert paths turn these into a bare `garden` brief, not YAML. */
99
+ const GUIDANCE_FACTS = {
100
+ card: "链接、标题、一句说明",
101
+ stat: "指标名、数字、单位,需要时写来源",
102
+ compare: "两三件东西的名字,以及每件一句差别",
103
+ timeline: "每条的日期、标题和一句说明",
104
+ chart: "图表类型、横轴、几组已经算好的数字、单位",
105
+ figure: "图放在哪里,读者点开每一部分时要看到的说明",
106
+ html: "同名目录里的 HTML 文件,以及这张图要表达的意思",
107
+ interactive: "读者能拧的参数,以及拧的时候想让他看出什么",
108
+ file: "文件路径,和一句说明里面有什么",
109
+ relations: "三到六个要素的名字,以及谁影响谁",
110
+ list: "每条做法或信号的一句话",
111
+ choice: "两三个选项,每个有一个醒目的数字和一句说明",
112
+ };
113
+
114
+ function ideaFence(text) {
115
+ return `\`\`\`garden\n${text}\n\`\`\``;
116
+ }
117
+
118
+ /** The block an insert command pastes. Named components stay an uncompiled idea for the garden-ui skill. */
119
+ export function guidanceFence(name) {
120
+ if (!name) return IDEA.example;
121
+ const spec = CATALOG[name];
122
+ return ideaFence(`做成${spec.label}。${spec.when}请写上${GUIDANCE_FACTS[name]}。`);
123
+ }
124
+
125
+ function svgThumb(inner) {
126
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="48" viewBox="0 0 72 48"><rect width="72" height="48" rx="6" fill="#f6f3ec"/>${inner}</svg>`;
127
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
128
+ }
129
+
130
+ const PREVIEWS = {
131
+ idea: svgThumb(`<path d="M14 16h28M14 24h22M14 32h16" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/><circle cx="54" cy="24" r="8" fill="none" stroke="#c4552a" stroke-width="2"/>`),
132
+ card: svgThumb(`<rect x="8" y="8" width="56" height="32" rx="4" fill="#fff" stroke="#d9d3c7"/><rect x="12" y="12" width="16" height="16" rx="2" fill="#c4552a"/><path d="M34 16h24M34 22h18M34 30h22" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
133
+ stat: svgThumb(`<text x="10" y="32" fill="#c4552a" font-family="ui-sans-serif,sans-serif" font-size="20" font-weight="700">3.2</text><path d="M46 30h14" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
134
+ compare: svgThumb(`<rect x="8" y="10" width="24" height="28" rx="3" fill="#fff" stroke="#d9d3c7"/><rect x="40" y="10" width="24" height="28" rx="3" fill="#fff" stroke="#c4552a"/><path d="M12 18h16M12 24h12M44 18h16M44 24h12" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
135
+ timeline: svgThumb(`<path d="M20 8v32" stroke="#d9d3c7" stroke-width="2"/><circle cx="20" cy="14" r="3" fill="#c4552a"/><circle cx="20" cy="24" r="3" fill="#2b2926"/><circle cx="20" cy="34" r="3" fill="#2b2926"/><path d="M30 14h24M30 24h18M30 34h20" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
136
+ chart: svgThumb(`<path d="M10 38h52" stroke="#d9d3c7" stroke-width="2"/><rect x="16" y="22" width="8" height="16" fill="#2b2926"/><rect x="32" y="12" width="8" height="26" fill="#c4552a"/><rect x="48" y="18" width="8" height="20" fill="#2b2926"/>`),
137
+ figure: svgThumb(`<rect x="10" y="10" width="52" height="28" rx="3" fill="#fff" stroke="#2b2926"/><circle cx="24" cy="24" r="4" fill="#c4552a"/><circle cx="48" cy="20" r="4" fill="#2b2926"/>`),
138
+ html: svgThumb(`<path d="M18 16l-8 8 8 8M54 16l8 8-8 8" fill="none" stroke="#c4552a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 14l8 20" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
139
+ interactive: svgThumb(`<path d="M12 24h48" stroke="#d9d3c7" stroke-width="4" stroke-linecap="round"/><circle cx="40" cy="24" r="6" fill="#c4552a"/>`),
140
+ file: svgThumb(`<path d="M22 8h18l10 10v22a2 2 0 0 1-2 2H22a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z" fill="#fff" stroke="#2b2926"/><path d="M40 8v10h10" fill="none" stroke="#c4552a" stroke-width="2"/>`),
141
+ relations: svgThumb(`<circle cx="20" cy="16" r="5" fill="#c4552a"/><circle cx="52" cy="16" r="5" fill="#2b2926"/><circle cx="36" cy="34" r="5" fill="#2b2926"/><path d="M25 18l22 0M23 20l10 10M49 20l-10 10" stroke="#2b2926" stroke-width="1.5"/>`),
142
+ list: svgThumb(`<circle cx="16" cy="14" r="3" fill="#c4552a"/><circle cx="16" cy="24" r="3" fill="#2b2926"/><circle cx="16" cy="34" r="3" fill="#2b2926"/><path d="M26 14h30M26 24h24M26 34h28" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
143
+ choice: svgThumb(`<rect x="8" y="12" width="24" height="24" rx="4" fill="#fff" stroke="#c4552a"/><rect x="40" y="12" width="24" height="24" rx="4" fill="#fff" stroke="#d9d3c7"/><path d="M14 24h12M46 24h12" stroke="#2b2926" stroke-width="2" stroke-linecap="round"/>`),
144
+ };
145
+
146
+ function escapeHtml(value) {
147
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
148
+ }
149
+
150
+ /** HTML for one slash-menu row: a thumbnail plus the label. */
151
+ export function suggestHtml(item) {
152
+ return `<span class="garden-suggest"><img class="garden-suggest-thumb" alt="" width="72" height="48" src="${item.preview}"><span class="garden-suggest-label">${escapeHtml(item.label)}</span></span>`;
153
+ }
154
+
155
+ const SLASH_COMMAND = "garden";
156
+
157
+ /**
158
+ * Two steps of the Typora `/` menu. A strict prefix of `garden` (including `/`
159
+ * alone) is the command hint. `/garden` itself is the component picker.
160
+ */
161
+ export function slashQuery(textBefore) {
162
+ const none = { phase: "none", query: "", replaceLength: 0, lead: "" };
163
+ const component = /(^|[\r\n]|[^\S\r\n])\/garden(?=$|[ \t])(?:[ \t]+(\S*))?$/u.exec(textBefore);
164
+ if (component) {
165
+ const boundary = component[1];
166
+ const start = component.index + boundary.length;
167
+ return {
168
+ phase: "components",
169
+ query: component[2] ?? "",
170
+ replaceLength: textBefore.length - start,
171
+ lead: boundary === "" || boundary === "\n" || boundary === "\r" ? "" : "\n",
172
+ };
173
+ }
174
+ const slash = /(^|[\r\n]|[^\S\r\n])\/(\S*)$/u.exec(textBefore);
175
+ if (!slash) return none;
176
+ const typed = slash[2];
177
+ const folded = typed.toLowerCase();
178
+ if (folded !== SLASH_COMMAND && SLASH_COMMAND.startsWith(folded)) {
179
+ return { phase: "command", query: typed, replaceLength: 1 + typed.length, lead: "" };
180
+ }
181
+ return none;
182
+ }
183
+
98
184
  export function pickerItems() {
99
185
  return [
100
- { name: "", label: `想法 · ${IDEA.label}`, body: IDEA.example, when: IDEA.when, whenNot: IDEA.whenNot },
186
+ { name: "", label: `想法 · ${IDEA.label}`, body: guidanceFence(""), preview: PREVIEWS.idea, when: IDEA.when, whenNot: IDEA.whenNot },
101
187
  ...GARDEN_NAMES.map((name) => ({
102
188
  name,
103
189
  label: `${name} · ${CATALOG[name].label}`,
104
- body: CATALOG[name].example,
190
+ body: guidanceFence(name),
191
+ preview: PREVIEWS[name],
105
192
  when: CATALOG[name].when,
106
193
  whenNot: CATALOG[name].whenNot,
107
194
  })),