@hueest/xray 0.2.0 → 0.3.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/dist/client.d.ts +28 -15
- package/dist/client.js +220 -78
- package/dist/core.d.ts +16 -27
- package/dist/core.js +33 -210
- package/dist/index.d.ts +80 -57
- package/dist/index.js +235 -596
- package/dist/{plate-Bv6W-GkA.d.ts → plate-BIr62u7l.d.ts} +55 -30
- package/dist/{plate-DoE1HEXp.js → plate-BRR6d8Se.js} +8 -4
- package/dist/project-DORoPAo7.js +10224 -0
- package/dist/{react.core-BzMG_cDy.d.ts → react.core-BUj8ziwh.d.ts} +3 -3
- package/dist/{react.core-IyFy2b_8.js → react.core-DMIhXHZF.js} +6 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.dev.d.ts +1 -1
- package/dist/react.dev.js +2 -2
- package/dist/react.js +1 -1
- package/dist/serialize-Cs7hUxmK.d.ts +107 -0
- package/package.json +2 -1
- package/dist/breakpoints-CMoFUUOv.js +0 -1393
- package/dist/serialize-B--oY2bV.d.ts +0 -143
|
@@ -1,1393 +0,0 @@
|
|
|
1
|
-
import { a as ROOT_ATTR, l as SPEC_INLINE, u as SPEC_LEAF } from "./plate-DoE1HEXp.js";
|
|
2
|
-
//#region src/classify.ts
|
|
3
|
-
/**
|
|
4
|
-
* Turn id-form rules + an id-tree into the shipped plate: each distinct
|
|
5
|
-
* `(tier, media, declarations)` becomes one class, named by a per-plate
|
|
6
|
-
* sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
|
|
7
|
-
* class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
|
|
8
|
-
* not a content hash: the plate prefix already keeps tokens disjoint across plates
|
|
9
|
-
* (the stitch-isolation guarantee), so a per-plate counter is collision-free by
|
|
10
|
-
* construction — and low-entropy ids compress far better than random hashes
|
|
11
|
-
* (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
|
|
12
|
-
* unlayered; the existing cascade is reproduced by emission order (losers
|
|
13
|
-
* first), so the classes are emitted in `(layered, spec, order)` order exactly
|
|
14
|
-
* as `renderRules` did.
|
|
15
|
-
*
|
|
16
|
-
* Pure data-in/data-out — runs at plugin load (node) and in tests.
|
|
17
|
-
*/
|
|
18
|
-
function classify(rules, tree, plateName) {
|
|
19
|
-
const classBase = `${CLASS_PREFIX}${encodePlateName(plateName)}-`;
|
|
20
|
-
const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
|
|
21
|
-
const nameForKey = /* @__PURE__ */ new Map();
|
|
22
|
-
const body = /* @__PURE__ */ new Map();
|
|
23
|
-
const usage = /* @__PURE__ */ new Map();
|
|
24
|
-
const emitOrder = [];
|
|
25
|
-
const nodeClasses = /* @__PURE__ */ new Map();
|
|
26
|
-
for (const rule of sorted) {
|
|
27
|
-
if (rule.decls.length === 0) continue;
|
|
28
|
-
const decls = rule.decls.join("; ");
|
|
29
|
-
const media = rule.media ?? [];
|
|
30
|
-
const container = rule.container ?? [];
|
|
31
|
-
const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
|
|
32
|
-
const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
|
|
33
|
-
let name = nameForKey.get(key);
|
|
34
|
-
if (name === void 0) {
|
|
35
|
-
name = classBase + nameForKey.size.toString(36);
|
|
36
|
-
nameForKey.set(key, name);
|
|
37
|
-
body.set(name, {
|
|
38
|
-
media,
|
|
39
|
-
container,
|
|
40
|
-
decls
|
|
41
|
-
});
|
|
42
|
-
usage.set(name, {
|
|
43
|
-
root: false,
|
|
44
|
-
desc: false
|
|
45
|
-
});
|
|
46
|
-
emitOrder.push(name);
|
|
47
|
-
}
|
|
48
|
-
const ids = rule.ids.length === 0 ? [0] : rule.ids;
|
|
49
|
-
for (const id of ids) {
|
|
50
|
-
const u = usage.get(name);
|
|
51
|
-
if (!u) throw new Error(`missing usage entry for ${name}`);
|
|
52
|
-
if (id === 0) u.root = true;
|
|
53
|
-
else u.desc = true;
|
|
54
|
-
let list = nodeClasses.get(id);
|
|
55
|
-
if (!list) {
|
|
56
|
-
list = [];
|
|
57
|
-
nodeClasses.set(id, list);
|
|
58
|
-
}
|
|
59
|
-
if (!list.includes(name)) list.push(name);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
const lines = [];
|
|
63
|
-
for (const name of emitOrder) {
|
|
64
|
-
const item = body.get(name);
|
|
65
|
-
const u = usage.get(name);
|
|
66
|
-
if (!item || !u) throw new Error(`missing class body for ${name}`);
|
|
67
|
-
const { media, container, decls } = item;
|
|
68
|
-
const sels = [];
|
|
69
|
-
if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
|
|
70
|
-
if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
|
|
71
|
-
let text = `${sels.join(", ")} { ${decls} }`;
|
|
72
|
-
for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
|
|
73
|
-
for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
|
|
74
|
-
lines.push(text);
|
|
75
|
-
}
|
|
76
|
-
return {
|
|
77
|
-
tree: render(tree, nodeClasses),
|
|
78
|
-
css: lines.join("\n")
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
/** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
|
|
82
|
-
function render(node, classes) {
|
|
83
|
-
const out = {};
|
|
84
|
-
if (node.leaf) out.leaf = node.leaf;
|
|
85
|
-
if (node.ref !== void 0) out.ref = node.ref;
|
|
86
|
-
const cls = classes.get(node.id);
|
|
87
|
-
if (cls && cls.length > 0) out.cls = cls.join(" ");
|
|
88
|
-
if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
/** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
|
|
92
|
-
const CLASS_PREFIX = "xr-";
|
|
93
|
-
/**
|
|
94
|
-
* Encode a plate name into a class-token prefix, injectively. This prefix *is*
|
|
95
|
-
* the isolation boundary across stitched plates (ADR 0007), so distinct plate
|
|
96
|
-
* names must never produce the same prefix — a collision would let one plate's
|
|
97
|
-
* `<style>` block reorder another's classes.
|
|
98
|
-
*
|
|
99
|
-
* Plate names are constrained by `isValidPlateName`: each path segment is
|
|
100
|
-
* `\w+(-\w+)*` (word chars with single interior hyphens, no doubled or edge
|
|
101
|
-
* hyphen) and segments are joined by `/`. The slash is the only char that is
|
|
102
|
-
* not class-safe, so rendering it as `--` is enough — and because no segment
|
|
103
|
-
* holds a literal `--` or an edge hyphen, no `-` ever abuts a `/`, so every
|
|
104
|
-
* `--` in the prefix unambiguously came from a `/`, making the map injective.
|
|
105
|
-
* Keeping `/` visually as `--` also leaves nested names readable in the
|
|
106
|
-
* generated CSS (`product--card`).
|
|
107
|
-
*/
|
|
108
|
-
function encodePlateName(name) {
|
|
109
|
-
return name.replace(/\//g, "--");
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* What the class identity keys on, alongside media + declarations:
|
|
113
|
-
* - `'tier'`: the discrete cascade tier — smaller (more bodies dedupe), but a
|
|
114
|
-
* rare same-tier inversion within the continuous `author` band can mis-order
|
|
115
|
-
* one property on one node (ADR 0007). The default.
|
|
116
|
-
* - `'full'`: the exact `spec` — provably no inversion, marginally larger.
|
|
117
|
-
* One-line flip if QA ever surfaces a mis-order.
|
|
118
|
-
*/
|
|
119
|
-
const SPEC_FOLD = "full";
|
|
120
|
-
function tierLabel(spec) {
|
|
121
|
-
if (spec <= -2) return "ctx";
|
|
122
|
-
if (spec === -1) return "fb";
|
|
123
|
-
if (spec >= 2e7) return "gate";
|
|
124
|
-
if (spec >= 1e7) return "inl";
|
|
125
|
-
if (spec >= 9e6) return "leaf";
|
|
126
|
-
return "au";
|
|
127
|
-
}
|
|
128
|
-
//#endregion
|
|
129
|
-
//#region src/css.ts
|
|
130
|
-
/**
|
|
131
|
-
* Pure CSS plumbing shared by both extraction strategies: which properties are
|
|
132
|
-
* layout (vs paint), approximate selector specificity for cascade-preserving
|
|
133
|
-
* ordering, and rendering scoped rules back to a single plate CSS string.
|
|
134
|
-
*/
|
|
135
|
-
const EXACT_PROPS = new Set([
|
|
136
|
-
"display",
|
|
137
|
-
"position",
|
|
138
|
-
"float",
|
|
139
|
-
"clear",
|
|
140
|
-
"box-sizing",
|
|
141
|
-
"top",
|
|
142
|
-
"right",
|
|
143
|
-
"bottom",
|
|
144
|
-
"left",
|
|
145
|
-
"width",
|
|
146
|
-
"height",
|
|
147
|
-
"block-size",
|
|
148
|
-
"inline-size",
|
|
149
|
-
"aspect-ratio",
|
|
150
|
-
"order",
|
|
151
|
-
"flex",
|
|
152
|
-
"gap",
|
|
153
|
-
"row-gap",
|
|
154
|
-
"column-gap",
|
|
155
|
-
"line-height",
|
|
156
|
-
"font",
|
|
157
|
-
"font-size",
|
|
158
|
-
"font-family",
|
|
159
|
-
"font-weight",
|
|
160
|
-
"font-style",
|
|
161
|
-
"white-space",
|
|
162
|
-
"text-align",
|
|
163
|
-
"text-indent",
|
|
164
|
-
"vertical-align",
|
|
165
|
-
"letter-spacing",
|
|
166
|
-
"word-spacing",
|
|
167
|
-
"word-break",
|
|
168
|
-
"overflow-wrap",
|
|
169
|
-
"text-wrap",
|
|
170
|
-
"transform",
|
|
171
|
-
"transform-origin",
|
|
172
|
-
"translate",
|
|
173
|
-
"scale",
|
|
174
|
-
"rotate",
|
|
175
|
-
"table-layout",
|
|
176
|
-
"border-collapse",
|
|
177
|
-
"border-spacing",
|
|
178
|
-
"caption-side",
|
|
179
|
-
"column-count",
|
|
180
|
-
"column-width",
|
|
181
|
-
"columns",
|
|
182
|
-
"column-span",
|
|
183
|
-
"column-fill",
|
|
184
|
-
"writing-mode",
|
|
185
|
-
"direction",
|
|
186
|
-
"contain",
|
|
187
|
-
"content-visibility",
|
|
188
|
-
"container",
|
|
189
|
-
"container-type",
|
|
190
|
-
"container-name",
|
|
191
|
-
"visibility"
|
|
192
|
-
]);
|
|
193
|
-
const PATTERN_PROPS = [
|
|
194
|
-
/^(margin|padding)(-(top|right|bottom|left|block|inline))?(-(start|end))?$/,
|
|
195
|
-
/^inset(-(block|inline))?(-(start|end))?$/,
|
|
196
|
-
/^border(-(top|right|bottom|left|block|inline))?(-(start|end))?-(width|style)$/,
|
|
197
|
-
/^border(-(top|bottom)-(left|right))?-radius$/,
|
|
198
|
-
/^border-(start|end)-(start|end)-radius$/,
|
|
199
|
-
/^grid(-|$)/,
|
|
200
|
-
/^flex-/,
|
|
201
|
-
/^(min|max)-(width|height|block-size|inline-size)$/,
|
|
202
|
-
/^(align|justify|place)-(items|content|self)$/,
|
|
203
|
-
/^overflow(-[xy])?$/
|
|
204
|
-
];
|
|
205
|
-
/** Layout properties are the only declarations a plate carries; paint stays out (ADR 0003). */
|
|
206
|
-
function isLayoutProp(prop) {
|
|
207
|
-
if (prop.startsWith("--")) return false;
|
|
208
|
-
if (EXACT_PROPS.has(prop)) return true;
|
|
209
|
-
return PATTERN_PROPS.some((re) => re.test(prop));
|
|
210
|
-
}
|
|
211
|
-
function resolveVars(value, computed) {
|
|
212
|
-
let out = value;
|
|
213
|
-
for (let i = 0; i < 10 && out.includes("var("); i++) {
|
|
214
|
-
const next = out.replace(/var\(\s*(--[\w-]+)\s*(?:,([^()]*))?\)/g, (_, name, fallback) => computed.getPropertyValue(name).trim() || fallback?.trim() || "");
|
|
215
|
-
if (next === out) break;
|
|
216
|
-
out = next;
|
|
217
|
-
}
|
|
218
|
-
return out;
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* A grid track list that is only `px` lengths is a frozen computed value —
|
|
222
|
-
* authors write `fr`/`%`/`minmax`/`auto`, not sub-pixel tracks, so this is
|
|
223
|
-
* almost always a var()-driven `grid` shorthand the engine dropped, leaving us
|
|
224
|
-
* the resolved computed track sizes. Frozen px locks the grid to the capture
|
|
225
|
-
* width (off-centre at any other size); we can't recover the author value, so we
|
|
226
|
-
* reconstruct a responsive one. Already-fluid lists (fr/%/minmax/repeat/auto/…)
|
|
227
|
-
* pass through untouched.
|
|
228
|
-
*
|
|
229
|
-
* - Rows follow their content in a skeleton (the bones set the height), so a
|
|
230
|
-
* frozen row list becomes `auto` tracks.
|
|
231
|
-
* - Columns are almost always a centred layout: a wide content track flanked by
|
|
232
|
-
* gutters. Make the gutters flexible and equal (`1fr`) so the grid recentres
|
|
233
|
-
* at any width — this also erases the scrollbar-width asymmetry the capture
|
|
234
|
-
* picked up — and cap the content track(s) at the captured size
|
|
235
|
-
* (`minmax(0, px)`) so they shrink on narrow viewports but never overgrow. A
|
|
236
|
-
* uniform list (no track ≥2× the others — e.g. an even `repeat(n, 1fr)`) has
|
|
237
|
-
* no gutters, so every track becomes `1fr`.
|
|
238
|
-
*
|
|
239
|
-
* A single capture can't tell fixed tracks from flexible ones; the precise fix is
|
|
240
|
-
* to diff track sizes across the capture-all widths (a later refinement).
|
|
241
|
-
*/
|
|
242
|
-
const PX_ONLY_TRACKS = /^\s*(?:-?[\d.]+px\s*)+$/;
|
|
243
|
-
function fluidTracks(value, axis) {
|
|
244
|
-
if (!PX_ONLY_TRACKS.test(value)) return value;
|
|
245
|
-
const tracks = value.trim().split(/\s+/).map((t) => Number.parseFloat(t));
|
|
246
|
-
if (axis === "rows") return tracks.map(() => "auto").join(" ");
|
|
247
|
-
const max = Math.max(...tracks);
|
|
248
|
-
const isContent = tracks.map((t) => t >= max * .5);
|
|
249
|
-
if (isContent.every(Boolean)) return tracks.map(() => "1fr").join(" ");
|
|
250
|
-
return tracks.map((t, i) => isContent[i] ? `minmax(0, ${t}px)` : "1fr").join(" ");
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Extract the layout-relevant declarations of a style block as `prop: value`
|
|
254
|
-
* strings. Custom-property references are resolved against `computed` (the
|
|
255
|
-
* matched element's computed style) — plates carry no `var()` definitions, so
|
|
256
|
-
* references must be frozen to their capture-time value.
|
|
257
|
-
*/
|
|
258
|
-
function filterLayoutDecls(style, computed) {
|
|
259
|
-
const out = [];
|
|
260
|
-
for (let i = 0; i < style.length; i++) {
|
|
261
|
-
const prop = style.item(i);
|
|
262
|
-
if (!isLayoutProp(prop)) continue;
|
|
263
|
-
let value = style.getPropertyValue(prop).trim();
|
|
264
|
-
if (value.includes("var(") && computed) value = resolveVars(value, computed).trim();
|
|
265
|
-
if (!value && computed) value = computed.getPropertyValue(prop).trim();
|
|
266
|
-
if (!value) continue;
|
|
267
|
-
if (prop === "grid-template-columns") value = fluidTracks(value, "columns");
|
|
268
|
-
else if (prop === "grid-template-rows") value = fluidTracks(value, "rows");
|
|
269
|
-
const priority = style.getPropertyPriority(prop);
|
|
270
|
-
out.push(`${prop}: ${value}${priority ? " !important" : ""}`);
|
|
271
|
-
}
|
|
272
|
-
return out;
|
|
273
|
-
}
|
|
274
|
-
/**
|
|
275
|
-
* Approximate selector specificity packed as a single comparable number
|
|
276
|
-
* (`a * 1e6 + b * 1e3 + c`). Good enough to order rewritten rules so the
|
|
277
|
-
* original cascade winner still wins; known approximation: `:is()`/`:not()`
|
|
278
|
-
* sum their arguments instead of taking the max.
|
|
279
|
-
*/
|
|
280
|
-
function specificity(selector) {
|
|
281
|
-
let a = 0;
|
|
282
|
-
let b = 0;
|
|
283
|
-
let c = 0;
|
|
284
|
-
let s = selector.replace(/(['"]).*?\1/g, " ");
|
|
285
|
-
s = s.replace(/:where\([^()]*\)/g, " ");
|
|
286
|
-
s = s.replace(/:(nth-[a-z-]+|lang|dir)\([^()]*\)/g, () => {
|
|
287
|
-
b++;
|
|
288
|
-
return " ";
|
|
289
|
-
});
|
|
290
|
-
s = s.replace(/:(not|is|has)\(/g, " (");
|
|
291
|
-
s = s.replace(/\[[^\]]*\]/g, () => {
|
|
292
|
-
b++;
|
|
293
|
-
return " ";
|
|
294
|
-
});
|
|
295
|
-
s = s.replace(/#[a-zA-Z][\w-]*/g, () => {
|
|
296
|
-
a++;
|
|
297
|
-
return " ";
|
|
298
|
-
});
|
|
299
|
-
s = s.replace(/\.[a-zA-Z][\w-]*/g, () => {
|
|
300
|
-
b++;
|
|
301
|
-
return " ";
|
|
302
|
-
});
|
|
303
|
-
s = s.replace(/::[a-z-]+/g, () => {
|
|
304
|
-
c++;
|
|
305
|
-
return " ";
|
|
306
|
-
});
|
|
307
|
-
s = s.replace(/:[a-z-]+/g, () => {
|
|
308
|
-
b++;
|
|
309
|
-
return " ";
|
|
310
|
-
});
|
|
311
|
-
for (const _ of s.matchAll(/[a-zA-Z][\w-]*/g)) c++;
|
|
312
|
-
return a * 1e6 + b * 1e3 + c;
|
|
313
|
-
}
|
|
314
|
-
/** Split a selector list on top-level commas (commas inside `()`/`[]`/strings don't split). */
|
|
315
|
-
function splitSelectorList(selectorText) {
|
|
316
|
-
const out = [];
|
|
317
|
-
let depth = 0;
|
|
318
|
-
let quote = "";
|
|
319
|
-
let cur = "";
|
|
320
|
-
for (const ch of selectorText) {
|
|
321
|
-
if (quote) {
|
|
322
|
-
cur += ch;
|
|
323
|
-
if (ch === quote) quote = "";
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
if (ch === "\"" || ch === "'") {
|
|
327
|
-
quote = ch;
|
|
328
|
-
cur += ch;
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (ch === "(" || ch === "[") depth++;
|
|
332
|
-
else if (ch === ")" || ch === "]") depth--;
|
|
333
|
-
if (ch === "," && depth === 0) {
|
|
334
|
-
if (cur.trim()) out.push(cur.trim());
|
|
335
|
-
cur = "";
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
cur += ch;
|
|
339
|
-
}
|
|
340
|
-
if (cur.trim()) out.push(cur.trim());
|
|
341
|
-
return out;
|
|
342
|
-
}
|
|
343
|
-
//#endregion
|
|
344
|
-
//#region src/similarity.ts
|
|
345
|
-
const kindCounts = /* @__PURE__ */ new WeakMap();
|
|
346
|
-
/** Descendant tally by kind — the cheap shape summary similarity is scored on. */
|
|
347
|
-
function countsOf(node) {
|
|
348
|
-
const cached = kindCounts.get(node);
|
|
349
|
-
if (cached) return cached;
|
|
350
|
-
const counts = {
|
|
351
|
-
text: 0,
|
|
352
|
-
media: 0,
|
|
353
|
-
box: 0,
|
|
354
|
-
container: 0
|
|
355
|
-
};
|
|
356
|
-
counts[node.leaf ?? "container"]++;
|
|
357
|
-
for (const kid of node.kids ?? []) {
|
|
358
|
-
const kidCounts = countsOf(kid);
|
|
359
|
-
counts.text += kidCounts.text;
|
|
360
|
-
counts.media += kidCounts.media;
|
|
361
|
-
counts.box += kidCounts.box;
|
|
362
|
-
counts.container += kidCounts.container;
|
|
363
|
-
}
|
|
364
|
-
kindCounts.set(node, counts);
|
|
365
|
-
return counts;
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* How alike two subtrees are, 0..1 — overlap of their descendant kind
|
|
369
|
-
* tallies. The same element rendered at another viewport (or the next item
|
|
370
|
-
* of the same list) keeps roughly the same census; different sections don't.
|
|
371
|
-
*/
|
|
372
|
-
function similarity(a, b) {
|
|
373
|
-
if ((a.leaf ?? "container") !== (b.leaf ?? "container")) return 0;
|
|
374
|
-
if (a.leaf) return 1;
|
|
375
|
-
const ca = countsOf(a);
|
|
376
|
-
const cb = countsOf(b);
|
|
377
|
-
let diff = 0;
|
|
378
|
-
let total = 0;
|
|
379
|
-
for (const kind of [
|
|
380
|
-
"text",
|
|
381
|
-
"media",
|
|
382
|
-
"box",
|
|
383
|
-
"container"
|
|
384
|
-
]) {
|
|
385
|
-
diff += Math.abs(ca[kind] - cb[kind]);
|
|
386
|
-
total += ca[kind] + cb[kind];
|
|
387
|
-
}
|
|
388
|
-
return total === 0 ? 1 : 1 - diff / total;
|
|
389
|
-
}
|
|
390
|
-
//#endregion
|
|
391
|
-
//#region src/diagnostics.ts
|
|
392
|
-
function createDiagnostics() {
|
|
393
|
-
const byCode = /* @__PURE__ */ new Map();
|
|
394
|
-
return {
|
|
395
|
-
add(code, options) {
|
|
396
|
-
const bump = options?.count ?? 1;
|
|
397
|
-
const existing = byCode.get(code);
|
|
398
|
-
if (existing) {
|
|
399
|
-
existing.count = (existing.count ?? 0) + bump;
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
byCode.set(code, {
|
|
403
|
-
code,
|
|
404
|
-
message: DIAGNOSTIC_MESSAGES[code],
|
|
405
|
-
count: bump,
|
|
406
|
-
...options?.detail !== void 0 ? { detail: options.detail } : {}
|
|
407
|
-
});
|
|
408
|
-
},
|
|
409
|
-
list() {
|
|
410
|
-
return [...byCode.values()];
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Build a single diagnostic record with its canonical message, for callers
|
|
416
|
-
* outside the walk that surface a diagnostic directly (e.g. the client mapping
|
|
417
|
-
* `CaptureTooLargeError`, or the server reporting an invalid committed plate).
|
|
418
|
-
* Uses the same message table as the collector so the text is identical
|
|
419
|
-
* everywhere. `detail` must never carry user CSS or captured text.
|
|
420
|
-
*/
|
|
421
|
-
function makeDiagnostic(code, options) {
|
|
422
|
-
return {
|
|
423
|
-
code,
|
|
424
|
-
message: DIAGNOSTIC_MESSAGES[code],
|
|
425
|
-
...options?.count !== void 0 ? { count: options.count } : {},
|
|
426
|
-
...options?.detail !== void 0 ? { detail: options.detail } : {}
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
/**
|
|
430
|
-
* The human-readable message per code, shared verbatim by browser and server so
|
|
431
|
-
* their text cannot drift. Messages describe the omission and its fidelity
|
|
432
|
-
* cost; none echoes user CSS or captured text. `formatDiagnostic` appends the
|
|
433
|
-
* aggregate count and any short detail.
|
|
434
|
-
*/
|
|
435
|
-
const DIAGNOSTIC_MESSAGES = {
|
|
436
|
-
"unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
|
|
437
|
-
"unreadable-import": "could not read an @import (likely cross-origin); its rules were not captured",
|
|
438
|
-
"skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
|
|
439
|
-
"skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
|
|
440
|
-
"unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
|
|
441
|
-
"dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, script/style/etc.)",
|
|
442
|
-
"pruned-run": "collapsed long runs of similar siblings (a list/grid) to their first few items to keep the plate small",
|
|
443
|
-
"too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
|
|
444
|
-
"invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
|
|
445
|
-
};
|
|
446
|
-
/**
|
|
447
|
-
* The single shared formatter. Renders one diagnostic to a stable one-line
|
|
448
|
-
* string used IDENTICALLY by the browser console and the Vite server output, so
|
|
449
|
-
* the two can never disagree. Shape: `<message> (<count>)[: <detail>]`. Count is
|
|
450
|
-
* omitted when 1 or absent; detail is appended only when present and is never
|
|
451
|
-
* user content.
|
|
452
|
-
*/
|
|
453
|
-
function formatDiagnostic(diagnostic) {
|
|
454
|
-
const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
|
|
455
|
-
const detail = diagnostic.detail !== void 0 ? `: ${diagnostic.detail}` : "";
|
|
456
|
-
return `${diagnostic.message}${count}${detail}`;
|
|
457
|
-
}
|
|
458
|
-
/** The grouped-warning header for a plate's diagnostics, shared by browser and server. */
|
|
459
|
-
function diagnosticsHeader(name) {
|
|
460
|
-
return `[xray] "${name}" captured with reduced fidelity:`;
|
|
461
|
-
}
|
|
462
|
-
//#endregion
|
|
463
|
-
//#region src/serialize.ts
|
|
464
|
-
/**
|
|
465
|
-
* Capture-only DOM annotations (ADR 0018), read DURING the walk to shape the
|
|
466
|
-
* `PlateNode` tree and NEVER written into the tree, the Plate, or the persisted
|
|
467
|
-
* JSON. They are the declarative 90% of capture customization; the programmable
|
|
468
|
-
* `captureWalker` is the 10% escape hatch.
|
|
469
|
-
*
|
|
470
|
-
* - `data-xr-ignore` drops the element AND its subtree (it contributes no bone).
|
|
471
|
-
* - `data-xr-bone-kind="text|media|box"` collapses the element's whole subtree
|
|
472
|
-
* into ONE Bone leaf of the given kind, sized from the element's own box.
|
|
473
|
-
*
|
|
474
|
-
* Both are subordinate to the stitch guard (ADR 0006): a nested `<Skeleton>`
|
|
475
|
-
* boundary always becomes a stitch first, so neither annotation can make a
|
|
476
|
-
* boundary leak into the parent plate. See `walk`.
|
|
477
|
-
*/
|
|
478
|
-
const XR_IGNORE_ATTR = "data-xr-ignore";
|
|
479
|
-
const XR_BONE_KIND_ATTR = "data-xr-bone-kind";
|
|
480
|
-
/** The `LeafKind` values an author may legally name in `data-xr-bone-kind`. */
|
|
481
|
-
const LEAF_KINDS = [
|
|
482
|
-
"text",
|
|
483
|
-
"media",
|
|
484
|
-
"box"
|
|
485
|
-
];
|
|
486
|
-
/** Narrow an arbitrary `data-xr-bone-kind` string to a `LeafKind`, or null if unknown. */
|
|
487
|
-
function asLeafKind(value) {
|
|
488
|
-
return LEAF_KINDS.find((kind) => kind === value) ?? null;
|
|
489
|
-
}
|
|
490
|
-
/** Reinterpret the internal decision as the opaque result at the ctx/next boundary. Zero runtime cost. */
|
|
491
|
-
function wrap(node) {
|
|
492
|
-
return node;
|
|
493
|
-
}
|
|
494
|
-
/** Recover the internal decision from an opaque result inside the engine. Zero runtime cost. */
|
|
495
|
-
function unwrap(result) {
|
|
496
|
-
return result;
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Typed identity wrapper for authoring a capture walker (ADR 0018) — the
|
|
500
|
-
* `defineConfig` pattern. It returns its argument unchanged at runtime; its only
|
|
501
|
-
* job is to infer and check the `XrayCaptureWalker` shape at the call site so an
|
|
502
|
-
* author gets completion and a typo in a hook name is caught.
|
|
503
|
-
*/
|
|
504
|
-
function defineXrayCaptureWalker(walker) {
|
|
505
|
-
return walker;
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* A per-capture memo of `getComputedStyle(el)` keyed by element, scoped to ONE
|
|
509
|
-
* synchronous capture (capture performance plan, piece 5). The walk reads each
|
|
510
|
-
* entry node's own style through here, and the ancestor-walking helpers
|
|
511
|
-
* (contrast, line-height, clipping) memoize the parents they climb. A
|
|
512
|
-
* `CSSStyleDeclaration` is a live view, so this WeakMap lives only as long as
|
|
513
|
-
* the single capture that built it and is then dropped — it is NEVER cached
|
|
514
|
-
* across captures or time (a stored style would silently go stale; the plan
|
|
515
|
-
* forbids it). `get` lazily computes and stores on first lookup.
|
|
516
|
-
*/
|
|
517
|
-
var StyleCache = class {
|
|
518
|
-
#win;
|
|
519
|
-
#map = /* @__PURE__ */ new WeakMap();
|
|
520
|
-
constructor(win) {
|
|
521
|
-
this.#win = win;
|
|
522
|
-
}
|
|
523
|
-
get(el) {
|
|
524
|
-
const hit = this.#map.get(el);
|
|
525
|
-
if (hit) return hit;
|
|
526
|
-
const cs = this.#win.getComputedStyle(el);
|
|
527
|
-
this.#map.set(el, cs);
|
|
528
|
-
return cs;
|
|
529
|
-
}
|
|
530
|
-
};
|
|
531
|
-
function isElementNode(node) {
|
|
532
|
-
return node.nodeType === 1;
|
|
533
|
-
}
|
|
534
|
-
function isTextNode(node) {
|
|
535
|
-
return node.nodeType === 3;
|
|
536
|
-
}
|
|
537
|
-
const SKIP_TAGS = new Set([
|
|
538
|
-
"SCRIPT",
|
|
539
|
-
"STYLE",
|
|
540
|
-
"LINK",
|
|
541
|
-
"META",
|
|
542
|
-
"TEMPLATE",
|
|
543
|
-
"NOSCRIPT",
|
|
544
|
-
"TITLE"
|
|
545
|
-
]);
|
|
546
|
-
const MEDIA_TAGS = new Set([
|
|
547
|
-
"IMG",
|
|
548
|
-
"SVG",
|
|
549
|
-
"VIDEO",
|
|
550
|
-
"CANVAS",
|
|
551
|
-
"PICTURE",
|
|
552
|
-
"IFRAME",
|
|
553
|
-
"OBJECT",
|
|
554
|
-
"EMBED",
|
|
555
|
-
"INPUT",
|
|
556
|
-
"SELECT",
|
|
557
|
-
"TEXTAREA",
|
|
558
|
-
"PROGRESS",
|
|
559
|
-
"METER",
|
|
560
|
-
"AUDIO"
|
|
561
|
-
]);
|
|
562
|
-
/** Pseudo-classes that only apply under interaction — a static skeleton never needs them. */
|
|
563
|
-
const DYNAMIC_PSEUDO = /:(hover|active|focus|focus-visible|focus-within|visited|link|target|enabled|disabled|checked)\b/;
|
|
564
|
-
/** Pseudo-element rules are skipped for v1 (no box to map them onto). */
|
|
565
|
-
const PSEUDO_ELEMENT = /::?(before|after|placeholder|selection|marker|backdrop|first-line|first-letter|file-selector-button)\b/;
|
|
566
|
-
function px(n) {
|
|
567
|
-
return `${Math.round(n * 10) / 10}px`;
|
|
568
|
-
}
|
|
569
|
-
function isInlineish(display) {
|
|
570
|
-
return display === "inline" || display.startsWith("inline-");
|
|
571
|
-
}
|
|
572
|
-
function pad(value) {
|
|
573
|
-
return Number.parseFloat(value) || 0;
|
|
574
|
-
}
|
|
575
|
-
/** A computed value already resolved to a `px` length, or null (`normal`, a unitless ratio, ''). */
|
|
576
|
-
function pxValue(value) {
|
|
577
|
-
return /(?:^|\s)-?[\d.]+px$/.test(value.trim()) ? Number.parseFloat(value) : null;
|
|
578
|
-
}
|
|
579
|
-
function parseRgb(value) {
|
|
580
|
-
const match = /rgba?\(([^)]+)\)/.exec(value);
|
|
581
|
-
if (!match?.[1]) return null;
|
|
582
|
-
const parts = match[1].split(/[,/]/).map((p) => Number.parseFloat(p.trim()));
|
|
583
|
-
const [r, g, b] = parts;
|
|
584
|
-
if (r === void 0 || g === void 0 || b === void 0) return null;
|
|
585
|
-
return {
|
|
586
|
-
r,
|
|
587
|
-
g,
|
|
588
|
-
b,
|
|
589
|
-
a: parts.length > 3 ? parts[3] ?? 1 : 1
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
const rgbCache = /* @__PURE__ */ new Map();
|
|
593
|
-
/**
|
|
594
|
-
* Resolve any computed CSS colour to sRGB bytes. `rgb()/rgba()` parse directly
|
|
595
|
-
* (the cheap path, and the only one a layout-less test env needs); everything
|
|
596
|
-
* else — `oklch()`, `color()`, `hsl()`, named — is painted onto a 1×1 canvas
|
|
597
|
-
* and read back, since browsers serialise a computed colour in the space it was
|
|
598
|
-
* authored in (Chrome returns `oklch(...)` verbatim), which a regex can't read.
|
|
599
|
-
* Without this an oklch-filled control reads as no fill and never becomes a box.
|
|
600
|
-
* Cached by string: the same token recurs across every node of a themed card.
|
|
601
|
-
*/
|
|
602
|
-
function toRgb(value, win) {
|
|
603
|
-
const direct = parseRgb(value);
|
|
604
|
-
if (direct) return direct;
|
|
605
|
-
const cached = rgbCache.get(value);
|
|
606
|
-
if (cached !== void 0) return cached;
|
|
607
|
-
let result = null;
|
|
608
|
-
try {
|
|
609
|
-
const ctx = win.document.createElement("canvas").getContext("2d");
|
|
610
|
-
if (ctx) {
|
|
611
|
-
ctx.fillStyle = value;
|
|
612
|
-
ctx.fillRect(0, 0, 1, 1);
|
|
613
|
-
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
614
|
-
if (r !== void 0 && g !== void 0 && b !== void 0) result = {
|
|
615
|
-
r,
|
|
616
|
-
g,
|
|
617
|
-
b,
|
|
618
|
-
a: (a ?? 255) / 255
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
} catch {
|
|
622
|
-
result = null;
|
|
623
|
-
}
|
|
624
|
-
rgbCache.set(value, result);
|
|
625
|
-
return result;
|
|
626
|
-
}
|
|
627
|
-
/** A background-image that actually paints (a url or gradient), not `none`/`initial`. */
|
|
628
|
-
function hasPaintedImage(cs) {
|
|
629
|
-
return /url\(|gradient\(/.test(cs.backgroundImage);
|
|
630
|
-
}
|
|
631
|
-
function hasVisibleBackground(cs, win) {
|
|
632
|
-
if (hasPaintedImage(cs)) return true;
|
|
633
|
-
const bg = toRgb(cs.backgroundColor, win);
|
|
634
|
-
return bg !== null && bg.a > 0;
|
|
635
|
-
}
|
|
636
|
-
/** Fill that visibly contrasts with the nearest painted ancestor — meaning ~24 per channel. */
|
|
637
|
-
const FILL_CONTRAST = 72;
|
|
638
|
-
/**
|
|
639
|
-
* Does this element paint a fill that stands out from what's behind it? A
|
|
640
|
-
* background image always counts; a background color counts only when it's
|
|
641
|
-
* mostly opaque and clearly differs from the nearest ancestor's background
|
|
642
|
-
* (so a white button on a white card doesn't, but a blue one does).
|
|
643
|
-
*/
|
|
644
|
-
function hasContrastingFill(el, cs, win, styles) {
|
|
645
|
-
if (hasPaintedImage(cs)) return true;
|
|
646
|
-
const bg = toRgb(cs.backgroundColor, win);
|
|
647
|
-
if (!bg || bg.a < .5) return false;
|
|
648
|
-
let ancestor = el.parentElement;
|
|
649
|
-
let behind = null;
|
|
650
|
-
while (ancestor) {
|
|
651
|
-
const candidate = toRgb(styles.get(ancestor).backgroundColor, win);
|
|
652
|
-
if (candidate && candidate.a > 0) {
|
|
653
|
-
behind = candidate;
|
|
654
|
-
break;
|
|
655
|
-
}
|
|
656
|
-
ancestor = ancestor.parentElement;
|
|
657
|
-
}
|
|
658
|
-
const base = behind ?? {
|
|
659
|
-
r: 255,
|
|
660
|
-
g: 255,
|
|
661
|
-
b: 255,
|
|
662
|
-
a: 1
|
|
663
|
-
};
|
|
664
|
-
return Math.abs(bg.r - base.r) + Math.abs(bg.g - base.g) + Math.abs(bg.b - base.b) > FILL_CONTRAST;
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Capture the rendered subtree(s) under a Skeleton into a Plate. `roots` are
|
|
668
|
-
* the component's top-level elements (the children of the layout-neutral
|
|
669
|
-
* wrapper); the synthetic root node (id 0) stands in for the wrapper itself.
|
|
670
|
-
*/
|
|
671
|
-
function capture(roots, options) {
|
|
672
|
-
const view = captureRegime(roots, {
|
|
673
|
-
walker: options.walker,
|
|
674
|
-
captureRootIsBoundary: options.captureRootIsBoundary
|
|
675
|
-
});
|
|
676
|
-
const { tree, css } = classify(view.rules, view.tree, options.name);
|
|
677
|
-
return {
|
|
678
|
-
v: 1,
|
|
679
|
-
name: options.name,
|
|
680
|
-
tree,
|
|
681
|
-
css
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
/**
|
|
685
|
-
* Thrown when a subtree has more nodes than the capture limit — almost always
|
|
686
|
-
* a `<Skeleton>` sitting too high in the tree. Raised after the cheap walk and
|
|
687
|
-
* before the O(rules × nodes) CSS extraction, so an oversized capture is
|
|
688
|
-
* refused without freezing the main thread on it.
|
|
689
|
-
*/
|
|
690
|
-
var CaptureTooLargeError = class extends Error {
|
|
691
|
-
nodeCount;
|
|
692
|
-
/** Folds the oversized-capture warning into the shared diagnostics vocabulary (diagnostics.ts). */
|
|
693
|
-
code = "too-large";
|
|
694
|
-
constructor(nodeCount) {
|
|
695
|
-
super(`xray: capture has ${nodeCount} nodes, over the limit`);
|
|
696
|
-
this.name = "CaptureTooLargeError";
|
|
697
|
-
this.nodeCount = nodeCount;
|
|
698
|
-
}
|
|
699
|
-
};
|
|
700
|
-
/**
|
|
701
|
-
* Capture one view's worth of a component: the tree plus id-form rules,
|
|
702
|
-
* with the viewport width it was taken at. The caller (the dev client)
|
|
703
|
-
* derives the view interval and ships it off; `capture` is the
|
|
704
|
-
* single-view convenience on top.
|
|
705
|
-
*/
|
|
706
|
-
function captureRegime(roots, options = {}) {
|
|
707
|
-
const doc = roots[0]?.ownerDocument;
|
|
708
|
-
const win = doc?.defaultView;
|
|
709
|
-
if (!doc || !win) throw new Error("xray: capture roots must be attached to a document");
|
|
710
|
-
const diagnostics = createDiagnostics();
|
|
711
|
-
const styles = new StyleCache(win);
|
|
712
|
-
const { tree, entries } = walkAll(roots, win, styles, diagnostics, options.walker, options.captureRootIsBoundary ?? false);
|
|
713
|
-
if (options.maxNodes !== void 0 && entries.length > options.maxNodes) throw new CaptureTooLargeError(entries.length);
|
|
714
|
-
const rules = copyRules(entries, doc, win, styles, diagnostics);
|
|
715
|
-
const list = diagnostics.list();
|
|
716
|
-
return {
|
|
717
|
-
width: win.innerWidth,
|
|
718
|
-
tree,
|
|
719
|
-
rules,
|
|
720
|
-
...list.length > 0 ? { diagnostics: list } : {}
|
|
721
|
-
};
|
|
722
|
-
}
|
|
723
|
-
function walkAll(roots, win, styles, diagnostics, walker, captureRootIsBoundary = false) {
|
|
724
|
-
const state = {
|
|
725
|
-
nextId: 1,
|
|
726
|
-
entries: [],
|
|
727
|
-
win,
|
|
728
|
-
styles,
|
|
729
|
-
diagnostics,
|
|
730
|
-
walker
|
|
731
|
-
};
|
|
732
|
-
const kids = [];
|
|
733
|
-
for (const root of roots) {
|
|
734
|
-
const node = walk(root, state, captureRootIsBoundary);
|
|
735
|
-
if (node) kids.push(node);
|
|
736
|
-
}
|
|
737
|
-
const raw = kids.length > 0 ? {
|
|
738
|
-
id: 0,
|
|
739
|
-
kids
|
|
740
|
-
} : { id: 0 };
|
|
741
|
-
const dropped = /* @__PURE__ */ new Set();
|
|
742
|
-
return {
|
|
743
|
-
tree: pruneRuns(raw, dropped, diagnostics),
|
|
744
|
-
entries: dropped.size > 0 ? state.entries.filter((entry) => !dropped.has(entry.id)) : state.entries
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
/** A run longer than this collapses down to the first few items. */
|
|
748
|
-
const RUN_LIMIT = 8;
|
|
749
|
-
const RUN_KEEP = 5;
|
|
750
|
-
const RUN_SIMILARITY = .9;
|
|
751
|
-
/**
|
|
752
|
-
* Long runs of similar siblings are lists — a 200-offering grid, an endless
|
|
753
|
-
* feed. A skeleton conventionally suggests a list with a few rows, and
|
|
754
|
-
* capturing every row balloons the plate file, so runs collapse to their
|
|
755
|
-
* first items. The dropped ids are pruned from the entries so no rules are
|
|
756
|
-
* emitted for them.
|
|
757
|
-
*/
|
|
758
|
-
function pruneRuns(node, dropped, diagnostics) {
|
|
759
|
-
const kids = node.kids;
|
|
760
|
-
if (!kids || kids.length === 0) return node;
|
|
761
|
-
const pruned = [];
|
|
762
|
-
let i = 0;
|
|
763
|
-
while (i < kids.length) {
|
|
764
|
-
const head = kids[i];
|
|
765
|
-
if (!head) break;
|
|
766
|
-
let j = i + 1;
|
|
767
|
-
while (j < kids.length) {
|
|
768
|
-
const next = kids[j];
|
|
769
|
-
if (!next || similarity(head, next) < RUN_SIMILARITY) break;
|
|
770
|
-
j++;
|
|
771
|
-
}
|
|
772
|
-
const keep = j - i > RUN_LIMIT ? RUN_KEEP : j - i;
|
|
773
|
-
if (j - i > keep) diagnostics.add("pruned-run", { count: j - i - keep });
|
|
774
|
-
for (let k = i; k < i + keep; k++) {
|
|
775
|
-
const kid = kids[k];
|
|
776
|
-
if (kid) pruned.push(pruneRuns(kid, dropped, diagnostics));
|
|
777
|
-
}
|
|
778
|
-
for (let k = i + keep; k < j; k++) {
|
|
779
|
-
const kid = kids[k];
|
|
780
|
-
if (kid) collectIds(kid, dropped);
|
|
781
|
-
}
|
|
782
|
-
i = j;
|
|
783
|
-
}
|
|
784
|
-
return {
|
|
785
|
-
...node,
|
|
786
|
-
kids: pruned
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
function collectIds(node, out) {
|
|
790
|
-
out.add(node.id);
|
|
791
|
-
for (const kid of node.kids ?? []) collectIds(kid, out);
|
|
792
|
-
}
|
|
793
|
-
function walk(el, state, isCaptureRoot = false) {
|
|
794
|
-
const tag = el.tagName.toUpperCase();
|
|
795
|
-
if (SKIP_TAGS.has(tag)) {
|
|
796
|
-
state.diagnostics.add("dropped-tag");
|
|
797
|
-
return null;
|
|
798
|
-
}
|
|
799
|
-
const cs = state.styles.get(el);
|
|
800
|
-
if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") {
|
|
801
|
-
state.diagnostics.add("dropped-tag");
|
|
802
|
-
return null;
|
|
803
|
-
}
|
|
804
|
-
if (!isCaptureRoot) {
|
|
805
|
-
const refName = el.getAttribute("data-xr-boundary") ?? el.getAttribute("data-xr-root");
|
|
806
|
-
if (refName) return {
|
|
807
|
-
id: state.nextId++,
|
|
808
|
-
ref: refName
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
const walker = state.walker;
|
|
812
|
-
if (walker?.element) {
|
|
813
|
-
const next = () => wrap(annotationWalk(el, cs, state));
|
|
814
|
-
const ctx = {
|
|
815
|
-
el,
|
|
816
|
-
ignore: () => wrap(ignoreElement(state)),
|
|
817
|
-
bone: (opts) => wrap(boneElement(el, cs, opts?.kind, state)),
|
|
818
|
-
walk: next
|
|
819
|
-
};
|
|
820
|
-
return unwrap(walker.element(ctx, next));
|
|
821
|
-
}
|
|
822
|
-
return annotationWalk(el, cs, state);
|
|
823
|
-
}
|
|
824
|
-
/**
|
|
825
|
-
* The built-in annotation walker (ADR 0018), the rung between the custom walker
|
|
826
|
-
* and the default classifier. It honors the capture-only `data-xr-*`
|
|
827
|
-
* annotations and otherwise falls through to the default classifier. The stitch
|
|
828
|
-
* guard already ran in `walk`, so a boundary element never reaches here — an
|
|
829
|
-
* annotation can never override a nested `<Skeleton>` (ADR 0006).
|
|
830
|
-
*/
|
|
831
|
-
function annotationWalk(el, cs, state) {
|
|
832
|
-
if (el.hasAttribute(XR_IGNORE_ATTR)) return ignoreElement(state);
|
|
833
|
-
const kindAttr = el.getAttribute(XR_BONE_KIND_ATTR);
|
|
834
|
-
if (kindAttr !== null) {
|
|
835
|
-
const kind = asLeafKind(kindAttr.trim());
|
|
836
|
-
if (kind !== null) return boneElement(el, cs, kind, state);
|
|
837
|
-
state.diagnostics.add("dropped-tag");
|
|
838
|
-
}
|
|
839
|
-
return defaultClassify(el, cs, state);
|
|
840
|
-
}
|
|
841
|
-
/**
|
|
842
|
-
* `ctx.ignore()` / `data-xr-ignore`: drop the element and its subtree. No Entry,
|
|
843
|
-
* no node, no descent — it contributes nothing to the Plate. Aggregated under
|
|
844
|
-
* the existing `dropped-tag` diagnostic (a hidden/non-visual drop is the same
|
|
845
|
-
* fidelity story; do not spam a new code per ignored element).
|
|
846
|
-
*/
|
|
847
|
-
function ignoreElement(state) {
|
|
848
|
-
state.diagnostics.add("dropped-tag");
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
/**
|
|
852
|
-
* `ctx.bone()` / `data-xr-bone-kind`: collapse the element's whole subtree into
|
|
853
|
-
* ONE Bone leaf, sized from the element's own measured box (ADR 0018). When
|
|
854
|
-
* `kind` is omitted it shares the default classifier's kind-inference heuristic
|
|
855
|
-
* (`inferLeafKind`, ONE code path — Q4). The subtree is NOT walked: this is the
|
|
856
|
-
* point of the helper. Reuses the default classifier's leaf sizing so the bone
|
|
857
|
-
* reserves the real space.
|
|
858
|
-
*/
|
|
859
|
-
function boneElement(el, cs, kind, state) {
|
|
860
|
-
const entry = {
|
|
861
|
-
id: state.nextId++,
|
|
862
|
-
el,
|
|
863
|
-
fallback: [],
|
|
864
|
-
cs
|
|
865
|
-
};
|
|
866
|
-
state.entries.push(entry);
|
|
867
|
-
const leaf = kind ?? inferLeafKind(el);
|
|
868
|
-
const rect = el.getBoundingClientRect();
|
|
869
|
-
entry.leaf = leaf;
|
|
870
|
-
if (isInlineish(cs.display)) {
|
|
871
|
-
entry.fallback.push("display: inline-block");
|
|
872
|
-
if (leaf === "media") entry.fallback.push("vertical-align: middle");
|
|
873
|
-
} else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
|
|
874
|
-
entry.sizeFallback = [
|
|
875
|
-
"box-sizing: border-box",
|
|
876
|
-
`width: ${px(rect.width)}`,
|
|
877
|
-
`height: ${px(rect.height)}`
|
|
878
|
-
];
|
|
879
|
-
return {
|
|
880
|
-
id: entry.id,
|
|
881
|
-
leaf
|
|
882
|
-
};
|
|
883
|
-
}
|
|
884
|
-
/**
|
|
885
|
-
* The kind-inference heuristic shared by `ctx.bone()` without an explicit kind
|
|
886
|
-
* and the default classifier (ADR 0018, Q4 — ONE code path). A media tag is
|
|
887
|
-
* `media`; anything else collapsed to a single bone reads as a `box`. The
|
|
888
|
-
* default classifier reaches the same outcomes through its own structural
|
|
889
|
-
* branches (MEDIA_TAGS -> media, contrasting fill / painted empty -> box), so
|
|
890
|
-
* keeping this one function is the single source of truth for "what kind is an
|
|
891
|
-
* element that has no children to descend into".
|
|
892
|
-
*/
|
|
893
|
-
function inferLeafKind(el) {
|
|
894
|
-
return MEDIA_TAGS.has(el.tagName.toUpperCase()) ? "media" : "box";
|
|
895
|
-
}
|
|
896
|
-
/**
|
|
897
|
-
* The DEFAULT classifier — the unchanged step-4 body, extracted so the
|
|
898
|
-
* precedence ladder's `next()` can call it (ADR 0018). With no walker and no
|
|
899
|
-
* annotations this is the only path taken, and its output is byte-identical to
|
|
900
|
-
* before the ladder was introduced.
|
|
901
|
-
*/
|
|
902
|
-
function defaultClassify(el, cs, state) {
|
|
903
|
-
const tag = el.tagName.toUpperCase();
|
|
904
|
-
const entry = {
|
|
905
|
-
id: state.nextId++,
|
|
906
|
-
el,
|
|
907
|
-
fallback: [],
|
|
908
|
-
cs
|
|
909
|
-
};
|
|
910
|
-
state.entries.push(entry);
|
|
911
|
-
const node = { id: entry.id };
|
|
912
|
-
const rect = el.getBoundingClientRect();
|
|
913
|
-
if (MEDIA_TAGS.has(tag)) {
|
|
914
|
-
entry.leaf = "media";
|
|
915
|
-
node.leaf = "media";
|
|
916
|
-
if (isInlineish(cs.display)) entry.fallback.push("display: inline-block", "vertical-align: middle");
|
|
917
|
-
let { width, height } = rect;
|
|
918
|
-
const parent = el.parentElement;
|
|
919
|
-
if ((width < 8 || height < 8) && parent && parent.childElementCount === 1) {
|
|
920
|
-
const pcs = state.styles.get(parent);
|
|
921
|
-
const prect = parent.getBoundingClientRect();
|
|
922
|
-
const pw = prect.width - pad(pcs.paddingLeft) - pad(pcs.paddingRight);
|
|
923
|
-
const ph = prect.height - pad(pcs.paddingTop) - pad(pcs.paddingBottom);
|
|
924
|
-
if (pw * ph > width * height) {
|
|
925
|
-
width = pw;
|
|
926
|
-
height = ph;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
entry.sizeFallback = [
|
|
930
|
-
"box-sizing: border-box",
|
|
931
|
-
`width: ${px(width)}`,
|
|
932
|
-
`height: ${px(height)}`
|
|
933
|
-
];
|
|
934
|
-
return node;
|
|
935
|
-
}
|
|
936
|
-
const kids = [];
|
|
937
|
-
const entriesBefore = state.entries.length;
|
|
938
|
-
let textRun = [];
|
|
939
|
-
let hasTextBar = false;
|
|
940
|
-
const flushTextRun = () => {
|
|
941
|
-
if (textRun.length === 0) return;
|
|
942
|
-
const kid = textBar(textRun, state);
|
|
943
|
-
if (kid) {
|
|
944
|
-
kids.push(kid);
|
|
945
|
-
hasTextBar = true;
|
|
946
|
-
}
|
|
947
|
-
textRun = [];
|
|
948
|
-
};
|
|
949
|
-
for (const child of Array.from(el.childNodes)) if (isElementNode(child)) {
|
|
950
|
-
flushTextRun();
|
|
951
|
-
const kid = walk(child, state);
|
|
952
|
-
if (kid) kids.push(kid);
|
|
953
|
-
} else if (isTextNode(child)) textRun.push(child);
|
|
954
|
-
flushTextRun();
|
|
955
|
-
if (kids.length > 0) {
|
|
956
|
-
if (kids.every((kid) => kid.leaf !== void 0 && kid.kids === void 0) && hasContrastingFill(el, cs, state.win, state.styles)) {
|
|
957
|
-
state.entries.length = entriesBefore;
|
|
958
|
-
entry.leaf = "box";
|
|
959
|
-
node.leaf = "box";
|
|
960
|
-
if (isInlineish(cs.display)) entry.fallback.push("display: inline-block");
|
|
961
|
-
else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
|
|
962
|
-
entry.sizeFallback = [
|
|
963
|
-
"box-sizing: border-box",
|
|
964
|
-
`width: ${px(rect.width)}`,
|
|
965
|
-
`height: ${px(rect.height)}`
|
|
966
|
-
];
|
|
967
|
-
return node;
|
|
968
|
-
}
|
|
969
|
-
node.kids = kids;
|
|
970
|
-
if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
|
|
971
|
-
if (hasTextBar) {
|
|
972
|
-
const lh = pxValue(cs.lineHeight);
|
|
973
|
-
if (lh !== null) entry.fallback.push(`line-height: ${px(lh)}`);
|
|
974
|
-
}
|
|
975
|
-
return node;
|
|
976
|
-
}
|
|
977
|
-
const painted = hasVisibleBackground(cs, state.win);
|
|
978
|
-
if (isInlineish(cs.display)) entry.fallback.push("display: inline-block");
|
|
979
|
-
else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
|
|
980
|
-
if (painted) {
|
|
981
|
-
entry.leaf = "box";
|
|
982
|
-
node.leaf = "box";
|
|
983
|
-
entry.sizeFallback = [
|
|
984
|
-
"box-sizing: border-box",
|
|
985
|
-
`width: ${px(rect.width)}`,
|
|
986
|
-
`height: ${px(rect.height)}`
|
|
987
|
-
];
|
|
988
|
-
} else entry.fallback.push("box-sizing: border-box", `min-width: ${px(rect.width)}`, `min-height: ${px(rect.height)}`);
|
|
989
|
-
return node;
|
|
990
|
-
}
|
|
991
|
-
/** A run of adjacent text nodes becomes one synthetic inline bar sized to its rendered box. */
|
|
992
|
-
function textBar(run, state) {
|
|
993
|
-
const first = run[0];
|
|
994
|
-
const last = run[run.length - 1];
|
|
995
|
-
const doc = first?.ownerDocument;
|
|
996
|
-
if (!first || !last || !doc) return null;
|
|
997
|
-
if (run.every((node) => (node.textContent ?? "").trim() === "")) return null;
|
|
998
|
-
const range = doc.createRange();
|
|
999
|
-
range.setStartBefore(first);
|
|
1000
|
-
range.setEndAfter(last);
|
|
1001
|
-
const rect = range.getBoundingClientRect();
|
|
1002
|
-
const { width, height } = clampToClipping(rect.width, rect.height, first.parentElement, { getComputedStyle: (el) => state.styles.get(el) });
|
|
1003
|
-
const entry = {
|
|
1004
|
-
id: state.nextId++,
|
|
1005
|
-
el: null,
|
|
1006
|
-
leaf: "text",
|
|
1007
|
-
fallback: ["display: inline-block", "vertical-align: middle"]
|
|
1008
|
-
};
|
|
1009
|
-
if (width > 0 && height > 0) entry.sizeFallback = [`width: ${px(width)}`, `height: ${px(snapToLines(height, first.parentElement, state.styles))}`];
|
|
1010
|
-
state.entries.push(entry);
|
|
1011
|
-
return {
|
|
1012
|
-
id: entry.id,
|
|
1013
|
-
leaf: "text"
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
/**
|
|
1017
|
-
* A `Range` rect is the tight union of the glyph line boxes, so a wrapped run
|
|
1018
|
-
* measures lines × glyph-height — short of the lines × line-height the source
|
|
1019
|
-
* actually occupied (a 2-line paragraph rendered ~3px short per line). When the
|
|
1020
|
-
* parent's line-height resolves to px and the run is clearly multi-line, round
|
|
1021
|
-
* to a whole number of line boxes so the bone reserves the real block height.
|
|
1022
|
-
* A single line is left untouched — its line box comes from the parent's pinned
|
|
1023
|
-
* line-height, and the thin glyph bar reads as text rather than a fat block.
|
|
1024
|
-
*/
|
|
1025
|
-
function snapToLines(height, parent, styles) {
|
|
1026
|
-
if (!parent) return height;
|
|
1027
|
-
const lh = pxValue(styles.get(parent).lineHeight);
|
|
1028
|
-
if (lh === null || lh <= 0 || height < lh * 1.5) return height;
|
|
1029
|
-
return Math.round(height / lh) * lh;
|
|
1030
|
-
}
|
|
1031
|
-
function clampToClipping(width, height, fromParent, win) {
|
|
1032
|
-
for (let host = fromParent; host; host = host.parentElement) {
|
|
1033
|
-
const cs = win.getComputedStyle(host);
|
|
1034
|
-
const clipsX = cs.overflowX !== "visible";
|
|
1035
|
-
const clipsY = cs.overflowY !== "visible";
|
|
1036
|
-
if (!clipsX && !clipsY) continue;
|
|
1037
|
-
const hrect = host.getBoundingClientRect();
|
|
1038
|
-
if (clipsY) {
|
|
1039
|
-
const inner = hrect.height - pad(cs.paddingTop) - pad(cs.paddingBottom) - pad(cs.borderTopWidth) - pad(cs.borderBottomWidth);
|
|
1040
|
-
if (inner > 0) height = Math.min(height, inner);
|
|
1041
|
-
}
|
|
1042
|
-
if (clipsX) {
|
|
1043
|
-
const inner = hrect.width - pad(cs.paddingLeft) - pad(cs.paddingRight) - pad(cs.borderLeftWidth) - pad(cs.borderRightWidth);
|
|
1044
|
-
if (inner > 0) width = Math.min(width, inner);
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
return {
|
|
1048
|
-
width,
|
|
1049
|
-
height
|
|
1050
|
-
};
|
|
1051
|
-
}
|
|
1052
|
-
/** The capture-time measurements everything starts from, at the lowest precedence. */
|
|
1053
|
-
function baseRules(entries, styles) {
|
|
1054
|
-
const rules = [];
|
|
1055
|
-
let order = 0;
|
|
1056
|
-
const context = entries.find((e) => e.el)?.el?.parentElement;
|
|
1057
|
-
if (context) {
|
|
1058
|
-
const cs = styles.get(context);
|
|
1059
|
-
rules.push({
|
|
1060
|
-
ids: [],
|
|
1061
|
-
decls: [
|
|
1062
|
-
`font-family: ${cs.fontFamily}`,
|
|
1063
|
-
`font-size: ${cs.fontSize}`,
|
|
1064
|
-
`font-weight: ${cs.fontWeight}`,
|
|
1065
|
-
`line-height: ${cs.lineHeight}`
|
|
1066
|
-
],
|
|
1067
|
-
spec: -2,
|
|
1068
|
-
order: order++
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
for (const entry of entries) {
|
|
1072
|
-
if (entry.fallback.length > 0) rules.push({
|
|
1073
|
-
ids: [entry.id],
|
|
1074
|
-
decls: entry.fallback,
|
|
1075
|
-
spec: -1,
|
|
1076
|
-
order: order++
|
|
1077
|
-
});
|
|
1078
|
-
if (entry.sizeFallback && entry.sizeFallback.length > 0) rules.push({
|
|
1079
|
-
ids: [entry.id],
|
|
1080
|
-
decls: entry.sizeFallback,
|
|
1081
|
-
spec: SPEC_LEAF,
|
|
1082
|
-
order: order++
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
return rules;
|
|
1086
|
-
}
|
|
1087
|
-
/** `@container <name>? <query>` — the text to re-wrap a lifted rule with. */
|
|
1088
|
-
function containerCondition(rule) {
|
|
1089
|
-
const query = rule.containerQuery;
|
|
1090
|
-
if (query) {
|
|
1091
|
-
const name = rule.containerName?.trim();
|
|
1092
|
-
return name ? `${name} ${query}` : query;
|
|
1093
|
-
}
|
|
1094
|
-
const cond = "conditionText" in rule && typeof rule.conditionText === "string" ? rule.conditionText : "";
|
|
1095
|
-
if (cond) return cond;
|
|
1096
|
-
const condition = /@container\s+([^{]+)\{/.exec(rule.cssText ?? "")?.[1];
|
|
1097
|
-
return condition ? condition.trim() : "";
|
|
1098
|
-
}
|
|
1099
|
-
function isCssStyleRule(rule) {
|
|
1100
|
-
return rule.constructor.name === "CSSStyleRule";
|
|
1101
|
-
}
|
|
1102
|
-
function isCssMediaRule(rule) {
|
|
1103
|
-
return rule.constructor.name === "CSSMediaRule";
|
|
1104
|
-
}
|
|
1105
|
-
function isCssContainerRule(rule) {
|
|
1106
|
-
return rule.constructor.name === "CSSContainerRule";
|
|
1107
|
-
}
|
|
1108
|
-
function isCssSupportsRule(rule) {
|
|
1109
|
-
return rule.constructor.name === "CSSSupportsRule";
|
|
1110
|
-
}
|
|
1111
|
-
function isCssLayerBlockRule(rule) {
|
|
1112
|
-
return rule.constructor.name === "CSSLayerBlockRule" && "cssRules" in rule;
|
|
1113
|
-
}
|
|
1114
|
-
function isCssImportRule(rule) {
|
|
1115
|
-
return rule.constructor.name === "CSSImportRule";
|
|
1116
|
-
}
|
|
1117
|
-
function importLayerName(rule) {
|
|
1118
|
-
if (!("layerName" in rule)) return null;
|
|
1119
|
-
return typeof rule.layerName === "string" ? rule.layerName : null;
|
|
1120
|
-
}
|
|
1121
|
-
function supportsCondition(win, condition) {
|
|
1122
|
-
if (!("CSS" in win)) return true;
|
|
1123
|
-
const css = win.CSS;
|
|
1124
|
-
if (typeof css !== "object" || css === null || !("supports" in css)) return true;
|
|
1125
|
-
if (typeof css.supports !== "function") return true;
|
|
1126
|
-
try {
|
|
1127
|
-
return css.supports(condition) === true;
|
|
1128
|
-
} catch {
|
|
1129
|
-
return true;
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
/** Flatten every reachable author rule, tracking media + container conditions and layer membership. */
|
|
1133
|
-
function collectStyleRules(doc, win, diagnostics) {
|
|
1134
|
-
const out = [];
|
|
1135
|
-
const visit = (list, media, container, layered) => {
|
|
1136
|
-
if (!list) return;
|
|
1137
|
-
for (let i = 0; i < list.length; i++) {
|
|
1138
|
-
const rule = list[i];
|
|
1139
|
-
if (!rule) continue;
|
|
1140
|
-
if (isCssStyleRule(rule)) {
|
|
1141
|
-
out.push({
|
|
1142
|
-
rule,
|
|
1143
|
-
media,
|
|
1144
|
-
container,
|
|
1145
|
-
layered
|
|
1146
|
-
});
|
|
1147
|
-
continue;
|
|
1148
|
-
}
|
|
1149
|
-
if (isCssMediaRule(rule)) {
|
|
1150
|
-
visit(rule.cssRules, [...media, rule.media.mediaText], container, layered);
|
|
1151
|
-
continue;
|
|
1152
|
-
}
|
|
1153
|
-
if (isCssContainerRule(rule)) {
|
|
1154
|
-
visit(rule.cssRules, media, [...container, containerCondition(rule)], layered);
|
|
1155
|
-
continue;
|
|
1156
|
-
}
|
|
1157
|
-
if (isCssSupportsRule(rule)) {
|
|
1158
|
-
if (supportsCondition(win, rule.conditionText)) visit(rule.cssRules, media, container, layered);
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
if (isCssLayerBlockRule(rule)) {
|
|
1162
|
-
visit(rule.cssRules, media, container, true);
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
if (isCssImportRule(rule)) {
|
|
1166
|
-
try {
|
|
1167
|
-
const importMedia = rule.media.mediaText;
|
|
1168
|
-
visit(rule.styleSheet?.cssRules, importMedia && importMedia !== "all" ? [...media, importMedia] : media, container, layered || importLayerName(rule) != null);
|
|
1169
|
-
} catch {
|
|
1170
|
-
diagnostics.add("unreadable-import");
|
|
1171
|
-
}
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
diagnostics.add("unsupported-rule");
|
|
1175
|
-
}
|
|
1176
|
-
};
|
|
1177
|
-
const sheets = [...Array.from(doc.styleSheets), ...doc.adoptedStyleSheets ?? []];
|
|
1178
|
-
for (const sheet of sheets) {
|
|
1179
|
-
const sheetMedia = sheet.media.mediaText;
|
|
1180
|
-
try {
|
|
1181
|
-
visit(sheet.cssRules, sheetMedia && sheetMedia !== "all" ? [sheetMedia] : [], [], false);
|
|
1182
|
-
} catch {
|
|
1183
|
-
diagnostics.add("unreadable-stylesheet");
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
return out;
|
|
1187
|
-
}
|
|
1188
|
-
function safeMatches(el, selector) {
|
|
1189
|
-
try {
|
|
1190
|
-
return el.matches(selector);
|
|
1191
|
-
} catch {
|
|
1192
|
-
return false;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
function pushSelector(map, key, sel) {
|
|
1196
|
-
const list = map.get(key);
|
|
1197
|
-
if (list) list.push(sel);
|
|
1198
|
-
else map.set(key, [sel]);
|
|
1199
|
-
}
|
|
1200
|
-
/** The rightmost compound of a selector — what an element must itself satisfy to match. */
|
|
1201
|
-
function rightmostCompound(selector) {
|
|
1202
|
-
let depth = 0;
|
|
1203
|
-
let quote = "";
|
|
1204
|
-
for (let i = selector.length - 1; i >= 0; i--) {
|
|
1205
|
-
const ch = selector.charAt(i);
|
|
1206
|
-
if (quote) {
|
|
1207
|
-
if (ch === quote) quote = "";
|
|
1208
|
-
continue;
|
|
1209
|
-
}
|
|
1210
|
-
if (ch === "\"" || ch === "'") quote = ch;
|
|
1211
|
-
else if (ch === ")" || ch === "]") depth++;
|
|
1212
|
-
else if (ch === "(" || ch === "[") depth--;
|
|
1213
|
-
else if (depth === 0 && (ch === " " || ch === ">" || ch === "+" || ch === "~")) return selector.slice(i + 1);
|
|
1214
|
-
}
|
|
1215
|
-
return selector;
|
|
1216
|
-
}
|
|
1217
|
-
/**
|
|
1218
|
-
* Index author rules the way a browser's rule hash does: bucket each selector
|
|
1219
|
-
* by a token its matching element must carry (id, a class, or tag), so an
|
|
1220
|
-
* element only tests the rules that could possibly apply to it. CSS-module
|
|
1221
|
-
* class names are unique, so this turns an O(rules × nodes) sweep into a
|
|
1222
|
-
* near-linear one. Selectors keyed on nothing concrete (`*`, attribute-only)
|
|
1223
|
-
* fall back to a universal bucket tested against every element.
|
|
1224
|
-
*/
|
|
1225
|
-
function indexRules(doc, win, diagnostics) {
|
|
1226
|
-
const index = {
|
|
1227
|
-
byId: /* @__PURE__ */ new Map(),
|
|
1228
|
-
byClass: /* @__PURE__ */ new Map(),
|
|
1229
|
-
byTag: /* @__PURE__ */ new Map(),
|
|
1230
|
-
universal: []
|
|
1231
|
-
};
|
|
1232
|
-
let order = 1e3;
|
|
1233
|
-
for (const found of collectStyleRules(doc, win, diagnostics)) {
|
|
1234
|
-
order++;
|
|
1235
|
-
for (const selector of splitSelectorList(found.rule.selectorText)) {
|
|
1236
|
-
if (PSEUDO_ELEMENT.test(selector)) {
|
|
1237
|
-
diagnostics.add("skipped-pseudo-element");
|
|
1238
|
-
continue;
|
|
1239
|
-
}
|
|
1240
|
-
if (DYNAMIC_PSEUDO.test(selector)) {
|
|
1241
|
-
diagnostics.add("skipped-dynamic-pseudo");
|
|
1242
|
-
continue;
|
|
1243
|
-
}
|
|
1244
|
-
const sel = {
|
|
1245
|
-
rule: found.rule,
|
|
1246
|
-
selector,
|
|
1247
|
-
media: found.media,
|
|
1248
|
-
container: found.container,
|
|
1249
|
-
layered: found.layered,
|
|
1250
|
-
spec: specificity(selector),
|
|
1251
|
-
order
|
|
1252
|
-
};
|
|
1253
|
-
const compound = rightmostCompound(selector).replace(/:{1,2}[\w-]+\([^()]*\)/g, "");
|
|
1254
|
-
const id = /#(-?[A-Za-z_][\w-]*)/.exec(compound);
|
|
1255
|
-
const cls = /\.(-?[A-Za-z_][\w-]*)/.exec(compound);
|
|
1256
|
-
const tag = /^(-?[A-Za-z][\w-]*)/.exec(compound);
|
|
1257
|
-
const idName = id?.[1];
|
|
1258
|
-
const className = cls?.[1];
|
|
1259
|
-
const tagName = tag?.[1];
|
|
1260
|
-
if (idName) pushSelector(index.byId, idName, sel);
|
|
1261
|
-
else if (className) pushSelector(index.byClass, className, sel);
|
|
1262
|
-
else if (tagName) pushSelector(index.byTag, tagName.toLowerCase(), sel);
|
|
1263
|
-
else index.universal.push(sel);
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
return index;
|
|
1267
|
-
}
|
|
1268
|
-
function hasInlineStyle(el) {
|
|
1269
|
-
return "style" in el && typeof el.style === "object" && el.style !== null && "length" in el.style && "getPropertyValue" in el.style && "getPropertyPriority" in el.style;
|
|
1270
|
-
}
|
|
1271
|
-
/** Lift matching author rules, rewritten to plate-local ids (ADR 0005). */
|
|
1272
|
-
function copyRules(entries, doc, win, styles, diagnostics) {
|
|
1273
|
-
const rules = baseRules(entries, styles);
|
|
1274
|
-
const elEntries = entries.filter((e) => e.el !== null);
|
|
1275
|
-
const index = indexRules(doc, win, diagnostics);
|
|
1276
|
-
const matches = /* @__PURE__ */ new Map();
|
|
1277
|
-
const matchOf = (sel) => {
|
|
1278
|
-
let m = matches.get(sel);
|
|
1279
|
-
if (!m) {
|
|
1280
|
-
m = {
|
|
1281
|
-
ids: [],
|
|
1282
|
-
decls: null
|
|
1283
|
-
};
|
|
1284
|
-
matches.set(sel, m);
|
|
1285
|
-
}
|
|
1286
|
-
return m;
|
|
1287
|
-
};
|
|
1288
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1289
|
-
for (const entry of elEntries) {
|
|
1290
|
-
const el = entry.el;
|
|
1291
|
-
seen.clear();
|
|
1292
|
-
const candidates = [];
|
|
1293
|
-
const collect = (list) => {
|
|
1294
|
-
if (!list) return;
|
|
1295
|
-
for (const sel of list) if (!seen.has(sel)) {
|
|
1296
|
-
seen.add(sel);
|
|
1297
|
-
candidates.push(sel);
|
|
1298
|
-
}
|
|
1299
|
-
};
|
|
1300
|
-
if (el.id) collect(index.byId.get(el.id));
|
|
1301
|
-
for (const cls of el.classList) collect(index.byClass.get(cls));
|
|
1302
|
-
collect(index.byTag.get(el.tagName.toLowerCase()));
|
|
1303
|
-
collect(index.universal);
|
|
1304
|
-
for (const sel of candidates) {
|
|
1305
|
-
if (!safeMatches(el, sel.selector)) continue;
|
|
1306
|
-
const m = matchOf(sel);
|
|
1307
|
-
if (m.decls === null) m.decls = filterLayoutDecls(sel.rule.style, entry.cs ?? styles.get(el));
|
|
1308
|
-
if (m.decls.length > 0) m.ids.push(entry.id);
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
for (const sel of allSelectors(index)) {
|
|
1312
|
-
const m = matches.get(sel);
|
|
1313
|
-
if (!m || m.ids.length === 0 || !m.decls || m.decls.length === 0) continue;
|
|
1314
|
-
rules.push({
|
|
1315
|
-
ids: m.ids,
|
|
1316
|
-
decls: m.decls,
|
|
1317
|
-
media: sel.media,
|
|
1318
|
-
...sel.container.length > 0 ? { container: sel.container } : {},
|
|
1319
|
-
layered: sel.layered,
|
|
1320
|
-
spec: sel.spec,
|
|
1321
|
-
order: sel.order
|
|
1322
|
-
});
|
|
1323
|
-
}
|
|
1324
|
-
let order = 1e7;
|
|
1325
|
-
for (const entry of elEntries) {
|
|
1326
|
-
if (!hasInlineStyle(entry.el)) continue;
|
|
1327
|
-
const style = entry.el.style;
|
|
1328
|
-
if (!style || style.length === 0) continue;
|
|
1329
|
-
const decls = filterLayoutDecls(style, entry.cs ?? styles.get(entry.el));
|
|
1330
|
-
if (decls.length === 0) continue;
|
|
1331
|
-
rules.push({
|
|
1332
|
-
ids: [entry.id],
|
|
1333
|
-
decls,
|
|
1334
|
-
spec: SPEC_INLINE,
|
|
1335
|
-
order: order++
|
|
1336
|
-
});
|
|
1337
|
-
}
|
|
1338
|
-
return rules;
|
|
1339
|
-
}
|
|
1340
|
-
function* allSelectors(index) {
|
|
1341
|
-
for (const list of index.byId.values()) yield* list;
|
|
1342
|
-
for (const list of index.byClass.values()) yield* list;
|
|
1343
|
-
for (const list of index.byTag.values()) yield* list;
|
|
1344
|
-
yield* index.universal;
|
|
1345
|
-
}
|
|
1346
|
-
//#endregion
|
|
1347
|
-
//#region src/breakpoints.ts
|
|
1348
|
-
/**
|
|
1349
|
-
* Per-plate breakpoint derivation (ADR 0004): the width thresholds that could
|
|
1350
|
-
* change this plate come from the `@media` conditions on its own copied rules
|
|
1351
|
-
* plus the queries the app evaluated through `matchMedia` during capture.
|
|
1352
|
-
* Thresholds partition the width axis into views; identical captures in
|
|
1353
|
-
* adjacent views collapse at merge time.
|
|
1354
|
-
*/
|
|
1355
|
-
const FONT_SIZE_PX = 16;
|
|
1356
|
-
/** A breakpoint is the first integer px width of the view ABOVE the boundary. */
|
|
1357
|
-
function thresholdsOf(condition) {
|
|
1358
|
-
const out = [];
|
|
1359
|
-
const toPx = (value, unit) => Number.parseFloat(value) * (unit === "px" ? 1 : FONT_SIZE_PX);
|
|
1360
|
-
const push = (raw, exclusiveBelow) => {
|
|
1361
|
-
const bp = exclusiveBelow ? Number.isInteger(raw) ? raw + 1 : Math.ceil(raw) : Math.ceil(raw);
|
|
1362
|
-
if (Number.isFinite(bp) && bp > 0) out.push(bp);
|
|
1363
|
-
};
|
|
1364
|
-
for (const m of condition.matchAll(/min-width:\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[1] ?? "", m[2] ?? "px"), false);
|
|
1365
|
-
for (const m of condition.matchAll(/max-width:\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[1] ?? "", m[2] ?? "px"), true);
|
|
1366
|
-
for (const m of condition.matchAll(/width\s*(>=?)\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[2] ?? "", m[3] ?? "px"), m[1] === ">");
|
|
1367
|
-
for (const m of condition.matchAll(/width\s*(<=?)\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[2] ?? "", m[3] ?? "px"), m[1] === "<=");
|
|
1368
|
-
for (const m of condition.matchAll(/([\d.]+)(px|em|rem)\s*(<=?)\s*width/g)) push(toPx(m[1] ?? "", m[2] ?? "px"), m[3] === "<");
|
|
1369
|
-
return out;
|
|
1370
|
-
}
|
|
1371
|
-
/** Width thresholds for this plate: its rules' media conditions + the app's matchMedia queries. */
|
|
1372
|
-
function deriveBreakpoints(rules, matchMediaQueries = []) {
|
|
1373
|
-
const set = /* @__PURE__ */ new Set();
|
|
1374
|
-
for (const rule of rules) for (const condition of rule.media ?? []) for (const bp of thresholdsOf(condition)) set.add(bp);
|
|
1375
|
-
for (const query of matchMediaQueries) for (const bp of thresholdsOf(query)) set.add(bp);
|
|
1376
|
-
return [...set].toSorted((a, b) => a - b);
|
|
1377
|
-
}
|
|
1378
|
-
/** The view interval `[min, max)` a given viewport width falls into. */
|
|
1379
|
-
function regimeFor(width, breakpoints) {
|
|
1380
|
-
let min;
|
|
1381
|
-
let max;
|
|
1382
|
-
for (const bp of breakpoints) if (bp <= width) min = bp;
|
|
1383
|
-
else {
|
|
1384
|
-
max = bp;
|
|
1385
|
-
break;
|
|
1386
|
-
}
|
|
1387
|
-
return {
|
|
1388
|
-
...min === void 0 ? {} : { min },
|
|
1389
|
-
...max === void 0 ? {} : { max }
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
//#endregion
|
|
1393
|
-
export { captureRegime as a, formatDiagnostic as c, capture as i, makeDiagnostic as l, regimeFor as n, defineXrayCaptureWalker as o, CaptureTooLargeError as r, diagnosticsHeader as s, deriveBreakpoints as t, classify as u };
|