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/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/build.d.ts +18 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/constants.d.ts +30 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/converters/color.d.ts +24 -0
- package/dist/converters/color.d.ts.map +1 -0
- package/dist/converters/dimension.d.ts +17 -0
- package/dist/converters/dimension.d.ts.map +1 -0
- package/dist/converters/duration.d.ts +17 -0
- package/dist/converters/duration.d.ts.map +1 -0
- package/dist/converters/font-family.d.ts +17 -0
- package/dist/converters/font-family.d.ts.map +1 -0
- package/dist/converters/font-weight.d.ts +17 -0
- package/dist/converters/font-weight.d.ts.map +1 -0
- package/dist/converters/index.d.ts +25 -0
- package/dist/converters/index.d.ts.map +1 -0
- package/dist/converters/line-height.d.ts +55 -0
- package/dist/converters/line-height.d.ts.map +1 -0
- package/dist/converters/number.d.ts +17 -0
- package/dist/converters/number.d.ts.map +1 -0
- package/dist/converters/typography.d.ts +19 -0
- package/dist/converters/typography.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1454 -0
- package/dist/index.js.map +1 -0
- package/dist/transform.d.ts +12 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +64 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +68 -0
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
|