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,215 @@
1
+ /**
2
+ * The write verbs share one path.
3
+ *
4
+ * `set`, `unset`, `remove` and `move` differ only in which ops they build. The
5
+ * funnel around those ops is identical, and it is the part that matters:
6
+ *
7
+ * read again → validate → apply → validate → refuse on a NEW error →
8
+ * serialize the changed ranges → write atomically → report
9
+ *
10
+ * `add` keeps its own front end because a fragment goes through the compiler
11
+ * first, but it calls the same `applyAndWrite`.
12
+ */
13
+
14
+ import { CliError, EXIT_INVALID, EXIT_OK, EXIT_UNREADABLE, fail, out } from "./cli.mjs";
15
+ import { CSS_ALIASES, REJECTED_PROPERTIES } from "../table.mjs";
16
+ import { shorthandHint } from "../validate.mjs";
17
+ import { writeAtomic, revisionOf } from "./fs.mjs";
18
+ import { jsonOps } from "./op-json.mjs";
19
+
20
+ /**
21
+ * Apply ops to a loaded document and write, or refuse.
22
+ *
23
+ * @param {object} loaded the result of `readDesignFile`
24
+ * @param {Array} ops
25
+ * @param {object} options `{ values, verb, json }`
26
+ * @returns {Promise<object>} the receipt
27
+ */
28
+ export async function applyAndWrite(loaded, ops, options = {}) {
29
+ const { values = {}, verb = "set" } = options;
30
+ const validateDesign = await loadValidate();
31
+ const newErrors = await loadNewErrors();
32
+ const serialize = await loadSerialize();
33
+ const applyOps = await loadApplyOps();
34
+
35
+ const before = validateDesign(loaded.doc);
36
+ let next;
37
+ let inverse;
38
+ try {
39
+ ({ doc: next, inverse } = applyOps(loaded.doc, ops));
40
+ } catch (error) {
41
+ // A refusal is a result, not a crash. It travels back as a receipt so every
42
+ // verb reports it the same way, and so `--json` gets the same shape as a
43
+ // success.
44
+ const receipt = refusedReceipt(verb, error.message, {
45
+ code: error.code ?? "ops/failed",
46
+ diagnostics: [],
47
+ hint: error.hint ?? null,
48
+ });
49
+ reportRefusal(receipt, values, loaded.file);
50
+ return { ...receipt, refused: true };
51
+ }
52
+
53
+ const after = validateDesign(next);
54
+ const introduced = newErrors(before, after);
55
+
56
+ if (introduced.length && !values.force) {
57
+ const receipt = refusedReceipt(verb, "This edit would introduce a new error.", {
58
+ code: "cli/would-introduce-errors",
59
+ diagnostics: introduced,
60
+ hint: introduced[0]?.hint ?? null,
61
+ });
62
+ reportRefusal(receipt, values, loaded.file);
63
+ return { ...receipt, refused: true };
64
+ }
65
+
66
+ const text = serialize(loaded.doc, next);
67
+ const changed = text !== loaded.text;
68
+ const revision = revisionOf(text);
69
+
70
+ if (!values["dry-run"] && changed) writeAtomic(loaded.file, text);
71
+
72
+ const warnings = after.filter((d) => d.severity === "warning");
73
+ const errors = after.filter((d) => d.severity === "error");
74
+ return {
75
+ ok: true,
76
+ verb,
77
+ applied: jsonOps(ops),
78
+ // Kept live as well, because a caller in this process can apply it.
79
+ inverse,
80
+ inverseJson: jsonOps(inverse),
81
+ changed,
82
+ revision,
83
+ diagnostics: after,
84
+ summary: { errors: errors.length, warnings: warnings.length },
85
+ };
86
+ }
87
+
88
+
89
+ /**
90
+ * A refusal, in the shape every reader expects.
91
+ *
92
+ * `suggestion` is carried through when there is one, because format-v3 §6 documents it
93
+ * on the diagnostic and the human text prints it: a refusal that says "no such id" and
94
+ * stops is a dead end for a model, which cannot see the file to guess.
95
+ */
96
+ export function refusedReceipt(verb, message, extra) {
97
+ const refused = { code: extra.code, message, hint: extra.hint ?? null };
98
+ if (extra.suggestion !== undefined && extra.suggestion !== null) refused.suggestion = extra.suggestion;
99
+ return {
100
+ ok: false,
101
+ verb,
102
+ applied: [],
103
+ added: [],
104
+ normalized: [],
105
+ dropped: [],
106
+ diagnostics: extra.diagnostics ?? [],
107
+ revision: null,
108
+ changed: false,
109
+ refused,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Every refusal prints the same way: the code, the reason, what to write
115
+ * instead, and the line that says the file did not move. A refusal that does not
116
+ * say what to write is a dead end for the model, which cannot see the file.
117
+ */
118
+ export function reportRefusal(receipt, values, file) {
119
+ if (values.json) {
120
+ out(JSON.stringify(receipt));
121
+ return;
122
+ }
123
+ const refusal = receipt.refused ?? {};
124
+ const lines = [`REFUSED ${refusal.message ?? "Refused."}`];
125
+ if (refusal.hint) lines.push(` ${refusal.hint}`);
126
+ for (const d of receipt.diagnostics ?? []) {
127
+ lines.push(` ${d.code}${d.target ? ` at ${d.target}` : ""} — ${d.message}`);
128
+ if (d.hint) lines.push(` ${d.hint}`);
129
+ }
130
+ lines.push(`${file} was not changed.`);
131
+ out(lines.join("\n"));
132
+ }
133
+
134
+ export function reportWrite(receipt, values, file) {
135
+ if (values.json) {
136
+ out(JSON.stringify({ ...receipt, inverse: receipt.inverseJson ?? jsonOps(receipt.inverse) }));
137
+ return;
138
+ }
139
+ if (!receipt.changed) {
140
+ out(`${file}: already as asked (0 errors, ${receipt.summary.warnings} warnings)`, { quiet: values.quiet });
141
+ return;
142
+ }
143
+ const what = `${receipt.applied.length} op${receipt.applied.length === 1 ? "" : "s"}`;
144
+ out(`${file}: ${what}, revision ${receipt.revision} (${receipt.summary.errors} errors, ${receipt.summary.warnings} warnings)`, { quiet: values.quiet });
145
+ }
146
+
147
+ // -- module loading ----------------------------------------------------------
148
+
149
+ /**
150
+ * `lib/node/` is two levels below `lib/`, so one `..` from this file is
151
+ * `lib/node/`. The specifiers below are plain file names.
152
+ */
153
+ async function loadModule(specifier, names) {
154
+ const module = await import(new URL(specifier, import.meta.url));
155
+ for (const name of names) {
156
+ if (typeof module[name] === "function") return module[name];
157
+ }
158
+ throw new Error(`${specifier} exports none of ${names.join(", ")}`);
159
+ }
160
+
161
+ const loadValidate = () => loadModule("../validate.mjs", ["validateDesign"]);
162
+ const loadSerialize = () => loadModule("../serialize.mjs", ["serialize"]);
163
+ const loadApplyOps = () => loadModule("../ops.mjs", ["applyOps"]);
164
+ export const loadNewErrors = () => loadModule("../validate.mjs", ["newErrors"]);
165
+
166
+ /**
167
+ * `--style 'a:b;c:d'` becomes one op per declaration.
168
+ *
169
+ * The property name and the value are canonicalized here, before the op exists.
170
+ * Otherwise `--style "background:#eee"` writes a second `background` declaration
171
+ * beside the existing `background-color`, and the file ends up with two spellings
172
+ * of one intent — which is exactly what the canonical form exists to prevent.
173
+ *
174
+ * @param {Function} normalizeValue from lib/table.mjs, injected so this module
175
+ * does not import the format table into the node layer twice.
176
+ */
177
+ export function styleOps(target, styleText, normalizeValue) {
178
+ const ops = [];
179
+ for (const part of String(styleText ?? "").split(";")) {
180
+ const trimmed = part.trim();
181
+ if (!trimmed) continue;
182
+ const colon = trimmed.indexOf(":");
183
+ if (colon === -1) {
184
+ throw new CliError(`--style wants property:value pairs, and got ${JSON.stringify(trimmed)}`, EXIT_INVALID, "cli/bad-style", 'Such as --style "font-size:20px;color:#111827"');
185
+ }
186
+ const rawProperty = trimmed.slice(0, colon).trim();
187
+ const rawValue = trimmed.slice(colon + 1).trim();
188
+ ops.push(canonicalStyleOp(target, rawProperty, rawValue, normalizeValue));
189
+ }
190
+ return ops;
191
+ }
192
+
193
+ /** One style op with a canonical property name and value. */
194
+ export function canonicalStyleOp(target, rawProperty, rawValue, normalizeValue) {
195
+ let property = String(rawProperty).trim().toLowerCase();
196
+ if (property.startsWith("--")) {
197
+ return { op: "set", target, prop: `style:${property}`, value: String(rawValue) };
198
+ }
199
+ const alias = CSS_ALIASES[property];
200
+ if (alias) property = alias;
201
+ if (REJECTED_PROPERTIES[property]) {
202
+ const info = REJECTED_PROPERTIES[property];
203
+ const hint = shorthandHint(property, rawValue) ?? (info.replacement ? `Use ${info.replacement} instead.` : "Remove it.");
204
+ throw new CliError(info.reason, EXIT_INVALID, "style/rejected-property", hint, { property, value: rawValue });
205
+ }
206
+ if (!normalizeValue) return { op: "set", target, prop: `style:${property}`, value: String(rawValue) };
207
+ try {
208
+ const normalized = normalizeValue(property, rawValue);
209
+ return { op: "set", target, prop: `style:${property}`, value: normalized.value };
210
+ } catch (error) {
211
+ throw new CliError(error.message, EXIT_INVALID, error.code ?? "style/bad-value", error.hint ?? "", { property, value: rawValue });
212
+ }
213
+ }
214
+
215
+ export { EXIT_INVALID, EXIT_OK, EXIT_UNREADABLE, fail };
@@ -0,0 +1,288 @@
1
+ /**
2
+ * lib/normalize.mjs — the canonical form.
3
+ *
4
+ * HTML has many legal ways to write one intent. That breaks diffs, breaks
5
+ * round trips, and breaks the promise that one op equals one byte range. So the
6
+ * tool picks one spelling per intent and writes it.
7
+ *
8
+ * Two layers, and both are idempotent:
9
+ *
10
+ * text line endings, the trailing newline, attribute quoting
11
+ * semantic colors, lengths, four-value compression, attribute order,
12
+ * declaration order inside a style attribute
13
+ *
14
+ * What normalization must NOT do:
15
+ * - reformat a file that is already canonical (the bytes must not move)
16
+ * - touch the inside of an unknown element, a comment or a doctype
17
+ * - reorder pages or reorder elements
18
+ * - pretty print: whitespace between elements is content, and it stays
19
+ *
20
+ * Pure. No node: imports. Runs in the browser.
21
+ */
22
+
23
+ import { parseDeclarations, getAttr, prepareBuiltDocument } from "./parse.mjs";
24
+ import { serialize } from "./serialize.mjs";
25
+ import { normalizeStyleText, compareProperties } from "./table.mjs";
26
+
27
+ /**
28
+ * The document skeleton is written by hand and by exporters, and its attributes
29
+ * have conventions of their own: `<meta charset>` before `<meta name>`,
30
+ * `<link rel>` first. Reordering them would create a diff on every real file for
31
+ * no gain, so the canonical form stops at the design layer and normalizes the
32
+ * contents of these elements instead.
33
+ */
34
+ export const SKELETON_TAGS = new Set(["html", "head", "body", "title", "meta", "link", "style", "script", "base"]);
35
+
36
+ export class NormalizeError extends Error {
37
+ constructor(code, message) {
38
+ super(message);
39
+ this.name = "NormalizeError";
40
+ this.code = code;
41
+ }
42
+ }
43
+
44
+ /** Replace CRLF and a lone CR with LF. */
45
+ export function normalizeLineEndings(text) {
46
+ return String(text).replace(/\r\n?/g, "\n");
47
+ }
48
+
49
+ /** Exactly one trailing newline. */
50
+ export function normalizeFinalNewline(text) {
51
+ return String(text).replace(/\n*$/, "\n");
52
+ }
53
+
54
+ /**
55
+ * The text layer. Reports whether it changed anything, so a caller can write
56
+ * nothing when there is nothing to write.
57
+ */
58
+ export function normalizeText(source) {
59
+ const before = String(source);
60
+ const after = normalizeFinalNewline(normalizeLineEndings(before));
61
+ return { text: after, changed: after !== before };
62
+ }
63
+
64
+ // -- attribute order ---------------------------------------------------------
65
+
66
+ /**
67
+ * `data-id`, `data-type`, then the other `data-*` alphabetically, then plain
68
+ * attributes alphabetically, and `style` last.
69
+ *
70
+ * The order is fixed so that two files with the same design are the same bytes,
71
+ * which is what makes a diff readable and a round trip exact.
72
+ */
73
+ export function attributeRank(attr) {
74
+ if (attr.name === "data-id") return [0, ""];
75
+ if (attr.name === "data-type") return [1, ""];
76
+ if (attr.name === "style") return [3, ""];
77
+ if (attr.name.startsWith("data-")) return [2, attr.name];
78
+ return [2.5, attr.name];
79
+ }
80
+
81
+ export function compareAttributes(a, b) {
82
+ const [ra, na] = attributeRank(a);
83
+ const [rb, nb] = attributeRank(b);
84
+ if (ra !== rb) return ra - rb;
85
+ return na < nb ? -1 : na > nb ? 1 : 0;
86
+ }
87
+
88
+ export function attributesAreSorted(attrs) {
89
+ for (let i = 1; i < attrs.length; i += 1) {
90
+ if (compareAttributes(attrs[i - 1], attrs[i]) > 0) return false;
91
+ }
92
+ return true;
93
+ }
94
+
95
+ export function sortAttributes(attrs) {
96
+ return [...attrs].sort(compareAttributes);
97
+ }
98
+
99
+ // -- style declarations ------------------------------------------------------
100
+
101
+ export function declarationsAreOrdered(declarations) {
102
+ for (let i = 1; i < declarations.length; i += 1) {
103
+ if (compareProperties(declarations[i - 1].property, declarations[i].property) > 0) return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * Normalize one `style` attribute value.
110
+ * @returns {{ text: string, changed: boolean, normalized: Array, dropped: Array }}
111
+ */
112
+ export function normalizeStyleAttribute(value) {
113
+ const { text, normalized, dropped } = normalizeStyleText(value ?? "", parseDeclarations);
114
+ return { text, changed: text !== (value ?? ""), normalized, dropped };
115
+ }
116
+
117
+ // -- the document ------------------------------------------------------------
118
+
119
+ /**
120
+ * Normalize a parsed document in place.
121
+ *
122
+ * @param {object} doc
123
+ * @returns {{ doc: object, changed: boolean, normalized: Array, dropped: Array, text: string }}
124
+ */
125
+ export function normalizeDocument(doc, options = {}) {
126
+ const normalized = [];
127
+ const dropped = [];
128
+ const onDrop = options.onDrop;
129
+
130
+ const report = (target, prop, from, to) => {
131
+ normalized.push({ target, prop, from, to });
132
+ };
133
+
134
+ const visit = (node, id) => {
135
+ if (node.kind === "element") {
136
+ const ownId = getAttr(node, "data-id") || id;
137
+ const isSkeleton = SKELETON_TAGS.has(node.tag) && !node.attrs.some((a) => a.name === "data-id");
138
+
139
+ // Attributes: order them, and normalize the style value. A hand-written
140
+ // skeleton element keeps its attribute order.
141
+ const styleAttr = node.attrs.find((a) => a.name === "style");
142
+ if (styleAttr && typeof styleAttr.value === "string" && styleAttr.value !== "") {
143
+ const result = normalizeStyleAttribute(styleAttr.value);
144
+ for (const item of result.normalized) {
145
+ normalized.push({ target: ownId, prop: item.prop, from: item.from, to: item.to });
146
+ }
147
+ for (const item of result.dropped) {
148
+ const entry = { target: ownId, prop: item.prop, value: item.value, reason: item.reason, replacement: item.replacement ?? null };
149
+ dropped.push(entry);
150
+ if (onDrop) onDrop(entry);
151
+ }
152
+ if (result.text !== styleAttr.value) styleAttr.value = result.text;
153
+ }
154
+
155
+ // A valueless attribute becomes an explicit one. `data-hidden` and
156
+ // `data-hidden="true"` must not be two spellings of one intent.
157
+ for (const attr of node.attrs) {
158
+ if (attr.value === null && attr.name.startsWith("data-") && attr.name !== "data-hidden") {
159
+ attr.value = "";
160
+ }
161
+ }
162
+
163
+ if (!isSkeleton && !attributesAreSorted(node.attrs)) {
164
+ node.attrs = sortAttributes(node.attrs);
165
+ report(ownId, "attributes", "unsorted", "canonical order");
166
+ }
167
+
168
+ // A canonical file has no empty style attribute.
169
+ const empty = node.attrs.find((a) => a.name === "style" && (a.value === "" || a.value === null));
170
+ if (empty) {
171
+ node.attrs = node.attrs.filter((a) => a !== empty);
172
+ report(ownId, "style", "empty", "removed");
173
+ }
174
+ }
175
+
176
+ for (const child of node.children || []) visit(child, id);
177
+ };
178
+
179
+ visit(doc, null);
180
+
181
+ const text = options.serialize ? options.serialize(doc, doc) : doc.source;
182
+ return {
183
+ doc,
184
+ changed: normalized.length > 0 || text !== doc.source,
185
+ normalized,
186
+ dropped,
187
+ text,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * A dry run for `format --check`: does this file already read as canonical.
193
+ *
194
+ * It answers by running the real normalizer on a copy and comparing the result,
195
+ * so the answer can never disagree with what `format` would do. A hand-written
196
+ * checker that re-implements the rules is how a `--check` ends up reporting a
197
+ * file as clean that `format` then rewrites.
198
+ *
199
+ * Working on a copy matters: the check must not mutate the document it is
200
+ * asked about.
201
+ */
202
+ export function isCanonical(doc) {
203
+ const wanted = canonicalTextOf(doc);
204
+ const actual = normalizeText(doc.source).text;
205
+ if (wanted === actual) return { canonical: true, reasons: [], total: 0 };
206
+
207
+ // Report the first differences so the answer is actionable rather than a bare
208
+ // "no". A caller that only wants the boolean ignores this list.
209
+ const reasons = firstDifferences(actual, wanted);
210
+ return { canonical: false, reasons, total: reasons.length };
211
+ }
212
+
213
+ /** The canonical text of a document, without touching the document. */
214
+ export function canonicalTextOf(doc) {
215
+ // Work on a structural clone and seal it, so the clone is a valid document
216
+ // for the serializer. Mutating the caller's document here would make a
217
+ // read-only question ("is this canonical?") change the answer for every
218
+ // question asked afterwards.
219
+ const clones = (doc.children || []).map(cloneNode);
220
+ const copy = {
221
+ kind: "document",
222
+ source: doc.source,
223
+ children: clones,
224
+ diagnostics: [],
225
+ styleBlocks: (doc.styleBlocks || []).map((block) => ({ ...block, node: cloneNode(block.node) })),
226
+ };
227
+ cloneDerived(copy, clones);
228
+ prepareBuiltDocument(copy, clones);
229
+ normalizeDocument(copy, { serialize: (a, b) => b.source });
230
+ return normalizeText(serialize(copy, copy)).text;
231
+ }
232
+
233
+ /** A structural clone deep enough that normalization cannot reach the original. */
234
+ function cloneNode(node) {
235
+ if (!node) return node;
236
+ if (node.kind === "text") {
237
+ return { kind: "text", value: node.value, raw: node.raw, start: node.start, end: node.end };
238
+ }
239
+ if (node.kind === "comment" || node.kind === "doctype") {
240
+ return { kind: node.kind, raw: node.raw, start: node.start, end: node.end };
241
+ }
242
+ return {
243
+ kind: "element",
244
+ tag: node.tag,
245
+ rawTag: node.rawTag,
246
+ selfClosing: node.selfClosing,
247
+ attrs: node.attrs.map((a) => ({ ...a })),
248
+ children: (node.children || []).map(cloneNode),
249
+ start: node.start,
250
+ end: node.end,
251
+ openStart: node.openStart,
252
+ openEnd: node.openEnd,
253
+ closeStart: node.closeStart,
254
+ closeEnd: node.closeEnd,
255
+ parent: null,
256
+ };
257
+ }
258
+
259
+ /** The clone's own html/head/body, so validation-style lookups still work. */
260
+ function cloneDerived(copy, clones) {
261
+ const flat = [];
262
+ const collect = (nodes) => {
263
+ for (const node of nodes) {
264
+ if (node.kind === "element") {
265
+ flat.push(node);
266
+ collect(node.children || []);
267
+ }
268
+ }
269
+ };
270
+ collect(clones);
271
+ copy.html = flat.find((n) => n.tag === "html") || null;
272
+ const within = copy.html ? copy.html.children.filter((n) => n.kind === "element") : flat;
273
+ copy.head = within.find((n) => n.tag === "head") || null;
274
+ copy.body = within.find((n) => n.tag === "body") || null;
275
+ return copy;
276
+ }
277
+
278
+ function firstDifferences(actual, wanted) {
279
+ const reasons = [];
280
+ const a = actual.split("\n");
281
+ const b = wanted.split("\n");
282
+ for (let i = 0; i < Math.max(a.length, b.length) && reasons.length < 20; i += 1) {
283
+ if (a[i] !== b[i]) {
284
+ reasons.push(`line ${i + 1}: has ${JSON.stringify((a[i] ?? "").slice(0, 90))}, canonical is ${JSON.stringify((b[i] ?? "").slice(0, 90))}`);
285
+ }
286
+ }
287
+ return reasons;
288
+ }