@servicetitan/hammer-token 2.5.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -2
- package/README.md +332 -0
- package/build/web/core/component-variables.scss +1088 -131
- package/build/web/core/component.d.ts +558 -0
- package/build/web/core/component.js +6685 -249
- package/build/web/core/component.scss +557 -69
- package/build/web/core/css-utils/a2-border.css +23 -51
- package/build/web/core/css-utils/a2-color.css +221 -233
- package/build/web/core/css-utils/a2-font.css +1 -29
- package/build/web/core/css-utils/a2-spacing.css +238 -483
- package/build/web/core/css-utils/a2-utils.css +496 -781
- package/build/web/core/css-utils/border.css +23 -51
- package/build/web/core/css-utils/color.css +221 -233
- package/build/web/core/css-utils/font.css +1 -29
- package/build/web/core/css-utils/spacing.css +238 -483
- package/build/web/core/css-utils/utils.css +496 -781
- package/build/web/core/index.d.ts +6 -0
- package/build/web/core/index.js +1 -1
- package/build/web/core/primitive-variables.scss +148 -65
- package/build/web/core/primitive.d.ts +209 -0
- package/build/web/core/primitive.js +779 -61
- package/build/web/core/primitive.scss +207 -124
- package/build/web/core/semantic-variables.scss +363 -245
- package/build/web/core/semantic.d.ts +221 -0
- package/build/web/core/semantic.js +1592 -347
- package/build/web/core/semantic.scss +219 -140
- package/build/web/index.d.ts +3 -4
- package/build/web/types.d.ts +17 -0
- package/config.js +121 -496
- package/eslint.config.mjs +11 -1
- package/package.json +15 -5
- package/src/global/primitive/breakpoint.tokens.json +54 -0
- package/src/global/primitive/color.tokens.json +1092 -0
- package/src/global/primitive/duration.tokens.json +44 -0
- package/src/global/primitive/font.tokens.json +151 -0
- package/src/global/primitive/radius.tokens.json +94 -0
- package/src/global/primitive/size.tokens.json +174 -0
- package/src/global/primitive/transition.tokens.json +32 -0
- package/src/theme/core/background.tokens.json +1312 -0
- package/src/theme/core/border.tokens.json +192 -0
- package/src/theme/core/chart.tokens.json +982 -0
- package/src/theme/core/component/ai-mark.tokens.json +20 -0
- package/src/theme/core/component/alert.tokens.json +261 -0
- package/src/theme/core/component/announcement.tokens.json +460 -0
- package/src/theme/core/component/avatar.tokens.json +137 -0
- package/src/theme/core/component/badge.tokens.json +42 -0
- package/src/theme/core/component/breadcrumb.tokens.json +42 -0
- package/src/theme/core/component/button-toggle.tokens.json +428 -0
- package/src/theme/core/component/button.tokens.json +941 -0
- package/src/theme/core/component/calendar.tokens.json +391 -0
- package/src/theme/core/component/card.tokens.json +107 -0
- package/src/theme/core/component/checkbox.tokens.json +631 -0
- package/src/theme/core/component/chip.tokens.json +169 -0
- package/src/theme/core/component/combobox.tokens.json +269 -0
- package/src/theme/core/component/details.tokens.json +152 -0
- package/src/theme/core/component/dialog.tokens.json +87 -0
- package/src/theme/core/component/divider.tokens.json +23 -0
- package/src/theme/core/component/dnd.tokens.json +208 -0
- package/src/theme/core/component/drawer.tokens.json +61 -0
- package/src/theme/core/component/drilldown.tokens.json +61 -0
- package/src/theme/core/component/edit-card.tokens.json +381 -0
- package/src/theme/core/component/field-label.tokens.json +42 -0
- package/src/theme/core/component/field-message.tokens.json +65 -0
- package/src/theme/core/component/icon.tokens.json +42 -0
- package/src/theme/core/component/link.tokens.json +108 -0
- package/src/theme/core/component/list-view.tokens.json +82 -0
- package/src/theme/core/component/listbox.tokens.json +283 -0
- package/src/theme/core/component/menu.tokens.json +230 -0
- package/src/theme/core/component/overflow.tokens.json +84 -0
- package/src/theme/core/component/page.tokens.json +377 -0
- package/src/theme/core/component/pagination.tokens.json +63 -0
- package/src/theme/core/component/popover.tokens.json +122 -0
- package/src/theme/core/component/progress-bar.tokens.json +133 -0
- package/src/theme/core/component/radio.tokens.json +631 -0
- package/src/theme/core/component/segmented-control.tokens.json +175 -0
- package/src/theme/core/component/select-card.tokens.json +943 -0
- package/src/theme/core/component/side-nav.tokens.json +349 -0
- package/src/theme/core/component/skeleton.tokens.json +42 -0
- package/src/theme/core/component/spinner.tokens.json +96 -0
- package/src/theme/core/component/status-icon.tokens.json +164 -0
- package/src/theme/core/component/stepper.tokens.json +484 -0
- package/src/theme/core/component/switch.tokens.json +285 -0
- package/src/theme/core/component/tab.tokens.json +192 -0
- package/src/theme/core/component/text-field.tokens.json +160 -0
- package/src/theme/core/component/text.tokens.json +59 -0
- package/src/theme/core/component/toast.tokens.json +343 -0
- package/src/theme/core/component/toolbar.tokens.json +114 -0
- package/src/theme/core/component/tooltip.tokens.json +61 -0
- package/src/theme/core/focus.tokens.json +56 -0
- package/src/theme/core/foreground.tokens.json +416 -0
- package/src/theme/core/gradient.tokens.json +41 -0
- package/src/theme/core/opacity.tokens.json +25 -0
- package/src/theme/core/shadow.tokens.json +81 -0
- package/src/theme/core/status.tokens.json +74 -0
- package/src/theme/core/typography.tokens.json +163 -0
- package/src/utils/__tests__/css-utils-format-utils.test.js +312 -0
- package/src/utils/__tests__/sd-build-configs.test.js +306 -0
- package/src/utils/__tests__/sd-formats.test.js +942 -0
- package/src/utils/__tests__/sd-transforms.test.js +336 -0
- package/src/utils/__tests__/token-helpers.test.js +1160 -0
- package/src/utils/copy-css-utils-cli.js +13 -1
- package/src/utils/css-utils-format-utils.js +105 -176
- package/src/utils/figma/__tests__/sync-gradient.test.js +561 -0
- package/src/utils/figma/__tests__/token-conversion.test.js +117 -0
- package/src/utils/figma/__tests__/token-resolution.test.js +231 -0
- package/src/utils/figma/auth.js +355 -0
- package/src/utils/figma/constants.js +22 -0
- package/src/utils/figma/errors.js +80 -0
- package/src/utils/figma/figma-api.js +1069 -0
- package/src/utils/figma/get-token.js +348 -0
- package/src/utils/figma/sync-components.js +909 -0
- package/src/utils/figma/sync-main.js +692 -0
- package/src/utils/figma/sync-orchestration.js +683 -0
- package/src/utils/figma/sync-primitives.js +230 -0
- package/src/utils/figma/sync-semantic.js +1056 -0
- package/src/utils/figma/token-conversion.js +340 -0
- package/src/utils/figma/token-parsing.js +186 -0
- package/src/utils/figma/token-resolution.js +569 -0
- package/src/utils/figma/utils.js +199 -0
- package/src/utils/sd-build-configs.js +305 -0
- package/src/utils/sd-formats.js +948 -0
- package/src/utils/sd-transforms.js +165 -0
- package/src/utils/token-helpers.js +848 -0
- package/tsconfig.json +18 -0
- package/vitest.config.js +17 -0
- package/.turbo/turbo-build.log +0 -37
- package/build/web/core/raw.js +0 -234
- package/src/global/primitive/breakpoint.js +0 -19
- package/src/global/primitive/color.js +0 -231
- package/src/global/primitive/duration.js +0 -16
- package/src/global/primitive/font.js +0 -60
- package/src/global/primitive/radius.js +0 -31
- package/src/global/primitive/size.js +0 -55
- package/src/global/primitive/transition.js +0 -16
- package/src/theme/core/background.js +0 -170
- package/src/theme/core/border.js +0 -103
- package/src/theme/core/charts.js +0 -464
- package/src/theme/core/component/button.js +0 -708
- package/src/theme/core/component/checkbox.js +0 -405
- package/src/theme/core/focus.js +0 -35
- package/src/theme/core/foreground.js +0 -148
- package/src/theme/core/overlay.js +0 -137
- package/src/theme/core/shadow.js +0 -29
- package/src/theme/core/status.js +0 -49
- package/src/theme/core/typography.js +0 -82
- package/type/types.ts +0 -344
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Figma API Utilities
|
|
5
|
+
*
|
|
6
|
+
* Functions for making requests to the Figma API and managing collections, modes, and variables.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { getAccessToken } = require("./auth");
|
|
12
|
+
|
|
13
|
+
// Debug logging helper
|
|
14
|
+
const DEBUG_LOG_PATH = path.join(__dirname, "../../../../.cursor/debug.log");
|
|
15
|
+
function debugLog(location, message, data, hypothesisId) {
|
|
16
|
+
try {
|
|
17
|
+
const logEntry =
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
location,
|
|
20
|
+
message,
|
|
21
|
+
data,
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
sessionId: "debug-session",
|
|
24
|
+
runId: "run1",
|
|
25
|
+
hypothesisId,
|
|
26
|
+
}) + "\n";
|
|
27
|
+
fs.appendFileSync(DEBUG_LOG_PATH, logEntry);
|
|
28
|
+
} catch (_e) {
|
|
29
|
+
// Silently fail if logging fails
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Configuration
|
|
34
|
+
const FIGMA_API_BASE = "https://api.figma.com/v1";
|
|
35
|
+
|
|
36
|
+
// Rate limit tracking
|
|
37
|
+
const rateLimitInfo = {
|
|
38
|
+
remaining: null,
|
|
39
|
+
resetAt: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sleep for specified milliseconds
|
|
44
|
+
* @param {number} ms - Milliseconds to sleep
|
|
45
|
+
* @returns {Promise} Promise that resolves after the delay
|
|
46
|
+
*/
|
|
47
|
+
function sleep(ms) {
|
|
48
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Make a request to the Figma API with retry logic
|
|
53
|
+
* @param {Object} config - Configuration object
|
|
54
|
+
* @param {string} endpoint - API endpoint
|
|
55
|
+
* @param {Object} options - Request options
|
|
56
|
+
* @param {number} retries - Number of retry attempts
|
|
57
|
+
* @returns {Promise<any>} API response data
|
|
58
|
+
*/
|
|
59
|
+
async function figmaRequest(config, endpoint, options = {}, retries = 3) {
|
|
60
|
+
// Get access token (OAuth2)
|
|
61
|
+
const accessToken = await getAccessToken(config);
|
|
62
|
+
|
|
63
|
+
const url = `${FIGMA_API_BASE}/files/${config.fileKey}${endpoint}`;
|
|
64
|
+
|
|
65
|
+
// OAuth2 uses Bearer token
|
|
66
|
+
const headers = {
|
|
67
|
+
Authorization: `Bearer ${accessToken}`,
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
...options.headers,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
// Build request config, ensuring body is preserved
|
|
75
|
+
const requestConfig = {
|
|
76
|
+
method: options.method || "GET",
|
|
77
|
+
headers,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Only include body for POST/PUT/PATCH requests
|
|
81
|
+
if (
|
|
82
|
+
options.body &&
|
|
83
|
+
(options.method === "POST" ||
|
|
84
|
+
options.method === "PUT" ||
|
|
85
|
+
options.method === "PATCH")
|
|
86
|
+
) {
|
|
87
|
+
requestConfig.body = options.body;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Include any other options (but don't override method, headers, or body)
|
|
91
|
+
for (const [key, value] of Object.entries(options)) {
|
|
92
|
+
if (key !== "method" && key !== "headers" && key !== "body") {
|
|
93
|
+
requestConfig[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Debug logging for POST /variables body parse failures only
|
|
98
|
+
if (
|
|
99
|
+
endpoint === "/variables" &&
|
|
100
|
+
requestConfig.method === "POST" &&
|
|
101
|
+
options.body
|
|
102
|
+
) {
|
|
103
|
+
try {
|
|
104
|
+
if (typeof options.body === "string") {
|
|
105
|
+
JSON.parse(options.body);
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
debugLog(
|
|
109
|
+
"figma-api.js:figmaRequest",
|
|
110
|
+
"POST /variables body parse error",
|
|
111
|
+
{
|
|
112
|
+
error: e.message,
|
|
113
|
+
bodyPreview:
|
|
114
|
+
typeof options.body === "string"
|
|
115
|
+
? options.body.substring(0, 500)
|
|
116
|
+
: String(options.body).substring(0, 500),
|
|
117
|
+
},
|
|
118
|
+
"B3",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const response = await fetch(url, requestConfig);
|
|
124
|
+
|
|
125
|
+
// Update rate limit info from headers
|
|
126
|
+
const rateLimitRemaining = response.headers.get("X-RateLimit-Remaining");
|
|
127
|
+
const rateLimitReset = response.headers.get("X-RateLimit-Reset");
|
|
128
|
+
if (rateLimitRemaining !== null) {
|
|
129
|
+
rateLimitInfo.remaining = parseInt(rateLimitRemaining, 10);
|
|
130
|
+
}
|
|
131
|
+
if (rateLimitReset !== null) {
|
|
132
|
+
rateLimitInfo.resetAt = parseInt(rateLimitReset, 10) * 1000; // Convert to ms
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Handle rate limiting (429)
|
|
136
|
+
if (response.status === 429) {
|
|
137
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
138
|
+
let waitTime;
|
|
139
|
+
|
|
140
|
+
if (retryAfter) {
|
|
141
|
+
waitTime = parseInt(retryAfter, 10) * 1000;
|
|
142
|
+
} else if (rateLimitInfo.resetAt) {
|
|
143
|
+
waitTime = Math.max(0, rateLimitInfo.resetAt - Date.now());
|
|
144
|
+
} else {
|
|
145
|
+
waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff with jitter
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (attempt < retries - 1) {
|
|
149
|
+
console.warn(
|
|
150
|
+
` ⚠️ Rate limited. Waiting ${Math.ceil(waitTime / 1000)}s before retry...`,
|
|
151
|
+
);
|
|
152
|
+
await sleep(waitTime);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check rate limit before making request (preventive)
|
|
158
|
+
if (rateLimitInfo.remaining === 0 && rateLimitInfo.resetAt) {
|
|
159
|
+
const waitTime = Math.max(0, rateLimitInfo.resetAt - Date.now());
|
|
160
|
+
if (waitTime > 0) {
|
|
161
|
+
console.warn(
|
|
162
|
+
` ⚠️ Rate limit reached. Waiting ${Math.ceil(waitTime / 1000)}s...`,
|
|
163
|
+
);
|
|
164
|
+
await sleep(waitTime);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const errorText = await response.text();
|
|
170
|
+
debugLog(
|
|
171
|
+
"figma-api.js:figmaRequest",
|
|
172
|
+
"API error response",
|
|
173
|
+
{
|
|
174
|
+
status: response.status,
|
|
175
|
+
statusText: response.statusText,
|
|
176
|
+
errorText: errorText?.substring(0, 200),
|
|
177
|
+
url,
|
|
178
|
+
fileKey: config.fileKey,
|
|
179
|
+
fileKeyLength: config.fileKey?.length,
|
|
180
|
+
},
|
|
181
|
+
"C",
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
let errorMessage = `Figma API error (${response.status}): ${errorText || response.statusText}`;
|
|
185
|
+
|
|
186
|
+
// Provide helpful error messages
|
|
187
|
+
if (response.status === 401) {
|
|
188
|
+
errorMessage =
|
|
189
|
+
"Authentication failed. Please check your OAuth2 credentials (FIGMA_CLIENT_ID, FIGMA_CLIENT_SECRET, FIGMA_REFRESH_TOKEN).";
|
|
190
|
+
} else if (response.status === 403) {
|
|
191
|
+
// Try to parse error for more details
|
|
192
|
+
let details = "";
|
|
193
|
+
try {
|
|
194
|
+
const errorJson = JSON.parse(errorText);
|
|
195
|
+
if (errorJson.err) {
|
|
196
|
+
details = ` (${errorJson.err})`;
|
|
197
|
+
} else if (errorJson.message) {
|
|
198
|
+
details = ` (${errorJson.message})`;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
if (errorText && errorText.length < 200) {
|
|
202
|
+
details = ` (${errorText})`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
errorMessage =
|
|
207
|
+
`Access forbidden (403). Please check file permissions and token access.${details}\n\n` +
|
|
208
|
+
`This usually means:\n` +
|
|
209
|
+
` 1. Your OAuth2 token doesn't have permission to access this file\n` +
|
|
210
|
+
` 2. The file is in a team/org that requires organization-level access\n` +
|
|
211
|
+
` 3. The token was created by a different account\n` +
|
|
212
|
+
` 4. The token doesn't have the required scopes\n\n` +
|
|
213
|
+
`To fix:\n` +
|
|
214
|
+
` - Verify you can open the file in Figma: https://www.figma.com/design/${config.fileKey}/...\n` +
|
|
215
|
+
` - Verify your OAuth2 app has the required scopes\n` +
|
|
216
|
+
` - Check that the refresh token hasn't expired (re-authenticate if needed)\n`;
|
|
217
|
+
} else if (response.status === 404) {
|
|
218
|
+
// Try to parse error for more details
|
|
219
|
+
let details = "";
|
|
220
|
+
try {
|
|
221
|
+
const errorJson = JSON.parse(errorText);
|
|
222
|
+
if (errorJson.err) {
|
|
223
|
+
details = ` (${errorJson.err})`;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// Not JSON, use raw text
|
|
227
|
+
if (errorText) {
|
|
228
|
+
details = ` (${errorText})`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
debugLog(
|
|
232
|
+
"figma-api.js:figmaRequest",
|
|
233
|
+
"404 error details",
|
|
234
|
+
{
|
|
235
|
+
fileKey: config.fileKey,
|
|
236
|
+
fileKeyLength: config.fileKey?.length,
|
|
237
|
+
details,
|
|
238
|
+
errorText: errorText?.substring(0, 200),
|
|
239
|
+
url,
|
|
240
|
+
errorMessageBefore: errorMessage,
|
|
241
|
+
},
|
|
242
|
+
"D",
|
|
243
|
+
);
|
|
244
|
+
errorMessage = `File not found. Please check the file key: ${config.fileKey}${details}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// In debug mode, show full error
|
|
248
|
+
if (process.env.DEBUG) {
|
|
249
|
+
console.error(`Full API response:`, {
|
|
250
|
+
status: response.status,
|
|
251
|
+
statusText: response.statusText,
|
|
252
|
+
body: errorText,
|
|
253
|
+
url: url,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
throw new Error(errorMessage);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return response.json();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
debugLog(
|
|
263
|
+
"figma-api.js:figmaRequest",
|
|
264
|
+
"figmaRequest catch block",
|
|
265
|
+
{
|
|
266
|
+
attempt,
|
|
267
|
+
retries,
|
|
268
|
+
errorMessage: error.message,
|
|
269
|
+
errorMessageLength: error.message?.length,
|
|
270
|
+
fileKey: config.fileKey,
|
|
271
|
+
isLastAttempt: attempt === retries - 1,
|
|
272
|
+
},
|
|
273
|
+
"E",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// If it's the last attempt or not a network error, throw
|
|
277
|
+
if (
|
|
278
|
+
attempt === retries - 1 ||
|
|
279
|
+
(error.message && !error.message.includes("fetch"))
|
|
280
|
+
) {
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Wait before retrying network errors
|
|
285
|
+
await sleep(Math.pow(2, attempt) * 1000);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Verify file access before attempting to sync
|
|
292
|
+
* @param {Object} config - Configuration object
|
|
293
|
+
* @returns {Promise<boolean>} True if access is granted
|
|
294
|
+
*/
|
|
295
|
+
async function verifyFileAccess(config) {
|
|
296
|
+
try {
|
|
297
|
+
// Try to get file metadata - this will fail if we don't have access.
|
|
298
|
+
// Use ?depth=1 to avoid "Request too large" on large files (Figma requires depth > 0; depth=1 returns only Pages).
|
|
299
|
+
const fileData = await figmaRequest(config, "?depth=1");
|
|
300
|
+
|
|
301
|
+
if (process.env.DEBUG) {
|
|
302
|
+
console.log(` File name: ${fileData.name || "Unknown"}`);
|
|
303
|
+
console.log(` File key: ${config.fileKey}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return true;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error.message.includes("404") || error.message.includes("Not found")) {
|
|
309
|
+
// Check if it's an authentication issue vs access issue
|
|
310
|
+
const isAuthError =
|
|
311
|
+
error.message.includes("401") ||
|
|
312
|
+
error.message.includes("Authentication");
|
|
313
|
+
|
|
314
|
+
if (isAuthError) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Authentication failed for file ${config.fileKey}.\n\n` +
|
|
317
|
+
`Please verify:\n` +
|
|
318
|
+
` 1. Your OAuth2 credentials are correct (FIGMA_CLIENT_ID, FIGMA_CLIENT_SECRET, FIGMA_REFRESH_TOKEN)\n` +
|
|
319
|
+
` 2. The refresh token hasn't expired (re-authenticate if needed)\n` +
|
|
320
|
+
` 3. Your OAuth2 app has the required scopes`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Cannot access file ${config.fileKey}.\n\n` +
|
|
326
|
+
`This file appears to be in a team or organization. Please verify:\n` +
|
|
327
|
+
` 1. Your OAuth2 app was created by an account that has access to this file\n` +
|
|
328
|
+
` 2. You are a member of the team/organization that owns this file\n` +
|
|
329
|
+
` 3. Your OAuth2 app has organization-level access if the file is in an organization\n` +
|
|
330
|
+
` 4. For Enterprise organizations, API access may be restricted - contact your admin\n\n` +
|
|
331
|
+
`To fix:\n` +
|
|
332
|
+
` - Log into Figma and verify you can open this file: https://www.figma.com/design/${config.fileKey}/...\n` +
|
|
333
|
+
` - Verify your OAuth2 app has the required scopes\n` +
|
|
334
|
+
` - Re-authenticate to get a new refresh token if needed`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get or create an extended variable collection
|
|
343
|
+
* @param {Object} config - Configuration object
|
|
344
|
+
* @param {string} parentCollectionId - ID of the parent collection to extend
|
|
345
|
+
* @param {string} extendedCollectionName - Name for the extended collection
|
|
346
|
+
* @returns {Promise<Object>} Extended collection object
|
|
347
|
+
*/
|
|
348
|
+
async function getOrCreateExtendedCollection(
|
|
349
|
+
config,
|
|
350
|
+
parentCollectionId,
|
|
351
|
+
extendedCollectionName,
|
|
352
|
+
) {
|
|
353
|
+
// Get all local variables
|
|
354
|
+
const variablesData = await figmaRequest(config, "/variables/local");
|
|
355
|
+
const collections = variablesData.meta?.variableCollections
|
|
356
|
+
? Object.values(variablesData.meta.variableCollections)
|
|
357
|
+
: [];
|
|
358
|
+
|
|
359
|
+
// Find existing extended collection
|
|
360
|
+
const existingCollection = collections.find(
|
|
361
|
+
(coll) =>
|
|
362
|
+
coll.name === extendedCollectionName &&
|
|
363
|
+
coll.parentVariableCollectionId === parentCollectionId,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
if (existingCollection) {
|
|
367
|
+
return existingCollection;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Create new extended collection
|
|
371
|
+
const tempCollectionId = "temp_extended_collection_" + Date.now();
|
|
372
|
+
const response = await figmaRequest(config, "/variables", {
|
|
373
|
+
method: "POST",
|
|
374
|
+
body: JSON.stringify({
|
|
375
|
+
variableCollections: [
|
|
376
|
+
{
|
|
377
|
+
action: "CREATE",
|
|
378
|
+
id: tempCollectionId,
|
|
379
|
+
name: extendedCollectionName,
|
|
380
|
+
parentVariableCollectionId: parentCollectionId,
|
|
381
|
+
isExtension: true,
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
}),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const realCollectionId = response.meta?.tempIdToRealId?.[tempCollectionId];
|
|
388
|
+
if (!realCollectionId) {
|
|
389
|
+
throw new Error("Failed to create extended collection: no ID returned");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Fetch the newly created collection to get full details
|
|
393
|
+
const updatedVariablesData = await figmaRequest(config, "/variables/local");
|
|
394
|
+
const updatedCollections = updatedVariablesData.meta?.variableCollections
|
|
395
|
+
? Object.values(updatedVariablesData.meta.variableCollections)
|
|
396
|
+
: [];
|
|
397
|
+
const newCollection = updatedCollections.find(
|
|
398
|
+
(coll) => coll.id === realCollectionId,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
if (!newCollection) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`Failed to retrieve newly created extended collection with ID: ${realCollectionId}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return newCollection;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Get or create variable collection
|
|
412
|
+
* @param {Object} config - Configuration object
|
|
413
|
+
* @returns {Promise<Object>} Collection object
|
|
414
|
+
*/
|
|
415
|
+
async function getOrCreateCollection(config) {
|
|
416
|
+
// Get all local variables
|
|
417
|
+
// This will fail with 404 if file doesn't exist or token doesn't have access
|
|
418
|
+
try {
|
|
419
|
+
const variablesData = await figmaRequest(config, "/variables/local");
|
|
420
|
+
|
|
421
|
+
// variableCollections is a dictionary (object) keyed by collection ID, convert to array
|
|
422
|
+
const collections = variablesData.meta?.variableCollections
|
|
423
|
+
? Object.values(variablesData.meta.variableCollections)
|
|
424
|
+
: [];
|
|
425
|
+
|
|
426
|
+
// Find existing collection
|
|
427
|
+
const existingCollection = collections.find(
|
|
428
|
+
(coll) => coll.name === config.collectionName,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (existingCollection) {
|
|
432
|
+
return existingCollection;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Create new collection using the bulk operations endpoint
|
|
436
|
+
const tempCollectionId = "temp_collection_" + Date.now();
|
|
437
|
+
const response = await figmaRequest(config, "/variables", {
|
|
438
|
+
method: "POST",
|
|
439
|
+
body: JSON.stringify({
|
|
440
|
+
variableCollections: [
|
|
441
|
+
{
|
|
442
|
+
action: "CREATE",
|
|
443
|
+
id: tempCollectionId,
|
|
444
|
+
name: config.collectionName,
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
}),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Get the real collection ID from the response
|
|
451
|
+
const realCollectionId = response.meta?.tempIdToRealId?.[tempCollectionId];
|
|
452
|
+
if (!realCollectionId) {
|
|
453
|
+
throw new Error("Failed to create collection: no ID returned");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Fetch the newly created collection to get full details
|
|
457
|
+
const updatedVariablesData = await figmaRequest(config, "/variables/local");
|
|
458
|
+
const updatedCollections = updatedVariablesData.meta?.variableCollections
|
|
459
|
+
? Object.values(updatedVariablesData.meta.variableCollections)
|
|
460
|
+
: [];
|
|
461
|
+
const newCollection = updatedCollections.find(
|
|
462
|
+
(coll) => coll.id === realCollectionId,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (!newCollection) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Failed to retrieve newly created collection with ID: ${realCollectionId}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return newCollection;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
debugLog(
|
|
474
|
+
"figma-api.js:getOrCreateCollection",
|
|
475
|
+
"getOrCreateCollection error",
|
|
476
|
+
{ errorMessage: error.message, fileKey: config.fileKey },
|
|
477
|
+
"F6",
|
|
478
|
+
);
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Get or create variable modes (Light/Dark)
|
|
485
|
+
* @param {Object} config - Configuration object
|
|
486
|
+
* @param {string} collectionId - Collection ID
|
|
487
|
+
* @returns {Promise<Object>} Object with light and dark mode objects
|
|
488
|
+
*/
|
|
489
|
+
async function getOrCreateModes(config, collectionId) {
|
|
490
|
+
// Get collection details to see existing modes
|
|
491
|
+
const variablesData = await figmaRequest(config, "/variables/local");
|
|
492
|
+
|
|
493
|
+
// variableCollections is a dictionary (object) keyed by collection ID
|
|
494
|
+
const collections = variablesData.meta?.variableCollections || {};
|
|
495
|
+
const collection = collections[collectionId];
|
|
496
|
+
|
|
497
|
+
const modes = collection?.modes || [];
|
|
498
|
+
const lightMode = modes.find((m) => m.name === config.lightModeName);
|
|
499
|
+
const darkMode = modes.find((m) => m.name === config.darkModeName);
|
|
500
|
+
|
|
501
|
+
const createdModes = {};
|
|
502
|
+
|
|
503
|
+
// Handle Light mode: if it doesn't exist, check if there's a default mode to rename
|
|
504
|
+
if (!lightMode) {
|
|
505
|
+
// Check if there's a default mode (usually "Mode 1" or similar default name)
|
|
506
|
+
// When Figma creates a collection, it automatically creates a default mode
|
|
507
|
+
const defaultMode = modes.find(
|
|
508
|
+
(m) =>
|
|
509
|
+
m.name === "Mode 1" ||
|
|
510
|
+
m.name === "Default" ||
|
|
511
|
+
(modes.length === 1 && m.name !== config.darkModeName),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
if (defaultMode && defaultMode.modeId) {
|
|
515
|
+
// Rename the default mode to "Light" instead of creating a new one
|
|
516
|
+
await figmaRequest(config, "/variables", {
|
|
517
|
+
method: "POST",
|
|
518
|
+
body: JSON.stringify({
|
|
519
|
+
variableModes: [
|
|
520
|
+
{
|
|
521
|
+
action: "UPDATE",
|
|
522
|
+
id: defaultMode.modeId,
|
|
523
|
+
name: config.lightModeName,
|
|
524
|
+
variableCollectionId: collectionId,
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
}),
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Fetch the updated collection to get the renamed mode
|
|
531
|
+
const updatedVariablesData = await figmaRequest(
|
|
532
|
+
config,
|
|
533
|
+
"/variables/local",
|
|
534
|
+
);
|
|
535
|
+
const updatedCollections =
|
|
536
|
+
updatedVariablesData.meta?.variableCollections || {};
|
|
537
|
+
const updatedCollection = updatedCollections[collectionId];
|
|
538
|
+
const renamedLightMode = updatedCollection?.modes?.find(
|
|
539
|
+
(m) => m.modeId === defaultMode.modeId,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (!renamedLightMode) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`Failed to retrieve renamed light mode with ID: ${defaultMode.modeId}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Map modeId to id for backward compatibility with existing code
|
|
549
|
+
createdModes.light = {
|
|
550
|
+
...renamedLightMode,
|
|
551
|
+
id: renamedLightMode.id || renamedLightMode.modeId,
|
|
552
|
+
};
|
|
553
|
+
} else {
|
|
554
|
+
// No default mode found, create a new Light mode
|
|
555
|
+
const tempModeId = "temp_light_mode_" + Date.now();
|
|
556
|
+
const response = await figmaRequest(config, "/variables", {
|
|
557
|
+
method: "POST",
|
|
558
|
+
body: JSON.stringify({
|
|
559
|
+
variableModes: [
|
|
560
|
+
{
|
|
561
|
+
action: "CREATE",
|
|
562
|
+
id: tempModeId,
|
|
563
|
+
name: config.lightModeName,
|
|
564
|
+
variableCollectionId: collectionId,
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
}),
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const realModeId = response.meta?.tempIdToRealId?.[tempModeId];
|
|
571
|
+
if (!realModeId) {
|
|
572
|
+
throw new Error("Failed to create light mode: no ID returned");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Fetch the updated collection to get the new mode
|
|
576
|
+
const updatedVariablesData = await figmaRequest(
|
|
577
|
+
config,
|
|
578
|
+
"/variables/local",
|
|
579
|
+
);
|
|
580
|
+
const updatedCollections =
|
|
581
|
+
updatedVariablesData.meta?.variableCollections || {};
|
|
582
|
+
const updatedCollection = updatedCollections[collectionId];
|
|
583
|
+
const newLightMode = updatedCollection?.modes?.find(
|
|
584
|
+
(m) => m.modeId === realModeId,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (!newLightMode) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Failed to retrieve newly created light mode with ID: ${realModeId}`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Map modeId to id for backward compatibility with existing code
|
|
594
|
+
createdModes.light = {
|
|
595
|
+
...newLightMode,
|
|
596
|
+
id: newLightMode.id || newLightMode.modeId,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
// Map modeId to id for backward compatibility with existing code
|
|
601
|
+
createdModes.light = { ...lightMode, id: lightMode.id || lightMode.modeId };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Create Dark mode if it doesn't exist
|
|
605
|
+
if (!darkMode) {
|
|
606
|
+
const tempModeId = "temp_dark_mode_" + Date.now();
|
|
607
|
+
const response = await figmaRequest(config, "/variables", {
|
|
608
|
+
method: "POST",
|
|
609
|
+
body: JSON.stringify({
|
|
610
|
+
variableModes: [
|
|
611
|
+
{
|
|
612
|
+
action: "CREATE",
|
|
613
|
+
id: tempModeId,
|
|
614
|
+
name: config.darkModeName,
|
|
615
|
+
variableCollectionId: collectionId,
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
}),
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const realModeId = response.meta?.tempIdToRealId?.[tempModeId];
|
|
622
|
+
if (!realModeId) {
|
|
623
|
+
throw new Error("Failed to create dark mode: no ID returned");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Fetch the updated collection to get the new mode
|
|
627
|
+
const updatedVariablesData = await figmaRequest(config, "/variables/local");
|
|
628
|
+
const updatedCollections =
|
|
629
|
+
updatedVariablesData.meta?.variableCollections || {};
|
|
630
|
+
const updatedCollection = updatedCollections[collectionId];
|
|
631
|
+
const newDarkMode = updatedCollection?.modes?.find(
|
|
632
|
+
(m) => m.modeId === realModeId,
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
if (!newDarkMode) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`Failed to retrieve newly created dark mode with ID: ${realModeId}`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Map modeId to id for backward compatibility with existing code
|
|
642
|
+
createdModes.dark = {
|
|
643
|
+
...newDarkMode,
|
|
644
|
+
id: newDarkMode.id || newDarkMode.modeId,
|
|
645
|
+
};
|
|
646
|
+
} else {
|
|
647
|
+
// Map modeId to id for backward compatibility with existing code
|
|
648
|
+
createdModes.dark = { ...darkMode, id: darkMode.id || darkMode.modeId };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Clean up any unwanted modes (e.g., default "mode 1" created by Figma)
|
|
652
|
+
// Fetch the collection again to get all current modes
|
|
653
|
+
const finalVariablesData = await figmaRequest(config, "/variables/local");
|
|
654
|
+
const finalCollections = finalVariablesData.meta?.variableCollections || {};
|
|
655
|
+
const finalCollection = finalCollections[collectionId];
|
|
656
|
+
const allModes = finalCollection?.modes || [];
|
|
657
|
+
|
|
658
|
+
// Find modes that aren't Light or Dark
|
|
659
|
+
const unwantedModes = allModes.filter(
|
|
660
|
+
(m) => m.name !== config.lightModeName && m.name !== config.darkModeName,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// Delete unwanted modes (only if they have valid modeIds)
|
|
664
|
+
// Note: mode.id might be a node ID (like "5:0") which is not valid for variable mode operations
|
|
665
|
+
// We must use mode.modeId for variable mode operations
|
|
666
|
+
if (unwantedModes.length > 0) {
|
|
667
|
+
const modeDeletions = unwantedModes
|
|
668
|
+
.map((mode) => {
|
|
669
|
+
const modeId = mode.modeId; // Only use modeId, not mode.id (which might be a node ID)
|
|
670
|
+
// Skip if modeId is missing or invalid
|
|
671
|
+
// Note: Figma mode IDs are typically in "X:Y" format (e.g., "19:0"), which is valid for deletion
|
|
672
|
+
if (!modeId) {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
return { action: "DELETE", id: modeId };
|
|
676
|
+
})
|
|
677
|
+
.filter((deletion) => deletion !== null);
|
|
678
|
+
|
|
679
|
+
if (modeDeletions.length > 0) {
|
|
680
|
+
try {
|
|
681
|
+
await figmaRequest(config, "/variables", {
|
|
682
|
+
method: "POST",
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
variableModes: modeDeletions,
|
|
685
|
+
}),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
console.log(
|
|
689
|
+
` 🗑️ Removed ${modeDeletions.length} unwanted mode(s): ${unwantedModes.map((m) => m.name).join(", ")}`,
|
|
690
|
+
);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
// Log but don't fail the entire sync if mode deletion fails
|
|
693
|
+
console.warn(
|
|
694
|
+
` ⚠️ Failed to remove unwanted modes: ${error.message}`,
|
|
695
|
+
);
|
|
696
|
+
if (process.env.DEBUG) {
|
|
697
|
+
console.warn(
|
|
698
|
+
` Unwanted modes: ${JSON.stringify(unwantedModes, null, 2)}`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
console.warn(
|
|
704
|
+
` ⚠️ Found ${unwantedModes.length} unwanted mode(s) but none have valid IDs to delete`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return createdModes;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Create or update a variable (DEPRECATED - use buildBulkVariableRequest instead)
|
|
714
|
+
* @param {Object} config - Configuration object
|
|
715
|
+
* @param {string} collectionId - Collection ID
|
|
716
|
+
* @param {Object} modes - Modes object
|
|
717
|
+
* @param {string} variableName - Variable name
|
|
718
|
+
* @param {string} variableType - Variable type
|
|
719
|
+
* @param {any} lightValue - Light mode value
|
|
720
|
+
* @param {any} darkValue - Dark mode value
|
|
721
|
+
* @param {Map} resolvedTokens - Resolved tokens map
|
|
722
|
+
* @param {Array} existingVariables - Existing variables array
|
|
723
|
+
* @returns {Promise<Object>} Result object with action and variable
|
|
724
|
+
*/
|
|
725
|
+
async function createOrUpdateVariable(
|
|
726
|
+
config,
|
|
727
|
+
collectionId,
|
|
728
|
+
modes,
|
|
729
|
+
variableName,
|
|
730
|
+
variableType,
|
|
731
|
+
lightValue,
|
|
732
|
+
darkValue,
|
|
733
|
+
resolvedTokens,
|
|
734
|
+
existingVariables,
|
|
735
|
+
) {
|
|
736
|
+
// This function is deprecated but kept for backward compatibility
|
|
737
|
+
// It uses functions from other modules that need to be imported
|
|
738
|
+
const {
|
|
739
|
+
isReference,
|
|
740
|
+
extractReferencePath,
|
|
741
|
+
resolveReferencePath,
|
|
742
|
+
} = require("./token-resolution");
|
|
743
|
+
const { convertTokenValueToFigma } = require("./token-conversion");
|
|
744
|
+
|
|
745
|
+
// Check if this should be an alias (for semantic/component tokens)
|
|
746
|
+
const isLightReference = isReference(lightValue);
|
|
747
|
+
const isDarkReference = isReference(darkValue);
|
|
748
|
+
|
|
749
|
+
let lightAliasId = null;
|
|
750
|
+
let darkAliasId = null;
|
|
751
|
+
|
|
752
|
+
// Resolve alias targets (only for non-primitive tokens)
|
|
753
|
+
const tokenData = resolvedTokens.get(variableName);
|
|
754
|
+
const isPrimitive = tokenData?.isPrimitive ?? false;
|
|
755
|
+
|
|
756
|
+
if (!isPrimitive && isLightReference) {
|
|
757
|
+
const refPath = extractReferencePath(lightValue);
|
|
758
|
+
const targetPath = resolveReferencePath(refPath, resolvedTokens);
|
|
759
|
+
// Use the local getVariableIdByName function
|
|
760
|
+
const variable = existingVariables.find(
|
|
761
|
+
(v) => v.name === targetPath || v.key === targetPath,
|
|
762
|
+
);
|
|
763
|
+
lightAliasId = variable?.id || null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!isPrimitive && isDarkReference) {
|
|
767
|
+
const refPath = extractReferencePath(darkValue);
|
|
768
|
+
const targetPath = resolveReferencePath(refPath, resolvedTokens);
|
|
769
|
+
// Use the local getVariableIdByName function
|
|
770
|
+
const variable = existingVariables.find(
|
|
771
|
+
(v) => v.name === targetPath || v.key === targetPath,
|
|
772
|
+
);
|
|
773
|
+
darkAliasId = variable?.id || null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Get existing variable
|
|
777
|
+
const existing = existingVariables.find(
|
|
778
|
+
(v) => v.name === variableName || v.key === variableName,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
// Convert values for comparison/creation
|
|
782
|
+
let lightFigmaValue;
|
|
783
|
+
let darkFigmaValue;
|
|
784
|
+
|
|
785
|
+
const tokenScopes = tokenData?.token?.$extensions?.["com.figma.scopes"] ?? [];
|
|
786
|
+
|
|
787
|
+
if (lightAliasId) {
|
|
788
|
+
lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
|
|
789
|
+
} else {
|
|
790
|
+
const converted = convertTokenValueToFigma(variableType, lightValue, {
|
|
791
|
+
variableName,
|
|
792
|
+
scopes: tokenScopes,
|
|
793
|
+
});
|
|
794
|
+
if (converted === null) {
|
|
795
|
+
throw new Error(`Could not convert light value for ${variableName}`);
|
|
796
|
+
}
|
|
797
|
+
lightFigmaValue = converted;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (darkAliasId) {
|
|
801
|
+
darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
|
|
802
|
+
} else {
|
|
803
|
+
const converted = convertTokenValueToFigma(variableType, darkValue, {
|
|
804
|
+
variableName,
|
|
805
|
+
scopes: tokenScopes,
|
|
806
|
+
});
|
|
807
|
+
if (converted === null) {
|
|
808
|
+
throw new Error(`Could not convert dark value for ${variableName}`);
|
|
809
|
+
}
|
|
810
|
+
darkFigmaValue = converted;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (existing) {
|
|
814
|
+
// Check if values are the same (by actual value; normalizes Figma vs our color shape and alias key order)
|
|
815
|
+
const existingValues = existing.valuesByMode || {};
|
|
816
|
+
const existingLight = existingValues[modes.light.id];
|
|
817
|
+
const existingDark = existingValues[modes.dark.id];
|
|
818
|
+
|
|
819
|
+
const lightSame = valuesEqual(existingLight, lightFigmaValue);
|
|
820
|
+
const darkSame = valuesEqual(existingDark, darkFigmaValue);
|
|
821
|
+
|
|
822
|
+
if (lightSame && darkSame) {
|
|
823
|
+
return { action: "skipped", variable: existing };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Update variable
|
|
827
|
+
await figmaRequest(config, `/variables/${existing.id}`, {
|
|
828
|
+
method: "PUT",
|
|
829
|
+
body: JSON.stringify({
|
|
830
|
+
name: variableName,
|
|
831
|
+
valuesByMode: {
|
|
832
|
+
[modes.light.id]: lightFigmaValue,
|
|
833
|
+
[modes.dark.id]: darkFigmaValue,
|
|
834
|
+
},
|
|
835
|
+
}),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
return { action: "updated", variable: existing };
|
|
839
|
+
} else {
|
|
840
|
+
// Create new variable
|
|
841
|
+
const response = await figmaRequest(config, "/variables", {
|
|
842
|
+
method: "POST",
|
|
843
|
+
body: JSON.stringify({
|
|
844
|
+
name: variableName,
|
|
845
|
+
variableCollectionId: collectionId,
|
|
846
|
+
resolvedType: (() => {
|
|
847
|
+
switch (variableType) {
|
|
848
|
+
case "color":
|
|
849
|
+
return "COLOR";
|
|
850
|
+
case "dimension":
|
|
851
|
+
case "duration":
|
|
852
|
+
case "number":
|
|
853
|
+
return "FLOAT";
|
|
854
|
+
case "string":
|
|
855
|
+
return "STRING";
|
|
856
|
+
default:
|
|
857
|
+
return "STRING";
|
|
858
|
+
}
|
|
859
|
+
})(),
|
|
860
|
+
valuesByMode: {
|
|
861
|
+
[modes.light.id]: lightFigmaValue,
|
|
862
|
+
[modes.dark.id]: darkFigmaValue,
|
|
863
|
+
},
|
|
864
|
+
}),
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const newVariable = response.meta.variable;
|
|
868
|
+
// Add to cache for future alias resolution
|
|
869
|
+
existingVariables.push(newVariable);
|
|
870
|
+
|
|
871
|
+
return { action: "created", variable: newVariable };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Normalize a color value to { r, g, b, a } for comparison.
|
|
877
|
+
* Accepts our format { r, g, b, a } or Figma's format { components, alpha, hex }.
|
|
878
|
+
* @param {any} val - Color object
|
|
879
|
+
* @returns {{ r: number, g: number, b: number, a: number } | null} Normalized RGBA or null if not a color
|
|
880
|
+
*/
|
|
881
|
+
function normalizeColorForCompare(val) {
|
|
882
|
+
if (!val || typeof val !== "object") return null;
|
|
883
|
+
// Our format: { r, g, b, a }
|
|
884
|
+
if (val.r !== undefined && val.g !== undefined && val.b !== undefined) {
|
|
885
|
+
return {
|
|
886
|
+
r: Number(val.r),
|
|
887
|
+
g: Number(val.g),
|
|
888
|
+
b: Number(val.b),
|
|
889
|
+
a: Number(val.a ?? 1),
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
// Figma format: { components: [r,g,b], alpha, hex }
|
|
893
|
+
if (Array.isArray(val.components) && val.components.length >= 3) {
|
|
894
|
+
return {
|
|
895
|
+
r: Number(val.components[0]),
|
|
896
|
+
g: Number(val.components[1]),
|
|
897
|
+
b: Number(val.components[2]),
|
|
898
|
+
a: Number(val.alpha ?? 1),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Compare two Figma variable values by actual value, not object structure.
|
|
906
|
+
* Colors: normalizes both (our { r,g,b,a } or Figma's { components, alpha, hex }) and compares r,g,b,a with tolerance.
|
|
907
|
+
* Aliases: compares type and id so key order does not matter.
|
|
908
|
+
* @param {any} val1 - First value (e.g. from Figma API)
|
|
909
|
+
* @param {any} val2 - Second value (e.g. our computed value)
|
|
910
|
+
* @returns {boolean} True if values are equal
|
|
911
|
+
*/
|
|
912
|
+
function valuesEqual(val1, val2) {
|
|
913
|
+
if (typeof val1 !== typeof val2) return false;
|
|
914
|
+
|
|
915
|
+
if (typeof val1 === "object" && val1 !== null && val2 !== null) {
|
|
916
|
+
const norm1 = normalizeColorForCompare(val1);
|
|
917
|
+
const norm2 = normalizeColorForCompare(val2);
|
|
918
|
+
if (norm1 !== null && norm2 !== null) {
|
|
919
|
+
return (
|
|
920
|
+
Math.abs(norm1.r - norm2.r) < 0.001 &&
|
|
921
|
+
Math.abs(norm1.g - norm2.g) < 0.001 &&
|
|
922
|
+
Math.abs(norm1.b - norm2.b) < 0.001 &&
|
|
923
|
+
Math.abs(norm1.a - norm2.a) < 0.001
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
// VARIABLE_ALIAS: compare by type and id only (ignore key order)
|
|
927
|
+
if (
|
|
928
|
+
val1.type === "VARIABLE_ALIAS" &&
|
|
929
|
+
val2.type === "VARIABLE_ALIAS" &&
|
|
930
|
+
val1.id !== undefined &&
|
|
931
|
+
val2.id !== undefined
|
|
932
|
+
) {
|
|
933
|
+
return String(val1.id) === String(val2.id);
|
|
934
|
+
}
|
|
935
|
+
// Other objects (e.g. dimensions): structural comparison
|
|
936
|
+
return JSON.stringify(val1) === JSON.stringify(val2);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Numbers: compare with tolerance so float precision (e.g. 0.6 vs 0.6000000238418579) doesn't force updates
|
|
940
|
+
// Figma FLOAT can differ by ~2e-8; use 1e-6 so opacity/dimension values match
|
|
941
|
+
const numericTolerance = 1e-6;
|
|
942
|
+
if (typeof val1 === "number" && typeof val2 === "number") {
|
|
943
|
+
return Math.abs(val1 - val2) < numericTolerance;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Figma may return FLOAT as string (e.g. "0.6") while we send number — treat as equal if numeric
|
|
947
|
+
const num1 = typeof val1 === "number" ? val1 : parseFloat(val1);
|
|
948
|
+
const num2 = typeof val2 === "number" ? val2 : parseFloat(val2);
|
|
949
|
+
if (!Number.isNaN(num1) && !Number.isNaN(num2)) {
|
|
950
|
+
return Math.abs(num1 - num2) < numericTolerance;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return val1 === val2;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Get variable ID by name from existing variables cache
|
|
958
|
+
* Optimized: Use Map for O(1) lookup instead of array search
|
|
959
|
+
* @param {string} variableName - Variable name to find
|
|
960
|
+
* @param {Array} existingVariables - Array of existing variables
|
|
961
|
+
* @returns {string|null} Variable ID or null if not found
|
|
962
|
+
*/
|
|
963
|
+
function getVariableIdByName(variableName, existingVariables) {
|
|
964
|
+
// Optimize: Create a Map for O(1) lookups if we have many variables
|
|
965
|
+
// For small arrays, linear search is fine, but for large arrays, Map is better
|
|
966
|
+
if (existingVariables.length > 100) {
|
|
967
|
+
// Create a Map for efficient lookups
|
|
968
|
+
const variableMap = new Map();
|
|
969
|
+
for (const v of existingVariables) {
|
|
970
|
+
if (v.name) variableMap.set(v.name, v.id);
|
|
971
|
+
if (v.key && v.key !== v.name) variableMap.set(v.key, v.id);
|
|
972
|
+
}
|
|
973
|
+
return variableMap.get(variableName) || null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// For smaller arrays, use linear search (simpler and faster for small N)
|
|
977
|
+
const variable = existingVariables.find(
|
|
978
|
+
(v) => v.name === variableName || v.key === variableName,
|
|
979
|
+
);
|
|
980
|
+
return variable?.id || null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Determine the variable name for a token path by checking token data and existing variables
|
|
985
|
+
* @param {string} targetPath - Token path (e.g., "color/neutral/0")
|
|
986
|
+
* @param {Map} resolvedTokensForRefs - Map of resolved tokens for reference resolution
|
|
987
|
+
* @param {Array} existingVariables - Array of existing Figma variables
|
|
988
|
+
* @param {Map} tempIdMap - Temporary ID map for tracking created variables
|
|
989
|
+
* @param {Function} isComponentToken - Function to check if a token is a component token
|
|
990
|
+
* @returns {string} Variable name with correct prefix (primitive/, semantic/, or component/)
|
|
991
|
+
*/
|
|
992
|
+
function determineVariableName(
|
|
993
|
+
targetPath,
|
|
994
|
+
resolvedTokensForRefs,
|
|
995
|
+
existingVariables,
|
|
996
|
+
tempIdMap,
|
|
997
|
+
isComponentToken,
|
|
998
|
+
) {
|
|
999
|
+
// First, check if token exists in resolvedTokensForRefs
|
|
1000
|
+
const targetTokenData = resolvedTokensForRefs.get(targetPath);
|
|
1001
|
+
if (targetTokenData) {
|
|
1002
|
+
if (targetTokenData.isPrimitive) {
|
|
1003
|
+
return `primitive/${targetPath}`;
|
|
1004
|
+
}
|
|
1005
|
+
if (isComponentToken && isComponentToken(targetTokenData.sourceFilePath)) {
|
|
1006
|
+
return `component/${targetPath}`;
|
|
1007
|
+
}
|
|
1008
|
+
return `semantic/${targetPath}`;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Token not in registry - check existing variables to determine type
|
|
1012
|
+
// Check in order: primitive, component, semantic (most common fallback)
|
|
1013
|
+
const primitiveVarName = `primitive/${targetPath}`;
|
|
1014
|
+
const componentVarName = `component/${targetPath}`;
|
|
1015
|
+
const semanticVarName = `semantic/${targetPath}`;
|
|
1016
|
+
const primitiveId = getVariableIdByName(primitiveVarName, existingVariables);
|
|
1017
|
+
const componentId = getVariableIdByName(componentVarName, existingVariables);
|
|
1018
|
+
const primitiveInTemp = tempIdMap.has(primitiveVarName);
|
|
1019
|
+
const componentInTemp = tempIdMap.has(componentVarName);
|
|
1020
|
+
if (primitiveId || primitiveInTemp) {
|
|
1021
|
+
return primitiveVarName;
|
|
1022
|
+
}
|
|
1023
|
+
if (componentId || componentInTemp) {
|
|
1024
|
+
return componentVarName;
|
|
1025
|
+
}
|
|
1026
|
+
// Default to semantic if not found (backward compatibility)
|
|
1027
|
+
return semanticVarName;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Compare two token values to see if they differ
|
|
1032
|
+
* @param {any} value1 - First value
|
|
1033
|
+
* @param {any} value2 - Second value
|
|
1034
|
+
* @returns {boolean} True if values are different
|
|
1035
|
+
*/
|
|
1036
|
+
function tokenValuesDiffer(value1, value2) {
|
|
1037
|
+
if (value1 === value2) return false;
|
|
1038
|
+
if (value1 == null || value2 == null) return true;
|
|
1039
|
+
|
|
1040
|
+
// Deep comparison for objects
|
|
1041
|
+
if (typeof value1 === "object" && typeof value2 === "object") {
|
|
1042
|
+
const keys1 = Object.keys(value1).sort();
|
|
1043
|
+
const keys2 = Object.keys(value2).sort();
|
|
1044
|
+
if (keys1.length !== keys2.length) return true;
|
|
1045
|
+
|
|
1046
|
+
for (const key of keys1) {
|
|
1047
|
+
if (!keys2.includes(key)) return true;
|
|
1048
|
+
if (tokenValuesDiffer(value1[key], value2[key])) return true;
|
|
1049
|
+
}
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
module.exports = {
|
|
1057
|
+
figmaRequest,
|
|
1058
|
+
verifyFileAccess,
|
|
1059
|
+
getOrCreateCollection,
|
|
1060
|
+
getOrCreateExtendedCollection,
|
|
1061
|
+
getOrCreateModes,
|
|
1062
|
+
createOrUpdateVariable,
|
|
1063
|
+
valuesEqual,
|
|
1064
|
+
getVariableIdByName,
|
|
1065
|
+
determineVariableName,
|
|
1066
|
+
tokenValuesDiffer,
|
|
1067
|
+
debugLog,
|
|
1068
|
+
sleep,
|
|
1069
|
+
};
|