@penpair/marwes-ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tokens.js CHANGED
@@ -6,21 +6,24 @@ export const themeGroup = "Mode";
6
6
  const hex = (value) => Math.round(value).toString(16).padStart(2, "0").toUpperCase();
7
7
  /** Penpot colour tokens are hex; Marwes also uses rgba() and the word transparent. */
8
8
  export function toHexColor(value) {
9
- if (value === "transparent")
9
+ if (value === "transparent") {
10
10
  return "#00000000";
11
+ }
11
12
  const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(value);
12
13
  if (rgba) {
13
14
  const alpha = rgba[4] === undefined ? 1 : Number(rgba[4]);
14
15
  return `#${hex(Number(rgba[1]))}${hex(Number(rgba[2]))}${hex(Number(rgba[3]))}${alpha < 1 ? hex(alpha * 255) : ""}`;
15
16
  }
16
- if (/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value))
17
+ if (/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value)) {
17
18
  return value.toUpperCase();
19
+ }
18
20
  throw new Error(`Unsupported colour value: ${value}`);
19
21
  }
20
22
  const px = (value) => {
21
23
  const match = /^(-?[\d.]+)px$/.exec(value);
22
- if (!match)
24
+ if (!match) {
23
25
  throw new Error(`Expected a px value, got ${value}`);
26
+ }
24
27
  return String(Number(match[1]));
25
28
  };
26
29
  const firstFamily = (stack) => stack
@@ -38,25 +41,36 @@ function typographyTokens(vars, fontFamily, describe) {
38
41
  const variants = new Map();
39
42
  for (const [variable, value] of Object.entries(vars)) {
40
43
  const match = /^--mw-typography-(.+)-(font-size|line-height|font-weight|letter-spacing|transform)$/.exec(variable);
41
- if (!match)
44
+ if (!match) {
42
45
  continue;
43
- const entry = variants.get(match[1]) ?? { fontFamilies: fontFamily };
46
+ }
47
+ const entry = variants.get(match[1]) ?? {
48
+ fontFamilies: fontFamily,
49
+ };
44
50
  const key = typographyKeys[match[2]];
45
51
  if (match[2] === "transform") {
46
- if (value !== "none")
52
+ if (value !== "none") {
47
53
  entry[key] = value;
54
+ }
48
55
  }
49
- else
56
+ else {
50
57
  entry[key] = match[2] === "line-height" || match[2] === "font-weight" ? value : px(value);
58
+ }
51
59
  variants.set(match[1], entry);
52
60
  }
53
- return [...variants.entries()]
61
+ return [
62
+ ...variants.entries(),
63
+ ]
54
64
  .sort(([left], [right]) => left.localeCompare(right))
55
65
  .map(([variant, value]) => ({
56
66
  name: `mw-typography-${variant}`,
57
67
  type: "typography",
58
68
  // The paragraph variants in Marwes define only size and line height; weight and spacing fall back to the normal values, as a browser does.
59
- value: { fontWeight: "400", letterSpacing: "0", ...value },
69
+ value: {
70
+ fontWeight: "400",
71
+ letterSpacing: "0",
72
+ ...value,
73
+ },
60
74
  description: describe(`--mw-typography-${variant}-*`),
61
75
  }));
62
76
  }
