@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.
@@ -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
+ }