@pygmalionjs/pygmalion 0.2.19 → 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.
@@ -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/node/vite.mjs CHANGED
@@ -210,6 +210,10 @@ export function createPygmalionVitePlugins(config) {
210
210
  }),
211
211
  ];
212
212
 
213
+ // Assigned below when the mirror plugin is built. The artifact plugin resolves
214
+ // it lazily because a capture starts long after the plugin list is assembled.
215
+ let devMirror = null;
216
+
213
217
  if (project.preview.artifactFile || project.preview.generateArtifact) {
214
218
  plugins.push(
215
219
  pygmalionPreviewArtifactPlugin({
@@ -217,6 +221,7 @@ export function createPygmalionVitePlugins(config) {
217
221
  artifactFile: project.preview.artifactFile,
218
222
  endpoint: project.preview.artifactEndpoint,
219
223
  generateArtifact: project.preview.generateArtifact,
224
+ acquireLease: (label) => devMirror?.acquireLease(label) ?? null,
220
225
  }),
221
226
  );
222
227
  }
@@ -253,8 +258,7 @@ export function createPygmalionVitePlugins(config) {
253
258
  }
254
259
 
255
260
  if (project.mirror !== false) {
256
- plugins.push(
257
- pygmalionDevMirrorPlugin({
261
+ const mirrorPlugin = pygmalionDevMirrorPlugin({
258
262
  editorRoot: project.editorRoot,
259
263
  projectRoot: project.projectRoot,
260
264
  appDirectory: project.appDirectory,
@@ -280,8 +284,9 @@ export function createPygmalionVitePlugins(config) {
280
284
  PYGMALION_DEV_CONTROL,
281
285
  'mirror.control',
282
286
  ),
283
- }),
284
- );
287
+ });
288
+ devMirror = mirrorPlugin.pygmalion ?? null;
289
+ plugins.push(mirrorPlugin);
285
290
  }
286
291
 
287
292
  if (project.sessions !== false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.19",
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
@@ -161,6 +161,17 @@ export interface TokenDef {
161
161
  group?: string;
162
162
  }
163
163
 
164
+ export interface ViewportPresetDef {
165
+ /** Button text. The panel column is narrow, so keep it to a few characters. */
166
+ label: string;
167
+ /** Frame width in CSS pixels. */
168
+ width: number;
169
+ /** Frame height. Omit to resize width only and leave the current height alone. */
170
+ height?: number;
171
+ /** Tooltip text. Defaults to the dimensions. */
172
+ description?: string;
173
+ }
174
+
164
175
  export const DESIGN_IMPORT_KINDS: readonly [
165
176
  'icons',
166
177
  'design-tokens',
@@ -623,6 +634,12 @@ export declare function setComponentRegistry(r: ComponentRegistry): void;
623
634
 
624
635
  export declare function setTokens(t: TokenDef[]): void;
625
636
 
637
+ /** Declares the frame widths the panel offers. A malformed entry throws at mount. */
638
+ export declare function setViewportPresets(
639
+ presets: readonly ViewportPresetDef[],
640
+ ): void;
641
+ export declare function getViewportPresets(): ViewportPresetDef[];
642
+
626
643
  /** Updates the standard route of the running route iframe. The existing editing status is maintained. */
627
644
  export declare function setPygmalionAppOrigin(origin: string): void;
628
645
  /** Update the static screen cache when a preview of the same URL points to a new code version. */
@@ -806,9 +823,16 @@ export interface PreviewArtifactExpectation {
806
823
  sourceRevision: string;
807
824
  }
808
825
 
826
+ export interface PreviewArtifactFrameRequest {
827
+ id: string;
828
+ fingerprint?: string;
829
+ }
830
+
809
831
  export interface PreviewArtifactTransportRequest extends PreviewArtifactExpectation {
810
832
  signal?: AbortSignal;
811
833
  captureBaseUrl?: string;
834
+ /** Frames to fetch. Omitted asks for the whole bundle, as earlier versions did. */
835
+ frames?: readonly PreviewArtifactFrameRequest[];
812
836
  }
813
837
 
814
838
  export type PreviewArtifactTransport = (
@@ -872,6 +896,10 @@ export type PreviewArtifactBootstrapResult =
872
896
  export declare function createPreviewRecipeFingerprint(
873
897
  recipes: readonly PreviewRecipeIdentity[],
874
898
  ): string;
899
+ export declare function createPreviewFrameFingerprint(
900
+ screen: StoryboardCaptureRecipeInput & { id?: string },
901
+ options?: { dependencyDigest?: string },
902
+ ): string;
875
903
  export declare function createPreviewCacheNamespace(
876
904
  options: PreviewCacheNamespaceOptions,
877
905
  ): string;
@@ -1620,6 +1648,8 @@ export declare function PygmalionEditor(props: {
1620
1648
  registry?: ComponentRegistry;
1621
1649
  tokens?: TokenDef[];
1622
1650
  initialPages?: InitialPageDef[];
1651
+ /** Named frame widths the application supports — rendered as one-click buttons beside the W/H boxes. */
1652
+ viewportPresets?: readonly ViewportPresetDef[];
1623
1653
  /** icons/tokens/atoms/components/screens Controller for getting inventory and full DOM layers. */
1624
1654
  designImport?: DesignImportController;
1625
1655
  /** Called with cumulative changes when editing asset panel tokens — Host is responsible for write-back (file saving). */
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,