@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,607 @@
|
|
|
1
|
+
import { SMALLPEN_FORMAT_CAPABILITIES } from "./capabilities.mjs";
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
import { tokenAliasPath, tokenValueMatchesType } from "./tokens-domain.mjs";
|
|
4
|
+
|
|
5
|
+
// Import a DTCG token document (Penpot / Tokens Studio export, single-set,
|
|
6
|
+
// multi-set, or legacy value/type form) into the Penpot-shaped Token Library
|
|
7
|
+
// (Form A) that the Package stores and the workspace projection reads.
|
|
8
|
+
//
|
|
9
|
+
// Identity is by name: a Token Set keeps its id when its name matches, a Token
|
|
10
|
+
// keeps its id when its (set name, token name) matches, a Theme keeps its id
|
|
11
|
+
// when its group/name matches. Nothing else is tracked between imports; the
|
|
12
|
+
// reviewer decides per row from the diff.
|
|
13
|
+
|
|
14
|
+
const TOKEN_TYPES = new Set(
|
|
15
|
+
SMALLPEN_FORMAT_CAPABILITIES.canonicalPackage.tokenTypes,
|
|
16
|
+
);
|
|
17
|
+
// Same rule as validateTokenLibrary in package.mjs.
|
|
18
|
+
const TOKEN_NAME_PATTERN = /^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$/;
|
|
19
|
+
|
|
20
|
+
// DTCG / Tokens Studio "$type" → SmallPen (Penpot-internal) type. Mirrors
|
|
21
|
+
// dtcg-token-type->token-type in common/src/app/common/types/token.cljc,
|
|
22
|
+
// including the singular spellings Penpot accepts for backwards compatibility.
|
|
23
|
+
const DTCG_TYPE_TO_TOKEN_TYPE = new Map([
|
|
24
|
+
["boolean", "boolean"],
|
|
25
|
+
["borderRadius", "border-radius"],
|
|
26
|
+
["borderWidth", "stroke-width"],
|
|
27
|
+
["boxShadow", "shadow"],
|
|
28
|
+
["color", "color"],
|
|
29
|
+
["dimension", "dimensions"],
|
|
30
|
+
["fontFamilies", "font-family"],
|
|
31
|
+
["fontFamily", "font-family"],
|
|
32
|
+
["fontSize", "font-size"],
|
|
33
|
+
["fontSizes", "font-size"],
|
|
34
|
+
["fontWeight", "font-weight"],
|
|
35
|
+
["fontWeights", "font-weight"],
|
|
36
|
+
["letterSpacing", "letter-spacing"],
|
|
37
|
+
["number", "number"],
|
|
38
|
+
["opacity", "opacity"],
|
|
39
|
+
["other", "other"],
|
|
40
|
+
["rotation", "rotation"],
|
|
41
|
+
["shadow", "shadow"],
|
|
42
|
+
["sizing", "sizing"],
|
|
43
|
+
["spacing", "spacing"],
|
|
44
|
+
["string", "string"],
|
|
45
|
+
["textCase", "text-case"],
|
|
46
|
+
["textDecoration", "text-decoration"],
|
|
47
|
+
["typography", "typography"],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function isRecord(value) {
|
|
51
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeTokenType(type) {
|
|
55
|
+
if (typeof type !== "string") return undefined;
|
|
56
|
+
if (DTCG_TYPE_TO_TOKEN_TYPE.has(type)) return DTCG_TYPE_TO_TOKEN_TYPE.get(type);
|
|
57
|
+
// SmallPen's own type names (already Penpot-internal) pass through.
|
|
58
|
+
if (TOKEN_TYPES.has(type)) return type;
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isDtcgLeaf(node) {
|
|
63
|
+
return isRecord(node) && Object.hasOwn(node, "$value");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isLegacyLeaf(node) {
|
|
67
|
+
return (
|
|
68
|
+
isRecord(node) &&
|
|
69
|
+
!Object.hasOwn(node, "$value") &&
|
|
70
|
+
Object.hasOwn(node, "value") &&
|
|
71
|
+
typeof node.type === "string"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Flatten one set's nested group tree into tokens named "group.sub.token".
|
|
76
|
+
// A group-level "$type" is inherited by leaves without their own, per the
|
|
77
|
+
// DTCG Format Module.
|
|
78
|
+
function flattenSet(tree, setName, warnings) {
|
|
79
|
+
const tokens = [];
|
|
80
|
+
const walk = (node, segments, inheritedType) => {
|
|
81
|
+
const groupType =
|
|
82
|
+
typeof node.$type === "string" ? node.$type : inheritedType;
|
|
83
|
+
for (const [key, child] of Object.entries(node)) {
|
|
84
|
+
if (key.startsWith("$") || !isRecord(child)) continue;
|
|
85
|
+
const path = [...segments, key];
|
|
86
|
+
const name = path.join(".");
|
|
87
|
+
if (isDtcgLeaf(child) || isLegacyLeaf(child)) {
|
|
88
|
+
const dtcg = isDtcgLeaf(child);
|
|
89
|
+
const rawType = dtcg ? child.$type : child.type;
|
|
90
|
+
const rawValue = dtcg ? child.$value : child.value;
|
|
91
|
+
const rawDescription = dtcg ? child.$description : child.description;
|
|
92
|
+
const sourceType = typeof rawType === "string" ? rawType : groupType;
|
|
93
|
+
const type = normalizeTokenType(sourceType);
|
|
94
|
+
if (type === undefined) {
|
|
95
|
+
warnings.push({
|
|
96
|
+
code: "unsupported_token_type",
|
|
97
|
+
name,
|
|
98
|
+
set: setName,
|
|
99
|
+
type: sourceType ?? null,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!TOKEN_NAME_PATTERN.test(name)) {
|
|
104
|
+
warnings.push({ code: "invalid_token_name", name, set: setName });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
tokens.push({
|
|
108
|
+
description:
|
|
109
|
+
typeof rawDescription === "string" ? rawDescription : "",
|
|
110
|
+
name,
|
|
111
|
+
type,
|
|
112
|
+
value: structuredClone(rawValue),
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
walk(child, path, groupType);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(tree, [], undefined);
|
|
120
|
+
return tokens;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function themePath(group, name) {
|
|
124
|
+
return `${group}/${name}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse a token document into a neutral shape: sets (in tokenSetOrder), themes,
|
|
128
|
+
// and activation, plus per-token warnings for what was skipped.
|
|
129
|
+
export function parseTokenDocument(documentValue, options = {}) {
|
|
130
|
+
if (!isRecord(documentValue)) {
|
|
131
|
+
fail("invalid_token_document", "Token import requires a JSON object");
|
|
132
|
+
}
|
|
133
|
+
const warnings = [];
|
|
134
|
+
const multiSet =
|
|
135
|
+
Object.hasOwn(documentValue, "$themes") ||
|
|
136
|
+
Object.hasOwn(documentValue, "$metadata");
|
|
137
|
+
let sets;
|
|
138
|
+
if (multiSet) {
|
|
139
|
+
sets = Object.entries(documentValue)
|
|
140
|
+
.filter(([key, value]) => !key.startsWith("$") && isRecord(value))
|
|
141
|
+
.map(([name, tree]) => ({ name, tokens: flattenSet(tree, name, warnings) }));
|
|
142
|
+
} else {
|
|
143
|
+
const name =
|
|
144
|
+
typeof options.setName === "string" && options.setName.trim().length > 0
|
|
145
|
+
? options.setName.trim()
|
|
146
|
+
: "Imported";
|
|
147
|
+
sets = [{ name, tokens: flattenSet(documentValue, name, warnings) }];
|
|
148
|
+
}
|
|
149
|
+
const metadata = isRecord(documentValue.$metadata) ? documentValue.$metadata : {};
|
|
150
|
+
const order = Array.isArray(metadata.tokenSetOrder) ? metadata.tokenSetOrder : [];
|
|
151
|
+
const rank = new Map(order.map((name, index) => [name, index]));
|
|
152
|
+
sets = sets
|
|
153
|
+
.map((set, index) => ({ index, set }))
|
|
154
|
+
.sort((left, right) => {
|
|
155
|
+
const l = rank.has(left.set.name)
|
|
156
|
+
? rank.get(left.set.name)
|
|
157
|
+
: order.length + left.index;
|
|
158
|
+
const r = rank.has(right.set.name)
|
|
159
|
+
? rank.get(right.set.name)
|
|
160
|
+
: order.length + right.index;
|
|
161
|
+
return l - r;
|
|
162
|
+
})
|
|
163
|
+
.map(({ set }) => set);
|
|
164
|
+
const themes = (Array.isArray(documentValue.$themes) ? documentValue.$themes : [])
|
|
165
|
+
.filter(isRecord)
|
|
166
|
+
.map((theme) => ({
|
|
167
|
+
description: typeof theme.description === "string" ? theme.description : "",
|
|
168
|
+
externalId: typeof theme.id === "string" ? theme.id : "",
|
|
169
|
+
group: typeof theme.group === "string" ? theme.group : "",
|
|
170
|
+
isSource: theme.isSource === true || theme["is-source"] === true,
|
|
171
|
+
name: typeof theme.name === "string" ? theme.name : "",
|
|
172
|
+
setNames: isRecord(theme.selectedTokenSets)
|
|
173
|
+
? Object.entries(theme.selectedTokenSets)
|
|
174
|
+
.filter(([, state]) => state === "enabled" || state === "source")
|
|
175
|
+
.map(([name]) => name)
|
|
176
|
+
: [],
|
|
177
|
+
}))
|
|
178
|
+
.filter((theme) => theme.name.length > 0);
|
|
179
|
+
const strings = (value) =>
|
|
180
|
+
Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
181
|
+
const parsed = {
|
|
182
|
+
activeSets: strings(metadata.activeSets),
|
|
183
|
+
activeThemes: strings(metadata.activeThemes),
|
|
184
|
+
sets,
|
|
185
|
+
themes,
|
|
186
|
+
warnings,
|
|
187
|
+
};
|
|
188
|
+
if (sets.every((set) => set.tokens.length === 0) && themes.length === 0) {
|
|
189
|
+
fail(
|
|
190
|
+
"no_tokens_found",
|
|
191
|
+
"No tokens, sets, or themes were found in the document",
|
|
192
|
+
{ warnings },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return parsed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function slugId(prefix, ...parts) {
|
|
199
|
+
const body = parts
|
|
200
|
+
.join("_")
|
|
201
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
202
|
+
.replace(/^_+|_+$/g, "");
|
|
203
|
+
return `${prefix}${body.length > 0 ? body : "item"}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function claimUnique(preferred, used) {
|
|
207
|
+
let candidate = preferred;
|
|
208
|
+
let suffix = 2;
|
|
209
|
+
while (used.has(candidate)) {
|
|
210
|
+
candidate = `${preferred}_${suffix}`;
|
|
211
|
+
suffix += 1;
|
|
212
|
+
}
|
|
213
|
+
used.add(candidate);
|
|
214
|
+
return candidate;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function previousIndex(previous) {
|
|
218
|
+
const sets = new Map();
|
|
219
|
+
const tokens = new Map();
|
|
220
|
+
const themes = new Map();
|
|
221
|
+
for (const set of previous?.sets ?? []) {
|
|
222
|
+
sets.set(set.name, set);
|
|
223
|
+
for (const token of set.tokens) {
|
|
224
|
+
tokens.set(`${set.name}\0${token.name}`, token);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const theme of previous?.themes ?? []) {
|
|
228
|
+
themes.set(themePath(theme.group, theme.name), theme);
|
|
229
|
+
}
|
|
230
|
+
return { sets, themes, tokens };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Build a Form A Token Library from a parsed document, reusing ids from the
|
|
234
|
+
// previous library wherever a set, token, or theme matches by name. Reused ids
|
|
235
|
+
// are reserved first so a fresh slug can never displace an existing identity.
|
|
236
|
+
export function buildTokenLibrary(parsed, previous = null, options = {}) {
|
|
237
|
+
const prior = previousIndex(previous);
|
|
238
|
+
const usedSetIds = new Set();
|
|
239
|
+
const usedTokenIds = new Set(options.reservedTokenIds ?? []);
|
|
240
|
+
const usedThemeIds = new Set();
|
|
241
|
+
const setIds = new Map();
|
|
242
|
+
const tokenIds = new Map();
|
|
243
|
+
const themeIds = new Map();
|
|
244
|
+
for (const set of parsed.sets) {
|
|
245
|
+
const priorSet = prior.sets.get(set.name);
|
|
246
|
+
if (priorSet) {
|
|
247
|
+
setIds.set(set.name, priorSet.id);
|
|
248
|
+
usedSetIds.add(priorSet.id);
|
|
249
|
+
}
|
|
250
|
+
for (const token of set.tokens) {
|
|
251
|
+
const key = `${set.name}\0${token.name}`;
|
|
252
|
+
const priorToken = prior.tokens.get(key);
|
|
253
|
+
if (priorToken) {
|
|
254
|
+
tokenIds.set(key, priorToken.id);
|
|
255
|
+
usedTokenIds.add(priorToken.id);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
for (const theme of parsed.themes) {
|
|
260
|
+
const key = themePath(theme.group, theme.name);
|
|
261
|
+
const priorTheme = prior.themes.get(key);
|
|
262
|
+
if (priorTheme) {
|
|
263
|
+
themeIds.set(key, priorTheme.id);
|
|
264
|
+
usedThemeIds.add(priorTheme.id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const sets = parsed.sets.map((set) => {
|
|
268
|
+
const id =
|
|
269
|
+
setIds.get(set.name) ?? claimUnique(slugId("tset_", set.name), usedSetIds);
|
|
270
|
+
const tokens = set.tokens.map((token) => {
|
|
271
|
+
const key = `${set.name}\0${token.name}`;
|
|
272
|
+
const tokenId =
|
|
273
|
+
tokenIds.get(key) ??
|
|
274
|
+
claimUnique(slugId("tok_", token.name), usedTokenIds);
|
|
275
|
+
return {
|
|
276
|
+
description: token.description,
|
|
277
|
+
id: tokenId,
|
|
278
|
+
name: token.name,
|
|
279
|
+
type: token.type,
|
|
280
|
+
value: structuredClone(token.value),
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
description: prior.sets.get(set.name)?.description ?? "",
|
|
285
|
+
id,
|
|
286
|
+
name: set.name,
|
|
287
|
+
tokens,
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
const setIdByName = new Map(sets.map((set) => [set.name, set.id]));
|
|
291
|
+
const themes = parsed.themes.map((theme) => {
|
|
292
|
+
const key = themePath(theme.group, theme.name);
|
|
293
|
+
const id =
|
|
294
|
+
themeIds.get(key) ??
|
|
295
|
+
claimUnique(slugId("theme_", theme.group, theme.name), usedThemeIds);
|
|
296
|
+
return {
|
|
297
|
+
description: theme.description,
|
|
298
|
+
externalId: theme.externalId,
|
|
299
|
+
group: theme.group,
|
|
300
|
+
id,
|
|
301
|
+
isSource: theme.isSource,
|
|
302
|
+
name: theme.name,
|
|
303
|
+
setIds: theme.setNames
|
|
304
|
+
.map((name) => setIdByName.get(name))
|
|
305
|
+
.filter((value) => value !== undefined),
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
const themeIdByPath = new Map(
|
|
309
|
+
themes.map((theme) => [themePath(theme.group, theme.name), theme.id]),
|
|
310
|
+
);
|
|
311
|
+
return {
|
|
312
|
+
activeSetIds: [
|
|
313
|
+
...new Set(
|
|
314
|
+
parsed.activeSets
|
|
315
|
+
.map((name) => setIdByName.get(name))
|
|
316
|
+
.filter((value) => value !== undefined),
|
|
317
|
+
),
|
|
318
|
+
],
|
|
319
|
+
activeThemeIds: [
|
|
320
|
+
...new Set(
|
|
321
|
+
parsed.activeThemes
|
|
322
|
+
.map((path) => themeIdByPath.get(path))
|
|
323
|
+
.filter((value) => value !== undefined),
|
|
324
|
+
),
|
|
325
|
+
],
|
|
326
|
+
id: previous?.id ?? "tlib_default",
|
|
327
|
+
sets,
|
|
328
|
+
themes,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The Package loader (tokens-domain parseTokenEntries) rejects a whole library
|
|
333
|
+
// when any token has a dangling alias, an alias of another type, an alias
|
|
334
|
+
// cycle, or a resolved value that does not fit its type. Penpot tolerates those
|
|
335
|
+
// on import and lets the user fix them later; SmallPen does not. Apply the same
|
|
336
|
+
// rules here so every token left in the library is one the loader accepts, and
|
|
337
|
+
// report each dropped token instead of failing the import. Dropping a token can
|
|
338
|
+
// orphan an alias that pointed at it, so repeat until nothing changes.
|
|
339
|
+
export function pruneUnresolvableTokens(library) {
|
|
340
|
+
const warnings = [];
|
|
341
|
+
const pruned = structuredClone(library);
|
|
342
|
+
for (;;) {
|
|
343
|
+
const byName = new Map();
|
|
344
|
+
for (const set of pruned.sets) {
|
|
345
|
+
for (const token of set.tokens) byName.set(token.name, token);
|
|
346
|
+
}
|
|
347
|
+
const reject = (set, token, code, details = {}) => {
|
|
348
|
+
warnings.push({ code, name: token.name, set: set.name, ...details });
|
|
349
|
+
};
|
|
350
|
+
const problem = (set, token) => {
|
|
351
|
+
const visiting = new Set();
|
|
352
|
+
let current = token;
|
|
353
|
+
let alias = tokenAliasPath(current.value);
|
|
354
|
+
while (alias !== null) {
|
|
355
|
+
if (visiting.has(current.name)) return ["token_alias_cycle", {}];
|
|
356
|
+
visiting.add(current.name);
|
|
357
|
+
const target = byName.get(alias);
|
|
358
|
+
if (!target) return ["missing_token_alias", { alias }];
|
|
359
|
+
if (target.type !== token.type) {
|
|
360
|
+
return ["token_alias_type_mismatch", { alias, aliasType: target.type }];
|
|
361
|
+
}
|
|
362
|
+
current = target;
|
|
363
|
+
alias = tokenAliasPath(current.value);
|
|
364
|
+
}
|
|
365
|
+
if (!tokenValueMatchesType(current.value, token.type)) {
|
|
366
|
+
return ["invalid_token_value", { value: structuredClone(current.value) }];
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
};
|
|
370
|
+
let dropped = 0;
|
|
371
|
+
for (const set of pruned.sets) {
|
|
372
|
+
set.tokens = set.tokens.filter((token) => {
|
|
373
|
+
const found = problem(set, token);
|
|
374
|
+
if (found === null) return true;
|
|
375
|
+
reject(set, token, found[0], found[1]);
|
|
376
|
+
dropped += 1;
|
|
377
|
+
return false;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (dropped === 0) break;
|
|
381
|
+
}
|
|
382
|
+
return { library: pruned, warnings };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function tokenView(setName, token) {
|
|
386
|
+
return {
|
|
387
|
+
description: token.description,
|
|
388
|
+
name: token.name,
|
|
389
|
+
set: setName,
|
|
390
|
+
type: token.type,
|
|
391
|
+
value: structuredClone(token.value),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function sameJson(left, right) {
|
|
396
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function sameToken(left, right) {
|
|
400
|
+
return (
|
|
401
|
+
left.type === right.type &&
|
|
402
|
+
left.description === right.description &&
|
|
403
|
+
sameJson(left.value, right.value)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function indexTokens(library) {
|
|
408
|
+
const index = new Map();
|
|
409
|
+
for (const set of library?.sets ?? []) {
|
|
410
|
+
for (const token of set.tokens) {
|
|
411
|
+
index.set(`${set.name}\0${token.name}`, tokenView(set.name, token));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return index;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function themeView(theme) {
|
|
418
|
+
return {
|
|
419
|
+
description: theme.description,
|
|
420
|
+
externalId: theme.externalId,
|
|
421
|
+
group: theme.group,
|
|
422
|
+
isSource: theme.isSource,
|
|
423
|
+
name: theme.name,
|
|
424
|
+
path: themePath(theme.group, theme.name),
|
|
425
|
+
setIds: [...theme.setIds],
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The review payload: every token, set, and theme that the import would add,
|
|
430
|
+
// remove, or change, with the real before/after values.
|
|
431
|
+
export function diffTokenLibraries(before, after) {
|
|
432
|
+
const beforeTokens = indexTokens(before);
|
|
433
|
+
const afterTokens = indexTokens(after);
|
|
434
|
+
const tokens = { added: [], changed: [], removed: [] };
|
|
435
|
+
for (const [key, token] of afterTokens) {
|
|
436
|
+
const previous = beforeTokens.get(key);
|
|
437
|
+
if (!previous) tokens.added.push(token);
|
|
438
|
+
else if (!sameToken(previous, token)) {
|
|
439
|
+
// Name the fields that differ so a reviewer can tell a value change
|
|
440
|
+
// from a description- or type-only change at a glance.
|
|
441
|
+
const fields = [];
|
|
442
|
+
if (!sameJson(previous.value, token.value)) fields.push("value");
|
|
443
|
+
if (previous.type !== token.type) fields.push("type");
|
|
444
|
+
if (previous.description !== token.description) fields.push("description");
|
|
445
|
+
tokens.changed.push({
|
|
446
|
+
after: token,
|
|
447
|
+
before: previous,
|
|
448
|
+
fields,
|
|
449
|
+
name: token.name,
|
|
450
|
+
set: token.set,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
for (const [key, token] of beforeTokens) {
|
|
455
|
+
if (!afterTokens.has(key)) tokens.removed.push(token);
|
|
456
|
+
}
|
|
457
|
+
const beforeSets = new Set((before?.sets ?? []).map((set) => set.name));
|
|
458
|
+
const afterSets = new Set((after?.sets ?? []).map((set) => set.name));
|
|
459
|
+
const sets = {
|
|
460
|
+
added: [...afterSets].filter((name) => !beforeSets.has(name)),
|
|
461
|
+
removed: [...beforeSets].filter((name) => !afterSets.has(name)),
|
|
462
|
+
};
|
|
463
|
+
const beforeThemes = new Map(
|
|
464
|
+
(before?.themes ?? []).map((theme) => [
|
|
465
|
+
themePath(theme.group, theme.name),
|
|
466
|
+
theme,
|
|
467
|
+
]),
|
|
468
|
+
);
|
|
469
|
+
const afterThemes = new Map(
|
|
470
|
+
(after?.themes ?? []).map((theme) => [
|
|
471
|
+
themePath(theme.group, theme.name),
|
|
472
|
+
theme,
|
|
473
|
+
]),
|
|
474
|
+
);
|
|
475
|
+
const themes = { added: [], changed: [], removed: [] };
|
|
476
|
+
for (const [path, theme] of afterThemes) {
|
|
477
|
+
const previous = beforeThemes.get(path);
|
|
478
|
+
if (!previous) themes.added.push(themeView(theme));
|
|
479
|
+
else if (!sameJson(themeView(previous), themeView(theme))) {
|
|
480
|
+
themes.changed.push({
|
|
481
|
+
after: themeView(theme),
|
|
482
|
+
before: themeView(previous),
|
|
483
|
+
path,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
for (const [path, theme] of beforeThemes) {
|
|
488
|
+
if (!afterThemes.has(path)) themes.removed.push(themeView(theme));
|
|
489
|
+
}
|
|
490
|
+
const byKey = (items, key) =>
|
|
491
|
+
items.sort((left, right) => key(left).localeCompare(key(right)));
|
|
492
|
+
byKey(tokens.added, (token) => `${token.set}/${token.name}`);
|
|
493
|
+
byKey(tokens.changed, (token) => `${token.set}/${token.name}`);
|
|
494
|
+
byKey(tokens.removed, (token) => `${token.set}/${token.name}`);
|
|
495
|
+
return {
|
|
496
|
+
sets,
|
|
497
|
+
summary: {
|
|
498
|
+
themesAdded: themes.added.length,
|
|
499
|
+
themesChanged: themes.changed.length,
|
|
500
|
+
themesRemoved: themes.removed.length,
|
|
501
|
+
tokensAdded: tokens.added.length,
|
|
502
|
+
tokensChanged: tokens.changed.length,
|
|
503
|
+
tokensRemoved: tokens.removed.length,
|
|
504
|
+
},
|
|
505
|
+
themes,
|
|
506
|
+
tokens,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function selectionKey(item) {
|
|
511
|
+
if (typeof item === "string") return item;
|
|
512
|
+
if (
|
|
513
|
+
isRecord(item) &&
|
|
514
|
+
typeof item.set === "string" &&
|
|
515
|
+
typeof item.name === "string"
|
|
516
|
+
) {
|
|
517
|
+
return `${item.set}/${item.name}`;
|
|
518
|
+
}
|
|
519
|
+
fail(
|
|
520
|
+
"invalid_token_selection",
|
|
521
|
+
'Token selection entries must be "set/name" or {set, name}',
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Keep only the selected token rows of a diff: an unselected change keeps its
|
|
526
|
+
// previous value, an unselected addition is dropped, an unselected removal is
|
|
527
|
+
// kept. Sets and themes always follow the imported document.
|
|
528
|
+
export function applyTokenSelection(before, after, diff, selection) {
|
|
529
|
+
const selected = new Set(selection.map(selectionKey));
|
|
530
|
+
const beforeSets = new Map((before?.sets ?? []).map((set) => [set.name, set]));
|
|
531
|
+
const library = structuredClone(after);
|
|
532
|
+
const setsByName = new Map(library.sets.map((set) => [set.name, set]));
|
|
533
|
+
const ensureSet = (name) => {
|
|
534
|
+
let set = setsByName.get(name);
|
|
535
|
+
if (!set) {
|
|
536
|
+
const prior = beforeSets.get(name);
|
|
537
|
+
set = {
|
|
538
|
+
description: prior?.description ?? "",
|
|
539
|
+
id: prior?.id ?? slugId("tset_", name),
|
|
540
|
+
name,
|
|
541
|
+
tokens: [],
|
|
542
|
+
};
|
|
543
|
+
library.sets.push(set);
|
|
544
|
+
setsByName.set(name, set);
|
|
545
|
+
}
|
|
546
|
+
return set;
|
|
547
|
+
};
|
|
548
|
+
const priorToken = (setName, tokenName) =>
|
|
549
|
+
beforeSets.get(setName)?.tokens.find((token) => token.name === tokenName);
|
|
550
|
+
for (const change of diff.tokens.changed) {
|
|
551
|
+
if (selected.has(`${change.set}/${change.name}`)) continue;
|
|
552
|
+
const set = setsByName.get(change.set);
|
|
553
|
+
const index = set.tokens.findIndex((token) => token.name === change.name);
|
|
554
|
+
set.tokens[index] = structuredClone(priorToken(change.set, change.name));
|
|
555
|
+
}
|
|
556
|
+
for (const added of diff.tokens.added) {
|
|
557
|
+
if (selected.has(`${added.set}/${added.name}`)) continue;
|
|
558
|
+
const set = setsByName.get(added.set);
|
|
559
|
+
set.tokens = set.tokens.filter((token) => token.name !== added.name);
|
|
560
|
+
}
|
|
561
|
+
for (const removed of diff.tokens.removed) {
|
|
562
|
+
if (selected.has(`${removed.set}/${removed.name}`)) continue;
|
|
563
|
+
ensureSet(removed.set).tokens.push(
|
|
564
|
+
structuredClone(priorToken(removed.set, removed.name)),
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
return library;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function findTokenLibrary(snapshot) {
|
|
571
|
+
for (const entry of snapshot.manifest.entries.tokens) {
|
|
572
|
+
const value = snapshot.entries[entry];
|
|
573
|
+
if (Array.isArray(value?.sets) && Array.isArray(value?.themes)) return value;
|
|
574
|
+
}
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// One call for the CLI and the review UI: parse, build, diff, optionally
|
|
579
|
+
// narrow to a selection, and hand back the Operation Batch that applies it.
|
|
580
|
+
export function importTokens(snapshot, documentValue, options = {}) {
|
|
581
|
+
const previous = findTokenLibrary(snapshot);
|
|
582
|
+
const parsed = parseTokenDocument(documentValue, { setName: options.setName });
|
|
583
|
+
// Other DTCG entries remain in the package when the Form A library is replaced.
|
|
584
|
+
// Their identities belong to existing bindings and cannot be reused by imports.
|
|
585
|
+
const previousIds = new Set(
|
|
586
|
+
(previous?.sets ?? []).flatMap((set) => set.tokens.map((token) => token.id)),
|
|
587
|
+
);
|
|
588
|
+
const reservedTokenIds = [...snapshot.domain.tokens.keys()].filter(
|
|
589
|
+
(id) => !previousIds.has(id),
|
|
590
|
+
);
|
|
591
|
+
const built = pruneUnresolvableTokens(
|
|
592
|
+
buildTokenLibrary(parsed, previous, { reservedTokenIds }),
|
|
593
|
+
);
|
|
594
|
+
let library = built.library;
|
|
595
|
+
let diff = diffTokenLibraries(previous, library);
|
|
596
|
+
if (Array.isArray(options.selection)) {
|
|
597
|
+
library = applyTokenSelection(previous, library, diff, options.selection);
|
|
598
|
+
diff = diffTokenLibraries(previous, library);
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
diff,
|
|
602
|
+
library,
|
|
603
|
+
operations: [{ library, type: "replace-token-library" }],
|
|
604
|
+
previousLibraryId: previous?.id ?? null,
|
|
605
|
+
warnings: [...parsed.warnings, ...built.warnings],
|
|
606
|
+
};
|
|
607
|
+
}
|