@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/tokens.js CHANGED
@@ -1,25 +1,29 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { validateEditableToken } from "@penpair/core";
2
3
  export const coreSetName = "Marwes · Core";
3
4
  export const darkSetName = "Marwes · Dark";
4
5
  export const themeGroup = "Mode";
5
6
  const hex = (value) => Math.round(value).toString(16).padStart(2, "0").toUpperCase();
6
7
  /** Penpot colour tokens are hex; Marwes also uses rgba() and the word transparent. */
7
8
  export function toHexColor(value) {
8
- if (value === "transparent")
9
+ if (value === "transparent") {
9
10
  return "#00000000";
11
+ }
10
12
  const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(value);
11
13
  if (rgba) {
12
14
  const alpha = rgba[4] === undefined ? 1 : Number(rgba[4]);
13
15
  return `#${hex(Number(rgba[1]))}${hex(Number(rgba[2]))}${hex(Number(rgba[3]))}${alpha < 1 ? hex(alpha * 255) : ""}`;
14
16
  }
15
- 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)) {
16
18
  return value.toUpperCase();
19
+ }
17
20
  throw new Error(`Unsupported colour value: ${value}`);
18
21
  }
19
22
  const px = (value) => {
20
23
  const match = /^(-?[\d.]+)px$/.exec(value);
21
- if (!match)
24
+ if (!match) {
22
25
  throw new Error(`Expected a px value, got ${value}`);
26
+ }
23
27
  return String(Number(match[1]));
24
28
  };
25
29
  const firstFamily = (stack) => stack
@@ -37,25 +41,36 @@ function typographyTokens(vars, fontFamily, describe) {
37
41
  const variants = new Map();
38
42
  for (const [variable, value] of Object.entries(vars)) {
39
43
  const match = /^--mw-typography-(.+)-(font-size|line-height|font-weight|letter-spacing|transform)$/.exec(variable);
40
- if (!match)
44
+ if (!match) {
41
45
  continue;
42
- const entry = variants.get(match[1]) ?? { fontFamilies: fontFamily };
46
+ }
47
+ const entry = variants.get(match[1]) ?? {
48
+ fontFamilies: fontFamily,
49
+ };
43
50
  const key = typographyKeys[match[2]];
44
51
  if (match[2] === "transform") {
45
- if (value !== "none")
52
+ if (value !== "none") {
46
53
  entry[key] = value;
54
+ }
47
55
  }
48
- else
56
+ else {
49
57
  entry[key] = match[2] === "line-height" || match[2] === "font-weight" ? value : px(value);
58
+ }
50
59
  variants.set(match[1], entry);
51
60
  }
52
- return [...variants.entries()]
61
+ return [
62
+ ...variants.entries(),
63
+ ]
53
64
  .sort(([left], [right]) => left.localeCompare(right))
54
65
  .map(([variant, value]) => ({
55
66
  name: `mw-typography-${variant}`,
56
67
  type: "typography",
57
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.
58
- value: { fontWeight: "400", letterSpacing: "0", ...value },
69
+ value: {
70
+ fontWeight: "400",
71
+ letterSpacing: "0",
72
+ ...value,
73
+ },
59
74
  description: describe(`--mw-typography-${variant}-*`),
60
75
  }));
61
76
  }
@@ -64,34 +79,77 @@ const byName = (left, right) => left.name.localeCompare(right.name);
64
79
  export function tokensFromVars(vars, describe) {
65
80
  const colors = Object.entries(vars)
66
81
  .filter(([variable]) => variable.startsWith("--mw-color-"))
67
- .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
+ }))
68
88
  .sort(byName);
69
89
  const density = Object.entries(vars)
70
90
  .filter(([variable, value]) => variable.startsWith("--mw-density-") && value.endsWith("px"))
71
- .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
+ }))
72
97
  .sort(byName);
73
- const radii = ["--mw-ui-radius", "--mw-ui-radius-button"]
98
+ const radii = [
99
+ "--mw-ui-radius",
100
+ "--mw-ui-radius-button",
101
+ ]
74
102
  .filter((variable) => vars[variable] !== undefined)
