hd-garden 0.3.13 → 0.4.1
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/.agents/skills/garden-interactive-chart/SKILL.md +6 -6
- package/.agents/skills/garden-ui/SKILL.md +4 -4
- package/app/a2/[slug]/page.tsx +61 -0
- package/app/a2/page.tsx +27 -0
- package/app/blog/[...path]/page.tsx +32 -22
- package/app/blog/page.tsx +6 -7
- package/app/columns/[column]/[...path]/page.tsx +32 -25
- package/app/columns/page.tsx +38 -52
- package/app/components/ArticleMeasure.tsx +95 -0
- package/app/components/ArticleTitle.tsx +20 -0
- package/app/components/BlogArchive.tsx +106 -102
- package/app/components/MarkdownArticle.tsx +145 -66
- package/app/components/MermaidDiagram.tsx +44 -71
- package/app/components/SiteFooter.tsx +3 -7
- package/app/components/SiteHeader.tsx +55 -41
- package/app/components/TableOfContents.tsx +3 -2
- package/app/components/TagIndex.tsx +97 -0
- package/app/components/garden/GardenBlock.tsx +45 -0
- package/app/components/garden/blocks.tsx +530 -0
- package/app/components/garden/chrome.tsx +350 -0
- package/app/components/garden/sandbox-message.ts +16 -0
- package/app/garden-modules.d.ts +2 -0
- package/app/globals.css +17 -12
- package/app/layout.tsx +9 -0
- package/app/lib/a2-meta.ts +22 -0
- package/app/lib/a2-preview.ts +50 -0
- package/app/lib/content.ts +52 -6
- package/app/lib/markdown-plugins.ts +2 -0
- package/app/lib/remark-a2.mjs +208 -0
- package/app/lib/remark-garden.mjs +88 -0
- package/app/page.tsx +122 -93
- package/app/styles/a2.css +1383 -0
- package/app/styles/shell.css +438 -0
- package/app/styles/tokens.css +111 -0
- package/app/tags/page.tsx +28 -0
- package/app/ui-parity/page.tsx +3 -2
- package/package.json +1 -1
- package/scripts/build-content.mjs +11 -1
- package/scripts/check-post.mjs +18 -4
- package/scripts/new-post.mjs +3 -0
- package/site.config.yaml +1 -1
- package/templates/garden-blocks.md +128 -0
- package/templates/garden-snippets.plist +121 -0
- package/ui/garden/catalog.mjs +196 -0
- package/ui/garden/check.mjs +103 -0
- package/ui/garden/enhance.mjs +89 -0
- package/ui/garden/parse.mjs +198 -0
- package/ui/garden/snippets.mjs +109 -0
- package/ui/garden/title.mjs +37 -0
- package/ui/garden/word-count.mjs +266 -0
- package/ui/host.mjs +45 -28
- package/ui/parity-fixtures.mjs +1 -1
- package/ui/preview/dist/garden-ui.css +81 -77
- package/ui/preview/dist/garden-ui.esm.js +124 -89
- package/ui/preview/dist/garden-ui.iife.js +124 -89
- package/ui/prompt/rules.md +17 -1
- package/ui/react/CatalogPage.tsx +12 -0
- package/ui/react/components/display.tsx +1 -1
- package/ui/react/components/media.tsx +2 -2
- package/ui/react/ui.css +81 -77
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure enhancements shared by the renderer and `garden check`.
|
|
3
|
+
* Math identifiers, code/diff pairing, table breakout and TOC shift.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const IDENTIFIER = /(^|[^\\A-Za-z])([A-Za-z]{2,})_([A-Za-z][A-Za-z0-9]*)/gu;
|
|
7
|
+
|
|
8
|
+
/** `group_num` becomes one identifier. `W_q` and `H_kv` stay real subscripts. */
|
|
9
|
+
export function protectMathIdentifiers(tex) {
|
|
10
|
+
return String(tex ?? "").replace(IDENTIFIER, (_, prefix, base, rest) => `${prefix}\\mathrm{${base}\\_${rest}}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** New-file text of a unified diff: additions and context, deletions dropped. */
|
|
14
|
+
export function diffToNewCode(diff) {
|
|
15
|
+
return String(diff ?? "")
|
|
16
|
+
.replace(/\n$/u, "")
|
|
17
|
+
.split("\n")
|
|
18
|
+
.flatMap((line) => {
|
|
19
|
+
if (line.startsWith("+")) return [line.slice(1)];
|
|
20
|
+
if (line.startsWith("-") || line.startsWith("\\")) return [];
|
|
21
|
+
return [line.startsWith(" ") ? line.slice(1) : line];
|
|
22
|
+
})
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isTrivialFormula(formula) {
|
|
27
|
+
const compact = String(formula ?? "").replace(/\s+/gu, "");
|
|
28
|
+
if (!compact || (compact.match(/\*/gu) || []).length !== 1) return false;
|
|
29
|
+
const outside = compact.replace(/\([^()]*\)/gu, "");
|
|
30
|
+
return !/[+\-/]/u.test(outside);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function interactiveFormulas(source) {
|
|
34
|
+
const text = String(source ?? "");
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const match of text.matchAll(/^formula:\s*(.+)$/gmu)) found.push(match[1]);
|
|
37
|
+
for (const match of text.matchAll(/<Let\b[^>]*?\{([^{}]+)\}/gu)) found.push(match[1]);
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isTrivialInteractive(source) {
|
|
42
|
+
const formulas = interactiveFormulas(source);
|
|
43
|
+
return formulas.length > 0 && formulas.every((formula) => isTrivialFormula(formula));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Steps that only restyle prose: a stepper and no input the reader can change. */
|
|
47
|
+
export function isProseSteps(source) {
|
|
48
|
+
const text = String(source ?? "");
|
|
49
|
+
const body = text.includes("\n---\n") ? text.split("\n---\n").slice(1).join("\n---\n") : text;
|
|
50
|
+
if (!/<(Steps|Stepper|Step)\b/u.test(body)) return false;
|
|
51
|
+
return !/<(State|Slider|Choice|Toggle|Input|Range)\b/u.test(body);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How far a table may break out of the text column.
|
|
56
|
+
* Symmetric, at most `cap` per side, never closer than `edge` to the viewport,
|
|
57
|
+
* and only when the viewport is at least `minViewport`.
|
|
58
|
+
*/
|
|
59
|
+
export function tableBreakout({
|
|
60
|
+
viewport,
|
|
61
|
+
articleLeft,
|
|
62
|
+
articleRight,
|
|
63
|
+
tableClient,
|
|
64
|
+
tableScroll,
|
|
65
|
+
minViewport = 900,
|
|
66
|
+
cap = 300,
|
|
67
|
+
edge = 24,
|
|
68
|
+
}) {
|
|
69
|
+
if (viewport < minViewport) return { left: 0, right: 0 };
|
|
70
|
+
const room = Math.max(0, Math.min(cap, articleLeft - edge, viewport - edge - articleRight));
|
|
71
|
+
const need = tableScroll - tableClient;
|
|
72
|
+
if (need <= 1 || room <= 0) return { left: 0, right: 0 };
|
|
73
|
+
const margin = Math.min(room, Math.ceil(need / 2) + 4);
|
|
74
|
+
return { left: margin, right: margin };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Slide the TOC right of a wide element measured against the TOC's original
|
|
79
|
+
* position. Hide it when the shifted TOC would not fit on screen.
|
|
80
|
+
*/
|
|
81
|
+
export function tocShift({ tocLeft, tocWidth, viewport, overlapRights, edge = 12 }) {
|
|
82
|
+
if (!overlapRights?.length) return { shift: 0, hide: false };
|
|
83
|
+
const maxRight = Math.max(...overlapRights);
|
|
84
|
+
let shift = Math.ceil(maxRight + 16 - tocLeft);
|
|
85
|
+
const hide = tocLeft + shift + tocWidth > viewport - edge;
|
|
86
|
+
if (hide) shift = Math.ceil(viewport - tocLeft + 8);
|
|
87
|
+
if (shift < 0) shift = 0;
|
|
88
|
+
return { shift, hide };
|
|
89
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import { CATALOG, knownName, legacyOf } from "./catalog.mjs";
|
|
3
|
+
|
|
4
|
+
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/u;
|
|
5
|
+
|
|
6
|
+
/** Fences in document order. Line numbers are 1-based in `markdown`. */
|
|
7
|
+
export function scanFences(markdown) {
|
|
8
|
+
const lines = String(markdown ?? "").split(/\r?\n/u);
|
|
9
|
+
const fences = [];
|
|
10
|
+
let open = null;
|
|
11
|
+
lines.forEach((line, index) => {
|
|
12
|
+
const match = FENCE_RE.exec(line);
|
|
13
|
+
if (open) {
|
|
14
|
+
const closer = match
|
|
15
|
+
&& match[2][0] === open.marker[0]
|
|
16
|
+
&& match[2].length >= open.marker.length
|
|
17
|
+
&& !match[3].trim()
|
|
18
|
+
&& match[1].length <= open.indent;
|
|
19
|
+
if (closer) {
|
|
20
|
+
fences.push({
|
|
21
|
+
indent: open.indent,
|
|
22
|
+
marker: open.marker,
|
|
23
|
+
info: open.info,
|
|
24
|
+
lang: open.lang,
|
|
25
|
+
meta: open.meta,
|
|
26
|
+
line: open.line,
|
|
27
|
+
endLine: index + 1,
|
|
28
|
+
body: open.body.join("\n"),
|
|
29
|
+
});
|
|
30
|
+
open = null;
|
|
31
|
+
} else {
|
|
32
|
+
open.body.push(line);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!match) return;
|
|
37
|
+
const info = match[3].trim();
|
|
38
|
+
const lang = info.split(/\s+/u)[0] ?? "";
|
|
39
|
+
open = {
|
|
40
|
+
indent: match[1].length,
|
|
41
|
+
marker: match[2],
|
|
42
|
+
info,
|
|
43
|
+
lang: lang.toLowerCase(),
|
|
44
|
+
meta: info.slice(lang.length).trim(),
|
|
45
|
+
line: index + 1,
|
|
46
|
+
body: [],
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
if (open) {
|
|
50
|
+
fences.push({ ...open, endLine: lines.length, body: open.body.join("\n"), unclosed: true });
|
|
51
|
+
}
|
|
52
|
+
return fences;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isGardenLang(lang) {
|
|
56
|
+
return lang === "garden" || String(lang || "").startsWith("garden:");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `garden`, `garden stat`, `garden stat #id`, or legacy `garden:stat`. */
|
|
60
|
+
export function parseGardenInfo(info) {
|
|
61
|
+
const raw = String(info ?? "").trim();
|
|
62
|
+
const legacy = /^garden:([a-z][\w-]*)$/iu.exec(raw.split(/\s+/u)[0] ?? "");
|
|
63
|
+
if (legacy) {
|
|
64
|
+
const id = /#([\w-]+)/u.exec(raw)?.[1] ?? null;
|
|
65
|
+
return { legacy: true, legacyLang: legacy[0].toLowerCase(), name: legacy[1].toLowerCase(), id };
|
|
66
|
+
}
|
|
67
|
+
if (!/^garden(?:\s|$)/iu.test(raw)) return null;
|
|
68
|
+
const rest = raw.slice("garden".length).trim();
|
|
69
|
+
if (!rest) return { legacy: false, name: "", id: null };
|
|
70
|
+
const match = /^([a-z][\w-]*)(?:\s+#([\w-]+))?$/iu.exec(rest);
|
|
71
|
+
if (!match) return { legacy: false, name: rest.split(/\s+/u)[0].toLowerCase(), id: null, bad: true };
|
|
72
|
+
return { legacy: false, name: match[1].toLowerCase(), id: match[2] ?? null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function splitPropsAndChildren(body) {
|
|
76
|
+
const text = String(body ?? "").replace(/\s+$/u, "");
|
|
77
|
+
const lines = text.split("\n");
|
|
78
|
+
const sep = lines.findIndex((line, index) => index > 0 && /^---\s*$/u.test(line));
|
|
79
|
+
if (sep === -1) return { yamlText: text, children: "" };
|
|
80
|
+
return { yamlText: lines.slice(0, sep).join("\n"), children: lines.slice(sep + 1).join("\n").replace(/^\n/u, "") };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function asObject(value) {
|
|
84
|
+
if (Array.isArray(value)) return { items: value };
|
|
85
|
+
if (value && typeof value === "object") return value;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One node: { name, props, children }.
|
|
91
|
+
* An empty name is an idea. Invalid YAML or an unknown name is still returned,
|
|
92
|
+
* with `error` set, so the checker and the renderer share one parse.
|
|
93
|
+
*/
|
|
94
|
+
export function parseGardenBlock(info, body) {
|
|
95
|
+
const header = parseGardenInfo(info);
|
|
96
|
+
if (!header) return null;
|
|
97
|
+
const { yamlText, children } = splitPropsAndChildren(body);
|
|
98
|
+
if (!header.name) {
|
|
99
|
+
const text = String(body ?? "").trim();
|
|
100
|
+
return { name: "", props: header.id ? { id: header.id } : {}, children: text, idea: text, legacy: header.legacy, legacyLang: header.legacyLang };
|
|
101
|
+
}
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = yamlText.trim() ? parseYaml(yamlText) : {};
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return {
|
|
107
|
+
name: header.name,
|
|
108
|
+
props: {},
|
|
109
|
+
children,
|
|
110
|
+
id: header.id,
|
|
111
|
+
legacy: header.legacy,
|
|
112
|
+
legacyLang: header.legacyLang,
|
|
113
|
+
error: { code: "garden-yaml", message: `YAML 解析失败:${error.message}` },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const props = asObject(parsed);
|
|
117
|
+
if (!props) {
|
|
118
|
+
return {
|
|
119
|
+
name: header.name,
|
|
120
|
+
props: {},
|
|
121
|
+
children,
|
|
122
|
+
id: header.id,
|
|
123
|
+
legacy: header.legacy,
|
|
124
|
+
error: { code: "garden-yaml", message: "YAML 必须是一组键值,或条目列表。" },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (header.id && props.id == null) props.id = header.id;
|
|
128
|
+
const spec = CATALOG[header.name];
|
|
129
|
+
if (!knownName(header.name) || !spec) {
|
|
130
|
+
return {
|
|
131
|
+
name: header.name,
|
|
132
|
+
props,
|
|
133
|
+
children,
|
|
134
|
+
id: header.id ?? props.id ?? null,
|
|
135
|
+
legacy: header.legacy,
|
|
136
|
+
legacyLang: header.legacyLang,
|
|
137
|
+
error: { code: "garden-name", message: `未知名字 ${header.name}` },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const unknown = Object.keys(props).filter((key) => key !== "id" && !spec.keys.includes(key));
|
|
141
|
+
if (unknown.length) {
|
|
142
|
+
return {
|
|
143
|
+
name: header.name,
|
|
144
|
+
props,
|
|
145
|
+
children,
|
|
146
|
+
id: header.id ?? props.id ?? null,
|
|
147
|
+
legacy: header.legacy,
|
|
148
|
+
error: { code: "garden-yaml", message: `不支持的键 ${unknown.join("、")}`, unknown, allowed: spec.keys },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
name: header.name,
|
|
153
|
+
props,
|
|
154
|
+
children,
|
|
155
|
+
id: header.id ?? props.id ?? null,
|
|
156
|
+
legacy: Boolean(header.legacy),
|
|
157
|
+
legacyLang: header.legacyLang,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function fenceInfo(fence) {
|
|
162
|
+
if (!fence) return "";
|
|
163
|
+
if (fence.lang === "garden") return `garden${fence.meta ? ` ${fence.meta}` : ""}`.trim();
|
|
164
|
+
return fence.info || fence.lang;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function classifyFence(fence) {
|
|
168
|
+
const legacy = legacyOf(fence.lang);
|
|
169
|
+
if (legacy) return { kind: "legacy", ...legacy, fence };
|
|
170
|
+
if (fence.lang === "html" && /\bgarden\b/u.test(fence.meta)) {
|
|
171
|
+
return { kind: "legacy", lang: "html", name: "html", suggestion: "`html garden` 已改为 `garden html`", fence };
|
|
172
|
+
}
|
|
173
|
+
if (isGardenLang(fence.lang)) {
|
|
174
|
+
const block = parseGardenBlock(fenceInfo(fence), fence.body);
|
|
175
|
+
return { kind: "garden", block, fence };
|
|
176
|
+
}
|
|
177
|
+
if (fence.lang === "diff") return { kind: "diff", fence };
|
|
178
|
+
if (fence.lang === "mermaid") return { kind: "mermaid", fence };
|
|
179
|
+
return { kind: "code", fence };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const COMPONENT_LANGS = new Set(["mermaid", "diff", "embed", "widget", "intent", "ui", "ui-error", "html"]);
|
|
183
|
+
|
|
184
|
+
export function isFullCodeFence(fence) {
|
|
185
|
+
if (!fence || fence.kind && fence.kind !== "code") return false;
|
|
186
|
+
const lang = fence.lang ?? fence.fence?.lang;
|
|
187
|
+
if (!lang) return true;
|
|
188
|
+
if (COMPONENT_LANGS.has(lang) || isGardenLang(lang)) return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function blanksBetween(markdown, fromLine, toLine) {
|
|
193
|
+
const lines = String(markdown).split(/\r?\n/u);
|
|
194
|
+
for (let line = fromLine; line < toLine - 1; line += 1) {
|
|
195
|
+
if (lines[line]?.trim()) return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { CATALOG, GARDEN_NAMES, IDEA } from "./catalog.mjs";
|
|
2
|
+
|
|
3
|
+
function fenceLines(example) {
|
|
4
|
+
return example.replace(/\\`/gu, "`").split("\n");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function snippetBody(example) {
|
|
8
|
+
const lines = fenceLines(example);
|
|
9
|
+
const cursorAt = lines.findIndex((line) => line.includes("让读者"));
|
|
10
|
+
if (cursorAt === -1) return [...lines.slice(0, -1), "$0", lines.at(-1)];
|
|
11
|
+
return lines.map((line, index) => (index === cursorAt ? line.replace("让读者……;想让读者看出……", "${1:让读者……;想让读者看出……}") : line));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function vscodeSnippets() {
|
|
15
|
+
const snippets = {
|
|
16
|
+
"Garden 块": {
|
|
17
|
+
prefix: "garden",
|
|
18
|
+
body: ["```garden ${1|idea,card,stat,compare,timeline,chart,figure,html,interactive|}", "$0", "```"],
|
|
19
|
+
description: "选择 Garden 块:想法 / card / stat / compare / timeline / chart / figure / html / interactive",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
for (const trigger of IDEA.triggers) {
|
|
23
|
+
snippets[`Garden ${IDEA.label} ${trigger}`] = {
|
|
24
|
+
prefix: trigger,
|
|
25
|
+
body: snippetBody(IDEA.example),
|
|
26
|
+
description: IDEA.when,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
for (const name of GARDEN_NAMES) {
|
|
30
|
+
const spec = CATALOG[name];
|
|
31
|
+
for (const trigger of spec.triggers) {
|
|
32
|
+
snippets[`Garden ${spec.label} ${trigger}`] = {
|
|
33
|
+
prefix: trigger,
|
|
34
|
+
body: fenceLines(spec.example),
|
|
35
|
+
description: `${spec.when} 不该用:${spec.whenNot}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
snippets["Garden 代码与对比"] = {
|
|
40
|
+
prefix: "gdiff",
|
|
41
|
+
body: ["```${1:lang}", "$0", "```", "", "```diff", "", "```"],
|
|
42
|
+
description: "完整代码块,紧跟它的 diff。站点收成「代码 / 对比上一版」。",
|
|
43
|
+
};
|
|
44
|
+
return snippets;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function gardenBlocksMarkdown() {
|
|
48
|
+
const sections = [
|
|
49
|
+
["想法", IDEA],
|
|
50
|
+
...GARDEN_NAMES.map((name) => [name, CATALOG[name]]),
|
|
51
|
+
];
|
|
52
|
+
return [
|
|
53
|
+
"# Garden 块模板",
|
|
54
|
+
"",
|
|
55
|
+
"从这里复制。触发词和 VS Code 片段、macOS 文本替换是同一份目录生成的。",
|
|
56
|
+
"",
|
|
57
|
+
...sections.flatMap(([name, spec]) => [
|
|
58
|
+
`## ${name === "想法" ? "想法" : name} · ${spec.label}`,
|
|
59
|
+
"",
|
|
60
|
+
spec.when,
|
|
61
|
+
"",
|
|
62
|
+
`不该用:${spec.whenNot}`,
|
|
63
|
+
"",
|
|
64
|
+
spec.example,
|
|
65
|
+
"",
|
|
66
|
+
]),
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function xmlEscape(value) {
|
|
71
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function textReplacementPlist() {
|
|
75
|
+
const items = [
|
|
76
|
+
...IDEA.triggers.map((trigger) => ({ shortcut: `;${trigger}`, phrase: IDEA.example })),
|
|
77
|
+
...GARDEN_NAMES.flatMap((name) => CATALOG[name].triggers.map((trigger) => ({
|
|
78
|
+
shortcut: `;${trigger}`,
|
|
79
|
+
phrase: CATALOG[name].example,
|
|
80
|
+
}))),
|
|
81
|
+
];
|
|
82
|
+
const dicts = items.map((item) => ` <dict>
|
|
83
|
+
<key>phrase</key>
|
|
84
|
+
<string>${xmlEscape(item.phrase)}</string>
|
|
85
|
+
<key>shortcut</key>
|
|
86
|
+
<string>${xmlEscape(item.shortcut)}</string>
|
|
87
|
+
</dict>`);
|
|
88
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
89
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
90
|
+
<plist version="1.0">
|
|
91
|
+
<array>
|
|
92
|
+
${dicts.join("\n")}
|
|
93
|
+
</array>
|
|
94
|
+
</plist>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function pickerItems() {
|
|
99
|
+
return [
|
|
100
|
+
{ name: "", label: `想法 · ${IDEA.label}`, body: IDEA.example, when: IDEA.when, whenNot: IDEA.whenNot },
|
|
101
|
+
...GARDEN_NAMES.map((name) => ({
|
|
102
|
+
name,
|
|
103
|
+
label: `${name} · ${CATALOG[name].label}`,
|
|
104
|
+
body: CATALOG[name].example,
|
|
105
|
+
when: CATALOG[name].when,
|
|
106
|
+
whenNot: CATALOG[name].whenNot,
|
|
107
|
+
})),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const SEPARATOR = /丨||| \| /gu;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A title splits into exactly two lines when it contains one 丨, | or " | ".
|
|
5
|
+
* Anything else stays one line. The separator stays in the accessible name.
|
|
6
|
+
*/
|
|
7
|
+
export function splitTitle(title) {
|
|
8
|
+
const text = String(title ?? "");
|
|
9
|
+
const matches = [...text.matchAll(SEPARATOR)];
|
|
10
|
+
if (matches.length === 0) return { split: false, title: text, lines: [text], separator: null, issue: null };
|
|
11
|
+
if (matches.length > 1) {
|
|
12
|
+
return { split: false, title: text, lines: [text], separator: null, issue: "multiple" };
|
|
13
|
+
}
|
|
14
|
+
const match = matches[0];
|
|
15
|
+
const before = text.slice(0, match.index).trim();
|
|
16
|
+
const after = text.slice(match.index + match[0].length).trim();
|
|
17
|
+
const separator = match[0] === " | " ? " | " : match[0];
|
|
18
|
+
if (!before || !after) {
|
|
19
|
+
return { split: false, title: text, lines: [text], separator, issue: "empty" };
|
|
20
|
+
}
|
|
21
|
+
return { split: true, title: text, lines: [before, after], separator, issue: null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Estimated line width in hero-em units. CJK is 1, capitals 0.68, spaces 0.3. */
|
|
25
|
+
export function titleWidthUnits(line) {
|
|
26
|
+
return [...String(line ?? "")].reduce((sum, char) => {
|
|
27
|
+
if (/[\u2E80-\uFFFF]/u.test(char)) return sum + 1;
|
|
28
|
+
if (/[A-Z]/u.test(char)) return sum + 0.68;
|
|
29
|
+
if (char === " ") return sum + 0.3;
|
|
30
|
+
return sum + 0.58;
|
|
31
|
+
}, 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function titleLineScale(lines) {
|
|
35
|
+
const width = Math.max(...lines.map((line) => titleWidthUnits(line)), 0.01);
|
|
36
|
+
return Number(width.toFixed(2));
|
|
37
|
+
}
|