@hueest/xray 0.1.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/index.js ADDED
@@ -0,0 +1,726 @@
1
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, relative, resolve } from "node:path";
3
+ /** App code imports plates from here, e.g. `import plate from 'virtual:xray/plates/my-banner'`. */
4
+ const VIRTUAL_PREFIX = "virtual:xray/plates/";
5
+ /** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
6
+ const ROOT_ATTR = "data-xr-root";
7
+ const SPEC_GATE = 2e7;
8
+ /** Fixed renderer-owned classes every rendered node carries. */
9
+ const ROOT_CLASS = "xr-root";
10
+ const NODE_CLASS = "xr-node";
11
+ const LEAF_CLASS = "xr-leaf";
12
+ `
13
+ /* One timeline per skeleton, not per leaf: the root animates one inherited
14
+ opacity property and every leaf reads it. Hundreds of independent infinite
15
+ opacity animations would pin the compositor and cook the CPU. */
16
+ @property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
17
+ .${ROOT_CLASS} {
18
+ --xr-bone-highlight: #f4f4f5;
19
+ display: contents;
20
+ animation: var(--xr-anim, xr-pulse) var(--xr-duration, 1.2s) ease-in-out infinite alternate;
21
+ }
22
+ /* No pointer-events:none — it blocks inspecting bones in devtools and buys
23
+ nothing (no real content to click). border-color: a node may capture
24
+ border-width for layout; its paint stays out. list-style: suppress a ::marker
25
+ a captured display:list-item would otherwise paint. */
26
+ .${NODE_CLASS} { border-color: transparent; list-style: none }
27
+ .${LEAF_CLASS} {
28
+ background: var(--xr-fill, var(--xr-bone, #e4e4e7));
29
+ border-radius: var(--xr-radius, 4px);
30
+ opacity: var(--xr-o);
31
+ }
32
+ /* Per-kind defaults: text reads as rounded bars, media as blocks. Re-theme any
33
+ kind via [data-xr-root] .${LEAF_CLASS}-text { ... }. */
34
+ .${LEAF_CLASS}-text { border-radius: var(--xr-radius-text, 999px) }
35
+ .${LEAF_CLASS}-media { border-radius: var(--xr-radius-media, var(--xr-radius, 4px)) }
36
+ @keyframes xr-pulse {
37
+ from { --xr-o: var(--xr-pulse-max, 1) }
38
+ to { --xr-o: var(--xr-pulse-min, 0.5) }
39
+ }
40
+ @media (prefers-reduced-motion: reduce) { .${ROOT_CLASS} { animation: none } }
41
+ /* Dark + high-contrast bone defaults. Guarded: light-dark() is an invalid value
42
+ without support and would drop the declaration — see the note above; remove
43
+ this @supports once widely available (2026-11-13). */
44
+ @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))) }
47
+ @media (prefers-contrast: more) {
48
+ .${LEAF_CLASS} { background: var(--xr-fill, var(--xr-bone, light-dark(#d4d4d8, #52525b))) }
49
+ }
50
+ }
51
+ /* Derive the sheen highlight from --xr-bone so re-theming one token updates both.
52
+ Relative color isn't widely available yet; the static highlight above is the
53
+ fallback (sheen just looks flatter), so no hard guard is needed. */
54
+ @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) }
56
+ }`.trim();
57
+ /** The (unserialized) plate an import resolves to before anything has been captured. */
58
+ function emptyPlate(name) {
59
+ return {
60
+ v: 1,
61
+ name,
62
+ tree: null,
63
+ css: ""
64
+ };
65
+ }
66
+ //#endregion
67
+ //#region src/chunk.ts
68
+ /**
69
+ * Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR
70
+ * 0008). This is the framework-neutral half of rendering: a skeleton is static,
71
+ * text-free DOM, so the structure is flattened to HTML strings here, at build,
72
+ * once — instead of every adapter walking a tree on the latency-critical first
73
+ * paint. The adapter only injects the strings and mounts a child plate at each
74
+ * stitch (see the React adapters). No escaping risk: bones carry only the
75
+ * content-addressed class tokens (ADR 0007), never user content.
76
+ */
77
+ function nodeClass(node) {
78
+ return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
79
+ }
80
+ /**
81
+ * Whether this subtree holds a stitch (`ref`) anywhere — a stitched subtree
82
+ * can't collapse to a flat string, since a `ref` resolves to a child plate at
83
+ * render time and must stay a mount point.
84
+ */
85
+ function hasStitch(node) {
86
+ return node.ref !== void 0 || (node.kids?.some(hasStitch) ?? false);
87
+ }
88
+ /** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
89
+ function toHtml(node) {
90
+ const kids = node.kids ? node.kids.map(toHtml).join("") : "";
91
+ return `<div class="${nodeClass(node)}">${kids}</div>`;
92
+ }
93
+ /**
94
+ * Serialize a node list into chunks: each maximal stitch-free run becomes one
95
+ * HTML `string`; a `ref` node becomes `{ r }`; a node that isn't itself a stitch
96
+ * but holds one deeper stays a real element (`{ c, k }`) and recurses. Mirrors
97
+ * the element shape the runtime renderer would otherwise build per node.
98
+ */
99
+ function toChunks(nodes) {
100
+ const out = [];
101
+ let buf = "";
102
+ const flush = () => {
103
+ if (buf) {
104
+ out.push(buf);
105
+ buf = "";
106
+ }
107
+ };
108
+ for (const node of nodes) {
109
+ if (!hasStitch(node)) {
110
+ buf += toHtml(node);
111
+ continue;
112
+ }
113
+ flush();
114
+ if (node.ref !== void 0) out.push({ r: node.ref });
115
+ else out.push({
116
+ c: nodeClass(node),
117
+ k: toChunks(node.kids ?? [])
118
+ });
119
+ }
120
+ flush();
121
+ return out;
122
+ }
123
+ /**
124
+ * Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The
125
+ * root's own content classes travel as `rootCls` (the adapter puts them on the
126
+ * root element); its children become `chunks`. A capture-less plate has null
127
+ * chunks and renders the fallback.
128
+ */
129
+ function serializePlate(merged) {
130
+ const { v, name, tree, css } = merged;
131
+ if (!tree) return {
132
+ v,
133
+ name,
134
+ chunks: null,
135
+ css
136
+ };
137
+ const chunks = toChunks(tree.kids ?? []);
138
+ return tree.cls ? {
139
+ v,
140
+ name,
141
+ chunks,
142
+ rootCls: tree.cls,
143
+ css
144
+ } : {
145
+ v,
146
+ name,
147
+ chunks,
148
+ css
149
+ };
150
+ }
151
+ //#endregion
152
+ //#region src/breakpoints.ts
153
+ /** The view interval `[min, max)` a given viewport width falls into. */
154
+ function regimeFor(width, breakpoints) {
155
+ let min;
156
+ let max;
157
+ for (const bp of breakpoints) if (bp <= width) min = bp;
158
+ else {
159
+ max = bp;
160
+ break;
161
+ }
162
+ return {
163
+ ...min === void 0 ? {} : { min },
164
+ ...max === void 0 ? {} : { max }
165
+ };
166
+ }
167
+ //#endregion
168
+ //#region src/classify.ts
169
+ /**
170
+ * Turn id-form rules + an id-tree into the shipped plate: each distinct
171
+ * `(tier, media, declarations)` becomes one class, named by a per-plate
172
+ * sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
173
+ * class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
174
+ * not a content hash: the plate prefix already keeps tokens disjoint across plates
175
+ * (the stitch-isolation guarantee), so a per-plate counter is collision-free by
176
+ * construction — and low-entropy ids compress far better than random hashes
177
+ * (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
178
+ * unlayered; the existing cascade is reproduced by emission order (losers
179
+ * first), so the classes are emitted in `(layered, spec, order)` order exactly
180
+ * as `renderRules` did.
181
+ *
182
+ * Pure data-in/data-out — runs at plugin load (node) and in tests.
183
+ */
184
+ function classify(rules, tree, plateName) {
185
+ const classBase = `${CLASS_PREFIX}${cssSafe(plateName)}-`;
186
+ const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
187
+ const nameForKey = /* @__PURE__ */ new Map();
188
+ const body = /* @__PURE__ */ new Map();
189
+ const usage = /* @__PURE__ */ new Map();
190
+ const emitOrder = [];
191
+ const nodeClasses = /* @__PURE__ */ new Map();
192
+ for (const rule of sorted) {
193
+ if (rule.decls.length === 0) continue;
194
+ const decls = rule.decls.join("; ");
195
+ const media = rule.media ?? [];
196
+ const container = rule.container ?? [];
197
+ const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
198
+ const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
199
+ let name = nameForKey.get(key);
200
+ if (name === void 0) {
201
+ name = classBase + nameForKey.size.toString(36);
202
+ nameForKey.set(key, name);
203
+ body.set(name, {
204
+ media,
205
+ container,
206
+ decls
207
+ });
208
+ usage.set(name, {
209
+ root: false,
210
+ desc: false
211
+ });
212
+ emitOrder.push(name);
213
+ }
214
+ const ids = rule.ids.length === 0 ? [0] : rule.ids;
215
+ for (const id of ids) {
216
+ const u = usage.get(name);
217
+ if (!u) throw new Error(`missing usage entry for ${name}`);
218
+ if (id === 0) u.root = true;
219
+ else u.desc = true;
220
+ let list = nodeClasses.get(id);
221
+ if (!list) {
222
+ list = [];
223
+ nodeClasses.set(id, list);
224
+ }
225
+ if (!list.includes(name)) list.push(name);
226
+ }
227
+ }
228
+ const lines = [];
229
+ for (const name of emitOrder) {
230
+ const item = body.get(name);
231
+ const u = usage.get(name);
232
+ if (!item || !u) throw new Error(`missing class body for ${name}`);
233
+ const { media, container, decls } = item;
234
+ const sels = [];
235
+ if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
236
+ if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
237
+ let text = `${sels.join(", ")} { ${decls} }`;
238
+ for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
239
+ for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
240
+ lines.push(text);
241
+ }
242
+ return {
243
+ tree: render(tree, nodeClasses),
244
+ css: lines.join("\n")
245
+ };
246
+ }
247
+ /** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
248
+ function render(node, classes) {
249
+ const out = {};
250
+ if (node.leaf) out.leaf = node.leaf;
251
+ if (node.ref !== void 0) out.ref = node.ref;
252
+ const cls = classes.get(node.id);
253
+ if (cls && cls.length > 0) out.cls = cls.join(" ");
254
+ if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
255
+ return out;
256
+ }
257
+ /** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
258
+ const CLASS_PREFIX = "xr-";
259
+ /** Make a plate name safe to embed in a class token (plate names are already constrained). */
260
+ function cssSafe(name) {
261
+ return name.replace(/[^\w-]/g, "_");
262
+ }
263
+ /**
264
+ * What the class identity keys on, alongside media + declarations:
265
+ * - `'tier'`: the discrete cascade tier — smaller (more bodies dedupe), but a
266
+ * rare same-tier inversion within the continuous `author` band can mis-order
267
+ * one property on one node (ADR 0007). The default.
268
+ * - `'full'`: the exact `spec` — provably no inversion, marginally larger.
269
+ * One-line flip if QA ever surfaces a mis-order.
270
+ */
271
+ const SPEC_FOLD = "full";
272
+ function tierLabel(spec) {
273
+ if (spec <= -2) return "ctx";
274
+ if (spec === -1) return "fb";
275
+ if (spec >= 2e7) return "gate";
276
+ if (spec >= 1e7) return "inl";
277
+ if (spec >= 9e6) return "leaf";
278
+ return "au";
279
+ }
280
+ //#endregion
281
+ //#region src/merge.ts
282
+ /**
283
+ * Turn the per-view captures in a plate file into the one plate a Skeleton
284
+ * renders.
285
+ *
286
+ * Each view is kept whole — its own subtree, its own ids, its own rules —
287
+ * and shown only across the viewport range it owns, via `@media display:none`
288
+ * gates. We do NOT merge the views' trees into one. An earlier design (ADR
289
+ * 0004) did, sharing common structure and forking the rest, but aligning two
290
+ * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
291
+ * stable keys proved unreliable: every heuristic mis-paired some nodes and
292
+ * remapped one view's rules onto another's elements, corrupting both. A lone
293
+ * capture is pixel-faithful; keeping each view lone preserves that. The cost
294
+ * is a larger plate (roughly the sum of the views) — bounded by the plate
295
+ * size cap — in exchange for correctness.
296
+ *
297
+ * Pure data-in/data-out — runs in node (plugin load) and in tests.
298
+ */
299
+ function mergeViews(file) {
300
+ const views = file.views;
301
+ if (views.length === 0) return emptyPlate(file.name);
302
+ if (views.length === 1) {
303
+ const [only] = views;
304
+ if (!only) return emptyPlate(file.name);
305
+ const { tree, css } = classify(only.rules, only.tree, file.name);
306
+ return {
307
+ v: 1,
308
+ name: file.name,
309
+ tree,
310
+ css
311
+ };
312
+ }
313
+ const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
314
+ let nextId = 1;
315
+ const rootKids = [];
316
+ const rules = [];
317
+ views.forEach((view, i) => {
318
+ const idMap = /* @__PURE__ */ new Map();
319
+ const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
320
+ rootKids.push(...kids);
321
+ rules.push(...remapRules(view.rules, idMap));
322
+ const lo = i === 0 ? 0 : starts[i] ?? 0;
323
+ const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
324
+ for (const kid of kids) {
325
+ if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
326
+ if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
327
+ }
328
+ });
329
+ const { tree, css } = classify(rules, {
330
+ id: 0,
331
+ kids: rootKids
332
+ }, file.name);
333
+ return {
334
+ v: 1,
335
+ name: file.name,
336
+ tree,
337
+ css
338
+ };
339
+ }
340
+ function gate(id, condition) {
341
+ return {
342
+ ids: [id],
343
+ decls: ["display: none !important"],
344
+ media: [condition],
345
+ spec: SPEC_GATE,
346
+ order: id
347
+ };
348
+ }
349
+ /** Deep-clone a captured subtree into fresh ids, recording the mapping. */
350
+ function cloneWithIds(node, idMap, allocate) {
351
+ const id = allocate();
352
+ idMap.set(node.id, id);
353
+ const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
354
+ return {
355
+ id,
356
+ ...node.leaf ? { leaf: node.leaf } : {},
357
+ ...node.ref !== void 0 ? { ref: node.ref } : {},
358
+ ...kids ? { kids } : {}
359
+ };
360
+ }
361
+ /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
362
+ function collectRefs(tree) {
363
+ const names = [];
364
+ const seen = /* @__PURE__ */ new Set();
365
+ const visit = (node) => {
366
+ if (node.ref !== void 0 && !seen.has(node.ref)) {
367
+ seen.add(node.ref);
368
+ names.push(node.ref);
369
+ }
370
+ for (const kid of node.kids ?? []) visit(kid);
371
+ };
372
+ if (tree) visit(tree);
373
+ return names;
374
+ }
375
+ function remapRules(rules, idMap) {
376
+ return rules.map((rule) => ({
377
+ ...rule,
378
+ ids: rule.ids.map((id) => idMap.get(id) ?? id)
379
+ }));
380
+ }
381
+ /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
382
+ function addCapture(file, name, capture, breakpoints) {
383
+ const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
384
+ const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
385
+ const views = [...file?.views ?? [], capture];
386
+ const byView = /* @__PURE__ */ new Map();
387
+ for (const view of views) byView.set(spanKey(view.width), view);
388
+ return {
389
+ v: 1,
390
+ name,
391
+ breakpoints: allBreakpoints,
392
+ views: [...byView.values()].toSorted((a, b) => a.width - b.width)
393
+ };
394
+ }
395
+ //#endregion
396
+ //#region src/index.ts
397
+ /**
398
+ * Vite plugin entry point for xray.
399
+ *
400
+ * Add `xrayVitePlugin()` to a Vite config to resolve committed Plate imports in
401
+ * dev and build. The dev capture client is injected only during `vite serve`,
402
+ * and only when `capture` or the HUD needs it.
403
+ *
404
+ * @module @hueest/xray
405
+ */
406
+ const RESOLVED_PREFIX = `\0${VIRTUAL_PREFIX}`;
407
+ const BOOT_ID = "virtual:xray/client";
408
+ const RESOLVED_BOOT_ID = `\0${BOOT_ID}`;
409
+ const ENDPOINT = "/__xray";
410
+ /**
411
+ * Create the Vite plugins that resolve committed Plates and, during dev,
412
+ * capture new Plates from rendered `<Skeleton>` boundaries.
413
+ *
414
+ * Typical setup:
415
+ *
416
+ * ```ts
417
+ * import { xrayVitePlugin } from '@hueest/xray'
418
+ *
419
+ * export default {
420
+ * plugins: [xrayVitePlugin({ hud: true })],
421
+ * }
422
+ * ```
423
+ *
424
+ * Returns two plugins:
425
+ * - `xray:data` resolves `virtual:xray/plates/<name>` from committed Plate files in dev and build.
426
+ * - `xray:dev` runs only during `vite serve`; it injects the capture client, receives Captures, writes Plate files, and hot-updates the page.
427
+ */
428
+ function xrayVitePlugin(options = {}) {
429
+ const platesDir = options.platesDir ?? "plates";
430
+ const capture = options.capture ?? false;
431
+ const delay = options.delay ?? 200;
432
+ const settleCap = options.settleCap ?? 5e3;
433
+ const maxNodes = options.maxNodes ?? 4e3;
434
+ const hud = options.hud ?? false;
435
+ let root = process.cwd();
436
+ const platePath = (name) => resolve(root, platesDir, `${name}.json`);
437
+ return [{
438
+ name: "xray:data",
439
+ configResolved(config) {
440
+ root = config.root;
441
+ },
442
+ resolveId(id) {
443
+ if (id.startsWith("virtual:xray/plates/")) return `\0${id}`;
444
+ },
445
+ load(id) {
446
+ if (!id.startsWith(RESOLVED_PREFIX)) return void 0;
447
+ const name = id.slice(RESOLVED_PREFIX.length);
448
+ if (!isValidPlateName(name)) return renderPlateModule(emptyPlate(name));
449
+ return renderPlateModule(readPlate(platePath(name), name));
450
+ }
451
+ }, {
452
+ name: "xray:dev",
453
+ apply: "serve",
454
+ config() {
455
+ return { resolve: { alias: [{
456
+ find: /^@hueest\/xray\/react$/,
457
+ replacement: "@hueest/xray/react/dev"
458
+ }] } };
459
+ },
460
+ transformIndexHtml: {
461
+ order: "pre",
462
+ handler() {
463
+ if (!capture && !hud) return void 0;
464
+ return [{
465
+ tag: "script",
466
+ attrs: {
467
+ type: "module",
468
+ src: `/@id/${BOOT_ID}`
469
+ },
470
+ injectTo: "head-prepend"
471
+ }];
472
+ }
473
+ },
474
+ resolveId(id) {
475
+ if (id === BOOT_ID) return RESOLVED_BOOT_ID;
476
+ },
477
+ load(id) {
478
+ if (id !== RESOLVED_BOOT_ID) return void 0;
479
+ return bootstrapCode(delay, settleCap, maxNodes, hud, capture);
480
+ },
481
+ configureServer(server) {
482
+ const dir = () => resolve(root, platesDir);
483
+ server.watcher.add(dir());
484
+ const onPlateFile = (file) => {
485
+ if (!file.startsWith(dir()) || !file.endsWith(".json")) return;
486
+ const name = relative(dir(), file).slice(0, -5);
487
+ if (!isValidPlateName(name)) return;
488
+ announcePlate(server.ws, name, readPlateFile(file));
489
+ };
490
+ server.watcher.on("add", onPlateFile);
491
+ server.watcher.on("change", onPlateFile);
492
+ server.middlewares.use(ENDPOINT, (req, res) => {
493
+ if (req.method === "GET" && req.url === "/coverage") {
494
+ res.setHeader("content-type", "application/json");
495
+ res.end(JSON.stringify(readCoverage(dir())));
496
+ return;
497
+ }
498
+ handleCapturePost(req, res, server, platePath);
499
+ });
500
+ }
501
+ }];
502
+ }
503
+ /**
504
+ * The injected dev module: boot the client, POST captures back, optionally
505
+ * mount the HUD. The client installs first so the HUD finds the light box store.
506
+ */
507
+ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
508
+ return [
509
+ `import { installXrayClient } from '@hueest/xray/client'`,
510
+ ...hud ? [`import { installXrayHud } from '@hueest/xray/hud'`] : [],
511
+ "installXrayClient({",
512
+ ` delay: ${delay},`,
513
+ ` settleCap: ${settleCap},`,
514
+ ` maxNodes: ${maxNodes},`,
515
+ ` capture: ${capture},`,
516
+ " sink(payload) {",
517
+ ` void fetch('${ENDPOINT}', {`,
518
+ " method: 'POST',",
519
+ " headers: { 'content-type': 'application/json' },",
520
+ " body: JSON.stringify(payload),",
521
+ " })",
522
+ " },",
523
+ "})",
524
+ ...hud ? ["installXrayHud()"] : [],
525
+ `void fetch('${ENDPOINT}/coverage')`,
526
+ " .then((response) => response.json())",
527
+ " .then((coverage) => {",
528
+ " for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
529
+ " })",
530
+ " .catch(() => {})",
531
+ "if (import.meta.hot) {",
532
+ " import.meta.hot.on('xray:plate', (data) => {",
533
+ " globalThis.__XRAY__?.updatePlate?.(data.name, data.plate)",
534
+ " globalThis.__XRAY__?.coverage?.set(data.name, { breakpoints: data.breakpoints, spans: data.spans })",
535
+ " })",
536
+ "}",
537
+ ""
538
+ ].join("\n");
539
+ }
540
+ /** A committed plate file past this is almost always a `<Skeleton>` set too high. */
541
+ const MAX_PLATE_BYTES = 2e6;
542
+ function isRecord(value) {
543
+ return typeof value === "object" && value !== null;
544
+ }
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
+ /** Send the merged plate (and its disk coverage) over the HMR event so the
570
+ * client hot-swaps it in place and the HUD updates its band display. */
571
+ function announcePlate(ws, name, plateFile) {
572
+ const merged = plateFile ? mergeViews({
573
+ ...plateFile,
574
+ name
575
+ }) : emptyPlate(name);
576
+ ws.send({
577
+ type: "custom",
578
+ event: "xray:plate",
579
+ data: {
580
+ name,
581
+ plate: serializePlate(merged),
582
+ breakpoints: plateFile?.breakpoints ?? [],
583
+ spans: (plateFile?.views ?? []).map((view) => ({
584
+ min: view.min,
585
+ max: view.max
586
+ }))
587
+ }
588
+ });
589
+ }
590
+ /**
591
+ * Per-plate breakpoints + captured spans, read from the committed plate files.
592
+ *
593
+ * @internal
594
+ */
595
+ function readCoverage(dir) {
596
+ const out = {};
597
+ let entries;
598
+ try {
599
+ entries = readdirSync(dir, {
600
+ recursive: true,
601
+ encoding: "utf8"
602
+ });
603
+ } catch {
604
+ return out;
605
+ }
606
+ for (const entry of entries) {
607
+ const rel = entry.replaceAll("\\", "/");
608
+ if (!rel.endsWith(".json")) continue;
609
+ const name = rel.slice(0, -5);
610
+ if (!isValidPlateName(name)) continue;
611
+ const plateFile = readPlateFile(resolve(dir, rel));
612
+ if (!plateFile) continue;
613
+ out[name] = {
614
+ breakpoints: plateFile.breakpoints ?? [],
615
+ spans: plateFile.views.map((view) => ({
616
+ min: view.min,
617
+ max: view.max
618
+ }))
619
+ };
620
+ }
621
+ return out;
622
+ }
623
+ /**
624
+ * Fold a posted capture into its plate file and hot-swap the plate in place.
625
+ *
626
+ * @internal
627
+ */
628
+ function handleCapturePost(req, res, server, platePath) {
629
+ if (req.method !== "POST") {
630
+ res.statusCode = 405;
631
+ res.end();
632
+ return;
633
+ }
634
+ let body = "";
635
+ req.on("data", (chunk) => {
636
+ body += typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
637
+ });
638
+ req.on("end", () => {
639
+ try {
640
+ const payload = JSON.parse(body);
641
+ if (!isIncomingCapture(payload) || !isValidPlateName(payload.name)) {
642
+ res.statusCode = 400;
643
+ res.end("invalid capture payload");
644
+ return;
645
+ }
646
+ const file = platePath(payload.name);
647
+ const existing = readPlateFile(file);
648
+ const next = addCapture(existing, payload.name, payload.capture, payload.breakpoints ?? []);
649
+ const serialized = `${JSON.stringify(next, null, 1)}\n`;
650
+ if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
651
+ res.statusCode = 204;
652
+ res.end();
653
+ return;
654
+ }
655
+ if (serialized.length > MAX_PLATE_BYTES) {
656
+ console.warn(`[xray] not writing "${payload.name}": ${Math.round(serialized.length / 1e3)}KB exceeds the cap.`, "The <Skeleton> likely sits too high in the tree.");
657
+ res.statusCode = 413;
658
+ res.end("plate too large");
659
+ return;
660
+ }
661
+ mkdirSync(dirname(file), { recursive: true });
662
+ writeFileSync(file, serialized);
663
+ announcePlate(server.ws, payload.name, next);
664
+ res.statusCode = 204;
665
+ res.end();
666
+ } catch (error) {
667
+ res.statusCode = 400;
668
+ res.end(error instanceof Error ? error.message : "invalid capture payload");
669
+ }
670
+ });
671
+ }
672
+ /**
673
+ * Names come from import specifiers and POST bodies — keep them inside the plates dir.
674
+ *
675
+ * @internal
676
+ */
677
+ function isValidPlateName(name) {
678
+ return /^[\w-]+(\/[\w-]+)*$/.test(name);
679
+ }
680
+ function readPlateFile(file) {
681
+ let raw;
682
+ try {
683
+ raw = readFileSync(file, "utf8");
684
+ } catch {
685
+ return null;
686
+ }
687
+ try {
688
+ const plateFile = JSON.parse(raw);
689
+ if (!isPlateFile(plateFile) || plateFile.v !== 1) return null;
690
+ return plateFile;
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+ /**
696
+ * Read a plate file and merge its views down to the plate the import
697
+ * resolves to. Missing, unreadable, or version-mismatched files resolve to an
698
+ * empty plate, so imports never break.
699
+ */
700
+ function readPlate(file, name) {
701
+ const plateFile = readPlateFile(file);
702
+ if (!plateFile) return emptyPlate(name);
703
+ return mergeViews({
704
+ ...plateFile,
705
+ name
706
+ });
707
+ }
708
+ /**
709
+ * Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
710
+ * a real import of that child's virtual module, wired into a `refs` map on the
711
+ * default export — so the bundler builds the actual reference graph and chunks
712
+ * the transitive closure a page uses, with no runtime registry (ADR 0006). A
713
+ * stitchless plate is just its JSON. Self-references are dropped (a module can't
714
+ * import itself); an unknown child resolves to its own empty plate, never a
715
+ * build break.
716
+ *
717
+ * @internal
718
+ */
719
+ function renderPlateModule(merged) {
720
+ const refs = collectRefs(merged.tree).filter((name) => name !== merged.name && isValidPlateName(name));
721
+ const json = JSON.stringify(serializePlate(merged));
722
+ if (refs.length === 0) return `export default ${json}`;
723
+ 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
+ }
725
+ //#endregion
726
+ export { addCapture, collectRefs, handleCapturePost, isValidPlateName, mergeViews, readCoverage, renderPlateModule, xrayVitePlugin };