@penpair/marwes-ui 0.1.3 → 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.d.ts +3 -0
- package/dist/build-kit.js +93 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +633 -92
- package/dist/components.d.ts +95 -0
- package/dist/components.js +497 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +80 -7
- package/dist/index.d.ts +13 -0
- package/dist/index.js +30 -1
- package/dist/marwes.js +45 -12
- package/dist/mode-trial.d.ts +95 -0
- package/dist/mode-trial.js +479 -0
- package/dist/ownership.d.ts +26 -0
- package/dist/ownership.js +222 -0
- package/dist/penpot.d.ts +21 -3
- package/dist/penpot.js +519 -87
- package/dist/reconciliation.d.ts +6 -0
- package/dist/reconciliation.js +179 -0
- package/dist/resolved-values.d.ts +11 -0
- package/dist/resolved-values.js +124 -0
- package/dist/snapshot-file.d.ts +45 -0
- package/dist/snapshot-file.js +282 -40
- package/dist/theme-input.js +18 -7
- package/dist/theme-lint.d.ts +31 -0
- package/dist/theme-lint.js +145 -0
- package/dist/tokens.d.ts +6 -2
- package/dist/tokens.js +221 -45
- package/package.json +2 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type AdapterContext } from "@penpair/core";
|
|
2
|
+
import { type TokenSnapshot } from "./tokens.js";
|
|
3
|
+
export declare const componentProperties: readonly ["width", "height", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "rowGap", "columnGap", "borderRadius", "borderWidth", "borderColor", "backgroundColor", "color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "boxShadow"];
|
|
4
|
+
export interface ComponentCase {
|
|
5
|
+
id: string;
|
|
6
|
+
component: "Button" | "Card" | "StatTile";
|
|
7
|
+
theme: "light" | "dark";
|
|
8
|
+
state: string;
|
|
9
|
+
viewport: {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
selector: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ComponentPilot {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
renderer: string;
|
|
18
|
+
sources: string[];
|
|
19
|
+
snapshot: string;
|
|
20
|
+
cases: ComponentCase[];
|
|
21
|
+
}
|
|
22
|
+
export interface ComponentMeasurement {
|
|
23
|
+
id: string;
|
|
24
|
+
/** All declared anatomy selectors must be observed; null properties are explicit unknowns. */
|
|
25
|
+
parts: Record<string, Record<string, string | null>>;
|
|
26
|
+
environment: {
|
|
27
|
+
browser: string;
|
|
28
|
+
fontsReady: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface ComponentSnapshot {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
kind: "component-pilot";
|
|
34
|
+
sourceHash: string;
|
|
35
|
+
hash: string;
|
|
36
|
+
marwesVersion: string;
|
|
37
|
+
cases: Array<ComponentCase & {
|
|
38
|
+
measurement: ComponentMeasurement;
|
|
39
|
+
tokenCandidates: Record<string, Record<string, string[]>>;
|
|
40
|
+
}>;
|
|
41
|
+
nativeLibrary: "gated-on-instance-override-proof";
|
|
42
|
+
}
|
|
43
|
+
export type ComponentRenderer = (cases: ComponentCase[]) => Promise<unknown>;
|
|
44
|
+
export declare function readComponentPilot(raw: Record<string, unknown>): ComponentPilot;
|
|
45
|
+
/** Hash declared source bytes as well as effective tokens: comments and non-token anatomy changes invalidate cheap CI checks. */
|
|
46
|
+
export declare function componentSourceHash(root: string, pilot: ComponentPilot, tokens: TokenSnapshot): Promise<string>;
|
|
47
|
+
/** Exact value candidates are evidence of equality only. Even one candidate is not a semantic binding. */
|
|
48
|
+
export declare function componentTokenCandidates(fields: Record<string, string | null>, tokens: TokenSnapshot, mode: "light" | "dark"): Record<string, string[]>;
|
|
49
|
+
export declare function measureComponentPilot(pilot: ComponentPilot, tokens: TokenSnapshot, sourceHash: string, renderer: ComponentRenderer): Promise<ComponentSnapshot>;
|
|
50
|
+
export declare function validateComponentSnapshot(raw: unknown): ComponentSnapshot;
|
|
51
|
+
export declare function diffComponentSnapshots(before: ComponentSnapshot, after: ComponentSnapshot): {
|
|
52
|
+
schemaVersion: number;
|
|
53
|
+
identical: boolean;
|
|
54
|
+
sourceChanged: boolean;
|
|
55
|
+
changes: {
|
|
56
|
+
id: string;
|
|
57
|
+
before: unknown;
|
|
58
|
+
after: unknown;
|
|
59
|
+
}[];
|
|
60
|
+
semanticBindings: string;
|
|
61
|
+
instanceOverrides: string;
|
|
62
|
+
};
|
|
63
|
+
export declare function componentsCommand(context: AdapterContext, args: string[]): Promise<number>;
|
|
64
|
+
export declare const componentSnapshotSchema: {
|
|
65
|
+
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
66
|
+
readonly $id: "urn:penpair:schema:v1:component-pilot";
|
|
67
|
+
readonly type: "object";
|
|
68
|
+
readonly required: readonly ["schemaVersion", "kind", "sourceHash", "hash", "marwesVersion", "cases", "nativeLibrary"];
|
|
69
|
+
readonly properties: {
|
|
70
|
+
readonly schemaVersion: {
|
|
71
|
+
readonly const: 1;
|
|
72
|
+
};
|
|
73
|
+
readonly kind: {
|
|
74
|
+
readonly const: "component-pilot";
|
|
75
|
+
};
|
|
76
|
+
readonly sourceHash: {
|
|
77
|
+
readonly type: "string";
|
|
78
|
+
};
|
|
79
|
+
readonly hash: {
|
|
80
|
+
readonly type: "string";
|
|
81
|
+
};
|
|
82
|
+
readonly marwesVersion: {
|
|
83
|
+
readonly type: "string";
|
|
84
|
+
};
|
|
85
|
+
readonly cases: {
|
|
86
|
+
readonly type: "array";
|
|
87
|
+
readonly minItems: 6;
|
|
88
|
+
readonly maxItems: 60;
|
|
89
|
+
};
|
|
90
|
+
readonly nativeLibrary: {
|
|
91
|
+
readonly const: "gated-on-instance-override-proof";
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
readonly additionalProperties: true;
|
|
95
|
+
};
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import { atomicWriteFile, parseCommandArgs, resolveProjectInput, resolveProjectOutput, } from "@penpair/core";
|
|
6
|
+
import { computeExpectation } from "./commands.js";
|
|
7
|
+
import { canonical, toHexColor } from "./tokens.js";
|
|
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
|
+
];
|
|
28
|
+
const digest = (v) => createHash("sha256").update(canonical(v)).digest("hex");
|
|
29
|
+
const text = (v, label, max = 500) => {
|
|
30
|
+
if (typeof v !== "string" || !v.trim() || v.length > max || /[\x00-\x1f]/.test(v)) {
|
|
31
|
+
throw new Error(`Invalid component ${label}`);
|
|
32
|
+
}
|
|
33
|
+
return v;
|
|
34
|
+
};
|
|
35
|
+
export function readComponentPilot(raw) {
|
|
36
|
+
const value = raw.marwes?.components;
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
38
|
+
throw new Error("Configure marwes.components with a trusted renderer, sources and explicit pilot cases");
|
|
39
|
+
}
|
|
40
|
+
const v = value;
|
|
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) {
|
|
48
|
+
throw new Error("Invalid schema-1 component pilot (max 100 sources, 60 cases)");
|
|
49
|
+
}
|
|
50
|
+
const ids = new Set();
|
|
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)) {
|
|
62
|
+
throw new Error("Pilot supports Button/Card/StatTile and explicit light/dark themes");
|
|
63
|
+
}
|
|
64
|
+
const id = text(c.id, "case id");
|
|
65
|
+
if (ids.has(id)) {
|
|
66
|
+
throw new Error("Duplicate component case id");
|
|
67
|
+
}
|
|
68
|
+
ids.add(id);
|
|
69
|
+
if (![
|
|
70
|
+
c.viewport?.width,
|
|
71
|
+
c.viewport?.height,
|
|
72
|
+
].every((n) => Number.isInteger(n) && n > 0 && n <= 8192)) {
|
|
73
|
+
throw new Error("Invalid component viewport");
|
|
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
|
+
};
|
|
85
|
+
});
|
|
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)) {
|
|
96
|
+
throw new Error(`Pilot requires ${component} in ${theme}`);
|
|
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
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function boundedRead(root, relative) {
|
|
109
|
+
const file = await resolveProjectInput(root, relative);
|
|
110
|
+
if ((await stat(file)).size > 4 * 1024 * 1024) {
|
|
111
|
+
throw new Error("Component input exceeds 4 MiB");
|
|
112
|
+
}
|
|
113
|
+
return readFile(file, "utf8");
|
|
114
|
+
}
|
|
115
|
+
/** Hash declared source bytes as well as effective tokens: comments and non-token anatomy changes invalidate cheap CI checks. */
|
|
116
|
+
export async function componentSourceHash(root, pilot, tokens) {
|
|
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
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function validateMeasurement(raw, id) {
|
|
135
|
+
const v = raw;
|
|
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") {
|
|
145
|
+
throw new Error(`Incomplete component measurement: ${id}`);
|
|
146
|
+
}
|
|
147
|
+
const parts = Object.create(null);
|
|
148
|
+
for (const [name, fields] of Object.entries(v.parts)) {
|
|
149
|
+
text(name, "part", 100);
|
|
150
|
+
if (!fields ||
|
|
151
|
+
typeof fields !== "object" ||
|
|
152
|
+
Array.isArray(fields) ||
|
|
153
|
+
Object.keys(fields).some((k) => !componentProperties.includes(k))) {
|
|
154
|
+
throw new Error("Invalid measured anatomy properties");
|
|
155
|
+
}
|
|
156
|
+
parts[name] = Object.fromEntries(componentProperties.map((key) => {
|
|
157
|
+
const value = fields[key];
|
|
158
|
+
if (value !== null && (typeof value !== "string" || value.length > 2000)) {
|
|
159
|
+
throw new Error(`Missing/invalid ${id}/${name}/${key}; use null for unknown`);
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
key,
|
|
163
|
+
value,
|
|
164
|
+
];
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
parts,
|
|
170
|
+
environment: {
|
|
171
|
+
browser: text(v.environment.browser, "browser"),
|
|
172
|
+
fontsReady: v.environment.fontsReady,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Exact value candidates are evidence of equality only. Even one candidate is not a semantic binding. */
|
|
177
|
+
export function componentTokenCandidates(fields, tokens, mode) {
|
|
178
|
+
const selected = tokens.themes.find((t) => t.group === "Mode" && t.name.toLowerCase() === mode);
|
|
179
|
+
if (!selected) {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
const effective = new Map();
|
|
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
|
+
}
|
|
192
|
+
const result = Object.create(null);
|
|
193
|
+
for (const [property, raw] of Object.entries(fields)) {
|
|
194
|
+
if (raw === null) {
|
|
195
|
+
result[property] = [];
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
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();
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
export async function measureComponentPilot(pilot, tokens, sourceHash, renderer) {
|
|
246
|
+
const raw = await renderer(structuredClone(pilot.cases));
|
|
247
|
+
if (!Array.isArray(raw) ||
|
|
248
|
+
raw.length !== pilot.cases.length ||
|
|
249
|
+
JSON.stringify(raw).length > 4 * 1024 * 1024) {
|
|
250
|
+
throw new Error("Renderer must return exactly one bounded observation per case");
|
|
251
|
+
}
|
|
252
|
+
const byId = new Map(raw.map((v) => [
|
|
253
|
+
v?.id,
|
|
254
|
+
v,
|
|
255
|
+
]));
|
|
256
|
+
if (byId.size !== raw.length) {
|
|
257
|
+
throw new Error("Duplicate renderer case");
|
|
258
|
+
}
|
|
259
|
+
const cases = pilot.cases.map((c) => {
|
|
260
|
+
const measurement = validateMeasurement(byId.get(c.id), c.id);
|
|
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
|
+
};
|
|
269
|
+
});
|
|
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
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export function validateComponentSnapshot(raw) {
|
|
284
|
+
const v = raw;
|
|
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) {
|
|
292
|
+
throw new Error("Unsupported component snapshot");
|
|
293
|
+
}
|
|
294
|
+
const { hash, ...body } = v;
|
|
295
|
+
if (hash !== digest(body)) {
|
|
296
|
+
throw new Error("Component snapshot hash mismatch");
|
|
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
|
+
}
|
|
314
|
+
return v;
|
|
315
|
+
}
|
|
316
|
+
export function diffComponentSnapshots(before, after) {
|
|
317
|
+
const changes = [];
|
|
318
|
+
const ids = new Set([
|
|
319
|
+
...before.cases,
|
|
320
|
+
...after.cases,
|
|
321
|
+
].map((c) => c.id));
|
|
322
|
+
for (const id of ids) {
|
|
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
|
+
}
|
|
332
|
+
}
|
|
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
|
+
};
|
|
341
|
+
}
|
|
342
|
+
export async function componentsCommand(context, args) {
|
|
343
|
+
const [sub, ...rest] = args;
|
|
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
|
+
});
|
|
359
|
+
if (sub === "schema") {
|
|
360
|
+
if (positionals.length || Object.keys(values).some((k) => k !== "json")) {
|
|
361
|
+
throw new Error("components schema accepts only --json");
|
|
362
|
+
}
|
|
363
|
+
context.output?.result(componentSnapshotSchema);
|
|
364
|
+
(context.output?.log ?? console.log)(JSON.stringify(componentSnapshotSchema));
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
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")) {
|
|
376
|
+
throw new Error("Usage: marwes components snapshot [--write] | check [--measure] | diff | build");
|
|
377
|
+
}
|
|
378
|
+
if (sub === "build") {
|
|
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
|
+
}
|
|
381
|
+
const pilot = readComponentPilot(context.rawConfig);
|
|
382
|
+
const expectation = await computeExpectation(context.root, context.rawConfig, context.config.instance);
|
|
383
|
+
if (expectation.problems.length) {
|
|
384
|
+
throw new Error(expectation.problems.join("; "));
|
|
385
|
+
}
|
|
386
|
+
const sourceHash = await componentSourceHash(context.root, pilot, expectation.fresh);
|
|
387
|
+
let prior;
|
|
388
|
+
try {
|
|
389
|
+
prior = validateComponentSnapshot(JSON.parse(await boundedRead(context.root, pilot.snapshot)));
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (error.code !== "ENOENT") {
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (sub === "check" && !values.measure) {
|
|
397
|
+
const current = prior?.sourceHash === sourceHash;
|
|
398
|
+
const report = {
|
|
399
|
+
schemaVersion: 1,
|
|
400
|
+
current,
|
|
401
|
+
kind: "source-freshness",
|
|
402
|
+
anatomyMeasured: false,
|
|
403
|
+
nativeLibrary: "gated-on-instance-override-proof",
|
|
404
|
+
};
|
|
405
|
+
context.output?.result(report);
|
|
406
|
+
(context.output?.log ?? console.log)(JSON.stringify(report));
|
|
407
|
+
return current ? 0 : 2;
|
|
408
|
+
}
|
|
409
|
+
// Explicit component commands alone execute the project renderer. Like theme modules, this is trusted local code, not a sandbox.
|
|
410
|
+
const file = await resolveProjectInput(context.root, pilot.renderer);
|
|
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") {
|
|
421
|
+
throw new Error("Component renderer must export async measure(cases); install its optional browser dependency in the project");
|
|
422
|
+
}
|
|
423
|
+
const fresh = await measureComponentPilot(pilot, expectation.fresh, sourceHash, module.measure);
|
|
424
|
+
if ((await componentSourceHash(context.root, pilot, (await computeExpectation(context.root, context.rawConfig, context.config.instance)).fresh)) !== sourceHash) {
|
|
425
|
+
throw new Error("Component source changed during measurement");
|
|
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
|
+
};
|
|
437
|
+
if (sub === "snapshot" && values.write) {
|
|
438
|
+
if (!complete) {
|
|
439
|
+
throw new Error("Incomplete measurements/fonts: previous component snapshot preserved");
|
|
440
|
+
}
|
|
441
|
+
const data = JSON.stringify(fresh, null, 2) + "\n";
|
|
442
|
+
if (context.redact && context.redact(data) !== data) {
|
|
443
|
+
throw new Error("Component evidence requires redaction; snapshot not published");
|
|
444
|
+
}
|
|
445
|
+
const target = await resolveProjectOutput(context.root, pilot.snapshot);
|
|
446
|
+
await mkdir(path.dirname(target), {
|
|
447
|
+
recursive: true,
|
|
448
|
+
});
|
|
449
|
+
await atomicWriteFile(target, data, {
|
|
450
|
+
replace: true,
|
|
451
|
+
});
|
|
452
|
+
report.written = true;
|
|
453
|
+
}
|
|
454
|
+
context.output?.result(report);
|
|
455
|
+
(context.output?.log ?? console.log)(JSON.stringify(report, null, 2));
|
|
456
|
+
return !complete ? 3 : report.written || report.current ? 0 : 2;
|
|
457
|
+
}
|
|
458
|
+
export const componentSnapshotSchema = {
|
|
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,
|
|
497
|
+
};
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ThemeLintSettings } from "./theme-lint.js";
|
|
2
|
+
import type { PenpotTokenSet } from "./tokens.js";
|
|
1
3
|
import type { ThemeReference } from "./theme-input.js";
|
|
2
4
|
/** The `marwes` block of penpair.config.json. */
|
|
3
5
|
export interface MarwesSettings {
|
|
@@ -12,6 +14,9 @@ export interface MarwesSettings {
|
|
|
12
14
|
};
|
|
13
15
|
/** Where the committed snapshot lives, relative to the project. */
|
|
14
16
|
snapshot: string;
|
|
17
|
+
/** Explicit project-owned token sets, appended to both modes. */
|
|
18
|
+
extras: PenpotTokenSet[];
|
|
19
|
+
themeLint: ThemeLintSettings;
|
|
15
20
|
}
|
|
16
21
|
export declare const defaultSnapshotPath = "design/marwes-tokens.snapshot.json";
|
|
17
22
|
export declare class MarwesConfigError extends Error {
|