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/parse.mjs ADDED
@@ -0,0 +1,997 @@
1
+ /**
2
+ * lib/parse.mjs — HTML text to a document tree.
3
+ *
4
+ * Two hard rules:
5
+ *
6
+ * 1. Nothing is rewritten. Every node carries `start`/`end` so the serializer
7
+ * can return the original bytes. Attributes keep their original order,
8
+ * quoting and case. Comments and unknown tags are kept as nodes.
9
+ * 2. Every node carries a source offset, so a diagnostic can name a line in
10
+ * the file. v1 could not do this, and its diagnostics were worse for it.
11
+ *
12
+ * This is a tolerant scanner, not a spec HTML5 parser. It does not build an
13
+ * implied tree (no implicit <tbody>, no foster parenting). It does close <p>
14
+ * and <li> implicitly, because that is what a hand-written file expects.
15
+ *
16
+ * Pure. No node: imports. Runs in the browser.
17
+ */
18
+
19
+ // -- tag tables --------------------------------------------------------------
20
+
21
+ export const VOID_ELEMENTS = new Set([
22
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link",
23
+ "meta", "param", "source", "track", "wbr",
24
+ ]);
25
+
26
+ export const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
27
+
28
+ /** Tags that an open element of the same name closes implicitly. */
29
+ const IMPLICIT_CLOSE = new Set(["p", "li", "dt", "dd", "option", "tr", "td", "th"]);
30
+
31
+ // -- entities ----------------------------------------------------------------
32
+
33
+ const NAMED_ENTITIES = {
34
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0",
35
+ copy: "\u00a9", reg: "\u00ae", trade: "\u2122", hellip: "\u2026",
36
+ mdash: "\u2014", ndash: "\u2013", lsquo: "\u2018", rsquo: "\u2019",
37
+ ldquo: "\u201c", rdquo: "\u201d", bull: "\u2022", middot: "\u00b7",
38
+ times: "\u00d7", divide: "\u00f7", plusmn: "\u00b1", deg: "\u00b0",
39
+ larr: "\u2190", rarr: "\u2192", uarr: "\u2191", darr: "\u2193",
40
+ harr: "\u2194", check: "\u2713", cross: "\u2717", star: "\u2605",
41
+ heart: "\u2665", dash: "\u2010", minus: "\u2212", le: "\u2264",
42
+ ge: "\u2265", ne: "\u2260", laquo: "\u00ab", raquo: "\u00bb",
43
+ sect: "\u00a7", para: "\u00b6", dagger: "\u2020", permil: "\u2030",
44
+ euro: "\u20ac", pound: "\u00a3", yen: "\u00a5", cent: "\u00a2",
45
+ ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009",
46
+ };
47
+
48
+ /**
49
+ * Decode entities in text.
50
+ * An unknown entity stays literal: guessing is worse than showing the raw text.
51
+ */
52
+ export function decodeEntities(text) {
53
+ if (!text.includes("&")) return text;
54
+ return text.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);/g, (whole, body) => {
55
+ if (body[0] === "#") {
56
+ const hex = body[1] === "x" || body[1] === "X";
57
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
58
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return whole;
59
+ try {
60
+ return String.fromCodePoint(code);
61
+ } catch {
62
+ return whole;
63
+ }
64
+ }
65
+ const value = NAMED_ENTITIES[body];
66
+ return value === undefined ? whole : value;
67
+ });
68
+ }
69
+
70
+ // -- diagnostics -------------------------------------------------------------
71
+
72
+ function diagnostic(severity, code, message, offset, hint) {
73
+ const d = { severity, code, message };
74
+ if (offset !== undefined && offset !== null) d.offset = offset;
75
+ if (hint) d.hint = hint;
76
+ return d;
77
+ }
78
+
79
+ // -- nodes -------------------------------------------------------------------
80
+
81
+ /**
82
+ * A frozen copy of a node as it was read.
83
+ *
84
+ * The serializer needs to compare "what the file says" against "what the tree
85
+ * now says". A caller edits the tree in place, so the original values would
86
+ * otherwise be overwritten and every edit would look like no change.
87
+ * `_raw` is that original, attached at parse time.
88
+ */
89
+ function snapshotAttrs(attrs) {
90
+ return attrs.map((a) => ({ name: a.name, rawName: a.rawName, value: a.value, quote: a.quote }));
91
+ }
92
+
93
+ function snapshotNode(node) {
94
+ const snap = {
95
+ kind: node.kind,
96
+ tag: node.tag,
97
+ selfClosing: Boolean(node.selfClosing),
98
+ attrs: snapshotAttrs(node.attrs || []),
99
+ children: [],
100
+ };
101
+ if (node.kind === "text") snap.value = node.value;
102
+ if (node.kind === "comment" || node.kind === "doctype") snap.raw = node.raw;
103
+ return snap;
104
+ }
105
+
106
+ /**
107
+ * Attach the parse-time snapshot.
108
+ *
109
+ * `_raw.children` is a COPY of the original child node objects, captured here
110
+ * and never mutated afterwards. Keeping the objects (not a reference to the
111
+ * live array) is what makes "one page added to three pages" visible: the copy
112
+ * still holds the three originals.
113
+ *
114
+ * A snapshot's own `_raw` is left undefined on purpose. That marks it as the
115
+ * file's state, so `nodeMatchesSnapshot` can tell it apart from a live node.
116
+ */
117
+ function seal(node) {
118
+ const snap = snapshotNode(node);
119
+ snap.children = (node.children || []).slice();
120
+ node._raw = snap;
121
+ return node;
122
+ }
123
+
124
+ function elementNode(tag, rawTag, attrs, selfClosing, start) {
125
+ return {
126
+ kind: "element",
127
+ tag,
128
+ rawTag,
129
+ attrs,
130
+ children: [],
131
+ selfClosing,
132
+ parent: null,
133
+ start,
134
+ end: start,
135
+ openStart: start,
136
+ openEnd: start,
137
+ closeStart: -1,
138
+ closeEnd: -1,
139
+ };
140
+ }
141
+
142
+ function textNode(value, raw, start) {
143
+ return { kind: "text", value, raw, parent: null, start, end: start + raw.length };
144
+ }
145
+
146
+ /**
147
+ * Seal a whole tree that a caller built by hand, bottom-up.
148
+ *
149
+ * Nothing in the tool edits an unsealed tree: without the snapshots the
150
+ * serializer cannot tell an edit from an untouched sibling, and it would write
151
+ * the entire document on every save. Callers that construct a document
152
+ * programmatically (the ops tests, the `.pen` reader, an importer) call this
153
+ * once and then behave exactly like a parsed file.
154
+ */
155
+ export function sealTreeFor(node) {
156
+ for (const child of node.children || []) {
157
+ if (child.kind === "element") sealTreeFor(child);
158
+ else if (!child._raw) seal(child);
159
+ }
160
+ if (node.kind === "element" && node.tag !== "#document") seal(node);
161
+ return node;
162
+ }
163
+
164
+ /** The original children sequence of a node, or the current one for a new node. */
165
+ export function originChildren(node) {
166
+ return node._raw && node._raw.children ? node._raw.children : (node.children || []);
167
+ }
168
+
169
+ /** True when a node has no stored original, i.e. it was created by an edit. */
170
+ export function isNewNode(node) {
171
+ return !node._raw;
172
+ }
173
+
174
+ // -- tags --------------------------------------------------------------------
175
+
176
+ /** Index just past the `>` that ends the tag that starts at `lt`, or -1. */
177
+ function findTagEnd(source, lt) {
178
+ let i = lt + 1;
179
+ let quote = null;
180
+ while (i < source.length) {
181
+ const ch = source[i];
182
+ if (quote) {
183
+ if (ch === quote) quote = null;
184
+ } else if (ch === '"' || ch === "'") {
185
+ quote = ch;
186
+ } else if (ch === ">") {
187
+ return i;
188
+ }
189
+ i += 1;
190
+ }
191
+ return -1;
192
+ }
193
+
194
+ /** Index of `</tag` at or after `from`, or -1. Only used for raw text elements. */
195
+ function indexOfCloseTag(source, tag, from) {
196
+ const needle = `</${tag}`;
197
+ const lower = source.toLowerCase();
198
+ const at = lower.indexOf(needle, from);
199
+ return at;
200
+ }
201
+
202
+ function isNameStart(ch) {
203
+ return /[A-Za-z_:]/.test(ch);
204
+ }
205
+
206
+ function isNameChar(ch) {
207
+ return /[A-Za-z0-9_:.-]/.test(ch);
208
+ }
209
+
210
+ /** Parse the inside of a start tag, after the tag name. */
211
+ /**
212
+ * Decode the structural character references in an attribute value.
213
+ *
214
+ * Only these four characters are decoded. The other 49 that `decodeEntities`
215
+ * knows about are left in place on purpose: they are not re-encoded on the way
216
+ * out, so decoding them would change the bytes of a file nobody edited, and byte
217
+ * fidelity is what the whole format rests on.
218
+ *
219
+ * The serializer escapes exactly these four back, so `&quot;` returns as
220
+ * `&quot;` and a data URI written with an entity is never double-encoded.
221
+ */
222
+ export function decodeAttrEntities(value) {
223
+ const text = String(value ?? "");
224
+ if (!text.includes("&")) return text;
225
+ return text.replace(/&(quot|apos|lt|gt|amp|#0*34|#0*39|#0*60|#0*62|#0*38|#x0*22|#x0*27|#x0*3c|#x0*3e|#x0*26);/gi, (whole) => {
226
+ const lower = whole.toLowerCase();
227
+ if (lower === "&quot;" || lower === "&#34;" || lower === "&#x22;") return '"';
228
+ if (lower === "&apos;" || lower === "&#39;" || lower === "&#x27;") return "'";
229
+ if (lower === "&lt;" || lower === "&#60;" || lower === "&#x3c;") return "<";
230
+ if (lower === "&gt;" || lower === "&#62;" || lower === "&#x3e;") return ">";
231
+ if (lower === "&amp;" || lower === "&#38;" || lower === "&#x26;") return "&";
232
+ return whole;
233
+ });
234
+ }
235
+
236
+ function parseAttributes(body, bodyStart, diagnostics) {
237
+ const attrs = [];
238
+ let i = 0;
239
+ while (i < body.length) {
240
+ while (i < body.length && /\s/.test(body[i])) i += 1;
241
+ if (i >= body.length) break;
242
+ if (body[i] === "/") { i += 1; continue; }
243
+
244
+ const attrStart = bodyStart + i;
245
+ const nameStart = i;
246
+ if (!isNameStart(body[i])) {
247
+ diagnostics.push(diagnostic(
248
+ "error", "syntax/bad-attribute",
249
+ `Attribute name cannot start with ${JSON.stringify(body[i])}.`,
250
+ attrStart,
251
+ "An attribute name starts with a letter, `_` or `:`.",
252
+ ));
253
+ // Skip to the next whitespace so the scanner makes progress.
254
+ while (i < body.length && !/\s/.test(body[i])) i += 1;
255
+ continue;
256
+ }
257
+ while (i < body.length && isNameChar(body[i])) i += 1;
258
+ const rawName = body.slice(nameStart, i);
259
+
260
+ let j = i;
261
+ while (j < body.length && /\s/.test(body[j])) j += 1;
262
+
263
+ if (body[j] !== "=") {
264
+ // Valueless attribute. Keep it as it stands: `data-hidden` is not `data-hidden=""`.
265
+ attrs.push({
266
+ kind: "attr", name: rawName.toLowerCase(), rawName, value: null, quote: null,
267
+ start: attrStart, end: attrStart + rawName.length,
268
+ attrStart, nameStart: attrStart, valueStart: -1, valueEnd: -1,
269
+ });
270
+ i = j;
271
+ continue;
272
+ }
273
+
274
+ let v = j + 1;
275
+ while (v < body.length && /\s/.test(body[v])) v += 1;
276
+ const valueStart = bodyStart + v;
277
+ let value = "";
278
+ let quote = null;
279
+ let valueEnd = valueStart;
280
+
281
+ if (body[v] === '"' || body[v] === "'") {
282
+ quote = body[v];
283
+ const close = body.indexOf(quote, v + 1);
284
+ if (close === -1) {
285
+ diagnostics.push(diagnostic(
286
+ "error", "syntax/unclosed-attribute",
287
+ `Attribute ${rawName} has an opening ${quote} with no closing ${quote}.`,
288
+ attrStart,
289
+ "Close the quoted value.",
290
+ ));
291
+ value = decodeAttrEntities(body.slice(v + 1));
292
+ valueEnd = bodyStart + body.length;
293
+ i = body.length;
294
+ } else {
295
+ // A quoted attribute value is fully decoded, structural entities
296
+ // included. The serializer escapes exactly these characters back, so
297
+ // `&quot;` returns as `&quot;` and a data URI written with an entity is
298
+ // not double-encoded on the next write.
299
+ value = decodeAttrEntities(body.slice(v + 1, close));
300
+ valueEnd = bodyStart + close;
301
+ i = close + 1;
302
+ }
303
+ } else {
304
+ let k = v;
305
+ while (k < body.length && !/[\s]/.test(body[k])) k += 1;
306
+ value = decodeAttrEntities(body.slice(v, k));
307
+ valueEnd = bodyStart + k;
308
+ i = k;
309
+ }
310
+
311
+ attrs.push({
312
+ kind: "attr", name: rawName.toLowerCase(), rawName, value, quote,
313
+ start: attrStart, end: valueEnd + (quote ? 1 : 0),
314
+ attrStart, nameStart: attrStart, valueStart, valueEnd,
315
+ });
316
+ }
317
+ return attrs;
318
+ }
319
+
320
+ function pushText(parent, source, from, to, diagnostics) {
321
+ if (to <= from) return;
322
+ const raw = source.slice(from, to);
323
+ const node = seal(textNode(decodeEntities(raw), raw, from));
324
+ node.parent = parent;
325
+ parent.children.push(node);
326
+ return node;
327
+ }
328
+
329
+ // -- the document ------------------------------------------------------------
330
+
331
+ /**
332
+ * Parse HTML text into a document model.
333
+ * @param {string} text
334
+ * @returns {object} document
335
+ */
336
+ export function parse(text) {
337
+ const source = String(text);
338
+ const diagnostics = [];
339
+ const doc = {
340
+ kind: "document",
341
+ source,
342
+ children: [],
343
+ doctype: null,
344
+ html: null,
345
+ head: null,
346
+ body: null,
347
+ styleBlocks: [],
348
+ diagnostics,
349
+ };
350
+
351
+ // A virtual root: the document, so that top-level nodes have a parent.
352
+ const root = { kind: "element", tag: "#document", rawTag: "#document", attrs: [], children: [], parent: null, start: 0, end: source.length, openStart: 0, openEnd: 0, closeStart: -1, closeEnd: -1, selfClosing: false };
353
+ const stack = [root];
354
+ const current = () => stack[stack.length - 1];
355
+
356
+ const closeAt = (depth, closeStart, closeEnd) => {
357
+ for (let d = stack.length - 1; d > depth; d -= 1) {
358
+ const el = stack[d];
359
+ el.end = el.openEnd;
360
+ diagnostics.push(diagnostic(
361
+ "warning", "syntax/unclosed-element",
362
+ `<${el.tag}> is not closed. It ends where its parent ends.`,
363
+ el.start,
364
+ `Add </${el.tag}> after its content.`,
365
+ ));
366
+ }
367
+ stack.length = depth + 1;
368
+ const el = stack[depth];
369
+ el.closeStart = closeStart;
370
+ el.closeEnd = closeEnd;
371
+ el.end = closeEnd;
372
+ stack.pop();
373
+ };
374
+
375
+ let i = 0;
376
+ while (i < source.length) {
377
+ const lt = source.indexOf("<", i);
378
+ if (lt === -1) {
379
+ pushText(current(), source, i, source.length, diagnostics);
380
+ break;
381
+ }
382
+ if (lt > i) pushText(current(), source, i, lt, diagnostics);
383
+
384
+ // Comment.
385
+ if (source.startsWith("<!--", lt)) {
386
+ const close = source.indexOf("-->", lt + 4);
387
+ const end = close === -1 ? source.length : close + 3;
388
+ if (close === -1) {
389
+ diagnostics.push(diagnostic("error", "syntax/unclosed-comment", "Comment has no `-->`.", lt, "Add `-->`."));
390
+ }
391
+ const node = seal({ kind: "comment", raw: source.slice(lt, end), parent: current(), start: lt, end });
392
+ current().children.push(node);
393
+ i = end;
394
+ continue;
395
+ }
396
+
397
+ // Doctype, CDATA and processing instructions.
398
+ if (source.startsWith("<!", lt) || source.startsWith("<?", lt)) {
399
+ const close = source.indexOf(">", lt);
400
+ const end = close === -1 ? source.length : close + 1;
401
+ const raw = source.slice(lt, end);
402
+ const node = seal({ kind: "doctype", raw, parent: current(), start: lt, end });
403
+ current().children.push(node);
404
+ if (doc.doctype === null && /^<!doctype/i.test(raw)) doc.doctype = node;
405
+ i = end;
406
+ continue;
407
+ }
408
+
409
+ // End tag.
410
+ if (source.startsWith("</", lt)) {
411
+ const close = source.indexOf(">", lt);
412
+ if (close === -1) {
413
+ pushText(current(), source, lt, source.length, diagnostics);
414
+ break;
415
+ }
416
+ const raw = source.slice(lt + 2, close);
417
+ const tag = raw.trim().split(/[\s/]/)[0].toLowerCase();
418
+ let depth = -1;
419
+ for (let d = stack.length - 1; d >= 1; d -= 1) {
420
+ if (stack[d].tag === tag) { depth = d; break; }
421
+ }
422
+ if (depth === -1) {
423
+ diagnostics.push(diagnostic(
424
+ "error", "syntax/stray-end-tag",
425
+ `</${tag}> closes nothing. No <${tag}> is open here.`,
426
+ lt,
427
+ `Remove </${tag}>, or add the matching <${tag}> before it.`,
428
+ ));
429
+ } else {
430
+ closeAt(depth, lt, close + 1);
431
+ }
432
+ i = close + 1;
433
+ continue;
434
+ }
435
+
436
+ // Start tag.
437
+ if (!isNameStart(source[lt + 1] || "")) {
438
+ // `<=`, `<<`, a lone `<`. It is text.
439
+ pushText(current(), source, lt, lt + 1, diagnostics);
440
+ i = lt + 1;
441
+ continue;
442
+ }
443
+
444
+ const end = findTagEnd(source, lt);
445
+ if (end === -1) {
446
+ const nextLt = source.indexOf("<", lt + 1);
447
+ const stop = nextLt === -1 ? source.length : nextLt;
448
+ const inside = source.slice(lt, stop);
449
+ if (/^\s*$/.test(inside) || !inside.includes("<")) {
450
+ diagnostics.push(diagnostic(
451
+ "error", "syntax/unclosed-tag",
452
+ `The tag that starts here has no \`>\`.`,
453
+ lt,
454
+ "Add `>` to close the start tag.",
455
+ ));
456
+ pushText(current(), source, lt, source.length, diagnostics);
457
+ break;
458
+ }
459
+ // There is another `<` inside: treat this one as text and keep scanning.
460
+ pushText(current(), source, lt, lt + 1, diagnostics);
461
+ i = lt + 1;
462
+ continue;
463
+ }
464
+
465
+ const inner = source.slice(lt + 1, end);
466
+ const selfClosing = inner.endsWith("/");
467
+ const body = selfClosing ? inner.slice(0, -1) : inner;
468
+ const nameMatch = /^[A-Za-z_:][A-Za-z0-9_:.-]*/.exec(body);
469
+ const rawTag = nameMatch ? nameMatch[0] : "";
470
+ const tag = rawTag.toLowerCase();
471
+ const attrs = parseAttributes(body.slice(rawTag.length), lt + 1 + rawTag.length, diagnostics);
472
+
473
+ // Close an open element of the same name for these tags.
474
+ if (IMPLICIT_CLOSE.has(tag) && current().tag === tag) {
475
+ closeAt(stack.length - 1, -1, -1);
476
+ }
477
+
478
+ const el = elementNode(tag, rawTag, attrs, selfClosing, lt);
479
+ el.parent = current();
480
+ el.openEnd = end + 1;
481
+ el.end = end + 1;
482
+ current().children.push(el);
483
+
484
+ if (VOID_ELEMENTS.has(tag) || selfClosing) {
485
+ i = end + 1;
486
+ continue;
487
+ }
488
+
489
+ if (RAW_TEXT_ELEMENTS.has(tag)) {
490
+ const close = indexOfCloseTag(source, tag, end + 1);
491
+ if (close === -1) {
492
+ const raw = source.slice(end + 1);
493
+ const child = seal(textNode(tag === "style" ? raw : decodeEntities(raw), raw, end + 1));
494
+ child.parent = el;
495
+ el.children.push(child);
496
+ el.closeStart = -1;
497
+ el.closeEnd = -1;
498
+ el.end = source.length;
499
+ diagnostics.push(diagnostic(
500
+ "warning", "syntax/unclosed-element",
501
+ `<${tag}> is not closed.`,
502
+ lt,
503
+ `Add </${tag}>.`,
504
+ ));
505
+ i = source.length;
506
+ continue;
507
+ }
508
+ const raw = source.slice(end + 1, close);
509
+ if (raw.length) {
510
+ const child = seal(textNode(tag === "style" ? raw : decodeEntities(raw), raw, end + 1));
511
+ child.parent = el;
512
+ el.children.push(child);
513
+ }
514
+ const closeEnd = close + `</${tag}>`.length;
515
+ el.closeStart = close;
516
+ el.closeEnd = closeEnd;
517
+ el.end = closeEnd;
518
+ i = closeEnd;
519
+ continue;
520
+ }
521
+
522
+ stack.push(el);
523
+ i = end + 1;
524
+ }
525
+
526
+ // Anything still open at the end.
527
+ for (let d = stack.length - 1; d >= 1; d -= 1) {
528
+ const el = stack[d];
529
+ el.end = source.length;
530
+ el.closeStart = -1;
531
+ el.closeEnd = -1;
532
+ diagnostics.push(diagnostic(
533
+ "warning", "syntax/unclosed-element",
534
+ `<${el.tag}> is not closed. It ends at the end of the file.`,
535
+ el.start,
536
+ `Add </${el.tag}> after its content.`,
537
+ ));
538
+ }
539
+
540
+ // Seal every node bottom-up: each element stores its original children
541
+ // sequence, so the serializer can tell an edit from an untouched sibling.
542
+ const sealTree = (node) => {
543
+ for (const child of node.children) {
544
+ if (child.kind === "element") sealTree(child);
545
+ else if (!child._raw) seal(child);
546
+ }
547
+ if (node.kind === "element" && node.tag !== "#document") seal(node);
548
+ };
549
+ sealTree(root);
550
+ if (!root._raw) seal(root);
551
+
552
+ doc.children = root.children;
553
+ for (const child of doc.children) child.parent = null;
554
+ // The document needs a snapshot too: a new page is appended to
555
+ // `doc.children`, and the serializer compares against this list.
556
+ doc._raw = snapshotNode(doc);
557
+ doc._raw.children = doc.children.slice();
558
+ doc.html = doc.children.find((n) => n.kind === "element" && n.tag === "html") || null;
559
+ const htmlChildren = doc.html ? doc.html.children.filter((n) => n.kind === "element") : doc.children.filter((n) => n.kind === "element");
560
+ doc.head = htmlChildren.find((n) => n.tag === "head") || null;
561
+ doc.body = htmlChildren.find((n) => n.tag === "body") || null;
562
+ doc.styleBlocks = collectStyleBlocks(doc);
563
+ return doc;
564
+ }
565
+
566
+ // -- style blocks ------------------------------------------------------------
567
+
568
+ /**
569
+ * `:root{…}` is the token block. `section[data-type="page"]{…}` is the page-frame
570
+ * block. Both must survive. v1 kept only the last block it read, which silently
571
+ * emptied the token table whenever a file had both.
572
+ */
573
+ export function styleBlockRole(cssText) {
574
+ const roles = [];
575
+ if (/:root\s*\{/.test(cssText)) roles.push("tokens");
576
+ if (/section\s*\[\s*data-type\s*=\s*["']?page["']?\s*\]/.test(cssText)) roles.push("page-frame");
577
+ if (roles.length === 0) return "unknown";
578
+ return roles.length === 2 ? "tokens+page-frame" : roles[0];
579
+ }
580
+
581
+ export function collectStyleBlocks(doc) {
582
+ const blocks = [];
583
+ const visit = (node, inHead) => {
584
+ for (const child of node.children || []) {
585
+ if (child.kind !== "element") continue;
586
+ if (child.tag === "style") {
587
+ const textNode = child.children.find((c) => c.kind === "text");
588
+ const text = textNode ? textNode.raw : "";
589
+ const role = styleBlockRole(text);
590
+ const block = {
591
+ kind: "style-block",
592
+ role,
593
+ inHead,
594
+ node: child,
595
+ text,
596
+ textStart: textNode ? textNode.start : child.openEnd,
597
+ textEnd: textNode ? textNode.end : child.openEnd,
598
+ declarations: [],
599
+ rules: [],
600
+ rootSpans: [],
601
+ };
602
+ if (role === "tokens" || role === "tokens+page-frame") {
603
+ block.declarations = parseRootDeclarations(text);
604
+ block.rootSpans = rootSpans(text);
605
+ }
606
+ blocks.push(block);
607
+ }
608
+ visit(child, inHead || child.tag === "head");
609
+ }
610
+ };
611
+ visit(doc, false);
612
+ return blocks;
613
+ }
614
+
615
+ /** All `:root` declarations, merged across every head style block. */
616
+ export function rootDeclarations(blocks) {
617
+ const out = [];
618
+ for (const block of blocks) {
619
+ if (block.role === "tokens" || block.role === "tokens+page-frame") out.push(...block.declarations);
620
+ }
621
+ return out;
622
+ }
623
+
624
+ /**
625
+ * The `:root{…}` rules in one style block's text, as spans.
626
+ *
627
+ * Both sides need these. The read side (`parseRootDeclarations`) collects the
628
+ * declarations out of the braces. The write side — a `set-token` op — has to put a
629
+ * new declaration *inside* the same braces, because a declaration appended after
630
+ * the closing `}` is not a token at all and the file would lose it on the next
631
+ * normalize. One `:root` finder, so the two sides cannot disagree about which
632
+ * braces they mean.
633
+ *
634
+ * @returns {Array<{open:number, close:number, bodyStart:number, bodyEnd:number}>}
635
+ */
636
+ export function rootSpans(cssText) {
637
+ const text = String(cssText ?? "");
638
+ const out = [];
639
+ const re = /:root\s*\{/g;
640
+ let m;
641
+ while ((m = re.exec(text)) !== null) {
642
+ const open = m.index + m[0].length - 1;
643
+ const close = findMatchingBrace(text, open);
644
+ if (close === -1) continue;
645
+ out.push({ open, close, bodyStart: open + 1, bodyEnd: close });
646
+ re.lastIndex = close;
647
+ }
648
+ return out;
649
+ }
650
+
651
+ /**
652
+ * Every `:root{…}` declaration in one style block's text.
653
+ *
654
+ * Offsets are relative to `cssText`, and `start`/`end` are the same pair
655
+ * `parseStyleDeclarations` in `lib/ops.mjs` reports, so a caller can drop a
656
+ * declaration without a second parser. `textStart`/`textEnd` stay as the pair that
657
+ * is relative to the `:root{}` body, which is the shape format-v3 §4.2 documents.
658
+ *
659
+ * This function used to report only `textStart`/`textEnd` plus a `blockOffset` that
660
+ * nothing read. `collectTokens` then read `decl.start`, which did not exist, so
661
+ * **every token diagnostic carried `offset: undefined`** — a warning a reader could
662
+ * not locate in the file.
663
+ */
664
+ export function parseRootDeclarations(cssText) {
665
+ const text = String(cssText ?? "");
666
+ const out = [];
667
+ for (const span of rootSpans(text)) {
668
+ for (const decl of parseDeclarations(text.slice(span.bodyStart, span.bodyEnd))) {
669
+ // `parseDeclarations` reports the whole piece, whitespace included, because
670
+ // §4.2 fixes that shape. `start`/`end` are the declaration itself, so a
671
+ // diagnostic offset points at the token and not at the space in front of it.
672
+ const piece = text.slice(span.bodyStart + decl.textStart, span.bodyStart + decl.textEnd);
673
+ out.push({
674
+ ...decl,
675
+ start: span.bodyStart + decl.textStart + (piece.length - piece.trimStart().length),
676
+ end: span.bodyStart + decl.textEnd - (piece.length - piece.trimEnd().length),
677
+ });
678
+ }
679
+ }
680
+ return out;
681
+ }
682
+
683
+ function findMatchingBrace(text, open) {
684
+ let depth = 0;
685
+ let quote = null;
686
+ let comment = false;
687
+ for (let i = open; i < text.length; i += 1) {
688
+ const ch = text[i];
689
+ if (comment) {
690
+ if (ch === "*" && text[i + 1] === "/") { comment = false; i += 1; }
691
+ continue;
692
+ }
693
+ if (quote) {
694
+ if (ch === "\\") { i += 1; continue; }
695
+ if (ch === quote) quote = null;
696
+ continue;
697
+ }
698
+ if (ch === "/" && text[i + 1] === "*") { comment = true; i += 1; continue; }
699
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
700
+ if (ch === "{") depth += 1;
701
+ else if (ch === "}") {
702
+ depth -= 1;
703
+ if (depth === 0) return i;
704
+ }
705
+ }
706
+ return -1;
707
+ }
708
+
709
+ // -- CSS declarations --------------------------------------------------------
710
+
711
+ /**
712
+ * Split a declaration list into declarations.
713
+ *
714
+ * The split must NOT be `text.split(";")`. A `;` inside a quoted string or
715
+ * inside parentheses is part of the value:
716
+ *
717
+ * background-image: url("data:image/svg+xml;base64,AAA")
718
+ *
719
+ * Splitting on `;` turns that into `url("data:image/svg+xml` and loses the
720
+ * image. v1 shipped that defect. So this walks the text and tracks four states:
721
+ * single quotes, double quotes, parenthesis depth, and comment.
722
+ *
723
+ * @returns {Array<{property:string, rawProperty:string, value:string, rawValue:string, textStart:number, textEnd:number}>}
724
+ */
725
+ export function parseDeclarations(text) {
726
+ const source = String(text);
727
+ const out = [];
728
+ let i = 0;
729
+ let start = 0;
730
+
731
+ const emit = (from, to) => {
732
+ const raw = source.slice(from, to);
733
+ if (!raw.trim()) return;
734
+ // Find the first `:` that is outside every quoted or parenthesised region.
735
+ let depth = 0;
736
+ let quote = null;
737
+ let colon = -1;
738
+ for (let k = 0; k < raw.length; k += 1) {
739
+ const ch = raw[k];
740
+ if (quote) {
741
+ if (ch === "\\") { k += 1; continue; }
742
+ if (ch === quote) quote = null;
743
+ continue;
744
+ }
745
+ if (ch === "/" && raw[k + 1] === "*") {
746
+ const end = raw.indexOf("*/", k + 2);
747
+ k = end === -1 ? raw.length : end + 1;
748
+ continue;
749
+ }
750
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
751
+ if (ch === "(") { depth += 1; continue; }
752
+ if (ch === ")") { depth = Math.max(0, depth - 1); continue; }
753
+ if (ch === ":" && depth === 0) { colon = k; break; }
754
+ }
755
+ if (colon === -1) return;
756
+ const rawProperty = raw.slice(0, colon);
757
+ const rawValue = raw.slice(colon + 1);
758
+ out.push({
759
+ property: rawProperty.trim().toLowerCase(),
760
+ rawProperty,
761
+ value: stripCssComments(rawValue).trim(),
762
+ rawValue,
763
+ textStart: from,
764
+ textEnd: to,
765
+ });
766
+ };
767
+
768
+ let quote = null;
769
+ let comment = false;
770
+ let depth = 0;
771
+ while (i < source.length) {
772
+ const ch = source[i];
773
+ if (comment) {
774
+ if (ch === "*" && source[i + 1] === "/") { comment = false; i += 2; continue; }
775
+ i += 1;
776
+ continue;
777
+ }
778
+ if (quote) {
779
+ if (ch === "\\") { i += 2; continue; }
780
+ if (ch === quote) quote = null;
781
+ i += 1;
782
+ continue;
783
+ }
784
+ if (ch === "/" && source[i + 1] === "*") { comment = true; i += 2; continue; }
785
+ if (ch === '"' || ch === "'") { quote = ch; i += 1; continue; }
786
+ if (ch === "(") { depth += 1; i += 1; continue; }
787
+ if (ch === ")") { depth = Math.max(0, depth - 1); i += 1; continue; }
788
+ if (ch === ";" && depth === 0) {
789
+ emit(start, i);
790
+ start = i + 1;
791
+ i += 1;
792
+ continue;
793
+ }
794
+ i += 1;
795
+ }
796
+ emit(start, source.length);
797
+
798
+ if (quote) {
799
+ const last = out[out.length - 1];
800
+ if (last) last.unclosedString = true;
801
+ }
802
+ return out;
803
+ }
804
+
805
+ export function stripCssComments(value) {
806
+ if (!value.includes("/*")) return value;
807
+ return value.replace(/\/\*[\s\S]*?\*\//g, " ");
808
+ }
809
+
810
+ /**
811
+ * A declaration list, remembered by the text it was read from.
812
+ *
813
+ * Splitting a `style` attribute is pure: the same text always gives the same
814
+ * declarations. The same text is also asked for many times — `validateDesign` on
815
+ * the reference file splits 1389 declarations into 13889 calls, 13.9ms of 42ms,
816
+ * because the measurement, the normalizer and the checks each walk the tree and
817
+ * each reads the same attributes. The file has 1440 `style` attributes and 361
818
+ * distinct ones, so the work can be cut by three quarters.
819
+ *
820
+ * `lib/render.mjs` used to keep a cache like this beside its own copy of the
821
+ * splitter. The copy is gone; the cache belongs here, next to the function it
822
+ * memoises, so there is one of each. It was dropped silently with the copy, and
823
+ * that is where the extra milliseconds came back from.
824
+ *
825
+ * The declarations are frozen, and so is the array that holds them. A caller that
826
+ * mutates what it is handed would poison every later read of the same text, and a
827
+ * frozen value turns that into a loud error in the test that does it rather than a
828
+ * wrong measurement somewhere else.
829
+ */
830
+ const DECLARATION_CACHE_LIMIT = 5000;
831
+ const declarationCache = new Map();
832
+
833
+ function parseDeclarationsCached(text) {
834
+ const hit = declarationCache.get(text);
835
+ if (hit !== undefined) return hit;
836
+ const out = Object.freeze(parseDeclarations(text).map((declaration) => Object.freeze(declaration)));
837
+ // A long session on a generated file can produce unbounded distinct styles.
838
+ // Emptying the cache is cheaper than tracking recency and costs one re-parse.
839
+ if (declarationCache.size >= DECLARATION_CACHE_LIMIT) declarationCache.clear();
840
+ declarationCache.set(text, out);
841
+ return out;
842
+ }
843
+
844
+ /**
845
+ * The `style` attribute of an element, as a property to value map. The last
846
+ * declaration of a property wins.
847
+ *
848
+ * This lives here, next to `parseDeclarations`, because it is a projection of
849
+ * that function and nothing more. It used to live in `lib/render.mjs` beside a
850
+ * second copy of the splitter, justified by a comment that claimed reaching back
851
+ * into this module "would be a cycle" — but `render.mjs` already imports `parse`
852
+ * and `getAttr` from here, so the cycle did not exist. Two splitters meant two
853
+ * chances to disagree on a `url("data:…;base64,…")`.
854
+ */
855
+ export function styleMapOf(node) {
856
+ const raw = getAttr(node, "style");
857
+ const out = new Map();
858
+ if (raw === null) return out;
859
+ for (const declaration of parseDeclarationsCached(raw)) {
860
+ if (declaration.property) out.set(declaration.property, declaration.value);
861
+ }
862
+ return out;
863
+ }
864
+
865
+ /** True when the declaration list contains an unclosed quote or parenthesis. */
866
+ export function declarationListIsBalanced(text) {
867
+ let quote = null;
868
+ let depth = 0;
869
+ let comment = false;
870
+ for (let i = 0; i < text.length; i += 1) {
871
+ const ch = text[i];
872
+ if (comment) {
873
+ if (ch === "*" && text[i + 1] === "/") { comment = false; i += 1; }
874
+ continue;
875
+ }
876
+ if (quote) {
877
+ if (ch === "\\") { i += 1; continue; }
878
+ if (ch === quote) quote = null;
879
+ continue;
880
+ }
881
+ if (ch === "/" && text[i + 1] === "*") { comment = true; i += 1; continue; }
882
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
883
+ if (ch === "(") depth += 1;
884
+ else if (ch === ")") depth -= 1;
885
+ }
886
+ return quote === null && depth === 0;
887
+ }
888
+
889
+ // -- tree helpers ------------------------------------------------------------
890
+
891
+ export function walk(node, visit, depth = 0) {
892
+ visit(node, depth);
893
+ for (const child of node.children || []) {
894
+ if (child.kind === "element") walk(child, visit, depth + 1);
895
+ }
896
+ }
897
+
898
+ export function findById(doc, id) {
899
+ let found = null;
900
+ walk(doc, (node) => {
901
+ if (found || node.kind !== "element") return;
902
+ const attr = getAttr(node, "data-id");
903
+ if (attr === id) found = node;
904
+ });
905
+ return found;
906
+ }
907
+
908
+ export function getAttr(node, name) {
909
+ if (!node || !node.attrs) return null;
910
+ const lower = name.toLowerCase();
911
+ for (const attr of node.attrs) {
912
+ if (attr.name === lower) return attr.value === null ? "" : attr.value;
913
+ }
914
+ return null;
915
+ }
916
+
917
+ export function hasAttr(node, name) {
918
+ if (!node || !node.attrs) return false;
919
+ const lower = name.toLowerCase();
920
+ return node.attrs.some((a) => a.name === lower);
921
+ }
922
+
923
+ export function getAttrNode(node, name) {
924
+ if (!node || !node.attrs) return null;
925
+ const lower = name.toLowerCase();
926
+ return node.attrs.find((a) => a.name === lower) || null;
927
+ }
928
+
929
+ /** Give a caller-built document the snapshot the serializer requires. */
930
+ export function prepareBuiltDocument(doc, children) {
931
+ const tree = children || doc.children || [];
932
+ const root = {
933
+ kind: "element", tag: "#document", rawTag: "#document", attrs: [], children: tree,
934
+ parent: null, start: 0, end: doc.source ? doc.source.length : 0,
935
+ openStart: 0, openEnd: 0, closeStart: -1, closeEnd: -1, selfClosing: false,
936
+ };
937
+ sealTreeFor(root);
938
+ if (!root._raw) seal(root);
939
+ doc.children = root.children;
940
+ doc._raw = snapshotNode(doc);
941
+ doc._raw.children = doc.children.slice();
942
+ return doc;
943
+ }
944
+
945
+ /** The `style` attribute, parsed into declarations. */
946
+ export function styleOf(node) {
947
+ const raw = getAttr(node, "style");
948
+ if (raw === null) return [];
949
+ return parseDeclarations(raw);
950
+ }
951
+
952
+ /** Every `data-` attribute on a node, in source order. */
953
+ export function dataAttrs(node) {
954
+ return (node.attrs || []).filter((a) => a.name.startsWith("data-"));
955
+ }
956
+
957
+ /** Direct element children. */
958
+ export function elementChildren(node) {
959
+ return (node.children || []).filter((c) => c.kind === "element");
960
+ }
961
+
962
+ /** The concatenated text of a node's text children. */
963
+ export function textOf(node) {
964
+ return (node.children || []).filter((c) => c.kind === "text").map((c) => c.value).join("");
965
+ }
966
+
967
+ /** A human path such as `body > section > div > p`. */
968
+ export function nodePath(node) {
969
+ const parts = [];
970
+ let current = node;
971
+ while (current && current.kind === "element" && current.tag !== "#document") {
972
+ parts.unshift(current.tag);
973
+ current = current.parent;
974
+ }
975
+ return parts.join(" > ");
976
+ }
977
+
978
+ /** Page elements in document order. */
979
+ export function pagesOf(doc) {
980
+ const pages = [];
981
+ walk(doc, (node) => {
982
+ if (node.kind === "element" && node.tag === "section" && getAttr(node, "data-type") === "page") {
983
+ pages.push(node);
984
+ }
985
+ });
986
+ return pages;
987
+ }
988
+
989
+ export function byteOffsetToLineCol(source, offset) {
990
+ const limit = Math.max(0, Math.min(offset ?? 0, source.length));
991
+ let line = 1;
992
+ let lineStart = 0;
993
+ for (let i = 0; i < limit; i += 1) {
994
+ if (source[i] === "\n") { line += 1; lineStart = i + 1; }
995
+ }
996
+ return { line, column: limit - lineStart + 1 };
997
+ }