@penpair/marwes-ui 0.2.0 → 0.3.1
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/penpot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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";
|
|
2
|
+
import { waitForStableLayout, guardRevision, writeCoordinator, runSnippet, snippet, guardFile, observationHash, readJournal, readJournalTarget, runJournaledSteps, } from "@penpair/core";
|
|
3
|
+
import { canonical, normalizeValue, } from "./tokens.js";
|
|
4
4
|
import { actionSatisfied, reconcileAction, remainingAction } from "./reconciliation.js";
|
|
5
5
|
export function readTokens() {
|
|
6
6
|
return snippet("marwes-tokens-read", `const tokens = penpot.library.local.tokens;
|
|
@@ -34,50 +34,120 @@ export function compareToPenpot(expected, live, options = {}) {
|
|
|
34
34
|
for (const wanted of expected.sets) {
|
|
35
35
|
const found = live.sets.find((s) => s.name === wanted.name);
|
|
36
36
|
if (!found) {
|
|
37
|
-
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
|
+
});
|
|
38
45
|
continue;
|
|
39
46
|
}
|
|
40
47
|
const tokens = [];
|
|
41
|
-
const have = new Map(found.tokens.map((t) => [
|
|
42
|
-
|
|
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
|
+
]));
|
|
43
56
|
for (const token of wanted.tokens) {
|
|
44
57
|
const actual = have.get(token.name);
|
|
45
|
-
if (!actual)
|
|
46
|
-
tokens.push({
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
else if (
|
|
52
|
-
tokens.push({
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
|
59
103
|
}
|
|
60
104
|
const themes = [];
|
|
61
105
|
for (const wanted of expected.themes) {
|
|
62
106
|
const found = live.themes.find((t) => t.group === wanted.group && t.name === wanted.name);
|
|
63
107
|
if (!found) {
|
|
64
|
-
themes.push({
|
|
108
|
+
themes.push({
|
|
109
|
+
theme: `${wanted.group}/${wanted.name}`,
|
|
110
|
+
missing: true,
|
|
111
|
+
missingSets: wanted.sets,
|
|
112
|
+
extraSets: [],
|
|
113
|
+
});
|
|
65
114
|
continue;
|
|
66
115
|
}
|
|
67
116
|
const missingSets = wanted.sets.filter((s) => !found.sets.includes(s));
|
|
68
|
-
const owned = new Set([
|
|
117
|
+
const owned = new Set([
|
|
118
|
+
...expected.sets,
|
|
119
|
+
...(options.ancestor?.sets ?? []),
|
|
120
|
+
].map((set) => set.name));
|
|
69
121
|
const extraSets = found.sets.filter((s) => !wanted.sets.includes(s) && owned.has(s));
|
|
70
|
-
if (missingSets.length || extraSets.length)
|
|
71
|
-
themes.push({
|
|
122
|
+
if (missingSets.length || extraSets.length) {
|
|
123
|
+
themes.push({
|
|
124
|
+
theme: `${wanted.group}/${wanted.name}`,
|
|
125
|
+
missingSets,
|
|
126
|
+
extraSets,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
72
129
|
}
|
|
73
130
|
const managed = new Set(expected.sets.map((s) => s.name));
|
|
74
|
-
const unexpectedSets = live.sets
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
};
|
|
77
145
|
}
|
|
78
146
|
export function formatDrift(drift, impact, limit = 6) {
|
|
79
147
|
const lines = [];
|
|
80
|
-
const used = (name) =>
|
|
148
|
+
const used = (name) => impact && impact[name]
|
|
149
|
+
? ` (used by ${impact[name]} shape${impact[name] === 1 ? "" : "s"})`
|
|
150
|
+
: "";
|
|
81
151
|
for (const s of drift.sets) {
|
|
82
152
|
if (s.missing) {
|
|
83
153
|
lines.push(`${s.set}: missing in Penpot (${s.tokens.length} tokens)`);
|
|
@@ -87,70 +157,142 @@ export function formatDrift(drift, impact, limit = 6) {
|
|
|
87
157
|
const extra = s.tokens.filter((t) => t.kind === "extra");
|
|
88
158
|
const changed = s.tokens.filter((t) => t.kind === "value" || t.kind === "type");
|
|
89
159
|
const described = s.tokens.filter((t) => t.kind === "description");
|
|
90
|
-
const cut = (items) =>
|
|
91
|
-
|
|
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) {
|
|
92
164
|
lines.push(`${s.set}: ${missing.length} missing in Penpot: ${cut(missing.map((t) => t.name))}`);
|
|
93
|
-
|
|
165
|
+
}
|
|
166
|
+
if (changed.length) {
|
|
94
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)}`))}`);
|
|
95
|
-
|
|
168
|
+
}
|
|
169
|
+
if (extra.length) {
|
|
96
170
|
lines.push(`${s.set}: ${extra.length} not in the expectation (added by hand?): ${cut(extra.map((t) => t.name))}`);
|
|
97
|
-
|
|
171
|
+
}
|
|
172
|
+
if (described.length) {
|
|
98
173
|
lines.push(`${s.set}: ${described.length} carry an old description (the recorded Marwes version): ${cut(described.map((t) => t.name))}`);
|
|
174
|
+
}
|
|
99
175
|
}
|
|
100
176
|
for (const t of drift.themes) {
|
|
101
|
-
if (t.missing)
|
|
177
|
+
if (t.missing) {
|
|
102
178
|
lines.push(`theme ${t.theme}: missing in Penpot`);
|
|
103
|
-
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
104
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
|
+
}
|
|
105
183
|
}
|
|
106
|
-
for (const name of drift.unexpectedSets)
|
|
184
|
+
for (const name of drift.unexpectedSets) {
|
|
107
185
|
lines.push(`set ${name}: in Penpot but no longer expected`);
|
|
186
|
+
}
|
|
108
187
|
return lines;
|
|
109
188
|
}
|
|
110
189
|
/** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
|
|
111
190
|
export function planPush(expected, live, options = {}) {
|
|
112
191
|
const drift = compareToPenpot(expected, live, options);
|
|
113
192
|
const actions = [];
|
|
114
|
-
const wantedSet = new Map(expected.sets.map((s) => [
|
|
193
|
+
const wantedSet = new Map(expected.sets.map((s) => [
|
|
194
|
+
s.name,
|
|
195
|
+
s,
|
|
196
|
+
]));
|
|
115
197
|
for (const s of drift.sets) {
|
|
116
198
|
const wanted = wantedSet.get(s.set);
|
|
117
199
|
if (s.missing) {
|
|
118
|
-
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
|
+
});
|
|
119
208
|
continue;
|
|
120
209
|
}
|
|
121
|
-
const byName = new Map(wanted.tokens.map((t) => [
|
|
210
|
+
const byName = new Map(wanted.tokens.map((t) => [
|
|
211
|
+
t.name,
|
|
212
|
+
t,
|
|
213
|
+
]));
|
|
122
214
|
const add = s.tokens.filter((t) => t.kind === "missing").map((t) => byName.get(t.name));
|
|
123
215
|
// A typography value assigned in place is stored un-normalised by Penpot, so those are removed and added again.
|
|
124
|
-
const typed = s.tokens
|
|
125
|
-
|
|
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
|
+
];
|
|
126
227
|
const replaced = new Set(replace.map((t) => t.name));
|
|
127
|
-
const update = s.tokens
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
actions.push({
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
134
252
|
const extra = s.tokens.filter((t) => t.kind === "extra").map((t) => t.name);
|
|
135
|
-
const ownedExtra = extra.filter(name => options.ancestor?.sets
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
263
|
}
|
|
139
264
|
const createdThemes = new Set();
|
|
140
265
|
for (const t of drift.themes) {
|
|
141
266
|
const [group, name] = t.theme.split("/");
|
|
142
267
|
if (t.missing) {
|
|
143
|
-
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
|
+
});
|
|
144
274
|
createdThemes.add(name);
|
|
145
275
|
}
|
|
146
|
-
else
|
|
147
|
-
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
|
+
}
|
|
148
285
|
}
|
|
149
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.
|
|
150
287
|
if (createdThemes.size > 0 && !live.themes.some((t) => t.active)) {
|
|
151
288
|
const first = expected.themes.find((t) => t.name === "Light") ?? expected.themes[0];
|
|
152
|
-
if (first && createdThemes.has(first.name))
|
|
153
|
-
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
|
+
}
|
|
154
296
|
}
|
|
155
297
|
return actions.filter((a) => a.kind !== "syncThemeSets" || a.add.length > 0 || a.remove.length > 0);
|
|
156
298
|
}
|
|
@@ -171,8 +313,9 @@ export function inBatches(tokens) {
|
|
|
171
313
|
current.push(token);
|
|
172
314
|
bytes += size;
|
|
173
315
|
}
|
|
174
|
-
if (current.length > 0)
|
|
316
|
+
if (current.length > 0) {
|
|
175
317
|
batches.push(current);
|
|
318
|
+
}
|
|
176
319
|
return batches;
|
|
177
320
|
}
|
|
178
321
|
const findSet = `const found = penpot.library.local.tokens.sets.filter((s) => s.name === CFG.set);
|
|
@@ -186,7 +329,9 @@ if (t.sets.some((s) => s.name === CFG.set)) return { ok: true, existed: true };
|
|
|
186
329
|
const set = t.addSet({ name: CFG.set });
|
|
187
330
|
// A new set must not take part in resolution before a theme says so.
|
|
188
331
|
if (set.active) set.toggleActive();
|
|
189
|
-
return { ok: true, active: !!set.active };`, {
|
|
332
|
+
return { ok: true, active: !!set.active };`, {
|
|
333
|
+
set: action.set,
|
|
334
|
+
});
|
|
190
335
|
case "addTokens":
|
|
191
336
|
return snippet("marwes-add-tokens", `${findSet}
|
|
192
337
|
const failed = [];
|
|
@@ -194,7 +339,10 @@ for (const t of CFG.tokens) {
|
|
|
194
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 || "" }); }
|
|
195
340
|
catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
196
341
|
}
|
|
197
|
-
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
|
+
});
|
|
198
346
|
case "updateTokens":
|
|
199
347
|
return snippet("marwes-update-tokens", `${findSet}
|
|
200
348
|
const failed = [];
|
|
@@ -203,7 +351,10 @@ for (const t of CFG.tokens) {
|
|
|
203
351
|
if (found.length !== 1) { failed.push(t.name + ": not found or ambiguous"); continue; }
|
|
204
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)); }
|
|
205
353
|
}
|
|
206
|
-
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
|
+
});
|
|
207
358
|
case "replaceTokens":
|
|
208
359
|
return snippet("marwes-replace-tokens", `${findSet}
|
|
209
360
|
const failed = [];
|
|
@@ -215,7 +366,10 @@ for (const t of CFG.tokens) {
|
|
|
215
366
|
set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" });
|
|
216
367
|
} catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
217
368
|
}
|
|
218
|
-
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
|
+
});
|
|
219
373
|
case "removeTokens":
|
|
220
374
|
return snippet("marwes-remove-tokens", `${findSet}
|
|
221
375
|
const failed = [];
|
|
@@ -224,13 +378,20 @@ for (const name of CFG.names) {
|
|
|
224
378
|
if (found.length !== 1) { failed.push(name + ": not found or ambiguous"); continue; }
|
|
225
379
|
try { found[0].remove(); } catch (error) { failed.push(name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
226
380
|
}
|
|
227
|
-
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
|
+
});
|
|
228
385
|
case "createTheme":
|
|
229
386
|
return snippet("marwes-create-theme", `const t = penpot.library.local.tokens;
|
|
230
387
|
if (t.themes.some((x) => x.name === CFG.name && x.group === CFG.group)) throw new Error("theme exists: " + CFG.name);
|
|
231
388
|
const theme = t.addTheme({ group: CFG.group, name: CFG.name });
|
|
232
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]); }
|
|
233
|
-
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
|
+
});
|
|
234
395
|
case "syncThemeSets":
|
|
235
396
|
return snippet("marwes-sync-theme", `const t = penpot.library.local.tokens;
|
|
236
397
|
const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
|
|
@@ -238,157 +399,329 @@ if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.nam
|
|
|
238
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]; };
|
|
239
400
|
for (const name of CFG.add) theme[0].addSet(pick(name));
|
|
240
401
|
for (const name of CFG.remove) theme[0].removeSet(pick(name));
|
|
241
|
-
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
|
+
});
|
|
242
408
|
case "activateTheme":
|
|
243
409
|
return snippet("marwes-activate-theme", `const t = penpot.library.local.tokens;
|
|
244
410
|
if (t.themes.some((x) => x.active)) return { ok: true, alreadyActive: true };
|
|
245
411
|
const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
|
|
246
412
|
if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.name);
|
|
247
413
|
theme[0].toggleActive();
|
|
248
|
-
return { ok: true, active: !!theme[0].active };`, {
|
|
414
|
+
return { ok: true, active: !!theme[0].active };`, {
|
|
415
|
+
group: action.group,
|
|
416
|
+
name: action.name,
|
|
417
|
+
});
|
|
249
418
|
}
|
|
250
419
|
}
|
|
251
420
|
const describeAction = (action, count) => {
|
|
252
421
|
switch (action.kind) {
|
|
253
|
-
case "createSet":
|
|
254
|
-
|
|
255
|
-
case "
|
|
256
|
-
|
|
257
|
-
case "
|
|
258
|
-
|
|
259
|
-
case "
|
|
260
|
-
|
|
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}`;
|
|
261
438
|
}
|
|
262
439
|
};
|
|
263
440
|
export const describePlan = (actions) => actions.map((a) => describeAction(a));
|
|
264
441
|
export async function readLive(client) {
|
|
265
|
-
const record = await runSnippet(client, readTokens(), {
|
|
266
|
-
|
|
442
|
+
const record = await runSnippet(client, readTokens(), {
|
|
443
|
+
effect: "read",
|
|
444
|
+
});
|
|
445
|
+
if (record.ok !== true || !Array.isArray(record.sets) || !Array.isArray(record.themes)) {
|
|
267
446
|
throw new Error("Token readback did not provide explicit sets and themes; synchronization is not verified");
|
|
268
|
-
|
|
447
|
+
}
|
|
448
|
+
const sets = record.sets;
|
|
449
|
+
const themes = record.themes;
|
|
269
450
|
const setNames = new Set();
|
|
270
451
|
let count = 0;
|
|
271
452
|
for (const set of sets) {
|
|
272
|
-
if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens))
|
|
453
|
+
if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens)) {
|
|
273
454
|
throw new Error("Invalid or ambiguous token set observation");
|
|
455
|
+
}
|
|
274
456
|
setNames.add(set.name);
|
|
275
457
|
const names = new Set();
|
|
276
458
|
for (const token of set.tokens) {
|
|
277
|
-
if (++count > 50000 ||
|
|
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") {
|
|
278
465
|
throw new Error("Incomplete, ambiguous or over-budget token observation");
|
|
466
|
+
}
|
|
279
467
|
names.add(token.name);
|
|
280
468
|
}
|
|
281
469
|
}
|
|
282
470
|
const themeNames = new Set();
|
|
283
471
|
for (const theme of themes) {
|
|
284
|
-
const key = JSON.stringify([
|
|
285
|
-
|
|
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) {
|
|
286
482
|
throw new Error("Invalid theme membership observation");
|
|
483
|
+
}
|
|
287
484
|
themeNames.add(key);
|
|
288
485
|
}
|
|
289
|
-
return {
|
|
486
|
+
return {
|
|
487
|
+
sets,
|
|
488
|
+
themes,
|
|
489
|
+
};
|
|
290
490
|
}
|
|
291
491
|
/**
|
|
292
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
|
|
293
493
|
* expectation, and no shape may have moved or been rebound.
|
|
294
494
|
*/
|
|
295
495
|
export async function applyPlan(client, expected, actions, onStep, options = {}) {
|
|
296
|
-
if (options.expectedFileId)
|
|
297
|
-
client = guardFile(client, {
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|
|
300
506
|
let layout;
|
|
301
507
|
const settle = async () => {
|
|
302
|
-
if (options.layout === false)
|
|
508
|
+
if (options.layout === false) {
|
|
303
509
|
return;
|
|
510
|
+
}
|
|
304
511
|
layout = await waitForStableLayout(client, options.layout);
|
|
305
|
-
if (!layout.stable)
|
|
512
|
+
if (!layout.stable) {
|
|
306
513
|
throw new Error(`Layout did not settle during token verification: ${layout.reason ?? "unknown reason"}`);
|
|
514
|
+
}
|
|
307
515
|
};
|
|
308
516
|
await settle();
|
|
309
517
|
const fingerprint = async () => {
|
|
310
|
-
const r = await runSnippet(client, structureFingerprint(), {
|
|
311
|
-
|
|
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) {
|
|
312
526
|
throw new Error("Structure readback did not provide a count and hash; nothing can be verified");
|
|
313
|
-
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
count: Number(r.count),
|
|
530
|
+
hash: String(r.hash),
|
|
531
|
+
};
|
|
314
532
|
};
|
|
315
533
|
const structureBefore = await fingerprint();
|
|
316
534
|
const initialLive = await readLive(client);
|
|
317
|
-
for (const action of actions)
|
|
318
|
-
if (action.kind === "removeTokens" &&
|
|
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))) {
|
|
319
540
|
throw new Error("Prune refuses tokens absent from the verified ownership ancestor");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
320
543
|
assertSafeDestruction(actions, initialLive, await impactOf(client));
|
|
321
544
|
if (options.journal) {
|
|
322
545
|
const target = await readJournalTarget(client);
|
|
323
|
-
const guarded = guardFile(client, {
|
|
546
|
+
const guarded = guardFile(client, {
|
|
547
|
+
id: target.fileId,
|
|
548
|
+
});
|
|
324
549
|
let originalStructure = structureBefore;
|
|
325
550
|
if (options.journal.resume) {
|
|
326
|
-
if (!options.journal.runId)
|
|
551
|
+
if (!options.journal.runId) {
|
|
327
552
|
throw new Error("Resume requires a run ID");
|
|
553
|
+
}
|
|
328
554
|
const prior = await readJournal(options.journal.root, options.journal.runId);
|
|
329
555
|
const payload = prior.payload;
|
|
330
|
-
if (!payload ||
|
|
556
|
+
if (!payload ||
|
|
557
|
+
!Array.isArray(payload.actions) ||
|
|
558
|
+
!payload.structure ||
|
|
559
|
+
typeof payload.structure.hash !== "string") {
|
|
331
560
|
throw new Error("Token journal has no original action/structure plan");
|
|
561
|
+
}
|
|
332
562
|
actions = payload.actions;
|
|
333
563
|
originalStructure = payload.structure;
|
|
334
564
|
}
|
|
335
|
-
if (structureBefore.hash !== originalStructure.hash)
|
|
565
|
+
if (structureBefore.hash !== originalStructure.hash) {
|
|
336
566
|
throw new Error("Shapes changed since the token run began; reconcile the design before resuming");
|
|
337
|
-
|
|
338
|
-
|
|
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)) {
|
|
339
575
|
throw new Error("Token source changed since the CLI plan; inspect again before writing");
|
|
340
|
-
|
|
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
|
+
]);
|
|
341
585
|
const record = await runJournaledSteps(work.map((action, index) => ({
|
|
342
|
-
key: `step-${index}-${observationHash(action).slice(0, 12)}`,
|
|
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"),
|
|
343
595
|
initial: baseline,
|
|
344
|
-
inspect: async () => {
|
|
596
|
+
inspect: async () => {
|
|
597
|
+
await settle();
|
|
598
|
+
return {
|
|
599
|
+
live: await readLive(guarded),
|
|
600
|
+
structure: await fingerprint(),
|
|
601
|
+
};
|
|
602
|
+
},
|
|
345
603
|
apply: async (current, target) => {
|
|
346
604
|
const residual = remainingAction(action, current.live);
|
|
347
|
-
if (!residual)
|
|
348
|
-
return {
|
|
605
|
+
if (!residual) {
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
reconciled: true,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
349
611
|
const mutation = actionSnippet(residual, "tokens" in residual ? residual.tokens : undefined);
|
|
350
612
|
const sourceGuard = `${destructionGuard(residual)}\n{ const observed = await (async () => { ${readTokens().code} })();
|
|
351
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`;
|
|
352
|
-
return runSnippet(guardRevision(guarded, target), {
|
|
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);
|
|
353
651
|
},
|
|
354
|
-
verify: (current) => current.structure.hash === originalStructure.hash && actionSatisfied(action, current.live),
|
|
355
|
-
reconcile: (before, current) => current.structure.hash !== originalStructure.hash ? "conflict" : reconcileAction(action, before.live, current.live),
|
|
356
|
-
})), { ...options.journal, coordinate: writeCoordinator(guarded, { root: options.journal.root, operation: "tokens-push", forceLock: options.journal.forceLock, warn: onStep }), operation: "tokens-push", input: { snapshot: observationHash(expected), ancestor: options.ancestor ? observationHash(options.ancestor) : null, prune: options.prune === true, layout: options.layout ?? {} }, payload: { actions, structure: originalStructure }, target: () => readJournalTarget(guarded), onStep,
|
|
357
|
-
finalVerify: async () => { await settle(); return compareToPenpot(expected, await readLive(guarded), options).inSync && (await fingerprint()).hash === originalStructure.hash; },
|
|
358
652
|
});
|
|
359
653
|
await settle();
|
|
360
654
|
const structureAfter = await fingerprint();
|
|
361
655
|
const drift = compareToPenpot(expected, await readLive(guarded), options);
|
|
362
|
-
return {
|
|
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
|
+
};
|
|
363
675
|
}
|
|
364
676
|
const steps = [];
|
|
365
677
|
for (const action of actions) {
|
|
366
|
-
const work = "tokens" in action
|
|
678
|
+
const work = "tokens" in action
|
|
679
|
+
? inBatches(action.tokens)
|
|
680
|
+
: [
|
|
681
|
+
undefined,
|
|
682
|
+
];
|
|
367
683
|
for (const batch of work) {
|
|
368
684
|
const message = describeAction(action, batch?.length);
|
|
369
685
|
onStep?.(message);
|
|
370
686
|
const mutation = actionSnippet(action, batch);
|
|
371
|
-
const result = await runSnippet(client, {
|
|
687
|
+
const result = await runSnippet(client, {
|
|
688
|
+
...mutation,
|
|
689
|
+
code: destructionGuard(action) + mutation.code,
|
|
690
|
+
}, {
|
|
691
|
+
effect: "write",
|
|
692
|
+
});
|
|
372
693
|
const failed = result.failed ?? [];
|
|
373
|
-
if (failed.length > 0)
|
|
694
|
+
if (failed.length > 0) {
|
|
374
695
|
throw new Error(`${message}: ${failed.join(" | ")}. Nothing after this was written.`);
|
|
696
|
+
}
|
|
375
697
|
steps.push(message);
|
|
376
698
|
}
|
|
377
699
|
}
|
|
378
700
|
await settle();
|
|
379
701
|
const structureAfter = await fingerprint();
|
|
380
702
|
const drift = compareToPenpot(expected, await readLive(client), options);
|
|
381
|
-
return {
|
|
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
|
+
};
|
|
382
712
|
}
|
|
383
713
|
function destructionGuard(action) {
|
|
384
|
-
if (action.kind !== "removeTokens" && action.kind !== "replaceTokens")
|
|
714
|
+
if (action.kind !== "removeTokens" && action.kind !== "replaceTokens") {
|
|
385
715
|
return "";
|
|
716
|
+
}
|
|
386
717
|
return `{ const live=await(async()=>{${readTokens().code}})(); const bindings=await(async()=>{${bindingCounts().code}})();
|
|
387
718
|
(${assertSafeDestruction.toString()})([${JSON.stringify(action)}],live,bindings.counts); }\n`;
|
|
388
719
|
}
|
|
389
720
|
/** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
|
|
390
721
|
export async function impactOf(client) {
|
|
391
|
-
const record = await runSnippet(client, bindingCounts(), {
|
|
722
|
+
const record = await runSnippet(client, bindingCounts(), {
|
|
723
|
+
effect: "read",
|
|
724
|
+
});
|
|
392
725
|
return record.counts;
|
|
393
726
|
}
|
|
394
727
|
export { canonical };
|