@hueest/xray 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{serialize-Bq6yJnNV.js → breakpoints-CMoFUUOv.js} +426 -104
- package/dist/client.d.ts +38 -3
- package/dist/client.js +716 -27
- package/dist/core.d.ts +8 -3
- package/dist/core.js +12 -6
- package/dist/css-escape-N7bOusGW.js +52 -0
- package/dist/hud.js +46 -0
- package/dist/index.d.ts +231 -5
- package/dist/index.js +753 -71
- package/dist/{plate-DboxMKg-.d.ts → plate-Bv6W-GkA.d.ts} +142 -0
- package/dist/plate-DoE1HEXp.js +152 -0
- package/dist/react.core-BzMG_cDy.d.ts +79 -0
- package/dist/react.core-IyFy2b_8.js +267 -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-B--oY2bV.d.ts +143 -0
- package/package.json +7 -7
- package/virtual.d.ts +6 -0
- package/dist/breakpoints-CBNlh7lQ.js +0 -47
- package/dist/plate-BuzRMPx4.js +0 -94
- package/dist/react.core-CqnDjfAJ.js +0 -59
- package/dist/react.core-fgWG_svU.d.ts +0 -21
- package/dist/serialize.d.ts +0 -88
- package/dist/serialize.js +0 -2
package/dist/index.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
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
|
/** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
|
|
6
7
|
const ROOT_ATTR = "data-xr-root";
|
|
8
|
+
/**
|
|
9
|
+
* Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
|
|
10
|
+
* standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
|
|
11
|
+
* just rendering. BASE_CSS reveals such a root instantly (no `--xr-skeleton-delay`,
|
|
12
|
+
* no fade) so the bones do not flash away and back. Never present on a nested
|
|
13
|
+
* stitch (which inherits the parent's reveal and has no `@starting-style` of its
|
|
14
|
+
* own) nor on an ordinary top-level skeleton (which keeps the normal delay).
|
|
15
|
+
*/
|
|
16
|
+
const ROOT_INSTANT_ATTR = "data-xr-instant";
|
|
7
17
|
const SPEC_GATE = 2e7;
|
|
8
18
|
/** Fixed renderer-owned classes every rendered node carries. */
|
|
9
19
|
const ROOT_CLASS = "xr-root";
|
|
@@ -14,10 +24,17 @@ const LEAF_CLASS = "xr-leaf";
|
|
|
14
24
|
opacity property and every leaf reads it. Hundreds of independent infinite
|
|
15
25
|
opacity animations would pin the compositor and cook the CPU. */
|
|
16
26
|
@property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
|
|
27
|
+
/* The reveal channel (ADR 0016): a second inherited opacity factor the leaf
|
|
28
|
+
multiplies into --xr-o. Transitioning THIS (not opacity directly) sidesteps
|
|
29
|
+
the animation-vs-transition collision ADR 0012 flagged. initial-value 1 (NOT
|
|
30
|
+
0) is the degradation floor: without @starting-style the reveal can never be
|
|
31
|
+
driven 0->1, so it must REST at 1 (fully visible). The delay-hide below is
|
|
32
|
+
pure progressive enhancement layered on top. */
|
|
33
|
+
@property --xr-reveal { syntax: "<number>"; inherits: true; initial-value: 1 }
|
|
17
34
|
.${ROOT_CLASS} {
|
|
18
|
-
--xr-bone-highlight: #f4f4f5;
|
|
35
|
+
--xr-bone-highlight-color: #f4f4f5;
|
|
19
36
|
display: contents;
|
|
20
|
-
animation: var(--xr-
|
|
37
|
+
animation: var(--xr-bone-animation, xr-pulse) var(--xr-bone-animation-duration, 1.2s) ease-in-out infinite alternate;
|
|
21
38
|
}
|
|
22
39
|
/* No pointer-events:none — it blocks inspecting bones in devtools and buys
|
|
23
40
|
nothing (no real content to click). border-color: a node may capture
|
|
@@ -25,34 +42,67 @@ const LEAF_CLASS = "xr-leaf";
|
|
|
25
42
|
a captured display:list-item would otherwise paint. */
|
|
26
43
|
.${NODE_CLASS} { border-color: transparent; list-style: none }
|
|
27
44
|
.${LEAF_CLASS} {
|
|
28
|
-
background: var(--xr-fill, var(--xr-bone, #e4e4e7));
|
|
29
|
-
border-radius: var(--xr-radius, 4px);
|
|
30
|
-
opacity
|
|
45
|
+
background: var(--xr-bone-fill, var(--xr-bone-color, #e4e4e7));
|
|
46
|
+
border-radius: var(--xr-bone-border-radius, 4px);
|
|
47
|
+
/* Composed opacity (ADR 0016): the pulse channel times the reveal channel.
|
|
48
|
+
Never transition opacity directly (the pulse animation owns it). */
|
|
49
|
+
opacity: calc(var(--xr-o) * var(--xr-reveal));
|
|
31
50
|
}
|
|
32
51
|
/* Per-kind defaults: text reads as rounded bars, media as blocks. Re-theme any
|
|
33
52
|
kind via [data-xr-root] .${LEAF_CLASS}-text { ... }. */
|
|
34
|
-
.${LEAF_CLASS}-text { border-radius: var(--xr-
|
|
35
|
-
.${LEAF_CLASS}-media { border-radius: var(--xr-
|
|
53
|
+
.${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
|
|
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) {
|
|
@@ -182,7 +232,7 @@ function regimeFor(width, breakpoints) {
|
|
|
182
232
|
* Pure data-in/data-out — runs at plugin load (node) and in tests.
|
|
183
233
|
*/
|
|
184
234
|
function classify(rules, tree, plateName) {
|
|
185
|
-
const classBase = `${CLASS_PREFIX}${
|
|
235
|
+
const classBase = `${CLASS_PREFIX}${encodePlateName(plateName)}-`;
|
|
186
236
|
const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
|
|
187
237
|
const nameForKey = /* @__PURE__ */ new Map();
|
|
188
238
|
const body = /* @__PURE__ */ new Map();
|
|
@@ -256,9 +306,23 @@ function render(node, classes) {
|
|
|
256
306
|
}
|
|
257
307
|
/** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
|
|
258
308
|
const CLASS_PREFIX = "xr-";
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Encode a plate name into a class-token prefix, injectively. This prefix *is*
|
|
311
|
+
* the isolation boundary across stitched plates (ADR 0007), so distinct plate
|
|
312
|
+
* names must never produce the same prefix — a collision would let one plate's
|
|
313
|
+
* `<style>` block reorder another's classes.
|
|
314
|
+
*
|
|
315
|
+
* Plate names are constrained by `isValidPlateName`: each path segment is
|
|
316
|
+
* `\w+(-\w+)*` (word chars with single interior hyphens, no doubled or edge
|
|
317
|
+
* hyphen) and segments are joined by `/`. The slash is the only char that is
|
|
318
|
+
* not class-safe, so rendering it as `--` is enough — and because no segment
|
|
319
|
+
* holds a literal `--` or an edge hyphen, no `-` ever abuts a `/`, so every
|
|
320
|
+
* `--` in the prefix unambiguously came from a `/`, making the map injective.
|
|
321
|
+
* Keeping `/` visually as `--` also leaves nested names readable in the
|
|
322
|
+
* generated CSS (`product--card`).
|
|
323
|
+
*/
|
|
324
|
+
function encodePlateName(name) {
|
|
325
|
+
return name.replace(/\//g, "--");
|
|
262
326
|
}
|
|
263
327
|
/**
|
|
264
328
|
* What the class identity keys on, alongside media + declarations:
|
|
@@ -382,7 +446,8 @@ function remapRules(rules, idMap) {
|
|
|
382
446
|
function addCapture(file, name, capture, breakpoints) {
|
|
383
447
|
const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
|
|
384
448
|
const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
|
|
385
|
-
const
|
|
449
|
+
const { diagnostics: _diagnostics, ...persisted } = capture;
|
|
450
|
+
const views = [...file?.views ?? [], persisted];
|
|
386
451
|
const byView = /* @__PURE__ */ new Map();
|
|
387
452
|
for (const view of views) byView.set(spanKey(view.width), view);
|
|
388
453
|
return {
|
|
@@ -393,6 +458,425 @@ function addCapture(file, name, capture, breakpoints) {
|
|
|
393
458
|
};
|
|
394
459
|
}
|
|
395
460
|
//#endregion
|
|
461
|
+
//#region src/diagnostics.ts
|
|
462
|
+
/**
|
|
463
|
+
* Build a single diagnostic record with its canonical message, for callers
|
|
464
|
+
* outside the walk that surface a diagnostic directly (e.g. the client mapping
|
|
465
|
+
* `CaptureTooLargeError`, or the server reporting an invalid committed plate).
|
|
466
|
+
* Uses the same message table as the collector so the text is identical
|
|
467
|
+
* everywhere. `detail` must never carry user CSS or captured text.
|
|
468
|
+
*/
|
|
469
|
+
function makeDiagnostic(code, options) {
|
|
470
|
+
return {
|
|
471
|
+
code,
|
|
472
|
+
message: DIAGNOSTIC_MESSAGES[code],
|
|
473
|
+
...options?.count !== void 0 ? { count: options.count } : {},
|
|
474
|
+
...options?.detail !== void 0 ? { detail: options.detail } : {}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The human-readable message per code, shared verbatim by browser and server so
|
|
479
|
+
* their text cannot drift. Messages describe the omission and its fidelity
|
|
480
|
+
* cost; none echoes user CSS or captured text. `formatDiagnostic` appends the
|
|
481
|
+
* aggregate count and any short detail.
|
|
482
|
+
*/
|
|
483
|
+
const DIAGNOSTIC_MESSAGES = {
|
|
484
|
+
"unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
|
|
485
|
+
"unreadable-import": "could not read an @import (likely cross-origin); its rules were not captured",
|
|
486
|
+
"skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
|
|
487
|
+
"skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
|
|
488
|
+
"unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
|
|
489
|
+
"dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, script/style/etc.)",
|
|
490
|
+
"pruned-run": "collapsed long runs of similar siblings (a list/grid) to their first few items to keep the plate small",
|
|
491
|
+
"too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
|
|
492
|
+
"invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* The single shared formatter. Renders one diagnostic to a stable one-line
|
|
496
|
+
* string used IDENTICALLY by the browser console and the Vite server output, so
|
|
497
|
+
* the two can never disagree. Shape: `<message> (<count>)[: <detail>]`. Count is
|
|
498
|
+
* omitted when 1 or absent; detail is appended only when present and is never
|
|
499
|
+
* user content.
|
|
500
|
+
*/
|
|
501
|
+
function formatDiagnostic(diagnostic) {
|
|
502
|
+
const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
|
|
503
|
+
const detail = diagnostic.detail !== void 0 ? `: ${diagnostic.detail}` : "";
|
|
504
|
+
return `${diagnostic.message}${count}${detail}`;
|
|
505
|
+
}
|
|
506
|
+
/** The grouped-warning header for a plate's diagnostics, shared by browser and server. */
|
|
507
|
+
function diagnosticsHeader(name) {
|
|
508
|
+
return `[xray] "${name}" captured with reduced fidelity:`;
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/name.ts
|
|
512
|
+
/**
|
|
513
|
+
* Plate-name validation, shared by the Vite plugin (`index.ts`) and the
|
|
514
|
+
* Node-only POST validator (`validate.ts`).
|
|
515
|
+
*
|
|
516
|
+
* Lives in its own dependency-free module so `validate.ts` can reuse it without
|
|
517
|
+
* importing the plugin entry (which would be a cycle), and so the regex stays
|
|
518
|
+
* the single source of truth for what a plate name may be — the same rule the
|
|
519
|
+
* loader, the file watcher, the coverage reader, the POST endpoint, and the
|
|
520
|
+
* ref-graph emitter all gate on.
|
|
521
|
+
*
|
|
522
|
+
* @module
|
|
523
|
+
*/
|
|
524
|
+
/**
|
|
525
|
+
* Names come from import specifiers and POST bodies — keep them inside the plates dir.
|
|
526
|
+
*
|
|
527
|
+
* Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens,
|
|
528
|
+
* never doubled and never at a segment edge — and segments are joined by `/`.
|
|
529
|
+
* The class-prefix encoder (`classify.ts`) renders `/` as `--` to keep nested
|
|
530
|
+
* names readable, and that prefix is the isolation boundary across stitched
|
|
531
|
+
* plates (ADR 0007), so the encoding must be injective. Banning a literal `--`
|
|
532
|
+
* and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
|
|
533
|
+
* prefix came from exactly one `/` — making distinct names always map to
|
|
534
|
+
* distinct prefixes (else e.g. `a/-b` and `a-/b` would both encode to `a---b`).
|
|
535
|
+
*
|
|
536
|
+
* @internal
|
|
537
|
+
*/
|
|
538
|
+
function isValidPlateName(name) {
|
|
539
|
+
return /^\w+(?:-\w+)*(?:\/\w+(?:-\w+)*)*$/.test(name);
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/css.ts
|
|
543
|
+
/**
|
|
544
|
+
* Pure CSS plumbing shared by both extraction strategies: which properties are
|
|
545
|
+
* layout (vs paint), approximate selector specificity for cascade-preserving
|
|
546
|
+
* ordering, and rendering scoped rules back to a single plate CSS string.
|
|
547
|
+
*/
|
|
548
|
+
const EXACT_PROPS = new Set([
|
|
549
|
+
"display",
|
|
550
|
+
"position",
|
|
551
|
+
"float",
|
|
552
|
+
"clear",
|
|
553
|
+
"box-sizing",
|
|
554
|
+
"top",
|
|
555
|
+
"right",
|
|
556
|
+
"bottom",
|
|
557
|
+
"left",
|
|
558
|
+
"width",
|
|
559
|
+
"height",
|
|
560
|
+
"block-size",
|
|
561
|
+
"inline-size",
|
|
562
|
+
"aspect-ratio",
|
|
563
|
+
"order",
|
|
564
|
+
"flex",
|
|
565
|
+
"gap",
|
|
566
|
+
"row-gap",
|
|
567
|
+
"column-gap",
|
|
568
|
+
"line-height",
|
|
569
|
+
"font",
|
|
570
|
+
"font-size",
|
|
571
|
+
"font-family",
|
|
572
|
+
"font-weight",
|
|
573
|
+
"font-style",
|
|
574
|
+
"white-space",
|
|
575
|
+
"text-align",
|
|
576
|
+
"text-indent",
|
|
577
|
+
"vertical-align",
|
|
578
|
+
"letter-spacing",
|
|
579
|
+
"word-spacing",
|
|
580
|
+
"word-break",
|
|
581
|
+
"overflow-wrap",
|
|
582
|
+
"text-wrap",
|
|
583
|
+
"transform",
|
|
584
|
+
"transform-origin",
|
|
585
|
+
"translate",
|
|
586
|
+
"scale",
|
|
587
|
+
"rotate",
|
|
588
|
+
"table-layout",
|
|
589
|
+
"border-collapse",
|
|
590
|
+
"border-spacing",
|
|
591
|
+
"caption-side",
|
|
592
|
+
"column-count",
|
|
593
|
+
"column-width",
|
|
594
|
+
"columns",
|
|
595
|
+
"column-span",
|
|
596
|
+
"column-fill",
|
|
597
|
+
"writing-mode",
|
|
598
|
+
"direction",
|
|
599
|
+
"contain",
|
|
600
|
+
"content-visibility",
|
|
601
|
+
"container",
|
|
602
|
+
"container-type",
|
|
603
|
+
"container-name",
|
|
604
|
+
"visibility"
|
|
605
|
+
]);
|
|
606
|
+
const PATTERN_PROPS = [
|
|
607
|
+
/^(margin|padding)(-(top|right|bottom|left|block|inline))?(-(start|end))?$/,
|
|
608
|
+
/^inset(-(block|inline))?(-(start|end))?$/,
|
|
609
|
+
/^border(-(top|right|bottom|left|block|inline))?(-(start|end))?-(width|style)$/,
|
|
610
|
+
/^border(-(top|bottom)-(left|right))?-radius$/,
|
|
611
|
+
/^border-(start|end)-(start|end)-radius$/,
|
|
612
|
+
/^grid(-|$)/,
|
|
613
|
+
/^flex-/,
|
|
614
|
+
/^(min|max)-(width|height|block-size|inline-size)$/,
|
|
615
|
+
/^(align|justify|place)-(items|content|self)$/,
|
|
616
|
+
/^overflow(-[xy])?$/
|
|
617
|
+
];
|
|
618
|
+
/** Layout properties are the only declarations a plate carries; paint stays out (ADR 0003). */
|
|
619
|
+
function isLayoutProp(prop) {
|
|
620
|
+
if (prop.startsWith("--")) return false;
|
|
621
|
+
if (EXACT_PROPS.has(prop)) return true;
|
|
622
|
+
return PATTERN_PROPS.some((re) => re.test(prop));
|
|
623
|
+
}
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/validate.ts
|
|
626
|
+
/**
|
|
627
|
+
* Structural validation of a posted capture, plus a single-CSS-declaration
|
|
628
|
+
* validator, for the dev `/__xray` endpoint.
|
|
629
|
+
*
|
|
630
|
+
* NODE-ONLY (see the capture-input-validation plan and ADR 0008). This module
|
|
631
|
+
* imports `valibot`, which must NEVER reach the browser bundles: the dev capture
|
|
632
|
+
* client, the HUD, the React adapters, and the framework-neutral `core` surface
|
|
633
|
+
* all ship to a browser, and none of them needs to validate an inbound payload.
|
|
634
|
+
* Only the Vite plugin (`index.ts`, the package `.` entry, built with
|
|
635
|
+
* `platform: 'node'`) imports this file, which keeps valibot out of every
|
|
636
|
+
* browser-facing dist chunk. Do NOT import this from `plate.ts`, `css.ts`, or
|
|
637
|
+
* anything a render/browser path also imports, and do NOT re-export these
|
|
638
|
+
* helpers from any public subpath — they stay private until a `doctor`-style
|
|
639
|
+
* tool creates a concrete external need (the plan's decisions).
|
|
640
|
+
*
|
|
641
|
+
* The endpoint accepts ONLY the payload shape xray itself produces. The dev
|
|
642
|
+
* client always posts a `captureRegime` result, so this schema is calibrated to
|
|
643
|
+
* what that emits — finite ids, the layout declarations `filterLayoutDecls`
|
|
644
|
+
* produces, the gate/fallback/leaf rule tiers, media/container condition stacks
|
|
645
|
+
* — and rejects everything else with a generic error. POST-time validation is
|
|
646
|
+
* necessary but not sufficient: a committed `plates/<name>.json` is also
|
|
647
|
+
* untrusted (hand-edited or from git), so the render path independently escapes
|
|
648
|
+
* raw-text `<style>` terminators (css-escape.ts).
|
|
649
|
+
*
|
|
650
|
+
* @module
|
|
651
|
+
*/
|
|
652
|
+
/**
|
|
653
|
+
* Bounds chosen to be far above any real capture yet small enough that a hostile
|
|
654
|
+
* payload cannot exhaust memory or wedge the merge. A `<Skeleton>` set too high
|
|
655
|
+
* is already caught by the per-capture node guard and the serialized-size cap;
|
|
656
|
+
* these are the structural ceilings the schema enforces before either runs.
|
|
657
|
+
*/
|
|
658
|
+
const MAX_TREE_NODES = 5e4;
|
|
659
|
+
const MAX_RULES = 5e4;
|
|
660
|
+
const MAX_RULE_IDS = 5e4;
|
|
661
|
+
const MAX_DECLS_PER_RULE = 1e3;
|
|
662
|
+
const MAX_CONDITIONS = 64;
|
|
663
|
+
const MAX_BREAKPOINTS = 256;
|
|
664
|
+
const MAX_TREE_DEPTH = 1e3;
|
|
665
|
+
/** A single declaration is `prop: value [!important]`; real ones are short. */
|
|
666
|
+
const MAX_DECL_LENGTH = 2e3;
|
|
667
|
+
/** A media/container condition string, generously bounded. */
|
|
668
|
+
const MAX_CONDITION_LENGTH = 1e3;
|
|
669
|
+
/** Diagnostic strings are dev console text, not plate content; keep them small. */
|
|
670
|
+
const MAX_DIAGNOSTIC_MESSAGE = 1e3;
|
|
671
|
+
const MAX_DIAGNOSTIC_DETAIL = 1e3;
|
|
672
|
+
/**
|
|
673
|
+
* ASCII control characters (C0 range + DEL). Written with escaped code points so
|
|
674
|
+
* this module stays a TEXT file: a literal control byte makes git treat it as
|
|
675
|
+
* binary and hides its lines from `grep`. The class is identical in meaning to
|
|
676
|
+
* the previous literal-byte one (NUL through 0x1F, plus 0x7F).
|
|
677
|
+
*/
|
|
678
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u;
|
|
679
|
+
/**
|
|
680
|
+
* Raw-text / markup terminators that must never appear in generated CSS, even
|
|
681
|
+
* when no bare `<`/`>` is present: HTML comment delimiters, a CDATA opener, and
|
|
682
|
+
* a `</style` raw-text terminator. Shared by the declaration and condition
|
|
683
|
+
* validators (case-insensitive).
|
|
684
|
+
*/
|
|
685
|
+
const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
|
|
686
|
+
/**
|
|
687
|
+
* Accept only a SINGLE CSS declaration of the form `property: value` with an
|
|
688
|
+
* optional trailing `!important`, and only for a layout property the capture
|
|
689
|
+
* path would emit. This is the same allowlist `filterLayoutDecls` applies
|
|
690
|
+
* (`isLayoutProp`), so a posted payload cannot persist paint- or
|
|
691
|
+
* script-adjacent junk the real capture would never produce.
|
|
692
|
+
*
|
|
693
|
+
* Rejects anything that could escape the single-declaration context or the
|
|
694
|
+
* `<style>` raw-text context downstream: rule braces (`{`/`}`), markup angle
|
|
695
|
+
* brackets (`<`/`>`), HTML comment / CDATA delimiters, a `</style` terminator,
|
|
696
|
+
* ASCII control characters, and any extra semicolon-separated declaration. A
|
|
697
|
+
* lone trailing `;` is tolerated (some serializers add one); a second
|
|
698
|
+
* declaration after it is not.
|
|
699
|
+
*/
|
|
700
|
+
function isValidDeclaration(decl) {
|
|
701
|
+
if (typeof decl !== "string") return false;
|
|
702
|
+
if (decl.length === 0 || decl.length > MAX_DECL_LENGTH) return false;
|
|
703
|
+
if (CONTROL_CHARS.test(decl)) return false;
|
|
704
|
+
if (/[{}<>]/.test(decl)) return false;
|
|
705
|
+
if (RAW_TEXT_TERMINATORS.test(decl)) return false;
|
|
706
|
+
const trimmed = decl.trim().replace(/;\s*$/, "");
|
|
707
|
+
if (trimmed.includes(";")) return false;
|
|
708
|
+
const colon = trimmed.indexOf(":");
|
|
709
|
+
if (colon <= 0) return false;
|
|
710
|
+
const prop = trimmed.slice(0, colon).trim().toLowerCase();
|
|
711
|
+
const value = trimmed.slice(colon + 1).trim();
|
|
712
|
+
if (value.length === 0) return false;
|
|
713
|
+
if (value.replace(/\s*!\s*important\s*$/i, "").trim().length === 0) return false;
|
|
714
|
+
return isLayoutProp(prop);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Accept only a media/container condition (prelude) of the shape xray actually
|
|
718
|
+
* emits, and reject anything that could break OUT of the `@media`/`@container`
|
|
719
|
+
* prelude into a top-level rule when `classify` interpolates it RAW into the
|
|
720
|
+
* generated CSS (`@media <condition> { ... }`). The capture path produces:
|
|
721
|
+
* synthesized span conditions `(min-width: 768px)` / `(max-width: 767.98px)`,
|
|
722
|
+
* author media text such as `screen and (min-width: 768px)`,
|
|
723
|
+
* `(orientation: landscape)`, comma media lists `screen, print`, and container
|
|
724
|
+
* queries like `(min-width: 400px)`, a named `sidebar (min-width: 400px)`, or a
|
|
725
|
+
* style query `style(--x: 1)`. Authors may also use Media Queries Level 4 range
|
|
726
|
+
* syntax, which browsers serialize into `mediaText`/`containerQuery` verbatim
|
|
727
|
+
* (`(width >= 600px)`, `(400px < width <= 700px)`), so a condition can legitimately
|
|
728
|
+
* carry a bare `<`/`>` — those are allowed.
|
|
729
|
+
*
|
|
730
|
+
* Rejects: rule braces (`{`/`}`) and a `;` — the CSS-rule breakout vectors —
|
|
731
|
+
* CSS comment delimiters (`/*` / `*\/`), the raw-text/markup terminators
|
|
732
|
+
* (`</style`, `<!--`, `-->`, `<![CDATA[`), ASCII control characters, and any
|
|
733
|
+
* over-length string.
|
|
734
|
+
*/
|
|
735
|
+
function isValidCondition(condition) {
|
|
736
|
+
if (typeof condition !== "string") return false;
|
|
737
|
+
if (condition.length === 0 || condition.length > MAX_CONDITION_LENGTH) return false;
|
|
738
|
+
if (CONTROL_CHARS.test(condition)) return false;
|
|
739
|
+
if (/[{};]/.test(condition)) return false;
|
|
740
|
+
if (condition.includes("/*") || condition.includes("*/")) return false;
|
|
741
|
+
if (RAW_TEXT_TERMINATORS.test(condition)) return false;
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
/** A finite integer id (plate-local node id), bounded to a sane range. */
|
|
745
|
+
const idSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(Number.MAX_SAFE_INTEGER));
|
|
746
|
+
/** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
|
|
747
|
+
const finiteNumber = v.pipe(v.number(), v.finite());
|
|
748
|
+
const conditionList = v.pipe(v.array(v.pipe(v.string(), v.check(isValidCondition, "invalid condition"))), v.maxLength(MAX_CONDITIONS));
|
|
749
|
+
const leafSchema = v.picklist([
|
|
750
|
+
"text",
|
|
751
|
+
"media",
|
|
752
|
+
"box"
|
|
753
|
+
]);
|
|
754
|
+
/**
|
|
755
|
+
* Recursive `PlateNode`. `id` is required (every captured node has one);
|
|
756
|
+
* `leaf`, `ref`, and `kids` are optional and mutually constrained by the tree
|
|
757
|
+
* shape the capture emits. `ref` (a stitch) must name a valid plate. Depth and
|
|
758
|
+
* child-count are bounded so a deeply nested or fanned-out payload cannot blow
|
|
759
|
+
* the stack or memory before the node guard runs.
|
|
760
|
+
*/
|
|
761
|
+
const plateNodeSchema = v.pipe(v.object({
|
|
762
|
+
id: idSchema,
|
|
763
|
+
leaf: v.optional(leafSchema),
|
|
764
|
+
ref: v.optional(v.pipe(v.string(), v.check(isValidPlateName, "invalid stitch ref"))),
|
|
765
|
+
kids: v.optional(v.pipe(v.array(v.lazy(() => plateNodeSchema)), v.maxLength(MAX_TREE_NODES)))
|
|
766
|
+
}), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
|
|
767
|
+
function treeWithinLimits(root) {
|
|
768
|
+
let count = 0;
|
|
769
|
+
const walk = (node, depth) => {
|
|
770
|
+
if (depth > MAX_TREE_DEPTH) return false;
|
|
771
|
+
if (++count > MAX_TREE_NODES) return false;
|
|
772
|
+
if (typeof node !== "object" || node === null) return true;
|
|
773
|
+
const kids = node.kids;
|
|
774
|
+
if (!Array.isArray(kids)) return true;
|
|
775
|
+
for (const kid of kids) if (!walk(kid, depth + 1)) return false;
|
|
776
|
+
return true;
|
|
777
|
+
};
|
|
778
|
+
return walk(root, 0);
|
|
779
|
+
}
|
|
780
|
+
const plateRuleSchema = v.object({
|
|
781
|
+
ids: v.pipe(v.array(idSchema), v.maxLength(MAX_RULE_IDS)),
|
|
782
|
+
decls: v.pipe(v.array(v.pipe(v.string(), v.check(isValidDeclaration, "invalid declaration"))), v.maxLength(MAX_DECLS_PER_RULE)),
|
|
783
|
+
media: v.optional(conditionList),
|
|
784
|
+
container: v.optional(conditionList),
|
|
785
|
+
layered: v.optional(v.boolean()),
|
|
786
|
+
spec: finiteNumber,
|
|
787
|
+
order: finiteNumber
|
|
788
|
+
});
|
|
789
|
+
/**
|
|
790
|
+
* The TRANSIENT, dev-only diagnostics that ride on a posted capture
|
|
791
|
+
* (plate.ts / diagnostics.ts). Validated LOOSELY and kept as a KNOWN key so a
|
|
792
|
+
* valid post is not rejected AND the field survives parsing for the server to
|
|
793
|
+
* log before `addCapture` strips it. `code` is checked against the closed
|
|
794
|
+
* DiagnosticCode set; message/detail are bounded strings, never plate content.
|
|
795
|
+
*/
|
|
796
|
+
const diagnosticCode = v.picklist([
|
|
797
|
+
"unreadable-stylesheet",
|
|
798
|
+
"unreadable-import",
|
|
799
|
+
"skipped-dynamic-pseudo",
|
|
800
|
+
"skipped-pseudo-element",
|
|
801
|
+
"unsupported-rule",
|
|
802
|
+
"dropped-tag",
|
|
803
|
+
"pruned-run",
|
|
804
|
+
"too-large",
|
|
805
|
+
"invalid-plate-file"
|
|
806
|
+
]);
|
|
807
|
+
const diagnosticSchema = v.object({
|
|
808
|
+
code: diagnosticCode,
|
|
809
|
+
message: v.pipe(v.string(), v.maxLength(MAX_DIAGNOSTIC_MESSAGE)),
|
|
810
|
+
count: v.optional(finiteNumber),
|
|
811
|
+
detail: v.optional(v.pipe(v.string(), v.maxLength(MAX_DIAGNOSTIC_DETAIL)))
|
|
812
|
+
});
|
|
813
|
+
/**
|
|
814
|
+
* A `ViewCapture`: a measured width plus an optional `[min, max)` span, the
|
|
815
|
+
* recursive tree, and the rule list. `min < max` when both are present (a
|
|
816
|
+
* zero-width or inverted span is never captured). `diagnostics` is an optional,
|
|
817
|
+
* loosely-validated, transient array preserved through parsing (see above).
|
|
818
|
+
*/
|
|
819
|
+
const viewCaptureSchema = v.pipe(v.object({
|
|
820
|
+
width: finiteNumber,
|
|
821
|
+
min: v.optional(finiteNumber),
|
|
822
|
+
max: v.optional(finiteNumber),
|
|
823
|
+
tree: plateNodeSchema,
|
|
824
|
+
rules: v.pipe(v.array(plateRuleSchema), v.maxLength(MAX_RULES)),
|
|
825
|
+
diagnostics: v.optional(v.pipe(v.array(diagnosticSchema), v.maxLength(MAX_CONDITIONS)))
|
|
826
|
+
}), v.check((c) => c.min === void 0 || c.max === void 0 || c.min < c.max, "capture span min must be below max"));
|
|
827
|
+
/**
|
|
828
|
+
* The full `/__xray` POST body: a valid plate name, the view capture, and the
|
|
829
|
+
* optional union of detected breakpoints (finite positive numbers, deduped and
|
|
830
|
+
* sorted — the same normalization `addCapture` performs, so an unsorted or
|
|
831
|
+
* duplicated post is accepted and normalized rather than rejected).
|
|
832
|
+
*/
|
|
833
|
+
const incomingCaptureSchema = v.object({
|
|
834
|
+
name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
|
|
835
|
+
capture: viewCaptureSchema,
|
|
836
|
+
breakpoints: v.optional(v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS), v.transform((bps) => [...new Set(bps)].toSorted((a, b) => a - b))))
|
|
837
|
+
});
|
|
838
|
+
/**
|
|
839
|
+
* Parse a posted body against the schema. Returns the normalized payload on
|
|
840
|
+
* success or `null` on any structural failure — the caller maps that to a
|
|
841
|
+
* generic `400` and NEVER echoes the payload or its CSS (the plan's error-
|
|
842
|
+
* response decision). Validation throws nothing the caller must format.
|
|
843
|
+
*/
|
|
844
|
+
function parseIncomingCapture(value) {
|
|
845
|
+
const result = v.safeParse(incomingCaptureSchema, value);
|
|
846
|
+
return result.success ? result.output : null;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* The committed `plates/<name>.json` shape (plate.ts). REUSES `viewCaptureSchema`
|
|
850
|
+
* — and therefore the same strict declaration and condition validators — so a
|
|
851
|
+
* file on disk is held to exactly the trust boundary a POST is: committed plates
|
|
852
|
+
* are untrusted (hand-edited or arriving via git), and `classify` interpolates
|
|
853
|
+
* their `decls`/`media`/`container` into generated CSS just the same. `v` must be
|
|
854
|
+
* the current `PLATE_VERSION` and `name` a valid plate name. `breakpoints` are
|
|
855
|
+
* finite non-negative numbers, bounded but NOT renormalized (the file is the
|
|
856
|
+
* source of truth; the merge layer owns ordering). The persisted `ViewCapture`
|
|
857
|
+
* never carries `diagnostics` (stripped before write), and the schema already
|
|
858
|
+
* makes that field optional, so a file without it is accepted.
|
|
859
|
+
*/
|
|
860
|
+
const plateFileSchema = v.object({
|
|
861
|
+
v: v.literal(1),
|
|
862
|
+
name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
|
|
863
|
+
breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
|
|
864
|
+
views: v.pipe(v.array(viewCaptureSchema), v.maxLength(MAX_RULES))
|
|
865
|
+
});
|
|
866
|
+
/**
|
|
867
|
+
* Parse a committed plate file against the SAME schema a POST goes through.
|
|
868
|
+
* Returns the typed `PlateFile` on success or `null` on any structural failure
|
|
869
|
+
* (corrupt JSON object, wrong shape, version mismatch, an injected declaration
|
|
870
|
+
* or media/container condition). The disk readers map `null` to an empty plate
|
|
871
|
+
* and the `invalid-plate-file` diagnostic — they never throw, so a malformed or
|
|
872
|
+
* hostile committed plate degrades to empty rather than breaking the build or
|
|
873
|
+
* smuggling raw CSS through `classify`.
|
|
874
|
+
*/
|
|
875
|
+
function parsePlateFile(value) {
|
|
876
|
+
const result = v.safeParse(plateFileSchema, value);
|
|
877
|
+
return result.success ? result.output : null;
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
396
880
|
//#region src/index.ts
|
|
397
881
|
/**
|
|
398
882
|
* Vite plugin entry point for xray.
|
|
@@ -432,8 +916,11 @@ function xrayVitePlugin(options = {}) {
|
|
|
432
916
|
const settleCap = options.settleCap ?? 5e3;
|
|
433
917
|
const maxNodes = options.maxNodes ?? 4e3;
|
|
434
918
|
const hud = options.hud ?? false;
|
|
919
|
+
const recordMode = options.fixtures?.record ?? "manual";
|
|
920
|
+
const replayMode = options.fixtures?.replay ?? false;
|
|
435
921
|
let root = process.cwd();
|
|
436
922
|
const platePath = (name) => resolve(root, platesDir, `${name}.json`);
|
|
923
|
+
const fixturePath = (name) => resolve(root, platesDir, `${name}.fixture.json`);
|
|
437
924
|
return [{
|
|
438
925
|
name: "xray:data",
|
|
439
926
|
configResolved(config) {
|
|
@@ -460,7 +947,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
460
947
|
transformIndexHtml: {
|
|
461
948
|
order: "pre",
|
|
462
949
|
handler() {
|
|
463
|
-
if (!capture && !hud) return
|
|
950
|
+
if (!capture && !hud && replayMode === false && recordMode === "manual") return;
|
|
464
951
|
return [{
|
|
465
952
|
tag: "script",
|
|
466
953
|
attrs: {
|
|
@@ -476,7 +963,7 @@ function xrayVitePlugin(options = {}) {
|
|
|
476
963
|
},
|
|
477
964
|
load(id) {
|
|
478
965
|
if (id !== RESOLVED_BOOT_ID) return void 0;
|
|
479
|
-
return bootstrapCode(delay, settleCap, maxNodes, hud, capture);
|
|
966
|
+
return bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode);
|
|
480
967
|
},
|
|
481
968
|
configureServer(server) {
|
|
482
969
|
const dir = () => resolve(root, platesDir);
|
|
@@ -495,6 +982,15 @@ function xrayVitePlugin(options = {}) {
|
|
|
495
982
|
res.end(JSON.stringify(readCoverage(dir())));
|
|
496
983
|
return;
|
|
497
984
|
}
|
|
985
|
+
if (req.method === "GET" && req.url === "/fixtures") {
|
|
986
|
+
res.setHeader("content-type", "application/json");
|
|
987
|
+
res.end(JSON.stringify(readFixtures(dir())));
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (req.url === "/fixture") {
|
|
991
|
+
handleFixturePost(req, res, fixturePath);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
498
994
|
handleCapturePost(req, res, server, platePath);
|
|
499
995
|
});
|
|
500
996
|
}
|
|
@@ -504,22 +1000,42 @@ function xrayVitePlugin(options = {}) {
|
|
|
504
1000
|
* The injected dev module: boot the client, POST captures back, optionally
|
|
505
1001
|
* mount the HUD. The client installs first so the HUD finds the light box store.
|
|
506
1002
|
*/
|
|
507
|
-
function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
|
|
1003
|
+
function bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode) {
|
|
508
1004
|
return [
|
|
509
|
-
`import { installXrayClient } from '@hueest/xray/client'`,
|
|
510
|
-
...hud ? [`import { installXrayHud } from '@hueest/xray/hud'`] : [],
|
|
1005
|
+
`import { installXrayClient } from '@hueest/xray/internal/client'`,
|
|
1006
|
+
...hud ? [`import { installXrayHud } from '@hueest/xray/internal/hud'`] : [],
|
|
511
1007
|
"installXrayClient({",
|
|
512
1008
|
` delay: ${delay},`,
|
|
513
1009
|
` settleCap: ${settleCap},`,
|
|
514
1010
|
` maxNodes: ${maxNodes},`,
|
|
515
1011
|
` capture: ${capture},`,
|
|
1012
|
+
` record: ${JSON.stringify(recordMode)},`,
|
|
1013
|
+
` replay: ${JSON.stringify(replayMode)},`,
|
|
1014
|
+
" recordFixtureSink(payload) {",
|
|
1015
|
+
` return fetch('${ENDPOINT}/fixture', {`,
|
|
1016
|
+
" method: 'POST',",
|
|
1017
|
+
" headers: { 'content-type': 'application/json' },",
|
|
1018
|
+
" body: JSON.stringify(payload),",
|
|
1019
|
+
" }).then((response) => {",
|
|
1020
|
+
" if (!response.ok) throw new Error('xray fixture POST failed: ' + response.status)",
|
|
1021
|
+
" })",
|
|
1022
|
+
" },",
|
|
516
1023
|
" sink(payload) {",
|
|
517
|
-
`
|
|
1024
|
+
` return fetch('${ENDPOINT}', {`,
|
|
518
1025
|
" method: 'POST',",
|
|
519
1026
|
" headers: { 'content-type': 'application/json' },",
|
|
520
1027
|
" body: JSON.stringify(payload),",
|
|
1028
|
+
" }).then((response) => {",
|
|
1029
|
+
" if (!response.ok) throw new Error('xray sink POST failed: ' + response.status)",
|
|
521
1030
|
" })",
|
|
522
1031
|
" },",
|
|
1032
|
+
" refreshCoverage() {",
|
|
1033
|
+
` return fetch('${ENDPOINT}/coverage')`,
|
|
1034
|
+
" .then((response) => response.json())",
|
|
1035
|
+
" .then((coverage) => {",
|
|
1036
|
+
" for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
|
|
1037
|
+
" })",
|
|
1038
|
+
" },",
|
|
523
1039
|
"})",
|
|
524
1040
|
...hud ? ["installXrayHud()"] : [],
|
|
525
1041
|
`void fetch('${ENDPOINT}/coverage')`,
|
|
@@ -528,6 +1044,10 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
|
|
|
528
1044
|
" for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
|
|
529
1045
|
" })",
|
|
530
1046
|
" .catch(() => {})",
|
|
1047
|
+
`void fetch('${ENDPOINT}/fixtures')`,
|
|
1048
|
+
" .then((response) => response.json())",
|
|
1049
|
+
" .then((fixtures) => { globalThis.__XRAY__?.fixtures?.seed?.(fixtures) })",
|
|
1050
|
+
" .catch(() => {})",
|
|
531
1051
|
"if (import.meta.hot) {",
|
|
532
1052
|
" import.meta.hot.on('xray:plate', (data) => {",
|
|
533
1053
|
" globalThis.__XRAY__?.updatePlate?.(data.name, data.plate)",
|
|
@@ -537,35 +1057,40 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
|
|
|
537
1057
|
""
|
|
538
1058
|
].join("\n");
|
|
539
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Print a plate's diagnostics to the Vite server output using the SAME grouped
|
|
1062
|
+
* header + shared formatter the browser console uses (diagnostics.ts), so the
|
|
1063
|
+
* two can never drift. Prefers Vite's logger (it preserves the formatting);
|
|
1064
|
+
* falls back to `console.warn`. No-op when there are no diagnostics.
|
|
1065
|
+
*/
|
|
1066
|
+
function reportServerDiagnostics(server, name, diagnostics) {
|
|
1067
|
+
if (!diagnostics || diagnostics.length === 0) return;
|
|
1068
|
+
const text = [diagnosticsHeader(name), ...diagnostics.map((d) => formatDiagnostic(d))].join("\n");
|
|
1069
|
+
const logger = server.config?.logger;
|
|
1070
|
+
if (logger) logger.warn(text);
|
|
1071
|
+
else console.warn(text);
|
|
1072
|
+
}
|
|
540
1073
|
/** A committed plate file past this is almost always a `<Skeleton>` set too high. */
|
|
541
1074
|
const MAX_PLATE_BYTES = 2e6;
|
|
1075
|
+
/**
|
|
1076
|
+
* Hard ceiling on the POST body we will buffer. A single posted capture is one
|
|
1077
|
+
* view (smaller than the accumulated plate file the `MAX_PLATE_BYTES` cap
|
|
1078
|
+
* guards), so this sits above the plate cap with headroom and exists only to
|
|
1079
|
+
* stop an oversized or runaway body from being read into memory. Enforced both
|
|
1080
|
+
* up front from `content-length` and while streaming, so a lying or absent
|
|
1081
|
+
* `content-length` cannot get past it. The dev endpoint is local and
|
|
1082
|
+
* unauthenticated, so this is the real backstop, not the header.
|
|
1083
|
+
*/
|
|
1084
|
+
const MAX_BODY_BYTES = 4e6;
|
|
1085
|
+
/** The only content type the capture client posts; anything else is rejected up front. */
|
|
1086
|
+
function isJsonContentType(value) {
|
|
1087
|
+
const header = Array.isArray(value) ? value[0] : value;
|
|
1088
|
+
if (typeof header !== "string") return false;
|
|
1089
|
+
return /^application\/json\b/i.test(header.trim());
|
|
1090
|
+
}
|
|
542
1091
|
function isRecord(value) {
|
|
543
1092
|
return typeof value === "object" && value !== null;
|
|
544
1093
|
}
|
|
545
|
-
function isNumberArray(value) {
|
|
546
|
-
return Array.isArray(value) && value.every((item) => typeof item === "number");
|
|
547
|
-
}
|
|
548
|
-
function isStringArray(value) {
|
|
549
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
550
|
-
}
|
|
551
|
-
function isLeafKind(value) {
|
|
552
|
-
return value === "text" || value === "media" || value === "box";
|
|
553
|
-
}
|
|
554
|
-
function isPlateNode(value) {
|
|
555
|
-
return isRecord(value) && typeof value.id === "number" && (!("leaf" in value) || isLeafKind(value.leaf)) && (!("ref" in value) || typeof value.ref === "string") && (!("kids" in value) || Array.isArray(value.kids) && value.kids.every(isPlateNode));
|
|
556
|
-
}
|
|
557
|
-
function isPlateRule(value) {
|
|
558
|
-
return isRecord(value) && isNumberArray(value.ids) && isStringArray(value.decls) && typeof value.spec === "number" && typeof value.order === "number" && (!("media" in value) || isStringArray(value.media)) && (!("container" in value) || isStringArray(value.container)) && (!("layered" in value) || typeof value.layered === "boolean");
|
|
559
|
-
}
|
|
560
|
-
function isViewCapture(value) {
|
|
561
|
-
return isRecord(value) && typeof value.width === "number" && (!("min" in value) || typeof value.min === "number") && (!("max" in value) || typeof value.max === "number") && isPlateNode(value.tree) && Array.isArray(value.rules) && value.rules.every(isPlateRule);
|
|
562
|
-
}
|
|
563
|
-
function isPlateFile(value) {
|
|
564
|
-
return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && isNumberArray(value.breakpoints) && Array.isArray(value.views) && value.views.every(isViewCapture);
|
|
565
|
-
}
|
|
566
|
-
function isIncomingCapture(value) {
|
|
567
|
-
return isRecord(value) && typeof value.name === "string" && isViewCapture(value.capture) && (!("breakpoints" in value) || isNumberArray(value.breakpoints));
|
|
568
|
-
}
|
|
569
1094
|
/** Send the merged plate (and its disk coverage) over the HMR event so the
|
|
570
1095
|
* client hot-swaps it in place and the HUD updates its band display. */
|
|
571
1096
|
function announcePlate(ws, name, plateFile) {
|
|
@@ -620,6 +1145,123 @@ function readCoverage(dir) {
|
|
|
620
1145
|
}
|
|
621
1146
|
return out;
|
|
622
1147
|
}
|
|
1148
|
+
/** The on-disk Fixture sidecar shape (ADR 0015): version, plate name, and the
|
|
1149
|
+
* OPAQUE devalue string. The node side never parses `data` — it is the browser
|
|
1150
|
+
* dev client's job (it has the live runtime). */
|
|
1151
|
+
const FIXTURE_VERSION = 1;
|
|
1152
|
+
const FIXTURE_SUFFIX = ".fixture.json";
|
|
1153
|
+
/** A recorded Fixture sidecar — `data` is the verbatim devalue string, treated
|
|
1154
|
+
* as opaque text server-side. */
|
|
1155
|
+
function isFixtureFile(value) {
|
|
1156
|
+
return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && typeof value.data === "string";
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* All recorded Fixtures as `{ <name>: <devalueString> }`, read from the
|
|
1160
|
+
* `<name>.fixture.json` sidecars (ADR 0015). The map values are the OPAQUE
|
|
1161
|
+
* devalue strings exactly as written — the node side never parses them; the
|
|
1162
|
+
* browser dev client owns the devalue decode. The `.fixture.json` suffix keeps
|
|
1163
|
+
* these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
|
|
1164
|
+
* `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
|
|
1165
|
+
*
|
|
1166
|
+
* @internal
|
|
1167
|
+
*/
|
|
1168
|
+
function readFixtures(dir) {
|
|
1169
|
+
const out = {};
|
|
1170
|
+
let entries;
|
|
1171
|
+
try {
|
|
1172
|
+
entries = readdirSync(dir, {
|
|
1173
|
+
recursive: true,
|
|
1174
|
+
encoding: "utf8"
|
|
1175
|
+
});
|
|
1176
|
+
} catch {
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
for (const entry of entries) {
|
|
1180
|
+
const rel = entry.replaceAll("\\", "/");
|
|
1181
|
+
if (!rel.endsWith(FIXTURE_SUFFIX)) continue;
|
|
1182
|
+
const name = rel.slice(0, -13);
|
|
1183
|
+
if (!isValidPlateName(name)) continue;
|
|
1184
|
+
let raw;
|
|
1185
|
+
try {
|
|
1186
|
+
raw = readFileSync(resolve(dir, rel), "utf8");
|
|
1187
|
+
} catch {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
const parsed = JSON.parse(raw);
|
|
1192
|
+
if (isFixtureFile(parsed) && parsed.v === FIXTURE_VERSION) out[name] = parsed.data;
|
|
1193
|
+
} catch {}
|
|
1194
|
+
}
|
|
1195
|
+
return out;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's
|
|
1199
|
+
* front-door checks (method, content type, declared/streamed size cap) and the
|
|
1200
|
+
* `isValidPlateName` gate, but treats the devalue `data` payload as OPAQUE TEXT:
|
|
1201
|
+
* it is validated only as a string and written verbatim, never eval'd or parsed
|
|
1202
|
+
* server-side. The sidecar lives NEXT TO the Plate but is never merged into it,
|
|
1203
|
+
* so the persisted Plate stays purely structural.
|
|
1204
|
+
*
|
|
1205
|
+
* @internal
|
|
1206
|
+
*/
|
|
1207
|
+
function handleFixturePost(req, res, fixturePath) {
|
|
1208
|
+
if (req.method !== "POST") {
|
|
1209
|
+
res.statusCode = 405;
|
|
1210
|
+
res.end();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (!isJsonContentType(req.headers?.["content-type"])) {
|
|
1214
|
+
res.statusCode = 415;
|
|
1215
|
+
res.end("unsupported media type");
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
|
|
1219
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
1220
|
+
res.statusCode = 413;
|
|
1221
|
+
res.end("payload too large");
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
let body = "";
|
|
1225
|
+
let bytes = 0;
|
|
1226
|
+
let aborted = false;
|
|
1227
|
+
req.on("data", (chunk) => {
|
|
1228
|
+
if (aborted) return;
|
|
1229
|
+
const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
1230
|
+
bytes += Buffer.byteLength(text);
|
|
1231
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
1232
|
+
aborted = true;
|
|
1233
|
+
res.statusCode = 413;
|
|
1234
|
+
res.end("payload too large");
|
|
1235
|
+
req.destroy?.();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
body += text;
|
|
1239
|
+
});
|
|
1240
|
+
req.on("end", () => {
|
|
1241
|
+
if (aborted) return;
|
|
1242
|
+
try {
|
|
1243
|
+
const raw = JSON.parse(body);
|
|
1244
|
+
if (!isRecord(raw) || typeof raw.name !== "string" || !isValidPlateName(raw.name) || typeof raw.data !== "string") {
|
|
1245
|
+
res.statusCode = 400;
|
|
1246
|
+
res.end("invalid fixture payload");
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const file = fixturePath(raw.name);
|
|
1250
|
+
const sidecar = {
|
|
1251
|
+
v: FIXTURE_VERSION,
|
|
1252
|
+
name: raw.name,
|
|
1253
|
+
data: raw.data
|
|
1254
|
+
};
|
|
1255
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1256
|
+
writeFileSync(file, `${JSON.stringify(sidecar, null, 1)}\n`);
|
|
1257
|
+
res.statusCode = 204;
|
|
1258
|
+
res.end();
|
|
1259
|
+
} catch {
|
|
1260
|
+
res.statusCode = 400;
|
|
1261
|
+
res.end("invalid fixture payload");
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
623
1265
|
/**
|
|
624
1266
|
* Fold a posted capture into its plate file and hot-swap the plate in place.
|
|
625
1267
|
*
|
|
@@ -631,20 +1273,46 @@ function handleCapturePost(req, res, server, platePath) {
|
|
|
631
1273
|
res.end();
|
|
632
1274
|
return;
|
|
633
1275
|
}
|
|
1276
|
+
if (!isJsonContentType(req.headers?.["content-type"])) {
|
|
1277
|
+
res.statusCode = 415;
|
|
1278
|
+
res.end("unsupported media type");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
|
|
1282
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
1283
|
+
res.statusCode = 413;
|
|
1284
|
+
res.end("payload too large");
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
634
1287
|
let body = "";
|
|
1288
|
+
let bytes = 0;
|
|
1289
|
+
let aborted = false;
|
|
635
1290
|
req.on("data", (chunk) => {
|
|
636
|
-
|
|
1291
|
+
if (aborted) return;
|
|
1292
|
+
const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
1293
|
+
bytes += Buffer.byteLength(text);
|
|
1294
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
1295
|
+
aborted = true;
|
|
1296
|
+
res.statusCode = 413;
|
|
1297
|
+
res.end("payload too large");
|
|
1298
|
+
req.destroy?.();
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
body += text;
|
|
637
1302
|
});
|
|
638
1303
|
req.on("end", () => {
|
|
1304
|
+
if (aborted) return;
|
|
639
1305
|
try {
|
|
640
|
-
const payload = JSON.parse(body);
|
|
641
|
-
if (!
|
|
1306
|
+
const payload = parseIncomingCapture(JSON.parse(body));
|
|
1307
|
+
if (!payload) {
|
|
642
1308
|
res.statusCode = 400;
|
|
643
1309
|
res.end("invalid capture payload");
|
|
644
1310
|
return;
|
|
645
1311
|
}
|
|
1312
|
+
reportServerDiagnostics(server, payload.name, payload.capture.diagnostics);
|
|
646
1313
|
const file = platePath(payload.name);
|
|
647
|
-
const existing =
|
|
1314
|
+
const { plateFile: existing, invalid } = loadPlateFile(file);
|
|
1315
|
+
if (invalid) reportServerDiagnostics(server, payload.name, [makeDiagnostic("invalid-plate-file", { detail: `${payload.name}.json` })]);
|
|
648
1316
|
const next = addCapture(existing, payload.name, payload.capture, payload.breakpoints ?? []);
|
|
649
1317
|
const serialized = `${JSON.stringify(next, null, 1)}\n`;
|
|
650
1318
|
if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
|
|
@@ -663,33 +1331,47 @@ function handleCapturePost(req, res, server, platePath) {
|
|
|
663
1331
|
announcePlate(server.ws, payload.name, next);
|
|
664
1332
|
res.statusCode = 204;
|
|
665
1333
|
res.end();
|
|
666
|
-
} catch
|
|
1334
|
+
} catch {
|
|
667
1335
|
res.statusCode = 400;
|
|
668
|
-
res.end(
|
|
1336
|
+
res.end("invalid capture payload");
|
|
669
1337
|
}
|
|
670
1338
|
});
|
|
671
1339
|
}
|
|
1340
|
+
function readPlateFile(file) {
|
|
1341
|
+
return loadPlateFile(file).plateFile;
|
|
1342
|
+
}
|
|
672
1343
|
/**
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
1344
|
+
* Read and validate a committed plate file, distinguishing "no file" from
|
|
1345
|
+
* "file present but unreadable". `invalid` is true ONLY when the file exists
|
|
1346
|
+
* but is corrupt JSON, the wrong shape, or a version mismatch — a missing file
|
|
1347
|
+
* is the normal pre-capture state and is not flagged. Callers use `invalid` to
|
|
1348
|
+
* warn (same diagnostics vocabulary) that an existing plate was ignored.
|
|
676
1349
|
*/
|
|
677
|
-
function
|
|
678
|
-
return /^[\w-]+(\/[\w-]+)*$/.test(name);
|
|
679
|
-
}
|
|
680
|
-
function readPlateFile(file) {
|
|
1350
|
+
function loadPlateFile(file) {
|
|
681
1351
|
let raw;
|
|
682
1352
|
try {
|
|
683
1353
|
raw = readFileSync(file, "utf8");
|
|
684
1354
|
} catch {
|
|
685
|
-
return
|
|
1355
|
+
return {
|
|
1356
|
+
plateFile: null,
|
|
1357
|
+
invalid: false
|
|
1358
|
+
};
|
|
686
1359
|
}
|
|
687
1360
|
try {
|
|
688
|
-
const plateFile = JSON.parse(raw);
|
|
689
|
-
if (!
|
|
690
|
-
|
|
1361
|
+
const plateFile = parsePlateFile(JSON.parse(raw));
|
|
1362
|
+
if (!plateFile) return {
|
|
1363
|
+
plateFile: null,
|
|
1364
|
+
invalid: true
|
|
1365
|
+
};
|
|
1366
|
+
return {
|
|
1367
|
+
plateFile,
|
|
1368
|
+
invalid: false
|
|
1369
|
+
};
|
|
691
1370
|
} catch {
|
|
692
|
-
return
|
|
1371
|
+
return {
|
|
1372
|
+
plateFile: null,
|
|
1373
|
+
invalid: true
|
|
1374
|
+
};
|
|
693
1375
|
}
|
|
694
1376
|
}
|
|
695
1377
|
/**
|
|
@@ -723,4 +1405,4 @@ function renderPlateModule(merged) {
|
|
|
723
1405
|
return `${refs.map((name, i) => `import __xr${i} from ${JSON.stringify(VIRTUAL_PREFIX + name)}`).join("\n")}\nexport default Object.assign(${json}, { refs: { ${refs.map((name, i) => `${JSON.stringify(name)}: __xr${i}`).join(", ")} } })`;
|
|
724
1406
|
}
|
|
725
1407
|
//#endregion
|
|
726
|
-
export { addCapture, collectRefs, handleCapturePost, isValidPlateName, mergeViews, readCoverage, renderPlateModule, xrayVitePlugin };
|
|
1408
|
+
export { addCapture, collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, mergeViews, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };
|