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
@@ -0,0 +1,702 @@
1
+ /**
2
+ * lib/fragment.mjs — the modifier. A model sends an HTML snippet; this turns it into
3
+ * one op, or refuses it with a reason.
4
+ *
5
+ * PURE ESM. No `node:` imports, no DOM, no dependencies beyond `lib/`.
6
+ *
7
+ * ## What "compile" means here
8
+ *
9
+ * The snippet is not trusted and not written as it arrives. It is parsed, its
10
+ * structure is checked against the element contract, its attributes are filtered
11
+ * through the format table, every value is normalized, and only then is it written
12
+ * back out as the `html` of one `insert` op. Anything the table does not accept is
13
+ * reported — in `dropped`, or as a refusal. Nothing is discarded in silence.
14
+ *
15
+ * ## Where the rules come from (docs/format-v3.md)
16
+ *
17
+ * §3 the element contract: which tag may carry which `data-type`
18
+ * §7 the format table in `lib/table.mjs` — the single source of truth
19
+ * §8 the semantic normalization of values
20
+ *
21
+ * ## Assumptions where docs/format-v3.md is silent
22
+ *
23
+ * F1. An out-of-table *property* is dropped and reported (`cursor`, `margin`, …).
24
+ * An allowed property with a value the table cannot read *refuses* the whole
25
+ * fragment: dropping it would throw away intent, and writing it would produce a
26
+ * file the validator rejects. A table reason is printed as the drop reason,
27
+ * because that is how the model learns what to write instead.
28
+ * F2. An allowed attribute is `data-id`, `data-type`, any of `COMMON_OPTIONAL`, or
29
+ * one listed for the type in `ATTR_BY_TYPE`. Every other attribute — `class`,
30
+ * `id`, `onclick`, `width` — is dropped and reported.
31
+ * F3. A missing `data-id` is filled in as `<page>__<name-or-tag>`; a `data-name`
32
+ * that cannot be an id (Chinese, spaces, a leading digit) falls back to the tag.
33
+ * A missing `data-type` is always refused: it is the one attribute that says what
34
+ * the element *is*, and guessing a type is how a `<div>` becomes a `frame` when
35
+ * the author meant a `shape`.
36
+ * F4. A collision is refused, including one the auto-filled id creates. Inventing a
37
+ * suffix would rename the model's element behind its back. The refusal carries the
38
+ * nearest existing ids.
39
+ * F5. `options.as` fills in the root's missing id. If the root already carries a
40
+ * different id, that disagreement is refused rather than silently resolved.
41
+ * F6. `options.in` names the parent. Without it, a `page` snippet lands in `<body>`
42
+ * and anything else lands in the resolved page. `options.index` defaults to the
43
+ * end of the parent's children.
44
+ * F7. An address that names nothing is `ops/node-not-found`, the same code the op
45
+ * engine uses, so a caller can treat "no such parent" once.
46
+ * F8. Missing required attributes (`src`, `alt`, `data-kind`, …) are *not* refused
47
+ * here. §6 gives those findings to `lib/validate.mjs`, which sees the written file
48
+ * anyway; reporting them twice would double every `<img>`.
49
+ * F9. Values are normalized by `normalizeValue` from `lib/table.mjs` — one
50
+ * implementation for the compiler, the validator and the normalizer, as §7
51
+ * requires. The compiler never rewrites a value the table cannot read; it refuses
52
+ * (F1) and prints the table's own hint.
53
+ */
54
+
55
+ import {
56
+ RESERVED_CONTAINERS,
57
+ VOID_TAGS,
58
+ buildIdIndex,
59
+ escapeAttrValue,
60
+ escapeTextValue,
61
+ parseFragmentHtml,
62
+ parseStyleDeclarations,
63
+ } from "./ops.mjs";
64
+ import {
65
+ ATTR_BY_TYPE,
66
+ COMMON_OPTIONAL,
67
+ CSS_ALIASES,
68
+ ID_PATTERN,
69
+ REJECTED_PROPERTIES,
70
+ TAG_TO_TYPE,
71
+ TYPE_HAS_CHILDREN,
72
+ TYPE_TAGS,
73
+ TableError,
74
+ normalizeValue,
75
+ specFor,
76
+ } from "./table.mjs";
77
+ import { distance } from "./levenshtein.mjs";
78
+ import { shorthandHint } from "./validate.mjs";
79
+
80
+ const ID_MAX = 64;
81
+
82
+ function isObject(value) {
83
+ return value !== null && typeof value === "object" && !Array.isArray(value);
84
+ }
85
+
86
+ function isElement(node) {
87
+ return isObject(node) && (node.kind === "element" || typeof node.tag === "string");
88
+ }
89
+
90
+ function attrOf(node, name) {
91
+ const wanted = String(name).toLowerCase();
92
+ for (const attr of Array.isArray(node.attrs) ? node.attrs : []) {
93
+ if (attr && typeof attr.name === "string" && attr.name.toLowerCase() === wanted) return attr;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function attrValueOf(node, name) {
99
+ const attr = attrOf(node, name);
100
+ return attr === null ? null : String(attr.value ?? "");
101
+ }
102
+
103
+ function dataIdOf(node) {
104
+ const value = attrValueOf(node, "data-id");
105
+ return value === null || value === "" ? null : value;
106
+ }
107
+
108
+ function dataTypeOf(node) {
109
+ const value = attrValueOf(node, "data-type");
110
+ return value === null || value === "" ? null : value;
111
+ }
112
+
113
+ /** Thrown by the walker, caught once by `compileFragment`. */
114
+ class Refusal extends Error {
115
+ constructor(code, message, extra = {}) {
116
+ super(message);
117
+ this.name = "Refusal";
118
+ this.code = code;
119
+ this.hint = extra.hint ?? null;
120
+ this.suggestion = extra.suggestion ?? null;
121
+ this.target = extra.target ?? null;
122
+ }
123
+ }
124
+
125
+ /* ------------------------------------------------------------------ *
126
+ * Reading the document
127
+ * ------------------------------------------------------------------ */
128
+
129
+ function rootsOf(doc) {
130
+ const found = [];
131
+ const push = (value) => {
132
+ if (isObject(value) && !found.includes(value)) found.push(value);
133
+ };
134
+ if (Array.isArray(doc.children)) for (const child of doc.children) push(child);
135
+ for (const key of ["html", "head", "body"]) push(doc[key]);
136
+ return found;
137
+ }
138
+
139
+ /** Every element in document order, with the elements above it. */
140
+ function walkElements(doc) {
141
+ const out = [];
142
+ const visit = (node, ancestry) => {
143
+ for (const child of Array.isArray(node.children) ? node.children : []) {
144
+ if (!isElement(child)) continue;
145
+ out.push({ node: child, ancestry });
146
+ visit(child, ancestry.concat([child]));
147
+ }
148
+ };
149
+ for (const root of rootsOf(doc)) {
150
+ out.push({ node: root, ancestry: [] });
151
+ visit(root, [root]);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /** Pages in document order. */
157
+ export function pagesOf(doc) {
158
+ const pages = [];
159
+ for (const entry of walkElements(doc)) {
160
+ if (dataTypeOf(entry.node) === "page" && dataIdOf(entry.node) !== null) pages.push(entry.node);
161
+ }
162
+ return pages;
163
+ }
164
+
165
+ /** A data-id, or one of the four reserved container words. */
166
+ function findNode(doc, address) {
167
+ if (typeof address !== "string" || address === "") return null;
168
+ for (const entry of walkElements(doc)) {
169
+ if (dataIdOf(entry.node) === address) return entry.node;
170
+ }
171
+ if (!RESERVED_CONTAINERS.includes(address)) return null;
172
+ if (address === "document") return doc;
173
+ const slot = address === "html" ? doc.html : address === "head" ? doc.head : doc.body;
174
+ if (isObject(slot)) return slot;
175
+ for (const entry of walkElements(doc)) if (entry.node.tag === address) return entry.node;
176
+ return null;
177
+ }
178
+
179
+ /** The innermost page above an element, or the element itself. */
180
+ function pageOfElement(doc, node) {
181
+ for (const entry of walkElements(doc)) {
182
+ if (entry.node !== node) continue;
183
+ for (let i = entry.ancestry.length - 1; i >= 0; i -= 1) {
184
+ if (dataTypeOf(entry.ancestry[i]) === "page") return entry.ancestry[i];
185
+ }
186
+ return dataTypeOf(node) === "page" ? node : null;
187
+ }
188
+ return null;
189
+ }
190
+
191
+ /* ------------------------------------------------------------------ *
192
+ * Ids
193
+ * ------------------------------------------------------------------ */
194
+
195
+ /** The first run of id characters in a display name, or null. */
196
+ function nameToken(value) {
197
+ if (typeof value !== "string") return null;
198
+ const match = /[A-Za-z_][A-Za-z0-9_-]*/.exec(value);
199
+ return match === null ? null : match[0];
200
+ }
201
+
202
+ function autoId(page, element) {
203
+ const prefix = nameToken(page === null ? null : dataIdOf(page)) ?? "page";
204
+ const token = nameToken(attrValueOf(element, "data-name")) ?? element.tag;
205
+ let id = `${prefix}__${token}`;
206
+ if (id.length > ID_MAX) id = id.slice(0, ID_MAX);
207
+ return ID_PATTERN.test(id) ? id : null;
208
+ }
209
+
210
+ /**
211
+ * The nearest ids in an index to `id`, closest first.
212
+ *
213
+ * `index` is whatever holds the ids: the `Map` from `buildIdIndex`, an array, a
214
+ * `Set`, or an object used as a set. Ties break alphabetically so the suggestion is
215
+ * the same on every run.
216
+ */
217
+ export function nearestIds(index, id, limit = 3) {
218
+ const candidates = [];
219
+ if (index instanceof Map) candidates.push(...index.keys());
220
+ else if (index instanceof Set) candidates.push(...index);
221
+ else if (Array.isArray(index)) candidates.push(...index);
222
+ else if (isObject(index)) candidates.push(...Object.keys(index));
223
+ const target = String(id ?? "");
224
+ return candidates
225
+ .map((candidate) => ({ candidate: String(candidate), score: distance(target, String(candidate)) }))
226
+ .sort((a, b) => a.score - b.score || (a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0))
227
+ .slice(0, Math.max(0, limit))
228
+ .map((entry) => entry.candidate);
229
+ }
230
+
231
+ /* ------------------------------------------------------------------ *
232
+ * Writing the fragment back
233
+ * ------------------------------------------------------------------ */
234
+
235
+ /**
236
+ * Serialize nodes as HTML.
237
+ *
238
+ * Only what this module produces is ever read back by `parseFragmentHtml`, so the
239
+ * escaper covers exactly the entities that parser decodes. Attribute order is the
240
+ * order the compiler chose — `data-id`, `data-type`, the rest as written, `style`
241
+ * last — which is also the order §8 normalizes to.
242
+ */
243
+ export function writeFragment(nodes) {
244
+ const out = [];
245
+ const write = (list) => {
246
+ for (const node of Array.isArray(list) ? list : []) {
247
+ if (!isObject(node)) continue;
248
+ if (node.kind === "text") {
249
+ out.push(escapeTextValue(node.value ?? node.raw ?? ""));
250
+ continue;
251
+ }
252
+ if (node.kind === "comment") {
253
+ out.push(typeof node.raw === "string" ? node.raw : `<!--${node.value ?? ""}-->`);
254
+ continue;
255
+ }
256
+ if (node.kind === "doctype") {
257
+ out.push(typeof node.raw === "string" ? node.raw : "<!doctype html>");
258
+ continue;
259
+ }
260
+ if (!isElement(node)) {
261
+ if (typeof node.raw === "string") out.push(node.raw);
262
+ continue;
263
+ }
264
+ const attrs = (Array.isArray(node.attrs) ? node.attrs : [])
265
+ .map((attr) => ` ${attr.name}="${escapeAttrValue(attr.value)}"`)
266
+ .join("");
267
+ out.push(`<${node.tag}${attrs}>`);
268
+ if (!VOID_TAGS.has(node.tag)) {
269
+ write(node.children);
270
+ out.push(`</${node.tag}>`);
271
+ }
272
+ }
273
+ };
274
+ write(nodes);
275
+ return out.join("");
276
+ }
277
+
278
+ /* ------------------------------------------------------------------ *
279
+ * Attributes and declarations
280
+ * ------------------------------------------------------------------ */
281
+
282
+ function isAllowedAttr(type, name) {
283
+ if (COMMON_OPTIONAL.includes(name)) return true;
284
+ const spec = ATTR_BY_TYPE[type];
285
+ if (!spec) return false;
286
+ return (spec.required ?? []).includes(name) || (spec.optional ?? []).includes(name);
287
+ }
288
+
289
+ /**
290
+ * Filter, normalize and rewrite one `style` attribute.
291
+ *
292
+ * @returns {string} the rebuilt attribute text — empty when nothing survived
293
+ * @throws {Refusal} when a value of an allowed property cannot be read (F1)
294
+ */
295
+ function compileStyle(value, target, result) {
296
+ const kept = [];
297
+ for (const declaration of parseStyleDeclarations(String(value ?? ""))) {
298
+ const written = declaration.property.toLowerCase();
299
+ if (written === "") continue;
300
+
301
+ let property = written;
302
+ let alias = null;
303
+ if (!Object.prototype.hasOwnProperty.call(REJECTED_PROPERTIES, written) && specFor(written) === null) {
304
+ const renamed = CSS_ALIASES[written];
305
+ if (typeof renamed === "string" && specFor(renamed) !== null) {
306
+ alias = written;
307
+ property = renamed;
308
+ }
309
+ }
310
+
311
+ if (Object.prototype.hasOwnProperty.call(REJECTED_PROPERTIES, written)) {
312
+ const rejected = REJECTED_PROPERTIES[written] ?? {};
313
+ // The hint is worked out from the value, so a one-sided margin is told to
314
+ // write `margin: 16px 0 0 0` rather than a fixed example with the wrong
315
+ // number in it.
316
+ const hint = shorthandHint(written, declaration.value)
317
+ ?? (rejected.replacement ? `Use ${rejected.replacement} instead.` : null);
318
+ const reason = [rejected.reason ?? "Not in the format.", hint].filter(Boolean).join(" ");
319
+ result.dropped.push({ target, prop: written, value: declaration.value, reason, replacement: hint });
320
+ continue;
321
+ }
322
+ if (specFor(property) === null) {
323
+ result.dropped.push({ target, prop: written, value: declaration.value, reason: "not in the format table" });
324
+ continue;
325
+ }
326
+
327
+ let normalized;
328
+ try {
329
+ normalized = normalizeValue(property, declaration.value);
330
+ } catch (err) {
331
+ if (err instanceof TableError) {
332
+ throw new Refusal(err.code, `${written}: ${err.message}`, {
333
+ target,
334
+ hint: err.hint ?? specFor(property)?.hint ?? null,
335
+ });
336
+ }
337
+ throw err;
338
+ }
339
+ if (alias !== null || normalized.changed) {
340
+ const entry = { target, prop: property, from: declaration.value, to: normalized.value };
341
+ if (alias !== null) entry.alias = alias;
342
+ result.normalized.push(entry);
343
+ }
344
+ kept.push(`${property}:${normalized.value}`);
345
+ }
346
+ return kept.join(";");
347
+ }
348
+
349
+ /* ------------------------------------------------------------------ *
350
+ * Compilation
351
+ * ------------------------------------------------------------------ */
352
+
353
+ function readOptions(options) {
354
+ const source = isObject(options) ? options : {};
355
+ const string = (value) => (typeof value === "string" && value !== "" ? value : null);
356
+ return {
357
+ in: string(source.in),
358
+ as: string(source.as),
359
+ page: string(source.page),
360
+ index: Number.isInteger(source.index) && source.index >= 0 ? source.index : null,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Build the element the op will carry: the ids and types made explicit, the
366
+ * attributes filtered, the style rewritten.
367
+ */
368
+ function compileElement(element, depth, context) {
369
+ const { page, options, used, result } = context;
370
+
371
+ const type = dataTypeOf(element);
372
+ if (type === null) {
373
+ throw new Refusal("structure/missing-type", `A <${element.tag}> in the fragment has no data-type.`, {
374
+ hint: `Add data-type, for example data-type="${TAG_TO_TYPE[element.tag] ?? "frame"}".`,
375
+ });
376
+ }
377
+ if (!Object.prototype.hasOwnProperty.call(TYPE_TAGS, type)) {
378
+ throw new Refusal("structure/unknown-type", `${JSON.stringify(type)} is not a data-type.`, {
379
+ target: dataIdOf(element) ?? undefined,
380
+ hint: `One of: ${Object.keys(TYPE_TAGS).join(", ")}`,
381
+ suggestion: nearestIds(TYPE_TAGS, type, 2).join(", ") || null,
382
+ });
383
+ }
384
+ if (!TYPE_TAGS[type].includes(element.tag)) {
385
+ throw new Refusal(
386
+ "structure/type-tag-mismatch",
387
+ `A <${element.tag}> cannot be a ${type}; ${type} is <${TYPE_TAGS[type].join(">, <")}>.`,
388
+ {
389
+ target: dataIdOf(element) ?? undefined,
390
+ hint: `Write <${TYPE_TAGS[type][0]} data-type="${type}">, or change data-type.`,
391
+ },
392
+ );
393
+ }
394
+ if (type === "page" && depth > 0) {
395
+ throw new Refusal("structure/page-misplaced", "A page cannot sit inside another element.", {
396
+ target: dataIdOf(element) ?? undefined,
397
+ hint: "A fragment holds a page at its top level, or no page at all.",
398
+ });
399
+ }
400
+
401
+ const elementChildren = (Array.isArray(element.children) ? element.children : []).filter(isElement);
402
+ if (elementChildren.length > 0 && !TYPE_HAS_CHILDREN.has(type) && type !== "icon") {
403
+ // An icon is the one exception, and it is not really an exception: an
404
+ // `<svg>` holds its own geometry. Every real icon is a `<path>` or a
405
+ // `<circle>` inside `<svg>`, so refusing children here would refuse every
406
+ // glyph a model can actually write.
407
+ const article = /^[aeiou]/i.test(type) ? "An" : "A";
408
+ throw new Refusal("structure/children-not-allowed", `${article} ${type} element cannot hold elements.`, {
409
+ target: dataIdOf(element) ?? undefined,
410
+ hint: type === "text"
411
+ ? "A text element holds one run of text. Wrap the parts in a frame, or use a span for inline emphasis."
412
+ : "Only page, frame and icon take children. Put the content in a frame.",
413
+ });
414
+ }
415
+
416
+ // -- the id ----------------------------------------------------------------
417
+
418
+ let id = dataIdOf(element);
419
+ if (id === null) {
420
+ if (depth === 0 && options.as !== null) {
421
+ id = options.as;
422
+ } else {
423
+ id = autoId(type === "page" ? element : page, element);
424
+ if (id === null) {
425
+ throw new Refusal("structure/bad-id", `Cannot build a data-id for <${element.tag}>.`, {
426
+ hint: `Ids match ${ID_PATTERN.source}`,
427
+ });
428
+ }
429
+ }
430
+ } else if (depth === 0 && options.as !== null && options.as !== id) {
431
+ throw new Refusal(
432
+ "structure/id-conflict",
433
+ `The fragment root says data-id=${JSON.stringify(id)}, but the call asked for ${JSON.stringify(options.as)}.`,
434
+ { target: id, hint: "Send one id, or none." },
435
+ );
436
+ }
437
+ if (!ID_PATTERN.test(id)) {
438
+ throw new Refusal("structure/bad-id", `${JSON.stringify(id)} is not a usable data-id.`, {
439
+ hint: `Ids match ${ID_PATTERN.source}`,
440
+ });
441
+ }
442
+ if (used.has(id)) {
443
+ throw new Refusal("structure/duplicate-id", `data-id ${JSON.stringify(id)} is already taken.`, {
444
+ target: id,
445
+ hint: "Every element in the file has its own data-id.",
446
+ suggestion: nearestIds(used, id).join(", ") || null,
447
+ });
448
+ }
449
+ used.add(id);
450
+ result.added.push(id);
451
+
452
+ // -- attributes ------------------------------------------------------------
453
+
454
+ const kept = [];
455
+ const seen = new Set();
456
+ let styleText = null;
457
+ for (const attr of Array.isArray(element.attrs) ? element.attrs : []) {
458
+ const name = String(attr.name).toLowerCase();
459
+ if (seen.has(name)) continue;
460
+ seen.add(name);
461
+ if (name === "data-id") {
462
+ kept.push({ name, value: id });
463
+ continue;
464
+ }
465
+ if (name === "data-type") {
466
+ kept.push({ name, value: type });
467
+ continue;
468
+ }
469
+ if (name === "style") {
470
+ const text = compileStyle(attr.value, id, result);
471
+ if (text !== "") styleText = text;
472
+ continue;
473
+ }
474
+ if (isAllowedAttr(type, name)) {
475
+ // Write the author's spelling, not the parser's normalized one. The parser
476
+ // lowercases `name` and keeps the original in `rawName`, and attribute case
477
+ // is load-bearing outside HTML: `export --format svg` emits real XML, where
478
+ // `viewbox` is not `viewBox` and the glyph silently loses its coordinate
479
+ // system. `normalize.mjs` never rewrites attribute case, so whatever is
480
+ // written here is what the file keeps. `data-id` and `data-type` are the
481
+ // format's own vocabulary and stay lowercase, above.
482
+ kept.push({ name: attr.rawName ?? name, value: String(attr.value ?? "") });
483
+ continue;
484
+ }
485
+ result.dropped.push({
486
+ target: id,
487
+ prop: name,
488
+ value: String(attr.value ?? ""),
489
+ reason: "not in the format table",
490
+ });
491
+ }
492
+ if (!seen.has("data-id")) kept.unshift({ name: "data-id", value: id });
493
+ if (!seen.has("data-type")) kept.unshift({ name: "data-type", value: type });
494
+ if (styleText !== null) kept.push({ name: "style", value: styleText });
495
+
496
+ // The written order is §8's: `data-id`, `data-type`, the rest as they were sent,
497
+ // `style` last. Writing it here means the op carries bytes that are already in the
498
+ // file's canonical shape, whatever order the model happened to use.
499
+ const rest = kept.filter((entry) => entry.name !== "data-id" && entry.name !== "data-type" && entry.name !== "style");
500
+ const style = kept.find((entry) => entry.name === "style");
501
+ const ordered = [{ name: "data-id", value: id }, { name: "data-type", value: type }, ...rest];
502
+ if (style !== undefined) ordered.push(style);
503
+
504
+ // -- children --------------------------------------------------------------
505
+
506
+ const children = [];
507
+ for (const child of Array.isArray(element.children) ? element.children : []) {
508
+ if (isElement(child) && type === "icon") {
509
+ // Inside an `<svg>` is the opaque layer. The geometry is kept exactly as
510
+ // written: it is not a design element, so it needs no data-type, gets no
511
+ // data-id, and is never addressed by an op. A real glyph is a `<path>`,
512
+ // and requiring a type on it would refuse every icon a model writes.
513
+ children.push({ kind: "element", tag: child.tag, rawTag: child.rawTag ?? child.tag, attrs: child.attrs ?? [], children: [], selfClosing: Boolean(child.selfClosing) });
514
+ } else if (isElement(child)) {
515
+ children.push(compileElement(child, depth + 1, context));
516
+ } else if (isObject(child) && child.kind === "text") {
517
+ children.push({ kind: "text", value: child.value ?? "", raw: child.raw ?? child.value ?? "" });
518
+ } else if (isObject(child) && child.kind === "comment") {
519
+ children.push({ kind: "comment", raw: child.raw ?? "" });
520
+ }
521
+ }
522
+
523
+ return {
524
+ kind: "element",
525
+ tag: element.tag,
526
+ rawTag: element.rawTag ?? element.tag,
527
+ attrs: ordered.map((entry) => ({
528
+ kind: "attr",
529
+ name: entry.name,
530
+ rawName: entry.name,
531
+ value: entry.value,
532
+ quote: '"',
533
+ })),
534
+ children,
535
+ selfClosing: false,
536
+ parent: null,
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Compile one HTML fragment into one `insert` op.
542
+ *
543
+ * @param {object} doc the document the fragment will be inserted into
544
+ * @param {string} html the fragment
545
+ * @param {{in?: string, index?: number, as?: string, page?: string}} [options]
546
+ * @returns {{ops: object[], added: string[], normalized: object[], dropped: object[],
547
+ * diagnostics: object[],
548
+ * refused: null | {code: string, message: string, hint: string|null,
549
+ * suggestion: string|null}}}
550
+ */
551
+ export function compileFragment(doc, html, options = {}) {
552
+ const result = {
553
+ ops: [],
554
+ added: [],
555
+ normalized: [],
556
+ dropped: [],
557
+ diagnostics: [],
558
+ refused: null,
559
+ };
560
+
561
+ const refuseWith = (refusal) => {
562
+ result.refused = {
563
+ code: refusal.code,
564
+ message: refusal.message,
565
+ hint: refusal.hint ?? null,
566
+ suggestion: refusal.suggestion ?? null,
567
+ };
568
+ result.ops = [];
569
+ const diagnostic = { severity: "error", code: refusal.code, message: refusal.message };
570
+ if (refusal.target !== null && refusal.target !== undefined) diagnostic.target = refusal.target;
571
+ if (refusal.hint) diagnostic.hint = refusal.hint;
572
+ if (refusal.suggestion) diagnostic.fix = refusal.suggestion;
573
+ result.diagnostics.push(diagnostic);
574
+ return result;
575
+ };
576
+
577
+ if (!isObject(doc)) {
578
+ return refuseWith(new Refusal("ops/no-doc", `Expected a document, got ${doc === null ? "null" : typeof doc}.`));
579
+ }
580
+ if (typeof html !== "string" || html.trim() === "") {
581
+ return refuseWith(
582
+ new Refusal("structure/empty-fragment", "The fragment is empty.", {
583
+ hint: 'Send at least one element, such as <div data-id="n_card" data-type="frame"></div>.',
584
+ }),
585
+ );
586
+ }
587
+
588
+ const roots = parseFragmentHtml(html);
589
+ const elementRoots = roots.filter(isElement);
590
+ if (elementRoots.length === 0) {
591
+ return refuseWith(
592
+ new Refusal("structure/empty-fragment", "The fragment has no elements, only text or comments.", {
593
+ hint: 'Send at least one element, such as <div data-id="n_card" data-type="frame"></div>.',
594
+ }),
595
+ );
596
+ }
597
+
598
+ const options_ = readOptions(options);
599
+ const index = buildIdIndex(doc);
600
+ const pages = pagesOf(doc);
601
+
602
+ // -- which page names the ids that have none --------------------------------
603
+
604
+ let page = null;
605
+ if (options_.page !== null) {
606
+ page = pages.find((candidate) => dataIdOf(candidate) === options_.page) ?? null;
607
+ if (page === null) {
608
+ return refuseWith(
609
+ new Refusal("structure/unknown-page", `There is no page with data-id ${JSON.stringify(options_.page)}.`, {
610
+ target: options_.page,
611
+ hint: pages.length === 0 ? "This document has no pages." : `Pages: ${pages.map(dataIdOf).join(", ")}`,
612
+ }),
613
+ );
614
+ }
615
+ } else if (options_.in !== null) {
616
+ const named = findNode(doc, options_.in);
617
+ if (named !== null && named !== doc) page = pageOfElement(doc, named);
618
+ }
619
+ if (page === null && pages.length > 0) page = pages[pages.length - 1];
620
+
621
+ // -- the parent -------------------------------------------------------------
622
+
623
+ const rootIsPage = elementRoots.some((element) => dataTypeOf(element) === "page");
624
+ let parentAddress = options_.in;
625
+ if (parentAddress === null) {
626
+ // A page snippet has a home with no page: `<body>`. Anything else needs a
627
+ // page to land in. Falling back to `<body>` for an element was the silent
628
+ // hole the old `add` guard covered: the node validates, `ls` and `export`
629
+ // never show it, and only the file knows it is there.
630
+ if (rootIsPage) parentAddress = isObject(doc.body) ? "body" : "document";
631
+ else if (page !== null) parentAddress = dataIdOf(page);
632
+ }
633
+ if (parentAddress === null) {
634
+ return refuseWith(
635
+ new Refusal("structure/no-page", "The document has no page to add this fragment to.", {
636
+ hint: "Add a page, or name a parent with in.",
637
+ }),
638
+ );
639
+ }
640
+ const parent = findNode(doc, parentAddress);
641
+ if (parent === null) {
642
+ return refuseWith(
643
+ new Refusal("ops/node-not-found", `There is no node with data-id ${JSON.stringify(parentAddress)}.`, {
644
+ target: parentAddress,
645
+ hint: "Name the parent with in, or leave in out to use the last page.",
646
+ suggestion: nearestIds(index, parentAddress).join(", ") || null,
647
+ }),
648
+ );
649
+ }
650
+ const parentType = dataTypeOf(parent);
651
+ if (parent !== doc && parentType !== null && !TYPE_HAS_CHILDREN.has(parentType)) {
652
+ return refuseWith(
653
+ new Refusal("structure/children-not-allowed", `A ${parentType} element cannot hold children.`, {
654
+ target: parentAddress,
655
+ hint: "Only page and frame take children.",
656
+ }),
657
+ );
658
+ }
659
+ if (rootIsPage && parent !== doc && parentType !== null && parentType !== "page") {
660
+ return refuseWith(
661
+ new Refusal("structure/page-misplaced", `A page cannot sit inside a ${parentType}.`, {
662
+ target: parentAddress,
663
+ hint: "Send the page without in, or name body.",
664
+ }),
665
+ );
666
+ }
667
+ if (rootIsPage && parentType === "page") {
668
+ return refuseWith(
669
+ new Refusal("structure/page-misplaced", "A page cannot sit inside another page.", {
670
+ target: parentAddress,
671
+ hint: "Send the page without in, or name body.",
672
+ }),
673
+ );
674
+ }
675
+
676
+ // -- compile ----------------------------------------------------------------
677
+
678
+ const context = { page, options: options_, used: new Set(index.keys()), result };
679
+ let nodes;
680
+ try {
681
+ nodes = elementRoots.map((element) => compileElement(element, 0, context));
682
+ } catch (err) {
683
+ if (err instanceof Refusal) return refuseWith(err);
684
+ throw err;
685
+ }
686
+
687
+ // -- emit -------------------------------------------------------------------
688
+
689
+ const siblings = Array.isArray(parent.children) ? parent.children : [];
690
+ const at = options_.index === null ? siblings.length : options_.index;
691
+ if (at > siblings.length) {
692
+ return refuseWith(
693
+ new Refusal(
694
+ "ops/bad-index",
695
+ `Cannot insert at index ${at}: ${JSON.stringify(parentAddress)} has ${siblings.length} children.`,
696
+ { target: parentAddress, hint: `Use an index from 0 to ${siblings.length}.` },
697
+ ),
698
+ );
699
+ }
700
+ result.ops = [{ op: "insert", target: parentAddress, index: at, html: writeFragment(nodes) }];
701
+ return result;
702
+ }