@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
package/dist/penpot.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { assertSafeDestruction } from "./ownership.js";
|
|
2
|
+
import { waitForStableLayout, guardRevision, writeCoordinator, runSnippet, snippet, guardFile, observationHash, readJournal, readJournalTarget, runJournaledSteps, } from "@penpair/core";
|
|
3
|
+
import { canonical, normalizeValue, } from "./tokens.js";
|
|
4
|
+
import { actionSatisfied, reconcileAction, remainingAction } from "./reconciliation.js";
|
|
3
5
|
export function readTokens() {
|
|
4
6
|
return snippet("marwes-tokens-read", `const tokens = penpot.library.local.tokens;
|
|
5
7
|
const set = (s, i) => ({ order: i, name: s.name, active: !!s.active, tokens: s.tokens.map((t) => ({ name: t.name, type: t.type, value: t.value, description: t.description || "" })) });
|
|
@@ -8,9 +10,10 @@ return { ok: true, readOnly: true, sets: tokens.sets.map(set),
|
|
|
8
10
|
}
|
|
9
11
|
/** How many shapes use each token, over every page: what a change of a token would reach. */
|
|
10
12
|
export function bindingCounts() {
|
|
11
|
-
return snippet("marwes-tokens-bindings", `const counts =
|
|
12
|
-
const
|
|
13
|
-
for
|
|
13
|
+
return snippet("marwes-tokens-bindings", `const counts = Object.create(null); let shapes = 0;
|
|
14
|
+
const stack=[],seen=new Set();
|
|
15
|
+
for(const page of penpot.currentFile.pages)for(const shape of page.root.children||[])stack.push(shape);
|
|
16
|
+
while(stack.length){if(++shapes>50000)throw new Error("Binding inventory exceeds 50000 shapes");const s=stack.pop();if(!s.id||seen.has(s.id))throw new Error("Invalid binding inventory identity");seen.add(s.id);const b=s.tokens||{};for(const name of Object.values(b)){if(typeof name!=="string")throw new Error("Unknown token binding");counts[name]=(counts[name]||0)+1;}for(const child of s.children||[])stack.push(child);}
|
|
14
17
|
return { ok: true, readOnly: true, shapes, counts };`);
|
|
15
18
|
}
|
|
16
19
|
/** A hash of everything structural on every page (ids, kinds, geometry, which tokens are bound), not of colours: pushing tokens must not move or rebind a shape. */
|
|
@@ -26,54 +29,125 @@ const display = (type, value) => {
|
|
|
26
29
|
return normal.length > 90 ? `${normal.slice(0, 87)}...` : normal;
|
|
27
30
|
};
|
|
28
31
|
/** Compares the committed expectation with what the file holds. Only the sets the expectation names are judged; other sets are the project's own. */
|
|
29
|
-
export function compareToPenpot(expected, live) {
|
|
32
|
+
export function compareToPenpot(expected, live, options = {}) {
|
|
30
33
|
const sets = [];
|
|
31
34
|
for (const wanted of expected.sets) {
|
|
32
35
|
const found = live.sets.find((s) => s.name === wanted.name);
|
|
33
36
|
if (!found) {
|
|
34
|
-
sets.push({
|
|
37
|
+
sets.push({
|
|
38
|
+
set: wanted.name,
|
|
39
|
+
missing: true,
|
|
40
|
+
tokens: wanted.tokens.map((t) => ({
|
|
41
|
+
name: t.name,
|
|
42
|
+
kind: "missing",
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
35
45
|
continue;
|
|
36
46
|
}
|
|
37
47
|
const tokens = [];
|
|
38
|
-
const have = new Map(found.tokens.map((t) => [
|
|
39
|
-
|
|
48
|
+
const have = new Map(found.tokens.map((t) => [
|
|
49
|
+
t.name,
|
|
50
|
+
t,
|
|
51
|
+
]));
|
|
52
|
+
const want = new Map(wanted.tokens.map((t) => [
|
|
53
|
+
t.name,
|
|
54
|
+
t,
|
|
55
|
+
]));
|
|
40
56
|
for (const token of wanted.tokens) {
|
|
41
57
|
const actual = have.get(token.name);
|
|
42
|
-
if (!actual)
|
|
43
|
-
tokens.push({
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
else if (
|
|
49
|
-
tokens.push({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
if (!actual) {
|
|
59
|
+
tokens.push({
|
|
60
|
+
name: token.name,
|
|
61
|
+
kind: "missing",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else if (actual.type !== token.type) {
|
|
65
|
+
tokens.push({
|
|
66
|
+
name: token.name,
|
|
67
|
+
kind: "type",
|
|
68
|
+
expected: token.type,
|
|
69
|
+
actual: actual.type,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (normalizeValue(token.type, token.value) !== normalizeValue(actual.type, actual.value)) {
|
|
73
|
+
tokens.push({
|
|
74
|
+
name: token.name,
|
|
75
|
+
kind: "value",
|
|
76
|
+
expected: display(token.type, token.value),
|
|
77
|
+
actual: display(actual.type, actual.value),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else if (token.description !== actual.description) {
|
|
81
|
+
tokens.push({
|
|
82
|
+
name: token.name,
|
|
83
|
+
kind: "description",
|
|
84
|
+
expected: token.description,
|
|
85
|
+
actual: actual.description,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const name of have.keys()) {
|
|
90
|
+
if (!want.has(name)) {
|
|
91
|
+
tokens.push({
|
|
92
|
+
name,
|
|
93
|
+
kind: "extra",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (tokens.length > 0) {
|
|
98
|
+
sets.push({
|
|
99
|
+
set: wanted.name,
|
|
100
|
+
tokens,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
56
103
|
}
|
|
57
104
|
const themes = [];
|
|
58
105
|
for (const wanted of expected.themes) {
|
|
59
106
|
const found = live.themes.find((t) => t.group === wanted.group && t.name === wanted.name);
|
|
60
107
|
if (!found) {
|
|
61
|
-
themes.push({
|
|
108
|
+
themes.push({
|
|
109
|
+
theme: `${wanted.group}/${wanted.name}`,
|
|
110
|
+
missing: true,
|
|
111
|
+
missingSets: wanted.sets,
|
|
112
|
+
extraSets: [],
|
|
113
|
+
});
|
|
62
114
|
continue;
|
|
63
115
|
}
|
|
64
116
|
const missingSets = wanted.sets.filter((s) => !found.sets.includes(s));
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
117
|
+
const owned = new Set([
|
|
118
|
+
...expected.sets,
|
|
119
|
+
...(options.ancestor?.sets ?? []),
|
|
120
|
+
].map((set) => set.name));
|
|
121
|
+
const extraSets = found.sets.filter((s) => !wanted.sets.includes(s) && owned.has(s));
|
|
122
|
+
if (missingSets.length || extraSets.length) {
|
|
123
|
+
themes.push({
|
|
124
|
+
theme: `${wanted.group}/${wanted.name}`,
|
|
125
|
+
missingSets,
|
|
126
|
+
extraSets,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
68
129
|
}
|
|
69
130
|
const managed = new Set(expected.sets.map((s) => s.name));
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
131
|
+
const unexpectedSets = live.sets
|
|
132
|
+
.map((set) => set.name)
|
|
133
|
+
.filter((name) => options.ancestor?.sets.some((set) => set.name === name) && !managed.has(name));
|
|
134
|
+
const actionable = sets.some((set) => set.missing ||
|
|
135
|
+
set.tokens.some((token) => token.kind !== "extra" ||
|
|
136
|
+
options.ancestor?.sets
|
|
137
|
+
.find((old) => old.name === set.set)
|
|
138
|
+
?.tokens.some((old) => old.name === token.name)));
|
|
139
|
+
return {
|
|
140
|
+
inSync: !actionable && themes.length === 0,
|
|
141
|
+
sets,
|
|
142
|
+
themes,
|
|
143
|
+
unexpectedSets,
|
|
144
|
+
};
|
|
73
145
|
}
|
|
74
146
|
export function formatDrift(drift, impact, limit = 6) {
|
|
75
147
|
const lines = [];
|
|
76
|
-
const used = (name) =>
|
|
148
|
+
const used = (name) => impact && impact[name]
|
|
149
|
+
? ` (used by ${impact[name]} shape${impact[name] === 1 ? "" : "s"})`
|
|
150
|
+
: "";
|
|
77
151
|
for (const s of drift.sets) {
|
|
78
152
|
if (s.missing) {
|
|
79
153
|
lines.push(`${s.set}: missing in Penpot (${s.tokens.length} tokens)`);
|
|
@@ -83,69 +157,142 @@ export function formatDrift(drift, impact, limit = 6) {
|
|
|
83
157
|
const extra = s.tokens.filter((t) => t.kind === "extra");
|
|
84
158
|
const changed = s.tokens.filter((t) => t.kind === "value" || t.kind === "type");
|
|
85
159
|
const described = s.tokens.filter((t) => t.kind === "description");
|
|
86
|
-
const cut = (items) =>
|
|
87
|
-
|
|
160
|
+
const cut = (items) => items.length > limit
|
|
161
|
+
? `${items.slice(0, limit).join("; ")}; and ${items.length - limit} more`
|
|
162
|
+
: items.join("; ");
|
|
163
|
+
if (missing.length) {
|
|
88
164
|
lines.push(`${s.set}: ${missing.length} missing in Penpot: ${cut(missing.map((t) => t.name))}`);
|
|
89
|
-
|
|
165
|
+
}
|
|
166
|
+
if (changed.length) {
|
|
90
167
|
lines.push(`${s.set}: ${changed.length} differ from the expectation: ${cut(changed.map((t) => `${t.name} is ${t.actual}, expected ${t.expected}${used(t.name)}`))}`);
|
|
91
|
-
|
|
168
|
+
}
|
|
169
|
+
if (extra.length) {
|
|
92
170
|
lines.push(`${s.set}: ${extra.length} not in the expectation (added by hand?): ${cut(extra.map((t) => t.name))}`);
|
|
93
|
-
|
|
171
|
+
}
|
|
172
|
+
if (described.length) {
|
|
94
173
|
lines.push(`${s.set}: ${described.length} carry an old description (the recorded Marwes version): ${cut(described.map((t) => t.name))}`);
|
|
174
|
+
}
|
|
95
175
|
}
|
|
96
176
|
for (const t of drift.themes) {
|
|
97
|
-
if (t.missing)
|
|
177
|
+
if (t.missing) {
|
|
98
178
|
lines.push(`theme ${t.theme}: missing in Penpot`);
|
|
99
|
-
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
100
181
|
lines.push(`theme ${t.theme}: ${t.missingSets.length ? `lacks ${t.missingSets.join(", ")}` : ""}${t.missingSets.length && t.extraSets.length ? "; " : ""}${t.extraSets.length ? `also has ${t.extraSets.join(", ")}` : ""}`);
|
|
182
|
+
}
|
|
101
183
|
}
|
|
102
|
-
for (const name of drift.unexpectedSets)
|
|
184
|
+
for (const name of drift.unexpectedSets) {
|
|
103
185
|
lines.push(`set ${name}: in Penpot but no longer expected`);
|
|
186
|
+
}
|
|
104
187
|
return lines;
|
|
105
188
|
}
|
|
106
189
|
/** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
|
|
107
190
|
export function planPush(expected, live, options = {}) {
|
|
108
|
-
const drift = compareToPenpot(expected, live);
|
|
191
|
+
const drift = compareToPenpot(expected, live, options);
|
|
109
192
|
const actions = [];
|
|
110
|
-
const wantedSet = new Map(expected.sets.map((s) => [
|
|
193
|
+
const wantedSet = new Map(expected.sets.map((s) => [
|
|
194
|
+
s.name,
|
|
195
|
+
s,
|
|
196
|
+
]));
|
|
111
197
|
for (const s of drift.sets) {
|
|
112
198
|
const wanted = wantedSet.get(s.set);
|
|
113
199
|
if (s.missing) {
|
|
114
|
-
actions.push({
|
|
200
|
+
actions.push({
|
|
201
|
+
kind: "createSet",
|
|
202
|
+
set: s.set,
|
|
203
|
+
}, {
|
|
204
|
+
kind: "addTokens",
|
|
205
|
+
set: s.set,
|
|
206
|
+
tokens: wanted.tokens,
|
|
207
|
+
});
|
|
115
208
|
continue;
|
|
116
209
|
}
|
|
117
|
-
const byName = new Map(wanted.tokens.map((t) => [
|
|
210
|
+
const byName = new Map(wanted.tokens.map((t) => [
|
|
211
|
+
t.name,
|
|
212
|
+
t,
|
|
213
|
+
]));
|
|
118
214
|
const add = s.tokens.filter((t) => t.kind === "missing").map((t) => byName.get(t.name));
|
|
119
215
|
// A typography value assigned in place is stored un-normalised by Penpot, so those are removed and added again.
|
|
120
|
-
const typed = s.tokens
|
|
121
|
-
|
|
216
|
+
const typed = s.tokens
|
|
217
|
+
.filter((t) => t.kind === "type" ||
|
|
218
|
+
((t.kind === "value" || t.kind === "description") &&
|
|
219
|
+
byName.get(t.name)?.type === "typography"))
|
|
220
|
+
.map((t) => byName.get(t.name));
|
|
221
|
+
const replace = [
|
|
222
|
+
...new Map(typed.map((t) => [
|
|
223
|
+
t.name,
|
|
224
|
+
t,
|
|
225
|
+
])).values(),
|
|
226
|
+
];
|
|
122
227
|
const replaced = new Set(replace.map((t) => t.name));
|
|
123
|
-
const update = s.tokens
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
actions.push({
|
|
128
|
-
|
|
129
|
-
|
|
228
|
+
const update = s.tokens
|
|
229
|
+
.filter((t) => (t.kind === "value" || t.kind === "description") && !replaced.has(t.name))
|
|
230
|
+
.map((t) => byName.get(t.name));
|
|
231
|
+
if (add.length) {
|
|
232
|
+
actions.push({
|
|
233
|
+
kind: "addTokens",
|
|
234
|
+
set: s.set,
|
|
235
|
+
tokens: add,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (update.length) {
|
|
239
|
+
actions.push({
|
|
240
|
+
kind: "updateTokens",
|
|
241
|
+
set: s.set,
|
|
242
|
+
tokens: update,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (replace.length) {
|
|
246
|
+
actions.push({
|
|
247
|
+
kind: "replaceTokens",
|
|
248
|
+
set: s.set,
|
|
249
|
+
tokens: replace,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
130
252
|
const extra = s.tokens.filter((t) => t.kind === "extra").map((t) => t.name);
|
|
131
|
-
|
|
132
|
-
|
|
253
|
+
const ownedExtra = extra.filter((name) => options.ancestor?.sets
|
|
254
|
+
.find((old) => old.name === s.set)
|
|
255
|
+
?.tokens.some((token) => token.name === name));
|
|
256
|
+
if (options.prune && ownedExtra.length) {
|
|
257
|
+
actions.push({
|
|
258
|
+
kind: "removeTokens",
|
|
259
|
+
set: s.set,
|
|
260
|
+
names: ownedExtra,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
133
263
|
}
|
|
134
264
|
const createdThemes = new Set();
|
|
135
265
|
for (const t of drift.themes) {
|
|
136
266
|
const [group, name] = t.theme.split("/");
|
|
137
267
|
if (t.missing) {
|
|
138
|
-
actions.push({
|
|
268
|
+
actions.push({
|
|
269
|
+
kind: "createTheme",
|
|
270
|
+
group,
|
|
271
|
+
name,
|
|
272
|
+
sets: expected.themes.find((x) => x.group === group && x.name === name).sets,
|
|
273
|
+
});
|
|
139
274
|
createdThemes.add(name);
|
|
140
275
|
}
|
|
141
|
-
else
|
|
142
|
-
actions.push({
|
|
276
|
+
else {
|
|
277
|
+
actions.push({
|
|
278
|
+
kind: "syncThemeSets",
|
|
279
|
+
group,
|
|
280
|
+
name,
|
|
281
|
+
add: t.missingSets,
|
|
282
|
+
remove: options.prune ? t.extraSets : [],
|
|
283
|
+
});
|
|
284
|
+
}
|
|
143
285
|
}
|
|
144
286
|
// A file that has no active theme gets Light, so the tokens take part in resolution; a file that already has one keeps its choice.
|
|
145
287
|
if (createdThemes.size > 0 && !live.themes.some((t) => t.active)) {
|
|
146
288
|
const first = expected.themes.find((t) => t.name === "Light") ?? expected.themes[0];
|
|
147
|
-
if (first && createdThemes.has(first.name))
|
|
148
|
-
actions.push({
|
|
289
|
+
if (first && createdThemes.has(first.name)) {
|
|
290
|
+
actions.push({
|
|
291
|
+
kind: "activateTheme",
|
|
292
|
+
group: first.group,
|
|
293
|
+
name: first.name,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
149
296
|
}
|
|
150
297
|
return actions.filter((a) => a.kind !== "syncThemeSets" || a.add.length > 0 || a.remove.length > 0);
|
|
151
298
|
}
|
|
@@ -166,8 +313,9 @@ export function inBatches(tokens) {
|
|
|
166
313
|
current.push(token);
|
|
167
314
|
bytes += size;
|
|
168
315
|
}
|
|
169
|
-
if (current.length > 0)
|
|
316
|
+
if (current.length > 0) {
|
|
170
317
|
batches.push(current);
|
|
318
|
+
}
|
|
171
319
|
return batches;
|
|
172
320
|
}
|
|
173
321
|
const findSet = `const found = penpot.library.local.tokens.sets.filter((s) => s.name === CFG.set);
|
|
@@ -181,7 +329,9 @@ if (t.sets.some((s) => s.name === CFG.set)) return { ok: true, existed: true };
|
|
|
181
329
|
const set = t.addSet({ name: CFG.set });
|
|
182
330
|
// A new set must not take part in resolution before a theme says so.
|
|
183
331
|
if (set.active) set.toggleActive();
|
|
184
|
-
return { ok: true, active: !!set.active };`, {
|
|
332
|
+
return { ok: true, active: !!set.active };`, {
|
|
333
|
+
set: action.set,
|
|
334
|
+
});
|
|
185
335
|
case "addTokens":
|
|
186
336
|
return snippet("marwes-add-tokens", `${findSet}
|
|
187
337
|
const failed = [];
|
|
@@ -189,7 +339,10 @@ for (const t of CFG.tokens) {
|
|
|
189
339
|
try { if (set.tokens.some((x) => x.name === t.name)) { failed.push(t.name + ": already there"); continue; } set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" }); }
|
|
190
340
|
catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
191
341
|
}
|
|
192
|
-
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
|
|
342
|
+
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
|
|
343
|
+
set: action.set,
|
|
344
|
+
tokens,
|
|
345
|
+
});
|
|
193
346
|
case "updateTokens":
|
|
194
347
|
return snippet("marwes-update-tokens", `${findSet}
|
|
195
348
|
const failed = [];
|
|
@@ -198,7 +351,10 @@ for (const t of CFG.tokens) {
|
|
|
198
351
|
if (found.length !== 1) { failed.push(t.name + ": not found or ambiguous"); continue; }
|
|
199
352
|
try { found[0].value = t.value; found[0].description = t.description || ""; } catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
200
353
|
}
|
|
201
|
-
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
|
|
354
|
+
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
|
|
355
|
+
set: action.set,
|
|
356
|
+
tokens,
|
|
357
|
+
});
|
|
202
358
|
case "replaceTokens":
|
|
203
359
|
return snippet("marwes-replace-tokens", `${findSet}
|
|
204
360
|
const failed = [];
|
|
@@ -210,7 +366,10 @@ for (const t of CFG.tokens) {
|
|
|
210
366
|
set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" });
|
|
211
367
|
} catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
212
368
|
}
|
|
213
|
-
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
|
|
369
|
+
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
|
|
370
|
+
set: action.set,
|
|
371
|
+
tokens,
|
|
372
|
+
});
|
|
214
373
|
case "removeTokens":
|
|
215
374
|
return snippet("marwes-remove-tokens", `${findSet}
|
|
216
375
|
const failed = [];
|
|
@@ -219,13 +378,20 @@ for (const name of CFG.names) {
|
|
|
219
378
|
if (found.length !== 1) { failed.push(name + ": not found or ambiguous"); continue; }
|
|
220
379
|
try { found[0].remove(); } catch (error) { failed.push(name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
221
380
|
}
|
|
222
|
-
return { ok: true, done: CFG.names.length - failed.length, failed };`, {
|
|
381
|
+
return { ok: true, done: CFG.names.length - failed.length, failed };`, {
|
|
382
|
+
set: action.set,
|
|
383
|
+
names: action.names,
|
|
384
|
+
});
|
|
223
385
|
case "createTheme":
|
|
224
386
|
return snippet("marwes-create-theme", `const t = penpot.library.local.tokens;
|
|
225
387
|
if (t.themes.some((x) => x.name === CFG.name && x.group === CFG.group)) throw new Error("theme exists: " + CFG.name);
|
|
226
388
|
const theme = t.addTheme({ group: CFG.group, name: CFG.name });
|
|
227
389
|
for (const name of CFG.sets) { const s = t.sets.filter((x) => x.name === name); if (s.length !== 1) throw new Error("set missing or ambiguous: " + name); theme.addSet(s[0]); }
|
|
228
|
-
return { ok: true, sets: (theme.activeSets || []).map((x) => x.name) };`, {
|
|
390
|
+
return { ok: true, sets: (theme.activeSets || []).map((x) => x.name) };`, {
|
|
391
|
+
group: action.group,
|
|
392
|
+
name: action.name,
|
|
393
|
+
sets: action.sets,
|
|
394
|
+
});
|
|
229
395
|
case "syncThemeSets":
|
|
230
396
|
return snippet("marwes-sync-theme", `const t = penpot.library.local.tokens;
|
|
231
397
|
const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
|
|
@@ -233,63 +399,329 @@ if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.nam
|
|
|
233
399
|
const pick = (name) => { const s = t.sets.filter((x) => x.name === name); if (s.length !== 1) throw new Error("set missing or ambiguous: " + name); return s[0]; };
|
|
234
400
|
for (const name of CFG.add) theme[0].addSet(pick(name));
|
|
235
401
|
for (const name of CFG.remove) theme[0].removeSet(pick(name));
|
|
236
|
-
return { ok: true, sets: (theme[0].activeSets || []).map((x) => x.name) };`, {
|
|
402
|
+
return { ok: true, sets: (theme[0].activeSets || []).map((x) => x.name) };`, {
|
|
403
|
+
group: action.group,
|
|
404
|
+
name: action.name,
|
|
405
|
+
add: action.add,
|
|
406
|
+
remove: action.remove,
|
|
407
|
+
});
|
|
237
408
|
case "activateTheme":
|
|
238
409
|
return snippet("marwes-activate-theme", `const t = penpot.library.local.tokens;
|
|
239
410
|
if (t.themes.some((x) => x.active)) return { ok: true, alreadyActive: true };
|
|
240
411
|
const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
|
|
241
412
|
if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.name);
|
|
242
413
|
theme[0].toggleActive();
|
|
243
|
-
return { ok: true, active: !!theme[0].active };`, {
|
|
414
|
+
return { ok: true, active: !!theme[0].active };`, {
|
|
415
|
+
group: action.group,
|
|
416
|
+
name: action.name,
|
|
417
|
+
});
|
|
244
418
|
}
|
|
245
419
|
}
|
|
246
420
|
const describeAction = (action, count) => {
|
|
247
421
|
switch (action.kind) {
|
|
248
|
-
case "createSet":
|
|
249
|
-
|
|
250
|
-
case "
|
|
251
|
-
|
|
252
|
-
case "
|
|
253
|
-
|
|
254
|
-
case "
|
|
255
|
-
|
|
422
|
+
case "createSet":
|
|
423
|
+
return `create set ${action.set}`;
|
|
424
|
+
case "addTokens":
|
|
425
|
+
return `add ${count ?? action.tokens.length} token(s) to ${action.set}`;
|
|
426
|
+
case "updateTokens":
|
|
427
|
+
return `update ${count ?? action.tokens.length} token(s) in ${action.set}`;
|
|
428
|
+
case "replaceTokens":
|
|
429
|
+
return `replace ${count ?? action.tokens.length} token(s) in ${action.set}`;
|
|
430
|
+
case "removeTokens":
|
|
431
|
+
return `remove ${action.names.length} token(s) from ${action.set}`;
|
|
432
|
+
case "createTheme":
|
|
433
|
+
return `create theme ${action.group}/${action.name}`;
|
|
434
|
+
case "syncThemeSets":
|
|
435
|
+
return `theme ${action.group}/${action.name}: add ${action.add.length}, remove ${action.remove.length} set(s)`;
|
|
436
|
+
case "activateTheme":
|
|
437
|
+
return `activate theme ${action.name}`;
|
|
256
438
|
}
|
|
257
439
|
};
|
|
258
440
|
export const describePlan = (actions) => actions.map((a) => describeAction(a));
|
|
259
441
|
export async function readLive(client) {
|
|
260
|
-
const record = await runSnippet(client, readTokens()
|
|
261
|
-
|
|
442
|
+
const record = await runSnippet(client, readTokens(), {
|
|
443
|
+
effect: "read",
|
|
444
|
+
});
|
|
445
|
+
if (record.ok !== true || !Array.isArray(record.sets) || !Array.isArray(record.themes)) {
|
|
446
|
+
throw new Error("Token readback did not provide explicit sets and themes; synchronization is not verified");
|
|
447
|
+
}
|
|
448
|
+
const sets = record.sets;
|
|
449
|
+
const themes = record.themes;
|
|
450
|
+
const setNames = new Set();
|
|
451
|
+
let count = 0;
|
|
452
|
+
for (const set of sets) {
|
|
453
|
+
if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens)) {
|
|
454
|
+
throw new Error("Invalid or ambiguous token set observation");
|
|
455
|
+
}
|
|
456
|
+
setNames.add(set.name);
|
|
457
|
+
const names = new Set();
|
|
458
|
+
for (const token of set.tokens) {
|
|
459
|
+
if (++count > 50000 ||
|
|
460
|
+
typeof token.name !== "string" ||
|
|
461
|
+
names.has(token.name) ||
|
|
462
|
+
typeof token.type !== "string" ||
|
|
463
|
+
token.value === undefined ||
|
|
464
|
+
typeof token.description !== "string") {
|
|
465
|
+
throw new Error("Incomplete, ambiguous or over-budget token observation");
|
|
466
|
+
}
|
|
467
|
+
names.add(token.name);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const themeNames = new Set();
|
|
471
|
+
for (const theme of themes) {
|
|
472
|
+
const key = JSON.stringify([
|
|
473
|
+
theme.group,
|
|
474
|
+
theme.name,
|
|
475
|
+
]);
|
|
476
|
+
if (typeof theme.group !== "string" ||
|
|
477
|
+
typeof theme.name !== "string" ||
|
|
478
|
+
themeNames.has(key) ||
|
|
479
|
+
!Array.isArray(theme.sets) ||
|
|
480
|
+
theme.sets.some((name) => typeof name !== "string" || !setNames.has(name)) ||
|
|
481
|
+
new Set(theme.sets).size !== theme.sets.length) {
|
|
482
|
+
throw new Error("Invalid theme membership observation");
|
|
483
|
+
}
|
|
484
|
+
themeNames.add(key);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
sets,
|
|
488
|
+
themes,
|
|
489
|
+
};
|
|
262
490
|
}
|
|
263
491
|
/**
|
|
264
492
|
* Carries out the plan one write at a time, stopping at the first anomaly, then reads everything back: the file must now match the
|
|
265
493
|
* expectation, and no shape may have moved or been rebound.
|
|
266
494
|
*/
|
|
267
|
-
export async function applyPlan(client, expected, actions, onStep) {
|
|
495
|
+
export async function applyPlan(client, expected, actions, onStep, options = {}) {
|
|
496
|
+
if (options.expectedFileId) {
|
|
497
|
+
client = guardFile(client, {
|
|
498
|
+
id: options.expectedFileId,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (options.journal) {
|
|
502
|
+
client = guardFile(client, {
|
|
503
|
+
id: (await readJournalTarget(client)).fileId,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
let layout;
|
|
507
|
+
const settle = async () => {
|
|
508
|
+
if (options.layout === false) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
layout = await waitForStableLayout(client, options.layout);
|
|
512
|
+
if (!layout.stable) {
|
|
513
|
+
throw new Error(`Layout did not settle during token verification: ${layout.reason ?? "unknown reason"}`);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
await settle();
|
|
268
517
|
const fingerprint = async () => {
|
|
269
|
-
const r = await runSnippet(client, structureFingerprint()
|
|
270
|
-
|
|
518
|
+
const r = await runSnippet(client, structureFingerprint(), {
|
|
519
|
+
effect: "read",
|
|
520
|
+
});
|
|
521
|
+
if (r.ok !== true ||
|
|
522
|
+
!Number.isInteger(r.count) ||
|
|
523
|
+
Number(r.count) < 0 ||
|
|
524
|
+
typeof r.hash !== "string" ||
|
|
525
|
+
!r.hash) {
|
|
526
|
+
throw new Error("Structure readback did not provide a count and hash; nothing can be verified");
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
count: Number(r.count),
|
|
530
|
+
hash: String(r.hash),
|
|
531
|
+
};
|
|
271
532
|
};
|
|
272
533
|
const structureBefore = await fingerprint();
|
|
534
|
+
const initialLive = await readLive(client);
|
|
535
|
+
for (const action of actions) {
|
|
536
|
+
if (action.kind === "removeTokens" &&
|
|
537
|
+
action.names.some((name) => !options.ancestor?.sets
|
|
538
|
+
.find((set) => set.name === action.set)
|
|
539
|
+
?.tokens.some((token) => token.name === name))) {
|
|
540
|
+
throw new Error("Prune refuses tokens absent from the verified ownership ancestor");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
assertSafeDestruction(actions, initialLive, await impactOf(client));
|
|
544
|
+
if (options.journal) {
|
|
545
|
+
const target = await readJournalTarget(client);
|
|
546
|
+
const guarded = guardFile(client, {
|
|
547
|
+
id: target.fileId,
|
|
548
|
+
});
|
|
549
|
+
let originalStructure = structureBefore;
|
|
550
|
+
if (options.journal.resume) {
|
|
551
|
+
if (!options.journal.runId) {
|
|
552
|
+
throw new Error("Resume requires a run ID");
|
|
553
|
+
}
|
|
554
|
+
const prior = await readJournal(options.journal.root, options.journal.runId);
|
|
555
|
+
const payload = prior.payload;
|
|
556
|
+
if (!payload ||
|
|
557
|
+
!Array.isArray(payload.actions) ||
|
|
558
|
+
!payload.structure ||
|
|
559
|
+
typeof payload.structure.hash !== "string") {
|
|
560
|
+
throw new Error("Token journal has no original action/structure plan");
|
|
561
|
+
}
|
|
562
|
+
actions = payload.actions;
|
|
563
|
+
originalStructure = payload.structure;
|
|
564
|
+
}
|
|
565
|
+
if (structureBefore.hash !== originalStructure.hash) {
|
|
566
|
+
throw new Error("Shapes changed since the token run began; reconcile the design before resuming");
|
|
567
|
+
}
|
|
568
|
+
const baseline = {
|
|
569
|
+
live: await readLive(guarded),
|
|
570
|
+
structure: structureBefore,
|
|
571
|
+
};
|
|
572
|
+
if (!options.journal.resume &&
|
|
573
|
+
options.expectedLive &&
|
|
574
|
+
observationHash(options.expectedLive) !== observationHash(baseline.live)) {
|
|
575
|
+
throw new Error("Token source changed since the CLI plan; inspect again before writing");
|
|
576
|
+
}
|
|
577
|
+
const work = actions.flatMap((action) => "tokens" in action
|
|
578
|
+
? inBatches(action.tokens).map((tokens) => ({
|
|
579
|
+
...action,
|
|
580
|
+
tokens,
|
|
581
|
+
}))
|
|
582
|
+
: [
|
|
583
|
+
action,
|
|
584
|
+
]);
|
|
585
|
+
const record = await runJournaledSteps(work.map((action, index) => ({
|
|
586
|
+
key: `step-${index}-${observationHash(action).slice(0, 12)}`,
|
|
587
|
+
label: describeAction(action),
|
|
588
|
+
signature: [
|
|
589
|
+
actionSnippet(action, "tokens" in action ? action.tokens : undefined).code,
|
|
590
|
+
actionSatisfied.toString(),
|
|
591
|
+
reconcileAction.toString(),
|
|
592
|
+
remainingAction.toString(),
|
|
593
|
+
waitForStableLayout.toString(),
|
|
594
|
+
].join("\n"),
|
|
595
|
+
initial: baseline,
|
|
596
|
+
inspect: async () => {
|
|
597
|
+
await settle();
|
|
598
|
+
return {
|
|
599
|
+
live: await readLive(guarded),
|
|
600
|
+
structure: await fingerprint(),
|
|
601
|
+
};
|
|
602
|
+
},
|
|
603
|
+
apply: async (current, target) => {
|
|
604
|
+
const residual = remainingAction(action, current.live);
|
|
605
|
+
if (!residual) {
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
reconciled: true,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
const mutation = actionSnippet(residual, "tokens" in residual ? residual.tokens : undefined);
|
|
612
|
+
const sourceGuard = `${destructionGuard(residual)}\n{ const observed = await (async () => { ${readTokens().code} })();
|
|
613
|
+
if (JSON.stringify({sets:observed.sets, themes:observed.themes}) !== ${JSON.stringify(JSON.stringify(current.live))}) return {ok:false, message:"Token source changed before mutation"}; }\n`;
|
|
614
|
+
return runSnippet(guardRevision(guarded, target), {
|
|
615
|
+
...mutation,
|
|
616
|
+
code: sourceGuard + mutation.code,
|
|
617
|
+
}, {
|
|
618
|
+
effect: "write",
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
verify: (current) => current.structure.hash === originalStructure.hash &&
|
|
622
|
+
actionSatisfied(action, current.live),
|
|
623
|
+
reconcile: (before, current) => current.structure.hash !== originalStructure.hash
|
|
624
|
+
? "conflict"
|
|
625
|
+
: reconcileAction(action, before.live, current.live),
|
|
626
|
+
})), {
|
|
627
|
+
...options.journal,
|
|
628
|
+
coordinate: writeCoordinator(guarded, {
|
|
629
|
+
root: options.journal.root,
|
|
630
|
+
operation: "tokens-push",
|
|
631
|
+
forceLock: options.journal.forceLock,
|
|
632
|
+
warn: onStep,
|
|
633
|
+
}),
|
|
634
|
+
operation: "tokens-push",
|
|
635
|
+
input: {
|
|
636
|
+
snapshot: observationHash(expected),
|
|
637
|
+
ancestor: options.ancestor ? observationHash(options.ancestor) : null,
|
|
638
|
+
prune: options.prune === true,
|
|
639
|
+
layout: options.layout ?? {},
|
|
640
|
+
},
|
|
641
|
+
payload: {
|
|
642
|
+
actions,
|
|
643
|
+
structure: originalStructure,
|
|
644
|
+
},
|
|
645
|
+
target: () => readJournalTarget(guarded),
|
|
646
|
+
onStep,
|
|
647
|
+
finalVerify: async () => {
|
|
648
|
+
await settle();
|
|
649
|
+
return (compareToPenpot(expected, await readLive(guarded), options).inSync &&
|
|
650
|
+
(await fingerprint()).hash === originalStructure.hash);
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
await settle();
|
|
654
|
+
const structureAfter = await fingerprint();
|
|
655
|
+
const drift = compareToPenpot(expected, await readLive(guarded), options);
|
|
656
|
+
return {
|
|
657
|
+
layout,
|
|
658
|
+
steps: record.steps.map((step) => step.label),
|
|
659
|
+
structureBefore: originalStructure,
|
|
660
|
+
structureAfter,
|
|
661
|
+
structureUnchanged: originalStructure.hash === structureAfter.hash,
|
|
662
|
+
drift,
|
|
663
|
+
...(record.redacted
|
|
664
|
+
? {
|
|
665
|
+
redacted: true,
|
|
666
|
+
complete: false,
|
|
667
|
+
}
|
|
668
|
+
: {}),
|
|
669
|
+
verified: !record.redacted && drift.inSync && originalStructure.hash === structureAfter.hash,
|
|
670
|
+
journal: {
|
|
671
|
+
runId: record.runId,
|
|
672
|
+
state: record.state,
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
273
676
|
const steps = [];
|
|
274
677
|
for (const action of actions) {
|
|
275
|
-
const work = "tokens" in action
|
|
678
|
+
const work = "tokens" in action
|
|
679
|
+
? inBatches(action.tokens)
|
|
680
|
+
: [
|
|
681
|
+
undefined,
|
|
682
|
+
];
|
|
276
683
|
for (const batch of work) {
|
|
277
684
|
const message = describeAction(action, batch?.length);
|
|
278
685
|
onStep?.(message);
|
|
279
|
-
const
|
|
686
|
+
const mutation = actionSnippet(action, batch);
|
|
687
|
+
const result = await runSnippet(client, {
|
|
688
|
+
...mutation,
|
|
689
|
+
code: destructionGuard(action) + mutation.code,
|
|
690
|
+
}, {
|
|
691
|
+
effect: "write",
|
|
692
|
+
});
|
|
280
693
|
const failed = result.failed ?? [];
|
|
281
|
-
if (failed.length > 0)
|
|
694
|
+
if (failed.length > 0) {
|
|
282
695
|
throw new Error(`${message}: ${failed.join(" | ")}. Nothing after this was written.`);
|
|
696
|
+
}
|
|
283
697
|
steps.push(message);
|
|
284
698
|
}
|
|
285
699
|
}
|
|
700
|
+
await settle();
|
|
286
701
|
const structureAfter = await fingerprint();
|
|
287
|
-
const drift = compareToPenpot(expected, await readLive(client));
|
|
288
|
-
return {
|
|
702
|
+
const drift = compareToPenpot(expected, await readLive(client), options);
|
|
703
|
+
return {
|
|
704
|
+
layout,
|
|
705
|
+
steps,
|
|
706
|
+
structureBefore,
|
|
707
|
+
structureAfter,
|
|
708
|
+
structureUnchanged: structureBefore.hash === structureAfter.hash,
|
|
709
|
+
drift,
|
|
710
|
+
verified: drift.inSync && structureBefore.hash === structureAfter.hash,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function destructionGuard(action) {
|
|
714
|
+
if (action.kind !== "removeTokens" && action.kind !== "replaceTokens") {
|
|
715
|
+
return "";
|
|
716
|
+
}
|
|
717
|
+
return `{ const live=await(async()=>{${readTokens().code}})(); const bindings=await(async()=>{${bindingCounts().code}})();
|
|
718
|
+
(${assertSafeDestruction.toString()})([${JSON.stringify(action)}],live,bindings.counts); }\n`;
|
|
289
719
|
}
|
|
290
720
|
/** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
|
|
291
721
|
export async function impactOf(client) {
|
|
292
|
-
const record = await runSnippet(client, bindingCounts()
|
|
722
|
+
const record = await runSnippet(client, bindingCounts(), {
|
|
723
|
+
effect: "read",
|
|
724
|
+
});
|
|
293
725
|
return record.counts;
|
|
294
726
|
}
|
|
295
727
|
export { canonical };
|