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.
- package/README.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- 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 `"` returns as
|
|
220
|
+
* `"` 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 === """ || lower === """ || lower === """) return '"';
|
|
228
|
+
if (lower === "'" || lower === "'" || lower === "'") return "'";
|
|
229
|
+
if (lower === "<" || lower === "<" || lower === "<") return "<";
|
|
230
|
+
if (lower === ">" || lower === ">" || lower === ">") return ">";
|
|
231
|
+
if (lower === "&" || lower === "&" || lower === "&") 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
|
+
// `"` returns as `"` 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
|
+
}
|