@penpair/core 0.3.3 → 0.3.4

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.
@@ -63,6 +63,7 @@ export function baselineCoverage(config, results = []) {
63
63
  return (evidence?.approval?.kind !== "forced-reference" &&
64
64
  ![
65
65
  "missing",
66
+ "renamed",
66
67
  "unstable",
67
68
  "case-mismatch",
68
69
  ].includes(result.status) &&
@@ -199,6 +200,7 @@ export async function readBaselineCoverage(root, config, expectedFileId) {
199
200
  "same",
200
201
  "changed",
201
202
  "missing",
203
+ "renamed",
202
204
  "unstable",
203
205
  "case-mismatch",
204
206
  ].includes(value.status) ||
@@ -22,8 +22,10 @@ export interface CoverageConfig {
22
22
  }
23
23
  /** One frame of a Penpot page that becomes an approved reference image. */
24
24
  export interface BaselineFrame {
25
- /** The exact name of a top-level frame on the page. */
26
- frame: string;
25
+ /** Exact top-level frame name, or an expected name when frameId is supplied. */
26
+ frame?: string;
27
+ /** Stable board UUID, resolved at any depth on the configured page. */
28
+ frameId?: string;
27
29
  /** Names the image file (`<id>.png`) and the check that uses it. Lowercase letters, digits, hyphens and underscores. */
28
30
  id: string;
29
31
  /** The route in the implementation this frame is the design of. Frames without one are exported but not given a check. */
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ const frameIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3
  const idPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
3
4
  export function validateBaselines(value, fail) {
4
5
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -18,13 +19,27 @@ export function validateBaselines(value, fail) {
18
19
  fail("baselines.frames must list at least one frame, at most 1000");
19
20
  }
20
21
  const seen = new Set();
22
+ const seenFrameIds = new Set();
21
23
  for (const entry of v.frames) {
22
24
  const f = entry;
23
25
  if (typeof f !== "object" || f === null) {
24
26
  fail("every entry in baselines.frames must be an object");
25
27
  }
26
- if (typeof f.frame !== "string" || f.frame.length === 0) {
27
- fail("every baselines.frames entry needs a frame name");
28
+ if (f.frame === undefined && f.frameId === undefined) {
29
+ fail("every baselines.frames entry needs frame or frameId");
30
+ }
31
+ if (f.frame !== undefined && (typeof f.frame !== "string" || f.frame.length === 0)) {
32
+ fail("baselines.frames frame must be a non-empty name");
33
+ }
34
+ if (f.frameId !== undefined) {
35
+ if (typeof f.frameId !== "string" || !frameIdPattern.test(f.frameId)) {
36
+ fail("baselines.frames frameId must be a UUID");
37
+ }
38
+ const frameId = f.frameId.toLowerCase();
39
+ if (seenFrameIds.has(frameId)) {
40
+ fail(`baselines.frames frameId ${JSON.stringify(f.frameId)} is used twice`);
41
+ }
42
+ seenFrameIds.add(frameId);
28
43
  }
29
44
  if (typeof f.id !== "string" || !idPattern.test(f.id)) {
30
45
  fail(`baselines.frames id ${JSON.stringify(f.id)} must be lowercase letters, digits, hyphens or underscores`);
@@ -14,8 +14,10 @@ export type BaselineStatus =
14
14
  | "changed"
15
15
  /** The export never settled (two identical renders in a row), so it is not trusted. */
16
16
  | "unstable"
17
- /** The page has no top-level frame with that name. */
18
- | "missing" | "case-mismatch";
17
+ /** No board matches the configured ID, or no top-level frame matches the name. */
18
+ | "missing"
19
+ /** An ID-resolved board no longer has its configured expected name. */
20
+ | "renamed" | "case-mismatch";
19
21
  export interface ArtifactProvenance extends CaptureIdentity {
20
22
  approval?: {
21
23
  kind: "owner-words";
package/dist/baselines.js CHANGED
@@ -45,6 +45,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
45
45
  });
46
46
  const listing = await runOnPage(client, frames({
47
47
  page: config.page,
48
+ recursive: config.frames.some((entry) => entry.frameId !== undefined),
48
49
  }), config.page, options);
49
50
  if (listing.ok !== true ||
50
51
  typeof listing.fileId !== "string" ||
@@ -55,6 +56,9 @@ export async function exportBaselines(client, projectRoot, config, options = {})
55
56
  !Array.isArray(listing.frames)) {
56
57
  throw new Error("Baseline listing lacks file/page/revision evidence");
57
58
  }
59
+ if (listing.truncated === true) {
60
+ throw new Error("Baseline frame search truncated at the page shape budget; nothing was captured");
61
+ }
58
62
  const found = listing.frames;
59
63
  const results = [];
60
64
  const wrote = [];
@@ -79,7 +83,9 @@ export async function exportBaselines(client, projectRoot, config, options = {})
79
83
  if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
80
84
  throw new Error("Existing baseline path leaves its folder");
81
85
  }
82
- const matches = found.filter((f) => f.name === wanted.frame);
86
+ const matches = found.filter((frame) => wanted.frameId
87
+ ? frame.id.toLowerCase() === wanted.frameId.toLowerCase()
88
+ : frame.topLevel !== false && frame.name === wanted.frame);
83
89
  if (!matches.length) {
84
90
  results.push({
85
91
  ...(wanted.case
@@ -88,7 +94,8 @@ export async function exportBaselines(client, projectRoot, config, options = {})
88
94
  }
89
95
  : {}),
90
96
  id: wanted.id,
91
- frame: wanted.frame,
97
+ frame: wanted.frame ?? wanted.frameId,
98
+ frameId: wanted.frameId,
92
99
  file,
93
100
  status: "missing",
94
101
  written: false,
@@ -99,7 +106,22 @@ export async function exportBaselines(client, projectRoot, config, options = {})
99
106
  throw new Error(`more than one top-level frame on ${config.page} is named ${JSON.stringify(wanted.frame)}; rename one so the design reference is unambiguous`);
100
107
  }
101
108
  const frame = matches[0];
102
- options.onStep?.(`exporting ${wanted.frame}`);
109
+ if (wanted.frameId && wanted.frame !== undefined && wanted.frame !== frame.name) {
110
+ results.push({
111
+ id: wanted.id,
112
+ frame: wanted.frame,
113
+ frameId: frame.id,
114
+ case: wanted.case,
115
+ file,
116
+ status: "renamed",
117
+ problems: [
118
+ `Expected frame name ${JSON.stringify(wanted.frame)}; current name is ${JSON.stringify(frame.name)}`,
119
+ ],
120
+ written: false,
121
+ });
122
+ continue;
123
+ }
124
+ options.onStep?.(`exporting ${frame.name}`);
103
125
  const exported = await exportShapePng(client, frame.id, {
104
126
  ...options,
105
127
  page: config.page,
@@ -112,7 +134,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
112
134
  }
113
135
  : {}),
114
136
  id: wanted.id,
115
- frame: wanted.frame,
137
+ frame: frame.name,
116
138
  frameId: frame.id,
117
139
  width: frame.width,
118
140
  height: frame.height,
@@ -253,6 +275,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
253
275
  }
254
276
  const after = await runOnPage(client, frames({
255
277
  page: config.page,
278
+ recursive: config.frames.some((entry) => entry.frameId !== undefined),
256
279
  }), config.page, options);
257
280
  const source = (value) => ({
258
281
  fileId: value.fileId,
@@ -260,11 +283,14 @@ export async function exportBaselines(client, projectRoot, config, options = {})
260
283
  revision: value.revn,
261
284
  frames: value.frames,
262
285
  });
263
- if (observationHash(source(listing)) !== observationHash(source(after))) {
286
+ if (after.ok !== true ||
287
+ after.truncated === true ||
288
+ observationHash(source(listing)) !== observationHash(source(after))) {
264
289
  throw new Error("Baseline source changed before publication; previous artifacts were preserved");
265
290
  }
266
291
  const complete = results.length === config.frames.length &&
267
292
  results.every((result) => result.status !== "missing" &&
293
+ result.status !== "renamed" &&
268
294
  result.status !== "unstable" &&
269
295
  result.status !== "case-mismatch");
270
296
  if (options.apply && complete) {
package/dist/cli-main.js CHANGED
@@ -1914,9 +1914,16 @@ async function main(argv, cli) {
1914
1914
  if (!wanted?.case || baseline?.page !== values.page) {
1915
1915
  throw new Error("Approval case must reference an explicitly configured frame on this page");
1916
1916
  }
1917
- const found = await findShapesByName(client, wanted.frame, values.page);
1918
- if (found.matches.length !== 1 || found.matches[0].id !== values.frame) {
1919
- throw new Error("Approval frame ID does not match the configured case");
1917
+ if (wanted.frameId) {
1918
+ if (wanted.frameId.toLowerCase() !== values.frame.toLowerCase()) {
1919
+ throw new Error("Approval frame ID does not match the configured case");
1920
+ }
1921
+ }
1922
+ else {
1923
+ const found = await findShapesByName(client, wanted.frame, values.page);
1924
+ if (found.matches.length !== 1 || found.matches[0].id !== values.frame) {
1925
+ throw new Error("Approval frame ID does not match the configured case");
1926
+ }
1920
1927
  }
1921
1928
  intent = wanted.case;
1922
1929
  }
@@ -285,7 +285,12 @@ export declare const projectConfigSchema: {
285
285
  readonly maxItems: 1000;
286
286
  readonly items: {
287
287
  readonly type: "object";
288
- readonly required: readonly ["frame", "id"];
288
+ readonly required: readonly ["id"];
289
+ readonly anyOf: readonly [{
290
+ readonly required: readonly ["frame"];
291
+ }, {
292
+ readonly required: readonly ["frameId"];
293
+ }];
289
294
  readonly dependentRequired: {
290
295
  readonly case: readonly ["route"];
291
296
  };
@@ -294,6 +299,10 @@ export declare const projectConfigSchema: {
294
299
  readonly type: "string";
295
300
  readonly minLength: 1;
296
301
  };
302
+ readonly frameId: {
303
+ readonly type: "string";
304
+ readonly pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
305
+ };
297
306
  readonly id: {
298
307
  readonly type: "string";
299
308
  readonly pattern: "^[a-z0-9][a-z0-9_-]{0,63}$";
@@ -333,9 +333,20 @@ export const projectConfigSchema = {
333
333
  items: {
334
334
  type: "object",
335
335
  required: [
336
- "frame",
337
336
  "id",
338
337
  ],
338
+ anyOf: [
339
+ {
340
+ required: [
341
+ "frame",
342
+ ],
343
+ },
344
+ {
345
+ required: [
346
+ "frameId",
347
+ ],
348
+ },
349
+ ],
339
350
  dependentRequired: {
340
351
  case: [
341
352
  "route",
@@ -346,6 +357,10 @@ export const projectConfigSchema = {
346
357
  type: "string",
347
358
  minLength: 1,
348
359
  },
360
+ frameId: {
361
+ type: "string",
362
+ pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
363
+ },
349
364
  id: {
350
365
  type: "string",
351
366
  pattern: "^[a-z0-9][a-z0-9_-]{0,63}$",
@@ -4,8 +4,10 @@ export declare function pages(): Snippet;
4
4
  export declare function switchPage(name: string): Snippet;
5
5
  export interface FramesParams {
6
6
  page: string;
7
+ /** Include nested boards with a bounded whole-page search. */
8
+ recursive?: boolean;
7
9
  }
8
- /** The top-level frames of a page: id, name and size, so they can be found by name and exported. */
10
+ /** List top-level frames by default; opt in to a bounded search of every board on the page. */
9
11
  export declare function frames(params: FramesParams): Snippet;
10
12
  export interface TreeParams {
11
13
  page: string;
@@ -14,11 +14,36 @@ return { ok: true, switched: true };`, {
14
14
  name,
15
15
  });
16
16
  }
17
- /** The top-level frames of a page: id, name and size, so they can be found by name and exported. */
17
+ /** List top-level frames by default; opt in to a bounded search of every board on the page. */
18
18
  export function frames(params) {
19
19
  return readSnippet("frames", `${requirePage}
20
+ if (!CFG.recursive) {
21
+ return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot.currentFile.id, revn: penpot.currentFile.revn, page: penpot.currentPage.name, pageId: penpot.currentPage.id,
22
+ frames: penpot.currentPage.root.children.filter((shape) => shape.type === "board").map((shape) => ({ id: shape.id, name: shape.name, width: Math.round(shape.width), height: Math.round(shape.height) })) };
23
+ }
24
+ const boards = [];
25
+ const budget = 10000;
26
+ const stack = [];
27
+ let visited = 0;
28
+ let truncated = false;
29
+ const enqueue = (children, parentBoardId, topLevel) => {
30
+ const take = Math.min(children.length, Math.max(0, budget - visited - stack.length));
31
+ if (take < children.length) { truncated = true; }
32
+ for (let index = take - 1; index >= 0; index--) {
33
+ stack.push({ shape: children[index], parentBoardId, topLevel });
34
+ }
35
+ };
36
+ enqueue(penpot.currentPage.root.children, null, true);
37
+ while (stack.length) {
38
+ const { shape, parentBoardId, topLevel } = stack.pop();
39
+ visited++;
40
+ if (shape.type === "board") {
41
+ boards.push({ id: shape.id, name: shape.name, width: Math.round(shape.width), height: Math.round(shape.height), parentBoardId, topLevel });
42
+ }
43
+ enqueue(shape.children || [], shape.type === "board" ? shape.id : parentBoardId, false);
44
+ }
20
45
  return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot.currentFile.id, revn: penpot.currentFile.revn, page: penpot.currentPage.name, pageId: penpot.currentPage.id,
21
- frames: penpot.currentPage.root.children.filter((c) => c.type === "board").map((c) => ({ id: c.id, name: c.name, width: Math.round(c.width), height: Math.round(c.height) })) };`, params);
46
+ frames: boards, visited, truncated, complete: !truncated };`, params);
22
47
  }
23
48
  /** One line per shape: type, name, size, position on the page, layout and sizing, the text it carries. */
24
49
  export function tree(params) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export declare const version = "0.3.3";
2
+ export declare const version = "0.3.4";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export const version = "0.3.3";
2
+ export const version = "0.3.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/core",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
5
5
  "license": "MIT",
6
6
  "repository": {