@pygmalionjs/pygmalion 0.2.20 → 0.2.21

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.
@@ -1,4 +1,4 @@
1
- import { F as s, N as a, e as r, s as i, a as t } from "./App-BncfvzKi.js";
1
+ import { F as s, N as a, e as r, s as i, a as t } from "./App-B_6qmxhS.js";
2
2
  export {
3
3
  s as FrozenRoutePreviewView,
4
4
  a as NodeModel,
Binary file
@@ -27,6 +27,7 @@ export const ROUTE_PREVIEW_ARTIFACT_V3_LIMITS = Object.freeze({
27
27
  diagnosticCountPerFrame: 256,
28
28
  identifierLength: 512,
29
29
  tokenLength: 128,
30
+ fingerprintLength: 256,
30
31
  selectorLength: 4_096,
31
32
  labelLength: 1_024,
32
33
  messageLength: 4_096,
@@ -65,6 +66,23 @@ function validOptionalIdentifier(value) {
65
66
  return value == null || validIdentifier(value);
66
67
  }
67
68
 
69
+ /**
70
+ * A frame fingerprint is opaque to this layer: the consumer decides what a frame
71
+ * depends on. Only shape is enforced, so a hash, a revision, or a composite key
72
+ * all pass, and none of them can smuggle in a record key.
73
+ */
74
+ function validFingerprint(value) {
75
+ return (
76
+ typeof value === 'string' &&
77
+ value.length > 0 &&
78
+ value.length <= ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.fingerprintLength &&
79
+ value === value.trim() &&
80
+ // Hyphens and colons are ordinary in a hash or a composite key, so only
81
+ // whitespace and control characters are refused.
82
+ !/[\s\u0000-\u001f\u007f]/u.test(value)
83
+ );
84
+ }
85
+
68
86
  function validViewport(value) {
69
87
  return (
70
88
  isPlainRecord(value) &&
@@ -380,6 +398,12 @@ export function createRoutePreviewArtifactV3(input) {
380
398
  {
381
399
  status: rawCapture.status,
382
400
  viewport: copyViewport(rawCapture.viewport),
401
+ // What this frame was captured from. A consumer compares its own
402
+ // fingerprint against this one, so a frame survives a revision bump
403
+ // whose files it does not depend on.
404
+ ...(rawCapture.fingerprint == null
405
+ ? {}
406
+ : { fingerprint: String(rawCapture.fingerprint) }),
383
407
  ...(compact.frames[frameId] == null
384
408
  ? {}
385
409
  : { snapshot: compact.frames[frameId] }),
@@ -459,6 +483,13 @@ export function validateRoutePreviewArtifactV3(bundle) {
459
483
  errors.push(`Frame "${frameId}" status or viewport is invalid.`);
460
484
  continue;
461
485
  }
486
+ if (
487
+ rawFrame.fingerprint !== undefined &&
488
+ !validFingerprint(rawFrame.fingerprint)
489
+ ) {
490
+ errors.push(`Frame "${frameId}" fingerprint is invalid.`);
491
+ continue;
492
+ }
462
493
  const diagnostics = rawFrame.diagnostics;
463
494
  if (
464
495
  !Array.isArray(diagnostics) ||
@@ -543,6 +574,136 @@ export function validateRoutePreviewArtifactBundle(bundle) {
543
574
  : validateRoutePreviewArtifactV2(bundle);
544
575
  }
545
576
 
577
+ /**
578
+ * Assets a set of frames still needs. A frame lists its head and stylesheet
579
+ * assets by hash, so a subset or a merge can drop what nothing references
580
+ * instead of carrying every asset the bundle ever held.
581
+ */
582
+ function referencedAssets(frames) {
583
+ const head = new Set();
584
+ const stylesheets = new Set();
585
+ const screenshots = new Set();
586
+ for (const frame of Object.values(frames)) {
587
+ if (!isPlainRecord(frame)) continue;
588
+ for (const reference of frame.snapshot?.head ?? []) {
589
+ if (!isPlainRecord(reference) || typeof reference.hash !== 'string') continue;
590
+ (reference.kind === 'stylesheet' ? stylesheets : head).add(reference.hash);
591
+ }
592
+ if (isPlainRecord(frame.screenshot) && typeof frame.screenshot.hash === 'string') {
593
+ screenshots.add(frame.screenshot.hash);
594
+ }
595
+ }
596
+ return { head, stylesheets, screenshots };
597
+ }
598
+
599
+ function keepAssets(record, keys) {
600
+ const kept = {};
601
+ for (const key of Object.keys(record ?? {}).sort()) {
602
+ if (keys.has(key)) kept[key] = record[key];
603
+ }
604
+ return kept;
605
+ }
606
+
607
+ function withFrames(bundle, frames) {
608
+ const used = referencedAssets(frames);
609
+ return {
610
+ version: 3,
611
+ namespace: bundle.namespace,
612
+ ...(bundle.sourceRevision == null ? {} : { sourceRevision: bundle.sourceRevision }),
613
+ assets: {
614
+ head: keepAssets(bundle.assets?.head, used.head),
615
+ stylesheets: keepAssets(bundle.assets?.stylesheets, used.stylesheets),
616
+ screenshots: keepAssets(bundle.assets?.screenshots, used.screenshots),
617
+ },
618
+ frames,
619
+ };
620
+ }
621
+
622
+ /**
623
+ * Picks the frames a consumer asked for and reports what it must still capture.
624
+ *
625
+ * A frame is usable when it exists and its stored fingerprint matches the
626
+ * requested one. Requests without a fingerprint accept whatever is stored, which
627
+ * keeps the whole-bundle callers of earlier versions working.
628
+ */
629
+ export function selectRoutePreviewArtifactFrames(bundle, wanted) {
630
+ if (!isPlainRecord(bundle) || !isPlainRecord(bundle.frames)) {
631
+ throw new TypeError('Route preview artifact is invalid.');
632
+ }
633
+ if (!Array.isArray(wanted)) {
634
+ throw new TypeError('Wanted frames must be an array.');
635
+ }
636
+ const frames = {};
637
+ const missing = [];
638
+ const stale = [];
639
+ for (const request of wanted) {
640
+ const id = isPlainRecord(request) ? request.id : request;
641
+ if (typeof id !== 'string' || !id) {
642
+ throw new TypeError('Wanted frame id must be a non-empty string.');
643
+ }
644
+ const frame = Object.hasOwn(bundle.frames, id) ? bundle.frames[id] : null;
645
+ if (!isPlainRecord(frame)) {
646
+ missing.push(id);
647
+ continue;
648
+ }
649
+ const fingerprint = isPlainRecord(request) ? request.fingerprint : undefined;
650
+ if (fingerprint != null && frame.fingerprint !== fingerprint) {
651
+ stale.push(id);
652
+ continue;
653
+ }
654
+ frames[id] = frame;
655
+ }
656
+ return { bundle: withFrames(bundle, frames), missing, stale };
657
+ }
658
+
659
+ /**
660
+ * Folds freshly captured frames into a stored bundle.
661
+ *
662
+ * Publishing per frame is what makes a capture proportional to what changed: one
663
+ * screen can be replaced without re-rendering the other seventy.
664
+ */
665
+ export function mergeRoutePreviewArtifactV3(base, incoming) {
666
+ if (!isPlainRecord(incoming) || !isPlainRecord(incoming.frames)) {
667
+ throw new TypeError('Incoming route preview artifact is invalid.');
668
+ }
669
+ if (base == null) return withFrames(incoming, { ...incoming.frames });
670
+ if (!isPlainRecord(base) || !isPlainRecord(base.frames)) {
671
+ throw new TypeError('Stored route preview artifact is invalid.');
672
+ }
673
+ if (base.namespace !== incoming.namespace) {
674
+ throw new TypeError('Route preview artifacts belong to different namespaces.');
675
+ }
676
+ const frames = { ...base.frames, ...incoming.frames };
677
+ if (Object.keys(frames).length > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.frameCount) {
678
+ throw new RangeError('Route preview artifact has too many frames.');
679
+ }
680
+ const assets = {
681
+ head: { ...(base.assets?.head ?? {}), ...(incoming.assets?.head ?? {}) },
682
+ stylesheets: {
683
+ ...(base.assets?.stylesheets ?? {}),
684
+ ...(incoming.assets?.stylesheets ?? {}),
685
+ },
686
+ screenshots: {
687
+ ...(base.assets?.screenshots ?? {}),
688
+ ...(incoming.assets?.screenshots ?? {}),
689
+ },
690
+ };
691
+ return withFrames(
692
+ {
693
+ namespace: incoming.namespace,
694
+ // The newest capture names the bundle, but a frame keeps the fingerprint it
695
+ // was captured with, so the two no longer have to agree.
696
+ ...(incoming.sourceRevision == null
697
+ ? base.sourceRevision == null
698
+ ? {}
699
+ : { sourceRevision: base.sourceRevision }
700
+ : { sourceRevision: incoming.sourceRevision }),
701
+ assets,
702
+ },
703
+ frames,
704
+ );
705
+ }
706
+
546
707
  export function reconstructRoutePreviewArtifactSnapshotV3(bundle, frameId) {
547
708
  const report = validateRoutePreviewArtifactV3(bundle);
548
709
  if (!report.valid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
package/storyboard.d.ts CHANGED
@@ -225,6 +225,8 @@ export interface RoutePreviewArtifactV3Capture {
225
225
  bytes: Uint8Array;
226
226
  };
227
227
  diagnostics?: readonly StoryboardCaptureDiagnostic[];
228
+ /** What this frame was captured from, compared per frame instead of per bundle. */
229
+ fingerprint?: string;
228
230
  }
229
231
 
230
232
  export interface RoutePreviewArtifactBundleV3 {
@@ -258,6 +260,22 @@ export declare function createRoutePreviewArtifactV3(input: {
258
260
  sourceRevision?: string;
259
261
  captures: Record<string, RoutePreviewArtifactV3Capture>;
260
262
  }): RoutePreviewArtifactBundleV3;
263
+ export interface RoutePreviewArtifactFrameRequest {
264
+ id: string;
265
+ fingerprint?: string;
266
+ }
267
+ export declare function selectRoutePreviewArtifactFrames(
268
+ bundle: unknown,
269
+ wanted: readonly (RoutePreviewArtifactFrameRequest | string)[],
270
+ ): {
271
+ bundle: RoutePreviewArtifactBundleV3;
272
+ missing: string[];
273
+ stale: string[];
274
+ };
275
+ export declare function mergeRoutePreviewArtifactV3(
276
+ base: unknown,
277
+ incoming: unknown,
278
+ ): RoutePreviewArtifactBundleV3;
261
279
  export declare function validateRoutePreviewArtifactV3(bundle: unknown): {
262
280
  valid: boolean;
263
281
  errors: string[];
package/types.d.ts CHANGED
@@ -823,9 +823,16 @@ export interface PreviewArtifactExpectation {
823
823
  sourceRevision: string;
824
824
  }
825
825
 
826
+ export interface PreviewArtifactFrameRequest {
827
+ id: string;
828
+ fingerprint?: string;
829
+ }
830
+
826
831
  export interface PreviewArtifactTransportRequest extends PreviewArtifactExpectation {
827
832
  signal?: AbortSignal;
828
833
  captureBaseUrl?: string;
834
+ /** Frames to fetch. Omitted asks for the whole bundle, as earlier versions did. */
835
+ frames?: readonly PreviewArtifactFrameRequest[];
829
836
  }
830
837
 
831
838
  export type PreviewArtifactTransport = (
@@ -889,6 +896,10 @@ export type PreviewArtifactBootstrapResult =
889
896
  export declare function createPreviewRecipeFingerprint(
890
897
  recipes: readonly PreviewRecipeIdentity[],
891
898
  ): string;
899
+ export declare function createPreviewFrameFingerprint(
900
+ screen: StoryboardCaptureRecipeInput & { id?: string },
901
+ options?: { dependencyDigest?: string },
902
+ ): string;
892
903
  export declare function createPreviewCacheNamespace(
893
904
  options: PreviewCacheNamespaceOptions,
894
905
  ): string;
package/vite.d.ts CHANGED
@@ -503,14 +503,17 @@ export declare function reconstructRoutePreviewArtifactSnapshots(
503
503
  export {
504
504
  ROUTE_PREVIEW_ARTIFACT_V3_LIMITS,
505
505
  createRoutePreviewArtifactV3,
506
+ mergeRoutePreviewArtifactV3,
506
507
  reconstructRoutePreviewArtifactScreenshotV3,
507
508
  reconstructRoutePreviewArtifactSnapshotV3,
508
509
  reconstructRoutePreviewArtifactSnapshotsV3,
510
+ selectRoutePreviewArtifactFrames,
509
511
  validateRoutePreviewArtifactBundle,
510
512
  validateRoutePreviewArtifactV3,
511
513
  } from './storyboard';
512
514
  export type {
513
515
  RoutePreviewArtifactBundleV3,
516
+ RoutePreviewArtifactFrameRequest,
514
517
  RoutePreviewArtifactV3Capture,
515
518
  StoryboardCaptureDiagnostic,
516
519
  StoryboardCaptureStatus,