75
- .map((variable) => ({ name: variable.slice(2), type: "borderRadius", value: px(vars[variable]), description: describe(variable) }));
76
- 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
+ ]
77
114
  .filter((variable) => vars[variable] !== undefined)
78
- .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
+ }));
79
121
  const primary = vars["--mw-font-primary"];
80
122
  const typography = primary === undefined ? [] : typographyTokens(vars, firstFamily(primary), describe);
81
- return [...colors, ...density, ...radii, ...fonts, ...typography];
123
+ return [
124
+ ...colors,
125
+ ...density,
126
+ ...radii,
127
+ ...fonts,
128
+ ...typography,
129
+ ];
82
130
  }
83
131
  /** The tokens of `of` that `base` does not already provide with the same value: what is left to say on top of it. */
84
132
  export function tokensAbove(base, of) {
85
- 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
+ ]));
86
137
  return of.filter((token) => known.get(token.name) !== canonical(token.value));
87
138
  }
88
139
  /** JSON with sorted keys, so the same data always gives the same text. */
89
140
  export function canonical(value) {
90
141
  const sort = (input) => {
91
- if (Array.isArray(input))
142
+ if (Array.isArray(input)) {
92
143
  return input.map(sort);
93
- if (typeof input === "object" && input !== null)
94
- 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
+ }
95
153
  return input;
96
154
  };
97
155
  return JSON.stringify(sort(value));
@@ -99,23 +157,34 @@ export function canonical(value) {
99
157
  const typographyVariable = /^--mw-typography-(.+)-(font-size|line-height|font-weight|letter-spacing|transform)$/;
100
158
  /** Whether a Marwes variable becomes a token (see tokensFromVars). Everything else is reported, never silently dropped. */
101
159
  export function isMapped(variable, value) {
102
- if (variable.startsWith("--mw-color-"))
160
+ if (variable.startsWith("--mw-color-")) {
103
161
  return true;
104
- if (variable.startsWith("--mw-density-"))
162
+ }
163
+ if (variable.startsWith("--mw-density-")) {
105
164
  return value.endsWith("px");
106
- if (variable === "--mw-ui-radius" || variable === "--mw-ui-radius-button")
165
+ }
166
+ if (variable === "--mw-ui-radius" || variable === "--mw-ui-radius-button") {
107
167
  return true;
108
- 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") {
109
172
  return true;
173
+ }
110
174
  return typographyVariable.test(variable);
111
175
  }
112
176
  export function unmappedVariables(...themes) {
113
177
  const found = new Set();
114
- for (const vars of themes)
115
- for (const [variable, value] of Object.entries(vars))
116
- if (!isMapped(variable, value))
178
+ for (const vars of themes) {
179
+ for (const [variable, value] of Object.entries(vars)) {
180
+ if (!isMapped(variable, value)) {
117
181
  found.add(variable);
118
- return [...found].sort();
182
+ }
183
+ }
184
+ }
185
+ return [
186
+ ...found,
187
+ ].sort();
119
188
  }
120
189
  const hashOf = (parts) => createHash("sha256").update(canonical(parts)).digest("hex");
121
190
  /**
@@ -125,7 +194,9 @@ const hashOf = (parts) => createHash("sha256").update(canonical(parts)).digest("
125
194
  */
126
195
  export function buildSnapshot(input) {
127
196
  const { core, version, name } = input;
128
- 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
+ }));
129
200
  const marwes = (variable) => `${variable} (Marwes ${version})`;
130
201
  const marwesDark = (variable) => `${variable} in dark mode (Marwes ${version})`;
131
202
  const overlay = (which) => (variable) => `${variable}, ${name} ${which} theme (Marwes ${version})`;
@@ -136,51 +207,156 @@ export function buildSnapshot(input) {
136
207
  const spacing = Object.values(core.mwThemeVars.spacing)
137
208
  .map((reference) => /var\((--mw-spacing-sp-(\d+))\)/.exec(reference))
138
209
  .filter((match) => match !== null)
139
- .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
+ }))
140
216
  .sort((left, right) => Number(left.value) - Number(right.value));
