@penpair/marwes-ui 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/commands.d.ts +28 -0
- package/dist/commands.js +232 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +34 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +11 -0
- package/dist/marwes.d.ts +17 -0
- package/dist/marwes.js +72 -0
- package/dist/penpot.d.ts +137 -0
- package/dist/penpot.js +295 -0
- package/dist/snapshot-file.d.ts +43 -0
- package/dist/snapshot-file.js +87 -0
- package/dist/theme-input.d.ts +11 -0
- package/dist/theme-input.js +21 -0
- package/dist/tokens.d.ts +75 -0
- package/dist/tokens.js +183 -0
- package/package.json +51 -3
package/dist/penpot.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { runSnippet, snippet } from "@penpair/core";
|
|
2
|
+
import { canonical, normalizeValue } from "./tokens.js";
|
|
3
|
+
export function readTokens() {
|
|
4
|
+
return snippet("marwes-tokens-read", `const tokens = penpot.library.local.tokens;
|
|
5
|
+
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 || "" })) });
|
|
6
|
+
return { ok: true, readOnly: true, sets: tokens.sets.map(set),
|
|
7
|
+
themes: tokens.themes.map((t) => ({ group: t.group, name: t.name, active: !!t.active, sets: (t.activeSets || []).map((x) => (typeof x === "string" ? x : x.name)) })) };`);
|
|
8
|
+
}
|
|
9
|
+
/** How many shapes use each token, over every page: what a change of a token would reach. */
|
|
10
|
+
export function bindingCounts() {
|
|
11
|
+
return snippet("marwes-tokens-bindings", `const counts = {}; let shapes = 0;
|
|
12
|
+
const visit = (s) => { shapes++; const b = s.tokens; if (b && typeof b === "object") for (const key of Object.keys(b)) { const name = b[key]; if (typeof name === "string") counts[name] = (counts[name] || 0) + 1; } (s.children || []).forEach(visit); };
|
|
13
|
+
for (const page of penpot.currentFile.pages) (page.root.children || []).forEach(visit);
|
|
14
|
+
return { ok: true, readOnly: true, shapes, counts };`);
|
|
15
|
+
}
|
|
16
|
+
/** 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. */
|
|
17
|
+
export function structureFingerprint() {
|
|
18
|
+
return snippet("marwes-tokens-structure", `const cyrb = (text) => { let h1 = 0xdeadbeef, h2 = 0x41c6ce57; for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); h1 = Math.imul(h1 ^ c, 2654435761); h2 = Math.imul(h2 ^ c, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); };
|
|
19
|
+
const rows = []; const round = (n) => (typeof n === "number" ? Math.round(n * 100) / 100 : n);
|
|
20
|
+
const visit = (page, s) => { rows.push([page, s.id, s.type, s.name, round(s.x), round(s.y), round(s.width), round(s.height), JSON.stringify(s.tokens || {})].join("|")); (s.children || []).forEach((c) => visit(page, c)); };
|
|
21
|
+
for (const page of penpot.currentFile.pages) (page.root.children || []).forEach((c) => visit(page.name, c));
|
|
22
|
+
return { ok: true, readOnly: true, count: rows.length, hash: cyrb(rows.join("\\n")) };`);
|
|
23
|
+
}
|
|
24
|
+
const display = (type, value) => {
|
|
25
|
+
const normal = normalizeValue(type, value);
|
|
26
|
+
return normal.length > 90 ? `${normal.slice(0, 87)}...` : normal;
|
|
27
|
+
};
|
|
28
|
+
/** 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) {
|
|
30
|
+
const sets = [];
|
|
31
|
+
for (const wanted of expected.sets) {
|
|
32
|
+
const found = live.sets.find((s) => s.name === wanted.name);
|
|
33
|
+
if (!found) {
|
|
34
|
+
sets.push({ set: wanted.name, missing: true, tokens: wanted.tokens.map((t) => ({ name: t.name, kind: "missing" })) });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const tokens = [];
|
|
38
|
+
const have = new Map(found.tokens.map((t) => [t.name, t]));
|
|
39
|
+
const want = new Map(wanted.tokens.map((t) => [t.name, t]));
|
|
40
|
+
for (const token of wanted.tokens) {
|
|
41
|
+
const actual = have.get(token.name);
|
|
42
|
+
if (!actual)
|
|
43
|
+
tokens.push({ name: token.name, kind: "missing" });
|
|
44
|
+
else if (actual.type !== token.type)
|
|
45
|
+
tokens.push({ name: token.name, kind: "type", expected: token.type, actual: actual.type });
|
|
46
|
+
else if (normalizeValue(token.type, token.value) !== normalizeValue(actual.type, actual.value))
|
|
47
|
+
tokens.push({ name: token.name, kind: "value", expected: display(token.type, token.value), actual: display(actual.type, actual.value) });
|
|
48
|
+
else if (token.description !== actual.description)
|
|
49
|
+
tokens.push({ name: token.name, kind: "description", expected: token.description, actual: actual.description });
|
|
50
|
+
}
|
|
51
|
+
for (const name of have.keys())
|
|
52
|
+
if (!want.has(name))
|
|
53
|
+
tokens.push({ name, kind: "extra" });
|
|
54
|
+
if (tokens.length > 0)
|
|
55
|
+
sets.push({ set: wanted.name, tokens });
|
|
56
|
+
}
|
|
57
|
+
const themes = [];
|
|
58
|
+
for (const wanted of expected.themes) {
|
|
59
|
+
const found = live.themes.find((t) => t.group === wanted.group && t.name === wanted.name);
|
|
60
|
+
if (!found) {
|
|
61
|
+
themes.push({ theme: `${wanted.group}/${wanted.name}`, missing: true, missingSets: wanted.sets, extraSets: [] });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const missingSets = wanted.sets.filter((s) => !found.sets.includes(s));
|
|
65
|
+
const extraSets = found.sets.filter((s) => !wanted.sets.includes(s));
|
|
66
|
+
if (missingSets.length || extraSets.length)
|
|
67
|
+
themes.push({ theme: `${wanted.group}/${wanted.name}`, missingSets, extraSets });
|
|
68
|
+
}
|
|
69
|
+
const managed = new Set(expected.sets.map((s) => s.name));
|
|
70
|
+
const looksManaged = (name) => name.startsWith("Marwes · ") || name === `${expected.project} · Light` || name === `${expected.project} · Dark`;
|
|
71
|
+
const unexpectedSets = live.sets.map((s) => s.name).filter((name) => looksManaged(name) && !managed.has(name));
|
|
72
|
+
return { inSync: sets.length === 0 && themes.length === 0 && unexpectedSets.length === 0, sets, themes, unexpectedSets };
|
|
73
|
+
}
|
|
74
|
+
export function formatDrift(drift, impact, limit = 6) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
const used = (name) => (impact && impact[name] ? ` (used by ${impact[name]} shape${impact[name] === 1 ? "" : "s"})` : "");
|
|
77
|
+
for (const s of drift.sets) {
|
|
78
|
+
if (s.missing) {
|
|
79
|
+
lines.push(`${s.set}: missing in Penpot (${s.tokens.length} tokens)`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const missing = s.tokens.filter((t) => t.kind === "missing");
|
|
83
|
+
const extra = s.tokens.filter((t) => t.kind === "extra");
|
|
84
|
+
const changed = s.tokens.filter((t) => t.kind === "value" || t.kind === "type");
|
|
85
|
+
const described = s.tokens.filter((t) => t.kind === "description");
|
|
86
|
+
const cut = (items) => (items.length > limit ? `${items.slice(0, limit).join("; ")}; and ${items.length - limit} more` : items.join("; "));
|
|
87
|
+
if (missing.length)
|
|
88
|
+
lines.push(`${s.set}: ${missing.length} missing in Penpot: ${cut(missing.map((t) => t.name))}`);
|
|
89
|
+
if (changed.length)
|
|
90
|
+
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
|
+
if (extra.length)
|
|
92
|
+
lines.push(`${s.set}: ${extra.length} not in the expectation (added by hand?): ${cut(extra.map((t) => t.name))}`);
|
|
93
|
+
if (described.length)
|
|
94
|
+
lines.push(`${s.set}: ${described.length} carry an old description (the recorded Marwes version): ${cut(described.map((t) => t.name))}`);
|
|
95
|
+
}
|
|
96
|
+
for (const t of drift.themes) {
|
|
97
|
+
if (t.missing)
|
|
98
|
+
lines.push(`theme ${t.theme}: missing in Penpot`);
|
|
99
|
+
else
|
|
100
|
+
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(", ")}` : ""}`);
|
|
101
|
+
}
|
|
102
|
+
for (const name of drift.unexpectedSets)
|
|
103
|
+
lines.push(`set ${name}: in Penpot but no longer expected`);
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
/** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
|
|
107
|
+
export function planPush(expected, live, options = {}) {
|
|
108
|
+
const drift = compareToPenpot(expected, live);
|
|
109
|
+
const actions = [];
|
|
110
|
+
const wantedSet = new Map(expected.sets.map((s) => [s.name, s]));
|
|
111
|
+
for (const s of drift.sets) {
|
|
112
|
+
const wanted = wantedSet.get(s.set);
|
|
113
|
+
if (s.missing) {
|
|
114
|
+
actions.push({ kind: "createSet", set: s.set }, { kind: "addTokens", set: s.set, tokens: wanted.tokens });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const byName = new Map(wanted.tokens.map((t) => [t.name, t]));
|
|
118
|
+
const add = s.tokens.filter((t) => t.kind === "missing").map((t) => byName.get(t.name));
|
|
119
|
+
// A typography value assigned in place is stored un-normalised by Penpot, so those are removed and added again.
|
|
120
|
+
const typed = s.tokens.filter((t) => t.kind === "type" || ((t.kind === "value" || t.kind === "description") && byName.get(t.name)?.type === "typography")).map((t) => byName.get(t.name));
|
|
121
|
+
const replace = [...new Map(typed.map((t) => [t.name, t])).values()];
|
|
122
|
+
const replaced = new Set(replace.map((t) => t.name));
|
|
123
|
+
const update = s.tokens.filter((t) => (t.kind === "value" || t.kind === "description") && !replaced.has(t.name)).map((t) => byName.get(t.name));
|
|
124
|
+
if (add.length)
|
|
125
|
+
actions.push({ kind: "addTokens", set: s.set, tokens: add });
|
|
126
|
+
if (update.length)
|
|
127
|
+
actions.push({ kind: "updateTokens", set: s.set, tokens: update });
|
|
128
|
+
if (replace.length)
|
|
129
|
+
actions.push({ kind: "replaceTokens", set: s.set, tokens: replace });
|
|
130
|
+
const extra = s.tokens.filter((t) => t.kind === "extra").map((t) => t.name);
|
|
131
|
+
if (options.prune && extra.length)
|
|
132
|
+
actions.push({ kind: "removeTokens", set: s.set, names: extra });
|
|
133
|
+
}
|
|
134
|
+
const createdThemes = new Set();
|
|
135
|
+
for (const t of drift.themes) {
|
|
136
|
+
const [group, name] = t.theme.split("/");
|
|
137
|
+
if (t.missing) {
|
|
138
|
+
actions.push({ kind: "createTheme", group, name, sets: expected.themes.find((x) => x.group === group && x.name === name).sets });
|
|
139
|
+
createdThemes.add(name);
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
actions.push({ kind: "syncThemeSets", group, name, add: t.missingSets, remove: options.prune ? t.extraSets : [] });
|
|
143
|
+
}
|
|
144
|
+
// 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
|
+
if (createdThemes.size > 0 && !live.themes.some((t) => t.active)) {
|
|
146
|
+
const first = expected.themes.find((t) => t.name === "Light") ?? expected.themes[0];
|
|
147
|
+
if (first && createdThemes.has(first.name))
|
|
148
|
+
actions.push({ kind: "activateTheme", group: first.group, name: first.name });
|
|
149
|
+
}
|
|
150
|
+
return actions.filter((a) => a.kind !== "syncThemeSets" || a.add.length > 0 || a.remove.length > 0);
|
|
151
|
+
}
|
|
152
|
+
const batchSize = 40;
|
|
153
|
+
const maxBatchBytes = 45 * 1024;
|
|
154
|
+
/** Splits a long token list into batches that stay well under the request limit. */
|
|
155
|
+
export function inBatches(tokens) {
|
|
156
|
+
const batches = [];
|
|
157
|
+
let current = [];
|
|
158
|
+
let bytes = 0;
|
|
159
|
+
for (const token of tokens) {
|
|
160
|
+
const size = JSON.stringify(token).length;
|
|
161
|
+
if (current.length >= batchSize || (bytes + size > maxBatchBytes && current.length > 0)) {
|
|
162
|
+
batches.push(current);
|
|
163
|
+
current = [];
|
|
164
|
+
bytes = 0;
|
|
165
|
+
}
|
|
166
|
+
current.push(token);
|
|
167
|
+
bytes += size;
|
|
168
|
+
}
|
|
169
|
+
if (current.length > 0)
|
|
170
|
+
batches.push(current);
|
|
171
|
+
return batches;
|
|
172
|
+
}
|
|
173
|
+
const findSet = `const found = penpot.library.local.tokens.sets.filter((s) => s.name === CFG.set);
|
|
174
|
+
if (found.length !== 1) throw new Error("set missing or ambiguous: " + CFG.set);
|
|
175
|
+
const set = found[0];`;
|
|
176
|
+
function actionSnippet(action, tokens) {
|
|
177
|
+
switch (action.kind) {
|
|
178
|
+
case "createSet":
|
|
179
|
+
return snippet("marwes-create-set", `const t = penpot.library.local.tokens;
|
|
180
|
+
if (t.sets.some((s) => s.name === CFG.set)) return { ok: true, existed: true };
|
|
181
|
+
const set = t.addSet({ name: CFG.set });
|
|
182
|
+
// A new set must not take part in resolution before a theme says so.
|
|
183
|
+
if (set.active) set.toggleActive();
|
|
184
|
+
return { ok: true, active: !!set.active };`, { set: action.set });
|
|
185
|
+
case "addTokens":
|
|
186
|
+
return snippet("marwes-add-tokens", `${findSet}
|
|
187
|
+
const failed = [];
|
|
188
|
+
for (const t of CFG.tokens) {
|
|
189
|
+
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
|
+
catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, { set: action.set, tokens });
|
|
193
|
+
case "updateTokens":
|
|
194
|
+
return snippet("marwes-update-tokens", `${findSet}
|
|
195
|
+
const failed = [];
|
|
196
|
+
for (const t of CFG.tokens) {
|
|
197
|
+
const found = set.tokens.filter((x) => x.name === t.name);
|
|
198
|
+
if (found.length !== 1) { failed.push(t.name + ": not found or ambiguous"); continue; }
|
|
199
|
+
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
|
+
}
|
|
201
|
+
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, { set: action.set, tokens });
|
|
202
|
+
case "replaceTokens":
|
|
203
|
+
return snippet("marwes-replace-tokens", `${findSet}
|
|
204
|
+
const failed = [];
|
|
205
|
+
for (const t of CFG.tokens) {
|
|
206
|
+
try {
|
|
207
|
+
const old = set.tokens.filter((x) => x.name === t.name);
|
|
208
|
+
if (old.length > 1) { failed.push(t.name + ": ambiguous"); continue; }
|
|
209
|
+
if (old.length === 1) old[0].remove();
|
|
210
|
+
set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" });
|
|
211
|
+
} catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, done: CFG.tokens.length - failed.length, failed };`, { set: action.set, tokens });
|
|
214
|
+
case "removeTokens":
|
|
215
|
+
return snippet("marwes-remove-tokens", `${findSet}
|
|
216
|
+
const failed = [];
|
|
217
|
+
for (const name of CFG.names) {
|
|
218
|
+
const found = set.tokens.filter((x) => x.name === name);
|
|
219
|
+
if (found.length !== 1) { failed.push(name + ": not found or ambiguous"); continue; }
|
|
220
|
+
try { found[0].remove(); } catch (error) { failed.push(name + ": " + String(error && error.message || error).slice(0, 120)); }
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, done: CFG.names.length - failed.length, failed };`, { set: action.set, names: action.names });
|
|
223
|
+
case "createTheme":
|
|
224
|
+
return snippet("marwes-create-theme", `const t = penpot.library.local.tokens;
|
|
225
|
+
if (t.themes.some((x) => x.name === CFG.name && x.group === CFG.group)) throw new Error("theme exists: " + CFG.name);
|
|
226
|
+
const theme = t.addTheme({ group: CFG.group, name: CFG.name });
|
|
227
|
+
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) };`, { group: action.group, name: action.name, sets: action.sets });
|
|
229
|
+
case "syncThemeSets":
|
|
230
|
+
return snippet("marwes-sync-theme", `const t = penpot.library.local.tokens;
|
|
231
|
+
const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
|
|
232
|
+
if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.name);
|
|
233
|
+
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
|
+
for (const name of CFG.add) theme[0].addSet(pick(name));
|
|
235
|
+
for (const name of CFG.remove) theme[0].removeSet(pick(name));
|
|
236
|
+
return { ok: true, sets: (theme[0].activeSets || []).map((x) => x.name) };`, { group: action.group, name: action.name, add: action.add, remove: action.remove });
|
|
237
|
+
case "activateTheme":
|
|
238
|
+
return snippet("marwes-activate-theme", `const t = penpot.library.local.tokens;
|
|
239
|
+
if (t.themes.some((x) => x.active)) return { ok: true, alreadyActive: true };
|
|
240
|
+
const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
|
|
241
|
+
if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.name);
|
|
242
|
+
theme[0].toggleActive();
|
|
243
|
+
return { ok: true, active: !!theme[0].active };`, { group: action.group, name: action.name });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const describeAction = (action, count) => {
|
|
247
|
+
switch (action.kind) {
|
|
248
|
+
case "createSet": return `create set ${action.set}`;
|
|
249
|
+
case "addTokens": return `add ${count ?? action.tokens.length} token(s) to ${action.set}`;
|
|
250
|
+
case "updateTokens": return `update ${count ?? action.tokens.length} token(s) in ${action.set}`;
|
|
251
|
+
case "replaceTokens": return `replace ${count ?? action.tokens.length} token(s) in ${action.set}`;
|
|
252
|
+
case "removeTokens": return `remove ${action.names.length} token(s) from ${action.set}`;
|
|
253
|
+
case "createTheme": return `create theme ${action.group}/${action.name}`;
|
|
254
|
+
case "syncThemeSets": return `theme ${action.group}/${action.name}: add ${action.add.length}, remove ${action.remove.length} set(s)`;
|
|
255
|
+
case "activateTheme": return `activate theme ${action.name}`;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
export const describePlan = (actions) => actions.map((a) => describeAction(a));
|
|
259
|
+
export async function readLive(client) {
|
|
260
|
+
const record = await runSnippet(client, readTokens());
|
|
261
|
+
return { sets: record.sets, themes: record.themes };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 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
|
+
* expectation, and no shape may have moved or been rebound.
|
|
266
|
+
*/
|
|
267
|
+
export async function applyPlan(client, expected, actions, onStep) {
|
|
268
|
+
const fingerprint = async () => {
|
|
269
|
+
const r = await runSnippet(client, structureFingerprint());
|
|
270
|
+
return { count: Number(r.count), hash: String(r.hash) };
|
|
271
|
+
};
|
|
272
|
+
const structureBefore = await fingerprint();
|
|
273
|
+
const steps = [];
|
|
274
|
+
for (const action of actions) {
|
|
275
|
+
const work = "tokens" in action ? inBatches(action.tokens) : [undefined];
|
|
276
|
+
for (const batch of work) {
|
|
277
|
+
const message = describeAction(action, batch?.length);
|
|
278
|
+
onStep?.(message);
|
|
279
|
+
const result = await runSnippet(client, actionSnippet(action, batch));
|
|
280
|
+
const failed = result.failed ?? [];
|
|
281
|
+
if (failed.length > 0)
|
|
282
|
+
throw new Error(`${message}: ${failed.join(" | ")}. Nothing after this was written.`);
|
|
283
|
+
steps.push(message);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const structureAfter = await fingerprint();
|
|
287
|
+
const drift = compareToPenpot(expected, await readLive(client));
|
|
288
|
+
return { steps, structureBefore, structureAfter, structureUnchanged: structureBefore.hash === structureAfter.hash, drift, verified: drift.inSync && structureBefore.hash === structureAfter.hash };
|
|
289
|
+
}
|
|
290
|
+
/** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
|
|
291
|
+
export async function impactOf(client) {
|
|
292
|
+
const record = await runSnippet(client, bindingCounts());
|
|
293
|
+
return record.counts;
|
|
294
|
+
}
|
|
295
|
+
export { canonical };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type TokenSnapshot } from "./tokens.js";
|
|
2
|
+
export declare function readSnapshot(root: string, file: string): Promise<TokenSnapshot | undefined>;
|
|
3
|
+
export declare function writeSnapshot(root: string, file: string, snapshot: TokenSnapshot): Promise<void>;
|
|
4
|
+
export interface TokenChange {
|
|
5
|
+
name: string;
|
|
6
|
+
before: string;
|
|
7
|
+
after: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SetChange {
|
|
10
|
+
set: string;
|
|
11
|
+
/** The whole set is new or gone. */
|
|
12
|
+
setAdded?: boolean;
|
|
13
|
+
setRemoved?: boolean;
|
|
14
|
+
added: string[];
|
|
15
|
+
removed: string[];
|
|
16
|
+
changed: TokenChange[];
|
|
17
|
+
}
|
|
18
|
+
export interface SnapshotDiff {
|
|
19
|
+
identical: boolean;
|
|
20
|
+
versionChange?: {
|
|
21
|
+
before: string;
|
|
22
|
+
after: string;
|
|
23
|
+
};
|
|
24
|
+
sets: SetChange[];
|
|
25
|
+
themes: {
|
|
26
|
+
added: string[];
|
|
27
|
+
removed: string[];
|
|
28
|
+
changed: {
|
|
29
|
+
theme: string;
|
|
30
|
+
before: string[];
|
|
31
|
+
after: string[];
|
|
32
|
+
}[];
|
|
33
|
+
};
|
|
34
|
+
/** Marwes variables that are not tokens, and that appeared or disappeared since the other snapshot. */
|
|
35
|
+
unmapped: {
|
|
36
|
+
added: string[];
|
|
37
|
+
removed: string[];
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** What differs between two expectations: the Marwes version, then set by set which tokens were added, removed or changed. */
|
|
41
|
+
export declare function diffSnapshots(before: TokenSnapshot | undefined, after: TokenSnapshot): SnapshotDiff;
|
|
42
|
+
/** A short, readable account of a diff for the terminal; long lists are cut with a count. */
|
|
43
|
+
export declare function formatDiff(diff: SnapshotDiff, limit?: number): string[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { canonical } from "./tokens.js";
|
|
4
|
+
export async function readSnapshot(root, file) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(await readFile(path.join(root, file), "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code === "ENOENT")
|
|
10
|
+
return undefined;
|
|
11
|
+
throw new Error(`${file} is not a readable snapshot: ${String(error.message)}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function writeSnapshot(root, file, snapshot) {
|
|
15
|
+
const target = path.join(root, file);
|
|
16
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
17
|
+
await writeFile(target, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
18
|
+
}
|
|
19
|
+
const show = (token) => (typeof token.value === "string" ? token.value : canonical(token.value));
|
|
20
|
+
/** What differs between two expectations: the Marwes version, then set by set which tokens were added, removed or changed. */
|
|
21
|
+
export function diffSnapshots(before, after) {
|
|
22
|
+
const sets = [];
|
|
23
|
+
const previous = new Map((before?.sets ?? []).map((s) => [s.name, s]));
|
|
24
|
+
const next = new Map(after.sets.map((s) => [s.name, s]));
|
|
25
|
+
for (const [name, set] of next) {
|
|
26
|
+
const old = previous.get(name);
|
|
27
|
+
const oldTokens = new Map((old?.tokens ?? []).map((t) => [t.name, t]));
|
|
28
|
+
const newTokens = new Map(set.tokens.map((t) => [t.name, t]));
|
|
29
|
+
const change = {
|
|
30
|
+
set: name,
|
|
31
|
+
...(old ? {} : { setAdded: true }),
|
|
32
|
+
added: [...newTokens.keys()].filter((n) => !oldTokens.has(n)),
|
|
33
|
+
removed: [...oldTokens.keys()].filter((n) => !newTokens.has(n)),
|
|
34
|
+
changed: [...newTokens.entries()].filter(([n, t]) => oldTokens.has(n) && show(oldTokens.get(n)) !== show(t)).map(([n, t]) => ({ name: n, before: show(oldTokens.get(n)), after: show(t) })),
|
|
35
|
+
};
|
|
36
|
+
if (change.setAdded || change.added.length || change.removed.length || change.changed.length)
|
|
37
|
+
sets.push(change);
|
|
38
|
+
}
|
|
39
|
+
for (const [name, old] of previous)
|
|
40
|
+
if (!next.has(name))
|
|
41
|
+
sets.push({ set: name, setRemoved: true, added: [], removed: old.tokens.map((t) => t.name), changed: [] });
|
|
42
|
+
const oldThemes = new Map((before?.themes ?? []).map((t) => [`${t.group}/${t.name}`, t.sets]));
|
|
43
|
+
const newThemes = new Map(after.themes.map((t) => [`${t.group}/${t.name}`, t.sets]));
|
|
44
|
+
const themes = {
|
|
45
|
+
added: [...newThemes.keys()].filter((k) => !oldThemes.has(k)),
|
|
46
|
+
removed: [...oldThemes.keys()].filter((k) => !newThemes.has(k)),
|
|
47
|
+
changed: [...newThemes.entries()].filter(([k, s]) => oldThemes.has(k) && canonical(oldThemes.get(k)) !== canonical(s)).map(([k, s]) => ({ theme: k, before: oldThemes.get(k), after: s })),
|
|
48
|
+
};
|
|
49
|
+
const wasUnmapped = new Set(before?.unmapped ?? []);
|
|
50
|
+
const isUnmapped = new Set(after.unmapped ?? []);
|
|
51
|
+
const unmapped = { added: [...isUnmapped].filter((v) => !wasUnmapped.has(v)), removed: [...wasUnmapped].filter((v) => !isUnmapped.has(v)) };
|
|
52
|
+
const versionChange = before && before.marwesVersion !== after.marwesVersion ? { before: before.marwesVersion, after: after.marwesVersion } : undefined;
|
|
53
|
+
const identical = !!before && !versionChange && sets.length === 0 && !themes.added.length && !themes.removed.length && !themes.changed.length && !unmapped.added.length && !unmapped.removed.length;
|
|
54
|
+
return { identical, ...(versionChange ? { versionChange } : {}), sets, themes, unmapped };
|
|
55
|
+
}
|
|
56
|
+
/** A short, readable account of a diff for the terminal; long lists are cut with a count. */
|
|
57
|
+
export function formatDiff(diff, limit = 8) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (diff.versionChange)
|
|
60
|
+
lines.push(`Marwes ${diff.versionChange.before} -> ${diff.versionChange.after}`);
|
|
61
|
+
const cut = (items) => (items.length > limit ? `${items.slice(0, limit).join(", ")} and ${items.length - limit} more` : items.join(", "));
|
|
62
|
+
for (const s of diff.sets) {
|
|
63
|
+
if (s.setAdded)
|
|
64
|
+
lines.push(`${s.set}: new set with ${s.added.length} tokens`);
|
|
65
|
+
else if (s.setRemoved)
|
|
66
|
+
lines.push(`${s.set}: set no longer expected (${s.removed.length} tokens)`);
|
|
67
|
+
else {
|
|
68
|
+
if (s.added.length)
|
|
69
|
+
lines.push(`${s.set}: ${s.added.length} added: ${cut(s.added)}`);
|
|
70
|
+
if (s.removed.length)
|
|
71
|
+
lines.push(`${s.set}: ${s.removed.length} removed: ${cut(s.removed)}`);
|
|
72
|
+
if (s.changed.length)
|
|
73
|
+
lines.push(`${s.set}: ${s.changed.length} changed: ${cut(s.changed.map((c) => `${c.name} ${c.before} -> ${c.after}`))}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (diff.unmapped.added.length)
|
|
77
|
+
lines.push(`NEW Marwes variables that are not tokens (the design file does not know them yet; review, then extend the adapter or accept): ${cut(diff.unmapped.added)}`);
|
|
78
|
+
if (diff.unmapped.removed.length)
|
|
79
|
+
lines.push(`Marwes variables that are no longer provided: ${cut(diff.unmapped.removed)}`);
|
|
80
|
+
for (const t of diff.themes.added)
|
|
81
|
+
lines.push(`theme ${t}: new`);
|
|
82
|
+
for (const t of diff.themes.removed)
|
|
83
|
+
lines.push(`theme ${t}: no longer expected`);
|
|
84
|
+
for (const t of diff.themes.changed)
|
|
85
|
+
lines.push(`theme ${t.theme}: sets ${t.before.join(" + ")} -> ${t.after.join(" + ")}`);
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ThemeReference {
|
|
2
|
+
/** The file, relative to the project, that exports the theme (TypeScript is fine). */
|
|
3
|
+
module: string;
|
|
4
|
+
/** The name of the export that is a Marwes ThemeInput. */
|
|
5
|
+
export: string;
|
|
6
|
+
}
|
|
7
|
+
/** Loads a theme the project already has, as the app itself would: the same file, the same export. */
|
|
8
|
+
export declare function loadThemeInput(root: string, reference: ThemeReference): Promise<{
|
|
9
|
+
input: Record<string, unknown>;
|
|
10
|
+
fileHash: string;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
/** Loads a theme the project already has, as the app itself would: the same file, the same export. */
|
|
6
|
+
export async function loadThemeInput(root, reference) {
|
|
7
|
+
const file = path.resolve(root, reference.module);
|
|
8
|
+
let source;
|
|
9
|
+
try {
|
|
10
|
+
source = await readFile(file);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error(`The theme file ${reference.module} does not exist (marwes.themes in penpair.config.json).`);
|
|
14
|
+
}
|
|
15
|
+
const jiti = createJiti(file, { interopDefault: true, moduleCache: false });
|
|
16
|
+
const loaded = (await jiti.import(file));
|
|
17
|
+
const input = loaded[reference.export];
|
|
18
|
+
if (typeof input !== "object" || input === null)
|
|
19
|
+
throw new Error(`${reference.module} has no object export named ${reference.export}.`);
|
|
20
|
+
return { input: input, fileHash: createHash("sha256").update(source).digest("hex").slice(0, 12) };
|
|
21
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type PenpotTokenType = "color" | "spacing" | "sizing" | "borderRadius" | "fontFamilies" | "typography";
|
|
2
|
+
export interface PenpotToken {
|
|
3
|
+
name: string;
|
|
4
|
+
type: PenpotTokenType;
|
|
5
|
+
/** Plain strings for simple tokens; a composite typography token carries an object. */
|
|
6
|
+
value: string | Record<string, string>;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PenpotTokenSet {
|
|
10
|
+
name: string;
|
|
11
|
+
tokens: PenpotToken[];
|
|
12
|
+
}
|
|
13
|
+
export interface PenpotTokenTheme {
|
|
14
|
+
group: string;
|
|
15
|
+
name: string;
|
|
16
|
+
sets: string[];
|
|
17
|
+
}
|
|
18
|
+
/** What the Penpot file is expected to hold: generated from the installed Marwes and the project's theme, committed to the project. */
|
|
19
|
+
export interface TokenSnapshot {
|
|
20
|
+
schemaVersion: 1;
|
|
21
|
+
/** The Marwes version the values came from. */
|
|
22
|
+
marwesVersion: string;
|
|
23
|
+
/** What produced the file, so a reader knows not to edit it by hand. */
|
|
24
|
+
generatedBy: string;
|
|
25
|
+
/** The project name that prefixes its overlay sets. */
|
|
26
|
+
project: string;
|
|
27
|
+
/** A hash of the version, the sets, the themes and the unmapped variables: two snapshots with the same hash are the same expectation. */
|
|
28
|
+
hash: string;
|
|
29
|
+
/** Variables the installed Marwes provides that this adapter does not turn into tokens. A new one means Marwes grew something the design file does not know about yet. */
|
|
30
|
+
unmapped: string[];
|
|
31
|
+
sets: PenpotTokenSet[];
|
|
32
|
+
themes: PenpotTokenTheme[];
|
|
33
|
+
}
|
|
34
|
+
/** The part of Marwes core the snapshot needs. The real package exports these; a test supplies its own. */
|
|
35
|
+
export interface MarwesCore {
|
|
36
|
+
resolveThemeInput: (input: Record<string, unknown>) => unknown;
|
|
37
|
+
themeToCSSVars: (theme: unknown) => Record<string, string>;
|
|
38
|
+
mwThemeVars: {
|
|
39
|
+
spacing: Record<string, string>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export declare const coreSetName = "Marwes \u00B7 Core";
|
|
43
|
+
export declare const darkSetName = "Marwes \u00B7 Dark";
|
|
44
|
+
export declare const themeGroup = "Mode";
|
|
45
|
+
/** Penpot colour tokens are hex; Marwes also uses rgba() and the word transparent. */
|
|
46
|
+
export declare function toHexColor(value: string): string;
|
|
47
|
+
/** Every token a resolved theme provides (everything but the spacing scale, which is not part of a theme). */
|
|
48
|
+
export declare function tokensFromVars(vars: Record<string, string>, describe: (variable: string) => string): PenpotToken[];
|
|
49
|
+
/** The tokens of `of` that `base` does not already provide with the same value: what is left to say on top of it. */
|
|
50
|
+
export declare function tokensAbove(base: PenpotToken[], of: PenpotToken[]): PenpotToken[];
|
|
51
|
+
/** JSON with sorted keys, so the same data always gives the same text. */
|
|
52
|
+
export declare function canonical(value: unknown): string;
|
|
53
|
+
/** Whether a Marwes variable becomes a token (see tokensFromVars). Everything else is reported, never silently dropped. */
|
|
54
|
+
export declare function isMapped(variable: string, value: string): boolean;
|
|
55
|
+
export declare function unmappedVariables(...themes: Record<string, string>[]): string[];
|
|
56
|
+
export interface SnapshotInput {
|
|
57
|
+
core: MarwesCore;
|
|
58
|
+
version: string;
|
|
59
|
+
/** The project's own light and dark themes (a Marwes ThemeInput each), when it has them. */
|
|
60
|
+
project?: {
|
|
61
|
+
light?: Record<string, unknown>;
|
|
62
|
+
dark?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
/** The project's name, which prefixes its overlay sets. */
|
|
65
|
+
name: string;
|
|
66
|
+
generatedBy: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The Penpot token sets for Marwes and for the project, generated from the installed Marwes. Light values live in the core set,
|
|
70
|
+
* only what differs in dark goes in the dark set, and what the project's own theme changes goes in small overlay sets above them,
|
|
71
|
+
* so a Marwes update changes the Marwes sets and never silently loses the project's choices.
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildSnapshot(input: SnapshotInput): TokenSnapshot;
|
|
74
|
+
/** The value of a token as it compares in Penpot: strings are exact, typography is normalised the way Penpot stores it. */
|
|
75
|
+
export declare function normalizeValue(type: string, value: unknown): string;
|