@smallpen/core 0.1.0-alpha.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/package.json +18 -0
- package/src/canonical.mjs +64 -0
- package/src/capabilities.mjs +495 -0
- package/src/catalog.mjs +362 -0
- package/src/component-samples.mjs +84 -0
- package/src/components-domain.mjs +335 -0
- package/src/contexts.mjs +248 -0
- package/src/design-projection.mjs +657 -0
- package/src/design-read.mjs +825 -0
- package/src/design-system-authoring.mjs +102 -0
- package/src/design-system-canvas.mjs +862 -0
- package/src/design-system.mjs +437 -0
- package/src/design-validation.mjs +324 -0
- package/src/draft.mjs +784 -0
- package/src/effective-tokens.mjs +377 -0
- package/src/errors.mjs +12 -0
- package/src/index.mjs +112 -0
- package/src/initialization.mjs +310 -0
- package/src/package.mjs +5935 -0
- package/src/projection-values.mjs +138 -0
- package/src/requirements-domain.mjs +222 -0
- package/src/scenarios-domain.mjs +268 -0
- package/src/token-advice.mjs +272 -0
- package/src/token-import.mjs +607 -0
- package/src/tokens-domain.mjs +410 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { SMALLPEN_FORMAT_CAPABILITIES } from "./capabilities.mjs";
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
const TOKEN_TYPES = new Set([
|
|
5
|
+
...SMALLPEN_FORMAT_CAPABILITIES.canonicalPackage.tokenTypes,
|
|
6
|
+
"dimension",
|
|
7
|
+
]);
|
|
8
|
+
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
9
|
+
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assetReference(value, path) {
|
|
15
|
+
if (value === undefined) return undefined;
|
|
16
|
+
if (
|
|
17
|
+
!isRecord(value) ||
|
|
18
|
+
Object.keys(value).length !== 2 ||
|
|
19
|
+
typeof value.assetId !== "string" ||
|
|
20
|
+
value.assetId.length === 0 ||
|
|
21
|
+
typeof value.packageId !== "string" ||
|
|
22
|
+
!value.packageId.startsWith("pkg_") ||
|
|
23
|
+
!ID_PATTERN.test(value.packageId)
|
|
24
|
+
) {
|
|
25
|
+
fail("invalid_asset_reference", `${path} must contain Package and asset ids`, {
|
|
26
|
+
path,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return structuredClone(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stringRecord(value, path) {
|
|
33
|
+
if (!isRecord(value)) {
|
|
34
|
+
fail("invalid_token_context_rule", `${path} must contain an object`, {
|
|
35
|
+
path,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
for (const [field, child] of Object.entries(value)) {
|
|
39
|
+
if (
|
|
40
|
+
field.length === 0 ||
|
|
41
|
+
typeof child !== "string" ||
|
|
42
|
+
child.length === 0
|
|
43
|
+
) {
|
|
44
|
+
fail(
|
|
45
|
+
"invalid_token_context_rule",
|
|
46
|
+
`${path} must map Axis ids to value ids`,
|
|
47
|
+
{ path },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return structuredClone(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extension(value, path) {
|
|
55
|
+
const extensions = value.$extensions;
|
|
56
|
+
const smallpen = isRecord(extensions) ? extensions.smallpen : undefined;
|
|
57
|
+
if (!isRecord(smallpen)) {
|
|
58
|
+
fail(
|
|
59
|
+
"missing_token_id",
|
|
60
|
+
`${path} requires $extensions.smallpen.id`,
|
|
61
|
+
{ path: `${path}.$extensions.smallpen.id` },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const fields = new Set([
|
|
65
|
+
"contextValues",
|
|
66
|
+
"deprecated",
|
|
67
|
+
"draft",
|
|
68
|
+
"id",
|
|
69
|
+
"overrideOf",
|
|
70
|
+
"replacement",
|
|
71
|
+
"visibility",
|
|
72
|
+
]);
|
|
73
|
+
const unsupported = Object.keys(smallpen).filter((field) => !fields.has(field));
|
|
74
|
+
if (unsupported.length > 0) {
|
|
75
|
+
fail(
|
|
76
|
+
"unsupported_token_extension",
|
|
77
|
+
`${path} contains unsupported SmallPen extension field ${unsupported[0]}`,
|
|
78
|
+
{ fields: unsupported, path: `${path}.$extensions.smallpen` },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
typeof smallpen.id !== "string" ||
|
|
83
|
+
!smallpen.id.startsWith("tok_") ||
|
|
84
|
+
!ID_PATTERN.test(smallpen.id)
|
|
85
|
+
) {
|
|
86
|
+
fail("invalid_token_id", `${path} requires a permanent tok_ id`, {
|
|
87
|
+
path: `${path}.$extensions.smallpen.id`,
|
|
88
|
+
value: smallpen.id,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
smallpen.visibility !== undefined &&
|
|
93
|
+
smallpen.visibility !== "private" &&
|
|
94
|
+
smallpen.visibility !== "public"
|
|
95
|
+
) {
|
|
96
|
+
fail("invalid_token_visibility", `${path} visibility is invalid`, {
|
|
97
|
+
path: `${path}.$extensions.smallpen.visibility`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (const field of ["deprecated", "draft"]) {
|
|
101
|
+
if (smallpen[field] !== undefined && typeof smallpen[field] !== "boolean") {
|
|
102
|
+
fail("invalid_token_extension", `${path} ${field} must be boolean`, {
|
|
103
|
+
path: `${path}.$extensions.smallpen.${field}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let contextValues = [];
|
|
108
|
+
if (smallpen.contextValues !== undefined) {
|
|
109
|
+
if (!Array.isArray(smallpen.contextValues)) {
|
|
110
|
+
fail(
|
|
111
|
+
"invalid_token_context_values",
|
|
112
|
+
`${path} contextValues must be an array`,
|
|
113
|
+
{ path: `${path}.$extensions.smallpen.contextValues` },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
contextValues = smallpen.contextValues.map((candidate, index) => {
|
|
117
|
+
const candidatePath =
|
|
118
|
+
`${path}.$extensions.smallpen.contextValues[${index}]`;
|
|
119
|
+
if (
|
|
120
|
+
!isRecord(candidate) ||
|
|
121
|
+
Object.keys(candidate).length !== 2 ||
|
|
122
|
+
!Object.hasOwn(candidate, "value") ||
|
|
123
|
+
!Object.hasOwn(candidate, "when")
|
|
124
|
+
) {
|
|
125
|
+
fail(
|
|
126
|
+
"invalid_token_context_value",
|
|
127
|
+
`${candidatePath} requires value and when`,
|
|
128
|
+
{ path: candidatePath },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
value: structuredClone(candidate.value),
|
|
133
|
+
when: stringRecord(candidate.when, `${candidatePath}.when`),
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
contextValues,
|
|
139
|
+
deprecated: smallpen.deprecated === true,
|
|
140
|
+
draft: smallpen.draft === true,
|
|
141
|
+
id: smallpen.id,
|
|
142
|
+
overrideOf: assetReference(
|
|
143
|
+
smallpen.overrideOf,
|
|
144
|
+
`${path}.$extensions.smallpen.overrideOf`,
|
|
145
|
+
),
|
|
146
|
+
replacement: assetReference(
|
|
147
|
+
smallpen.replacement,
|
|
148
|
+
`${path}.$extensions.smallpen.replacement`,
|
|
149
|
+
),
|
|
150
|
+
visibility: smallpen.visibility === "private" ? "private" : "public",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function tokenAliasPath(value) {
|
|
155
|
+
if (typeof value !== "string") return null;
|
|
156
|
+
return /^\{([^{}]+)\}$/.exec(value)?.[1] ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function tokenValueMatchesType(value, type) {
|
|
160
|
+
if (type === "color") {
|
|
161
|
+
return (
|
|
162
|
+
(typeof value === "string" && /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value)) ||
|
|
163
|
+
(isRecord(value) &&
|
|
164
|
+
String(value.colorSpace).toLowerCase() === "srgb" &&
|
|
165
|
+
Array.isArray(value.components) &&
|
|
166
|
+
value.components.length === 3 &&
|
|
167
|
+
value.components.every(
|
|
168
|
+
(component) =>
|
|
169
|
+
typeof component === "number" &&
|
|
170
|
+
Number.isFinite(component) &&
|
|
171
|
+
component >= 0 &&
|
|
172
|
+
component <= 1,
|
|
173
|
+
) &&
|
|
174
|
+
(value.alpha === undefined ||
|
|
175
|
+
(typeof value.alpha === "number" &&
|
|
176
|
+
Number.isFinite(value.alpha) &&
|
|
177
|
+
value.alpha >= 0 &&
|
|
178
|
+
value.alpha <= 1)))
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
new Set([
|
|
183
|
+
"border-radius",
|
|
184
|
+
"dimension",
|
|
185
|
+
"dimensions",
|
|
186
|
+
"font-size",
|
|
187
|
+
"letter-spacing",
|
|
188
|
+
"number",
|
|
189
|
+
"rotation",
|
|
190
|
+
"sizing",
|
|
191
|
+
"spacing",
|
|
192
|
+
"stroke-width",
|
|
193
|
+
]).has(type)
|
|
194
|
+
) {
|
|
195
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
196
|
+
}
|
|
197
|
+
if (type === "opacity") {
|
|
198
|
+
return (
|
|
199
|
+
typeof value === "number" &&
|
|
200
|
+
Number.isFinite(value) &&
|
|
201
|
+
value >= 0 &&
|
|
202
|
+
value <= 1
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (type === "boolean") return typeof value === "boolean";
|
|
206
|
+
if (
|
|
207
|
+
new Set([
|
|
208
|
+
"font-family",
|
|
209
|
+
"font-weight",
|
|
210
|
+
"other",
|
|
211
|
+
"string",
|
|
212
|
+
"text-case",
|
|
213
|
+
"text-decoration",
|
|
214
|
+
]).has(type)
|
|
215
|
+
) {
|
|
216
|
+
return typeof value === "string" ||
|
|
217
|
+
(type === "font-weight" && typeof value === "number");
|
|
218
|
+
}
|
|
219
|
+
if (type === "typography") {
|
|
220
|
+
return (
|
|
221
|
+
isRecord(value) &&
|
|
222
|
+
typeof value.fontFamily === "string" &&
|
|
223
|
+
typeof value.fontSize === "number" &&
|
|
224
|
+
Number.isFinite(value.fontSize) &&
|
|
225
|
+
typeof value.fontWeight === "number" &&
|
|
226
|
+
Number.isFinite(value.fontWeight)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (type === "shadow") return isRecord(value) || Array.isArray(value);
|
|
230
|
+
return value !== undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function visitDtcgGroup(value, segments, inheritedType, filePath, state) {
|
|
234
|
+
const declaredType =
|
|
235
|
+
typeof value.$type === "string" ? value.$type : inheritedType;
|
|
236
|
+
for (const [name, child] of Object.entries(value)) {
|
|
237
|
+
if (name.startsWith("$")) continue;
|
|
238
|
+
if (!isRecord(child)) {
|
|
239
|
+
fail(
|
|
240
|
+
"invalid_token_group",
|
|
241
|
+
`${filePath}:${[...segments, name].join(".")} must contain an object`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const pathSegments = [...segments, name];
|
|
245
|
+
const tokenPath = pathSegments.join(".");
|
|
246
|
+
const semanticPath = `${filePath}:${tokenPath}`;
|
|
247
|
+
const smallpen = child.$extensions?.smallpen;
|
|
248
|
+
const hasContextValues =
|
|
249
|
+
isRecord(smallpen) && Array.isArray(smallpen.contextValues);
|
|
250
|
+
if (Object.hasOwn(child, "$value") || hasContextValues) {
|
|
251
|
+
const type = typeof child.$type === "string" ? child.$type : declaredType;
|
|
252
|
+
if (!TOKEN_TYPES.has(type)) {
|
|
253
|
+
fail("invalid_token_type", `Unsupported token type at ${semanticPath}`, {
|
|
254
|
+
path: `${semanticPath}.$type`,
|
|
255
|
+
type,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const metadata = extension(child, semanticPath);
|
|
259
|
+
if (state.tokens.has(metadata.id)) {
|
|
260
|
+
fail("duplicate_token_id", `Duplicate Token id: ${metadata.id}`, {
|
|
261
|
+
path: semanticPath,
|
|
262
|
+
tokenId: metadata.id,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (state.byPath.has(tokenPath)) {
|
|
266
|
+
fail("duplicate_token_path", `Duplicate Token path: ${tokenPath}`, {
|
|
267
|
+
path: semanticPath,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const token = {
|
|
271
|
+
contextValues: metadata.contextValues,
|
|
272
|
+
deprecated: metadata.deprecated,
|
|
273
|
+
description:
|
|
274
|
+
typeof child.$description === "string" ? child.$description : undefined,
|
|
275
|
+
draft: metadata.draft,
|
|
276
|
+
filePath,
|
|
277
|
+
group: segments.join("."),
|
|
278
|
+
id: metadata.id,
|
|
279
|
+
overrideOf: metadata.overrideOf,
|
|
280
|
+
path: tokenPath,
|
|
281
|
+
rawValue: Object.hasOwn(child, "$value")
|
|
282
|
+
? structuredClone(child.$value)
|
|
283
|
+
: undefined,
|
|
284
|
+
replacement: metadata.replacement,
|
|
285
|
+
type,
|
|
286
|
+
visibility: metadata.visibility,
|
|
287
|
+
};
|
|
288
|
+
state.tokens.set(token.id, token);
|
|
289
|
+
state.byPath.set(token.path, token);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
visitDtcgGroup(child, pathSegments, declaredType, filePath, state);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function addPenpotLibraryTokens(library, filePath, state) {
|
|
297
|
+
for (const tokenSet of library.sets) {
|
|
298
|
+
for (const tokenValue of tokenSet.tokens) {
|
|
299
|
+
const token = {
|
|
300
|
+
contextValues: [],
|
|
301
|
+
deprecated: false,
|
|
302
|
+
description: tokenValue.description,
|
|
303
|
+
draft: false,
|
|
304
|
+
filePath,
|
|
305
|
+
group: tokenSet.name,
|
|
306
|
+
id: tokenValue.id,
|
|
307
|
+
overrideOf: undefined,
|
|
308
|
+
path: tokenValue.name,
|
|
309
|
+
rawValue: structuredClone(tokenValue.value),
|
|
310
|
+
replacement: undefined,
|
|
311
|
+
type: tokenValue.type,
|
|
312
|
+
visibility: "public",
|
|
313
|
+
};
|
|
314
|
+
if (state.tokens.has(token.id)) {
|
|
315
|
+
fail("duplicate_token_id", `Duplicate Token id: ${token.id}`, {
|
|
316
|
+
path: filePath,
|
|
317
|
+
tokenId: token.id,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
state.tokens.set(token.id, token);
|
|
321
|
+
// Aliases ("{group.token}") resolve by path. Penpot libraries allow the
|
|
322
|
+
// same token name in several sets, so the first occurrence wins rather
|
|
323
|
+
// than failing the whole library.
|
|
324
|
+
if (!state.byPath.has(token.path)) {
|
|
325
|
+
state.byPath.set(token.path, token);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resolveBaseToken(token, byPath, visiting) {
|
|
332
|
+
if (token.resolvedValue !== undefined) return token.resolvedValue;
|
|
333
|
+
if (token.rawValue === undefined) return undefined;
|
|
334
|
+
if (visiting.has(token.id)) {
|
|
335
|
+
fail("token_alias_cycle", `Token alias cycle includes ${token.path}`, {
|
|
336
|
+
path: token.path,
|
|
337
|
+
tokenId: token.id,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const alias = tokenAliasPath(token.rawValue);
|
|
341
|
+
let resolved = token.rawValue;
|
|
342
|
+
if (alias) {
|
|
343
|
+
const target = byPath.get(alias);
|
|
344
|
+
if (!target) {
|
|
345
|
+
fail("missing_token_alias", `Missing Token alias: ${alias}`, {
|
|
346
|
+
path: token.path,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (target.type !== token.type) {
|
|
350
|
+
fail(
|
|
351
|
+
"token_alias_type_mismatch",
|
|
352
|
+
`Token alias type does not match ${target.path}`,
|
|
353
|
+
{ path: token.path },
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
visiting.add(token.id);
|
|
357
|
+
resolved = resolveBaseToken(target, byPath, visiting);
|
|
358
|
+
visiting.delete(token.id);
|
|
359
|
+
}
|
|
360
|
+
if (!tokenValueMatchesType(resolved, token.type)) {
|
|
361
|
+
fail("invalid_token_value", `Value does not match Token type at ${token.path}`, {
|
|
362
|
+
path: token.path,
|
|
363
|
+
tokenId: token.id,
|
|
364
|
+
type: token.type,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
token.resolvedValue = structuredClone(resolved);
|
|
368
|
+
return token.resolvedValue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function parseTokenEntries(manifest, entries) {
|
|
372
|
+
const state = { byPath: new Map(), tokens: new Map() };
|
|
373
|
+
for (const entry of manifest.entries.tokens) {
|
|
374
|
+
const value = entries[entry];
|
|
375
|
+
if (!isRecord(value)) {
|
|
376
|
+
fail("invalid_token_file", `${entry} must contain an object`, { entry });
|
|
377
|
+
}
|
|
378
|
+
if (Array.isArray(value.sets) && Array.isArray(value.themes)) {
|
|
379
|
+
addPenpotLibraryTokens(value, entry, state);
|
|
380
|
+
} else {
|
|
381
|
+
visitDtcgGroup(value, [], undefined, entry, state);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const token of state.tokens.values()) {
|
|
385
|
+
for (const [index, contextual] of token.contextValues.entries()) {
|
|
386
|
+
if (!tokenValueMatchesType(contextual.value, token.type)) {
|
|
387
|
+
fail(
|
|
388
|
+
"invalid_token_context_value",
|
|
389
|
+
`Context value does not match Token type at ${token.path}`,
|
|
390
|
+
{
|
|
391
|
+
index,
|
|
392
|
+
path: `${token.path}.contextValues[${index}].value`,
|
|
393
|
+
tokenId: token.id,
|
|
394
|
+
type: token.type,
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (token.rawValue !== undefined) {
|
|
400
|
+
resolveBaseToken(token, state.byPath, new Set());
|
|
401
|
+
} else if (!token.overrideOf || token.contextValues.length === 0) {
|
|
402
|
+
fail(
|
|
403
|
+
"missing_token_value",
|
|
404
|
+
`Token ${token.path} requires a base value or Context values for an override`,
|
|
405
|
+
{ path: token.path, tokenId: token.id },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return state.tokens;
|
|
410
|
+
}
|