141
217
  const unmapped = unmappedVariables(lightDefaults, darkDefaults);
142
218
  const sets = [
143
- { name: coreSetName, tokens: [...lightBase, ...spacing] },
144
- { 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,
145
237
  ];
146
- const lightSets = [coreSetName];
147
- const darkSets = [coreSetName, darkSetName];
148
238
  if (input.project?.light) {
149
239
  const tokens = tokensAbove(lightBase, tokensFromVars(mode("light", input.project.light), overlay("light")));
150
240
  if (tokens.length > 0) {
151
- sets.push({ name: `${name} · Light`, tokens });
241
+ sets.push({
242
+ name: `${name} · Light`,
243
+ tokens,
244
+ });
152
245
  lightSets.push(`${name} · Light`);
153
246
  }
154
247
  }
155
248
  if (input.project?.dark) {
156
249
  const tokens = tokensAbove(darkBase, tokensFromVars(mode("dark", input.project.dark), overlay("dark")));
157
250
  if (tokens.length > 0) {
158
- sets.push({ name: `${name} · Dark`, tokens });
251
+ sets.push({
252
+ name: `${name} · Dark`,
253
+ tokens,
254
+ });
159
255
  darkSets.push(`${name} · Dark`);
160
256
  }
161
257
  }
258
+ const ownership = Object.fromEntries(sets.map((set) => [
259
+ set.name,
260
+ "adapter",
261
+ ]));
262
+ const seenSets = new Set(sets.map((set) => set.name));
263
+ for (const extra of input.extras ?? []) {
264
+ if (seenSets.has(extra.name)) {
265
+ throw new Error("Project extra collides with generated set: " + extra.name);
266
+ }
267
+ if (typeof extra.name !== "string" ||
268
+ !extra.name.trim() ||
269
+ extra.name.length > 200 ||
270
+ !Array.isArray(extra.tokens)) {
271
+ throw new Error("Invalid project extra set");
272
+ }
273
+ const tokenNames = new Set();
274
+ for (const token of extra.tokens) {
275
+ validateEditableToken(token);
276
+ if (tokenNames.has(token.name)) {
277
+ throw new Error("Duplicate project extra token: " + token.name);
278
+ }
279
+ tokenNames.add(token.name);
280
+ }
281
+ seenSets.add(extra.name);
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
+ });
294
+ lightSets.push(extra.name);
295
+ darkSets.push(extra.name);
296
+ }
162
297
  const themes = [
163
- { group: themeGroup, name: "Light", sets: lightSets },
164
- { 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
+ },
165
308
  ];
166
- return { schemaVersion: 1, marwesVersion: version, generatedBy: input.generatedBy, project: name, hash: hashOf({ marwesVersion: version, project: name, sets, themes, unmapped }), 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
+ };
167
327
  }
168
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. */
169
329
  export function normalizeValue(type, value) {
170
330
  if (type === "typography" && typeof value === "object" && value !== null) {
171
331
  const entry = {};
172
332
  for (const [key, raw] of Object.entries(value)) {
173
- if (key === "fontFamilies" || key === "fontFamily")
174
- entry.fontFamily = (Array.isArray(raw) ? raw : [raw]).map(String).join(",");
175
- 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 {
176
343
  entry[key] = String(raw);
344
+ }
177
345
  }
178
346
  return canonical(entry);
179
347
  }
180
- 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)) {
181
349
  return value.toUpperCase();
350
+ }
182
351
  // Penpot stores a font family token as a list (["Inter"]) whatever it was given.
183
- if (type === "fontFamilies" || type === "fontFamily")
184
- 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
+ }
185
361
  return typeof value === "string" ? value : canonical(value);
186
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/marwes-ui",
3
- "version": "0.1.3",
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.1.3"
47
+ "@penpair/core": "0.3.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",