figma-console-mcp 1.33.2 → 1.35.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/README.md +30 -16
- package/dist/cloudflare/core/cloud-websocket-connector.js +35 -0
- package/dist/cloudflare/core/figma-tools.js +1 -1
- package/dist/cloudflare/core/slot-tools.js +334 -0
- package/dist/cloudflare/core/tokens/alias-resolver.js +15 -2
- package/dist/cloudflare/core/tokens/config.js +10 -0
- package/dist/cloudflare/core/tokens/dialect.js +232 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +37 -3
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +35 -14
- package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
- package/dist/cloudflare/core/tokens/index.js +2 -1
- package/dist/cloudflare/core/tokens/schemas.js +4 -0
- package/dist/cloudflare/core/tokens-tools.js +942 -81
- package/dist/cloudflare/core/websocket-connector.js +59 -0
- package/dist/cloudflare/core/write-tools.js +348 -58
- package/dist/cloudflare/index.js +10 -7
- package/dist/core/design-system-manifest.d.ts +1 -1
- package/dist/core/design-system-manifest.d.ts.map +1 -1
- package/dist/core/figma-connector.d.ts +34 -0
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-tools.js +1 -1
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/slot-tools.d.ts +7 -0
- package/dist/core/slot-tools.d.ts.map +1 -0
- package/dist/core/slot-tools.js +335 -0
- package/dist/core/slot-tools.js.map +1 -0
- package/dist/core/tokens/alias-resolver.d.ts +16 -0
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
- package/dist/core/tokens/alias-resolver.js +15 -2
- package/dist/core/tokens/alias-resolver.js.map +1 -1
- package/dist/core/tokens/config.d.ts +28 -0
- package/dist/core/tokens/config.d.ts.map +1 -1
- package/dist/core/tokens/config.js +10 -0
- package/dist/core/tokens/config.js.map +1 -1
- package/dist/core/tokens/dialect.d.ts +107 -0
- package/dist/core/tokens/dialect.d.ts.map +1 -0
- package/dist/core/tokens/dialect.js +233 -0
- package/dist/core/tokens/dialect.js.map +1 -0
- package/dist/core/tokens/figma-converter.d.ts +2 -0
- package/dist/core/tokens/figma-converter.d.ts.map +1 -1
- package/dist/core/tokens/figma-converter.js +37 -3
- package/dist/core/tokens/figma-converter.js.map +1 -1
- package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
- package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
- package/dist/core/tokens/formatters/dtcg.js +35 -14
- package/dist/core/tokens/formatters/dtcg.js.map +1 -1
- package/dist/core/tokens/formatters/json.d.ts.map +1 -1
- package/dist/core/tokens/formatters/json.js +28 -10
- package/dist/core/tokens/formatters/json.js.map +1 -1
- package/dist/core/tokens/index.d.ts +2 -1
- package/dist/core/tokens/index.d.ts.map +1 -1
- package/dist/core/tokens/index.js +2 -1
- package/dist/core/tokens/index.js.map +1 -1
- package/dist/core/tokens/schemas.d.ts +3 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -1
- package/dist/core/tokens/schemas.js +4 -0
- package/dist/core/tokens/schemas.js.map +1 -1
- package/dist/core/tokens/types.d.ts +29 -0
- package/dist/core/tokens/types.d.ts.map +1 -1
- package/dist/core/tokens/types.js.map +1 -1
- package/dist/core/tokens-tools.d.ts +211 -12
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +942 -81
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/core/websocket-connector.d.ts +60 -0
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-connector.js +59 -0
- package/dist/core/websocket-connector.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +348 -58
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +7 -4
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +682 -8
- package/figma-desktop-bridge/ui.html +70 -1
- package/package.json +1 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG dialect helpers — legacy vs 2025.10 value encodings.
|
|
3
|
+
*
|
|
4
|
+
* The token pipeline historically speaks the "legacy" DTCG dialect: colors
|
|
5
|
+
* as hex strings ("#4085F2"), FLOAT dimensions as bare numbers. The DTCG
|
|
6
|
+
* 2025.10 spec (https://tr.designtokens.org/format/) switched to object
|
|
7
|
+
* forms:
|
|
8
|
+
*
|
|
9
|
+
* color = { colorSpace: "srgb", components: [r, g, b], alpha?, hex? }
|
|
10
|
+
* dimension = { value: number, unit: "px" | "rem" }
|
|
11
|
+
* duration = { value: number, unit: "ms" | "s" } (we already emit this)
|
|
12
|
+
*
|
|
13
|
+
* Export stays legacy by default (downstream consumers depend on it) and
|
|
14
|
+
* opts into 2025 via the `dtcgDialect` option. Import accepts BOTH dialects
|
|
15
|
+
* unconditionally. This module centralizes:
|
|
16
|
+
*
|
|
17
|
+
* - 2025 encoding helpers used by the dtcg/json formatters at render time
|
|
18
|
+
* - dialect-agnostic canonicalization used by the import diff so a 2025
|
|
19
|
+
* color object compares equal to the same color's legacy hex string
|
|
20
|
+
* (both are quantized to 1/255 per channel, tolerating the 8-bit
|
|
21
|
+
* precision loss inherent to hex)
|
|
22
|
+
* - stripping of the transient `rawColor` field the converter carries on
|
|
23
|
+
* COLOR TokenValues (full-precision floats for 2025 components) so it
|
|
24
|
+
* never leaks into serialized output
|
|
25
|
+
*/
|
|
26
|
+
export function clamp01(n) {
|
|
27
|
+
if (Number.isNaN(n))
|
|
28
|
+
return 0;
|
|
29
|
+
return Math.max(0, Math.min(1, n));
|
|
30
|
+
}
|
|
31
|
+
function byteHex(f) {
|
|
32
|
+
return Math.round(clamp01(f) * 255)
|
|
33
|
+
.toString(16)
|
|
34
|
+
.padStart(2, "0");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Canonicalize any color-like literal to a lowercase 8-digit hex string
|
|
38
|
+
* (`#rrggbbaa`), or return null when the literal isn't recognizably a color.
|
|
39
|
+
*
|
|
40
|
+
* Accepts:
|
|
41
|
+
* - hex strings: #rgb, #rrggbb, #rrggbbaa
|
|
42
|
+
* - 2025.10 color objects with srgb (or unspecified) colorSpace +
|
|
43
|
+
* 3 numeric components (+ optional alpha)
|
|
44
|
+
* - color objects with a hex fallback field (any colorSpace) + optional alpha
|
|
45
|
+
*
|
|
46
|
+
* Both the components form and the hex form quantize to 1 / 255 per channel,
|
|
47
|
+
* so a full-precision components array compares equal to the hex string the
|
|
48
|
+
* legacy pipeline derived from the same Figma floats.
|
|
49
|
+
*/
|
|
50
|
+
export function colorLiteralToCanonicalHex(literal) {
|
|
51
|
+
if (typeof literal === "string") {
|
|
52
|
+
const m = literal
|
|
53
|
+
.trim()
|
|
54
|
+
.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
|
55
|
+
if (!m)
|
|
56
|
+
return null;
|
|
57
|
+
let digits = m[1].toLowerCase();
|
|
58
|
+
if (digits.length === 3) {
|
|
59
|
+
digits = digits
|
|
60
|
+
.split("")
|
|
61
|
+
.map((c) => c + c)
|
|
62
|
+
.join("");
|
|
63
|
+
}
|
|
64
|
+
if (digits.length === 6)
|
|
65
|
+
digits += "ff";
|
|
66
|
+
return `#${digits}`;
|
|
67
|
+
}
|
|
68
|
+
if (literal && typeof literal === "object" && !Array.isArray(literal)) {
|
|
69
|
+
const o = literal;
|
|
70
|
+
const colorSpace = typeof o.colorSpace === "string" ? o.colorSpace : undefined;
|
|
71
|
+
const comps = o.components;
|
|
72
|
+
if ((colorSpace === undefined || colorSpace === "srgb") &&
|
|
73
|
+
Array.isArray(comps) &&
|
|
74
|
+
comps.length === 3 &&
|
|
75
|
+
comps.every((c) => typeof c === "number")) {
|
|
76
|
+
const [r, g, b] = comps;
|
|
77
|
+
const a = typeof o.alpha === "number" ? o.alpha : 1;
|
|
78
|
+
return `#${byteHex(r)}${byteHex(g)}${byteHex(b)}${byteHex(a)}`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof o.hex === "string") {
|
|
81
|
+
const base = colorLiteralToCanonicalHex(o.hex);
|
|
82
|
+
if (!base)
|
|
83
|
+
return null;
|
|
84
|
+
if (typeof o.alpha === "number") {
|
|
85
|
+
return base.slice(0, 7) + byteHex(o.alpha);
|
|
86
|
+
}
|
|
87
|
+
return base;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse a hex color string to raw rgba floats (0–1). Returns null on
|
|
94
|
+
* anything that isn't a valid 3/6/8-digit hex string.
|
|
95
|
+
*/
|
|
96
|
+
export function hexToRawRgba(hex) {
|
|
97
|
+
const canonical = colorLiteralToCanonicalHex(hex);
|
|
98
|
+
if (!canonical)
|
|
99
|
+
return null;
|
|
100
|
+
const d = canonical.slice(1);
|
|
101
|
+
return {
|
|
102
|
+
r: parseInt(d.slice(0, 2), 16) / 255,
|
|
103
|
+
g: parseInt(d.slice(2, 4), 16) / 255,
|
|
104
|
+
b: parseInt(d.slice(4, 6), 16) / 255,
|
|
105
|
+
a: parseInt(d.slice(6, 8), 16) / 255,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Encode a color TokenValue in the DTCG 2025.10 object form.
|
|
110
|
+
*
|
|
111
|
+
* Prefers the converter's transient `rawColor` floats (full precision — NOT
|
|
112
|
+
* round-tripped through 8-bit hex); falls back to parsing a hex-string
|
|
113
|
+
* literal. Literals already in object form pass through verbatim. Returns
|
|
114
|
+
* null when the value can't be encoded (caller keeps the legacy rendering).
|
|
115
|
+
*
|
|
116
|
+
* `alpha` is emitted only when < 1; `hex` is always included as the interop
|
|
117
|
+
* courtesy field (#RRGGBB, alpha carried separately).
|
|
118
|
+
*/
|
|
119
|
+
export function colorValueTo2025(value) {
|
|
120
|
+
const lit = value.literal;
|
|
121
|
+
if (lit && typeof lit === "object" && !Array.isArray(lit)) {
|
|
122
|
+
// Already object-form (e.g. a re-exported 2025 document) — pass through.
|
|
123
|
+
const o = lit;
|
|
124
|
+
if ("colorSpace" in o || "components" in o || "hex" in o)
|
|
125
|
+
return o;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
let floats = value.rawColor ?? null;
|
|
129
|
+
if (!floats && typeof lit === "string") {
|
|
130
|
+
floats = hexToRawRgba(lit);
|
|
131
|
+
}
|
|
132
|
+
if (!floats)
|
|
133
|
+
return null;
|
|
134
|
+
const { r, g, b, a } = floats;
|
|
135
|
+
return {
|
|
136
|
+
colorSpace: "srgb",
|
|
137
|
+
components: [r, g, b],
|
|
138
|
+
...(a < 1 ? { alpha: a } : {}),
|
|
139
|
+
hex: `#${byteHex(r)}${byteHex(g)}${byteHex(b)}`.toUpperCase(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Encode a dimension literal in the DTCG 2025.10 object form. The converter
|
|
144
|
+
* emits Figma FLOAT dimensions as bare unitless numbers conventionally
|
|
145
|
+
* interpreted as px (the same convention the css/json formatters use), so
|
|
146
|
+
* only bare finite numbers are converted; everything else (unit strings,
|
|
147
|
+
* pre-encoded objects) keeps its current rendering.
|
|
148
|
+
*/
|
|
149
|
+
export function dimensionLiteralTo2025(literal) {
|
|
150
|
+
if (typeof literal === "number" && Number.isFinite(literal)) {
|
|
151
|
+
return { value: literal, unit: "px" };
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Normalize a TokenValue to a dialect-agnostic comparable form for diffing:
|
|
157
|
+
*
|
|
158
|
+
* - the transient `rawColor` field is dropped (Figma-side values carry it;
|
|
159
|
+
* parsed code-side values never do)
|
|
160
|
+
* - color-like literals (hex strings OR 2025 color objects) normalize to a
|
|
161
|
+
* lowercase 8-digit hex string, quantized to 1/255 per channel
|
|
162
|
+
* - `{ value, unit }` objects with px/ms normalize to the bare number
|
|
163
|
+
* ("s" converts to ms first), so `{value: 16, unit: "px"}` equals 16 and
|
|
164
|
+
* `{value: 0.3, unit: "s"}` equals `{value: 300, unit: "ms"}`
|
|
165
|
+
* - "16px"-style strings normalize to the bare number
|
|
166
|
+
*
|
|
167
|
+
* Conservative by design: anything not confidently recognized is returned
|
|
168
|
+
* unchanged (minus rawColor), falling back to the existing deep comparison.
|
|
169
|
+
*/
|
|
170
|
+
export function canonicalizeTokenValueForComparison(v) {
|
|
171
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
172
|
+
return v;
|
|
173
|
+
const obj = v;
|
|
174
|
+
if (!("literal" in obj) && !("reference" in obj))
|
|
175
|
+
return v;
|
|
176
|
+
const { rawColor: _rawColor, ...rest } = obj;
|
|
177
|
+
if (rest.literal !== undefined) {
|
|
178
|
+
const hex = colorLiteralToCanonicalHex(rest.literal);
|
|
179
|
+
if (hex !== null)
|
|
180
|
+
return { ...rest, literal: hex };
|
|
181
|
+
const num = numericLiteralToCanonical(rest.literal);
|
|
182
|
+
if (num !== null)
|
|
183
|
+
return { ...rest, literal: num };
|
|
184
|
+
}
|
|
185
|
+
return rest;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Normalize dimension/duration-shaped literals to a bare comparable number:
|
|
189
|
+
* - `{ value: n, unit: "px" | "ms" }` → n
|
|
190
|
+
* - `{ value: n, unit: "s" }` → n * 1000 (canonical ms)
|
|
191
|
+
* - `"16px"` strings → 16
|
|
192
|
+
* Returns null for everything else (including rem/em/% and objects with
|
|
193
|
+
* extra fields — those keep structural comparison).
|
|
194
|
+
*/
|
|
195
|
+
function numericLiteralToCanonical(literal) {
|
|
196
|
+
if (typeof literal === "string") {
|
|
197
|
+
const m = literal.trim().match(/^(-?(?:\d+\.?\d*|\.\d+))px$/i);
|
|
198
|
+
return m ? Number(m[1]) : null;
|
|
199
|
+
}
|
|
200
|
+
if (literal &&
|
|
201
|
+
typeof literal === "object" &&
|
|
202
|
+
!Array.isArray(literal) &&
|
|
203
|
+
Object.keys(literal).length === 2) {
|
|
204
|
+
const o = literal;
|
|
205
|
+
if (typeof o.value === "number" && typeof o.unit === "string") {
|
|
206
|
+
if (o.unit === "px" || o.unit === "ms")
|
|
207
|
+
return o.value;
|
|
208
|
+
if (o.unit === "s")
|
|
209
|
+
return o.value * 1000;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Return a copy of a mode-keyed TokenValue map with the transient `rawColor`
|
|
216
|
+
* field removed from every entry. Used wherever values get serialized
|
|
217
|
+
* (lastSyncedValue snapshots, diff-plan samples) so the transient field never
|
|
218
|
+
* appears in output — keeping legacy output byte-identical.
|
|
219
|
+
*/
|
|
220
|
+
export function stripRawColorFromValues(values) {
|
|
221
|
+
const out = {};
|
|
222
|
+
for (const [k, v] of Object.entries(values)) {
|
|
223
|
+
if (v && typeof v === "object" && "rawColor" in v) {
|
|
224
|
+
const { rawColor: _rawColor, ...rest } = v;
|
|
225
|
+
out[k] = rest;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
out[k] = v;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* $extensions["figma-console-mcp"] for round-trip non-destructiveness.
|
|
13
13
|
*/
|
|
14
14
|
import { slugifySetName } from "./alias-resolver.js";
|
|
15
|
+
import { stripRawColorFromValues } from "./dialect.js";
|
|
15
16
|
/**
|
|
16
17
|
* Convert a Figma variables payload to our canonical TokenDocument.
|
|
17
18
|
*/
|
|
@@ -126,14 +127,35 @@ function convertVariable(variable, wantedModes, variableById, collectionNameById
|
|
|
126
127
|
// reconstruct it.
|
|
127
128
|
...(strippedTypeSuffix ? { figmaName: variable.name } : {}),
|
|
128
129
|
...(Object.keys(springByMode).length > 0 ? { spring: springByMode } : {}),
|
|
130
|
+
// Scopes: stash only when NON-default. ["ALL_SCOPES"] (Figma's
|
|
131
|
+
// default) and empty/absent arrays are omitted so pre-existing
|
|
132
|
+
// exports stay byte-identical. codeSyntax: stash only when
|
|
133
|
+
// non-empty, same reasoning.
|
|
134
|
+
...(hasNonDefaultScopes(variable.scopes)
|
|
135
|
+
? { scopes: [...variable.scopes] }
|
|
136
|
+
: {}),
|
|
137
|
+
...(variable.codeSyntax && Object.keys(variable.codeSyntax).length > 0
|
|
138
|
+
? { codeSyntax: { ...variable.codeSyntax } }
|
|
139
|
+
: {}),
|
|
129
140
|
lastSyncedAt: new Date().toISOString(),
|
|
130
141
|
// We snapshot the synced value so future merge calls can detect
|
|
131
|
-
// two-sided conflicts.
|
|
132
|
-
|
|
142
|
+
// two-sided conflicts. rawColor is transient render-time data and
|
|
143
|
+
// must not leak into serialized extensions (legacy output stays
|
|
144
|
+
// byte-identical).
|
|
145
|
+
lastSyncedValue: stripRawColorFromValues({ ...values }),
|
|
133
146
|
},
|
|
134
147
|
},
|
|
135
148
|
};
|
|
136
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* True when a variable's scopes array is meaningfully restrictive — i.e.
|
|
152
|
+
* present, non-empty, and not just the default ["ALL_SCOPES"].
|
|
153
|
+
*/
|
|
154
|
+
function hasNonDefaultScopes(scopes) {
|
|
155
|
+
if (!Array.isArray(scopes) || scopes.length === 0)
|
|
156
|
+
return false;
|
|
157
|
+
return !(scopes.length === 1 && scopes[0] === "ALL_SCOPES");
|
|
158
|
+
}
|
|
137
159
|
function mapResolvedType(resolvedType, variableName, warnings) {
|
|
138
160
|
switch (resolvedType) {
|
|
139
161
|
case "COLOR":
|
|
@@ -212,7 +234,19 @@ function convertValue(rawValue, variable, modeName, variableById, collectionName
|
|
|
212
234
|
// Literal values per type.
|
|
213
235
|
if (resolvedType === "COLOR") {
|
|
214
236
|
if (typeof rawValue === "object" && rawValue !== null && "r" in rawValue) {
|
|
215
|
-
|
|
237
|
+
// The hex string stays the literal (legacy dialect + back-compat), but
|
|
238
|
+
// we also carry the raw full-precision floats so the 2025.10 dialect
|
|
239
|
+
// can emit `components` without round-tripping through 8-bit hex.
|
|
240
|
+
// `rawColor` is transient — see TokenValue.rawColor in types.ts.
|
|
241
|
+
return {
|
|
242
|
+
literal: rgbaToHex(rawValue),
|
|
243
|
+
rawColor: {
|
|
244
|
+
r: rawValue.r,
|
|
245
|
+
g: rawValue.g,
|
|
246
|
+
b: rawValue.b,
|
|
247
|
+
a: rawValue.a ?? 1,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
216
250
|
}
|
|
217
251
|
warnings.push(`COLOR value isn't an RGB object: ${JSON.stringify(rawValue)}`);
|
|
218
252
|
return { literal: String(rawValue) };
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
* structured $value form. Aliases emit `"$value": "{path.to.target}"`.
|
|
20
20
|
*
|
|
21
21
|
* This formatter is the canonical output — the format every other
|
|
22
|
-
* formatter (CSS variables
|
|
23
|
-
*
|
|
22
|
+
* formatter (CSS variables, Tailwind v4/v3, SCSS, TS module, JSON
|
|
23
|
+
* flat/nested, Style Dictionary v3, Tokens Studio) ultimately derives from.
|
|
24
24
|
*/
|
|
25
25
|
import { FIGMA_MCP_EXTENSION_KEY } from "../types.js";
|
|
26
26
|
import { formatDtcgReference } from "../alias-resolver.js";
|
|
27
|
+
import { colorValueTo2025, dimensionLiteralTo2025, } from "../dialect.js";
|
|
27
28
|
export function formatDtcg(doc, opts) {
|
|
28
29
|
const warnings = [];
|
|
29
30
|
const files = [];
|
|
@@ -35,6 +36,9 @@ export function formatDtcg(doc, opts) {
|
|
|
35
36
|
// 4. neither → one file with everything
|
|
36
37
|
const splitByMode = opts.target.splitByMode ?? false;
|
|
37
38
|
const splitByCollection = opts.target.splitByCollection ?? false;
|
|
39
|
+
// Value-encoding dialect. 'legacy' (default) is byte-identical to the
|
|
40
|
+
// historical output; '2025' opts into DTCG 2025.10 object colors/dimensions.
|
|
41
|
+
const dialect = opts.target.dtcgDialect ?? "legacy";
|
|
38
42
|
if (splitByMode && splitByCollection) {
|
|
39
43
|
for (const set of doc.sets) {
|
|
40
44
|
for (const mode of set.modes) {
|
|
@@ -43,7 +47,7 @@ export function formatDtcg(doc, opts) {
|
|
|
43
47
|
.filter((t) => t !== null);
|
|
44
48
|
files.push({
|
|
45
49
|
path: filenameFor(opts, set, mode),
|
|
46
|
-
content: serializeAsDtcg({ sets: [{ ...set, modes: [mode], tokens: fileTokens }], meta: doc.meta }, warnings, mode),
|
|
50
|
+
content: serializeAsDtcg({ sets: [{ ...set, modes: [mode], tokens: fileTokens }], meta: doc.meta }, warnings, dialect, mode),
|
|
47
51
|
});
|
|
48
52
|
}
|
|
49
53
|
}
|
|
@@ -65,7 +69,7 @@ export function formatDtcg(doc, opts) {
|
|
|
65
69
|
}));
|
|
66
70
|
files.push({
|
|
67
71
|
path: filenameFor(opts, undefined, mode),
|
|
68
|
-
content: serializeAsDtcg({ sets: fileSets, meta: doc.meta }, warnings, mode),
|
|
72
|
+
content: serializeAsDtcg({ sets: fileSets, meta: doc.meta }, warnings, dialect, mode),
|
|
69
73
|
});
|
|
70
74
|
}
|
|
71
75
|
}
|
|
@@ -73,14 +77,14 @@ export function formatDtcg(doc, opts) {
|
|
|
73
77
|
for (const set of doc.sets) {
|
|
74
78
|
files.push({
|
|
75
79
|
path: filenameFor(opts, set),
|
|
76
|
-
content: serializeAsDtcg({ sets: [set], meta: doc.meta }, warnings),
|
|
80
|
+
content: serializeAsDtcg({ sets: [set], meta: doc.meta }, warnings, dialect),
|
|
77
81
|
});
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
else {
|
|
81
85
|
files.push({
|
|
82
86
|
path: filenameFor(opts),
|
|
83
|
-
content: serializeAsDtcg(doc, warnings),
|
|
87
|
+
content: serializeAsDtcg(doc, warnings, dialect),
|
|
84
88
|
});
|
|
85
89
|
}
|
|
86
90
|
return { files, warnings };
|
|
@@ -131,7 +135,7 @@ function slugify(s) {
|
|
|
131
135
|
* file represents — otherwise the parser sees only `$value` literals and
|
|
132
136
|
* labels them "Default", which breaks round-trip diffs on multi-mode files.
|
|
133
137
|
*/
|
|
134
|
-
function serializeAsDtcg(doc, warnings, fileMode) {
|
|
138
|
+
function serializeAsDtcg(doc, warnings, dialect, fileMode) {
|
|
135
139
|
// Build the nested DTCG group tree by walking every token's path and
|
|
136
140
|
// building groups along the way.
|
|
137
141
|
const tree = {};
|
|
@@ -176,7 +180,7 @@ function serializeAsDtcg(doc, warnings, fileMode) {
|
|
|
176
180
|
tree[setKey] = setGroup;
|
|
177
181
|
}
|
|
178
182
|
for (const token of set.tokens) {
|
|
179
|
-
writeTokenIntoTree(setGroup, token, set.modes, warnings, fileMode);
|
|
183
|
+
writeTokenIntoTree(setGroup, token, set.modes, warnings, dialect, fileMode);
|
|
180
184
|
}
|
|
181
185
|
}
|
|
182
186
|
return JSON.stringify(sortKeys(tree), null, 2) + "\n";
|
|
@@ -200,7 +204,7 @@ function setKeyFor(set) {
|
|
|
200
204
|
* figma-console-mcp $extensions so the parser can restore the original
|
|
201
205
|
* name on round-trip. A warning names both sides of the conflict.
|
|
202
206
|
*/
|
|
203
|
-
function writeTokenIntoTree(root, token, setModes, warnings, fileMode) {
|
|
207
|
+
function writeTokenIntoTree(root, token, setModes, warnings, dialect, fileMode) {
|
|
204
208
|
let cursor = root;
|
|
205
209
|
for (let i = 0; i < token.path.length - 1; i++) {
|
|
206
210
|
const segment = token.path[i];
|
|
@@ -222,7 +226,7 @@ function writeTokenIntoTree(root, token, setModes, warnings, fileMode) {
|
|
|
222
226
|
cursor = next;
|
|
223
227
|
}
|
|
224
228
|
const leafKey = token.path[token.path.length - 1];
|
|
225
|
-
const rendered = renderToken(token, setModes, warnings, fileMode);
|
|
229
|
+
const rendered = renderToken(token, setModes, warnings, dialect, fileMode);
|
|
226
230
|
const existing = cursor[leafKey];
|
|
227
231
|
if (existing &&
|
|
228
232
|
typeof existing === "object" &&
|
|
@@ -261,7 +265,7 @@ function isToken(node) {
|
|
|
261
265
|
* splitByMode. Callers who want one-file-per-mode should set splitByMode at
|
|
262
266
|
* the formatter level.
|
|
263
267
|
*/
|
|
264
|
-
function renderToken(token, setModes, warnings, fileMode) {
|
|
268
|
+
function renderToken(token, setModes, warnings, dialect, fileMode) {
|
|
265
269
|
const result = {
|
|
266
270
|
$value: "",
|
|
267
271
|
$type: token.type,
|
|
@@ -273,13 +277,13 @@ function renderToken(token, setModes, warnings, fileMode) {
|
|
|
273
277
|
// Pick the primary mode: the set's first mode when the token has a value
|
|
274
278
|
// for it, otherwise the token's first mode.
|
|
275
279
|
const primaryMode = setModes[0] in token.values ? setModes[0] : modeKeys[0];
|
|
276
|
-
result.$value = encodeValue(token.values[primaryMode], token, warnings);
|
|
280
|
+
result.$value = encodeValue(token.values[primaryMode], token, warnings, dialect);
|
|
277
281
|
const otherModes = {};
|
|
278
282
|
if (!isSingleMode) {
|
|
279
283
|
for (const m of modeKeys) {
|
|
280
284
|
if (m === primaryMode)
|
|
281
285
|
continue;
|
|
282
|
-
otherModes[m] = encodeValue(token.values[m], token, warnings);
|
|
286
|
+
otherModes[m] = encodeValue(token.values[m], token, warnings, dialect);
|
|
283
287
|
}
|
|
284
288
|
}
|
|
285
289
|
// Preserve any pre-existing extensions (e.g. studio.tokens, our own metadata).
|
|
@@ -311,7 +315,7 @@ function renderToken(token, setModes, warnings, fileMode) {
|
|
|
311
315
|
}
|
|
312
316
|
return result;
|
|
313
317
|
}
|
|
314
|
-
function encodeValue(value, token, warnings) {
|
|
318
|
+
function encodeValue(value, token, warnings, dialect) {
|
|
315
319
|
if (!value) {
|
|
316
320
|
warnings.push(`Token ${token.path.join(".")} has no mode values — emitting empty string.`);
|
|
317
321
|
return "";
|
|
@@ -323,6 +327,23 @@ function encodeValue(value, token, warnings) {
|
|
|
323
327
|
warnings.push(`Token ${token.path.join(".")} has neither literal nor reference — emitting empty string.`);
|
|
324
328
|
return "";
|
|
325
329
|
}
|
|
330
|
+
// DTCG 2025.10 dialect: colors emit the object form (components from the
|
|
331
|
+
// converter's full-precision rawColor floats, hex kept as the interop
|
|
332
|
+
// courtesy field); dimension-typed bare numbers emit { value, unit: "px" }.
|
|
333
|
+
// duration already emits { value, unit: "ms" } in both dialects. Anything
|
|
334
|
+
// the encoders don't recognize keeps the legacy rendering.
|
|
335
|
+
if (dialect === "2025") {
|
|
336
|
+
if (token.type === "color") {
|
|
337
|
+
const encoded = colorValueTo2025(value);
|
|
338
|
+
if (encoded)
|
|
339
|
+
return encoded;
|
|
340
|
+
}
|
|
341
|
+
else if (token.type === "dimension") {
|
|
342
|
+
const encoded = dimensionLiteralTo2025(value.literal);
|
|
343
|
+
if (encoded)
|
|
344
|
+
return encoded;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
326
347
|
return value.literal;
|
|
327
348
|
}
|
|
328
349
|
function mergeExtension(token, key, payload) {
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
* aliases get a `null` (caller can decide how to fill those in).
|
|
32
32
|
*/
|
|
33
33
|
import { buildTokenIndex, resolveAliasChain } from "../alias-resolver.js";
|
|
34
|
+
import { colorValueTo2025, dimensionLiteralTo2025, } from "../dialect.js";
|
|
34
35
|
export function formatJsonFlat(doc, opts) {
|
|
35
36
|
const warnings = [];
|
|
36
37
|
const files = [];
|
|
37
38
|
const splitByCollection = opts.target.splitByCollection ?? false;
|
|
38
39
|
const prefix = opts.target.prefix ?? "";
|
|
40
|
+
const dialect = opts.target.dtcgDialect ?? "legacy";
|
|
39
41
|
// Plain JSON has no native alias mechanism — resolve aliases to their
|
|
40
42
|
// literal target so consumers get usable values, not opaque `{ref}` strings.
|
|
41
43
|
const tokenIndex = buildTokenIndex(doc);
|
|
@@ -43,14 +45,14 @@ export function formatJsonFlat(doc, opts) {
|
|
|
43
45
|
for (const set of doc.sets) {
|
|
44
46
|
files.push({
|
|
45
47
|
path: filenameFor(opts, set, "flat"),
|
|
46
|
-
content: renderFlat([set], prefix, tokenIndex, warnings),
|
|
48
|
+
content: renderFlat([set], prefix, tokenIndex, warnings, dialect),
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
else {
|
|
51
53
|
files.push({
|
|
52
54
|
path: filenameFor(opts, undefined, "flat"),
|
|
53
|
-
content: renderFlat(doc.sets, prefix, tokenIndex, warnings),
|
|
55
|
+
content: renderFlat(doc.sets, prefix, tokenIndex, warnings, dialect),
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
return { files, warnings };
|
|
@@ -59,19 +61,20 @@ export function formatJsonNested(doc, opts) {
|
|
|
59
61
|
const warnings = [];
|
|
60
62
|
const files = [];
|
|
61
63
|
const splitByCollection = opts.target.splitByCollection ?? false;
|
|
64
|
+
const dialect = opts.target.dtcgDialect ?? "legacy";
|
|
62
65
|
const tokenIndex = buildTokenIndex(doc);
|
|
63
66
|
if (splitByCollection) {
|
|
64
67
|
for (const set of doc.sets) {
|
|
65
68
|
files.push({
|
|
66
69
|
path: filenameFor(opts, set, "nested"),
|
|
67
|
-
content: renderNested([set], tokenIndex, warnings),
|
|
70
|
+
content: renderNested([set], tokenIndex, warnings, dialect),
|
|
68
71
|
});
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
else {
|
|
72
75
|
files.push({
|
|
73
76
|
path: filenameFor(opts, undefined, "nested"),
|
|
74
|
-
content: renderNested(doc.sets, tokenIndex, warnings),
|
|
77
|
+
content: renderNested(doc.sets, tokenIndex, warnings, dialect),
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
80
|
return { files, warnings };
|
|
@@ -92,7 +95,7 @@ function slugify(s) {
|
|
|
92
95
|
.replace(/[^a-z0-9]+/g, "-")
|
|
93
96
|
.replace(/^-+|-+$/g, "");
|
|
94
97
|
}
|
|
95
|
-
function renderFlat(sets, prefix, tokenIndex, warnings) {
|
|
98
|
+
function renderFlat(sets, prefix, tokenIndex, warnings, dialect) {
|
|
96
99
|
const out = {};
|
|
97
100
|
for (const set of sets) {
|
|
98
101
|
const primaryMode = pickPrimaryMode(set.modes);
|
|
@@ -102,7 +105,7 @@ function renderFlat(sets, prefix, tokenIndex, warnings) {
|
|
|
102
105
|
const key = modeName === primaryMode
|
|
103
106
|
? baseName
|
|
104
107
|
: `${baseName}--${slugify(modeName)}`;
|
|
105
|
-
const resolved = resolveValue(value, token, modeName, tokenIndex, warnings);
|
|
108
|
+
const resolved = resolveValue(value, token, modeName, tokenIndex, warnings, dialect);
|
|
106
109
|
if (resolved !== undefined)
|
|
107
110
|
out[key] = resolved;
|
|
108
111
|
}
|
|
@@ -112,7 +115,7 @@ function renderFlat(sets, prefix, tokenIndex, warnings) {
|
|
|
112
115
|
const sorted = Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
|
|
113
116
|
return JSON.stringify(sorted, null, 2) + "\n";
|
|
114
117
|
}
|
|
115
|
-
function renderNested(sets, tokenIndex, warnings) {
|
|
118
|
+
function renderNested(sets, tokenIndex, warnings, dialect) {
|
|
116
119
|
const out = {};
|
|
117
120
|
for (const set of sets) {
|
|
118
121
|
const isMultiMode = set.modes.length > 1;
|
|
@@ -131,7 +134,7 @@ function renderNested(sets, tokenIndex, warnings) {
|
|
|
131
134
|
if (isMultiMode) {
|
|
132
135
|
const modeValues = {};
|
|
133
136
|
for (const [modeName, value] of Object.entries(token.values)) {
|
|
134
|
-
const resolved = resolveValue(value, token, modeName, tokenIndex, warnings);
|
|
137
|
+
const resolved = resolveValue(value, token, modeName, tokenIndex, warnings, dialect);
|
|
135
138
|
if (resolved !== undefined)
|
|
136
139
|
modeValues[modeName] = resolved;
|
|
137
140
|
}
|
|
@@ -140,7 +143,7 @@ function renderNested(sets, tokenIndex, warnings) {
|
|
|
140
143
|
else {
|
|
141
144
|
const [onlyModeName, onlyValue] = Object.entries(token.values)[0] ?? [];
|
|
142
145
|
if (onlyValue) {
|
|
143
|
-
const resolved = resolveValue(onlyValue, token, onlyModeName, tokenIndex, warnings);
|
|
146
|
+
const resolved = resolveValue(onlyValue, token, onlyModeName, tokenIndex, warnings, dialect);
|
|
144
147
|
if (resolved !== undefined)
|
|
145
148
|
cursor[leafKey] = resolved;
|
|
146
149
|
}
|
|
@@ -152,7 +155,7 @@ function renderNested(sets, tokenIndex, warnings) {
|
|
|
152
155
|
function pickPrimaryMode(modes) {
|
|
153
156
|
return modes.find((m) => /^(default|light|value)$/i.test(m)) ?? modes[0];
|
|
154
157
|
}
|
|
155
|
-
function resolveValue(value, token, mode, tokenIndex, warnings) {
|
|
158
|
+
function resolveValue(value, token, mode, tokenIndex, warnings, dialect) {
|
|
156
159
|
let effective = value;
|
|
157
160
|
if (value.reference) {
|
|
158
161
|
effective = resolveAliasChain(value, mode, tokenIndex);
|
|
@@ -168,6 +171,21 @@ function resolveValue(value, token, mode, tokenIndex, warnings) {
|
|
|
168
171
|
if (!effective || effective.literal === undefined || effective.literal === null) {
|
|
169
172
|
return undefined;
|
|
170
173
|
}
|
|
174
|
+
// DTCG 2025.10 dialect (opt-in): colors emit the object form, dimensions
|
|
175
|
+
// emit { value, unit: "px" }. Legacy (default) keeps hex strings and
|
|
176
|
+
// "16px" strings — byte-identical to historical output.
|
|
177
|
+
if (dialect === "2025") {
|
|
178
|
+
if (token.type === "color") {
|
|
179
|
+
const encoded = colorValueTo2025(effective);
|
|
180
|
+
if (encoded)
|
|
181
|
+
return encoded;
|
|
182
|
+
}
|
|
183
|
+
else if (token.type === "dimension") {
|
|
184
|
+
const encoded = dimensionLiteralTo2025(effective.literal);
|
|
185
|
+
if (encoded)
|
|
186
|
+
return encoded;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
171
189
|
if (typeof effective.literal === "number") {
|
|
172
190
|
if (token.type === "dimension")
|
|
173
191
|
return `${effective.literal}px`;
|
|
@@ -11,5 +11,6 @@ export { TokensConfigSchema, loadTokensConfig, findTokensConfig, DEFAULT_TOKENS_
|
|
|
11
11
|
export { ExportTokensInputSchema, ImportTokensInputSchema, ExportFormatSchema, ImportFormatSchema, SyncStrategySchema, ConflictResolutionSchema, } from "./schemas.js";
|
|
12
12
|
export { parse, detectFormat, } from "./parsers/index.js";
|
|
13
13
|
export { format, } from "./formatters/index.js";
|
|
14
|
-
export { buildTokenIndex, resolveReference, resolveAliasChain, validateAliases, formatDtcgReference, parseDtcgReference, referenceTargetPath, slugifySetName, } from "./alias-resolver.js";
|
|
14
|
+
export { buildTokenIndex, buildTokenLookup, resolveReference, resolveAliasChain, validateAliases, formatDtcgReference, parseDtcgReference, referenceTargetPath, slugifySetName, } from "./alias-resolver.js";
|
|
15
15
|
export { convertFigmaVariablesToDocument, } from "./figma-converter.js";
|
|
16
|
+
export { canonicalizeTokenValueForComparison, colorLiteralToCanonicalHex, colorValueTo2025, dimensionLiteralTo2025, hexToRawRgba, stripRawColorFromValues, clamp01, } from "./dialect.js";
|
|
@@ -88,6 +88,10 @@ export const ExportTokensInputSchema = z.object({
|
|
|
88
88
|
.boolean()
|
|
89
89
|
.optional()
|
|
90
90
|
.describe("Emit one file per Figma collection. Default false. Useful when collections map to different runtime themes."),
|
|
91
|
+
dtcgDialect: z
|
|
92
|
+
.enum(["legacy", "2025"])
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("DTCG dialect for dtcg/json-flat/json-nested outputs. legacy (default): hex-string colors, maximum compatibility (Style Dictionary v4, Tokens Studio). 2025: DTCG 2025.10 object colors/dimensions (Style Dictionary v5+). Other formats (css-vars, scss, tailwind, ts-module) render final code and ignore this option."),
|
|
91
95
|
colorFormat: ColorFormatSchema.optional().describe("Color value format in the output. Default: 'hex'. Use 'oklch' for modern Tailwind v4 charts."),
|
|
92
96
|
sizeUnit: SizeUnitSchema.optional().describe("Unit for dimension tokens. Default: 'rem' for web outputs, 'pt' for iOS, 'dp' for Android."),
|
|
93
97
|
remBase: z
|