@hueest/xray 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +14 -12
- package/dist/client.d.ts +42 -51
- package/dist/client.js +134 -141
- package/dist/core.d.ts +34 -44
- package/dist/core.js +27 -33
- package/dist/css-escape-DJ-BsrNm.js +47 -0
- package/dist/hud.d.ts +7 -8
- package/dist/hud.js +5 -5
- package/dist/index.d.ts +251 -284
- package/dist/index.js +292 -338
- package/dist/measure-CUQxB-Ic.d.ts +524 -0
- package/dist/{plate-BwJUgHDc.js → plate-CXIh5_OB.js} +52 -59
- package/dist/react.core-BS2yvqeh.js +301 -0
- package/dist/react.core-BkJoCgWJ.d.ts +115 -0
- package/dist/react.d.ts +11 -13
- package/dist/react.dev.d.ts +4 -6
- package/dist/react.dev.js +54 -61
- package/dist/react.js +21 -21
- package/dist/{project-BAXKpnfF.js → serialize-Cj-EHvMv.js} +872 -1026
- package/package.json +8 -6
- package/virtual.d.ts +25 -32
- package/dist/css-escape-N7bOusGW.js +0 -52
- package/dist/react.core-DeEli0Lp.js +0 -307
- package/dist/react.core-sJfIcaXX.d.ts +0 -102
- package/dist/serialize-BqbQVxYl.d.ts +0 -564
package/dist/index.js
CHANGED
|
@@ -4,12 +4,12 @@ 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
6
|
/**
|
|
7
|
-
* Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a standalone
|
|
8
|
+
* `<Skeleton>` replacing a stitch a still-showing parent skeleton was just rendering. BASE_CSS
|
|
9
|
+
* reveals such a root instantly (no `--xr-skeleton-delay`, no fade) so the bones do not flash away
|
|
10
|
+
* and back. Never present on a nested stitch (which inherits the parent's reveal and has no
|
|
11
|
+
* `@starting-style` of its own) nor on an ordinary top-level skeleton (which keeps the normal
|
|
12
|
+
* delay).
|
|
13
13
|
*/
|
|
14
14
|
const ROOT_INSTANT_ATTR = "data-xr-instant";
|
|
15
15
|
/** Fixed renderer-owned classes every rendered node carries. */
|
|
@@ -17,46 +17,37 @@ const ROOT_CLASS = "xr-root";
|
|
|
17
17
|
const NODE_CLASS = "xr-node";
|
|
18
18
|
const LEAF_CLASS = "xr-leaf";
|
|
19
19
|
/**
|
|
20
|
-
* Renderer-owned base styles, emitted once by the outermost view (ADR 0007),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* (unlayered app CSS would beat layered skeleton rules, and it inverts
|
|
27
|
-
* `!important`).
|
|
20
|
+
* Renderer-owned base styles, emitted once by the outermost view (ADR 0007), before a plate's node
|
|
21
|
+
* rules so a node's captured class wins by source order. Everything visual is driven by inheritable
|
|
22
|
+
* custom properties, so consumers theme by setting `--xr-*` in `:root` or on any ancestor — and
|
|
23
|
+
* per-plate via `[data-xr-root="name"]` (the root carries its plate name). See ADR 0011 and
|
|
24
|
+
* docs/architecture.md. Class-based and unlayered — `@layer` was rejected (unlayered app CSS would
|
|
25
|
+
* beat layered skeleton rules, and it inverts `!important`).
|
|
28
26
|
*
|
|
29
|
-
* Progressive enhancement (ADR 0011): the plain bone fill + opacity pulse use
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* `prefers-contrast`, relative-color-as-fallback) carry no hard guard.
|
|
27
|
+
* Progressive enhancement (ADR 0011): the plain bone fill + opacity pulse use only
|
|
28
|
+
* universally-supported CSS and are the floor. `light-dark()` is guarded by `@supports` because an
|
|
29
|
+
* unsupported VALUE drops the whole declaration (an invisible bone), and it is not
|
|
30
|
+
* Baseline-widely-available until 2026-11-13 — drop the guard then. Features that merely no-op when
|
|
31
|
+
* absent (`@property`, `prefers-contrast`, relative-color-as-fallback) carry no hard guard.
|
|
35
32
|
*
|
|
36
|
-
* Tokens (ADR 0017 taxonomy). The `--xr-bone-*` family styles an individual
|
|
37
|
-
*
|
|
38
|
-
* `--xr-bone-border-radius`
|
|
39
|
-
* `--xr-bone-
|
|
40
|
-
* `--xr-bone-
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `--xr-
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `--xr-skeleton-transition-duration` (the reveal/swap fade). The private
|
|
49
|
-
* channels `--xr-o` (pulse) and `--xr-reveal` (reveal) stay undocumented
|
|
50
|
-
* `@property` plumbing, not a theming surface.
|
|
33
|
+
* Tokens (ADR 0017 taxonomy). The `--xr-bone-*` family styles an individual Bone: `--xr-bone-color`
|
|
34
|
+
* (fill), `--xr-bone-highlight-color` (sheen), `--xr-bone-border-radius` (+
|
|
35
|
+
* `--xr-bone-text-line-border-radius` / `--xr-bone-text-block-border-radius` /
|
|
36
|
+
* `--xr-bone-media-border-radius`), `--xr-bone-animation-duration`, the pulse range
|
|
37
|
+
* `--xr-bone-pulse-opacity-min`/`--xr-bone-pulse-opacity-max`, `--xr-bone-animation` (mode:
|
|
38
|
+
* `xr-pulse` | `none` | a custom keyframe), and `--xr-bone-fill` (override the leaf paint, e.g. a
|
|
39
|
+
* sheen gradient built from the two bone color tokens). The `--xr-skeleton-*` family governs
|
|
40
|
+
* whole-skeleton display timing (ADR 0016): `--xr-skeleton-delay` (grace before the skeleton
|
|
41
|
+
* becomes visible), `--xr-skeleton-min-duration` (minimum visible time once shown; read by the
|
|
42
|
+
* `useSkeletonTiming` hook, not used in CSS), and `--xr-skeleton-transition-duration` (the
|
|
43
|
+
* reveal/swap fade). The private channels `--xr-o` (pulse) and `--xr-reveal` (reveal) stay
|
|
44
|
+
* undocumented `@property` plumbing, not a theming surface.
|
|
51
45
|
*
|
|
52
|
-
* Declared as a PLAIN template literal — no `.trim()` (or any other call) on
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* therefore starts immediately after the backtick (the old leading newline
|
|
58
|
-
* `.trim()` used to strip simply isn't there), keeping the string value
|
|
59
|
-
* byte-identical to the trimmed original.
|
|
46
|
+
* Declared as a PLAIN template literal — no `.trim()` (or any other call) on the initializer. A
|
|
47
|
+
* method call is not provably pure to a conservative tree-shaker (rolldown/Rollup) and would pin
|
|
48
|
+
* this ~5 KB string into every consumer chunk that imports the React adapter, referenced or not
|
|
49
|
+
* (ADR 0026). The template starts immediately after the backtick, so there is no leading newline to
|
|
50
|
+
* strip.
|
|
60
51
|
*/
|
|
61
52
|
const BASE_CSS = `/* One timeline per skeleton, not per leaf: the root animates one inherited
|
|
62
53
|
opacity property and every leaf reads it. Hundreds of independent infinite
|
|
@@ -89,10 +80,14 @@ const BASE_CSS = `/* One timeline per skeleton, not per leaf: the root animates
|
|
|
89
80
|
/* Per-kind defaults: a single text LINE reads as a rounded bar; a multi-line
|
|
90
81
|
text BLOCK as a soft rect (a tall pill misreads as a button); media carries
|
|
91
82
|
the author radius, defaulting to the same soft rect. Re-theme any kind via
|
|
92
|
-
[data-xr-root] .${LEAF_CLASS}-text { ... }. */
|
|
93
|
-
.${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
|
|
83
|
+
[data-xr-root] .${LEAF_CLASS}-text-line { ... }. */
|
|
84
|
+
.${LEAF_CLASS}-text-line { border-radius: var(--xr-bone-text-line-border-radius, 999px) }
|
|
94
85
|
.${LEAF_CLASS}-text-block { border-radius: var(--xr-bone-text-block-border-radius, var(--xr-bone-border-radius, 4px)) }
|
|
95
86
|
.${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
|
|
87
|
+
/* A hidden bone holds its measured box in the flow but paints nothing: the
|
|
88
|
+
space-preserving complement to data-xr-ignore. visibility (not display) so
|
|
89
|
+
the captured size rules still lay the box out. */
|
|
90
|
+
.${LEAF_CLASS}-hidden { visibility: hidden }
|
|
96
91
|
@keyframes xr-pulse {
|
|
97
92
|
from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
|
|
98
93
|
to { --xr-o: var(--xr-bone-pulse-opacity-min, 0.5) }
|
|
@@ -148,7 +143,7 @@ const BASE_CSS = `/* One timeline per skeleton, not per leaf: the root animates
|
|
|
148
143
|
/** The (unserialized) plate an import resolves to before anything has been captured. */
|
|
149
144
|
function emptyPlate(name) {
|
|
150
145
|
return {
|
|
151
|
-
v:
|
|
146
|
+
v: 1,
|
|
152
147
|
name,
|
|
153
148
|
tree: null,
|
|
154
149
|
css: ""
|
|
@@ -157,22 +152,21 @@ function emptyPlate(name) {
|
|
|
157
152
|
//#endregion
|
|
158
153
|
//#region src/chunk.ts
|
|
159
154
|
/**
|
|
160
|
-
* Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* content-addressed class tokens (ADR 0007), never user content.
|
|
155
|
+
* Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR 0008). This is the
|
|
156
|
+
* framework-neutral half of rendering: a skeleton is static, text-free DOM, so the structure is
|
|
157
|
+
* flattened to HTML strings here, at build, once — instead of every adapter walking a tree on the
|
|
158
|
+
* latency-critical first paint. The adapter only injects the strings and mounts a child plate at
|
|
159
|
+
* each stitch (see the React adapters). No escaping risk: bones carry only the content-addressed
|
|
160
|
+
* class tokens (ADR 0007), never user content.
|
|
167
161
|
*/
|
|
168
162
|
function nodeClass(node) {
|
|
169
163
|
return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
|
|
170
164
|
}
|
|
171
165
|
/**
|
|
172
|
-
* Whether this subtree holds a render-time DYNAMIC node anywhere — a stitch
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
166
|
+
* Whether this subtree holds a render-time DYNAMIC node anywhere — a stitch (`ref`, a mount point
|
|
167
|
+
* resolved to a child plate) or a template (`count`, a cell the adapter repeats). Either keeps the
|
|
168
|
+
* subtree from collapsing to one flat string, since both are expanded at render rather than baked
|
|
169
|
+
* into the markup.
|
|
176
170
|
*/
|
|
177
171
|
function hasDynamic(node) {
|
|
178
172
|
return node.ref !== void 0 || node.count !== void 0 || (node.kids?.some(hasDynamic) ?? false);
|
|
@@ -183,13 +177,12 @@ function toHtml(node) {
|
|
|
183
177
|
return `<div class="${nodeClass(node)}">${kids}</div>`;
|
|
184
178
|
}
|
|
185
179
|
/**
|
|
186
|
-
* Serialize a node list into chunks: each maximal run with no render-time dynamic
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* `toHtml` is a plain string the adapter repeats.
|
|
180
|
+
* Serialize a node list into chunks: each maximal run with no render-time dynamic node becomes one
|
|
181
|
+
* HTML `string`; a `ref` node becomes `{ r }`; a `count` node becomes `{ t: html, n }` (its cell's
|
|
182
|
+
* HTML once + the repeat count); a node that isn't itself dynamic but holds one deeper stays a real
|
|
183
|
+
* element (`{ c, k }`) and recurses. Mirrors the element shape the runtime renderer would otherwise
|
|
184
|
+
* build per node. A `count` cell is guaranteed stitch-free by the capture pass, so its `toHtml` is
|
|
185
|
+
* a plain string the adapter repeats.
|
|
193
186
|
*/
|
|
194
187
|
function toChunks(nodes) {
|
|
195
188
|
const out = [];
|
|
@@ -220,17 +213,15 @@ function toChunks(nodes) {
|
|
|
220
213
|
return out;
|
|
221
214
|
}
|
|
222
215
|
/**
|
|
223
|
-
* Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* chunks and renders the fallback.
|
|
216
|
+
* Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The root's own content
|
|
217
|
+
* classes travel as `rootCls` (the adapter puts them on the root element); its children become
|
|
218
|
+
* `chunks`. A capture-less plate has null chunks and renders the fallback.
|
|
227
219
|
*
|
|
228
|
-
* Two-artifact contract (
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* `{ plate, css }` pair, or the `xray:plate` dev event, which ships no css).
|
|
220
|
+
* Two-artifact contract (ADR 0026): the returned `Plate` is the TREE artifact only; the input's
|
|
221
|
+
* `css` is deliberately NOT copied onto it. The css string stays the producer's
|
|
222
|
+
* (`GatedPlate`/`StoredPlate`) field, and each lowering site decides independently how its css
|
|
223
|
+
* travels: the plugin's virtual `.css` modules + `export const css`, `captureElement`'s `{ plate,
|
|
224
|
+
* css }` pair, or the `xray:plate` dev event, which ships no css.
|
|
234
225
|
*/
|
|
235
226
|
function serializePlate(merged) {
|
|
236
227
|
const { v, name, tree } = merged;
|
|
@@ -252,13 +243,11 @@ function serializePlate(merged) {
|
|
|
252
243
|
};
|
|
253
244
|
}
|
|
254
245
|
/**
|
|
255
|
-
* Distinct plate names referenced by the tree's stitches, in first-seen order.
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* `mergeViews`/`addCapture` removal (the multi-View merge/gate now lives in the
|
|
261
|
-
* in-browser `serializePlateIR`, project.ts).
|
|
246
|
+
* Distinct plate names referenced by the tree's stitches, in first-seen order. Walks a `RenderNode`
|
|
247
|
+
* tree (the stored, post-classify shape), so it drives the gen-side stitch import graph straight
|
|
248
|
+
* off a `StoredPlate.tree` (`renderPlateModule`, index.ts). The one node-side primitive needed at
|
|
249
|
+
* build time; multi-View gating happens in the in-browser `serializePlateIR` (serialize.ts, ADR
|
|
250
|
+
* 0022).
|
|
262
251
|
*/
|
|
263
252
|
function collectRefs(tree) {
|
|
264
253
|
const names = [];
|
|
@@ -276,11 +265,10 @@ function collectRefs(tree) {
|
|
|
276
265
|
//#endregion
|
|
277
266
|
//#region src/diagnostics.ts
|
|
278
267
|
/**
|
|
279
|
-
* Build a single diagnostic record with its canonical message, for callers
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* everywhere. `detail` must never carry user CSS or captured text.
|
|
268
|
+
* Build a single diagnostic record with its canonical message, for callers outside the walk that
|
|
269
|
+
* surface a diagnostic directly (e.g. the client mapping `CaptureTooLargeError`, or the server
|
|
270
|
+
* reporting an invalid committed plate). Uses the same message table as the collector so the text
|
|
271
|
+
* is identical everywhere. `detail` must never carry user CSS or captured text.
|
|
284
272
|
*/
|
|
285
273
|
function makeDiagnostic(code, options) {
|
|
286
274
|
return {
|
|
@@ -291,10 +279,9 @@ function makeDiagnostic(code, options) {
|
|
|
291
279
|
};
|
|
292
280
|
}
|
|
293
281
|
/**
|
|
294
|
-
* The human-readable message per code, shared verbatim by browser and server so
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* aggregate count and any short detail.
|
|
282
|
+
* The human-readable message per code, shared verbatim by browser and server so their text cannot
|
|
283
|
+
* drift. Messages describe the omission and its fidelity cost; none echoes user CSS or captured
|
|
284
|
+
* text. `formatDiagnostic` appends the aggregate count and any short detail.
|
|
298
285
|
*/
|
|
299
286
|
const DIAGNOSTIC_MESSAGES = {
|
|
300
287
|
"unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
|
|
@@ -309,11 +296,10 @@ const DIAGNOSTIC_MESSAGES = {
|
|
|
309
296
|
"invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
|
|
310
297
|
};
|
|
311
298
|
/**
|
|
312
|
-
* The single shared formatter. Renders one diagnostic to a stable one-line
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* user content.
|
|
299
|
+
* The single shared formatter. Renders one diagnostic to a stable one-line string used IDENTICALLY
|
|
300
|
+
* by the browser console and the Vite server output, so the two can never disagree. Shape:
|
|
301
|
+
* `<message> (<count>)[: <detail>]`. Count is omitted when 1 or absent; detail is appended only
|
|
302
|
+
* when present and is never user content.
|
|
317
303
|
*/
|
|
318
304
|
function formatDiagnostic(diagnostic) {
|
|
319
305
|
const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
|
|
@@ -327,28 +313,26 @@ function diagnosticsHeader(name) {
|
|
|
327
313
|
//#endregion
|
|
328
314
|
//#region src/name.ts
|
|
329
315
|
/**
|
|
330
|
-
* Plate-name validation, shared by the Vite plugin (`index.ts`) and the
|
|
331
|
-
*
|
|
316
|
+
* Plate-name validation, shared by the Vite plugin (`index.ts`) and the Node-only POST validator
|
|
317
|
+
* (`validate.ts`).
|
|
332
318
|
*
|
|
333
|
-
* Lives in its own dependency-free module so `validate.ts` can reuse it without
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* ref-graph emitter all gate on.
|
|
319
|
+
* Lives in its own dependency-free module so `validate.ts` can reuse it without importing the
|
|
320
|
+
* plugin entry (which would be a cycle), and so the regex stays the single source of truth for what
|
|
321
|
+
* a plate name may be — the same rule the loader, the file watcher, the coverage reader, the POST
|
|
322
|
+
* endpoint, and the ref-graph emitter all gate on.
|
|
338
323
|
*
|
|
339
324
|
* @module
|
|
340
325
|
*/
|
|
341
326
|
/**
|
|
342
327
|
* Names come from import specifiers and POST bodies — keep them inside the plates dir.
|
|
343
328
|
*
|
|
344
|
-
* Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens,
|
|
345
|
-
* never
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* distinct prefixes (else e.g. `a/-b` and `a-/b` would both encode to `a---b`).
|
|
329
|
+
* Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens, never doubled and
|
|
330
|
+
* never at a segment edge — and segments are joined by `/`. The class-prefix encoder
|
|
331
|
+
* (`classify.ts`) renders `/` as `--` to keep nested names readable, and that prefix is the
|
|
332
|
+
* isolation boundary across stitched plates (ADR 0007), so the encoding must be injective. Banning
|
|
333
|
+
* a literal `--` and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
|
|
334
|
+
* prefix came from exactly one `/` — making distinct names always map to distinct prefixes (else
|
|
335
|
+
* e.g. `a/-b` and `a-/b` would both encode to `a---b`).
|
|
352
336
|
*
|
|
353
337
|
* @internal
|
|
354
338
|
*/
|
|
@@ -358,71 +342,66 @@ function isValidPlateName(name) {
|
|
|
358
342
|
//#endregion
|
|
359
343
|
//#region src/validate.ts
|
|
360
344
|
/**
|
|
361
|
-
* Structural validation of a posted/committed `StoredPlate`, plus the
|
|
362
|
-
*
|
|
363
|
-
* endpoint and disk reads.
|
|
345
|
+
* Structural validation of a posted/committed `StoredPlate`, plus the single-CSS-declaration /
|
|
346
|
+
* media-condition validators, for the dev `/__xray` endpoint and disk reads.
|
|
364
347
|
*
|
|
365
|
-
* NODE-ONLY (
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* `
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
* helpers from any public subpath — they stay private until a `doctor`-style
|
|
374
|
-
* tool creates a concrete external need (the plan's decisions).
|
|
348
|
+
* NODE-ONLY (ADR 0023, ADR 0008). This module imports `valibot`, which must NEVER reach the browser
|
|
349
|
+
* bundles: the dev capture client, the HUD, the React adapters, and the framework-neutral `core`
|
|
350
|
+
* surface all ship to a browser, and none of them needs to validate an inbound payload. Only the
|
|
351
|
+
* Vite plugin (`index.ts`, the package `.` entry, built with `platform: 'node'`) imports this file,
|
|
352
|
+
* which keeps valibot out of every browser-facing dist chunk. Do NOT import this from `plate.ts`,
|
|
353
|
+
* `css.ts`, or anything a render/browser path also imports, and do NOT re-export these helpers from
|
|
354
|
+
* any public subpath — they stay private until a `doctor`-style tool creates a concrete external
|
|
355
|
+
* need (ADR 0023).
|
|
375
356
|
*
|
|
376
|
-
* The endpoint accepts ONLY the artifact shape xray itself produces.
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
* `
|
|
384
|
-
*
|
|
385
|
-
* committed `StoredPlate` is also untrusted (hand-edited or from git), so the
|
|
386
|
-
* render path independently escapes raw-text `<style>` terminators (css-escape.ts).
|
|
357
|
+
* The endpoint accepts ONLY the artifact shape xray itself produces. The dev client posts a
|
|
358
|
+
* finished `StoredPlate` — the whole Plate already projected in the browser (`serializePlateIR`,
|
|
359
|
+
* ADR 0022) — so `parseStoredPlate` is the single validator: a post-classify `RenderNode` tree plus
|
|
360
|
+
* an already-classified css blob, guarded for `<style>`-breakout only (a per-decl allowlist cannot
|
|
361
|
+
* apply to a finished css string; see `isValidPlateCss`). The per-declaration / per-condition
|
|
362
|
+
* validators (`isValidDeclaration` / `isValidCondition`) remain exported helpers — the css blob
|
|
363
|
+
* already passed them AT CAPTURE TIME in the browser. Validation is necessary but not sufficient: a
|
|
364
|
+
* committed `StoredPlate` is also untrusted (hand-edited or from git), so the render path
|
|
365
|
+
* independently escapes raw-text `<style>` terminators (css-escape.ts).
|
|
387
366
|
*
|
|
388
367
|
* @module
|
|
389
368
|
*/
|
|
390
369
|
/**
|
|
391
|
-
* Bounds chosen to be far above any real capture yet small enough that a hostile
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
370
|
+
* Bounds chosen to be far above any real capture yet small enough that a hostile payload cannot
|
|
371
|
+
* exhaust memory or wedge the merge. A `<Skeleton>` set too high is already caught by the
|
|
372
|
+
* per-capture node guard and the serialized-size cap; these are the structural ceilings the schema
|
|
373
|
+
* enforces before either runs.
|
|
395
374
|
*/
|
|
396
375
|
const MAX_TREE_NODES = 5e4;
|
|
397
376
|
const MAX_BREAKPOINTS = 256;
|
|
398
377
|
const MAX_TREE_DEPTH = 1e3;
|
|
399
378
|
/**
|
|
400
|
-
* A template node's repeat count (
|
|
401
|
-
*
|
|
379
|
+
* A template node's repeat count (Template, ADR 0022). Far above any real feed, yet bounded so a
|
|
380
|
+
* hostile `count` cannot drive an unbounded render-time string `.repeat`.
|
|
402
381
|
*/
|
|
403
382
|
const MAX_TEMPLATE_COUNT = 1e4;
|
|
404
383
|
/** A single declaration is `prop: value [!important]`; real ones are short. */
|
|
405
384
|
const MAX_DECL_LENGTH = 2e3;
|
|
406
385
|
/**
|
|
407
|
-
* Raw-text / markup terminators that must never appear in generated CSS, even
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* validators (case-insensitive).
|
|
386
|
+
* Raw-text / markup terminators that must never appear in generated CSS, even when no bare `<`/`>`
|
|
387
|
+
* is present: HTML comment delimiters, a CDATA opener, and a `</style` raw-text terminator. Shared
|
|
388
|
+
* by the declaration and condition validators (case-insensitive).
|
|
411
389
|
*/
|
|
412
390
|
const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
|
|
413
391
|
/** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
|
|
414
392
|
const finiteNumber = v.pipe(v.number(), v.finite());
|
|
415
393
|
const leafSchema = v.picklist([
|
|
416
|
-
"text",
|
|
394
|
+
"text-line",
|
|
417
395
|
"text-block",
|
|
418
396
|
"media",
|
|
419
|
-
"box"
|
|
397
|
+
"box",
|
|
398
|
+
"hidden"
|
|
420
399
|
]);
|
|
421
400
|
/**
|
|
422
|
-
* Depth + total-node guard shared by the recursive node schema. The recursive
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
401
|
+
* Depth + total-node guard shared by the recursive node schema. The recursive `array(lazy(...))`
|
|
402
|
+
* bounds breadth per level; this guards total depth so a long thin chain cannot recurse without
|
|
403
|
+
* limit, and the running `count` bounds the total node budget so a fanned-out payload cannot
|
|
404
|
+
* exhaust memory.
|
|
426
405
|
*/
|
|
427
406
|
function treeWithinLimits(root) {
|
|
428
407
|
let count = 0;
|
|
@@ -440,27 +419,24 @@ function treeWithinLimits(root) {
|
|
|
440
419
|
/** A finished plate's css blob, generously bounded (post-classify, whole-plate). */
|
|
441
420
|
const MAX_PLATE_CSS_LENGTH = 4e6;
|
|
442
421
|
/**
|
|
443
|
-
* A NUL byte — the one control character that must never reach the rendered
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
* broad `CONTROL_CHARS` class does NOT apply here.
|
|
422
|
+
* A NUL byte — the one control character that must never reach the rendered `<style>`. Unlike the
|
|
423
|
+
* per-declaration validator, the whole-plate css blob legitimately carries newlines/tabs
|
|
424
|
+
* (`classify` joins rules with `\n`), so the broad `CONTROL_CHARS` class does NOT apply here.
|
|
447
425
|
*/
|
|
448
426
|
const NUL = /\u0000/u;
|
|
449
427
|
/**
|
|
450
|
-
* Validate a STORED, already-classified css blob (ADR 0022
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* render path
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* css-escape.ts) — that stays as the belt; this is the braces. A `StoredPlate` is
|
|
463
|
-
* untrusted exactly as a `PlateFile` was (hand-edited / arriving via git).
|
|
428
|
+
* Validate a STORED, already-classified css blob (ADR 0022). The trust model differs from a
|
|
429
|
+
* pre-classify `decls[]` array: a `StoredPlate.css` is the WHOLE plate's content-addressed CSS — a
|
|
430
|
+
* string of selectors, `{ ... }` rule blocks, and `@media`/`@container` at-rules — so the
|
|
431
|
+
* per-declaration `isValidDeclaration` allowlist (which forbids `{}` and re-checks `isLayoutProp`)
|
|
432
|
+
* cannot apply: it would reject every real plate. Braces, newlines, and bare `<`/`>` (Media Queries
|
|
433
|
+
* Level 4 range syntax, `@media (width >= 600px)`) are all legitimate here. The css was produced by
|
|
434
|
+
* `filterLayoutDecls`-filtered decls at capture time in the browser; the disk guard covers only the
|
|
435
|
+
* security boundary the render path also enforces: no `<style>` raw-text breakout. Reject the
|
|
436
|
+
* raw-text / markup terminators (`</style`, `<!--`, `-->`, `<![CDATA[`) and a NUL byte, and bound
|
|
437
|
+
* the length. The render path independently escapes these (`escapeStyleText`, css-escape.ts) — that
|
|
438
|
+
* stays as the belt; this is the braces. A `StoredPlate` is untrusted (hand-edited / arriving via
|
|
439
|
+
* git).
|
|
464
440
|
*/
|
|
465
441
|
function isValidPlateCss(css) {
|
|
466
442
|
if (typeof css !== "string") return false;
|
|
@@ -470,10 +446,10 @@ function isValidPlateCss(css) {
|
|
|
470
446
|
return true;
|
|
471
447
|
}
|
|
472
448
|
/**
|
|
473
|
-
* Recursive `RenderNode` — the POST-classify tree shape (plate.ts): no `id` (ids
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
449
|
+
* Recursive `RenderNode` — the POST-classify tree shape (plate.ts): no `id` (ids are stripped at
|
|
450
|
+
* classify), an optional content-class string `cls`, and the same `leaf`/`ref`/`count`/`kids` the
|
|
451
|
+
* renderer reads. Mirrors `plateNodeSchema` minus `id`, plus a bounded `cls`. Depth/breadth bounded
|
|
452
|
+
* by the shared `treeWithinLimits`.
|
|
477
453
|
*/
|
|
478
454
|
const renderNodeSchema = v.pipe(v.object({
|
|
479
455
|
leaf: v.optional(leafSchema),
|
|
@@ -483,30 +459,27 @@ const renderNodeSchema = v.pipe(v.object({
|
|
|
483
459
|
kids: v.optional(v.pipe(v.array(v.lazy(() => renderNodeSchema)), v.maxLength(MAX_TREE_NODES)))
|
|
484
460
|
}), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
|
|
485
461
|
/**
|
|
486
|
-
* The committed/posted `StoredPlate` shape
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* non-negative numbers, bounded but NOT renormalized (the file is the source of
|
|
493
|
-
* truth). `v` must be the current `PLATE_VERSION`.
|
|
462
|
+
* The committed/posted `StoredPlate` shape (plate.ts): the whole plate already projected in the
|
|
463
|
+
* browser. Validated as untrusted input — a `StoredPlate` on disk is untrusted (hand-edited or
|
|
464
|
+
* arriving via git). `tree` is a `RenderNode` (post-classify, nullable for a capture-less plate);
|
|
465
|
+
* `css` is the already-classified blob, guarded for `<style>`-breakout only (`isValidPlateCss`,
|
|
466
|
+
* above). `breakpoints` are finite non-negative numbers, bounded but NOT renormalized (the file is
|
|
467
|
+
* the source of truth). `v` must be the current `PLATE_VERSION`.
|
|
494
468
|
*/
|
|
495
469
|
const storedPlateSchema = v.object({
|
|
496
|
-
v: v.literal(
|
|
470
|
+
v: v.literal(1),
|
|
497
471
|
name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
|
|
498
472
|
breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
|
|
499
473
|
tree: v.nullable(renderNodeSchema),
|
|
500
474
|
css: v.pipe(v.string(), v.check(isValidPlateCss, "invalid plate css"))
|
|
501
475
|
});
|
|
502
476
|
/**
|
|
503
|
-
* Parse a committed/posted `StoredPlate` against its schema (
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
* breaking the build or smuggling a `<style>`-escaping css blob through render.
|
|
477
|
+
* Parse a committed/posted `StoredPlate` against its schema (ADR 0022). Returns the typed
|
|
478
|
+
* `StoredPlate` on success or `null` on any structural failure (corrupt object, wrong shape,
|
|
479
|
+
* version mismatch, an injected `<style>` terminator in the css, an oversized/over-deep tree). The
|
|
480
|
+
* disk readers and the POST handler map `null` to an empty plate / a generic 400 and never echo the
|
|
481
|
+
* payload — a malformed or hostile stored plate degrades to empty rather than breaking the build or
|
|
482
|
+
* smuggling a `<style>`-escaping css blob through render.
|
|
510
483
|
*/
|
|
511
484
|
function parseStoredPlate(value) {
|
|
512
485
|
const result = v.safeParse(storedPlateSchema, value);
|
|
@@ -517,9 +490,9 @@ function parseStoredPlate(value) {
|
|
|
517
490
|
/**
|
|
518
491
|
* Vite plugin entry point for xray.
|
|
519
492
|
*
|
|
520
|
-
* Add `xrayVitePlugin()` to a Vite config to resolve committed Plate imports in
|
|
521
|
-
* dev
|
|
522
|
-
*
|
|
493
|
+
* Add `xrayVitePlugin()` to a Vite config to resolve committed Plate imports in dev and build. The
|
|
494
|
+
* dev capture client is injected only during `vite serve`, and only when `capture` or the HUD needs
|
|
495
|
+
* it.
|
|
523
496
|
*
|
|
524
497
|
* @module @hueest/xray
|
|
525
498
|
*/
|
|
@@ -528,19 +501,20 @@ const BOOT_ID = "virtual:xray/client";
|
|
|
528
501
|
const RESOLVED_BOOT_ID = `\0${BOOT_ID}`;
|
|
529
502
|
const ENDPOINT = "/__xray";
|
|
530
503
|
/**
|
|
531
|
-
* The shared base-css virtual module every plate's `.css` module imports, so
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* and this id must be routed through that pipeline (it ends in `.css`).
|
|
504
|
+
* The shared base-css virtual module every plate's `.css` module imports, so Vite dedupes BASE_CSS
|
|
505
|
+
* across plates instead of repeating it per plate. Deliberately NOT `\0`-prefixed: an unprefixed
|
|
506
|
+
* virtual id is tried first (the vanilla-extract pattern) because Vite's CSS plugins match on the
|
|
507
|
+
* module id, and this id must be routed through that pipeline (it ends in `.css`).
|
|
536
508
|
*/
|
|
537
509
|
const BASE_CSS_ID = "virtual:xray/base.css";
|
|
538
|
-
/**
|
|
539
|
-
*
|
|
510
|
+
/**
|
|
511
|
+
* A plate's per-name css virtual module id. Also un-`\0`-prefixed for the same reason as
|
|
512
|
+
* `BASE_CSS_ID` — see above.
|
|
513
|
+
*/
|
|
540
514
|
const plateCssId = (name) => `${VIRTUAL_PREFIX}${name}.css`;
|
|
541
515
|
/**
|
|
542
|
-
* Create the Vite plugins that resolve committed Plates and, during dev,
|
|
543
|
-
*
|
|
516
|
+
* Create the Vite plugins that resolve committed Plates and, during dev, capture new Plates from
|
|
517
|
+
* rendered `<Skeleton>` boundaries.
|
|
544
518
|
*
|
|
545
519
|
* Typical setup:
|
|
546
520
|
*
|
|
@@ -553,8 +527,10 @@ const plateCssId = (name) => `${VIRTUAL_PREFIX}${name}.css`;
|
|
|
553
527
|
* ```
|
|
554
528
|
*
|
|
555
529
|
* Returns two plugins:
|
|
530
|
+
*
|
|
556
531
|
* - `xray:data` resolves `virtual:xray/plates/<name>` from committed Plate files in dev and build.
|
|
557
|
-
* - `xray:dev` runs only during `vite serve`; it injects the capture client, receives Captures,
|
|
532
|
+
* - `xray:dev` runs only during `vite serve`; it injects the capture client, receives Captures,
|
|
533
|
+
* writes Plate files, and hot-updates the page.
|
|
558
534
|
*/
|
|
559
535
|
function xrayVitePlugin(options = {}) {
|
|
560
536
|
const platesDir = options.platesDir ?? "plates";
|
|
@@ -659,8 +635,8 @@ function xrayVitePlugin(options = {}) {
|
|
|
659
635
|
}];
|
|
660
636
|
}
|
|
661
637
|
/**
|
|
662
|
-
* The injected dev module: boot the client, POST captures back, optionally
|
|
663
|
-
*
|
|
638
|
+
* The injected dev module: boot the client, POST captures back, optionally mount the HUD. The
|
|
639
|
+
* client installs first so the HUD finds the light box store.
|
|
664
640
|
*/
|
|
665
641
|
function bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, replayMode) {
|
|
666
642
|
return [
|
|
@@ -720,39 +696,33 @@ function bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, repl
|
|
|
720
696
|
].join("\n");
|
|
721
697
|
}
|
|
722
698
|
/**
|
|
723
|
-
* Drop both of a plate's virtual modules from Vite's dev transform cache after
|
|
724
|
-
*
|
|
699
|
+
* Drop both of a plate's virtual modules from Vite's dev transform cache after its file changes,
|
|
700
|
+
* each through the channel appropriate to it:
|
|
725
701
|
*
|
|
726
|
-
* - The `.css` module (UN-prefixed id — see `plateCssId`) is invalidated AND
|
|
727
|
-
*
|
|
728
|
-
* the
|
|
729
|
-
* reload. The css module has no capture boundary, so a live reload of it
|
|
702
|
+
* - The `.css` module (UN-prefixed id — see `plateCssId`) is invalidated AND reloaded: standard Vite
|
|
703
|
+
* CSS HMR (`reloadModule` re-transforms and pushes the update over the ws), so fresh styles land
|
|
704
|
+
* in the open page with no reload. The css module has no capture boundary, so a live reload of it
|
|
730
705
|
* carries no remount-loop risk.
|
|
731
|
-
* - The JS (tree) module (`\0`-prefixed id) is invalidated ONLY — deliberately
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
* the client until the NEXT full page load, which then re-transforms from
|
|
738
|
-
* disk and serves a tree consistent with the css.
|
|
706
|
+
* - The JS (tree) module (`\0`-prefixed id) is invalidated ONLY — deliberately NO `reloadModule`/HMR
|
|
707
|
+
* push. A live re-import would remount the capture boundary and re-fire the capture (the exact
|
|
708
|
+
* infinite loop the "no addWatchFile" comment in `load` guards against; live tree updates keep
|
|
709
|
+
* flowing over the `xray:plate` announce into the dev store instead). Bare `invalidateModule` is
|
|
710
|
+
* server-side cache marking only — nothing reaches the client until the NEXT full page load,
|
|
711
|
+
* which then re-transforms from disk and serves a tree consistent with the css.
|
|
739
712
|
*
|
|
740
|
-
* WITHOUT the JS half
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
* de-synchronized the two modules' invalidation policies; this helper is the
|
|
746
|
-
* single place that keeps them in lockstep.
|
|
713
|
+
* WITHOUT the JS half, a page reload after a capture pairs Vite's CACHED stale tree transform
|
|
714
|
+
* (stale content-addressed class names) with the freshly reloaded css — class names no longer match
|
|
715
|
+
* rules and skeletons render garbled until a dev-server restart clears the transform cache. The two
|
|
716
|
+
* modules need distinct invalidation policies; this helper is the single place that keeps them in
|
|
717
|
+
* lockstep.
|
|
747
718
|
*
|
|
748
|
-
* Scope note (stitched plates): a parent's module embeds ONLY its own stored
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
* parent modules hold nothing child-derived that could go stale.
|
|
719
|
+
* Scope note (stitched plates): a parent's module embeds ONLY its own stored tree; a child's data
|
|
720
|
+
* arrives at runtime through the child module import (`refs: { "<child>": __xr0 }`), never inlined
|
|
721
|
+
* into the parent's transform. So invalidating the re-captured plate's OWN two modules is
|
|
722
|
+
* sufficient — parent modules hold nothing child-derived that could go stale.
|
|
753
723
|
*
|
|
754
|
-
* No-op per module when it was never resolved/imported yet (a fresh plate no
|
|
755
|
-
*
|
|
724
|
+
* No-op per module when it was never resolved/imported yet (a fresh plate no page has loaded, or a
|
|
725
|
+
* server stub without the module-graph subset).
|
|
756
726
|
*/
|
|
757
727
|
async function invalidatePlateModules(server, name) {
|
|
758
728
|
const jsMod = server.moduleGraph?.getModuleById(RESOLVED_PREFIX + name);
|
|
@@ -761,10 +731,10 @@ async function invalidatePlateModules(server, name) {
|
|
|
761
731
|
if (cssMod) await server.reloadModule?.(cssMod);
|
|
762
732
|
}
|
|
763
733
|
/**
|
|
764
|
-
* Print a plate's diagnostics to the Vite server output using the SAME grouped
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
734
|
+
* Print a plate's diagnostics to the Vite server output using the SAME grouped header + shared
|
|
735
|
+
* formatter the browser console uses (diagnostics.ts), so the two can never drift. Prefers Vite's
|
|
736
|
+
* logger (it preserves the formatting); falls back to `console.warn`. No-op when there are no
|
|
737
|
+
* diagnostics.
|
|
768
738
|
*/
|
|
769
739
|
function reportServerDiagnostics(server, name, diagnostics) {
|
|
770
740
|
if (!diagnostics || diagnostics.length === 0) return;
|
|
@@ -776,14 +746,12 @@ function reportServerDiagnostics(server, name, diagnostics) {
|
|
|
776
746
|
/** A committed plate file past this is almost always a `<Skeleton>` set too high. */
|
|
777
747
|
const MAX_PLATE_BYTES = 2e6;
|
|
778
748
|
/**
|
|
779
|
-
* Hard ceiling on the POST body
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
* `content-length` cannot get past it. The dev endpoint is local and
|
|
786
|
-
* unauthenticated, so this is the real backstop, not the header.
|
|
749
|
+
* Hard ceiling on the POST body the endpoint will buffer. The posted StoredPlate is the whole
|
|
750
|
+
* projected Plate the `MAX_PLATE_BYTES` cap guards, so this sits above the plate cap with headroom
|
|
751
|
+
* (the JSON envelope + diagnostics add a little) and exists only to stop an oversized or runaway
|
|
752
|
+
* body from being read into memory. Enforced both up front from `content-length` and while
|
|
753
|
+
* streaming, so a lying or absent `content-length` cannot get past it. The dev endpoint is local
|
|
754
|
+
* and unauthenticated, so this is the real backstop, not the header.
|
|
787
755
|
*/
|
|
788
756
|
const MAX_BODY_BYTES = 4e6;
|
|
789
757
|
/** The only content type the capture client posts; anything else is rejected up front. */
|
|
@@ -793,12 +761,11 @@ function isJsonContentType(value) {
|
|
|
793
761
|
return /^application\/json\b/i.test(header.trim());
|
|
794
762
|
}
|
|
795
763
|
/**
|
|
796
|
-
* Reject cross-origin browser writes. Browsers always attach `Origin` to
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
* files, not local tooling.
|
|
764
|
+
* Reject cross-origin browser writes. Browsers always attach `Origin` to POSTs; a request from the
|
|
765
|
+
* app's own page carries the dev server's origin, so its host must equal the `Host` the request
|
|
766
|
+
* arrived on. A request with NO Origin header is not from a browser page (curl, a node script) and
|
|
767
|
+
* is allowed — the gate targets drive-by browser writes to Plate and Fixture files, not local
|
|
768
|
+
* tooling.
|
|
802
769
|
*/
|
|
803
770
|
function isSameOriginWrite(req) {
|
|
804
771
|
const origin = Array.isArray(req.headers?.origin) ? req.headers.origin[0] : req.headers?.origin;
|
|
@@ -814,14 +781,12 @@ function isRecord(value) {
|
|
|
814
781
|
return typeof value === "object" && value !== null;
|
|
815
782
|
}
|
|
816
783
|
/**
|
|
817
|
-
* Derive the HUD coverage bands from a StoredPlate's `breakpoints` (ADR 0022
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* Keeps the `xray:plate`/coverage `spans` shape (`{min?, max?}[]`) identical — only
|
|
824
|
-
* the SOURCE changes (derived from breakpoints, not read off per-View captures).
|
|
784
|
+
* Derive the HUD coverage bands from a StoredPlate's `breakpoints` (ADR 0022). Per-View spans do
|
|
785
|
+
* not exist on disk: a StoredPlate sweeps ALL widths in one session, so coverage is complete
|
|
786
|
+
* per-Plate and the bands are the breakpoint partition — each adjacent breakpoint pair is a covered
|
|
787
|
+
* band, the first band open-min, the last open-max. `breakpoints=[b1,b2]` →
|
|
788
|
+
* `[{max:b1},{min:b1,max:b2},{min:b2}]`; `breakpoints=[]` → one open band `[{}]`. The
|
|
789
|
+
* `xray:plate`/coverage `spans` shape (`{min?, max?}[]`) is unchanged; only the SOURCE is derived.
|
|
825
790
|
*/
|
|
826
791
|
function spansFromBreakpoints(breakpoints) {
|
|
827
792
|
const sorted = [...breakpoints].toSorted((a, b) => a - b);
|
|
@@ -834,20 +799,18 @@ function spansFromBreakpoints(breakpoints) {
|
|
|
834
799
|
spans.push({ min: sorted.at(-1) });
|
|
835
800
|
return spans;
|
|
836
801
|
}
|
|
837
|
-
/**
|
|
838
|
-
* the
|
|
839
|
-
*
|
|
840
|
-
* `spans` derive from the stored breakpoints
|
|
802
|
+
/**
|
|
803
|
+
* Send the stored plate (lowered to chunks) and its derived coverage bands over the HMR event so
|
|
804
|
+
* the client hot-swaps it in place and the HUD updates its band display. The gen-side runs
|
|
805
|
+
* `serializePlate(stored)` (the Bundle seam) here; the `spans` derive from the stored breakpoints.
|
|
841
806
|
*
|
|
842
|
-
* Two-artifact contract (
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
|
|
849
|
-
* anymore, so no push can revert a mounted `<Skeleton>` to inline-`<style>`
|
|
850
|
-
* rendering. */
|
|
807
|
+
* Two-artifact contract (ADR 0026): the event carries the TREE only — `serializePlate` returns a
|
|
808
|
+
* css-less `Plate`, so there is no ws css side-channel by construction. The css half of a capture
|
|
809
|
+
* travels exclusively over Vite's CSS-HMR (`invalidatePlateModules` re-pushes the `.css` virtual
|
|
810
|
+
* module after the write). The live-store plate that outranks the statically-imported module in
|
|
811
|
+
* `useLivePlate` cannot carry inline css, so no push can revert a mounted `<Skeleton>` to
|
|
812
|
+
* inline-`<style>` rendering.
|
|
813
|
+
*/
|
|
851
814
|
function announcePlate(ws, name, stored) {
|
|
852
815
|
const breakpoints = stored?.breakpoints ?? [];
|
|
853
816
|
const merged = stored ? {
|
|
@@ -868,10 +831,9 @@ function announcePlate(ws, name, stored) {
|
|
|
868
831
|
});
|
|
869
832
|
}
|
|
870
833
|
/**
|
|
871
|
-
* Per-plate breakpoints + derived coverage bands, read from the committed
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* widths in one session, so its coverage is complete).
|
|
834
|
+
* Per-plate breakpoints + derived coverage bands, read from the committed StoredPlate files. The
|
|
835
|
+
* `spans` derive from each plate's `breakpoints` (ADR 0022: a StoredPlate sweeps all widths in one
|
|
836
|
+
* session, so its coverage is complete).
|
|
875
837
|
*
|
|
876
838
|
* @internal
|
|
877
839
|
*/
|
|
@@ -901,22 +863,25 @@ function readCoverage(dir) {
|
|
|
901
863
|
}
|
|
902
864
|
return out;
|
|
903
865
|
}
|
|
904
|
-
/**
|
|
905
|
-
*
|
|
906
|
-
* dev client's job (it has the live
|
|
866
|
+
/**
|
|
867
|
+
* The on-disk Fixture sidecar shape (ADR 0015): version, plate name, and the OPAQUE devalue string.
|
|
868
|
+
* The node side never parses `data` — it is the browser dev client's job (it has the live
|
|
869
|
+
* runtime).
|
|
870
|
+
*/
|
|
907
871
|
const FIXTURE_VERSION = 1;
|
|
908
872
|
const FIXTURE_SUFFIX = ".fixture.json";
|
|
909
|
-
/**
|
|
910
|
-
* as opaque text
|
|
873
|
+
/**
|
|
874
|
+
* A recorded Fixture sidecar — `data` is the verbatim devalue string, treated as opaque text
|
|
875
|
+
* server-side.
|
|
876
|
+
*/
|
|
911
877
|
function isFixtureFile(value) {
|
|
912
878
|
return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && typeof value.data === "string";
|
|
913
879
|
}
|
|
914
880
|
/**
|
|
915
|
-
* All recorded Fixtures as `{ <name>: <devalueString> }`, read from the
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
* these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
|
|
881
|
+
* All recorded Fixtures as `{ <name>: <devalueString> }`, read from the `<name>.fixture.json`
|
|
882
|
+
* sidecars (ADR 0015). The map values are the OPAQUE devalue strings exactly as written — the node
|
|
883
|
+
* side never parses them; the browser dev client owns the devalue decode. The `.fixture.json`
|
|
884
|
+
* suffix keeps these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
|
|
920
885
|
* `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
|
|
921
886
|
*
|
|
922
887
|
* @internal
|
|
@@ -951,12 +916,11 @@ function readFixtures(dir) {
|
|
|
951
916
|
return out;
|
|
952
917
|
}
|
|
953
918
|
/**
|
|
954
|
-
* Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
* so the persisted Plate stays purely structural.
|
|
919
|
+
* Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's front-door checks
|
|
920
|
+
* (method, content type, declared/streamed size cap) and the `isValidPlateName` gate, but treats
|
|
921
|
+
* the devalue `data` payload as OPAQUE TEXT: it is validated only as a string and written verbatim,
|
|
922
|
+
* never eval'd or parsed server-side. The sidecar lives NEXT TO the Plate but is never merged into
|
|
923
|
+
* it, so the persisted Plate stays purely structural.
|
|
960
924
|
*
|
|
961
925
|
* @internal
|
|
962
926
|
*/
|
|
@@ -1019,12 +983,11 @@ function handleFixturePost(req, res, fixturePath) {
|
|
|
1019
983
|
});
|
|
1020
984
|
}
|
|
1021
985
|
/**
|
|
1022
|
-
* Validate a posted `StoredPlate` envelope, write it AS-IS, and hot-swap the
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
* `
|
|
1026
|
-
*
|
|
1027
|
-
* never reaches the stored artifact (ADR 0008).
|
|
986
|
+
* Validate a posted `StoredPlate` envelope, write it AS-IS, and hot-swap the plate in place. The
|
|
987
|
+
* dev client posts a finished StoredPlate (the whole Plate already projected in the browser, ADR
|
|
988
|
+
* 0022) inside a `{ plate, diagnostics? }` envelope; there is NO server-side accumulation/merge.
|
|
989
|
+
* `diagnostics` is a SIBLING field the server logs and then drops; it never reaches the stored
|
|
990
|
+
* artifact (ADR 0008).
|
|
1028
991
|
*
|
|
1029
992
|
* @internal
|
|
1030
993
|
*/
|
|
@@ -1101,12 +1064,11 @@ function handleCapturePost(req, res, server, platePath) {
|
|
|
1101
1064
|
});
|
|
1102
1065
|
}
|
|
1103
1066
|
/**
|
|
1104
|
-
* Loosely validate the envelope's SIBLING `diagnostics` (never stored, just
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
* fails the post (the plate already validated); it just logs nothing.
|
|
1067
|
+
* Loosely validate the envelope's SIBLING `diagnostics` (never stored, just logged). The
|
|
1068
|
+
* StoredPlate validator does not carry diagnostics — they are a transient dev-only field on the
|
|
1069
|
+
* POST envelope — so this filters the array to the known-code shape `reportServerDiagnostics`
|
|
1070
|
+
* formats, dropping anything that is not a `{ code, message, ... }` record. A malformed diagnostics
|
|
1071
|
+
* field never fails the post (the plate already validated); it just logs nothing.
|
|
1110
1072
|
*/
|
|
1111
1073
|
function validEnvelopeDiagnostics(value) {
|
|
1112
1074
|
if (!Array.isArray(value)) return void 0;
|
|
@@ -1123,11 +1085,10 @@ function validEnvelopeDiagnostics(value) {
|
|
|
1123
1085
|
return out.length > 0 ? out : void 0;
|
|
1124
1086
|
}
|
|
1125
1087
|
/**
|
|
1126
|
-
* Read and validate a committed StoredPlate, distinguishing "no file" from "file
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
* (same diagnostics vocabulary) that an existing plate was ignored.
|
|
1088
|
+
* Read and validate a committed StoredPlate, distinguishing "no file" from "file present but
|
|
1089
|
+
* unreadable". `invalid` is true ONLY when the file exists but is corrupt JSON, the wrong shape, or
|
|
1090
|
+
* a version mismatch — a missing file is the normal pre-capture state and is not flagged. Callers
|
|
1091
|
+
* use `invalid` to warn (same diagnostics vocabulary) that an existing plate was ignored.
|
|
1131
1092
|
*/
|
|
1132
1093
|
function loadStoredPlate(file) {
|
|
1133
1094
|
let raw;
|
|
@@ -1160,20 +1121,17 @@ function readStoredPlate(file) {
|
|
|
1160
1121
|
return loadStoredPlate(file).stored;
|
|
1161
1122
|
}
|
|
1162
1123
|
/**
|
|
1163
|
-
* Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
* import itself); an unknown child resolves to its own empty plate, never a
|
|
1169
|
-
* build break.
|
|
1124
|
+
* Render a plate as the virtual module's source. Each stitch (`ref` node) becomes a real import of
|
|
1125
|
+
* that child's virtual module, wired into a `refs` map on the default export — so the bundler
|
|
1126
|
+
* builds the actual reference graph and chunks the transitive closure a page uses, with no runtime
|
|
1127
|
+
* registry (ADR 0006). A stitchless plate is just its JSON. Self-references are dropped (a module
|
|
1128
|
+
* can't import itself); an unknown child resolves to its own empty plate, never a build break.
|
|
1170
1129
|
*
|
|
1171
|
-
* `name` defaults to the plate's own `name`, but the loader passes the
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1174
|
-
* (`collectRefs` reads `stored.tree`, a RenderNode, unchanged by the cutover).
|
|
1130
|
+
* `name` defaults to the plate's own `name`, but the loader passes the PATH-derived name so a
|
|
1131
|
+
* hand-edited StoredPlate whose `name` field drifted from its filename still serializes and
|
|
1132
|
+
* self-references under the canonical name (`collectRefs` reads `stored.tree`, a RenderNode).
|
|
1175
1133
|
*
|
|
1176
|
-
* Two-artifact module shape (
|
|
1134
|
+
* Two-artifact module shape (ADR 0026):
|
|
1177
1135
|
*
|
|
1178
1136
|
* ```js
|
|
1179
1137
|
* import "virtual:xray/base.css"
|
|
@@ -1182,26 +1140,22 @@ function readStoredPlate(file) {
|
|
|
1182
1140
|
* export const css = "<the plate's scoped css string>"
|
|
1183
1141
|
* ```
|
|
1184
1142
|
*
|
|
1185
|
-
* The default export is the TREE artifact (`Plate` carries no css field
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
* unused named export out of built chunks (verified empirically in the plan's
|
|
1192
|
-
* scratch app; the side-effect css imports do not pin it).
|
|
1143
|
+
* The default export is the TREE artifact (`Plate` carries no css field); the page's styling flows
|
|
1144
|
+
* through Vite's own CSS pipeline via the two side-effect imports (dev inject + HMR, build
|
|
1145
|
+
* extraction into the chunk's `.css` asset). `export const css` is the plate's css as data, for dev
|
|
1146
|
+
* tooling / programmatic consumers that want the string itself — production adapters import only
|
|
1147
|
+
* the default, so Rollup TREE-SHAKES the unused named export out of built chunks (verified
|
|
1148
|
+
* empirically; the side-effect css imports do not pin it).
|
|
1193
1149
|
*
|
|
1194
|
-
* BASE_CSS rides as a JS-level import of the SHARED `virtual:xray/base.css`
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
* the plate's css module so a plate rule still beats a base rule at equal
|
|
1204
|
-
* specificity by source order (the old inline `${BASE_CSS}\n${css}` order).
|
|
1150
|
+
* BASE_CSS rides as a JS-level import of the SHARED `virtual:xray/base.css` module (not an
|
|
1151
|
+
* `@import` inside each plate's css): Vite dedupes JS module imports by id, so the base rules reach
|
|
1152
|
+
* the page exactly once no matter how many plates (nested/stitched included — every plate module
|
|
1153
|
+
* emits this import, module identity collapses them) a page mounts, and at build a multi-chunk app
|
|
1154
|
+
* gets it hoisted/shared rather than copied per chunk. An `@import` inside each plate's `.css`
|
|
1155
|
+
* would instead be INLINED per importer by Vite's postcss-import at build, duplicating BASE_CSS
|
|
1156
|
+
* into every plate's asset — the opposite of the dedupe this design locks in. Base rides before the
|
|
1157
|
+
* plate's css module so a plate rule still beats a base rule at equal specificity by source order
|
|
1158
|
+
* (the `${BASE_CSS}\n${css}` composition order).
|
|
1205
1159
|
*
|
|
1206
1160
|
* @internal
|
|
1207
1161
|
*/
|