@hueest/xray 0.2.0 → 0.4.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 +34 -18
- package/dist/client.js +236 -82
- package/dist/core.d.ts +15 -27
- package/dist/core.js +34 -210
- package/dist/index.d.ts +98 -59
- package/dist/index.js +240 -601
- package/dist/{plate-DoE1HEXp.js → plate-BRR6d8Se.js} +8 -4
- package/dist/project-BZosujs9.js +10250 -0
- package/dist/{react.core-IyFy2b_8.js → react.core-DMIhXHZF.js} +6 -1
- package/dist/{react.core-BzMG_cDy.d.ts → react.core-NhWc9qan.d.ts} +13 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.dev.d.ts +1 -1
- package/dist/react.dev.js +34 -9
- package/dist/react.js +1 -1
- package/dist/{plate-Bv6W-GkA.d.ts → serialize-BgdGt34A.d.ts} +193 -30
- package/package.json +2 -1
- package/dist/breakpoints-CMoFUUOv.js +0 -1393
- package/dist/serialize-B--oY2bV.d.ts +0 -143
package/dist/index.js
CHANGED
|
@@ -3,8 +3,6 @@ import { dirname, relative, resolve } from "node:path";
|
|
|
3
3
|
import * as v from "valibot";
|
|
4
4
|
/** App code imports plates from here, e.g. `import plate from 'virtual:xray/plates/my-banner'`. */
|
|
5
5
|
const VIRTUAL_PREFIX = "virtual:xray/plates/";
|
|
6
|
-
/** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
|
|
7
|
-
const ROOT_ATTR = "data-xr-root";
|
|
8
6
|
/**
|
|
9
7
|
* Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
|
|
10
8
|
* standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
|
|
@@ -14,7 +12,6 @@ const ROOT_ATTR = "data-xr-root";
|
|
|
14
12
|
* own) nor on an ordinary top-level skeleton (which keeps the normal delay).
|
|
15
13
|
*/
|
|
16
14
|
const ROOT_INSTANT_ATTR = "data-xr-instant";
|
|
17
|
-
const SPEC_GATE = 2e7;
|
|
18
15
|
/** Fixed renderer-owned classes every rendered node carries. */
|
|
19
16
|
const ROOT_CLASS = "xr-root";
|
|
20
17
|
const NODE_CLASS = "xr-node";
|
|
@@ -48,9 +45,12 @@ const LEAF_CLASS = "xr-leaf";
|
|
|
48
45
|
Never transition opacity directly (the pulse animation owns it). */
|
|
49
46
|
opacity: calc(var(--xr-o) * var(--xr-reveal));
|
|
50
47
|
}
|
|
51
|
-
/* Per-kind defaults: text reads as rounded
|
|
52
|
-
|
|
48
|
+
/* Per-kind defaults: a single text LINE reads as a rounded bar; a multi-line
|
|
49
|
+
text BLOCK as a soft rect (a tall pill misreads as a button); media carries
|
|
50
|
+
the author radius, defaulting to the same soft rect. Re-theme any kind via
|
|
51
|
+
[data-xr-root] .${LEAF_CLASS}-text { ... }. */
|
|
53
52
|
.${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
|
|
53
|
+
.${LEAF_CLASS}-text-block { border-radius: var(--xr-bone-text-block-border-radius, var(--xr-bone-border-radius, 4px)) }
|
|
54
54
|
.${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
|
|
55
55
|
@keyframes xr-pulse {
|
|
56
56
|
from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
|
|
@@ -107,7 +107,7 @@ const LEAF_CLASS = "xr-leaf";
|
|
|
107
107
|
/** The (unserialized) plate an import resolves to before anything has been captured. */
|
|
108
108
|
function emptyPlate(name) {
|
|
109
109
|
return {
|
|
110
|
-
v:
|
|
110
|
+
v: 2,
|
|
111
111
|
name,
|
|
112
112
|
tree: null,
|
|
113
113
|
css: ""
|
|
@@ -128,12 +128,13 @@ function nodeClass(node) {
|
|
|
128
128
|
return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
|
|
129
129
|
}
|
|
130
130
|
/**
|
|
131
|
-
* Whether this subtree holds a
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* Whether this subtree holds a render-time DYNAMIC node anywhere — a stitch
|
|
132
|
+
* (`ref`, a mount point resolved to a child plate) or a template (`count`, a cell
|
|
133
|
+
* the adapter repeats). Either keeps the subtree from collapsing to one flat
|
|
134
|
+
* string, since both are expanded at render rather than baked into the markup.
|
|
134
135
|
*/
|
|
135
|
-
function
|
|
136
|
-
return node.ref !== void 0 || (node.kids?.some(
|
|
136
|
+
function hasDynamic(node) {
|
|
137
|
+
return node.ref !== void 0 || node.count !== void 0 || (node.kids?.some(hasDynamic) ?? false);
|
|
137
138
|
}
|
|
138
139
|
/** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
|
|
139
140
|
function toHtml(node) {
|
|
@@ -141,10 +142,13 @@ function toHtml(node) {
|
|
|
141
142
|
return `<div class="${nodeClass(node)}">${kids}</div>`;
|
|
142
143
|
}
|
|
143
144
|
/**
|
|
144
|
-
* Serialize a node list into chunks: each maximal
|
|
145
|
-
* HTML `string`; a `ref` node becomes `{ r }`; a node
|
|
146
|
-
*
|
|
147
|
-
*
|
|
145
|
+
* Serialize a node list into chunks: each maximal run with no render-time dynamic
|
|
146
|
+
* node becomes one HTML `string`; a `ref` node becomes `{ r }`; a `count` node
|
|
147
|
+
* becomes `{ t: html, n }` (its cell's HTML once + the repeat count); a node that
|
|
148
|
+
* isn't itself dynamic but holds one deeper stays a real element (`{ c, k }`) and
|
|
149
|
+
* recurses. Mirrors the element shape the runtime renderer would otherwise build
|
|
150
|
+
* per node. A `count` cell is guaranteed stitch-free by the capture pass, so its
|
|
151
|
+
* `toHtml` is a plain string the adapter repeats.
|
|
148
152
|
*/
|
|
149
153
|
function toChunks(nodes) {
|
|
150
154
|
const out = [];
|
|
@@ -156,12 +160,16 @@ function toChunks(nodes) {
|
|
|
156
160
|
}
|
|
157
161
|
};
|
|
158
162
|
for (const node of nodes) {
|
|
159
|
-
if (!
|
|
163
|
+
if (!hasDynamic(node)) {
|
|
160
164
|
buf += toHtml(node);
|
|
161
165
|
continue;
|
|
162
166
|
}
|
|
163
167
|
flush();
|
|
164
168
|
if (node.ref !== void 0) out.push({ r: node.ref });
|
|
169
|
+
else if (node.count !== void 0) out.push({
|
|
170
|
+
t: toHtml(node),
|
|
171
|
+
n: node.count
|
|
172
|
+
});
|
|
165
173
|
else out.push({
|
|
166
174
|
c: nodeClass(node),
|
|
167
175
|
k: toChunks(node.kids ?? [])
|
|
@@ -198,231 +206,15 @@ function serializePlate(merged) {
|
|
|
198
206
|
css
|
|
199
207
|
};
|
|
200
208
|
}
|
|
201
|
-
//#endregion
|
|
202
|
-
//#region src/breakpoints.ts
|
|
203
|
-
/** The view interval `[min, max)` a given viewport width falls into. */
|
|
204
|
-
function regimeFor(width, breakpoints) {
|
|
205
|
-
let min;
|
|
206
|
-
let max;
|
|
207
|
-
for (const bp of breakpoints) if (bp <= width) min = bp;
|
|
208
|
-
else {
|
|
209
|
-
max = bp;
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
return {
|
|
213
|
-
...min === void 0 ? {} : { min },
|
|
214
|
-
...max === void 0 ? {} : { max }
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
//#endregion
|
|
218
|
-
//#region src/classify.ts
|
|
219
|
-
/**
|
|
220
|
-
* Turn id-form rules + an id-tree into the shipped plate: each distinct
|
|
221
|
-
* `(tier, media, declarations)` becomes one class, named by a per-plate
|
|
222
|
-
* sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
|
|
223
|
-
* class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
|
|
224
|
-
* not a content hash: the plate prefix already keeps tokens disjoint across plates
|
|
225
|
-
* (the stitch-isolation guarantee), so a per-plate counter is collision-free by
|
|
226
|
-
* construction — and low-entropy ids compress far better than random hashes
|
|
227
|
-
* (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
|
|
228
|
-
* unlayered; the existing cascade is reproduced by emission order (losers
|
|
229
|
-
* first), so the classes are emitted in `(layered, spec, order)` order exactly
|
|
230
|
-
* as `renderRules` did.
|
|
231
|
-
*
|
|
232
|
-
* Pure data-in/data-out — runs at plugin load (node) and in tests.
|
|
233
|
-
*/
|
|
234
|
-
function classify(rules, tree, plateName) {
|
|
235
|
-
const classBase = `${CLASS_PREFIX}${encodePlateName(plateName)}-`;
|
|
236
|
-
const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
|
|
237
|
-
const nameForKey = /* @__PURE__ */ new Map();
|
|
238
|
-
const body = /* @__PURE__ */ new Map();
|
|
239
|
-
const usage = /* @__PURE__ */ new Map();
|
|
240
|
-
const emitOrder = [];
|
|
241
|
-
const nodeClasses = /* @__PURE__ */ new Map();
|
|
242
|
-
for (const rule of sorted) {
|
|
243
|
-
if (rule.decls.length === 0) continue;
|
|
244
|
-
const decls = rule.decls.join("; ");
|
|
245
|
-
const media = rule.media ?? [];
|
|
246
|
-
const container = rule.container ?? [];
|
|
247
|
-
const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
|
|
248
|
-
const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
|
|
249
|
-
let name = nameForKey.get(key);
|
|
250
|
-
if (name === void 0) {
|
|
251
|
-
name = classBase + nameForKey.size.toString(36);
|
|
252
|
-
nameForKey.set(key, name);
|
|
253
|
-
body.set(name, {
|
|
254
|
-
media,
|
|
255
|
-
container,
|
|
256
|
-
decls
|
|
257
|
-
});
|
|
258
|
-
usage.set(name, {
|
|
259
|
-
root: false,
|
|
260
|
-
desc: false
|
|
261
|
-
});
|
|
262
|
-
emitOrder.push(name);
|
|
263
|
-
}
|
|
264
|
-
const ids = rule.ids.length === 0 ? [0] : rule.ids;
|
|
265
|
-
for (const id of ids) {
|
|
266
|
-
const u = usage.get(name);
|
|
267
|
-
if (!u) throw new Error(`missing usage entry for ${name}`);
|
|
268
|
-
if (id === 0) u.root = true;
|
|
269
|
-
else u.desc = true;
|
|
270
|
-
let list = nodeClasses.get(id);
|
|
271
|
-
if (!list) {
|
|
272
|
-
list = [];
|
|
273
|
-
nodeClasses.set(id, list);
|
|
274
|
-
}
|
|
275
|
-
if (!list.includes(name)) list.push(name);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const lines = [];
|
|
279
|
-
for (const name of emitOrder) {
|
|
280
|
-
const item = body.get(name);
|
|
281
|
-
const u = usage.get(name);
|
|
282
|
-
if (!item || !u) throw new Error(`missing class body for ${name}`);
|
|
283
|
-
const { media, container, decls } = item;
|
|
284
|
-
const sels = [];
|
|
285
|
-
if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
|
|
286
|
-
if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
|
|
287
|
-
let text = `${sels.join(", ")} { ${decls} }`;
|
|
288
|
-
for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
|
|
289
|
-
for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
|
|
290
|
-
lines.push(text);
|
|
291
|
-
}
|
|
292
|
-
return {
|
|
293
|
-
tree: render(tree, nodeClasses),
|
|
294
|
-
css: lines.join("\n")
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
/** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
|
|
298
|
-
function render(node, classes) {
|
|
299
|
-
const out = {};
|
|
300
|
-
if (node.leaf) out.leaf = node.leaf;
|
|
301
|
-
if (node.ref !== void 0) out.ref = node.ref;
|
|
302
|
-
const cls = classes.get(node.id);
|
|
303
|
-
if (cls && cls.length > 0) out.cls = cls.join(" ");
|
|
304
|
-
if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
|
|
305
|
-
return out;
|
|
306
|
-
}
|
|
307
|
-
/** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
|
|
308
|
-
const CLASS_PREFIX = "xr-";
|
|
309
|
-
/**
|
|
310
|
-
* Encode a plate name into a class-token prefix, injectively. This prefix *is*
|
|
311
|
-
* the isolation boundary across stitched plates (ADR 0007), so distinct plate
|
|
312
|
-
* names must never produce the same prefix — a collision would let one plate's
|
|
313
|
-
* `<style>` block reorder another's classes.
|
|
314
|
-
*
|
|
315
|
-
* Plate names are constrained by `isValidPlateName`: each path segment is
|
|
316
|
-
* `\w+(-\w+)*` (word chars with single interior hyphens, no doubled or edge
|
|
317
|
-
* hyphen) and segments are joined by `/`. The slash is the only char that is
|
|
318
|
-
* not class-safe, so rendering it as `--` is enough — and because no segment
|
|
319
|
-
* holds a literal `--` or an edge hyphen, no `-` ever abuts a `/`, so every
|
|
320
|
-
* `--` in the prefix unambiguously came from a `/`, making the map injective.
|
|
321
|
-
* Keeping `/` visually as `--` also leaves nested names readable in the
|
|
322
|
-
* generated CSS (`product--card`).
|
|
323
|
-
*/
|
|
324
|
-
function encodePlateName(name) {
|
|
325
|
-
return name.replace(/\//g, "--");
|
|
326
|
-
}
|
|
327
209
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
210
|
+
* Distinct plate names referenced by the tree's stitches, in first-seen order.
|
|
211
|
+
* Walks a `RenderNode` tree (the stored, post-classify shape), so it drives the
|
|
212
|
+
* gen-side stitch import graph straight off a `StoredPlate.tree`
|
|
213
|
+
* (`renderPlateModule`, index.ts). Relocated here from the now-deleted `merge.ts`
|
|
214
|
+
* in the ADR 0022 cutover — it is the one node-side primitive that survived the
|
|
215
|
+
* `mergeViews`/`addCapture` removal (the multi-View merge/gate now lives in the
|
|
216
|
+
* in-browser `serializePlateIR`, project.ts).
|
|
334
217
|
*/
|
|
335
|
-
const SPEC_FOLD = "full";
|
|
336
|
-
function tierLabel(spec) {
|
|
337
|
-
if (spec <= -2) return "ctx";
|
|
338
|
-
if (spec === -1) return "fb";
|
|
339
|
-
if (spec >= 2e7) return "gate";
|
|
340
|
-
if (spec >= 1e7) return "inl";
|
|
341
|
-
if (spec >= 9e6) return "leaf";
|
|
342
|
-
return "au";
|
|
343
|
-
}
|
|
344
|
-
//#endregion
|
|
345
|
-
//#region src/merge.ts
|
|
346
|
-
/**
|
|
347
|
-
* Turn the per-view captures in a plate file into the one plate a Skeleton
|
|
348
|
-
* renders.
|
|
349
|
-
*
|
|
350
|
-
* Each view is kept whole — its own subtree, its own ids, its own rules —
|
|
351
|
-
* and shown only across the viewport range it owns, via `@media display:none`
|
|
352
|
-
* gates. We do NOT merge the views' trees into one. An earlier design (ADR
|
|
353
|
-
* 0004) did, sharing common structure and forking the rest, but aligning two
|
|
354
|
-
* genuinely-different DOM trees (mobile stacked vs desktop two-column) without
|
|
355
|
-
* stable keys proved unreliable: every heuristic mis-paired some nodes and
|
|
356
|
-
* remapped one view's rules onto another's elements, corrupting both. A lone
|
|
357
|
-
* capture is pixel-faithful; keeping each view lone preserves that. The cost
|
|
358
|
-
* is a larger plate (roughly the sum of the views) — bounded by the plate
|
|
359
|
-
* size cap — in exchange for correctness.
|
|
360
|
-
*
|
|
361
|
-
* Pure data-in/data-out — runs in node (plugin load) and in tests.
|
|
362
|
-
*/
|
|
363
|
-
function mergeViews(file) {
|
|
364
|
-
const views = file.views;
|
|
365
|
-
if (views.length === 0) return emptyPlate(file.name);
|
|
366
|
-
if (views.length === 1) {
|
|
367
|
-
const [only] = views;
|
|
368
|
-
if (!only) return emptyPlate(file.name);
|
|
369
|
-
const { tree, css } = classify(only.rules, only.tree, file.name);
|
|
370
|
-
return {
|
|
371
|
-
v: 1,
|
|
372
|
-
name: file.name,
|
|
373
|
-
tree,
|
|
374
|
-
css
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
|
|
378
|
-
let nextId = 1;
|
|
379
|
-
const rootKids = [];
|
|
380
|
-
const rules = [];
|
|
381
|
-
views.forEach((view, i) => {
|
|
382
|
-
const idMap = /* @__PURE__ */ new Map();
|
|
383
|
-
const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
|
|
384
|
-
rootKids.push(...kids);
|
|
385
|
-
rules.push(...remapRules(view.rules, idMap));
|
|
386
|
-
const lo = i === 0 ? 0 : starts[i] ?? 0;
|
|
387
|
-
const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
|
|
388
|
-
for (const kid of kids) {
|
|
389
|
-
if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
|
|
390
|
-
if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
const { tree, css } = classify(rules, {
|
|
394
|
-
id: 0,
|
|
395
|
-
kids: rootKids
|
|
396
|
-
}, file.name);
|
|
397
|
-
return {
|
|
398
|
-
v: 1,
|
|
399
|
-
name: file.name,
|
|
400
|
-
tree,
|
|
401
|
-
css
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
function gate(id, condition) {
|
|
405
|
-
return {
|
|
406
|
-
ids: [id],
|
|
407
|
-
decls: ["display: none !important"],
|
|
408
|
-
media: [condition],
|
|
409
|
-
spec: SPEC_GATE,
|
|
410
|
-
order: id
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
/** Deep-clone a captured subtree into fresh ids, recording the mapping. */
|
|
414
|
-
function cloneWithIds(node, idMap, allocate) {
|
|
415
|
-
const id = allocate();
|
|
416
|
-
idMap.set(node.id, id);
|
|
417
|
-
const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
|
|
418
|
-
return {
|
|
419
|
-
id,
|
|
420
|
-
...node.leaf ? { leaf: node.leaf } : {},
|
|
421
|
-
...node.ref !== void 0 ? { ref: node.ref } : {},
|
|
422
|
-
...kids ? { kids } : {}
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
/** Distinct plate names referenced by the tree's stitches, in first-seen order. */
|
|
426
218
|
function collectRefs(tree) {
|
|
427
219
|
const names = [];
|
|
428
220
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -436,27 +228,6 @@ function collectRefs(tree) {
|
|
|
436
228
|
if (tree) visit(tree);
|
|
437
229
|
return names;
|
|
438
230
|
}
|
|
439
|
-
function remapRules(rules, idMap) {
|
|
440
|
-
return rules.map((rule) => ({
|
|
441
|
-
...rule,
|
|
442
|
-
ids: rule.ids.map((id) => idMap.get(id) ?? id)
|
|
443
|
-
}));
|
|
444
|
-
}
|
|
445
|
-
/** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
|
|
446
|
-
function addCapture(file, name, capture, breakpoints) {
|
|
447
|
-
const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
|
|
448
|
-
const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
|
|
449
|
-
const { diagnostics: _diagnostics, ...persisted } = capture;
|
|
450
|
-
const views = [...file?.views ?? [], persisted];
|
|
451
|
-
const byView = /* @__PURE__ */ new Map();
|
|
452
|
-
for (const view of views) byView.set(spanKey(view.width), view);
|
|
453
|
-
return {
|
|
454
|
-
v: 1,
|
|
455
|
-
name,
|
|
456
|
-
breakpoints: allBreakpoints,
|
|
457
|
-
views: [...byView.values()].toSorted((a, b) => a.width - b.width)
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
231
|
//#endregion
|
|
461
232
|
//#region src/diagnostics.ts
|
|
462
233
|
/**
|
|
@@ -486,9 +257,10 @@ const DIAGNOSTIC_MESSAGES = {
|
|
|
486
257
|
"skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
|
|
487
258
|
"skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
|
|
488
259
|
"unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
|
|
489
|
-
"dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, script/style/etc.)",
|
|
490
|
-
"
|
|
491
|
-
"
|
|
260
|
+
"dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, opacity:0, script/style/etc.)",
|
|
261
|
+
"dropped-subset": "dropped bones wholly inside a larger solid bone — the superset renders the same area (overlapping bones collapse to their union)",
|
|
262
|
+
"dropped-cropped": "dropped bones below the fold (off-screen at capture, in the viewport or a scroll container); real content fills in below without shifting the visible skeleton",
|
|
263
|
+
"too-large": "capture exceeded a Collect limit and was skipped; the <Skeleton> likely sits too high in the tree",
|
|
492
264
|
"invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
|
|
493
265
|
};
|
|
494
266
|
/**
|
|
@@ -539,93 +311,11 @@ function isValidPlateName(name) {
|
|
|
539
311
|
return /^\w+(?:-\w+)*(?:\/\w+(?:-\w+)*)*$/.test(name);
|
|
540
312
|
}
|
|
541
313
|
//#endregion
|
|
542
|
-
//#region src/css.ts
|
|
543
|
-
/**
|
|
544
|
-
* Pure CSS plumbing shared by both extraction strategies: which properties are
|
|
545
|
-
* layout (vs paint), approximate selector specificity for cascade-preserving
|
|
546
|
-
* ordering, and rendering scoped rules back to a single plate CSS string.
|
|
547
|
-
*/
|
|
548
|
-
const EXACT_PROPS = new Set([
|
|
549
|
-
"display",
|
|
550
|
-
"position",
|
|
551
|
-
"float",
|
|
552
|
-
"clear",
|
|
553
|
-
"box-sizing",
|
|
554
|
-
"top",
|
|
555
|
-
"right",
|
|
556
|
-
"bottom",
|
|
557
|
-
"left",
|
|
558
|
-
"width",
|
|
559
|
-
"height",
|
|
560
|
-
"block-size",
|
|
561
|
-
"inline-size",
|
|
562
|
-
"aspect-ratio",
|
|
563
|
-
"order",
|
|
564
|
-
"flex",
|
|
565
|
-
"gap",
|
|
566
|
-
"row-gap",
|
|
567
|
-
"column-gap",
|
|
568
|
-
"line-height",
|
|
569
|
-
"font",
|
|
570
|
-
"font-size",
|
|
571
|
-
"font-family",
|
|
572
|
-
"font-weight",
|
|
573
|
-
"font-style",
|
|
574
|
-
"white-space",
|
|
575
|
-
"text-align",
|
|
576
|
-
"text-indent",
|
|
577
|
-
"vertical-align",
|
|
578
|
-
"letter-spacing",
|
|
579
|
-
"word-spacing",
|
|
580
|
-
"word-break",
|
|
581
|
-
"overflow-wrap",
|
|
582
|
-
"text-wrap",
|
|
583
|
-
"transform",
|
|
584
|
-
"transform-origin",
|
|
585
|
-
"translate",
|
|
586
|
-
"scale",
|
|
587
|
-
"rotate",
|
|
588
|
-
"table-layout",
|
|
589
|
-
"border-collapse",
|
|
590
|
-
"border-spacing",
|
|
591
|
-
"caption-side",
|
|
592
|
-
"column-count",
|
|
593
|
-
"column-width",
|
|
594
|
-
"columns",
|
|
595
|
-
"column-span",
|
|
596
|
-
"column-fill",
|
|
597
|
-
"writing-mode",
|
|
598
|
-
"direction",
|
|
599
|
-
"contain",
|
|
600
|
-
"content-visibility",
|
|
601
|
-
"container",
|
|
602
|
-
"container-type",
|
|
603
|
-
"container-name",
|
|
604
|
-
"visibility"
|
|
605
|
-
]);
|
|
606
|
-
const PATTERN_PROPS = [
|
|
607
|
-
/^(margin|padding)(-(top|right|bottom|left|block|inline))?(-(start|end))?$/,
|
|
608
|
-
/^inset(-(block|inline))?(-(start|end))?$/,
|
|
609
|
-
/^border(-(top|right|bottom|left|block|inline))?(-(start|end))?-(width|style)$/,
|
|
610
|
-
/^border(-(top|bottom)-(left|right))?-radius$/,
|
|
611
|
-
/^border-(start|end)-(start|end)-radius$/,
|
|
612
|
-
/^grid(-|$)/,
|
|
613
|
-
/^flex-/,
|
|
614
|
-
/^(min|max)-(width|height|block-size|inline-size)$/,
|
|
615
|
-
/^(align|justify|place)-(items|content|self)$/,
|
|
616
|
-
/^overflow(-[xy])?$/
|
|
617
|
-
];
|
|
618
|
-
/** Layout properties are the only declarations a plate carries; paint stays out (ADR 0003). */
|
|
619
|
-
function isLayoutProp(prop) {
|
|
620
|
-
if (prop.startsWith("--")) return false;
|
|
621
|
-
if (EXACT_PROPS.has(prop)) return true;
|
|
622
|
-
return PATTERN_PROPS.some((re) => re.test(prop));
|
|
623
|
-
}
|
|
624
|
-
//#endregion
|
|
625
314
|
//#region src/validate.ts
|
|
626
315
|
/**
|
|
627
|
-
* Structural validation of a posted
|
|
628
|
-
*
|
|
316
|
+
* Structural validation of a posted/committed `StoredPlate`, plus the
|
|
317
|
+
* single-CSS-declaration / media-condition validators, for the dev `/__xray`
|
|
318
|
+
* endpoint and disk reads.
|
|
629
319
|
*
|
|
630
320
|
* NODE-ONLY (see the capture-input-validation plan and ADR 0008). This module
|
|
631
321
|
* imports `valibot`, which must NEVER reach the browser bundles: the dev capture
|
|
@@ -638,14 +328,17 @@ function isLayoutProp(prop) {
|
|
|
638
328
|
* helpers from any public subpath — they stay private until a `doctor`-style
|
|
639
329
|
* tool creates a concrete external need (the plan's decisions).
|
|
640
330
|
*
|
|
641
|
-
* The endpoint accepts ONLY the
|
|
642
|
-
* client
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
331
|
+
* The endpoint accepts ONLY the artifact shape xray itself produces. After the
|
|
332
|
+
* ADR 0022 cutover the dev client posts a finished `StoredPlate` — the whole
|
|
333
|
+
* Plate already projected in the browser (`serializePlateIR`) — so `parseStoredPlate`
|
|
334
|
+
* is the single validator: a post-classify `RenderNode` tree plus an
|
|
335
|
+
* already-classified css blob, guarded for `<style>`-breakout only (the per-decl
|
|
336
|
+
* allowlist no longer applies to a finished css string; see `isValidPlateCss`).
|
|
337
|
+
* The per-declaration / per-condition validators (`isValidDeclaration` /
|
|
338
|
+
* `isValidCondition`) remain exported helpers — the css blob already passed them
|
|
339
|
+
* AT CAPTURE TIME in the browser. Validation is necessary but not sufficient: a
|
|
340
|
+
* committed `StoredPlate` is also untrusted (hand-edited or from git), so the
|
|
341
|
+
* render path independently escapes raw-text `<style>` terminators (css-escape.ts).
|
|
649
342
|
*
|
|
650
343
|
* @module
|
|
651
344
|
*/
|
|
@@ -656,26 +349,15 @@ function isLayoutProp(prop) {
|
|
|
656
349
|
* these are the structural ceilings the schema enforces before either runs.
|
|
657
350
|
*/
|
|
658
351
|
const MAX_TREE_NODES = 5e4;
|
|
659
|
-
const MAX_RULES = 5e4;
|
|
660
|
-
const MAX_RULE_IDS = 5e4;
|
|
661
|
-
const MAX_DECLS_PER_RULE = 1e3;
|
|
662
|
-
const MAX_CONDITIONS = 64;
|
|
663
352
|
const MAX_BREAKPOINTS = 256;
|
|
664
353
|
const MAX_TREE_DEPTH = 1e3;
|
|
665
|
-
/** A single declaration is `prop: value [!important]`; real ones are short. */
|
|
666
|
-
const MAX_DECL_LENGTH = 2e3;
|
|
667
|
-
/** A media/container condition string, generously bounded. */
|
|
668
|
-
const MAX_CONDITION_LENGTH = 1e3;
|
|
669
|
-
/** Diagnostic strings are dev console text, not plate content; keep them small. */
|
|
670
|
-
const MAX_DIAGNOSTIC_MESSAGE = 1e3;
|
|
671
|
-
const MAX_DIAGNOSTIC_DETAIL = 1e3;
|
|
672
354
|
/**
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
* binary and hides its lines from `grep`. The class is identical in meaning to
|
|
676
|
-
* the previous literal-byte one (NUL through 0x1F, plus 0x7F).
|
|
355
|
+
* A template node's repeat count (Phase 2). Far above any real feed, yet bounded
|
|
356
|
+
* so a hostile `count` cannot drive an unbounded render-time string `.repeat`.
|
|
677
357
|
*/
|
|
678
|
-
const
|
|
358
|
+
const MAX_TEMPLATE_COUNT = 1e4;
|
|
359
|
+
/** A single declaration is `prop: value [!important]`; real ones are short. */
|
|
360
|
+
const MAX_DECL_LENGTH = 2e3;
|
|
679
361
|
/**
|
|
680
362
|
* Raw-text / markup terminators that must never appear in generated CSS, even
|
|
681
363
|
* when no bare `<`/`>` is present: HTML comment delimiters, a CDATA opener, and
|
|
@@ -683,87 +365,20 @@ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u;
|
|
|
683
365
|
* validators (case-insensitive).
|
|
684
366
|
*/
|
|
685
367
|
const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
|
|
686
|
-
/**
|
|
687
|
-
* Accept only a SINGLE CSS declaration of the form `property: value` with an
|
|
688
|
-
* optional trailing `!important`, and only for a layout property the capture
|
|
689
|
-
* path would emit. This is the same allowlist `filterLayoutDecls` applies
|
|
690
|
-
* (`isLayoutProp`), so a posted payload cannot persist paint- or
|
|
691
|
-
* script-adjacent junk the real capture would never produce.
|
|
692
|
-
*
|
|
693
|
-
* Rejects anything that could escape the single-declaration context or the
|
|
694
|
-
* `<style>` raw-text context downstream: rule braces (`{`/`}`), markup angle
|
|
695
|
-
* brackets (`<`/`>`), HTML comment / CDATA delimiters, a `</style` terminator,
|
|
696
|
-
* ASCII control characters, and any extra semicolon-separated declaration. A
|
|
697
|
-
* lone trailing `;` is tolerated (some serializers add one); a second
|
|
698
|
-
* declaration after it is not.
|
|
699
|
-
*/
|
|
700
|
-
function isValidDeclaration(decl) {
|
|
701
|
-
if (typeof decl !== "string") return false;
|
|
702
|
-
if (decl.length === 0 || decl.length > MAX_DECL_LENGTH) return false;
|
|
703
|
-
if (CONTROL_CHARS.test(decl)) return false;
|
|
704
|
-
if (/[{}<>]/.test(decl)) return false;
|
|
705
|
-
if (RAW_TEXT_TERMINATORS.test(decl)) return false;
|
|
706
|
-
const trimmed = decl.trim().replace(/;\s*$/, "");
|
|
707
|
-
if (trimmed.includes(";")) return false;
|
|
708
|
-
const colon = trimmed.indexOf(":");
|
|
709
|
-
if (colon <= 0) return false;
|
|
710
|
-
const prop = trimmed.slice(0, colon).trim().toLowerCase();
|
|
711
|
-
const value = trimmed.slice(colon + 1).trim();
|
|
712
|
-
if (value.length === 0) return false;
|
|
713
|
-
if (value.replace(/\s*!\s*important\s*$/i, "").trim().length === 0) return false;
|
|
714
|
-
return isLayoutProp(prop);
|
|
715
|
-
}
|
|
716
|
-
/**
|
|
717
|
-
* Accept only a media/container condition (prelude) of the shape xray actually
|
|
718
|
-
* emits, and reject anything that could break OUT of the `@media`/`@container`
|
|
719
|
-
* prelude into a top-level rule when `classify` interpolates it RAW into the
|
|
720
|
-
* generated CSS (`@media <condition> { ... }`). The capture path produces:
|
|
721
|
-
* synthesized span conditions `(min-width: 768px)` / `(max-width: 767.98px)`,
|
|
722
|
-
* author media text such as `screen and (min-width: 768px)`,
|
|
723
|
-
* `(orientation: landscape)`, comma media lists `screen, print`, and container
|
|
724
|
-
* queries like `(min-width: 400px)`, a named `sidebar (min-width: 400px)`, or a
|
|
725
|
-
* style query `style(--x: 1)`. Authors may also use Media Queries Level 4 range
|
|
726
|
-
* syntax, which browsers serialize into `mediaText`/`containerQuery` verbatim
|
|
727
|
-
* (`(width >= 600px)`, `(400px < width <= 700px)`), so a condition can legitimately
|
|
728
|
-
* carry a bare `<`/`>` — those are allowed.
|
|
729
|
-
*
|
|
730
|
-
* Rejects: rule braces (`{`/`}`) and a `;` — the CSS-rule breakout vectors —
|
|
731
|
-
* CSS comment delimiters (`/*` / `*\/`), the raw-text/markup terminators
|
|
732
|
-
* (`</style`, `<!--`, `-->`, `<![CDATA[`), ASCII control characters, and any
|
|
733
|
-
* over-length string.
|
|
734
|
-
*/
|
|
735
|
-
function isValidCondition(condition) {
|
|
736
|
-
if (typeof condition !== "string") return false;
|
|
737
|
-
if (condition.length === 0 || condition.length > MAX_CONDITION_LENGTH) return false;
|
|
738
|
-
if (CONTROL_CHARS.test(condition)) return false;
|
|
739
|
-
if (/[{};]/.test(condition)) return false;
|
|
740
|
-
if (condition.includes("/*") || condition.includes("*/")) return false;
|
|
741
|
-
if (RAW_TEXT_TERMINATORS.test(condition)) return false;
|
|
742
|
-
return true;
|
|
743
|
-
}
|
|
744
|
-
/** A finite integer id (plate-local node id), bounded to a sane range. */
|
|
745
|
-
const idSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(Number.MAX_SAFE_INTEGER));
|
|
746
368
|
/** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
|
|
747
369
|
const finiteNumber = v.pipe(v.number(), v.finite());
|
|
748
|
-
const conditionList = v.pipe(v.array(v.pipe(v.string(), v.check(isValidCondition, "invalid condition"))), v.maxLength(MAX_CONDITIONS));
|
|
749
370
|
const leafSchema = v.picklist([
|
|
750
371
|
"text",
|
|
372
|
+
"text-block",
|
|
751
373
|
"media",
|
|
752
374
|
"box"
|
|
753
375
|
]);
|
|
754
376
|
/**
|
|
755
|
-
*
|
|
756
|
-
* `
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
* the stack or memory before the node guard runs.
|
|
377
|
+
* Depth + total-node guard shared by the recursive node schema. The recursive
|
|
378
|
+
* `array(lazy(...))` bounds breadth per level; this guards total depth so a long
|
|
379
|
+
* thin chain cannot recurse without limit, and the running `count` bounds the
|
|
380
|
+
* total node budget so a fanned-out payload cannot exhaust memory.
|
|
760
381
|
*/
|
|
761
|
-
const plateNodeSchema = v.pipe(v.object({
|
|
762
|
-
id: idSchema,
|
|
763
|
-
leaf: v.optional(leafSchema),
|
|
764
|
-
ref: v.optional(v.pipe(v.string(), v.check(isValidPlateName, "invalid stitch ref"))),
|
|
765
|
-
kids: v.optional(v.pipe(v.array(v.lazy(() => plateNodeSchema)), v.maxLength(MAX_TREE_NODES)))
|
|
766
|
-
}), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
|
|
767
382
|
function treeWithinLimits(root) {
|
|
768
383
|
let count = 0;
|
|
769
384
|
const walk = (node, depth) => {
|
|
@@ -777,103 +392,79 @@ function treeWithinLimits(root) {
|
|
|
777
392
|
};
|
|
778
393
|
return walk(root, 0);
|
|
779
394
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
decls: v.pipe(v.array(v.pipe(v.string(), v.check(isValidDeclaration, "invalid declaration"))), v.maxLength(MAX_DECLS_PER_RULE)),
|
|
783
|
-
media: v.optional(conditionList),
|
|
784
|
-
container: v.optional(conditionList),
|
|
785
|
-
layered: v.optional(v.boolean()),
|
|
786
|
-
spec: finiteNumber,
|
|
787
|
-
order: finiteNumber
|
|
788
|
-
});
|
|
789
|
-
/**
|
|
790
|
-
* The TRANSIENT, dev-only diagnostics that ride on a posted capture
|
|
791
|
-
* (plate.ts / diagnostics.ts). Validated LOOSELY and kept as a KNOWN key so a
|
|
792
|
-
* valid post is not rejected AND the field survives parsing for the server to
|
|
793
|
-
* log before `addCapture` strips it. `code` is checked against the closed
|
|
794
|
-
* DiagnosticCode set; message/detail are bounded strings, never plate content.
|
|
795
|
-
*/
|
|
796
|
-
const diagnosticCode = v.picklist([
|
|
797
|
-
"unreadable-stylesheet",
|
|
798
|
-
"unreadable-import",
|
|
799
|
-
"skipped-dynamic-pseudo",
|
|
800
|
-
"skipped-pseudo-element",
|
|
801
|
-
"unsupported-rule",
|
|
802
|
-
"dropped-tag",
|
|
803
|
-
"pruned-run",
|
|
804
|
-
"too-large",
|
|
805
|
-
"invalid-plate-file"
|
|
806
|
-
]);
|
|
807
|
-
const diagnosticSchema = v.object({
|
|
808
|
-
code: diagnosticCode,
|
|
809
|
-
message: v.pipe(v.string(), v.maxLength(MAX_DIAGNOSTIC_MESSAGE)),
|
|
810
|
-
count: v.optional(finiteNumber),
|
|
811
|
-
detail: v.optional(v.pipe(v.string(), v.maxLength(MAX_DIAGNOSTIC_DETAIL)))
|
|
812
|
-
});
|
|
395
|
+
/** A finished plate's css blob, generously bounded (post-classify, whole-plate). */
|
|
396
|
+
const MAX_PLATE_CSS_LENGTH = 4e6;
|
|
813
397
|
/**
|
|
814
|
-
* A
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
398
|
+
* A NUL byte — the one control character that must never reach the rendered
|
|
399
|
+
* `<style>`. Unlike the per-declaration validator, the whole-plate css blob
|
|
400
|
+
* legitimately carries newlines/tabs (`classify` joins rules with `\n`), so the
|
|
401
|
+
* broad `CONTROL_CHARS` class does NOT apply here.
|
|
818
402
|
*/
|
|
819
|
-
const
|
|
820
|
-
width: finiteNumber,
|
|
821
|
-
min: v.optional(finiteNumber),
|
|
822
|
-
max: v.optional(finiteNumber),
|
|
823
|
-
tree: plateNodeSchema,
|
|
824
|
-
rules: v.pipe(v.array(plateRuleSchema), v.maxLength(MAX_RULES)),
|
|
825
|
-
diagnostics: v.optional(v.pipe(v.array(diagnosticSchema), v.maxLength(MAX_CONDITIONS)))
|
|
826
|
-
}), v.check((c) => c.min === void 0 || c.max === void 0 || c.min < c.max, "capture span min must be below max"));
|
|
403
|
+
const NUL = /\u0000/u;
|
|
827
404
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
405
|
+
* Validate a STORED, already-classified css blob (ADR 0022 cutover). The trust
|
|
406
|
+
* model differs from a pre-classify `decls[]` array: a `StoredPlate.css` is the
|
|
407
|
+
* WHOLE plate's content-addressed CSS — a string of selectors, `{ ... }` rule
|
|
408
|
+
* blocks, and `@media`/`@container` at-rules — so the per-declaration
|
|
409
|
+
* `isValidDeclaration` allowlist (which forbids `{}` and re-checks `isLayoutProp`)
|
|
410
|
+
* cannot apply: it would reject every real plate. Braces, newlines, and bare
|
|
411
|
+
* `<`/`>` (Media Queries Level 4 range syntax, `@media (width >= 600px)`) are all
|
|
412
|
+
* legitimate here. The css was produced by `filterLayoutDecls`-filtered decls at
|
|
413
|
+
* capture time in the browser; on disk we guard only the security boundary the
|
|
414
|
+
* render path also enforces: no `<style>` raw-text breakout. Reject the raw-text /
|
|
415
|
+
* markup terminators (`</style`, `<!--`, `-->`, `<![CDATA[`) and a NUL byte, and
|
|
416
|
+
* bound the length. The render path independently escapes these (`escapeStyleText`,
|
|
417
|
+
* css-escape.ts) — that stays as the belt; this is the braces. A `StoredPlate` is
|
|
418
|
+
* untrusted exactly as a `PlateFile` was (hand-edited / arriving via git).
|
|
832
419
|
*/
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
420
|
+
function isValidPlateCss(css) {
|
|
421
|
+
if (typeof css !== "string") return false;
|
|
422
|
+
if (css.length > MAX_PLATE_CSS_LENGTH) return false;
|
|
423
|
+
if (NUL.test(css)) return false;
|
|
424
|
+
if (RAW_TEXT_TERMINATORS.test(css)) return false;
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
838
427
|
/**
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
428
|
+
* Recursive `RenderNode` — the POST-classify tree shape (plate.ts): no `id` (ids
|
|
429
|
+
* are stripped at classify), an optional content-class string `cls`, and the same
|
|
430
|
+
* `leaf`/`ref`/`count`/`kids` the renderer reads. Mirrors `plateNodeSchema` minus
|
|
431
|
+
* `id`, plus a bounded `cls`. Depth/breadth bounded by the shared `treeWithinLimits`.
|
|
843
432
|
*/
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
433
|
+
const renderNodeSchema = v.pipe(v.object({
|
|
434
|
+
leaf: v.optional(leafSchema),
|
|
435
|
+
ref: v.optional(v.pipe(v.string(), v.check(isValidPlateName, "invalid stitch ref"))),
|
|
436
|
+
cls: v.optional(v.pipe(v.string(), v.maxLength(MAX_DECL_LENGTH))),
|
|
437
|
+
count: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(MAX_TEMPLATE_COUNT))),
|
|
438
|
+
kids: v.optional(v.pipe(v.array(v.lazy(() => renderNodeSchema)), v.maxLength(MAX_TREE_NODES)))
|
|
439
|
+
}), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
|
|
848
440
|
/**
|
|
849
|
-
* The committed `
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* never carries `diagnostics` (stripped before write), and the schema already
|
|
858
|
-
* makes that field optional, so a file without it is accepted.
|
|
441
|
+
* The committed/posted `StoredPlate` shape AFTER the ADR 0022 cutover (plate.ts):
|
|
442
|
+
* the whole plate already projected in the browser. Validated to the SAME trust
|
|
443
|
+
* boundary a `PlateFile` was — a `StoredPlate` on disk is untrusted (hand-edited
|
|
444
|
+
* or arriving via git). `tree` is a `RenderNode` (post-classify, nullable for a
|
|
445
|
+
* capture-less plate); `css` is the already-classified blob, guarded for
|
|
446
|
+
* `<style>`-breakout only (`isValidPlateCss`, above). `breakpoints` are finite
|
|
447
|
+
* non-negative numbers, bounded but NOT renormalized (the file is the source of
|
|
448
|
+
* truth). `v` must be the current `PLATE_VERSION`.
|
|
859
449
|
*/
|
|
860
|
-
const
|
|
861
|
-
v: v.literal(
|
|
450
|
+
const storedPlateSchema = v.object({
|
|
451
|
+
v: v.literal(2),
|
|
862
452
|
name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
|
|
863
453
|
breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
|
|
864
|
-
|
|
454
|
+
tree: v.nullable(renderNodeSchema),
|
|
455
|
+
css: v.pipe(v.string(), v.check(isValidPlateCss, "invalid plate css"))
|
|
865
456
|
});
|
|
866
457
|
/**
|
|
867
|
-
* Parse a committed
|
|
868
|
-
* Returns the typed `
|
|
869
|
-
* (corrupt
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* hostile
|
|
873
|
-
* smuggling
|
|
458
|
+
* Parse a committed/posted `StoredPlate` against its schema (the ADR 0022 cutover
|
|
459
|
+
* shape). Returns the typed `StoredPlate` on success or `null` on any structural
|
|
460
|
+
* failure (corrupt object, wrong shape, version mismatch, an injected `<style>`
|
|
461
|
+
* terminator in the css, an oversized/over-deep tree). The disk readers and the
|
|
462
|
+
* POST handler map `null` to an empty plate / a generic 400 and never echo the
|
|
463
|
+
* payload — a malformed or hostile stored plate degrades to empty rather than
|
|
464
|
+
* breaking the build or smuggling a `<style>`-escaping css blob through render.
|
|
874
465
|
*/
|
|
875
|
-
function
|
|
876
|
-
const result = v.safeParse(
|
|
466
|
+
function parseStoredPlate(value) {
|
|
467
|
+
const result = v.safeParse(storedPlateSchema, value);
|
|
877
468
|
return result.success ? result.output : null;
|
|
878
469
|
}
|
|
879
470
|
//#endregion
|
|
@@ -914,7 +505,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
914
505
|
const capture = options.capture ?? false;
|
|
915
506
|
const delay = options.delay ?? 200;
|
|
916
507
|
const settleCap = options.settleCap ?? 5e3;
|
|
917
|
-
const
|
|
508
|
+
const collect = options.collect;
|
|
918
509
|
const hud = options.hud ?? false;
|
|
919
510
|
const recordMode = options.fixtures?.record ?? "manual";
|
|
920
511
|
const replayMode = options.fixtures?.replay ?? false;
|
|
@@ -933,7 +524,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
933
524
|
if (!id.startsWith(RESOLVED_PREFIX)) return void 0;
|
|
934
525
|
const name = id.slice(RESOLVED_PREFIX.length);
|
|
935
526
|
if (!isValidPlateName(name)) return renderPlateModule(emptyPlate(name));
|
|
936
|
-
return renderPlateModule(
|
|
527
|
+
return renderPlateModule(readStoredPlate(platePath(name)) ?? emptyPlate(name), name);
|
|
937
528
|
}
|
|
938
529
|
}, {
|
|
939
530
|
name: "xray:dev",
|
|
@@ -963,7 +554,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
963
554
|
},
|
|
964
555
|
load(id) {
|
|
965
556
|
if (id !== RESOLVED_BOOT_ID) return void 0;
|
|
966
|
-
return bootstrapCode(delay, settleCap,
|
|
557
|
+
return bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, replayMode);
|
|
967
558
|
},
|
|
968
559
|
configureServer(server) {
|
|
969
560
|
const dir = () => resolve(root, platesDir);
|
|
@@ -972,7 +563,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
972
563
|
if (!file.startsWith(dir()) || !file.endsWith(".json")) return;
|
|
973
564
|
const name = relative(dir(), file).slice(0, -5);
|
|
974
565
|
if (!isValidPlateName(name)) return;
|
|
975
|
-
announcePlate(server.ws, name,
|
|
566
|
+
announcePlate(server.ws, name, readStoredPlate(file));
|
|
976
567
|
};
|
|
977
568
|
server.watcher.on("add", onPlateFile);
|
|
978
569
|
server.watcher.on("change", onPlateFile);
|
|
@@ -1000,14 +591,14 @@ function xrayVitePlugin(options = {}) {
|
|
|
1000
591
|
* The injected dev module: boot the client, POST captures back, optionally
|
|
1001
592
|
* mount the HUD. The client installs first so the HUD finds the light box store.
|
|
1002
593
|
*/
|
|
1003
|
-
function bootstrapCode(delay, settleCap,
|
|
594
|
+
function bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, replayMode) {
|
|
1004
595
|
return [
|
|
1005
596
|
`import { installXrayClient } from '@hueest/xray/internal/client'`,
|
|
1006
597
|
...hud ? [`import { installXrayHud } from '@hueest/xray/internal/hud'`] : [],
|
|
1007
598
|
"installXrayClient({",
|
|
1008
599
|
` delay: ${delay},`,
|
|
1009
600
|
` settleCap: ${settleCap},`,
|
|
1010
|
-
`
|
|
601
|
+
` collect: ${JSON.stringify(collect ?? {})},`,
|
|
1011
602
|
` capture: ${capture},`,
|
|
1012
603
|
` record: ${JSON.stringify(recordMode)},`,
|
|
1013
604
|
` replay: ${JSON.stringify(replayMode)},`,
|
|
@@ -1073,10 +664,11 @@ function reportServerDiagnostics(server, name, diagnostics) {
|
|
|
1073
664
|
/** A committed plate file past this is almost always a `<Skeleton>` set too high. */
|
|
1074
665
|
const MAX_PLATE_BYTES = 2e6;
|
|
1075
666
|
/**
|
|
1076
|
-
* Hard ceiling on the POST body we will buffer.
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
* stop an oversized or runaway body from being read into memory.
|
|
667
|
+
* Hard ceiling on the POST body we will buffer. The posted StoredPlate is the
|
|
668
|
+
* whole projected Plate the `MAX_PLATE_BYTES` cap guards, so this sits above the
|
|
669
|
+
* plate cap with headroom (the JSON envelope + diagnostics add a little) and
|
|
670
|
+
* exists only to stop an oversized or runaway body from being read into memory.
|
|
671
|
+
* Enforced both
|
|
1080
672
|
* up front from `content-length` and while streaming, so a lying or absent
|
|
1081
673
|
* `content-length` cannot get past it. The dev endpoint is local and
|
|
1082
674
|
* unauthenticated, so this is the real backstop, not the header.
|
|
@@ -1091,29 +683,55 @@ function isJsonContentType(value) {
|
|
|
1091
683
|
function isRecord(value) {
|
|
1092
684
|
return typeof value === "object" && value !== null;
|
|
1093
685
|
}
|
|
1094
|
-
/**
|
|
1095
|
-
*
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
686
|
+
/**
|
|
687
|
+
* Derive the HUD coverage bands from a StoredPlate's `breakpoints` (ADR 0022
|
|
688
|
+
* cutover). Per-View spans no longer exist on disk — a post-cutover StoredPlate
|
|
689
|
+
* sweeps ALL widths in one session, so coverage is complete per-Plate and the
|
|
690
|
+
* bands are just the breakpoint partition: each adjacent breakpoint pair is a
|
|
691
|
+
* covered band, the first band open-min, the last open-max. `breakpoints=[b1,b2]`
|
|
692
|
+
* → `[{max:b1},{min:b1,max:b2},{min:b2}]`; `breakpoints=[]` → one open band `[{}]`.
|
|
693
|
+
* Keeps the `xray:plate`/coverage `spans` shape (`{min?, max?}[]`) identical — only
|
|
694
|
+
* the SOURCE changes (derived from breakpoints, not read off per-View captures).
|
|
695
|
+
*/
|
|
696
|
+
function spansFromBreakpoints(breakpoints) {
|
|
697
|
+
const sorted = [...breakpoints].toSorted((a, b) => a - b);
|
|
698
|
+
if (sorted.length === 0) return [{}];
|
|
699
|
+
const spans = [{ max: sorted[0] }];
|
|
700
|
+
for (let i = 0; i < sorted.length - 1; i++) spans.push({
|
|
701
|
+
min: sorted[i],
|
|
702
|
+
max: sorted[i + 1]
|
|
703
|
+
});
|
|
704
|
+
spans.push({ min: sorted.at(-1) });
|
|
705
|
+
return spans;
|
|
706
|
+
}
|
|
707
|
+
/** Send the stored plate (lowered to chunks) and its derived coverage bands over
|
|
708
|
+
* the HMR event so the client hot-swaps it in place and the HUD updates its band
|
|
709
|
+
* display. The gen-side runs `serializePlate(stored)` (the Bundle seam) here; the
|
|
710
|
+
* `spans` derive from the stored breakpoints (the per-View list is gone). */
|
|
711
|
+
function announcePlate(ws, name, stored) {
|
|
712
|
+
const breakpoints = stored?.breakpoints ?? [];
|
|
713
|
+
const plate = stored ? serializePlate({
|
|
714
|
+
v: stored.v,
|
|
715
|
+
name,
|
|
716
|
+
tree: stored.tree,
|
|
717
|
+
css: stored.css
|
|
718
|
+
}) : serializePlate(emptyPlate(name));
|
|
1101
719
|
ws.send({
|
|
1102
720
|
type: "custom",
|
|
1103
721
|
event: "xray:plate",
|
|
1104
722
|
data: {
|
|
1105
723
|
name,
|
|
1106
|
-
plate
|
|
1107
|
-
breakpoints
|
|
1108
|
-
spans: (
|
|
1109
|
-
min: view.min,
|
|
1110
|
-
max: view.max
|
|
1111
|
-
}))
|
|
724
|
+
plate,
|
|
725
|
+
breakpoints,
|
|
726
|
+
spans: spansFromBreakpoints(breakpoints)
|
|
1112
727
|
}
|
|
1113
728
|
});
|
|
1114
729
|
}
|
|
1115
730
|
/**
|
|
1116
|
-
* Per-plate breakpoints +
|
|
731
|
+
* Per-plate breakpoints + derived coverage bands, read from the committed
|
|
732
|
+
* StoredPlate files. The `spans` derive from each plate's `breakpoints` (ADR 0022
|
|
733
|
+
* cutover — per-View spans no longer exist on disk; a StoredPlate sweeps all
|
|
734
|
+
* widths in one session, so its coverage is complete).
|
|
1117
735
|
*
|
|
1118
736
|
* @internal
|
|
1119
737
|
*/
|
|
@@ -1133,14 +751,12 @@ function readCoverage(dir) {
|
|
|
1133
751
|
if (!rel.endsWith(".json")) continue;
|
|
1134
752
|
const name = rel.slice(0, -5);
|
|
1135
753
|
if (!isValidPlateName(name)) continue;
|
|
1136
|
-
const
|
|
1137
|
-
if (!
|
|
754
|
+
const stored = readStoredPlate(resolve(dir, rel));
|
|
755
|
+
if (!stored) continue;
|
|
756
|
+
const breakpoints = stored.breakpoints ?? [];
|
|
1138
757
|
out[name] = {
|
|
1139
|
-
breakpoints
|
|
1140
|
-
spans:
|
|
1141
|
-
min: view.min,
|
|
1142
|
-
max: view.max
|
|
1143
|
-
}))
|
|
758
|
+
breakpoints,
|
|
759
|
+
spans: spansFromBreakpoints(breakpoints)
|
|
1144
760
|
};
|
|
1145
761
|
}
|
|
1146
762
|
return out;
|
|
@@ -1263,7 +879,12 @@ function handleFixturePost(req, res, fixturePath) {
|
|
|
1263
879
|
});
|
|
1264
880
|
}
|
|
1265
881
|
/**
|
|
1266
|
-
*
|
|
882
|
+
* Validate a posted `StoredPlate` envelope, write it AS-IS, and hot-swap the
|
|
883
|
+
* plate in place. After the ADR 0022 cutover the dev client posts a finished
|
|
884
|
+
* StoredPlate (the whole Plate already projected in the browser) inside a
|
|
885
|
+
* `{ plate, diagnostics? }` envelope — there is NO server-side accumulation/merge
|
|
886
|
+
* anymore. `diagnostics` is a SIBLING field the server logs and then drops; it
|
|
887
|
+
* never reaches the stored artifact (ADR 0008).
|
|
1267
888
|
*
|
|
1268
889
|
* @internal
|
|
1269
890
|
*/
|
|
@@ -1303,32 +924,33 @@ function handleCapturePost(req, res, server, platePath) {
|
|
|
1303
924
|
req.on("end", () => {
|
|
1304
925
|
if (aborted) return;
|
|
1305
926
|
try {
|
|
1306
|
-
const
|
|
1307
|
-
|
|
927
|
+
const raw = JSON.parse(body);
|
|
928
|
+
const envelope = isRecord(raw) ? raw : void 0;
|
|
929
|
+
const stored = parseStoredPlate(envelope?.plate);
|
|
930
|
+
if (!stored) {
|
|
1308
931
|
res.statusCode = 400;
|
|
1309
932
|
res.end("invalid capture payload");
|
|
1310
933
|
return;
|
|
1311
934
|
}
|
|
1312
|
-
reportServerDiagnostics(server,
|
|
1313
|
-
const file = platePath(
|
|
1314
|
-
const {
|
|
1315
|
-
if (invalid) reportServerDiagnostics(server,
|
|
1316
|
-
const
|
|
1317
|
-
const serialized = `${JSON.stringify(next, null, 1)}\n`;
|
|
935
|
+
reportServerDiagnostics(server, stored.name, validEnvelopeDiagnostics(envelope?.diagnostics));
|
|
936
|
+
const file = platePath(stored.name);
|
|
937
|
+
const { stored: existing, invalid } = loadStoredPlate(file);
|
|
938
|
+
if (invalid) reportServerDiagnostics(server, stored.name, [makeDiagnostic("invalid-plate-file", { detail: `${stored.name}.json` })]);
|
|
939
|
+
const serialized = `${JSON.stringify(stored, null, 1)}\n`;
|
|
1318
940
|
if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
|
|
1319
941
|
res.statusCode = 204;
|
|
1320
942
|
res.end();
|
|
1321
943
|
return;
|
|
1322
944
|
}
|
|
1323
945
|
if (serialized.length > MAX_PLATE_BYTES) {
|
|
1324
|
-
console.warn(`[xray] not writing "${
|
|
946
|
+
console.warn(`[xray] not writing "${stored.name}": ${Math.round(serialized.length / 1e3)}KB exceeds the cap.`, "The <Skeleton> likely sits too high in the tree.");
|
|
1325
947
|
res.statusCode = 413;
|
|
1326
948
|
res.end("plate too large");
|
|
1327
949
|
return;
|
|
1328
950
|
}
|
|
1329
951
|
mkdirSync(dirname(file), { recursive: true });
|
|
1330
952
|
writeFileSync(file, serialized);
|
|
1331
|
-
announcePlate(server.ws,
|
|
953
|
+
announcePlate(server.ws, stored.name, stored);
|
|
1332
954
|
res.statusCode = 204;
|
|
1333
955
|
res.end();
|
|
1334
956
|
} catch {
|
|
@@ -1337,55 +959,64 @@ function handleCapturePost(req, res, server, platePath) {
|
|
|
1337
959
|
}
|
|
1338
960
|
});
|
|
1339
961
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
962
|
+
/**
|
|
963
|
+
* Loosely validate the envelope's SIBLING `diagnostics` (never stored, just
|
|
964
|
+
* logged). The StoredPlate validator does not carry diagnostics — they are a
|
|
965
|
+
* transient dev-only field on the POST envelope — so this filters the array to
|
|
966
|
+
* the known-code shape `reportServerDiagnostics` formats, dropping anything that
|
|
967
|
+
* is not a `{ code, message, ... }` record. A malformed diagnostics field never
|
|
968
|
+
* fails the post (the plate already validated); it just logs nothing.
|
|
969
|
+
*/
|
|
970
|
+
function validEnvelopeDiagnostics(value) {
|
|
971
|
+
if (!Array.isArray(value)) return void 0;
|
|
972
|
+
const out = [];
|
|
973
|
+
for (const item of value) {
|
|
974
|
+
if (!isRecord(item) || typeof item.code !== "string" || typeof item.message !== "string") continue;
|
|
975
|
+
out.push({
|
|
976
|
+
code: item.code,
|
|
977
|
+
message: item.message,
|
|
978
|
+
...typeof item.count === "number" ? { count: item.count } : {},
|
|
979
|
+
...typeof item.detail === "string" ? { detail: item.detail } : {}
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
return out.length > 0 ? out : void 0;
|
|
1342
983
|
}
|
|
1343
984
|
/**
|
|
1344
|
-
* Read and validate a committed
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
985
|
+
* Read and validate a committed StoredPlate, distinguishing "no file" from "file
|
|
986
|
+
* present but unreadable". `invalid` is true ONLY when the file exists but is
|
|
987
|
+
* corrupt JSON, the wrong shape, or a version mismatch — a missing file is the
|
|
988
|
+
* normal pre-capture state and is not flagged. Callers use `invalid` to warn
|
|
989
|
+
* (same diagnostics vocabulary) that an existing plate was ignored.
|
|
1349
990
|
*/
|
|
1350
|
-
function
|
|
991
|
+
function loadStoredPlate(file) {
|
|
1351
992
|
let raw;
|
|
1352
993
|
try {
|
|
1353
994
|
raw = readFileSync(file, "utf8");
|
|
1354
995
|
} catch {
|
|
1355
996
|
return {
|
|
1356
|
-
|
|
997
|
+
stored: null,
|
|
1357
998
|
invalid: false
|
|
1358
999
|
};
|
|
1359
1000
|
}
|
|
1360
1001
|
try {
|
|
1361
|
-
const
|
|
1362
|
-
if (!
|
|
1363
|
-
|
|
1002
|
+
const stored = parseStoredPlate(JSON.parse(raw));
|
|
1003
|
+
if (!stored) return {
|
|
1004
|
+
stored: null,
|
|
1364
1005
|
invalid: true
|
|
1365
1006
|
};
|
|
1366
1007
|
return {
|
|
1367
|
-
|
|
1008
|
+
stored,
|
|
1368
1009
|
invalid: false
|
|
1369
1010
|
};
|
|
1370
1011
|
} catch {
|
|
1371
1012
|
return {
|
|
1372
|
-
|
|
1013
|
+
stored: null,
|
|
1373
1014
|
invalid: true
|
|
1374
1015
|
};
|
|
1375
1016
|
}
|
|
1376
1017
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
* resolves to. Missing, unreadable, or version-mismatched files resolve to an
|
|
1380
|
-
* empty plate, so imports never break.
|
|
1381
|
-
*/
|
|
1382
|
-
function readPlate(file, name) {
|
|
1383
|
-
const plateFile = readPlateFile(file);
|
|
1384
|
-
if (!plateFile) return emptyPlate(name);
|
|
1385
|
-
return mergeViews({
|
|
1386
|
-
...plateFile,
|
|
1387
|
-
name
|
|
1388
|
-
});
|
|
1018
|
+
function readStoredPlate(file) {
|
|
1019
|
+
return loadStoredPlate(file).stored;
|
|
1389
1020
|
}
|
|
1390
1021
|
/**
|
|
1391
1022
|
* Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
|
|
@@ -1396,13 +1027,21 @@ function readPlate(file, name) {
|
|
|
1396
1027
|
* import itself); an unknown child resolves to its own empty plate, never a
|
|
1397
1028
|
* build break.
|
|
1398
1029
|
*
|
|
1030
|
+
* `name` defaults to the plate's own `name`, but the loader passes the
|
|
1031
|
+
* PATH-derived name so a hand-edited StoredPlate whose `name` field drifted from
|
|
1032
|
+
* its filename still serializes and self-references under the canonical name
|
|
1033
|
+
* (`collectRefs` reads `stored.tree`, a RenderNode, unchanged by the cutover).
|
|
1034
|
+
*
|
|
1399
1035
|
* @internal
|
|
1400
1036
|
*/
|
|
1401
|
-
function renderPlateModule(merged) {
|
|
1402
|
-
const refs = collectRefs(merged.tree).filter((
|
|
1403
|
-
const json = JSON.stringify(serializePlate(
|
|
1037
|
+
function renderPlateModule(merged, name = merged.name) {
|
|
1038
|
+
const refs = collectRefs(merged.tree).filter((ref) => ref !== name && isValidPlateName(ref));
|
|
1039
|
+
const json = JSON.stringify(serializePlate({
|
|
1040
|
+
...merged,
|
|
1041
|
+
name
|
|
1042
|
+
}));
|
|
1404
1043
|
if (refs.length === 0) return `export default ${json}`;
|
|
1405
|
-
return `${refs.map((
|
|
1044
|
+
return `${refs.map((ref, i) => `import __xr${i} from ${JSON.stringify(VIRTUAL_PREFIX + ref)}`).join("\n")}\nexport default Object.assign(${json}, { refs: { ${refs.map((ref, i) => `${JSON.stringify(ref)}: __xr${i}`).join(", ")} } })`;
|
|
1406
1045
|
}
|
|
1407
1046
|
//#endregion
|
|
1408
|
-
export {
|
|
1047
|
+
export { collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };
|