@penpair/marwes-ui 0.2.0 → 0.3.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/build-kit.js +24 -6
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +500 -94
- package/dist/components.js +375 -74
- package/dist/config.js +53 -16
- package/dist/index.js +21 -3
- package/dist/marwes.js +45 -12
- package/dist/mode-trial.js +325 -75
- package/dist/ownership.js +162 -37
- package/dist/penpot.js +447 -114
- package/dist/reconciliation.js +112 -37
- package/dist/resolved-values.d.ts +3 -8
- package/dist/resolved-values.js +99 -103
- package/dist/snapshot-file.js +255 -50
- package/dist/theme-input.js +12 -4
- package/dist/theme-lint.js +113 -27
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +207 -52
- package/package.json +2 -2
package/dist/components.js
CHANGED
|
@@ -2,187 +2,453 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createJiti } from "jiti";
|
|
5
|
-
import { atomicWriteFile, parseCommandArgs, resolveProjectInput, resolveProjectOutput } from "@penpair/core";
|
|
5
|
+
import { atomicWriteFile, parseCommandArgs, resolveProjectInput, resolveProjectOutput, } from "@penpair/core";
|
|
6
6
|
import { computeExpectation } from "./commands.js";
|
|
7
7
|
import { canonical, toHexColor } from "./tokens.js";
|
|
8
|
-
export const componentProperties = [
|
|
8
|
+
export const componentProperties = [
|
|
9
|
+
"width",
|
|
10
|
+
"height",
|
|
11
|
+
"paddingTop",
|
|
12
|
+
"paddingRight",
|
|
13
|
+
"paddingBottom",
|
|
14
|
+
"paddingLeft",
|
|
15
|
+
"rowGap",
|
|
16
|
+
"columnGap",
|
|
17
|
+
"borderRadius",
|
|
18
|
+
"borderWidth",
|
|
19
|
+
"borderColor",
|
|
20
|
+
"backgroundColor",
|
|
21
|
+
"color",
|
|
22
|
+
"fontFamily",
|
|
23
|
+
"fontSize",
|
|
24
|
+
"fontWeight",
|
|
25
|
+
"lineHeight",
|
|
26
|
+
"boxShadow",
|
|
27
|
+
];
|
|
9
28
|
const digest = (v) => createHash("sha256").update(canonical(v)).digest("hex");
|
|
10
29
|
const text = (v, label, max = 500) => {
|
|
11
|
-
if (typeof v !== "string" || !v.trim() || v.length > max || /[\x00-\x1f]/.test(v))
|
|
30
|
+
if (typeof v !== "string" || !v.trim() || v.length > max || /[\x00-\x1f]/.test(v)) {
|
|
12
31
|
throw new Error(`Invalid component ${label}`);
|
|
32
|
+
}
|
|
13
33
|
return v;
|
|
14
34
|
};
|
|
15
35
|
export function readComponentPilot(raw) {
|
|
16
36
|
const value = raw.marwes?.components;
|
|
17
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
18
38
|
throw new Error("Configure marwes.components with a trusted renderer, sources and explicit pilot cases");
|
|
39
|
+
}
|
|
19
40
|
const v = value;
|
|
20
|
-
if (v.schemaVersion !== 1 ||
|
|
41
|
+
if (v.schemaVersion !== 1 ||
|
|
42
|
+
!Array.isArray(v.sources) ||
|
|
43
|
+
!v.sources.length ||
|
|
44
|
+
v.sources.length > 100 ||
|
|
45
|
+
!Array.isArray(v.cases) ||
|
|
46
|
+
!v.cases.length ||
|
|
47
|
+
v.cases.length > 60) {
|
|
21
48
|
throw new Error("Invalid schema-1 component pilot (max 100 sources, 60 cases)");
|
|
49
|
+
}
|
|
22
50
|
const ids = new Set();
|
|
23
|
-
const cases = v.cases.map(c => {
|
|
24
|
-
if (!c ||
|
|
51
|
+
const cases = v.cases.map((c) => {
|
|
52
|
+
if (!c ||
|
|
53
|
+
![
|
|
54
|
+
"Button",
|
|
55
|
+
"Card",
|
|
56
|
+
"StatTile",
|
|
57
|
+
].includes(c.component) ||
|
|
58
|
+
![
|
|
59
|
+
"light",
|
|
60
|
+
"dark",
|
|
61
|
+
].includes(c.theme)) {
|
|
25
62
|
throw new Error("Pilot supports Button/Card/StatTile and explicit light/dark themes");
|
|
63
|
+
}
|
|
26
64
|
const id = text(c.id, "case id");
|
|
27
|
-
if (ids.has(id))
|
|
65
|
+
if (ids.has(id)) {
|
|
28
66
|
throw new Error("Duplicate component case id");
|
|
67
|
+
}
|
|
29
68
|
ids.add(id);
|
|
30
|
-
if (![
|
|
69
|
+
if (![
|
|
70
|
+
c.viewport?.width,
|
|
71
|
+
c.viewport?.height,
|
|
72
|
+
].every((n) => Number.isInteger(n) && n > 0 && n <= 8192)) {
|
|
31
73
|
throw new Error("Invalid component viewport");
|
|
32
|
-
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
component: c.component,
|
|
78
|
+
theme: c.theme,
|
|
79
|
+
state: text(c.state, "state"),
|
|
80
|
+
viewport: {
|
|
81
|
+
...c.viewport,
|
|
82
|
+
},
|
|
83
|
+
selector: text(c.selector, "selector"),
|
|
84
|
+
};
|
|
33
85
|
});
|
|
34
|
-
for (const component of [
|
|
35
|
-
|
|
36
|
-
|
|
86
|
+
for (const component of [
|
|
87
|
+
"Button",
|
|
88
|
+
"Card",
|
|
89
|
+
"StatTile",
|
|
90
|
+
]) {
|
|
91
|
+
for (const theme of [
|
|
92
|
+
"light",
|
|
93
|
+
"dark",
|
|
94
|
+
]) {
|
|
95
|
+
if (!cases.some((c) => c.component === component && c.theme === theme)) {
|
|
37
96
|
throw new Error(`Pilot requires ${component} in ${theme}`);
|
|
38
|
-
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
renderer: text(v.renderer, "renderer"),
|
|
103
|
+
sources: v.sources.map((s) => text(s, "source")),
|
|
104
|
+
snapshot: text(v.snapshot, "snapshot"),
|
|
105
|
+
cases,
|
|
106
|
+
};
|
|
39
107
|
}
|
|
40
108
|
async function boundedRead(root, relative) {
|
|
41
109
|
const file = await resolveProjectInput(root, relative);
|
|
42
|
-
if ((await stat(file)).size > 4 * 1024 * 1024)
|
|
110
|
+
if ((await stat(file)).size > 4 * 1024 * 1024) {
|
|
43
111
|
throw new Error("Component input exceeds 4 MiB");
|
|
112
|
+
}
|
|
44
113
|
return readFile(file, "utf8");
|
|
45
114
|
}
|
|
46
115
|
/** Hash declared source bytes as well as effective tokens: comments and non-token anatomy changes invalidate cheap CI checks. */
|
|
47
116
|
export async function componentSourceHash(root, pilot, tokens) {
|
|
48
|
-
const files = [
|
|
49
|
-
|
|
50
|
-
|
|
117
|
+
const files = [
|
|
118
|
+
...new Set([
|
|
119
|
+
pilot.renderer,
|
|
120
|
+
...pilot.sources,
|
|
121
|
+
]),
|
|
122
|
+
].sort();
|
|
123
|
+
const sources = await Promise.all(files.map(async (file) => [
|
|
124
|
+
file,
|
|
125
|
+
digest(await boundedRead(root, file)),
|
|
126
|
+
]));
|
|
127
|
+
return digest({
|
|
128
|
+
pilot,
|
|
129
|
+
sources,
|
|
130
|
+
tokens: tokens.hash,
|
|
131
|
+
version: tokens.marwesVersion,
|
|
132
|
+
});
|
|
51
133
|
}
|
|
52
134
|
function validateMeasurement(raw, id) {
|
|
53
135
|
const v = raw;
|
|
54
|
-
if (!v ||
|
|
136
|
+
if (!v ||
|
|
137
|
+
v.id !== id ||
|
|
138
|
+
!v.parts ||
|
|
139
|
+
Array.isArray(v.parts) ||
|
|
140
|
+
typeof v.parts !== "object" ||
|
|
141
|
+
!Object.keys(v.parts).length ||
|
|
142
|
+
Object.keys(v.parts).length > 32 ||
|
|
143
|
+
!v.environment ||
|
|
144
|
+
typeof v.environment.fontsReady !== "boolean") {
|
|
55
145
|
throw new Error(`Incomplete component measurement: ${id}`);
|
|
146
|
+
}
|
|
56
147
|
const parts = Object.create(null);
|
|
57
148
|
for (const [name, fields] of Object.entries(v.parts)) {
|
|
58
149
|
text(name, "part", 100);
|
|
59
|
-
if (!fields ||
|
|
150
|
+
if (!fields ||
|
|
151
|
+
typeof fields !== "object" ||
|
|
152
|
+
Array.isArray(fields) ||
|
|
153
|
+
Object.keys(fields).some((k) => !componentProperties.includes(k))) {
|
|
60
154
|
throw new Error("Invalid measured anatomy properties");
|
|
61
|
-
|
|
155
|
+
}
|
|
156
|
+
parts[name] = Object.fromEntries(componentProperties.map((key) => {
|
|
62
157
|
const value = fields[key];
|
|
63
|
-
if (value !== null && (typeof value !== "string" || value.length > 2000))
|
|
158
|
+
if (value !== null && (typeof value !== "string" || value.length > 2000)) {
|
|
64
159
|
throw new Error(`Missing/invalid ${id}/${name}/${key}; use null for unknown`);
|
|
65
|
-
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
key,
|
|
163
|
+
value,
|
|
164
|
+
];
|
|
66
165
|
}));
|
|
67
166
|
}
|
|
68
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
parts,
|
|
170
|
+
environment: {
|
|
171
|
+
browser: text(v.environment.browser, "browser"),
|
|
172
|
+
fontsReady: v.environment.fontsReady,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
69
175
|
}
|
|
70
176
|
/** Exact value candidates are evidence of equality only. Even one candidate is not a semantic binding. */
|
|
71
177
|
export function componentTokenCandidates(fields, tokens, mode) {
|
|
72
|
-
const selected = tokens.themes.find(t => t.group === "Mode" && t.name.toLowerCase() === mode);
|
|
73
|
-
if (!selected)
|
|
178
|
+
const selected = tokens.themes.find((t) => t.group === "Mode" && t.name.toLowerCase() === mode);
|
|
179
|
+
if (!selected) {
|
|
74
180
|
return {};
|
|
181
|
+
}
|
|
75
182
|
const effective = new Map();
|
|
76
|
-
for (const setName of selected.sets)
|
|
77
|
-
for (const token of tokens.sets.find(s => s.name === setName)?.tokens ?? [])
|
|
78
|
-
effective.set(token.name, {
|
|
183
|
+
for (const setName of selected.sets) {
|
|
184
|
+
for (const token of tokens.sets.find((s) => s.name === setName)?.tokens ?? []) {
|
|
185
|
+
effective.set(token.name, {
|
|
186
|
+
set: setName,
|
|
187
|
+
value: token.value,
|
|
188
|
+
type: token.type,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
79
192
|
const result = Object.create(null);
|
|
80
193
|
for (const [property, raw] of Object.entries(fields)) {
|
|
81
194
|
if (raw === null) {
|
|
82
195
|
result[property] = [];
|
|
83
196
|
continue;
|
|
84
197
|
}
|
|
85
|
-
const color = [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
198
|
+
const color = [
|
|
199
|
+
"color",
|
|
200
|
+
"borderColor",
|
|
201
|
+
"backgroundColor",
|
|
202
|
+
].includes(property);
|
|
203
|
+
const types = color
|
|
204
|
+
? [
|
|
205
|
+
"color",
|
|
206
|
+
]
|
|
207
|
+
: property.startsWith("padding") || property.endsWith("Gap")
|
|
208
|
+
? [
|
|
209
|
+
"spacing",
|
|
210
|
+
"dimension",
|
|
211
|
+
]
|
|
212
|
+
: property === "borderRadius"
|
|
213
|
+
? [
|
|
214
|
+
"borderRadius",
|
|
215
|
+
"dimension",
|
|
216
|
+
]
|
|
217
|
+
: [
|
|
218
|
+
"width",
|
|
219
|
+
"height",
|
|
220
|
+
].includes(property)
|
|
221
|
+
? [
|
|
222
|
+
"sizing",
|
|
223
|
+
"dimension",
|
|
224
|
+
]
|
|
225
|
+
: [];
|
|
226
|
+
const normalize = (value) => {
|
|
227
|
+
try {
|
|
228
|
+
return color ? toHexColor(value).toLowerCase() : value.replace(/^(-?[\d.]+)px$/, "$1");
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
result[property] = [
|
|
235
|
+
...effective,
|
|
236
|
+
]
|
|
237
|
+
.filter(([, t]) => types.includes(t.type) &&
|
|
238
|
+
typeof t.value === "string" &&
|
|
239
|
+
normalize(t.value) === normalize(raw))
|
|
240
|
+
.map(([name, t]) => `${t.set}/${name}`)
|
|
241
|
+
.sort();
|
|
94
242
|
}
|
|
95
243
|
return result;
|
|
96
244
|
}
|
|
97
245
|
export async function measureComponentPilot(pilot, tokens, sourceHash, renderer) {
|
|
98
246
|
const raw = await renderer(structuredClone(pilot.cases));
|
|
99
|
-
if (!Array.isArray(raw) ||
|
|
247
|
+
if (!Array.isArray(raw) ||
|
|
248
|
+
raw.length !== pilot.cases.length ||
|
|
249
|
+
JSON.stringify(raw).length > 4 * 1024 * 1024) {
|
|
100
250
|
throw new Error("Renderer must return exactly one bounded observation per case");
|
|
101
|
-
|
|
102
|
-
|
|
251
|
+
}
|
|
252
|
+
const byId = new Map(raw.map((v) => [
|
|
253
|
+
v?.id,
|
|
254
|
+
v,
|
|
255
|
+
]));
|
|
256
|
+
if (byId.size !== raw.length) {
|
|
103
257
|
throw new Error("Duplicate renderer case");
|
|
104
|
-
|
|
258
|
+
}
|
|
259
|
+
const cases = pilot.cases.map((c) => {
|
|
105
260
|
const measurement = validateMeasurement(byId.get(c.id), c.id);
|
|
106
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
...c,
|
|
263
|
+
measurement,
|
|
264
|
+
tokenCandidates: Object.fromEntries(Object.entries(measurement.parts).map(([name, fields]) => [
|
|
265
|
+
name,
|
|
266
|
+
componentTokenCandidates(fields, tokens, c.theme),
|
|
267
|
+
])),
|
|
268
|
+
};
|
|
107
269
|
});
|
|
108
|
-
const body = {
|
|
109
|
-
|
|
270
|
+
const body = {
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
kind: "component-pilot",
|
|
273
|
+
sourceHash,
|
|
274
|
+
marwesVersion: tokens.marwesVersion,
|
|
275
|
+
cases,
|
|
276
|
+
nativeLibrary: "gated-on-instance-override-proof",
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
...body,
|
|
280
|
+
hash: digest(body),
|
|
281
|
+
};
|
|
110
282
|
}
|
|
111
283
|
export function validateComponentSnapshot(raw) {
|
|
112
284
|
const v = raw;
|
|
113
|
-
if (!v ||
|
|
285
|
+
if (!v ||
|
|
286
|
+
v.schemaVersion !== 1 ||
|
|
287
|
+
v.kind !== "component-pilot" ||
|
|
288
|
+
v.nativeLibrary !== "gated-on-instance-override-proof" ||
|
|
289
|
+
!/^[a-f0-9]{64}$/.test(v.sourceHash) ||
|
|
290
|
+
!Array.isArray(v.cases) ||
|
|
291
|
+
v.cases.length > 60) {
|
|
114
292
|
throw new Error("Unsupported component snapshot");
|
|
293
|
+
}
|
|
115
294
|
const { hash, ...body } = v;
|
|
116
|
-
if (hash !== digest(body))
|
|
295
|
+
if (hash !== digest(body)) {
|
|
117
296
|
throw new Error("Component snapshot hash mismatch");
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
297
|
+
}
|
|
298
|
+
const pilot = readComponentPilot({
|
|
299
|
+
marwes: {
|
|
300
|
+
components: {
|
|
301
|
+
schemaVersion: 1,
|
|
302
|
+
renderer: "unused",
|
|
303
|
+
sources: [
|
|
304
|
+
"unused",
|
|
305
|
+
],
|
|
306
|
+
snapshot: "unused",
|
|
307
|
+
cases: v.cases,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
for (const c of pilot.cases) {
|
|
312
|
+
validateMeasurement(v.cases.find((m) => m.id === c.id)?.measurement, c.id);
|
|
313
|
+
}
|
|
121
314
|
return v;
|
|
122
315
|
}
|
|
123
316
|
export function diffComponentSnapshots(before, after) {
|
|
124
317
|
const changes = [];
|
|
125
|
-
const ids = new Set([
|
|
318
|
+
const ids = new Set([
|
|
319
|
+
...before.cases,
|
|
320
|
+
...after.cases,
|
|
321
|
+
].map((c) => c.id));
|
|
126
322
|
for (const id of ids) {
|
|
127
|
-
const a = before.cases.find(c
|
|
128
|
-
|
|
129
|
-
|
|
323
|
+
const a = before.cases.find((c) => c.id === id);
|
|
324
|
+
const b = after.cases.find((c) => c.id === id);
|
|
325
|
+
if (canonical(a ?? null) !== canonical(b ?? null)) {
|
|
326
|
+
changes.push({
|
|
327
|
+
id,
|
|
328
|
+
before: a ?? null,
|
|
329
|
+
after: b ?? null,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
130
332
|
}
|
|
131
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
schemaVersion: 1,
|
|
335
|
+
identical: before.hash === after.hash,
|
|
336
|
+
sourceChanged: before.sourceHash !== after.sourceHash,
|
|
337
|
+
changes,
|
|
338
|
+
semanticBindings: "not-inferred",
|
|
339
|
+
instanceOverrides: "not-tested",
|
|
340
|
+
};
|
|
132
341
|
}
|
|
133
342
|
export async function componentsCommand(context, args) {
|
|
134
343
|
const [sub, ...rest] = args;
|
|
135
|
-
const { values, positionals } = parseCommandArgs({
|
|
344
|
+
const { values, positionals } = parseCommandArgs({
|
|
345
|
+
args: rest,
|
|
346
|
+
allowPositionals: true,
|
|
347
|
+
options: {
|
|
348
|
+
write: {
|
|
349
|
+
type: "boolean",
|
|
350
|
+
},
|
|
351
|
+
measure: {
|
|
352
|
+
type: "boolean",
|
|
353
|
+
},
|
|
354
|
+
json: {
|
|
355
|
+
type: "boolean",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
});
|
|
136
359
|
if (sub === "schema") {
|
|
137
|
-
if (positionals.length || Object.keys(values).some(k => k !== "json"))
|
|
360
|
+
if (positionals.length || Object.keys(values).some((k) => k !== "json")) {
|
|
138
361
|
throw new Error("components schema accepts only --json");
|
|
362
|
+
}
|
|
139
363
|
context.output?.result(componentSnapshotSchema);
|
|
140
364
|
(context.output?.log ?? console.log)(JSON.stringify(componentSnapshotSchema));
|
|
141
365
|
return 0;
|
|
142
366
|
}
|
|
143
|
-
if (![
|
|
367
|
+
if (![
|
|
368
|
+
"snapshot",
|
|
369
|
+
"check",
|
|
370
|
+
"diff",
|
|
371
|
+
"build",
|
|
372
|
+
].includes(sub ?? "") ||
|
|
373
|
+
positionals.length ||
|
|
374
|
+
(values.write && sub !== "snapshot") ||
|
|
375
|
+
(values.measure && sub !== "check")) {
|
|
144
376
|
throw new Error("Usage: marwes components snapshot [--write] | check [--measure] | diff | build");
|
|
145
|
-
|
|
377
|
+
}
|
|
378
|
+
if (sub === "build") {
|
|
146
379
|
throw new Error("Native library generation is gated: first prove preserved text/style instance overrides with the pinned Penpot API in an owner-authorized scratch file. No library was generated.");
|
|
380
|
+
}
|
|
147
381
|
const pilot = readComponentPilot(context.rawConfig);
|
|
148
382
|
const expectation = await computeExpectation(context.root, context.rawConfig, context.config.instance);
|
|
149
|
-
if (expectation.problems.length)
|
|
383
|
+
if (expectation.problems.length) {
|
|
150
384
|
throw new Error(expectation.problems.join("; "));
|
|
385
|
+
}
|
|
151
386
|
const sourceHash = await componentSourceHash(context.root, pilot, expectation.fresh);
|
|
152
387
|
let prior;
|
|
153
388
|
try {
|
|
154
389
|
prior = validateComponentSnapshot(JSON.parse(await boundedRead(context.root, pilot.snapshot)));
|
|
155
390
|
}
|
|
156
391
|
catch (error) {
|
|
157
|
-
if (error.code !== "ENOENT")
|
|
392
|
+
if (error.code !== "ENOENT") {
|
|
158
393
|
throw error;
|
|
394
|
+
}
|
|
159
395
|
}
|
|
160
396
|
if (sub === "check" && !values.measure) {
|
|
161
397
|
const current = prior?.sourceHash === sourceHash;
|
|
162
|
-
const report = {
|
|
398
|
+
const report = {
|
|
399
|
+
schemaVersion: 1,
|
|
400
|
+
current,
|
|
401
|
+
kind: "source-freshness",
|
|
402
|
+
anatomyMeasured: false,
|
|
403
|
+
nativeLibrary: "gated-on-instance-override-proof",
|
|
404
|
+
};
|
|
163
405
|
context.output?.result(report);
|
|
164
406
|
(context.output?.log ?? console.log)(JSON.stringify(report));
|
|
165
407
|
return current ? 0 : 2;
|
|
166
408
|
}
|
|
167
409
|
// Explicit component commands alone execute the project renderer. Like theme modules, this is trusted local code, not a sandbox.
|
|
168
410
|
const file = await resolveProjectInput(context.root, pilot.renderer);
|
|
169
|
-
const module = await createJiti(file, {
|
|
170
|
-
|
|
411
|
+
const module = (await createJiti(file, {
|
|
412
|
+
interopDefault: true,
|
|
413
|
+
moduleCache: false,
|
|
414
|
+
tryNative: false,
|
|
415
|
+
}).evalModule(await boundedRead(context.root, pilot.renderer), {
|
|
416
|
+
filename: file,
|
|
417
|
+
async: true,
|
|
418
|
+
forceTranspile: true,
|
|
419
|
+
}));
|
|
420
|
+
if (typeof module.measure !== "function") {
|
|
171
421
|
throw new Error("Component renderer must export async measure(cases); install its optional browser dependency in the project");
|
|
422
|
+
}
|
|
172
423
|
const fresh = await measureComponentPilot(pilot, expectation.fresh, sourceHash, module.measure);
|
|
173
|
-
if (await componentSourceHash(context.root, pilot, (await computeExpectation(context.root, context.rawConfig, context.config.instance)).fresh) !== sourceHash)
|
|
424
|
+
if ((await componentSourceHash(context.root, pilot, (await computeExpectation(context.root, context.rawConfig, context.config.instance)).fresh)) !== sourceHash) {
|
|
174
425
|
throw new Error("Component source changed during measurement");
|
|
175
|
-
|
|
176
|
-
const
|
|
426
|
+
}
|
|
427
|
+
const complete = fresh.cases.every((c) => c.measurement.environment.fontsReady &&
|
|
428
|
+
Object.values(c.measurement.parts).every((fields) => Object.values(fields).every((v) => v !== null)));
|
|
429
|
+
const report = {
|
|
430
|
+
schemaVersion: 1,
|
|
431
|
+
complete,
|
|
432
|
+
current: prior?.hash === fresh.hash,
|
|
433
|
+
comparison: prior ? diffComponentSnapshots(prior, fresh) : null,
|
|
434
|
+
snapshot: fresh,
|
|
435
|
+
written: false,
|
|
436
|
+
};
|
|
177
437
|
if (sub === "snapshot" && values.write) {
|
|
178
|
-
if (!complete)
|
|
438
|
+
if (!complete) {
|
|
179
439
|
throw new Error("Incomplete measurements/fonts: previous component snapshot preserved");
|
|
440
|
+
}
|
|
180
441
|
const data = JSON.stringify(fresh, null, 2) + "\n";
|
|
181
|
-
if (context.redact && context.redact(data) !== data)
|
|
442
|
+
if (context.redact && context.redact(data) !== data) {
|
|
182
443
|
throw new Error("Component evidence requires redaction; snapshot not published");
|
|
444
|
+
}
|
|
183
445
|
const target = await resolveProjectOutput(context.root, pilot.snapshot);
|
|
184
|
-
await mkdir(path.dirname(target), {
|
|
185
|
-
|
|
446
|
+
await mkdir(path.dirname(target), {
|
|
447
|
+
recursive: true,
|
|
448
|
+
});
|
|
449
|
+
await atomicWriteFile(target, data, {
|
|
450
|
+
replace: true,
|
|
451
|
+
});
|
|
186
452
|
report.written = true;
|
|
187
453
|
}
|
|
188
454
|
context.output?.result(report);
|
|
@@ -190,7 +456,42 @@ export async function componentsCommand(context, args) {
|
|
|
190
456
|
return !complete ? 3 : report.written || report.current ? 0 : 2;
|
|
191
457
|
}
|
|
192
458
|
export const componentSnapshotSchema = {
|
|
193
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
194
|
-
|
|
195
|
-
|
|
459
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
460
|
+
$id: "urn:penpair:schema:v1:component-pilot",
|
|
461
|
+
type: "object",
|
|
462
|
+
required: [
|
|
463
|
+
"schemaVersion",
|
|
464
|
+
"kind",
|
|
465
|
+
"sourceHash",
|
|
466
|
+
"hash",
|
|
467
|
+
"marwesVersion",
|
|
468
|
+
"cases",
|
|
469
|
+
"nativeLibrary",
|
|
470
|
+
],
|
|
471
|
+
properties: {
|
|
472
|
+
schemaVersion: {
|
|
473
|
+
const: 1,
|
|
474
|
+
},
|
|
475
|
+
kind: {
|
|
476
|
+
const: "component-pilot",
|
|
477
|
+
},
|
|
478
|
+
sourceHash: {
|
|
479
|
+
type: "string",
|
|
480
|
+
},
|
|
481
|
+
hash: {
|
|
482
|
+
type: "string",
|
|
483
|
+
},
|
|
484
|
+
marwesVersion: {
|
|
485
|
+
type: "string",
|
|
486
|
+
},
|
|
487
|
+
cases: {
|
|
488
|
+
type: "array",
|
|
489
|
+
minItems: 6,
|
|
490
|
+
maxItems: 60,
|
|
491
|
+
},
|
|
492
|
+
nativeLibrary: {
|
|
493
|
+
const: "gated-on-instance-override-proof",
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
additionalProperties: true,
|
|
196
497
|
};
|