@@ -65,34 +79,77 @@ const byName = (left, right) => left.name.localeCompare(right.name);
65
79
  export function tokensFromVars(vars, describe) {
66
80
  const colors = Object.entries(vars)
67
81
  .filter(([variable]) => variable.startsWith("--mw-color-"))
68
- .map(([variable, value]) => ({ name: variable.slice(2), type: "color", value: toHexColor(value), description: describe(variable) }))
82
+ .map(([variable, value]) => ({
83
+ name: variable.slice(2),
84
+ type: "color",
85
+ value: toHexColor(value),
86
+ description: describe(variable),
87
+ }))
69
88
  .sort(byName);
70
89
  const density = Object.entries(vars)
71
90
  .filter(([variable, value]) => variable.startsWith("--mw-density-") && value.endsWith("px"))
72
- .map(([variable, value]) => ({ name: variable.slice(2), type: "sizing", value: px(value), description: describe(variable) }))
91
+ .map(([variable, value]) => ({
92
+ name: variable.slice(2),
93
+ type: "sizing",
94
+ value: px(value),
95
+ description: describe(variable),
96
+ }))
73
97
  .sort(byName);
74
- const radii = ["--mw-ui-radius", "--mw-ui-radius-button"]
98
+ const radii = [
99
+ "--mw-ui-radius",
100
+ "--mw-ui-radius-button",
101
+ ]
75
102
  .filter((variable) => vars[variable] !== undefined)
76
- .map((variable) => ({ name: variable.slice(2), type: "borderRadius", value: px(vars[variable]), description: describe(variable) }));
77
- const fonts = ["--mw-font-primary", "--mw-font-secondary", "--mw-font-mono"]
103
+ .map((variable) => ({
104
+ name: variable.slice(2),
105
+ type: "borderRadius",
106
+ value: px(vars[variable]),
107
+ description: describe(variable),
108
+ }));
109
+ const fonts = [
110
+ "--mw-font-primary",
111
+ "--mw-font-secondary",
112
+ "--mw-font-mono",
113
+ ]
78
114
  .filter((variable) => vars[variable] !== undefined)
79
- .map((variable) => ({ name: variable.slice(2), type: "fontFamilies", value: firstFamily(vars[variable]), description: describe(variable) }));
115
+ .map((variable) => ({
116
+ name: variable.slice(2),
117
+ type: "fontFamilies",
118
+ value: firstFamily(vars[variable]),
119
+ description: describe(variable),
120
+ }));
80
121
  const primary = vars["--mw-font-primary"];
81
122
  const typography = primary === undefined ? [] : typographyTokens(vars, firstFamily(primary), describe);
82
- return [...colors, ...density, ...radii, ...fonts, ...typography];
123
+ return [
124
+ ...colors,
125
+ ...density,
126
+ ...radii,
127
+ ...fonts,
128
+ ...typography,
129
+ ];
83
130
  }
84
131
  /** The tokens of `of` that `base` does not already provide with the same value: what is left to say on top of it. */
85
132
  export function tokensAbove(base, of) {
86
- const known = new Map(base.map((token) => [token.name, canonical(token.value)]));
133
+ const known = new Map(base.map((token) => [
134
+ token.name,
135
+ canonical(token.value),
136
+ ]));
87
137
  return of.filter((token) => known.get(token.name) !== canonical(token.value));
88
138
  }
89
139
  /** JSON with sorted keys, so the same data always gives the same text. */
90
140
  export function canonical(value) {
91
141
  const sort = (input) => {
92
- if (Array.isArray(input))
142
+ if (Array.isArray(input)) {
93
143
  return input.map(sort);
94
- if (typeof input === "object" && input !== null)
95
- return Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, sort(v)]));
144
+ }
145
+ if (typeof input === "object" && input !== null) {
146
+ return Object.fromEntries(Object.entries(input)
147
+ .sort(([a], [b]) => a.localeCompare(b))
148
+ .map(([k, v]) => [
149
+ k,
150
+ sort(v),
151
+ ]));
152
+ }
96
153
  return input;
97
154
  };
98
155
  return JSON.stringify(sort(value));
