terrazzo-plugin-figma-json 0.1.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/index.js ADDED
@@ -0,0 +1,1454 @@
1
+ import wcmatch from "wildcard-match";
2
+ import Color from "colorjs.io";
3
+
4
+ //#region src/constants.ts
5
+ const PLUGIN_NAME = "terrazzo-plugin-figma-json";
6
+ const FORMAT_ID = "figma-json";
7
+ /**
8
+ * Internal metadata property keys used for token processing.
9
+ * These are added during transform and removed during build.
10
+ */
11
+ const INTERNAL_KEYS = {
12
+ ALIAS_OF: "_aliasOf",
13
+ SPLIT_FROM: "_splitFrom",
14
+ TOKEN_ID: "_tokenId"
15
+ };
16
+ /**
17
+ * Token types supported by Figma.
18
+ */
19
+ const SUPPORTED_TYPES = [
20
+ "color",
21
+ "dimension",
22
+ "duration",
23
+ "fontFamily",
24
+ "fontWeight",
25
+ "number",
26
+ "typography"
27
+ ];
28
+ /**
29
+ * Token types that are not supported by Figma and will be dropped with a warning.
30
+ */
31
+ const UNSUPPORTED_TYPES = [
32
+ "shadow",
33
+ "border",
34
+ "gradient",
35
+ "transition",
36
+ "strokeStyle",
37
+ "cubicBezier"
38
+ ];
39
+ /**
40
+ * Color spaces that Figma natively supports.
41
+ */
42
+ const FIGMA_COLOR_SPACES = ["srgb", "hsl"];
43
+
44
+ //#endregion
45
+ //#region src/utils.ts
46
+ /**
47
+ * Create an exclude matcher function from glob patterns.
48
+ *
49
+ * @param patterns - Array of glob patterns to match against token IDs
50
+ * @returns A function that returns true if the token ID should be excluded
51
+ */
52
+ function createExcludeMatcher(patterns) {
53
+ return patterns?.length ? wcmatch(patterns) : () => false;
54
+ }
55
+ /**
56
+ * Type guard to check if the resolver has a usable configuration.
57
+ * Terrazzo creates a default resolver even without a resolver file,
58
+ * but it has empty contexts that cause errors when used.
59
+ *
60
+ * @param resolver - The resolver from terrazzo parser
61
+ * @returns true if resolver has user-defined sets or modifiers with contexts
62
+ */
63
+ function hasValidResolverConfig(resolver) {
64
+ if (!resolver?.source || !resolver.listPermutations) return false;
65
+ const source = resolver.source;
66
+ const sets = source.sets ?? {};
67
+ const modifiers = source.modifiers ?? {};
68
+ const hasUserSets = Object.keys(sets).some((name) => name !== "allTokens");
69
+ const hasModifierContexts = Object.values(modifiers).some((mod) => mod.contexts && Object.keys(mod.contexts).length > 0);
70
+ return hasUserSets || hasModifierContexts;
71
+ }
72
+ /**
73
+ * Build default input from resolver's modifiers.
74
+ * Creates an input object with each modifier set to its default value.
75
+ *
76
+ * @param resolverSource - The resolver source configuration
77
+ * @returns Input object for resolver.apply() with default modifier values
78
+ */
79
+ function buildDefaultInput(resolverSource) {
80
+ const input = {};
81
+ if (resolverSource.modifiers) {
82
+ for (const [modifierName, modifier] of Object.entries(resolverSource.modifiers)) if (modifier.default) input[modifierName] = modifier.default;
83
+ }
84
+ return input;
85
+ }
86
+ /**
87
+ * Remove internal metadata properties from a parsed token value.
88
+ * These properties are used for internal processing and should not appear in output.
89
+ *
90
+ * @param parsedValue - Token value object to clean (mutated in place)
91
+ */
92
+ function removeInternalMetadata(parsedValue) {
93
+ delete parsedValue[INTERNAL_KEYS.ALIAS_OF];
94
+ delete parsedValue[INTERNAL_KEYS.SPLIT_FROM];
95
+ delete parsedValue[INTERNAL_KEYS.TOKEN_ID];
96
+ }
97
+ /**
98
+ * Safely parse a transform value that may be a JSON string or already an object.
99
+ * Returns the parsed value or null if parsing fails.
100
+ *
101
+ * @param value - The transform value (string or object)
102
+ * @returns Parsed value or null on error
103
+ */
104
+ function parseTransformValue(value) {
105
+ if (typeof value !== "string") return value;
106
+ try {
107
+ return JSON.parse(value);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * Extract partialAliasOf from a token if present.
114
+ * This property is added by terrazzo parser for composite tokens but not in public types.
115
+ */
116
+ function getPartialAliasOf(token) {
117
+ if (token && typeof token === "object" && "partialAliasOf" in token) {
118
+ const value = token.partialAliasOf;
119
+ if (value && typeof value === "object") return value;
120
+ }
121
+ }
122
+ /**
123
+ * Type guard to validate DTCGColorValue structure.
124
+ */
125
+ function isDTCGColorValue(value) {
126
+ if (!value || typeof value !== "object") return false;
127
+ const v = value;
128
+ if (typeof v.colorSpace !== "string") return false;
129
+ if (!Array.isArray(v.components) || v.components.length !== 3) return false;
130
+ for (const c of v.components) if (c !== "none" && typeof c !== "number") return false;
131
+ if (v.alpha !== void 0 && v.alpha !== "none" && typeof v.alpha !== "number") return false;
132
+ return true;
133
+ }
134
+ /**
135
+ * Type guard to validate DTCGDimensionValue structure.
136
+ */
137
+ function isDTCGDimensionValue(value) {
138
+ if (!value || typeof value !== "object") return false;
139
+ const v = value;
140
+ return typeof v.value === "number" && typeof v.unit === "string";
141
+ }
142
+ /**
143
+ * Type guard to validate DTCGDurationValue structure.
144
+ */
145
+ function isDTCGDurationValue(value) {
146
+ if (!value || typeof value !== "object") return false;
147
+ const v = value;
148
+ return typeof v.value === "number" && typeof v.unit === "string";
149
+ }
150
+ /**
151
+ * Type guard to validate DTCGTypographyValue structure.
152
+ * Only checks that it's an object - individual properties are validated during conversion.
153
+ */
154
+ function isDTCGTypographyValue(value) {
155
+ return value !== null && typeof value === "object" && !Array.isArray(value);
156
+ }
157
+
158
+ //#endregion
159
+ //#region src/build.ts
160
+ /**
161
+ * Set a nested property on an object using dot-notation path.
162
+ * Creates intermediate objects as needed.
163
+ *
164
+ * @param obj - The object to modify
165
+ * @param path - Dot-notation path (e.g., "color.primary.base")
166
+ * @param value - The value to set at the path
167
+ *
168
+ * @example
169
+ * const obj = {};
170
+ * setNestedProperty(obj, "color.primary", { $value: "#ff0000" });
171
+ * // obj = { color: { primary: { $value: "#ff0000" } } }
172
+ */
173
+ function setNestedProperty(obj, path, value) {
174
+ const parts = path.split(".");
175
+ if (parts.length === 0) return;
176
+ let current = obj;
177
+ for (let i = 0; i < parts.length - 1; i++) {
178
+ const part = parts[i];
179
+ if (!(part in current)) current[part] = {};
180
+ current = current[part];
181
+ }
182
+ const lastPart = parts[parts.length - 1];
183
+ current[lastPart] = value;
184
+ }
185
+ /**
186
+ * Convert a dot-notation token ID to Figma's slash notation.
187
+ *
188
+ * @param tokenId - Token ID in dot notation
189
+ * @returns Token ID in slash notation for Figma
190
+ *
191
+ * @example
192
+ * toFigmaVariableName("dimension.200") // "dimension/200"
193
+ * toFigmaVariableName("color.primary.base") // "color/primary/base"
194
+ */
195
+ function toFigmaVariableName(tokenId) {
196
+ return tokenId.replace(/\./g, "/");
197
+ }
198
+ /**
199
+ * Convert $root in a token ID to root for Figma compatibility.
200
+ * DTCG uses $root for default values, but Figma doesn't support $ in names.
201
+ *
202
+ * @param path - Token path that may contain $root
203
+ * @returns Path with $root replaced by root
204
+ *
205
+ * @example
206
+ * normalizeRootInPath("color.border.warning.$root") // "color.border.warning.root"
207
+ * normalizeRootInPath("color.primary") // "color.primary" (unchanged)
208
+ */
209
+ function normalizeRootInPath(path) {
210
+ return path.replace(/\.\$root\b/g, ".root");
211
+ }
212
+ /**
213
+ * Handle alias references by setting the appropriate $value or $extensions.
214
+ * Mutates parsedValue in place.
215
+ *
216
+ * - Same-file references: Sets $value to curly brace syntax (e.g., "{color.primary}")
217
+ * - Cross-file references: Keeps resolved $value and adds com.figma.aliasData extension
218
+ *
219
+ * The function checks for target token in this order:
220
+ * 1. Current context (same-file reference)
221
+ * 2. Set sources only (cross-file reference to primitive/semantic sets)
222
+ * 3. Never references other modifier contexts (e.g., dark won't reference light)
223
+ *
224
+ * @param options - Configuration for alias handling
225
+ * @param options.parsedValue - Token value object to modify (mutated)
226
+ * @param options.aliasOf - Target token ID this token references
227
+ * @param options.sourceName - Name of the current output file/collection
228
+ * @param options.tokenSources - Map of token IDs to their source info
229
+ * @param options.tokenOutputPaths - Map of token IDs to their output paths
230
+ * @param options.preserveReferences - Whether to preserve references (false = no-op)
231
+ */
232
+ function handleAliasReference({ parsedValue, aliasOf, sourceName, tokenSources, tokenOutputPaths, preserveReferences }) {
233
+ if (!preserveReferences || !aliasOf) return;
234
+ const normalizedAliasOf = aliasOf.replace(/\.\$root\b/g, "");
235
+ const targetOutputPath = tokenOutputPaths.get(normalizedAliasOf) ?? normalizeRootInPath(aliasOf);
236
+ let targetSources = tokenSources.get(normalizedAliasOf);
237
+ if (!targetSources) {
238
+ const parts = normalizedAliasOf.split(".");
239
+ while (parts.length > 1 && !targetSources) {
240
+ parts.pop();
241
+ targetSources = tokenSources.get(parts.join("."));
242
+ }
243
+ }
244
+ if (!targetSources?.length) return;
245
+ if (targetSources.some((s) => s.source === sourceName)) {
246
+ parsedValue.$value = `{${targetOutputPath}}`;
247
+ return;
248
+ }
249
+ const setSource = targetSources.find((s) => !s.isModifier);
250
+ if (setSource) {
251
+ const extensions = parsedValue.$extensions ?? {};
252
+ extensions["com.figma.aliasData"] = {
253
+ targetVariableSetName: setSource.source,
254
+ targetVariableName: toFigmaVariableName(targetOutputPath)
255
+ };
256
+ parsedValue.$extensions = extensions;
257
+ }
258
+ }
259
+ /**
260
+ * Extract token IDs from a resolver group (token definitions).
261
+ * Recursively walks the group structure to find all token IDs.
262
+ *
263
+ * Handles $root tokens specially per DTCG spec:
264
+ * - Token ID uses parent path (e.g., "color.primary" for "color.primary.$root")
265
+ * - Output path uses "root" without $ for Figma compatibility
266
+ *
267
+ * @param group - Object containing token definitions or nested groups
268
+ * @param prefix - Current path prefix for recursion
269
+ * @returns Array of token ID info with both normalized ID and output path
270
+ *
271
+ * @example
272
+ * extractTokenIds({ color: { primary: { $value: "#ff0000" } } })
273
+ * // [{ id: "color.primary", outputPath: "color.primary" }]
274
+ */
275
+ function extractTokenIds(group, prefix = "") {
276
+ const ids = [];
277
+ for (const [key, value] of Object.entries(group)) {
278
+ if (key.startsWith("$") && key !== "$root") continue;
279
+ const outputKey = key === "$root" ? "root" : key;
280
+ const outputPath = prefix ? `${prefix}.${outputKey}` : outputKey;
281
+ const normalizedPath = key === "$root" ? prefix : outputPath;
282
+ if (value && typeof value === "object" && "$value" in value) {
283
+ if (normalizedPath) ids.push({
284
+ id: normalizedPath,
285
+ outputPath
286
+ });
287
+ } else if (value && typeof value === "object") ids.push(...extractTokenIds(value, outputPath));
288
+ }
289
+ return ids;
290
+ }
291
+ /**
292
+ * Build maps tracking which tokens belong to which sources.
293
+ * Processes both sets and modifier contexts from the resolver source.
294
+ *
295
+ * @param resolverSource - The resolver source configuration
296
+ * @returns Maps for token sources, output paths, and all context keys
297
+ */
298
+ function buildTokenSourceMaps(resolverSource) {
299
+ const tokenSources = /* @__PURE__ */ new Map();
300
+ const tokenOutputPaths = /* @__PURE__ */ new Map();
301
+ const allContexts = /* @__PURE__ */ new Set();
302
+ function addTokenSource(tokenId, outputPath, info) {
303
+ const existing = tokenSources.get(tokenId);
304
+ if (existing) existing.push(info);
305
+ else tokenSources.set(tokenId, [info]);
306
+ if (!tokenOutputPaths.has(tokenId)) tokenOutputPaths.set(tokenId, outputPath);
307
+ }
308
+ if (resolverSource.sets) {
309
+ for (const [setName, set] of Object.entries(resolverSource.sets)) if (set.sources) for (const source of set.sources) {
310
+ const tokenInfos = extractTokenIds(source);
311
+ for (const { id, outputPath } of tokenInfos) addTokenSource(id, outputPath, {
312
+ source: setName,
313
+ isModifier: false
314
+ });
315
+ }
316
+ }
317
+ if (resolverSource.modifiers) {
318
+ for (const [modifierName, modifier] of Object.entries(resolverSource.modifiers)) if (modifier.contexts) for (const [contextName, contextSources] of Object.entries(modifier.contexts)) {
319
+ const contextKey = `${modifierName}-${contextName}`;
320
+ allContexts.add(contextKey);
321
+ if (Array.isArray(contextSources)) for (const source of contextSources) {
322
+ const tokenInfos = extractTokenIds(source);
323
+ for (const { id, outputPath } of tokenInfos) addTokenSource(id, outputPath, {
324
+ source: contextKey,
325
+ isModifier: true,
326
+ modifierName,
327
+ contextName
328
+ });
329
+ }
330
+ }
331
+ }
332
+ return {
333
+ tokenSources,
334
+ tokenOutputPaths,
335
+ allContexts
336
+ };
337
+ }
338
+ /**
339
+ * Build the Figma-compatible JSON output from transformed tokens.
340
+ * Requires a resolver file - legacy mode is not supported.
341
+ * Always returns output split by resolver structure (sets and modifier contexts).
342
+ *
343
+ * @returns Map of output name to JSON string (e.g., "primitive" -> "{...}")
344
+ */
345
+ function buildFigmaJson({ getTransforms, exclude, tokenName, preserveReferences = true, resolver }) {
346
+ const shouldExclude = createExcludeMatcher(exclude);
347
+ if (!hasValidResolverConfig(resolver)) {
348
+ const transforms = getTransforms({ format: FORMAT_ID });
349
+ if (transforms.length === 0) return /* @__PURE__ */ new Map();
350
+ const output = {};
351
+ for (const transform of transforms) {
352
+ if (!transform.token) continue;
353
+ const tokenId = transform.token.id;
354
+ if (shouldExclude(tokenId)) continue;
355
+ const outputName = tokenName?.(transform.token) ?? tokenId;
356
+ const parsedValue = parseTransformValue(transform.value);
357
+ if (!parsedValue) continue;
358
+ removeInternalMetadata(parsedValue);
359
+ setNestedProperty(output, outputName, parsedValue);
360
+ }
361
+ const result = /* @__PURE__ */ new Map();
362
+ result.set("default", JSON.stringify(output, null, 2));
363
+ return result;
364
+ }
365
+ const resolverSource = resolver.source;
366
+ if (!resolverSource) return /* @__PURE__ */ new Map();
367
+ const { tokenSources, tokenOutputPaths, allContexts } = buildTokenSourceMaps(resolverSource);
368
+ const outputBySource = /* @__PURE__ */ new Map();
369
+ for (const contextKey of allContexts) outputBySource.set(contextKey, {});
370
+ const defaultInput = buildDefaultInput(resolverSource);
371
+ const defaultTransforms = getTransforms({
372
+ format: FORMAT_ID,
373
+ input: defaultInput
374
+ });
375
+ for (const transform of defaultTransforms) {
376
+ const parsedValue = parseTransformValue(transform.value);
377
+ if (!parsedValue) continue;
378
+ let tokenId;
379
+ let outputName;
380
+ let aliasOf;
381
+ let sourceLookupId;
382
+ if (transform.token) {
383
+ tokenId = transform.token.id;
384
+ outputName = tokenName?.(transform.token) ?? tokenOutputPaths.get(tokenId) ?? tokenId;
385
+ aliasOf = parsedValue[INTERNAL_KEYS.ALIAS_OF] ?? transform.token.aliasOf;
386
+ sourceLookupId = tokenId;
387
+ } else if (parsedValue[INTERNAL_KEYS.SPLIT_FROM] && parsedValue[INTERNAL_KEYS.TOKEN_ID]) {
388
+ tokenId = parsedValue[INTERNAL_KEYS.TOKEN_ID];
389
+ const parentId = parsedValue[INTERNAL_KEYS.SPLIT_FROM];
390
+ const parentOutputPath = tokenOutputPaths.get(parentId);
391
+ if (parentOutputPath && parentOutputPath !== parentId) outputName = parentOutputPath + tokenId.slice(parentId.length);
392
+ else outputName = tokenId;
393
+ aliasOf = parsedValue[INTERNAL_KEYS.ALIAS_OF];
394
+ sourceLookupId = parentId;
395
+ } else continue;
396
+ if (shouldExclude(tokenId)) continue;
397
+ const setSource = (tokenSources.get(sourceLookupId) ?? []).find((s) => !s.isModifier);
398
+ if (!setSource) continue;
399
+ const sourceName = setSource.source;
400
+ let sourceOutput = outputBySource.get(sourceName);
401
+ if (!sourceOutput) {
402
+ sourceOutput = {};
403
+ outputBySource.set(sourceName, sourceOutput);
404
+ }
405
+ if (aliasOf) handleAliasReference({
406
+ parsedValue,
407
+ aliasOf,
408
+ sourceName,
409
+ tokenSources,
410
+ tokenOutputPaths,
411
+ preserveReferences
412
+ });
413
+ removeInternalMetadata(parsedValue);
414
+ setNestedProperty(sourceOutput, outputName, parsedValue);
415
+ }
416
+ const modifierTokensByContext = /* @__PURE__ */ new Map();
417
+ for (const [tokenId, sources] of tokenSources) for (const sourceInfo of sources) {
418
+ if (!sourceInfo.isModifier) continue;
419
+ const contextKey = sourceInfo.source;
420
+ const existing = modifierTokensByContext.get(contextKey);
421
+ if (existing) existing.add(tokenId);
422
+ else modifierTokensByContext.set(contextKey, new Set([tokenId]));
423
+ }
424
+ for (const [contextKey, tokenIds] of modifierTokensByContext) {
425
+ let contextInfo;
426
+ for (const tokenId of tokenIds) {
427
+ contextInfo = tokenSources.get(tokenId)?.find((s) => s.source === contextKey);
428
+ if (contextInfo) break;
429
+ }
430
+ if (!contextInfo?.modifierName || !contextInfo?.contextName) continue;
431
+ const input = { ...defaultInput };
432
+ input[contextInfo.modifierName] = contextInfo.contextName;
433
+ const contextTransforms = getTransforms({
434
+ format: FORMAT_ID,
435
+ input
436
+ });
437
+ let contextOutput = outputBySource.get(contextKey);
438
+ if (!contextOutput) {
439
+ contextOutput = {};
440
+ outputBySource.set(contextKey, contextOutput);
441
+ }
442
+ for (const tokenId of tokenIds) {
443
+ if (shouldExclude(tokenId)) continue;
444
+ const transform = contextTransforms.find((t) => t.token?.id === tokenId);
445
+ if (!transform) continue;
446
+ const outputName = tokenName?.(transform.token) ?? tokenOutputPaths.get(tokenId) ?? tokenId;
447
+ const parsedValue = parseTransformValue(transform.value);
448
+ if (!parsedValue) continue;
449
+ const aliasOf = parsedValue[INTERNAL_KEYS.ALIAS_OF] ?? transform.token.aliasOf;
450
+ if (aliasOf) handleAliasReference({
451
+ parsedValue,
452
+ aliasOf,
453
+ sourceName: contextKey,
454
+ tokenSources,
455
+ tokenOutputPaths,
456
+ preserveReferences
457
+ });
458
+ removeInternalMetadata(parsedValue);
459
+ setNestedProperty(contextOutput, outputName, parsedValue);
460
+ }
461
+ }
462
+ const result = /* @__PURE__ */ new Map();
463
+ for (const [sourceName, output] of outputBySource) result.set(sourceName, JSON.stringify(output, null, 2));
464
+ return result;
465
+ }
466
+
467
+ //#endregion
468
+ //#region src/converters/color.ts
469
+ /**
470
+ * Number of decimal places to round color components to.
471
+ * 6 decimals provides sufficient precision while avoiding floating-point issues.
472
+ */
473
+ const COLOR_PRECISION = 6;
474
+ /**
475
+ * Round a number to COLOR_PRECISION decimal places and clamp to [0, 1] range.
476
+ * Prevents floating-point precision issues (e.g., 1.0000000000000007 -> 1).
477
+ *
478
+ * @param value - Color component value (typically 0-1 for sRGB)
479
+ * @returns Rounded and clamped value in [0, 1] range
480
+ */
481
+ function roundAndClamp(value) {
482
+ const rounded = Math.round(value * 10 ** COLOR_PRECISION) / 10 ** COLOR_PRECISION;
483
+ return Math.max(0, Math.min(1, rounded));
484
+ }
485
+ /**
486
+ * Normalize color components: round to precision and clamp to valid range.
487
+ * Applies roundAndClamp to each component in the RGB/HSL triplet.
488
+ *
489
+ * @param components - Array of 3 color component values
490
+ * @returns Normalized triplet with values rounded and clamped
491
+ */
492
+ function normalizeComponents(components) {
493
+ return components.map(roundAndClamp);
494
+ }
495
+ /**
496
+ * Map DTCG color space names to colorjs.io color space IDs.
497
+ */
498
+ const DTCG_TO_COLORJS_SPACE = {
499
+ srgb: "srgb",
500
+ "srgb-linear": "srgb-linear",
501
+ hsl: "hsl",
502
+ hwb: "hwb",
503
+ lab: "lab",
504
+ lch: "lch",
505
+ oklab: "oklab",
506
+ oklch: "oklch",
507
+ "display-p3": "p3",
508
+ "a98-rgb": "a98rgb",
509
+ "prophoto-rgb": "prophoto",
510
+ rec2020: "rec2020",
511
+ "xyz-d65": "xyz-d65",
512
+ "xyz-d50": "xyz-d50"
513
+ };
514
+ /**
515
+ * Convert a DTCG color value to Figma-compatible format.
516
+ * Figma only supports sRGB and HSL color spaces.
517
+ *
518
+ * @example
519
+ * // sRGB colors pass through unchanged
520
+ * convertColor({
521
+ * colorSpace: "srgb",
522
+ * components: [0.5, 0.5, 0.5],
523
+ * alpha: 1
524
+ * }, context);
525
+ * // => { value: { colorSpace: "srgb", components: [0.5, 0.5, 0.5], alpha: 1 } }
526
+ *
527
+ * @example
528
+ * // OKLCH colors are converted to sRGB
529
+ * convertColor({
530
+ * colorSpace: "oklch",
531
+ * components: [0.7, 0.15, 150]
532
+ * }, context);
533
+ * // => { value: { colorSpace: "srgb", components: [...], alpha: 1 } }
534
+ */
535
+ function convertColor(value, context) {
536
+ if (!isDTCGColorValue(value)) {
537
+ context.logger.warn({
538
+ group: "plugin",
539
+ label: PLUGIN_NAME,
540
+ message: `Token "${context.tokenId}" has invalid color value: expected object with colorSpace and components`
541
+ });
542
+ return {
543
+ value: void 0,
544
+ skip: true
545
+ };
546
+ }
547
+ const color = value;
548
+ if (FIGMA_COLOR_SPACES.includes(color.colorSpace)) {
549
+ const components = color.components.map((c) => c === "none" ? 0 : c);
550
+ const normalizedComponents = color.colorSpace === "srgb" ? normalizeComponents(components) : components;
551
+ return { value: {
552
+ ...color,
553
+ components: normalizedComponents,
554
+ alpha: color.alpha === "none" ? 1 : color.alpha ?? 1
555
+ } };
556
+ }
557
+ const components = color.components.map((c) => c === "none" ? 0 : c);
558
+ const colorjsSpace = DTCG_TO_COLORJS_SPACE[color.colorSpace];
559
+ if (!colorjsSpace) {
560
+ context.logger.warn({
561
+ group: "plugin",
562
+ label: PLUGIN_NAME,
563
+ message: `Token "${context.tokenId}" has unknown color space: ${color.colorSpace}`
564
+ });
565
+ return {
566
+ value: void 0,
567
+ skip: true
568
+ };
569
+ }
570
+ try {
571
+ const srgbColor = new Color(colorjsSpace, components).to("srgb");
572
+ if (!srgbColor.inGamut()) {
573
+ context.logger.warn({
574
+ group: "plugin",
575
+ label: PLUGIN_NAME,
576
+ message: `Token "${context.tokenId}" color was clipped to sRGB gamut (original color space: ${color.colorSpace})`
577
+ });
578
+ srgbColor.toGamut({ method: "css" });
579
+ }
580
+ const srgbChannels = normalizeComponents(srgbColor.coords);
581
+ context.logger.info({
582
+ group: "plugin",
583
+ label: PLUGIN_NAME,
584
+ message: `Token "${context.tokenId}" color converted from ${color.colorSpace} to sRGB`
585
+ });
586
+ return { value: {
587
+ colorSpace: "srgb",
588
+ components: srgbChannels,
589
+ alpha: color.alpha === "none" ? 1 : color.alpha ?? 1
590
+ } };
591
+ } catch (err) {
592
+ context.logger.warn({
593
+ group: "plugin",
594
+ label: PLUGIN_NAME,
595
+ message: `Token "${context.tokenId}" color conversion failed: ${err instanceof Error ? err.message : String(err)}`
596
+ });
597
+ return {
598
+ value: void 0,
599
+ skip: true
600
+ };
601
+ }
602
+ }
603
+
604
+ //#endregion
605
+ //#region src/converters/dimension.ts
606
+ /**
607
+ * Convert a DTCG dimension value to Figma-compatible format.
608
+ * Figma only supports px units.
609
+ *
610
+ * @example
611
+ * // px values pass through unchanged
612
+ * convertDimension({ value: 16, unit: "px" }, context);
613
+ * // => { value: { value: 16, unit: "px" } }
614
+ *
615
+ * @example
616
+ * // rem values are converted to px (default base: 16px)
617
+ * convertDimension({ value: 1.5, unit: "rem" }, context);
618
+ * // => { value: { value: 24, unit: "px" } }
619
+ */
620
+ function convertDimension(value, context) {
621
+ if (!isDTCGDimensionValue(value)) {
622
+ context.logger.warn({
623
+ group: "plugin",
624
+ label: PLUGIN_NAME,
625
+ message: `Token "${context.tokenId}" has invalid dimension value: expected object with value (number) and unit (string)`
626
+ });
627
+ return {
628
+ value: void 0,
629
+ skip: true
630
+ };
631
+ }
632
+ const dimension = value;
633
+ if (!Number.isFinite(dimension.value)) {
634
+ context.logger.warn({
635
+ group: "plugin",
636
+ label: PLUGIN_NAME,
637
+ message: `Token "${context.tokenId}" has invalid dimension value: ${dimension.value}`
638
+ });
639
+ return {
640
+ value: void 0,
641
+ skip: true
642
+ };
643
+ }
644
+ if (dimension.unit === "px") return { value: dimension };
645
+ if (dimension.unit === "rem") {
646
+ const remBasePx = context.options.remBasePx ?? 16;
647
+ const pxValue = dimension.value * remBasePx;
648
+ context.logger.info({
649
+ group: "plugin",
650
+ label: PLUGIN_NAME,
651
+ message: `Token "${context.tokenId}" converted from ${dimension.value}rem to ${pxValue}px (base: ${remBasePx}px)`
652
+ });
653
+ return { value: {
654
+ value: pxValue,
655
+ unit: "px"
656
+ } };
657
+ }
658
+ context.logger.warn({
659
+ group: "plugin",
660
+ label: PLUGIN_NAME,
661
+ message: `Token "${context.tokenId}" has unsupported dimension unit: "${dimension.unit}". Figma only supports px units. Convert the value to px or use the 'transform' option to handle this token.`
662
+ });
663
+ return {
664
+ value: void 0,
665
+ skip: true
666
+ };
667
+ }
668
+
669
+ //#endregion
670
+ //#region src/converters/duration.ts
671
+ /**
672
+ * Convert a DTCG duration value to Figma-compatible format.
673
+ * Figma only supports seconds (s) unit.
674
+ *
675
+ * @example
676
+ * // s values pass through unchanged
677
+ * convertDuration({ value: 0.5, unit: "s" }, context);
678
+ * // => { value: { value: 0.5, unit: "s" } }
679
+ *
680
+ * @example
681
+ * // ms values are converted to s
682
+ * convertDuration({ value: 500, unit: "ms" }, context);
683
+ * // => { value: { value: 0.5, unit: "s" } }
684
+ */
685
+ function convertDuration(value, context) {
686
+ if (!isDTCGDurationValue(value)) {
687
+ context.logger.warn({
688
+ group: "plugin",
689
+ label: PLUGIN_NAME,
690
+ message: `Token "${context.tokenId}" has invalid duration value: expected object with value (number) and unit (string)`
691
+ });
692
+ return {
693
+ value: void 0,
694
+ skip: true
695
+ };
696
+ }
697
+ const duration = value;
698
+ if (!Number.isFinite(duration.value)) {
699
+ context.logger.warn({
700
+ group: "plugin",
701
+ label: PLUGIN_NAME,
702
+ message: `Token "${context.tokenId}" has invalid duration value: ${duration.value}`
703
+ });
704
+ return {
705
+ value: void 0,
706
+ skip: true
707
+ };
708
+ }
709
+ if (duration.unit === "s") return { value: duration };
710
+ if (duration.unit === "ms") {
711
+ const sValue = duration.value / 1e3;
712
+ context.logger.info({
713
+ group: "plugin",
714
+ label: PLUGIN_NAME,
715
+ message: `Token "${context.tokenId}" converted from ${duration.value}ms to ${sValue}s`
716
+ });
717
+ return { value: {
718
+ value: sValue,
719
+ unit: "s"
720
+ } };
721
+ }
722
+ context.logger.warn({
723
+ group: "plugin",
724
+ label: PLUGIN_NAME,
725
+ message: `Token "${context.tokenId}" has unsupported duration unit: "${duration.unit}". Figma only supports seconds (s). Convert the value to seconds or use the 'transform' option to handle this token.`
726
+ });
727
+ return {
728
+ value: void 0,
729
+ skip: true
730
+ };
731
+ }
732
+
733
+ //#endregion
734
+ //#region src/converters/font-family.ts
735
+ /**
736
+ * Convert a DTCG fontFamily value to Figma-compatible format.
737
+ * Figma requires a single string, not an array.
738
+ *
739
+ * @example
740
+ * // String values pass through unchanged
741
+ * convertFontFamily("Inter", context);
742
+ * // => { value: "Inter" }
743
+ *
744
+ * @example
745
+ * // Arrays are truncated to the first element
746
+ * convertFontFamily(["Inter", "Helvetica", "sans-serif"], context);
747
+ * // => { value: "Inter" } (with warning about dropped fallbacks)
748
+ */
749
+ function convertFontFamily(value, context) {
750
+ if (typeof value === "string") return { value };
751
+ if (Array.isArray(value)) {
752
+ if (value.length === 0) {
753
+ context.logger.warn({
754
+ group: "plugin",
755
+ label: PLUGIN_NAME,
756
+ message: `Token "${context.tokenId}" has empty fontFamily array`
757
+ });
758
+ return {
759
+ value: void 0,
760
+ skip: true
761
+ };
762
+ }
763
+ const firstFont = value[0];
764
+ if (value.length > 1) context.logger.warn({
765
+ group: "plugin",
766
+ label: PLUGIN_NAME,
767
+ message: `Token "${context.tokenId}" fontFamily array truncated to first element "${firstFont}" (dropped: ${value.slice(1).join(", ")})`
768
+ });
769
+ return { value: firstFont };
770
+ }
771
+ context.logger.warn({
772
+ group: "plugin",
773
+ label: PLUGIN_NAME,
774
+ message: `Token "${context.tokenId}" has invalid fontFamily value: ${typeof value}`
775
+ });
776
+ return {
777
+ value: void 0,
778
+ skip: true
779
+ };
780
+ }
781
+
782
+ //#endregion
783
+ //#region src/converters/font-weight.ts
784
+ /**
785
+ * Valid string aliases for font weights as per W3C DTCG spec.
786
+ */
787
+ const FONT_WEIGHT_ALIASES = {
788
+ thin: 100,
789
+ hairline: 100,
790
+ "extra-light": 200,
791
+ "ultra-light": 200,
792
+ light: 300,
793
+ normal: 400,
794
+ regular: 400,
795
+ book: 400,
796
+ medium: 500,
797
+ "semi-bold": 600,
798
+ "demi-bold": 600,
799
+ bold: 700,
800
+ "extra-bold": 800,
801
+ "ultra-bold": 800,
802
+ black: 900,
803
+ heavy: 900,
804
+ "extra-black": 950,
805
+ "ultra-black": 950
806
+ };
807
+ /**
808
+ * Convert a DTCG fontWeight value to Figma-compatible format.
809
+ * Output type matches input type (string stays string, number stays number).
810
+ *
811
+ * @example
812
+ * // Number values pass through with validation (1-1000)
813
+ * convertFontWeight(400, context);
814
+ * // => { value: 400 }
815
+ *
816
+ * @example
817
+ * // String aliases pass through if valid
818
+ * convertFontWeight("bold", context);
819
+ * // => { value: "bold" }
820
+ */
821
+ function convertFontWeight(value, context) {
822
+ if (typeof value === "number") {
823
+ if (!Number.isFinite(value) || value < 1 || value > 1e3) {
824
+ context.logger.warn({
825
+ group: "plugin",
826
+ label: PLUGIN_NAME,
827
+ message: `Token "${context.tokenId}" has invalid fontWeight value: ${value} (must be 1-1000)`
828
+ });
829
+ return {
830
+ value: void 0,
831
+ skip: true
832
+ };
833
+ }
834
+ return {
835
+ value,
836
+ outputType: "number"
837
+ };
838
+ }
839
+ if (typeof value === "string") {
840
+ if (!(value.toLowerCase() in FONT_WEIGHT_ALIASES)) {
841
+ const validAliases = Object.keys(FONT_WEIGHT_ALIASES).slice(0, 5).join(", ");
842
+ context.logger.warn({
843
+ group: "plugin",
844
+ label: PLUGIN_NAME,
845
+ message: `Token "${context.tokenId}" has unknown fontWeight alias: "${value}". Valid aliases include: ${validAliases}, etc. Use a valid alias or a numeric weight (1-1000).`
846
+ });
847
+ return {
848
+ value: void 0,
849
+ skip: true
850
+ };
851
+ }
852
+ return {
853
+ value,
854
+ outputType: "string"
855
+ };
856
+ }
857
+ context.logger.warn({
858
+ group: "plugin",
859
+ label: PLUGIN_NAME,
860
+ message: `Token "${context.tokenId}" has invalid fontWeight type: ${typeof value}`
861
+ });
862
+ return {
863
+ value: void 0,
864
+ skip: true
865
+ };
866
+ }
867
+
868
+ //#endregion
869
+ //#region src/converters/number.ts
870
+ /**
871
+ * Convert a DTCG number value to Figma-compatible format.
872
+ * Can output as Boolean if token has com.figma.type extension set to "boolean".
873
+ *
874
+ * @example
875
+ * // Regular number token
876
+ * convertNumber(1.5, context) // => { value: 1.5 }
877
+ *
878
+ * @example
879
+ * // Boolean extension: 0 becomes false, non-zero becomes true
880
+ * // Token with $extensions: { "com.figma": { "type": "boolean" } }
881
+ * convertNumber(0, contextWithBooleanExt) // => { value: false }
882
+ * convertNumber(1, contextWithBooleanExt) // => { value: true }
883
+ */
884
+ function convertNumber(value, context) {
885
+ if (typeof value !== "number") {
886
+ context.logger.warn({
887
+ group: "plugin",
888
+ label: PLUGIN_NAME,
889
+ message: `Token "${context.tokenId}" has invalid number value: ${typeof value}`
890
+ });
891
+ return {
892
+ value: void 0,
893
+ skip: true
894
+ };
895
+ }
896
+ if (!Number.isFinite(value)) {
897
+ context.logger.warn({
898
+ group: "plugin",
899
+ label: PLUGIN_NAME,
900
+ message: `Token "${context.tokenId}" has non-finite number value: ${value}`
901
+ });
902
+ return {
903
+ value: void 0,
904
+ skip: true
905
+ };
906
+ }
907
+ if (context.extensions?.["com.figma.type"] === "boolean") return { value: value !== 0 };
908
+ return { value };
909
+ }
910
+
911
+ //#endregion
912
+ //#region src/converters/line-height.ts
913
+ /**
914
+ * Convert a W3C DTCG lineHeight value to Figma-compatible format.
915
+ *
916
+ * ## W3C DTCG vs Figma Incompatibility
917
+ *
918
+ * The W3C DTCG specification defines lineHeight as a **number** type -
919
+ * a unitless multiplier relative to fontSize (e.g., `1.5` means 1.5× the
920
+ * font size). This matches CSS behavior where `line-height: 1.5` is unitless.
921
+ *
922
+ * However, Figma Variables require lineHeight to be a **dimension** type
923
+ * with explicit px units. There is no way to represent a unitless multiplier
924
+ * in Figma's variable system.
925
+ *
926
+ * ## Conversion Strategy
927
+ *
928
+ * This converter calculates the absolute lineHeight by multiplying the
929
+ * unitless multiplier with the fontSize:
930
+ *
931
+ * `absoluteLineHeight = lineHeight × fontSize`
932
+ *
933
+ * For example: `lineHeight: 1.5` with `fontSize: 16px` → `24px`
934
+ *
935
+ * ## Trade-off: Loss of Token Reference
936
+ *
937
+ * When converting a multiplier to an absolute dimension, any reference to
938
+ * a primitive number token is lost. This is unavoidable because:
939
+ *
940
+ * 1. Figma does not support unitless multipliers for lineHeight
941
+ * 2. We must compute a concrete px value at build time
942
+ * 3. The computed value cannot maintain an alias to the original number token
943
+ *
944
+ * This approach is the most token-setup-agnostic solution, as it works
945
+ * regardless of how the source tokens are structured.
946
+ *
947
+ * @example
948
+ * // Input: W3C DTCG typography with number lineHeight
949
+ * // lineHeight: 1.5, fontSize: { value: 16, unit: "px" }
950
+ * convertLineHeight(1.5, { ...context, fontSize: { value: 16, unit: "px" } });
951
+ * // Output: { value: { value: 24, unit: "px" } }
952
+ */
953
+ function convertLineHeight(value, context) {
954
+ if (typeof value !== "number") {
955
+ context.logger.warn({
956
+ group: "plugin",
957
+ label: PLUGIN_NAME,
958
+ message: `Token "${context.tokenId}" has invalid lineHeight value: expected number (per W3C DTCG spec), got ${typeof value}`
959
+ });
960
+ return {
961
+ value: void 0,
962
+ skip: true
963
+ };
964
+ }
965
+ if (!Number.isFinite(value)) {
966
+ context.logger.warn({
967
+ group: "plugin",
968
+ label: PLUGIN_NAME,
969
+ message: `Token "${context.tokenId}" has non-finite lineHeight value: ${value}`
970
+ });
971
+ return {
972
+ value: void 0,
973
+ skip: true
974
+ };
975
+ }
976
+ if (!context.fontSize) {
977
+ context.logger.warn({
978
+ group: "plugin",
979
+ label: PLUGIN_NAME,
980
+ message: `Token "${context.tokenId}" has lineHeight multiplier (${value}) but no fontSize is defined. Cannot calculate absolute lineHeight for Figma. Provide a fontSize in the typography token.`
981
+ });
982
+ return {
983
+ value: void 0,
984
+ skip: true
985
+ };
986
+ }
987
+ const rawLineHeight = value * context.fontSize.value;
988
+ const shouldRound = context.options.roundLineHeight !== false;
989
+ const absoluteLineHeight = shouldRound ? Math.round(rawLineHeight) : rawLineHeight;
990
+ const roundingNote = shouldRound && rawLineHeight !== absoluteLineHeight ? ` (rounded from ${rawLineHeight})` : "";
991
+ context.logger.info({
992
+ group: "plugin",
993
+ label: PLUGIN_NAME,
994
+ message: `Token "${context.tokenId}" lineHeight: ${value} × ${context.fontSize.value}px = ${absoluteLineHeight}px${roundingNote} (converted from W3C multiplier to Figma dimension)`
995
+ });
996
+ return { value: {
997
+ value: absoluteLineHeight,
998
+ unit: "px"
999
+ } };
1000
+ }
1001
+
1002
+ //#endregion
1003
+ //#region src/converters/typography.ts
1004
+ /**
1005
+ * Get the correct alias reference for a typography sub-property.
1006
+ * When a typography property references another typography token,
1007
+ * the alias needs to point to the corresponding sub-token.
1008
+ *
1009
+ * @param aliasOf - The referenced token ID, or undefined if not an alias
1010
+ * @param propertyName - The sub-property name (fontFamily, fontSize, etc.)
1011
+ * @param allTokens - Map of all tokens for type lookup
1012
+ * @returns Adjusted alias target, or undefined if not an alias
1013
+ *
1014
+ * @example
1015
+ * // If typography.base is a typography token:
1016
+ * getSubTokenAlias("typography.base", "fontFamily", tokens)
1017
+ * // "typography.base.fontFamily"
1018
+ *
1019
+ * // If dimension.100 is a primitive:
1020
+ * getSubTokenAlias("dimension.100", "fontSize", tokens)
1021
+ * // "dimension.100" (unchanged)
1022
+ */
1023
+ function getSubTokenAlias(aliasOf, propertyName, allTokens) {
1024
+ if (!aliasOf) return;
1025
+ if ((allTokens?.[aliasOf])?.$type === "typography") return `${aliasOf}.${propertyName}`;
1026
+ return aliasOf;
1027
+ }
1028
+ /**
1029
+ * Convert a DTCG typography value to Figma-compatible format.
1030
+ * Typography tokens are split into individual sub-tokens since Figma
1031
+ * doesn't support the composite typography type.
1032
+ *
1033
+ * @example
1034
+ * // Input typography token
1035
+ * convertTypography({
1036
+ * fontFamily: "Inter",
1037
+ * fontSize: { value: 16, unit: "px" },
1038
+ * fontWeight: 400,
1039
+ * lineHeight: 1.5,
1040
+ * letterSpacing: { value: 0, unit: "px" }
1041
+ * }, context);
1042
+ * // => { value: undefined, split: true, subTokens: [...] }
1043
+ */
1044
+ function convertTypography(value, context) {
1045
+ if (!isDTCGTypographyValue(value)) {
1046
+ context.logger.warn({
1047
+ group: "plugin",
1048
+ label: PLUGIN_NAME,
1049
+ message: `Token "${context.tokenId}" has invalid typography value: expected object, got ${typeof value}`
1050
+ });
1051
+ return {
1052
+ value: void 0,
1053
+ skip: true
1054
+ };
1055
+ }
1056
+ const typography = value;
1057
+ const partialAliasOf = context.partialAliasOf;
1058
+ const subTokens = [];
1059
+ if (typography.fontFamily !== void 0) {
1060
+ const aliasOf = getSubTokenAlias(partialAliasOf?.fontFamily, "fontFamily", context.allTokens);
1061
+ const result = convertFontFamily(typography.fontFamily, {
1062
+ ...context,
1063
+ tokenId: `${context.tokenId}.fontFamily`
1064
+ });
1065
+ if (!result.skip) subTokens.push({
1066
+ idSuffix: "fontFamily",
1067
+ $type: "fontFamily",
1068
+ value: result.value,
1069
+ aliasOf
1070
+ });
1071
+ }
1072
+ let resolvedFontSize;
1073
+ if (typography.fontSize !== void 0) {
1074
+ const aliasOf = getSubTokenAlias(partialAliasOf?.fontSize, "fontSize", context.allTokens);
1075
+ const result = convertDimension(typography.fontSize, {
1076
+ ...context,
1077
+ tokenId: `${context.tokenId}.fontSize`
1078
+ });
1079
+ if (!result.skip) {
1080
+ resolvedFontSize = result.value;
1081
+ subTokens.push({
1082
+ idSuffix: "fontSize",
1083
+ $type: "dimension",
1084
+ value: result.value,
1085
+ aliasOf
1086
+ });
1087
+ }
1088
+ }
1089
+ if (typography.fontWeight !== void 0) {
1090
+ const aliasOf = getSubTokenAlias(partialAliasOf?.fontWeight, "fontWeight", context.allTokens);
1091
+ const result = convertFontWeight(typography.fontWeight, {
1092
+ ...context,
1093
+ tokenId: `${context.tokenId}.fontWeight`
1094
+ });
1095
+ if (!result.skip) subTokens.push({
1096
+ idSuffix: "fontWeight",
1097
+ $type: result.outputType ?? "fontWeight",
1098
+ value: result.value,
1099
+ aliasOf
1100
+ });
1101
+ }
1102
+ if (typography.lineHeight !== void 0) {
1103
+ const result = convertLineHeight(typography.lineHeight, {
1104
+ ...context,
1105
+ tokenId: `${context.tokenId}.lineHeight`,
1106
+ fontSize: resolvedFontSize
1107
+ });
1108
+ if (!result.skip) subTokens.push({
1109
+ idSuffix: "lineHeight",
1110
+ $type: "dimension",
1111
+ value: result.value
1112
+ });
1113
+ }
1114
+ if (typography.letterSpacing !== void 0) {
1115
+ const aliasOf = getSubTokenAlias(partialAliasOf?.letterSpacing, "letterSpacing", context.allTokens);
1116
+ const result = convertDimension(typography.letterSpacing, {
1117
+ ...context,
1118
+ tokenId: `${context.tokenId}.letterSpacing`
1119
+ });
1120
+ if (!result.skip) subTokens.push({
1121
+ idSuffix: "letterSpacing",
1122
+ $type: "dimension",
1123
+ value: result.value,
1124
+ aliasOf
1125
+ });
1126
+ }
1127
+ if (subTokens.length === 0) {
1128
+ context.logger.warn({
1129
+ group: "plugin",
1130
+ label: PLUGIN_NAME,
1131
+ message: `Token "${context.tokenId}" typography value has no valid sub-properties`
1132
+ });
1133
+ return {
1134
+ value: void 0,
1135
+ skip: true
1136
+ };
1137
+ }
1138
+ return {
1139
+ value: void 0,
1140
+ split: true,
1141
+ subTokens
1142
+ };
1143
+ }
1144
+
1145
+ //#endregion
1146
+ //#region src/converters/index.ts
1147
+ /**
1148
+ * Registry of converters by token type.
1149
+ */
1150
+ const converters = {
1151
+ color: convertColor,
1152
+ dimension: convertDimension,
1153
+ duration: convertDuration,
1154
+ fontFamily: convertFontFamily,
1155
+ fontWeight: convertFontWeight,
1156
+ number: convertNumber,
1157
+ typography: convertTypography
1158
+ };
1159
+ /**
1160
+ * Check if a token type is supported by Figma.
1161
+ */
1162
+ function isSupportedType(type) {
1163
+ return SUPPORTED_TYPES.includes(type);
1164
+ }
1165
+ /**
1166
+ * Check if a value is an alias reference (curly brace syntax).
1167
+ */
1168
+ function isAlias(value) {
1169
+ return typeof value === "string" && value.startsWith("{") && value.endsWith("}");
1170
+ }
1171
+ /**
1172
+ * Extract the token ID from an alias reference.
1173
+ * @param alias - The alias string, e.g., "{color.primary}"
1174
+ * @returns The token ID, e.g., "color.primary"
1175
+ */
1176
+ function extractAliasTarget(alias) {
1177
+ return alias.slice(1, -1);
1178
+ }
1179
+ /**
1180
+ * Validate an alias reference and return any warnings.
1181
+ */
1182
+ function validateAlias(alias, context) {
1183
+ const targetId = extractAliasTarget(alias);
1184
+ if (!context.allTokens) return { valid: true };
1185
+ const targetToken = context.allTokens[targetId];
1186
+ if (!targetToken) return {
1187
+ valid: false,
1188
+ warning: `Token "${context.tokenId}" references non-existent token "${targetId}". Check the token path for typos or ensure the referenced token is defined.`
1189
+ };
1190
+ if (!isSupportedType(targetToken.$type)) return {
1191
+ valid: false,
1192
+ warning: UNSUPPORTED_TYPES.includes(targetToken.$type) ? `Token "${context.tokenId}" aliases unsupported type "${targetToken.$type}" (from "${targetId}"). This alias will be preserved but may not work in Figma. Consider referencing a supported token type instead.` : `Token "${context.tokenId}" aliases unknown type "${targetToken.$type}" (from "${targetId}"). This alias will be preserved but may not work in Figma. Verify the target token has a supported type.`
1193
+ };
1194
+ return { valid: true };
1195
+ }
1196
+ /**
1197
+ * Convert a token value to Figma-compatible format.
1198
+ *
1199
+ * @param token - The normalized token
1200
+ * @param value - The token value to convert
1201
+ * @param context - Converter context with logger and options
1202
+ * @returns Converted value or skip indicator
1203
+ */
1204
+ function convertToken(token, value, context) {
1205
+ const { $type } = token;
1206
+ if (!$type) {
1207
+ context.logger.warn({
1208
+ group: "plugin",
1209
+ label: PLUGIN_NAME,
1210
+ message: `Token "${context.tokenId}" is missing $type. Ensure all tokens have a valid $type defined either directly or inherited from a parent group.`
1211
+ });
1212
+ return {
1213
+ value: void 0,
1214
+ skip: true
1215
+ };
1216
+ }
1217
+ if (value === void 0 || value === null) {
1218
+ context.logger.warn({
1219
+ group: "plugin",
1220
+ label: PLUGIN_NAME,
1221
+ message: `Token "${context.tokenId}" has no value (${value}). Ensure $value is defined for this token.`
1222
+ });
1223
+ return {
1224
+ value: void 0,
1225
+ skip: true
1226
+ };
1227
+ }
1228
+ if (isAlias(value)) {
1229
+ const validation = validateAlias(value, context);
1230
+ if (validation.warning) context.logger.warn({
1231
+ group: "plugin",
1232
+ label: PLUGIN_NAME,
1233
+ message: validation.warning
1234
+ });
1235
+ return { value };
1236
+ }
1237
+ if (!isSupportedType($type)) {
1238
+ const isKnownUnsupported = UNSUPPORTED_TYPES.includes($type);
1239
+ if (context.options.warnOnUnsupported !== false) {
1240
+ const suggestion = isKnownUnsupported ? ` Consider excluding this token with the 'exclude' option, or use a supported type (color, dimension, duration, fontFamily, fontWeight, number).` : ` If this is a custom type, consider using the 'transform' option to convert it to a supported format.`;
1241
+ context.logger.warn({
1242
+ group: "plugin",
1243
+ label: PLUGIN_NAME,
1244
+ message: isKnownUnsupported ? `Token "${context.tokenId}" has unsupported type "${$type}" and will be skipped.${suggestion}` : `Token "${context.tokenId}" has unknown type "${$type}" and will be skipped.${suggestion}`
1245
+ });
1246
+ }
1247
+ return {
1248
+ value: void 0,
1249
+ skip: true
1250
+ };
1251
+ }
1252
+ const converter = converters[$type];
1253
+ return converter(value, context);
1254
+ }
1255
+
1256
+ //#endregion
1257
+ //#region src/transform.ts
1258
+ /**
1259
+ * Register a transform with optional resolver input.
1260
+ * Encapsulates the conditional logic for input presence.
1261
+ */
1262
+ function registerTransform(setTransform, id, value, input) {
1263
+ if (input) setTransform(id, {
1264
+ format: FORMAT_ID,
1265
+ value,
1266
+ input
1267
+ });
1268
+ else setTransform(id, {
1269
+ format: FORMAT_ID,
1270
+ value
1271
+ });
1272
+ }
1273
+ /**
1274
+ * Filter extensions to only include Figma-specific ones (com.figma.*).
1275
+ * Removes non-Figma extensions to keep output clean.
1276
+ *
1277
+ * @param extensions - Token extensions object that may include various namespaces
1278
+ * @returns Object with only com.figma.* keys, or undefined if none exist
1279
+ *
1280
+ * @example
1281
+ * filterFigmaExtensions({ "com.figma.type": "boolean", "custom.ext": "value" })
1282
+ * // { "com.figma.type": "boolean" }
1283
+ */
1284
+ function filterFigmaExtensions(extensions) {
1285
+ if (!extensions) return;
1286
+ const figmaExtensions = {};
1287
+ let hasFigmaExtensions = false;
1288
+ for (const [key, value] of Object.entries(extensions)) if (key.startsWith("com.figma")) {
1289
+ figmaExtensions[key] = value;
1290
+ hasFigmaExtensions = true;
1291
+ }
1292
+ return hasFigmaExtensions ? figmaExtensions : void 0;
1293
+ }
1294
+ /**
1295
+ * Transform a single token and register it via setTransform.
1296
+ * Handles custom transforms, split tokens (typography), and alias references.
1297
+ *
1298
+ * @param token - The normalized token from terrazzo parser
1299
+ * @param rawValue - The resolved token value
1300
+ * @param aliasOf - Target token ID if this is an alias, undefined otherwise
1301
+ * @param options - Plugin configuration options
1302
+ * @param context - Plugin hook context with logger
1303
+ * @param allTokens - Map of all tokens for alias validation
1304
+ * @param setTransform - Terrazzo callback to register transformed value
1305
+ * @param input - Optional resolver input. When omitted, uses legacy mode without resolver.
1306
+ */
1307
+ function transformToken(token, rawValue, aliasOf, options, context, allTokens, setTransform, input) {
1308
+ const customValue = options.transform?.(token);
1309
+ if (customValue !== void 0) {
1310
+ registerTransform(setTransform, token.id, JSON.stringify(customValue), input);
1311
+ return;
1312
+ }
1313
+ const partialAliasOf = getPartialAliasOf(token);
1314
+ const result = convertToken(token, rawValue, {
1315
+ logger: context.logger,
1316
+ options,
1317
+ tokenId: token.id,
1318
+ extensions: token.$extensions,
1319
+ allTokens,
1320
+ originalValue: token.originalValue?.$value,
1321
+ partialAliasOf
1322
+ });
1323
+ if (result.skip) return;
1324
+ if (result.split && result.subTokens) {
1325
+ for (const subToken of result.subTokens) {
1326
+ const subId = `${token.id}.${subToken.idSuffix}`;
1327
+ const transformedValue = {
1328
+ $type: subToken.$type,
1329
+ $value: subToken.value,
1330
+ [INTERNAL_KEYS.SPLIT_FROM]: token.id,
1331
+ [INTERNAL_KEYS.TOKEN_ID]: subId
1332
+ };
1333
+ if (subToken.aliasOf) transformedValue[INTERNAL_KEYS.ALIAS_OF] = subToken.aliasOf;
1334
+ if (token.$description) transformedValue.$description = token.$description;
1335
+ const subTokenFigmaExtensions = filterFigmaExtensions(token.$extensions);
1336
+ if (subTokenFigmaExtensions) transformedValue.$extensions = subTokenFigmaExtensions;
1337
+ registerTransform(setTransform, subId, JSON.stringify(transformedValue), input);
1338
+ }
1339
+ return;
1340
+ }
1341
+ const transformedValue = {
1342
+ $type: result.outputType ?? token.$type,
1343
+ $value: result.value
1344
+ };
1345
+ if (token.$description) transformedValue.$description = token.$description;
1346
+ const figmaExtensions = filterFigmaExtensions(token.$extensions);
1347
+ if (figmaExtensions) transformedValue.$extensions = figmaExtensions;
1348
+ if (aliasOf) {
1349
+ const originalValueStr = token.originalValue?.$value;
1350
+ let directAliasOf = aliasOf;
1351
+ if (typeof originalValueStr === "string" && originalValueStr.startsWith("{") && originalValueStr.endsWith("}")) directAliasOf = originalValueStr.slice(1, -1);
1352
+ transformedValue[INTERNAL_KEYS.ALIAS_OF] = directAliasOf;
1353
+ } else if (token.$type === "color" && partialAliasOf) {
1354
+ const colorPartialAlias = partialAliasOf;
1355
+ const refs = [];
1356
+ if (colorPartialAlias.colorSpace) refs.push(colorPartialAlias.colorSpace);
1357
+ if (colorPartialAlias.components) {
1358
+ for (const comp of colorPartialAlias.components) if (comp) refs.push(comp);
1359
+ }
1360
+ if (refs.length > 0) {
1361
+ const uniqueRefs = [...new Set(refs)];
1362
+ if (uniqueRefs.length === 1) transformedValue[INTERNAL_KEYS.ALIAS_OF] = uniqueRefs[0];
1363
+ }
1364
+ }
1365
+ registerTransform(setTransform, token.id, JSON.stringify(transformedValue), input);
1366
+ }
1367
+ /**
1368
+ * Transform DTCG tokens into Figma-compatible format.
1369
+ * Supports both resolver-based and non-resolver workflows.
1370
+ */
1371
+ function transformFigmaJson({ transform, options }) {
1372
+ const { setTransform, context, resolver, tokens } = transform;
1373
+ const shouldExclude = createExcludeMatcher(options.exclude);
1374
+ if (!hasValidResolverConfig(resolver)) {
1375
+ for (const token of Object.values(tokens)) {
1376
+ if (shouldExclude(token.id)) continue;
1377
+ transformToken(token, token.$value, token.aliasOf, options, context, tokens, setTransform);
1378
+ }
1379
+ return;
1380
+ }
1381
+ const permutations = resolver.listPermutations();
1382
+ for (const input of permutations) {
1383
+ const contextTokens = resolver.apply(input);
1384
+ for (const token of Object.values(contextTokens)) {
1385
+ if (shouldExclude(token.id)) continue;
1386
+ transformToken(token, token.$value, token.aliasOf, options, context, contextTokens, setTransform, input);
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ //#endregion
1392
+ //#region src/index.ts
1393
+ /**
1394
+ * Terrazzo plugin to convert DTCG design tokens to Figma-compatible JSON format.
1395
+ *
1396
+ * @example
1397
+ * // Basic usage
1398
+ * import { defineConfig } from "@terrazzo/cli";
1399
+ * import figmaJson from "terrazzo-plugin-figma-json";
1400
+ *
1401
+ * export default defineConfig({
1402
+ * plugins: [
1403
+ * figmaJson({ filename: "tokens.figma.json" }),
1404
+ * ],
1405
+ * });
1406
+ *
1407
+ * @example
1408
+ * // With all options
1409
+ * figmaJson({
1410
+ * filename: "design-tokens.figma.json",
1411
+ * exclude: ["internal.*", "deprecated.*"],
1412
+ * remBasePx: 16,
1413
+ * warnOnUnsupported: true,
1414
+ * preserveReferences: true,
1415
+ * tokenName: (token) => token.id.replace("color.", "brand."),
1416
+ * transform: (token) => {
1417
+ * if (token.id === "special.token") return { custom: true };
1418
+ * return undefined; // Use default transformation
1419
+ * },
1420
+ * });
1421
+ *
1422
+ */
1423
+ function figmaJsonPlugin(options) {
1424
+ const { skipBuild } = options ?? {};
1425
+ const filename = options?.filename ?? "tokens.figma.json";
1426
+ return {
1427
+ name: PLUGIN_NAME,
1428
+ async transform(transformOptions) {
1429
+ if (transformOptions.getTransforms({
1430
+ format: FORMAT_ID,
1431
+ id: "*"
1432
+ }).length) return;
1433
+ transformFigmaJson({
1434
+ transform: transformOptions,
1435
+ options: options ?? {}
1436
+ });
1437
+ },
1438
+ async build({ getTransforms, outputFile, resolver }) {
1439
+ if (skipBuild === true) return;
1440
+ const result = buildFigmaJson({
1441
+ getTransforms,
1442
+ exclude: options?.exclude,
1443
+ tokenName: options?.tokenName,
1444
+ preserveReferences: options?.preserveReferences,
1445
+ resolver
1446
+ });
1447
+ for (const [sourceName, contents] of result) outputFile(sourceName === "default" ? filename : `${sourceName}.${filename}`, contents);
1448
+ }
1449
+ };
1450
+ }
1451
+
1452
+ //#endregion
1453
+ export { FIGMA_COLOR_SPACES, FORMAT_ID, INTERNAL_KEYS, PLUGIN_NAME, SUPPORTED_TYPES, UNSUPPORTED_TYPES, buildDefaultInput, createExcludeMatcher, figmaJsonPlugin as default, getPartialAliasOf, hasValidResolverConfig, isDTCGColorValue, isDTCGDimensionValue, isDTCGDurationValue, isDTCGTypographyValue, parseTransformValue, removeInternalMetadata };
1454
+ //# sourceMappingURL=index.js.map