justskill-design 0.0.1 → 3.2.0

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.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
package/lib/render.mjs ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * lib/render.mjs — the document tree to what the canvas mounts.
3
+ *
4
+ * The renderer is shared by the canvas and by `export`. That is the whole point:
5
+ * what the user drags is what ships. A separate "preview renderer" is how a
6
+ * design tool ends up with two truths.
7
+ *
8
+ * Browser-safe on purpose. The canvas parses and renders the file itself, so the
9
+ * service never has to send a document tree over the wire and the two sides
10
+ * cannot disagree about what the file means.
11
+ *
12
+ * What the renderer does:
13
+ * - resolves `var(--token)` from the file's token block
14
+ * - inlines the token block into each page's scope
15
+ * - positions pages with the page-frame block and `data-canvas`
16
+ * - keeps every element's `data-id`, so the canvas can map a click to an op
17
+ *
18
+ * What it does not do:
19
+ * - it does not rewrite the design file. A mount is a view.
20
+ * - it does not inject canvas chrome into the design markup. The canvas puts
21
+ * its own styles in its own document, and each page gets a shadow root.
22
+ *
23
+ * Pure. No node: imports.
24
+ */
25
+
26
+ import { parse, parseDeclarations, getAttr, pagesOf, elementChildren, textOf, VOID_ELEMENTS, styleMapOf } from "./parse.mjs";
27
+ import { collectTokens } from "./validate.mjs";
28
+ import { CSS_ALLOWED, splitTopLevel } from "./table.mjs";
29
+ import { resolveTokens } from "./tokens.mjs";
30
+
31
+ // -- escaping ----------------------------------------------------------------
32
+
33
+ const TEXT_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
34
+ const ATTR_ESCAPES = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
35
+
36
+ export function escapeText(value) {
37
+ return String(value ?? "").replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
38
+ }
39
+
40
+ export function escapeAttr(value) {
41
+ return String(value ?? "").replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
42
+ }
43
+
44
+ // -- style -------------------------------------------------------------------
45
+
46
+ // `styleMapOf` is defined in `lib/parse.mjs`, next to `parseDeclarations`, and
47
+ // re-exported here because this module is where its callers look for it. It had
48
+ // a second copy of the declaration splitter beside it until the comment that
49
+ // justified the copy turned out to be false: this module already imports `parse`
50
+ // and `getAttr` from `parse.mjs`, so there was never a cycle to avoid.
51
+ export { styleMapOf };
52
+
53
+ /** Serialize a property map back to a `style` attribute value, in table order. */
54
+ export function styleStringOf(map) {
55
+ const entries = [...map.entries()].filter(([, value]) => value !== undefined && value !== null && value !== "");
56
+ entries.sort((a, b) => {
57
+ const ai = EMIT.indexOf(a[0]);
58
+ const bi = EMIT.indexOf(b[0]);
59
+ if (ai !== bi) return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
60
+ return a[0] < b[0] ? -1 : 1;
61
+ });
62
+ return entries.map(([p, v]) => `${p}:${v}`).join(";");
63
+ }
64
+
65
+ const EMIT = Object.keys(CSS_ALLOWED);
66
+
67
+ /**
68
+ * Replace `var(--name)` with the token's value, following the chain.
69
+ *
70
+ * `seen` is the chain, not the whole document: a name may be referenced twice in one
71
+ * value and only the second visit of the *same* chain is a cycle. Following a cycle
72
+ * is what `lib/export-html.mjs` did with its own copy of this function, and the
73
+ * report for a cyclic file was `REFUSED Maximum call stack size exceeded` — no line,
74
+ * no name, no hint. `docs/plan.md` §4.2 says the chain stops at 16 and that a cycle
75
+ * is refused; both stops are here so neither can be lost again.
76
+ */
77
+ // `resolveTokens` is defined in `lib/tokens.mjs`, with the depth limit and the
78
+ // cycle rule it obeys, and re-exported here because this module is where its
79
+ // callers look for it.
80
+ export { resolveTokens };
81
+
82
+ /** `data-canvas="x,y"`, or null when the page has no stated position. */
83
+ export function canvasPositionOf(node) {
84
+ const raw = getAttr(node, "data-canvas");
85
+ if (raw === null) return null;
86
+ const match = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(raw);
87
+ if (!match) return null;
88
+ return { x: Number(match[1]), y: Number(match[2]) };
89
+ }
90
+
91
+ function cloneTree(node) {
92
+ if (node.kind === "text") return { kind: "text", value: node.value, raw: node.raw };
93
+ if (node.kind === "comment" || node.kind === "doctype") return { kind: node.kind, raw: node.raw };
94
+ return {
95
+ kind: "element",
96
+ tag: node.tag,
97
+ rawTag: node.rawTag,
98
+ selfClosing: node.selfClosing,
99
+ attrs: (node.attrs || []).map((a) => ({ ...a })),
100
+ children: (node.children || []).map(cloneTree),
101
+ };
102
+ }
103
+
104
+ // -- the render model --------------------------------------------------------
105
+
106
+ /**
107
+ * Build the render model for a parsed document.
108
+ *
109
+ * @param {object} doc
110
+ * @param {object} options `{ resolveTokens: boolean }`
111
+ * @returns {{ pages: Array, tokens: Map, diagnostics: Array, pageFrame: string }}
112
+ */
113
+ export function renderModel(doc, options = {}) {
114
+ const { tokens } = collectTokens(doc);
115
+ const pages = [];
116
+ let index = 0;
117
+
118
+ for (const node of pagesOf(doc)) {
119
+ const id = getAttr(node, "data-id") || `page-${index}`;
120
+ const map = styleMapOf(node);
121
+ const width = map.get("width") || "auto";
122
+ const height = map.get("height") || "auto";
123
+
124
+ const copy = cloneTree(node);
125
+
126
+ // The page's own declarations move to the page box, and the section keeps
127
+ // only what positions it on the canvas.
128
+ const boxStyle = new Map();
129
+ for (const [property, value] of map) boxStyle.set(property, value);
130
+ boxStyle.set("position", "absolute");
131
+ const position = canvasPositionOf(node);
132
+ if (position) {
133
+ boxStyle.set("left", `${position.x}px`);
134
+ boxStyle.set("top", `${position.y}px`);
135
+ }
136
+ if (boxStyle.get("overflow") === undefined) boxStyle.set("overflow", "hidden");
137
+
138
+ pages.push({
139
+ id,
140
+ name: getAttr(node, "data-name") || id,
141
+ width,
142
+ height,
143
+ position,
144
+ index,
145
+ boxStyle: styleStringOf(boxStyle),
146
+ declarations: map,
147
+ // Built from the COPY, not from `node`: a mount never shares the tree the
148
+ // file was parsed into, so nothing the canvas renders can be mutated back
149
+ // into the document.
150
+ markup: copy.children.map((child) => renderNodeHtml(child, tokens, options)).join(""),
151
+ node: copy,
152
+ });
153
+ index += 1;
154
+ }
155
+
156
+ return {
157
+ pages,
158
+ tokens,
159
+ diagnostics: doc.diagnostics || [],
160
+ pageFrame: pageFrameRule(doc),
161
+ // Every rule the file states about itself, in document order. A shadow root
162
+ // cannot see the document's stylesheets, so the canvas has to put them in
163
+ // the page's own root — and it must put in exactly these, not a reset of its
164
+ // own invention. A canvas that invents rules renders a document the file
165
+ // does not describe.
166
+ styleText: styleTextOf(doc),
167
+ };
168
+ }
169
+
170
+ /** The text of every `<style>` block in the document, in order. */
171
+ export function styleTextOf(doc) {
172
+ return (doc.styleBlocks || []).map((block) => block.text ?? "").filter(Boolean).join("\n");
173
+ }
174
+
175
+ /**
176
+ * The same CSS with the `:root{…}` rule removed.
177
+ *
178
+ * `:root` matches no element inside a shadow root, so keeping the rule would
179
+ * define the tokens nowhere. The canvas declares them on `:host` instead; see
180
+ * `pageCss`.
181
+ */
182
+ export function withoutRootRule(cssText) {
183
+ const text = String(cssText ?? "");
184
+ let out = "";
185
+ let index = 0;
186
+ while (index < text.length) {
187
+ const at = text.indexOf(":root", index);
188
+ if (at === -1) {
189
+ out += text.slice(index);
190
+ break;
191
+ }
192
+ const open = text.indexOf("{", at);
193
+ if (open === -1) {
194
+ out += text.slice(index);
195
+ break;
196
+ }
197
+ let depth = 0;
198
+ let close = -1;
199
+ for (let i = open; i < text.length; i += 1) {
200
+ if (text[i] === "{") depth += 1;
201
+ else if (text[i] === "}") {
202
+ depth -= 1;
203
+ if (depth === 0) { close = i; break; }
204
+ }
205
+ }
206
+ if (close === -1) {
207
+ out += text.slice(index);
208
+ break;
209
+ }
210
+ out += text.slice(index, at);
211
+ index = close + 1;
212
+ }
213
+ return out.replace(/\n{2,}/g, "\n").trim();
214
+ }
215
+
216
+ /** The page-frame rule, as the file declares it. */
217
+ export function pageFrameRule(doc) {
218
+ for (const block of doc.styleBlocks || []) {
219
+ if (block.role === "page-frame" || block.role === "tokens+page-frame") {
220
+ const match = /section\s*\[\s*data-type\s*=\s*["']?page["']?\s*\]\s*\{([^}]*)\}/.exec(block.text);
221
+ if (match) return match[1].trim();
222
+ }
223
+ }
224
+ return "margin:0 0 24px";
225
+ }
226
+
227
+ /**
228
+ * Render one node as HTML for a mount.
229
+ *
230
+ * The element keeps its tag, its `data-*` attributes and its inline style. That
231
+ * is what makes the canvas able to map a clicked element straight back to its
232
+ * `data-id`, and what makes "what you see" the same bytes as "what you export".
233
+ */
234
+ export function renderNodeHtml(node, tokens, options = {}) {
235
+ if (node.kind === "text") return escapeText(node.value);
236
+ if (node.kind === "comment") return node.raw || "";
237
+ if (node.kind === "doctype") return "";
238
+ if (node.kind !== "element") return "";
239
+
240
+ const attrs = [];
241
+ for (const attr of node.attrs || []) {
242
+ if (attr.name === "style") continue;
243
+ if (attr.value === null || attr.value === undefined) {
244
+ attrs.push(attr.rawName || attr.name);
245
+ continue;
246
+ }
247
+ attrs.push(`${attr.rawName || attr.name}="${escapeAttr(attr.value)}"`);
248
+ }
249
+
250
+ const styleText = styleForMount(node, tokens, options);
251
+ if (styleText) attrs.push(`style="${escapeAttr(styleText)}"`);
252
+
253
+ const name = node.tag;
254
+ const head = attrs.length ? `<${name} ${attrs.join(" ")}>` : `<${name}>`;
255
+ const body = (node.children || []).map((child) => renderNodeHtml(child, tokens, options)).join("");
256
+ if (VOID_ELEMENTS.has(name)) {
257
+ // A void element cannot hold children. A hand-written file may still put
258
+ // them there, so they are emitted after the void element rather than
259
+ // dropped: a mount must show everything the file contains.
260
+ return `${head}${body}`;
261
+ }
262
+ return `${head}${body}</${name}>`;
263
+ }
264
+
265
+ /** The inline style a mounted element carries, with tokens resolved or kept. */
266
+ export function styleForMount(node, tokens, options = {}) {
267
+ const map = styleMapOf(node);
268
+ if (map.size === 0) return "";
269
+ if (options.resolveTokens === false) return styleStringOf(map);
270
+ const out = new Map();
271
+ for (const [property, value] of map) out.set(property, resolveTokens(value, tokens));
272
+ return styleStringOf(out);
273
+ }
274
+
275
+ /**
276
+ * The full CSS for one page's shadow root.
277
+ *
278
+ * A shadow root is what lets the canvas put a page's tokens and page-frame rule
279
+ * in exactly one scope, without either leaking into the canvas chrome. It is
280
+ * also why the canvas never has to write its own styles into the design file.
281
+ *
282
+ * The rules are the FILE's rules. A canvas that injects a reset of its own makes
283
+ * the file mean one thing on the canvas and another thing in a browser, and the
284
+ * author finds out at export time. So the only rule added here is what a shadow
285
+ * root needs and a document does not: `:host`.
286
+ */
287
+ export function pageCss(model, options = {}) {
288
+ const parts = [];
289
+ // `all: initial` first, and it is not decoration. An inherited property crosses
290
+ // a shadow boundary: the canvas chrome sets `line-height: 1.5` on the document
291
+ // so its own panels read well, the page's elements inherit it through the host,
292
+ // and every label on the canvas is a pixel taller than the same label in the
293
+ // file. Nothing in the page can override an inheritance it never declared.
294
+ //
295
+ // The two declarations after it are what a mount needs and a document gets for
296
+ // free: a block box, and a positioning context for the overlay.
297
+ parts.push(":host{all:initial;display:block;position:relative}");
298
+ // `:root` matches nothing in a shadow tree, so the token values go on the host
299
+ // element. The rest of the file's CSS is copied as it stands.
300
+ const declarations = [...(model.tokens ?? new Map()).entries()].map(([name, token]) => `${name}:${token.value}`).join(";");
301
+ if (declarations) parts.push(`:host{${declarations}}`);
302
+ const rules = withoutRootRule(model.styleText ?? "");
303
+ if (rules) parts.push(rules);
304
+ else parts.push(`section[data-type="page"]{${model.pageFrame || ""}}`);
305
+ return parts.join("\n");
306
+ }
307
+
308
+ /**
309
+ * What the canvas injects into a page's shadow root: the file's rules, plus the
310
+ * one rule that belongs to the canvas.
311
+ *
312
+ * Where a page sits is the canvas's business. `data-canvas` says where, and the
313
+ * page box is the paper the page is drawn on. A page's outer margin is the
314
+ * document flow's business: it is what separates one page from the next when the
315
+ * file is opened in a browser. On the canvas it is nothing, and left in place it
316
+ * escapes the mount and the paper grows by it — the chrome then paints a 24px
317
+ * band under every page whose own background is not white. No reader of the file
318
+ * ever sees that band and the file does not describe it. So the canvas states
319
+ * the one thing it owns, after the file's rules, and nothing else.
320
+ */
321
+ export function canvasMountCss(model, options = {}) {
322
+ return [pageCss(model, options), 'section[data-type="page"]{margin:0}'].join("\n");
323
+ }
324
+
325
+ /**
326
+ * The radius the canvas draws a page's paper with.
327
+ *
328
+ * The paper is the page's own shape. A page can round its own corners — the
329
+ * artboard can be a 24px-radius card rather than a screen — and the paper sits behind it.
330
+ * The chrome's own radius under a page with a bigger one leaves a white crescent
331
+ * in every corner, a shape the page never drew.
332
+ *
333
+ * The value is resolved here, not left to the page. The paper is drawn outside
334
+ * the shadow root, where the file's tokens are not defined: `var(--radius-lg)`
335
+ * would compute to nothing there and the corner would come back square.
336
+ */
337
+ export function pagePaperRadius(page, tokens) {
338
+ const declared = page?.declarations?.get?.("border-radius");
339
+ if (!declared) return "";
340
+ return resolveTokens(declared, tokens);
341
+ }
342
+
343
+ /** The page box style for a page that sits on the canvas. */
344
+ export function pageBoxStyle(page) {
345
+ const map = new Map();
346
+ for (const declaration of parseDeclarations(page.boxStyle)) map.set(declaration.property, declaration.value);
347
+ return styleStringOf(map);
348
+ }
349
+
350
+ /**
351
+ * Where a page sits, with a fallback when the file states no position.
352
+ * The canvas uses this when `data-canvas` is absent, so a fresh file is not a
353
+ * pile of pages at the same spot.
354
+ */
355
+ export function arrangePages(pages, options = {}) {
356
+ const gap = options.gap ?? 48;
357
+ const perRow = options.perRow ?? 4;
358
+ let nextX = 0;
359
+ let nextY = 0;
360
+ let rowHeight = 0;
361
+ let column = 0;
362
+ return pages.map((page) => {
363
+ if (page.position) return page.position;
364
+ const width = parseFloat(page.width) || 390;
365
+ const height = parseFloat(page.height) || 844;
366
+ if (column >= perRow) {
367
+ column = 0;
368
+ nextX = 0;
369
+ nextY += rowHeight + gap;
370
+ rowHeight = 0;
371
+ }
372
+ const position = { x: nextX, y: nextY };
373
+ nextX += width + gap;
374
+ rowHeight = Math.max(rowHeight, height);
375
+ column += 1;
376
+ return position;
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Font families a page uses that a browser does not already have.
382
+ *
383
+ * A design may name a family the machine does not have installed. Without a
384
+ * stylesheet the browser falls back, and the fallback has different metrics: the
385
+ * same text wraps at a different word and every box below it moves. That is not
386
+ * a cosmetic difference, it is a layout difference, and it is why the reference
387
+ * renders of an imported file disagreed with us while their total heights still
388
+ * matched.
389
+ *
390
+ * The list is what the file declares, resolved through its tokens. Nothing is
391
+ * appended: docs/format-v3.md §2.3 says a font family is written as given.
392
+ */
393
+ export function webfontFamilies(model) {
394
+ const found = new Map();
395
+ const families = (value) => splitTopLevel(String(value ?? ""), ",")
396
+ .map((part) => decodeQuoteEntities(String(part).trim()).replace(/^["']|["']$/g, "").trim())
397
+ .filter(Boolean);
398
+
399
+ const visit = (node) => {
400
+ if (node.kind === "element") {
401
+ const style = styleMapOf(node).get("font-family");
402
+ if (style) {
403
+ for (const family of families(resolveTokens(style, model.tokens))) {
404
+ if (!SYSTEM_FAMILIES.has(family.toLowerCase())) found.set(family, family);
405
+ }
406
+ }
407
+ }
408
+ for (const child of node.children ?? []) visit(child);
409
+ };
410
+ for (const page of model.pages) visit(page.node);
411
+ return [...found.values()].sort();
412
+ }
413
+
414
+ /**
415
+ * One stylesheet URL per family, asking only for the weights a page uses.
416
+ *
417
+ * A family with no weights is still requested: the browser needs the family
418
+ * before it can pick a weight at all. The canvas asks for the same fonts the
419
+ * export declares, because the two must render the same file at the same text
420
+ * widths — otherwise the author ships a layout they never saw.
421
+ */
422
+ export function webfontLinks(model, page) {
423
+ const families = webfontFamilies(model);
424
+ if (families.length === 0) return [];
425
+ const weights = new Set();
426
+ const visit = (node) => {
427
+ if (node?.kind === "element") {
428
+ const weight = styleMapOf(node).get("font-weight");
429
+ if (weight && /^\d{3}$/.test(weight)) weights.add(Number(weight));
430
+ }
431
+ for (const child of node?.children ?? []) visit(child);
432
+ };
433
+ // No page means the whole file. The canvas mounts every page at once, so it
434
+ // asks once for the union; asking per page would register the same family and
435
+ // weight from several subset files, and the browser would then have two
436
+ // `@font-face` rules for one face. `export` passes a page, because a
437
+ // deliverable should request only what it renders.
438
+ if (page) visit(page.node);
439
+ else for (const each of model.pages ?? []) visit(each.node);
440
+ const axis = [...weights].sort((a, b) => a - b);
441
+ return families.map((family) => {
442
+ const name = family.replace(/\s+/g, "+");
443
+ const query = axis.length ? `:wght@${axis.join(";")}` : "";
444
+ return `https://fonts.googleapis.com/css2?family=${name}${query}&display=swap`;
445
+ });
446
+ }
447
+
448
+ /**
449
+ * The families a browser is expected to have without being asked.
450
+ *
451
+ * The test is not "have I heard of it" but "does a stock machine ship it": the
452
+ * generic families, the three faces every desktop has, and the CJK faces the
453
+ * three desktop platforms install for their own scripts. `Inter`, `Roboto`,
454
+ * `Noto Sans` and `Noto Serif` are named by design tools and drawn from a
455
+ * webfont; a machine that has never installed one falls back, and the fallback
456
+ * has different metrics, so the design wraps somewhere else. Leaving them off
457
+ * this list is what makes the canvas and the deliverable ask for them, exactly as
458
+ * pen's own export does — offline the request fails and the fallback is what it
459
+ * always was, so nothing gets worse.
460
+ */
461
+ const SYSTEM_FAMILIES = new Set([
462
+ "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif",
463
+ "ui-sans-serif", "ui-monospace", "ui-rounded", "math", "emoji", "fangsong",
464
+ "-apple-system", "blinkmacsystemfont", "segoe ui", "helvetica", "helvetica neue",
465
+ "arial", "pingfang sc", "hiragino sans gb",
466
+ "microsoft yahei", "simsun", "simhei", "wenquanyi micro hei", "source han sans sc",
467
+ ]);
468
+
469
+ function decodeQuoteEntities(value) {
470
+ return value.replace(/&(?:quot|#0*34|#x0*22);/gi, '"').replace(/&(?:apos|#0*39|#x0*27);/gi, "'");
471
+ }
472
+
473
+ /** Convenience for a caller that only has text. */
474
+ export function renderText(text, options = {}) {
475
+ return renderModel(parse(text), options);
476
+ }
477
+
478
+ /**
479
+ * Elements in a page, for the layer tree.
480
+ *
481
+ * The inside of an `<svg>` is not listed. It is the opaque layer: `<path>`,
482
+ * `<circle>` and `<title>` are how one glyph draws itself, not nodes a user can
483
+ * select, and listing them buries the real structure under a hundred rows of
484
+ * geometry. The glyph's own `<svg>` is listed, with its name.
485
+ */
486
+ export function pageTree(node, depth = 0, out = [], insideSvg = false) {
487
+ for (const child of elementChildren(node)) {
488
+ if (insideSvg) continue;
489
+ const isGlyph = child.tag === "svg";
490
+ out.push({
491
+ id: getAttr(child, "data-id"),
492
+ type: getAttr(child, "data-type"),
493
+ tag: child.tag,
494
+ name: getAttr(child, "data-name"),
495
+ depth,
496
+ hidden: getAttr(child, "data-hidden") === "true",
497
+ childCount: isGlyph ? 0 : elementChildren(child).length,
498
+ text: textOf(child).trim().slice(0, 60),
499
+ });
500
+ pageTree(child, depth + 1, out, isGlyph);
501
+ }
502
+ return out;
503
+ }