@@ -100,23 +157,34 @@ export function canonical(value) {
100
157
  const typographyVariable = /^--mw-typography-(.+)-(font-size|line-height|font-weight|letter-spacing|transform)$/;
101
158
  /** Whether a Marwes variable becomes a token (see tokensFromVars). Everything else is reported, never silently dropped. */
102
159
  export function isMapped(variable, value) {
103
- if (variable.startsWith("--mw-color-"))
160
+ if (variable.startsWith("--mw-color-")) {
104
161
  return true;
105
- if (variable.startsWith("--mw-density-"))
162
+ }
163
+ if (variable.startsWith("--mw-density-")) {
106
164
  return value.endsWith("px");
107
- if (variable === "--mw-ui-radius" || variable === "--mw-ui-radius-button")
165
+ }
166
+ if (variable === "--mw-ui-radius" || variable === "--mw-ui-radius-button") {
108
167
  return true;
109
- if (variable === "--mw-font-primary" || variable === "--mw-font-secondary" || variable === "--mw-font-mono")
168
+ }
169
+ if (variable === "--mw-font-primary" ||
170
+ variable === "--mw-font-secondary" ||
171
+ variable === "--mw-font-mono") {
110
172
  return true;
173
+ }
111
174
  return typographyVariable.test(variable);
112
175
  }
113
176
  export function unmappedVariables(...themes) {
114
177
  const found = new Set();
115
- for (const vars of themes)
116
- for (const [variable, value] of Object.entries(vars))
117
- if (!isMapped(variable, value))
178
+ for (const vars of themes) {
179
+ for (const [variable, value] of Object.entries(vars)) {
180
+ if (!isMapped(variable, value)) {
118
181
  found.add(variable);
119
- return [...found].sort();
182
+ }
183
+ }
184
+ }
185
+ return [
186
+ ...found,
187
+ ].sort();
120
188
  }
121
189
  const hashOf = (parts) => createHash("sha256").update(canonical(parts)).digest("hex");
122
190
  /**
@@ -126,7 +194,9 @@ const hashOf = (parts) => createHash("sha256").update(canonical(parts)).digest("
126
194
  */
127
195
  export function buildSnapshot(input) {
128
196
  const { core, version, name } = input;
129
- const mode = (which, theme) => core.themeToCSSVars(core.resolveThemeInput(theme ?? { mode: which }));
197
+ const mode = (which, theme) => core.themeToCSSVars(core.resolveThemeInput(theme ?? {
198
+ mode: which,
199
+ }));
130
200
  const marwes = (variable) => `${variable} (Marwes ${version})`;
131
201
  const marwesDark = (variable) => `${variable} in dark mode (Marwes ${version})`;
132
202
  const overlay = (which) => (variable) => `${variable}, ${name} ${which} theme (Marwes ${version})`;
@@ -137,71 +207,156 @@ export function buildSnapshot(input) {
137
207
  const spacing = Object.values(core.mwThemeVars.spacing)
138
208
  .map((reference) => /var\((--mw-spacing-sp-(\d+))\)/.exec(reference))
139
209
  .filter((match) => match !== null)
140
- .map((match) => ({ name: match[1].slice(2), type: "spacing", value: match[2], description: marwes(match[1]) }))
210
+ .map((match) => ({
211
+ name: match[1].slice(2),
212
+ type: "spacing",
213
+ value: match[2],
214
+ description: marwes(match[1]),
215
+ }))
141
216
  .sort((left, right) => Number(left.value) - Number(right.value));
142
217
  const unmapped = unmappedVariables(lightDefaults, darkDefaults);
143
218
  const sets = [
144
- { name: coreSetName, tokens: [...lightBase, ...spacing] },
145
- { name: darkSetName, tokens: tokensAbove(lightBase, darkBase) },
219
+ {
220
+ name: coreSetName,
221
+ tokens: [
222
+ ...lightBase,
223
+ ...spacing,
224
+ ],
225
+ },
226
+ {
227
+ name: darkSetName,
228
+ tokens: tokensAbove(lightBase, darkBase),
229
+ },
230
+ ];
231
+ const lightSets = [
232
+ coreSetName,
233
+ ];
234
+ const darkSets = [
235
+ coreSetName,
236
+ darkSetName,
146
237
  ];
147
- const lightSets = [coreSetName];
148
- const darkSets = [coreSetName, darkSetName];
149
238
  if (input.project?.light) {
150
239
  const tokens = tokensAbove(lightBase, tokensFromVars(mode("light", input.project.light), overlay("light")));
151
240
  if (tokens.length > 0) {
152
- sets.push({ name: `${name} · Light`, tokens });
241
+ sets.push({
242
+ name: `${name} · Light`,
243
+ tokens,
244
+ });
153
245
  lightSets.push(`${name} · Light`);
154
246
  }
155
247
  }
156
248
  if (input.project?.dark) {
157
249
  const tokens = tokensAbove(darkBase, tokensFromVars(mode("dark", input.project.dark), overlay("dark")));
158
250
  if (tokens.length > 0) {
159
- sets.push({ name: `${name} · Dark`, tokens });
251
+ sets.push({
252
+ name: `${name} · Dark`,
253
+ tokens,
254
+ });
160
255
  darkSets.push(`${name} · Dark`);
161
256
  }
162
257
  }
163
- const ownership = Object.fromEntries(sets.map(set => [set.name, "adapter"]));
164
- const seenSets = new Set(sets.map(set => set.name));
258
+ const ownership = Object.fromEntries(sets.map((set) => [
259
+ set.name,
260
+ "adapter",
261
+ ]));
262
+ const seenSets = new Set(sets.map((set) => set.name));
165
263
  for (const extra of input.extras ?? []) {
166
- if (seenSets.has(extra.name))
264
+ if (seenSets.has(extra.name)) {
167
265
  throw new Error("Project extra collides with generated set: " + extra.name);
168
- if (typeof extra.name !== "string" || !extra.name.trim() || extra.name.length > 200 || !Array.isArray(extra.tokens))
266
+ }
267
+ if (typeof extra.name !== "string" ||
268
+ !extra.name.trim() ||
269
+ extra.name.length > 200 ||
270
+ !Array.isArray(extra.tokens)) {
169
271
  throw new Error("Invalid project extra set");
272
+ }
170
273
  const tokenNames = new Set();
171
274
  for (const token of extra.tokens) {
172
275
  validateEditableToken(token);
173
- if (tokenNames.has(token.name))
276
+ if (tokenNames.has(token.name)) {
174
277
  throw new Error("Duplicate project extra token: " + token.name);
278
+ }
175
279
  tokenNames.add(token.name);
176
280
  }
177
281
  seenSets.add(extra.name);
178
- sets.push({ name: extra.name, tokens: extra.tokens.map(token => validateEditableToken(token)).sort((a, b) => a.name.localeCompare(b.name)) });
179
- Object.defineProperty(ownership, extra.name, { value: "project", enumerable: true, configurable: true, writable: true });
282
+ sets.push({
283
+ name: extra.name,
284
+ tokens: extra.tokens
285
+ .map((token) => validateEditableToken(token))
286
+ .sort((a, b) => a.name.localeCompare(b.name)),
287
+ });
288
+ Object.defineProperty(ownership, extra.name, {
289
+ value: "project",
290
+ enumerable: true,
291
+ configurable: true,
292
+ writable: true,
293
+ });
180
294
  lightSets.push(extra.name);
181
295
  darkSets.push(extra.name);
182
296
  }
183
297
  const themes = [
184
- { group: themeGroup, name: "Light", sets: lightSets },
185
- { group: themeGroup, name: "Dark", sets: darkSets },
298
+ {
299
+ group: themeGroup,
300
+ name: "Light",
301
+ sets: lightSets,
302
+ },
303
+ {
304
+ group: themeGroup,
305
+ name: "Dark",
306
+ sets: darkSets,
307
+ },
186
308
  ];
187
- return { schemaVersion: 2, ownership, marwesVersion: version, generatedBy: input.generatedBy, project: name, hash: hashOf({ marwesVersion: version, project: name, sets, themes, unmapped, ownership }), unmapped, sets, themes };
309
+ return {
310
+ schemaVersion: 2,
311
+ ownership,
312
+ marwesVersion: version,
313
+ generatedBy: input.generatedBy,
314
+ project: name,
315
+ hash: hashOf({
316
+ marwesVersion: version,
317
+ project: name,
318
+ sets,
319
+ themes,
320
+ unmapped,
321
+ ownership,
322
+ }),
323
+ unmapped,
324
+ sets,
325
+ themes,
326
+ };
188
327
  }
189
328
  /** The value of a token as it compares in Penpot: strings are exact, font families and typography are normalised the way Penpot stores them. */
190
329
  export function normalizeValue(type, value) {
191
330
  if (type === "typography" && typeof value === "object" && value !== null) {
192
331
  const entry = {};
193
332
  for (const [key, raw] of Object.entries(value)) {
194
- if (key === "fontFamilies" || key === "fontFamily")
195
- entry.fontFamily = (Array.isArray(raw) ? raw : [raw]).map(String).join(",");
196
- else
333
+ if (key === "fontFamilies" || key === "fontFamily") {
334
+ entry.fontFamily = (Array.isArray(raw)
335
+ ? raw
336
+ : [
337
+ raw,
338
+ ])
339
+ .map(String)
340
+ .join(",");
341
+ }
342
+ else {
197
343
  entry[key] = String(raw);
344
+ }
198
345
  }
199
346
  return canonical(entry);
200
347
  }
201
- if (type === "color" && typeof value === "string" && /^#[0-9a-f]+$/i.test(value))
348
+ if (type === "color" && typeof value === "string" && /^#[0-9a-f]+$/i.test(value)) {
202
349
  return value.toUpperCase();
350
+ }
203
351
  // Penpot stores a font family token as a list (["Inter"]) whatever it was given.
204
- if (type === "fontFamilies" || type === "fontFamily")
205
- return (Array.isArray(value) ? value : [value]).map(String).join(",");
352
+ if (type === "fontFamilies" || type === "fontFamily") {
353
+ return (Array.isArray(value)
354
+ ? value
355
+ : [
356
+ value,
357
+ ])
358
+ .map(String)
359
+ .join(",");
360
+ }
206
361
  return typeof value === "string" ? value : canonical(value);
207
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/marwes-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The Marwes UI adapter for penpair: Marwes tokens (and your own theme) in your Penpot file, with drift checks against the installed Marwes: snapshot, check, diff, push.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "jiti": "^2.4.2",
47
- "@penpair/core": "0.2.0"
47
+ "@penpair/core": "0.3.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",