@hueest/xray 0.1.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 +64 -16
- package/dist/client.js +903 -72
- package/dist/core.d.ts +21 -27
- package/dist/core.js +39 -210
- package/dist/css-escape-N7bOusGW.js +52 -0
- package/dist/hud.js +46 -0
- package/dist/index.d.ts +297 -48
- package/dist/index.js +674 -353
- package/dist/plate-BIr62u7l.d.ts +363 -0
- package/dist/plate-BRR6d8Se.js +156 -0
- package/dist/project-DORoPAo7.js +10224 -0
- package/dist/react.core-BUj8ziwh.d.ts +79 -0
- package/dist/react.core-DMIhXHZF.js +272 -0
- package/dist/react.d.ts +2 -8
- package/dist/react.dev.d.ts +2 -9
- package/dist/react.dev.js +153 -23
- package/dist/react.js +24 -12
- package/dist/serialize-Cs7hUxmK.d.ts +107 -0
- package/package.json +8 -7
- package/virtual.d.ts +6 -0
- package/dist/breakpoints-CBNlh7lQ.js +0 -47
- package/dist/plate-BuzRMPx4.js +0 -94
- package/dist/plate-DboxMKg-.d.ts +0 -196
- package/dist/react.core-CqnDjfAJ.js +0 -59
- package/dist/react.core-fgWG_svU.d.ts +0 -21
- package/dist/serialize-Bq6yJnNV.js +0 -1071
- package/dist/serialize.d.ts +0 -88
- package/dist/serialize.js +0 -2
package/dist/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import * as v from "valibot";
|
|
3
4
|
/** App code imports plates from here, e.g. `import plate from 'virtual:xray/plates/my-banner'`. */
|
|
4
5
|
const VIRTUAL_PREFIX = "virtual:xray/plates/";
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
|
|
8
|
+
* standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
|
|
9
|
+
* just rendering. BASE_CSS reveals such a root instantly (no `--xr-skeleton-delay`,
|
|
10
|
+
* no fade) so the bones do not flash away and back. Never present on a nested
|
|
11
|
+
* stitch (which inherits the parent's reveal and has no `@starting-style` of its
|
|
12
|
+
* own) nor on an ordinary top-level skeleton (which keeps the normal delay).
|
|
13
|
+
*/
|
|
14
|
+
const ROOT_INSTANT_ATTR = "data-xr-instant";
|
|
8
15
|
/** Fixed renderer-owned classes every rendered node carries. */
|
|
9
16
|
const ROOT_CLASS = "xr-root";
|
|
10
17
|
const NODE_CLASS = "xr-node";
|
|
@@ -14,10 +21,17 @@ const LEAF_CLASS = "xr-leaf";
|
|
|
14
21
|
opacity property and every leaf reads it. Hundreds of independent infinite
|
|
15
22
|
opacity animations would pin the compositor and cook the CPU. */
|
|
16
23
|
@property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
|
|
24
|
+
/* The reveal channel (ADR 0016): a second inherited opacity factor the leaf
|
|
25
|
+
multiplies into --xr-o. Transitioning THIS (not opacity directly) sidesteps
|
|
26
|
+
the animation-vs-transition collision ADR 0012 flagged. initial-value 1 (NOT
|
|
27
|
+
0) is the degradation floor: without @starting-style the reveal can never be
|
|
28
|
+
driven 0->1, so it must REST at 1 (fully visible). The delay-hide below is
|
|
29
|
+
pure progressive enhancement layered on top. */
|
|
30
|
+
@property --xr-reveal { syntax: "<number>"; inherits: true; initial-value: 1 }
|
|
17
31
|
.${ROOT_CLASS} {
|
|
18
|
-
--xr-bone-highlight: #f4f4f5;
|
|
32
|
+
--xr-bone-highlight-color: #f4f4f5;
|
|
19
33
|
display: contents;
|
|
20
|
-
animation: var(--xr-
|
|
34
|
+
animation: var(--xr-bone-animation, xr-pulse) var(--xr-bone-animation-duration, 1.2s) ease-in-out infinite alternate;
|
|
21
35
|
}
|
|
22
36
|
/* No pointer-events:none — it blocks inspecting bones in devtools and buys
|
|
23
37
|
nothing (no real content to click). border-color: a node may capture
|
|
@@ -25,39 +39,75 @@ const LEAF_CLASS = "xr-leaf";
|
|
|
25
39
|
a captured display:list-item would otherwise paint. */
|
|
26
40
|
.${NODE_CLASS} { border-color: transparent; list-style: none }
|
|
27
41
|
.${LEAF_CLASS} {
|
|
28
|
-
background: var(--xr-fill, var(--xr-bone, #e4e4e7));
|
|
29
|
-
border-radius: var(--xr-radius, 4px);
|
|
30
|
-
opacity
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
background: var(--xr-bone-fill, var(--xr-bone-color, #e4e4e7));
|
|
43
|
+
border-radius: var(--xr-bone-border-radius, 4px);
|
|
44
|
+
/* Composed opacity (ADR 0016): the pulse channel times the reveal channel.
|
|
45
|
+
Never transition opacity directly (the pulse animation owns it). */
|
|
46
|
+
opacity: calc(var(--xr-o) * var(--xr-reveal));
|
|
47
|
+
}
|
|
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 { ... }. */
|
|
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
|
+
.${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
|
|
36
55
|
@keyframes xr-pulse {
|
|
37
|
-
from { --xr-o: var(--xr-pulse-max, 1) }
|
|
38
|
-
to { --xr-o: var(--xr-pulse-min, 0.5) }
|
|
56
|
+
from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
|
|
57
|
+
to { --xr-o: var(--xr-bone-pulse-opacity-min, 0.5) }
|
|
58
|
+
}
|
|
59
|
+
/* Reveal delay-hide (ADR 0016), a progressive enhancement guarded so it can NEVER
|
|
60
|
+
strand a skeleton invisible (open Q3). @starting-style is the trigger: only a
|
|
61
|
+
browser that supports it enters this block at all, and such a browser also
|
|
62
|
+
honors @property + transitions, so the 0->1 reveal completes. The @supports
|
|
63
|
+
selector(...) probe is the broadest cross-engine @starting-style feature test
|
|
64
|
+
available. Without support the block is skipped entirely and --xr-reveal stays
|
|
65
|
+
at its initial-value 1 = fully visible instant skeleton. INSIDE the block we
|
|
66
|
+
start at 0 and transition to 1 after the delay: invisible for
|
|
67
|
+
--xr-skeleton-delay, then a --xr-skeleton-transition-duration fade-in; a fast
|
|
68
|
+
load unmounts before the delay elapses and the skeleton is never seen. */
|
|
69
|
+
@supports (selector(:has(*))) {
|
|
70
|
+
/* The delay-hide applies to every root EXCEPT a stitch continuation (ADR 0020).
|
|
71
|
+
Scoping with :not([data-xr-instant]) keeps an ordinary top-level skeleton
|
|
72
|
+
byte-identical to before: it still mounts at --xr-reveal 0 and fades in after
|
|
73
|
+
the delay. The transition/initial-1 rest state matches the original. */
|
|
74
|
+
.${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) {
|
|
75
|
+
--xr-reveal: 1;
|
|
76
|
+
transition: --xr-reveal var(--xr-skeleton-transition-duration, 150ms) linear var(--xr-skeleton-delay, 250ms);
|
|
77
|
+
}
|
|
78
|
+
@starting-style { .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) { --xr-reveal: 0 } }
|
|
79
|
+
/* A stitch continuation (ADR 0020) reveals INSTANTLY: it was already visible as a
|
|
80
|
+
stitch under a still-showing parent, so re-paying the delay would flash it away
|
|
81
|
+
and back. No @starting-style and no transition — it rests at fully visible from
|
|
82
|
+
its first frame, matching how the stitch inherited the parent's reveal. */
|
|
83
|
+
.${ROOT_CLASS}[${ROOT_INSTANT_ATTR}] { --xr-reveal: 1 }
|
|
84
|
+
}
|
|
85
|
+
/* Reduced motion (open Q2): keep the anti-flash delay + min-duration (they are
|
|
86
|
+
not motion), drop only the FADE — collapse the reveal transition to instant and
|
|
87
|
+
kill the pulse animation (as before). */
|
|
88
|
+
@media (prefers-reduced-motion: reduce) {
|
|
89
|
+
.${ROOT_CLASS} { animation: none; --xr-skeleton-transition-duration: 0s }
|
|
39
90
|
}
|
|
40
|
-
@media (prefers-reduced-motion: reduce) { .${ROOT_CLASS} { animation: none } }
|
|
41
91
|
/* Dark + high-contrast bone defaults. Guarded: light-dark() is an invalid value
|
|
42
92
|
without support and would drop the declaration — see the note above; remove
|
|
43
93
|
this @supports once widely available (2026-11-13). */
|
|
44
94
|
@supports (color: light-dark(#000, #fff)) {
|
|
45
|
-
.${ROOT_CLASS} { --xr-bone-highlight: light-dark(#f4f4f5, #52525b) }
|
|
46
|
-
.${LEAF_CLASS} { background: var(--xr-fill, var(--xr-bone, light-dark(#e4e4e7, #3f3f46))) }
|
|
95
|
+
.${ROOT_CLASS} { --xr-bone-highlight-color: light-dark(#f4f4f5, #52525b) }
|
|
96
|
+
.${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#e4e4e7, #3f3f46))) }
|
|
47
97
|
@media (prefers-contrast: more) {
|
|
48
|
-
.${LEAF_CLASS} { background: var(--xr-fill, var(--xr-bone, light-dark(#d4d4d8, #52525b))) }
|
|
98
|
+
.${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#d4d4d8, #52525b))) }
|
|
49
99
|
}
|
|
50
100
|
}
|
|
51
|
-
/* Derive the sheen highlight from --xr-bone so re-theming one token updates
|
|
52
|
-
Relative color isn't widely available yet; the static highlight above is
|
|
53
|
-
fallback (sheen just looks flatter), so no hard guard is needed. */
|
|
101
|
+
/* Derive the sheen highlight from --xr-bone-color so re-theming one token updates
|
|
102
|
+
both. Relative color isn't widely available yet; the static highlight above is
|
|
103
|
+
the fallback (sheen just looks flatter), so no hard guard is needed. */
|
|
54
104
|
@supports (color: oklch(from red l c h)) {
|
|
55
|
-
.${ROOT_CLASS} { --xr-bone-highlight: oklch(from var(--xr-bone, #e4e4e7) calc(l + 0.08) c h) }
|
|
105
|
+
.${ROOT_CLASS} { --xr-bone-highlight-color: oklch(from var(--xr-bone-color, #e4e4e7) calc(l + 0.08) c h) }
|
|
56
106
|
}`.trim();
|
|
57
107
|
/** The (unserialized) plate an import resolves to before anything has been captured. */
|
|
58
108
|
function emptyPlate(name) {
|
|
59
109
|
return {
|
|
60
|
-
v:
|
|
110
|
+
v: 2,
|
|
61
111
|
name,
|
|
62
112
|
tree: null,
|
|
63
113
|
css: ""
|
|
@@ -78,12 +128,13 @@ function nodeClass(node) {
|
|
|
78
128
|
return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
|
|
79
129
|
}
|
|
80
130
|
/**
|
|
81
|
-
* Whether this subtree holds a
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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.
|
|
84
135
|
*/
|
|
85
|
-
function
|
|
86
|
-
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);
|
|
87
138
|
}
|
|
88
139
|
/** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
|
|
89
140
|
function toHtml(node) {
|
|
@@ -91,10 +142,13 @@ function toHtml(node) {
|
|
|
91
142
|
return `<div class="${nodeClass(node)}">${kids}</div>`;
|
|
92
143
|
}
|
|
93
144
|
/**
|
|
94
|
-
* Serialize a node list into chunks: each maximal
|
|
95
|
-
* HTML `string`; a `ref` node becomes `{ r }`; a node
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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.
|
|
98
152
|
*/
|
|
99
153
|
function toChunks(nodes) {
|
|
100
154
|
const out = [];
|
|
@@ -106,12 +160,16 @@ function toChunks(nodes) {
|
|
|
106
160
|
}
|
|
107
161
|
};
|
|
108
162
|
for (const node of nodes) {
|
|
109
|
-
if (!
|
|
163
|
+
if (!hasDynamic(node)) {
|
|
110
164
|
buf += toHtml(node);
|
|
111
165
|
continue;
|
|
112
166
|
}
|
|
113
167
|
flush();
|
|
114
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
|
+
});
|
|
115
173
|
else out.push({
|
|
116
174
|
c: nodeClass(node),
|
|
117
175
|
k: toChunks(node.kids ?? [])
|
|
@@ -148,217 +206,15 @@ function serializePlate(merged) {
|
|
|
148
206
|
css
|
|
149
207
|
};
|
|
150
208
|
}
|
|
151
|
-
//#endregion
|
|
152
|
-
//#region src/breakpoints.ts
|
|
153
|
-
/** The view interval `[min, max)` a given viewport width falls into. */
|
|
154
|
-
function regimeFor(width, breakpoints) {
|
|
155
|
-
let min;
|
|
156
|
-
let max;
|
|
157
|
-
for (const bp of breakpoints) if (bp <= width) min = bp;
|
|
158
|
-
else {
|
|
159
|
-
max = bp;
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
return {
|
|
163
|
-
...min === void 0 ? {} : { min },
|
|
164
|
-
...max === void 0 ? {} : { max }
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
//#endregion
|
|
168
|
-
//#region src/classify.ts
|
|
169
|
-
/**
|
|
170
|
-
* Turn id-form rules + an id-tree into the shipped plate: each distinct
|
|
171
|
-
* `(tier, media, declarations)` becomes one class, named by a per-plate
|
|
172
|
-
* sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
|
|
173
|
-
* class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
|
|
174
|
-
* not a content hash: the plate prefix already keeps tokens disjoint across plates
|
|
175
|
-
* (the stitch-isolation guarantee), so a per-plate counter is collision-free by
|
|
176
|
-
* construction — and low-entropy ids compress far better than random hashes
|
|
177
|
-
* (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
|
|
178
|
-
* unlayered; the existing cascade is reproduced by emission order (losers
|
|
179
|
-
* first), so the classes are emitted in `(layered, spec, order)` order exactly
|
|
180
|
-
* as `renderRules` did.
|
|
181
|
-
*
|
|
182
|
-
* Pure data-in/data-out — runs at plugin load (node) and in tests.
|
|
183
|
-
*/
|
|
184
|
-
function classify(rules, tree, plateName) {
|
|
185
|
-
const classBase = `${CLASS_PREFIX}${cssSafe(plateName)}-`;
|
|
186
|
-
const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
|
|
187
|
-
const nameForKey = /* @__PURE__ */ new Map();
|
|
188
|
-
const body = /* @__PURE__ */ new Map();
|
|
189
|
-
const usage = /* @__PURE__ */ new Map();
|
|
190
|
-
const emitOrder = [];
|
|
191
|
-
const nodeClasses = /* @__PURE__ */ new Map();
|
|
192
|
-
for (const rule of sorted) {
|
|
193
|
-
if (rule.decls.length === 0) continue;
|
|
194
|
-
const decls = rule.decls.join("; ");
|
|
195
|
-
const media = rule.media ?? [];
|
|
196
|
-
const container = rule.container ?? [];
|
|
197
|
-
const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
|
|
198
|
-
const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
|
|
199
|
-
let name = nameForKey.get(key);
|
|
200
|
-
if (name === void 0) {
|
|
201
|
-
name = classBase + nameForKey.size.toString(36);
|
|
202
|
-
nameForKey.set(key, name);
|
|
203
|
-
body.set(name, {
|
|
204
|
-
media,
|
|
205
|
-
container,
|
|
206
|
-
decls
|
|
207
|
-
});
|
|
208
|
-
usage.set(name, {
|
|
209
|
-
root: false,
|
|
210
|
-
desc: false
|
|
211
|
-
});
|
|
212
|
-
emitOrder.push(name);
|
|
213
|
-
}
|
|
214
|
-
const ids = rule.ids.length === 0 ? [0] : rule.ids;
|
|
215
|
-
for (const id of ids) {
|
|
216
|
-
const u = usage.get(name);
|
|
217
|
-
if (!u) throw new Error(`missing usage entry for ${name}`);
|
|
218
|
-
if (id === 0) u.root = true;
|
|
219
|
-
else u.desc = true;
|
|
220
|
-
let list = nodeClasses.get(id);
|
|
221
|
-
if (!list) {
|
|
222
|
-
list = [];
|
|
223
|
-
nodeClasses.set(id, list);
|
|
224
|
-
}
|
|
225
|
-
if (!list.includes(name)) list.push(name);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
const lines = [];
|
|
229
|
-
for (const name of emitOrder) {
|
|
230
|
-
const item = body.get(name);
|
|
231
|
-
const u = usage.get(name);
|
|
232
|
-
if (!item || !u) throw new Error(`missing class body for ${name}`);
|
|
233
|
-
const { media, container, decls } = item;
|
|
234
|
-
const sels = [];
|
|
235
|
-
if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
|
|
236
|
-
if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
|
|
237
|
-
let text = `${sels.join(", ")} { ${decls} }`;
|
|
238
|
-
for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
|
|
239
|
-
for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
|
|
240
|
-
lines.push(text);
|
|
241
|
-
}
|
|
242
|
-
return {
|
|
243
|
-
tree: render(tree, nodeClasses),
|
|
244
|
-
css: lines.join("\n")
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
/** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
|
|
248
|
-
function render(node, classes) {
|
|
249
|
-
const out = {};
|
|
250
|
-
if (node.leaf) out.leaf = node.leaf;
|
|
251
|
-
if (node.ref !== void 0) out.ref = node.ref;
|
|
252
|
-
const cls = classes.get(node.id);
|
|
253
|
-
if (cls && cls.length > 0) out.cls = cls.join(" ");
|
|
254
|
-
if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
|
|
255
|
-
return out;
|
|
256
|
-
}
|
|
257
|
-
/** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
|
|
258
|
-
const CLASS_PREFIX = "xr-";
|
|
259
|
-
/** Make a plate name safe to embed in a class token (plate names are already constrained). */
|
|
260
|
-
function cssSafe(name) {
|
|
261
|
-
return name.replace(/[^\w-]/g, "_");
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* What the class identity keys on, alongside media + declarations:
|
|
265
|
-
* - `'tier'`: the discrete cascade tier — smaller (more bodies dedupe), but a
|
|
266
|
-
* rare same-tier inversion within the continuous `author` band can mis-order
|
|
267
|
-
* one property on one node (ADR 0007). The default.
|
|
268
|
-
* - `'full'`: the exact `spec` — provably no inversion, marginally larger.
|
|
269
|
-
* One-line flip if QA ever surfaces a mis-order.
|
|
270
|
-
*/
|
|
271
|
-
const SPEC_FOLD = "full";
|
|
272
|
-
function tierLabel(spec) {
|
|
273
|
-
if (spec <= -2) return "ctx";
|
|
274
|
-
if (spec === -1) return "fb";
|
|
275
|
-
if (spec >= 2e7) return "gate";
|
|
276
|
-
if (spec >= 1e7) return "inl";
|
|
277
|
-
if (spec >= 9e6) return "leaf";
|
|
278
|
-
return "au";
|
|
279
|
-
}
|
|
280
|
-
//#endregion
|
|
281
|
-
//#region src/merge.ts
|
|
282
209
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
|
|
291
|
-
* stable keys proved unreliable: every heuristic mis-paired some nodes and
|
|
292
|
-
* remapped one view's rules onto another's elements, corrupting both. A lone
|
|
293
|
-
* capture is pixel-faithful; keeping each view lone preserves that. The cost
|
|
294
|
-
* is a larger plate (roughly the sum of the views) — bounded by the plate
|
|
295
|
-
* size cap — in exchange for correctness.
|
|
296
|
-
*
|
|
297
|
-
* Pure data-in/data-out — runs in node (plugin load) and in tests.
|
|
298
|
-
*/
|
|
299
|
-
function mergeViews(file) {
|
|
300
|
-
const views = file.views;
|
|
301
|
-
if (views.length === 0) return emptyPlate(file.name);
|
|
302
|
-
if (views.length === 1) {
|
|
303
|
-
const [only] = views;
|
|
304
|
-
if (!only) return emptyPlate(file.name);
|
|
305
|
-
const { tree, css } = classify(only.rules, only.tree, file.name);
|
|
306
|
-
return {
|
|
307
|
-
v: 1,
|
|
308
|
-
name: file.name,
|
|
309
|
-
tree,
|
|
310
|
-
css
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
|
|
314
|
-
let nextId = 1;
|
|
315
|
-
const rootKids = [];
|
|
316
|
-
const rules = [];
|
|
317
|
-
views.forEach((view, i) => {
|
|
318
|
-
const idMap = /* @__PURE__ */ new Map();
|
|
319
|
-
const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
|
|
320
|
-
rootKids.push(...kids);
|
|
321
|
-
rules.push(...remapRules(view.rules, idMap));
|
|
322
|
-
const lo = i === 0 ? 0 : starts[i] ?? 0;
|
|
323
|
-
const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
|
|
324
|
-
for (const kid of kids) {
|
|
325
|
-
if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
|
|
326
|
-
if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
const { tree, css } = classify(rules, {
|
|
330
|
-
id: 0,
|
|
331
|
-
kids: rootKids
|
|
332
|
-
}, file.name);
|
|
333
|
-
return {
|
|
334
|
-
v: 1,
|
|
335
|
-
name: file.name,
|
|
336
|
-
tree,
|
|
337
|
-
css
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
function gate(id, condition) {
|
|
341
|
-
return {
|
|
342
|
-
ids: [id],
|
|
343
|
-
decls: ["display: none !important"],
|
|
344
|
-
media: [condition],
|
|
345
|
-
spec: SPEC_GATE,
|
|
346
|
-
order: id
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
/** Deep-clone a captured subtree into fresh ids, recording the mapping. */
|
|
350
|
-
function cloneWithIds(node, idMap, allocate) {
|
|
351
|
-
const id = allocate();
|
|
352
|
-
idMap.set(node.id, id);
|
|
353
|
-
const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
|
|
354
|
-
return {
|
|
355
|
-
id,
|
|
356
|
-
...node.leaf ? { leaf: node.leaf } : {},
|
|
357
|
-
...node.ref !== void 0 ? { ref: node.ref } : {},
|
|
358
|
-
...kids ? { kids } : {}
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
/** Distinct plate names referenced by the tree's stitches, in first-seen order. */
|
|
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).
|
|
217
|
+
*/
|
|
362
218
|
function collectRefs(tree) {
|
|
363
219
|
const names = [];
|
|
364
220
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -372,26 +228,245 @@ function collectRefs(tree) {
|
|
|
372
228
|
if (tree) visit(tree);
|
|
373
229
|
return names;
|
|
374
230
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const views = [...file?.views ?? [], capture];
|
|
386
|
-
const byView = /* @__PURE__ */ new Map();
|
|
387
|
-
for (const view of views) byView.set(spanKey(view.width), view);
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/diagnostics.ts
|
|
233
|
+
/**
|
|
234
|
+
* Build a single diagnostic record with its canonical message, for callers
|
|
235
|
+
* outside the walk that surface a diagnostic directly (e.g. the client mapping
|
|
236
|
+
* `CaptureTooLargeError`, or the server reporting an invalid committed plate).
|
|
237
|
+
* Uses the same message table as the collector so the text is identical
|
|
238
|
+
* everywhere. `detail` must never carry user CSS or captured text.
|
|
239
|
+
*/
|
|
240
|
+
function makeDiagnostic(code, options) {
|
|
388
241
|
return {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
242
|
+
code,
|
|
243
|
+
message: DIAGNOSTIC_MESSAGES[code],
|
|
244
|
+
...options?.count !== void 0 ? { count: options.count } : {},
|
|
245
|
+
...options?.detail !== void 0 ? { detail: options.detail } : {}
|
|
393
246
|
};
|
|
394
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* The human-readable message per code, shared verbatim by browser and server so
|
|
250
|
+
* their text cannot drift. Messages describe the omission and its fidelity
|
|
251
|
+
* cost; none echoes user CSS or captured text. `formatDiagnostic` appends the
|
|
252
|
+
* aggregate count and any short detail.
|
|
253
|
+
*/
|
|
254
|
+
const DIAGNOSTIC_MESSAGES = {
|
|
255
|
+
"unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
|
|
256
|
+
"unreadable-import": "could not read an @import (likely cross-origin); its rules were not captured",
|
|
257
|
+
"skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
|
|
258
|
+
"skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
|
|
259
|
+
"unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
|
|
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 the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
|
|
264
|
+
"invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* The single shared formatter. Renders one diagnostic to a stable one-line
|
|
268
|
+
* string used IDENTICALLY by the browser console and the Vite server output, so
|
|
269
|
+
* the two can never disagree. Shape: `<message> (<count>)[: <detail>]`. Count is
|
|
270
|
+
* omitted when 1 or absent; detail is appended only when present and is never
|
|
271
|
+
* user content.
|
|
272
|
+
*/
|
|
273
|
+
function formatDiagnostic(diagnostic) {
|
|
274
|
+
const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
|
|
275
|
+
const detail = diagnostic.detail !== void 0 ? `: ${diagnostic.detail}` : "";
|
|
276
|
+
return `${diagnostic.message}${count}${detail}`;
|
|
277
|
+
}
|
|
278
|
+
/** The grouped-warning header for a plate's diagnostics, shared by browser and server. */
|
|
279
|
+
function diagnosticsHeader(name) {
|
|
280
|
+
return `[xray] "${name}" captured with reduced fidelity:`;
|
|
281
|
+
}
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/name.ts
|
|
284
|
+
/**
|
|
285
|
+
* Plate-name validation, shared by the Vite plugin (`index.ts`) and the
|
|
286
|
+
* Node-only POST validator (`validate.ts`).
|
|
287
|
+
*
|
|
288
|
+
* Lives in its own dependency-free module so `validate.ts` can reuse it without
|
|
289
|
+
* importing the plugin entry (which would be a cycle), and so the regex stays
|
|
290
|
+
* the single source of truth for what a plate name may be — the same rule the
|
|
291
|
+
* loader, the file watcher, the coverage reader, the POST endpoint, and the
|
|
292
|
+
* ref-graph emitter all gate on.
|
|
293
|
+
*
|
|
294
|
+
* @module
|
|
295
|
+
*/
|
|
296
|
+
/**
|
|
297
|
+
* Names come from import specifiers and POST bodies — keep them inside the plates dir.
|
|
298
|
+
*
|
|
299
|
+
* Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens,
|
|
300
|
+
* never doubled and never at a segment edge — and segments are joined by `/`.
|
|
301
|
+
* The class-prefix encoder (`classify.ts`) renders `/` as `--` to keep nested
|
|
302
|
+
* names readable, and that prefix is the isolation boundary across stitched
|
|
303
|
+
* plates (ADR 0007), so the encoding must be injective. Banning a literal `--`
|
|
304
|
+
* and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
|
|
305
|
+
* prefix came from exactly one `/` — making distinct names always map to
|
|
306
|
+
* distinct prefixes (else e.g. `a/-b` and `a-/b` would both encode to `a---b`).
|
|
307
|
+
*
|
|
308
|
+
* @internal
|
|
309
|
+
*/
|
|
310
|
+
function isValidPlateName(name) {
|
|
311
|
+
return /^\w+(?:-\w+)*(?:\/\w+(?:-\w+)*)*$/.test(name);
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/validate.ts
|
|
315
|
+
/**
|
|
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.
|
|
319
|
+
*
|
|
320
|
+
* NODE-ONLY (see the capture-input-validation plan and ADR 0008). This module
|
|
321
|
+
* imports `valibot`, which must NEVER reach the browser bundles: the dev capture
|
|
322
|
+
* client, the HUD, the React adapters, and the framework-neutral `core` surface
|
|
323
|
+
* all ship to a browser, and none of them needs to validate an inbound payload.
|
|
324
|
+
* Only the Vite plugin (`index.ts`, the package `.` entry, built with
|
|
325
|
+
* `platform: 'node'`) imports this file, which keeps valibot out of every
|
|
326
|
+
* browser-facing dist chunk. Do NOT import this from `plate.ts`, `css.ts`, or
|
|
327
|
+
* anything a render/browser path also imports, and do NOT re-export these
|
|
328
|
+
* helpers from any public subpath — they stay private until a `doctor`-style
|
|
329
|
+
* tool creates a concrete external need (the plan's decisions).
|
|
330
|
+
*
|
|
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).
|
|
342
|
+
*
|
|
343
|
+
* @module
|
|
344
|
+
*/
|
|
345
|
+
/**
|
|
346
|
+
* Bounds chosen to be far above any real capture yet small enough that a hostile
|
|
347
|
+
* payload cannot exhaust memory or wedge the merge. A `<Skeleton>` set too high
|
|
348
|
+
* is already caught by the per-capture node guard and the serialized-size cap;
|
|
349
|
+
* these are the structural ceilings the schema enforces before either runs.
|
|
350
|
+
*/
|
|
351
|
+
const MAX_TREE_NODES = 5e4;
|
|
352
|
+
const MAX_BREAKPOINTS = 256;
|
|
353
|
+
const MAX_TREE_DEPTH = 1e3;
|
|
354
|
+
/**
|
|
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`.
|
|
357
|
+
*/
|
|
358
|
+
const MAX_TEMPLATE_COUNT = 1e4;
|
|
359
|
+
/** A single declaration is `prop: value [!important]`; real ones are short. */
|
|
360
|
+
const MAX_DECL_LENGTH = 2e3;
|
|
361
|
+
/**
|
|
362
|
+
* Raw-text / markup terminators that must never appear in generated CSS, even
|
|
363
|
+
* when no bare `<`/`>` is present: HTML comment delimiters, a CDATA opener, and
|
|
364
|
+
* a `</style` raw-text terminator. Shared by the declaration and condition
|
|
365
|
+
* validators (case-insensitive).
|
|
366
|
+
*/
|
|
367
|
+
const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
|
|
368
|
+
/** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
|
|
369
|
+
const finiteNumber = v.pipe(v.number(), v.finite());
|
|
370
|
+
const leafSchema = v.picklist([
|
|
371
|
+
"text",
|
|
372
|
+
"text-block",
|
|
373
|
+
"media",
|
|
374
|
+
"box"
|
|
375
|
+
]);
|
|
376
|
+
/**
|
|
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.
|
|
381
|
+
*/
|
|
382
|
+
function treeWithinLimits(root) {
|
|
383
|
+
let count = 0;
|
|
384
|
+
const walk = (node, depth) => {
|
|
385
|
+
if (depth > MAX_TREE_DEPTH) return false;
|
|
386
|
+
if (++count > MAX_TREE_NODES) return false;
|
|
387
|
+
if (typeof node !== "object" || node === null) return true;
|
|
388
|
+
const kids = node.kids;
|
|
389
|
+
if (!Array.isArray(kids)) return true;
|
|
390
|
+
for (const kid of kids) if (!walk(kid, depth + 1)) return false;
|
|
391
|
+
return true;
|
|
392
|
+
};
|
|
393
|
+
return walk(root, 0);
|
|
394
|
+
}
|
|
395
|
+
/** A finished plate's css blob, generously bounded (post-classify, whole-plate). */
|
|
396
|
+
const MAX_PLATE_CSS_LENGTH = 4e6;
|
|
397
|
+
/**
|
|
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.
|
|
402
|
+
*/
|
|
403
|
+
const NUL = /\u0000/u;
|
|
404
|
+
/**
|
|
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).
|
|
419
|
+
*/
|
|
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
|
+
}
|
|
427
|
+
/**
|
|
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`.
|
|
432
|
+
*/
|
|
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"));
|
|
440
|
+
/**
|
|
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`.
|
|
449
|
+
*/
|
|
450
|
+
const storedPlateSchema = v.object({
|
|
451
|
+
v: v.literal(2),
|
|
452
|
+
name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
|
|
453
|
+
breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
|
|
454
|
+
tree: v.nullable(renderNodeSchema),
|
|
455
|
+
css: v.pipe(v.string(), v.check(isValidPlateCss, "invalid plate css"))
|
|
456
|
+
});
|
|
457
|
+
/**
|
|
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.
|
|
465
|
+
*/
|
|
466
|
+
function parseStoredPlate(value) {
|
|
467
|
+
const result = v.safeParse(storedPlateSchema, value);
|
|
468
|
+
return result.success ? result.output : null;
|
|
469
|
+
}
|
|
395
470
|
//#endregion
|
|
396
471
|
//#region src/index.ts
|
|
397
472
|
/**
|
|
@@ -432,8 +507,11 @@ function xrayVitePlugin(options = {}) {
|
|
|
432
507
|
const settleCap = options.settleCap ?? 5e3;
|
|
433
508
|
const maxNodes = options.maxNodes ?? 4e3;
|
|
434
509
|
const hud = options.hud ?? false;
|
|
510
|
+
const recordMode = options.fixtures?.record ?? "manual";
|
|
511
|
+
const replayMode = options.fixtures?.replay ?? false;
|
|
435
512
|
let root = process.cwd();
|
|
436
513
|
const platePath = (name) => resolve(root, platesDir, `${name}.json`);
|
|
514
|
+
const fixturePath = (name) => resolve(root, platesDir, `${name}.fixture.json`);
|
|
437
515
|
return [{
|
|
438
516
|
name: "xray:data",
|
|
439
517
|
configResolved(config) {
|
|
@@ -446,7 +524,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
446
524
|
if (!id.startsWith(RESOLVED_PREFIX)) return void 0;
|
|
447
525
|
const name = id.slice(RESOLVED_PREFIX.length);
|
|
448
526
|
if (!isValidPlateName(name)) return renderPlateModule(emptyPlate(name));
|
|
449
|
-
return renderPlateModule(
|
|
527
|
+
return renderPlateModule(readStoredPlate(platePath(name)) ?? emptyPlate(name), name);
|
|
450
528
|
}
|
|
451
529
|
}, {
|
|
452
530
|
name: "xray:dev",
|
|
@@ -460,7 +538,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
460
538
|
transformIndexHtml: {
|
|
461
539
|
order: "pre",
|
|
462
540
|
handler() {
|
|
463
|
-
if (!capture && !hud) return
|
|
541
|
+
if (!capture && !hud && replayMode === false && recordMode === "manual") return;
|
|
464
542
|
return [{
|
|
465
543
|
tag: "script",
|
|
466
544
|
attrs: {
|
|
@@ -476,7 +554,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
476
554
|
},
|
|
477
555
|
load(id) {
|
|
478
556
|
if (id !== RESOLVED_BOOT_ID) return void 0;
|
|
479
|
-
return bootstrapCode(delay, settleCap, maxNodes, hud, capture);
|
|
557
|
+
return bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode);
|
|
480
558
|
},
|
|
481
559
|
configureServer(server) {
|
|
482
560
|
const dir = () => resolve(root, platesDir);
|
|
@@ -485,7 +563,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
485
563
|
if (!file.startsWith(dir()) || !file.endsWith(".json")) return;
|
|
486
564
|
const name = relative(dir(), file).slice(0, -5);
|
|
487
565
|
if (!isValidPlateName(name)) return;
|
|
488
|
-
announcePlate(server.ws, name,
|
|
566
|
+
announcePlate(server.ws, name, readStoredPlate(file));
|
|
489
567
|
};
|
|
490
568
|
server.watcher.on("add", onPlateFile);
|
|
491
569
|
server.watcher.on("change", onPlateFile);
|
|
@@ -495,6 +573,15 @@ function xrayVitePlugin(options = {}) {
|
|
|
495
573
|
res.end(JSON.stringify(readCoverage(dir())));
|
|
496
574
|
return;
|
|
497
575
|
}
|
|
576
|
+
if (req.method === "GET" && req.url === "/fixtures") {
|
|
577
|
+
res.setHeader("content-type", "application/json");
|
|
578
|
+
res.end(JSON.stringify(readFixtures(dir())));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (req.url === "/fixture") {
|
|
582
|
+
handleFixturePost(req, res, fixturePath);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
498
585
|
handleCapturePost(req, res, server, platePath);
|
|
499
586
|
});
|
|
500
587
|
}
|
|
@@ -504,22 +591,42 @@ function xrayVitePlugin(options = {}) {
|
|
|
504
591
|
* The injected dev module: boot the client, POST captures back, optionally
|
|
505
592
|
* mount the HUD. The client installs first so the HUD finds the light box store.
|
|
506
593
|
*/
|
|
507
|
-
function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
|
|
594
|
+
function bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode) {
|
|
508
595
|
return [
|
|
509
|
-
`import { installXrayClient } from '@hueest/xray/client'`,
|
|
510
|
-
...hud ? [`import { installXrayHud } from '@hueest/xray/hud'`] : [],
|
|
596
|
+
`import { installXrayClient } from '@hueest/xray/internal/client'`,
|
|
597
|
+
...hud ? [`import { installXrayHud } from '@hueest/xray/internal/hud'`] : [],
|
|
511
598
|
"installXrayClient({",
|
|
512
599
|
` delay: ${delay},`,
|
|
513
600
|
` settleCap: ${settleCap},`,
|
|
514
601
|
` maxNodes: ${maxNodes},`,
|
|
515
602
|
` capture: ${capture},`,
|
|
603
|
+
` record: ${JSON.stringify(recordMode)},`,
|
|
604
|
+
` replay: ${JSON.stringify(replayMode)},`,
|
|
605
|
+
" recordFixtureSink(payload) {",
|
|
606
|
+
` return fetch('${ENDPOINT}/fixture', {`,
|
|
607
|
+
" method: 'POST',",
|
|
608
|
+
" headers: { 'content-type': 'application/json' },",
|
|
609
|
+
" body: JSON.stringify(payload),",
|
|
610
|
+
" }).then((response) => {",
|
|
611
|
+
" if (!response.ok) throw new Error('xray fixture POST failed: ' + response.status)",
|
|
612
|
+
" })",
|
|
613
|
+
" },",
|
|
516
614
|
" sink(payload) {",
|
|
517
|
-
`
|
|
615
|
+
` return fetch('${ENDPOINT}', {`,
|
|
518
616
|
" method: 'POST',",
|
|
519
617
|
" headers: { 'content-type': 'application/json' },",
|
|
520
618
|
" body: JSON.stringify(payload),",
|
|
619
|
+
" }).then((response) => {",
|
|
620
|
+
" if (!response.ok) throw new Error('xray sink POST failed: ' + response.status)",
|
|
521
621
|
" })",
|
|
522
622
|
" },",
|
|
623
|
+
" refreshCoverage() {",
|
|
624
|
+
` return fetch('${ENDPOINT}/coverage')`,
|
|
625
|
+
" .then((response) => response.json())",
|
|
626
|
+
" .then((coverage) => {",
|
|
627
|
+
" for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
|
|
628
|
+
" })",
|
|
629
|
+
" },",
|
|
523
630
|
"})",
|
|
524
631
|
...hud ? ["installXrayHud()"] : [],
|
|
525
632
|
`void fetch('${ENDPOINT}/coverage')`,
|
|
@@ -528,6 +635,10 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
|
|
|
528
635
|
" for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
|
|
529
636
|
" })",
|
|
530
637
|
" .catch(() => {})",
|
|
638
|
+
`void fetch('${ENDPOINT}/fixtures')`,
|
|
639
|
+
" .then((response) => response.json())",
|
|
640
|
+
" .then((fixtures) => { globalThis.__XRAY__?.fixtures?.seed?.(fixtures) })",
|
|
641
|
+
" .catch(() => {})",
|
|
531
642
|
"if (import.meta.hot) {",
|
|
532
643
|
" import.meta.hot.on('xray:plate', (data) => {",
|
|
533
644
|
" globalThis.__XRAY__?.updatePlate?.(data.name, data.plate)",
|
|
@@ -537,58 +648,90 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
|
|
|
537
648
|
""
|
|
538
649
|
].join("\n");
|
|
539
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* Print a plate's diagnostics to the Vite server output using the SAME grouped
|
|
653
|
+
* header + shared formatter the browser console uses (diagnostics.ts), so the
|
|
654
|
+
* two can never drift. Prefers Vite's logger (it preserves the formatting);
|
|
655
|
+
* falls back to `console.warn`. No-op when there are no diagnostics.
|
|
656
|
+
*/
|
|
657
|
+
function reportServerDiagnostics(server, name, diagnostics) {
|
|
658
|
+
if (!diagnostics || diagnostics.length === 0) return;
|
|
659
|
+
const text = [diagnosticsHeader(name), ...diagnostics.map((d) => formatDiagnostic(d))].join("\n");
|
|
660
|
+
const logger = server.config?.logger;
|
|
661
|
+
if (logger) logger.warn(text);
|
|
662
|
+
else console.warn(text);
|
|
663
|
+
}
|
|
540
664
|
/** A committed plate file past this is almost always a `<Skeleton>` set too high. */
|
|
541
665
|
const MAX_PLATE_BYTES = 2e6;
|
|
666
|
+
/**
|
|
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
|
|
672
|
+
* up front from `content-length` and while streaming, so a lying or absent
|
|
673
|
+
* `content-length` cannot get past it. The dev endpoint is local and
|
|
674
|
+
* unauthenticated, so this is the real backstop, not the header.
|
|
675
|
+
*/
|
|
676
|
+
const MAX_BODY_BYTES = 4e6;
|
|
677
|
+
/** The only content type the capture client posts; anything else is rejected up front. */
|
|
678
|
+
function isJsonContentType(value) {
|
|
679
|
+
const header = Array.isArray(value) ? value[0] : value;
|
|
680
|
+
if (typeof header !== "string") return false;
|
|
681
|
+
return /^application\/json\b/i.test(header.trim());
|
|
682
|
+
}
|
|
542
683
|
function isRecord(value) {
|
|
543
684
|
return typeof value === "object" && value !== null;
|
|
544
685
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return
|
|
565
|
-
}
|
|
566
|
-
function isIncomingCapture(value) {
|
|
567
|
-
return isRecord(value) && typeof value.name === "string" && isViewCapture(value.capture) && (!("breakpoints" in value) || isNumberArray(value.breakpoints));
|
|
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;
|
|
568
706
|
}
|
|
569
|
-
/** Send the
|
|
570
|
-
* client hot-swaps it in place and the HUD updates its band
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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));
|
|
576
719
|
ws.send({
|
|
577
720
|
type: "custom",
|
|
578
721
|
event: "xray:plate",
|
|
579
722
|
data: {
|
|
580
723
|
name,
|
|
581
|
-
plate
|
|
582
|
-
breakpoints
|
|
583
|
-
spans: (
|
|
584
|
-
min: view.min,
|
|
585
|
-
max: view.max
|
|
586
|
-
}))
|
|
724
|
+
plate,
|
|
725
|
+
breakpoints,
|
|
726
|
+
spans: spansFromBreakpoints(breakpoints)
|
|
587
727
|
}
|
|
588
728
|
});
|
|
589
729
|
}
|
|
590
730
|
/**
|
|
591
|
-
* 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).
|
|
592
735
|
*
|
|
593
736
|
* @internal
|
|
594
737
|
*/
|
|
@@ -608,20 +751,140 @@ function readCoverage(dir) {
|
|
|
608
751
|
if (!rel.endsWith(".json")) continue;
|
|
609
752
|
const name = rel.slice(0, -5);
|
|
610
753
|
if (!isValidPlateName(name)) continue;
|
|
611
|
-
const
|
|
612
|
-
if (!
|
|
754
|
+
const stored = readStoredPlate(resolve(dir, rel));
|
|
755
|
+
if (!stored) continue;
|
|
756
|
+
const breakpoints = stored.breakpoints ?? [];
|
|
613
757
|
out[name] = {
|
|
614
|
-
breakpoints
|
|
615
|
-
spans:
|
|
616
|
-
min: view.min,
|
|
617
|
-
max: view.max
|
|
618
|
-
}))
|
|
758
|
+
breakpoints,
|
|
759
|
+
spans: spansFromBreakpoints(breakpoints)
|
|
619
760
|
};
|
|
620
761
|
}
|
|
621
762
|
return out;
|
|
622
763
|
}
|
|
764
|
+
/** The on-disk Fixture sidecar shape (ADR 0015): version, plate name, and the
|
|
765
|
+
* OPAQUE devalue string. The node side never parses `data` — it is the browser
|
|
766
|
+
* dev client's job (it has the live runtime). */
|
|
767
|
+
const FIXTURE_VERSION = 1;
|
|
768
|
+
const FIXTURE_SUFFIX = ".fixture.json";
|
|
769
|
+
/** A recorded Fixture sidecar — `data` is the verbatim devalue string, treated
|
|
770
|
+
* as opaque text server-side. */
|
|
771
|
+
function isFixtureFile(value) {
|
|
772
|
+
return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && typeof value.data === "string";
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* All recorded Fixtures as `{ <name>: <devalueString> }`, read from the
|
|
776
|
+
* `<name>.fixture.json` sidecars (ADR 0015). The map values are the OPAQUE
|
|
777
|
+
* devalue strings exactly as written — the node side never parses them; the
|
|
778
|
+
* browser dev client owns the devalue decode. The `.fixture.json` suffix keeps
|
|
779
|
+
* these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
|
|
780
|
+
* `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
|
|
781
|
+
*
|
|
782
|
+
* @internal
|
|
783
|
+
*/
|
|
784
|
+
function readFixtures(dir) {
|
|
785
|
+
const out = {};
|
|
786
|
+
let entries;
|
|
787
|
+
try {
|
|
788
|
+
entries = readdirSync(dir, {
|
|
789
|
+
recursive: true,
|
|
790
|
+
encoding: "utf8"
|
|
791
|
+
});
|
|
792
|
+
} catch {
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
for (const entry of entries) {
|
|
796
|
+
const rel = entry.replaceAll("\\", "/");
|
|
797
|
+
if (!rel.endsWith(FIXTURE_SUFFIX)) continue;
|
|
798
|
+
const name = rel.slice(0, -13);
|
|
799
|
+
if (!isValidPlateName(name)) continue;
|
|
800
|
+
let raw;
|
|
801
|
+
try {
|
|
802
|
+
raw = readFileSync(resolve(dir, rel), "utf8");
|
|
803
|
+
} catch {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
const parsed = JSON.parse(raw);
|
|
808
|
+
if (isFixtureFile(parsed) && parsed.v === FIXTURE_VERSION) out[name] = parsed.data;
|
|
809
|
+
} catch {}
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
623
813
|
/**
|
|
624
|
-
*
|
|
814
|
+
* Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's
|
|
815
|
+
* front-door checks (method, content type, declared/streamed size cap) and the
|
|
816
|
+
* `isValidPlateName` gate, but treats the devalue `data` payload as OPAQUE TEXT:
|
|
817
|
+
* it is validated only as a string and written verbatim, never eval'd or parsed
|
|
818
|
+
* server-side. The sidecar lives NEXT TO the Plate but is never merged into it,
|
|
819
|
+
* so the persisted Plate stays purely structural.
|
|
820
|
+
*
|
|
821
|
+
* @internal
|
|
822
|
+
*/
|
|
823
|
+
function handleFixturePost(req, res, fixturePath) {
|
|
824
|
+
if (req.method !== "POST") {
|
|
825
|
+
res.statusCode = 405;
|
|
826
|
+
res.end();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (!isJsonContentType(req.headers?.["content-type"])) {
|
|
830
|
+
res.statusCode = 415;
|
|
831
|
+
res.end("unsupported media type");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
|
|
835
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
836
|
+
res.statusCode = 413;
|
|
837
|
+
res.end("payload too large");
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
let body = "";
|
|
841
|
+
let bytes = 0;
|
|
842
|
+
let aborted = false;
|
|
843
|
+
req.on("data", (chunk) => {
|
|
844
|
+
if (aborted) return;
|
|
845
|
+
const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
846
|
+
bytes += Buffer.byteLength(text);
|
|
847
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
848
|
+
aborted = true;
|
|
849
|
+
res.statusCode = 413;
|
|
850
|
+
res.end("payload too large");
|
|
851
|
+
req.destroy?.();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
body += text;
|
|
855
|
+
});
|
|
856
|
+
req.on("end", () => {
|
|
857
|
+
if (aborted) return;
|
|
858
|
+
try {
|
|
859
|
+
const raw = JSON.parse(body);
|
|
860
|
+
if (!isRecord(raw) || typeof raw.name !== "string" || !isValidPlateName(raw.name) || typeof raw.data !== "string") {
|
|
861
|
+
res.statusCode = 400;
|
|
862
|
+
res.end("invalid fixture payload");
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const file = fixturePath(raw.name);
|
|
866
|
+
const sidecar = {
|
|
867
|
+
v: FIXTURE_VERSION,
|
|
868
|
+
name: raw.name,
|
|
869
|
+
data: raw.data
|
|
870
|
+
};
|
|
871
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
872
|
+
writeFileSync(file, `${JSON.stringify(sidecar, null, 1)}\n`);
|
|
873
|
+
res.statusCode = 204;
|
|
874
|
+
res.end();
|
|
875
|
+
} catch {
|
|
876
|
+
res.statusCode = 400;
|
|
877
|
+
res.end("invalid fixture payload");
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
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).
|
|
625
888
|
*
|
|
626
889
|
* @internal
|
|
627
890
|
*/
|
|
@@ -631,79 +894,129 @@ function handleCapturePost(req, res, server, platePath) {
|
|
|
631
894
|
res.end();
|
|
632
895
|
return;
|
|
633
896
|
}
|
|
897
|
+
if (!isJsonContentType(req.headers?.["content-type"])) {
|
|
898
|
+
res.statusCode = 415;
|
|
899
|
+
res.end("unsupported media type");
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
|
|
903
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
904
|
+
res.statusCode = 413;
|
|
905
|
+
res.end("payload too large");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
634
908
|
let body = "";
|
|
909
|
+
let bytes = 0;
|
|
910
|
+
let aborted = false;
|
|
635
911
|
req.on("data", (chunk) => {
|
|
636
|
-
|
|
912
|
+
if (aborted) return;
|
|
913
|
+
const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
914
|
+
bytes += Buffer.byteLength(text);
|
|
915
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
916
|
+
aborted = true;
|
|
917
|
+
res.statusCode = 413;
|
|
918
|
+
res.end("payload too large");
|
|
919
|
+
req.destroy?.();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
body += text;
|
|
637
923
|
});
|
|
638
924
|
req.on("end", () => {
|
|
925
|
+
if (aborted) return;
|
|
639
926
|
try {
|
|
640
|
-
const
|
|
641
|
-
|
|
927
|
+
const raw = JSON.parse(body);
|
|
928
|
+
const envelope = isRecord(raw) ? raw : void 0;
|
|
929
|
+
const stored = parseStoredPlate(envelope?.plate);
|
|
930
|
+
if (!stored) {
|
|
642
931
|
res.statusCode = 400;
|
|
643
932
|
res.end("invalid capture payload");
|
|
644
933
|
return;
|
|
645
934
|
}
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
const
|
|
649
|
-
|
|
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`;
|
|
650
940
|
if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
|
|
651
941
|
res.statusCode = 204;
|
|
652
942
|
res.end();
|
|
653
943
|
return;
|
|
654
944
|
}
|
|
655
945
|
if (serialized.length > MAX_PLATE_BYTES) {
|
|
656
|
-
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.");
|
|
657
947
|
res.statusCode = 413;
|
|
658
948
|
res.end("plate too large");
|
|
659
949
|
return;
|
|
660
950
|
}
|
|
661
951
|
mkdirSync(dirname(file), { recursive: true });
|
|
662
952
|
writeFileSync(file, serialized);
|
|
663
|
-
announcePlate(server.ws,
|
|
953
|
+
announcePlate(server.ws, stored.name, stored);
|
|
664
954
|
res.statusCode = 204;
|
|
665
955
|
res.end();
|
|
666
|
-
} catch
|
|
956
|
+
} catch {
|
|
667
957
|
res.statusCode = 400;
|
|
668
|
-
res.end(
|
|
958
|
+
res.end("invalid capture payload");
|
|
669
959
|
}
|
|
670
960
|
});
|
|
671
961
|
}
|
|
672
962
|
/**
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
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.
|
|
676
969
|
*/
|
|
677
|
-
function
|
|
678
|
-
|
|
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;
|
|
679
983
|
}
|
|
680
|
-
|
|
984
|
+
/**
|
|
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.
|
|
990
|
+
*/
|
|
991
|
+
function loadStoredPlate(file) {
|
|
681
992
|
let raw;
|
|
682
993
|
try {
|
|
683
994
|
raw = readFileSync(file, "utf8");
|
|
684
995
|
} catch {
|
|
685
|
-
return
|
|
996
|
+
return {
|
|
997
|
+
stored: null,
|
|
998
|
+
invalid: false
|
|
999
|
+
};
|
|
686
1000
|
}
|
|
687
1001
|
try {
|
|
688
|
-
const
|
|
689
|
-
if (!
|
|
690
|
-
|
|
1002
|
+
const stored = parseStoredPlate(JSON.parse(raw));
|
|
1003
|
+
if (!stored) return {
|
|
1004
|
+
stored: null,
|
|
1005
|
+
invalid: true
|
|
1006
|
+
};
|
|
1007
|
+
return {
|
|
1008
|
+
stored,
|
|
1009
|
+
invalid: false
|
|
1010
|
+
};
|
|
691
1011
|
} catch {
|
|
692
|
-
return
|
|
1012
|
+
return {
|
|
1013
|
+
stored: null,
|
|
1014
|
+
invalid: true
|
|
1015
|
+
};
|
|
693
1016
|
}
|
|
694
1017
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
* resolves to. Missing, unreadable, or version-mismatched files resolve to an
|
|
698
|
-
* empty plate, so imports never break.
|
|
699
|
-
*/
|
|
700
|
-
function readPlate(file, name) {
|
|
701
|
-
const plateFile = readPlateFile(file);
|
|
702
|
-
if (!plateFile) return emptyPlate(name);
|
|
703
|
-
return mergeViews({
|
|
704
|
-
...plateFile,
|
|
705
|
-
name
|
|
706
|
-
});
|
|
1018
|
+
function readStoredPlate(file) {
|
|
1019
|
+
return loadStoredPlate(file).stored;
|
|
707
1020
|
}
|
|
708
1021
|
/**
|
|
709
1022
|
* Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
|
|
@@ -714,13 +1027,21 @@ function readPlate(file, name) {
|
|
|
714
1027
|
* import itself); an unknown child resolves to its own empty plate, never a
|
|
715
1028
|
* build break.
|
|
716
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
|
+
*
|
|
717
1035
|
* @internal
|
|
718
1036
|
*/
|
|
719
|
-
function renderPlateModule(merged) {
|
|
720
|
-
const refs = collectRefs(merged.tree).filter((
|
|
721
|
-
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
|
+
}));
|
|
722
1043
|
if (refs.length === 0) return `export default ${json}`;
|
|
723
|
-
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(", ")} } })`;
|
|
724
1045
|
}
|
|
725
1046
|
//#endregion
|
|
726
|
-
export {
|
|
1047
|
+
export { collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };
|