cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
5260f1a5703bb69e6c7f7cf0ce8033a561cac8b5

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

packages/kumo-figma/scripts/sync-tokens-to-figma.ts

525lines · modecode

1/**
2 * Figma Token Sync - Unidirectional sync from code to Figma
3 *
4 * This script uses config.ts as the single source of truth for design tokens.
5 * It purges all existing variables and recreates them from the config.
6 *
7 * Usage:
8 * FIGMA_TOKEN="your-token" pnpm --filter @cloudflare/kumo-figma figma:sync
9 * FIGMA_TOKEN="your-token" pnpm --filter @cloudflare/kumo-figma figma:sync get
10 *
11 * Environment Variables:
12 * FIGMA_TOKEN (required) - Figma personal access token
13 * FIGMA_FILE_KEY (optional) - Target Figma file, defaults to kumo file
14 */
15
16import { readFileSync, existsSync } from "node:fs";
17import { dirname, resolve } from "node:path";
18import { fileURLToPath } from "node:url";
19import {
20 THEME_CONFIG,
21 AVAILABLE_THEMES,
22} from "../../kumo/scripts/theme-generator/config.js";
23import type { TokenDefinition } from "../../kumo/scripts/theme-generator/types.js";
24import { resolveColor } from "./color-utils.js";
25import {
26 syncAllToFigma,
27 getLocalVariables,
28 type ResolvedToken,
29 type ResolvedTypographyToken,
30 type ExtendedMode,
31 type FigmaColorInput,
32} from "./figma-api.js";
33
34const __dirname = dirname(fileURLToPath(import.meta.url));
35const ENV_PATH = resolve(__dirname, ".env");
36
37// Load .env file if it exists
38if (existsSync(ENV_PATH)) {
39 const envContent = readFileSync(ENV_PATH, "utf-8");
40 for (const line of envContent.split("\n")) {
41 const trimmed = line.trim();
42 if (trimmed && !trimmed.startsWith("#")) {
43 const [key, ...valueParts] = trimmed.split("=");
44 const value = valueParts.join("=").replace(/^["']|["']$/g, "");
45 if (key && value && !process.env[key]) {
46 process.env[key] = value;
47 }
48 }
49 }
50}
51
52// Read environment variables
53const FIGMA_TOKEN = process.env.FIGMA_TOKEN;
54const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY || "sKKZc6pC6W1TtzWBLxDGSU";
55
56/**
57 * Parse CLI arguments
58 */
59function parseArgs(): { command: "sync" | "get"; collection?: string } {
60 const args = process.argv.slice(2);
61 let command: "sync" | "get" = "sync";
62 let collection: string | undefined;
63
64 for (let i = 0; i < args.length; i++) {
65 const arg = args[i];
66 if (arg === "get") {
67 command = "get";
68 } else if (arg === "--collection" && args[i + 1]) {
69 collection = args[++i];
70 } else if (arg === "--help" || arg === "-h") {
71 printHelp();
72 process.exit(0);
73 }
74 }
75
76 return { command, collection };
77}
78
79/**
80 * Print help message
81 */
82function printHelp(): void {
83 console.log(`
84Figma Token Sync - Unidirectional sync from code to Figma
85
86This script uses config.ts as the SINGLE SOURCE OF TRUTH for design tokens.
87Running sync will PURGE all existing variables and recreate them.
88
89Usage:
90 npx tsx sync-tokens-to-figma.ts [command] [options]
91
92Commands:
93 sync (default) Purge and recreate all tokens in Figma
94 get Fetch and display existing Figma variables
95
96Options:
97 --collection <name> Filter get results by collection name
98 --help, -h Show this help message
99
100Environment Variables:
101 FIGMA_TOKEN (required) Figma personal access token
102 FIGMA_FILE_KEY Target Figma file key
103
104Examples:
105 # Sync all tokens (purges existing, creates fresh)
106 FIGMA_TOKEN="..." npx tsx sync-tokens-to-figma.ts
107
108 # Get all Figma variables
109 FIGMA_TOKEN="..." npx tsx sync-tokens-to-figma.ts get
110`);
111}
112
113/**
114 * Opacity modifiers used in the codebase (bg-color/opacity patterns)
115 * These are scanned from component source files to generate Figma variables.
116 *
117 * Format: { baseColor: [opacityValues] }
118 * Example: { "info": [20], "error": [20, 70, 90] }
119 */
120const OPACITY_MODIFIERS: Record<string, number[]> = {
121 // Banner variants: bg-info/20, bg-alert/20, bg-kumo-danger/20
122 info: [20],
123 alert: [20],
124 error: [20, 70, 90],
125 // Button variants: bg-primary/50, bg-primary/70, bg-kumo-control/50
126 primary: [50, 70],
127 secondary: [50],
128};
129
130/**
131 * Generate opacity variant tokens from base tokens
132 *
133 * For each base color token that has opacity modifiers defined,
134 * creates additional tokens with the opacity baked into the alpha channel.
135 *
136 * Example: color-info + opacity 20 -> color-info/20 with alpha 0.2
137 */
138function generateOpacityVariants(baseTokens: ResolvedToken[]): ResolvedToken[] {
139 const opacityTokens: ResolvedToken[] = [];
140
141 for (const token of baseTokens) {
142 // Extract base color name from token (e.g., "color-info" -> "info")
143 const colorMatch = token.name.match(/^color-([\w-]+)$/);
144 if (!colorMatch) continue;
145
146 const colorName = colorMatch[1];
147 const opacities = OPACITY_MODIFIERS[colorName];
148 if (!opacities) continue;
149
150 // Generate a token for each opacity level
151 for (const opacity of opacities) {
152 const alpha = opacity / 100;
153 opacityTokens.push({
154 name: `${token.name}/${opacity}`,
155 light: { ...token.light, a: alpha },
156 dark: { ...token.dark, a: alpha },
157 });
158 }
159 }
160
161 return opacityTokens;
162}
163
164/**
165 * Validate that FIGMA_TOKEN is set and return it
166 */
167function getValidatedToken(): string {
168 if (!FIGMA_TOKEN) {
169 console.error("Error: FIGMA_TOKEN environment variable is required");
170 console.error("");
171 console.error("Usage:");
172 console.error(
173 ' FIGMA_TOKEN="your-token" pnpm --filter @cloudflare/kumo-figma figma:sync',
174 );
175 console.error("");
176 console.error(
177 "Get a token at: https://www.figma.com/developers/api#authentication",
178 );
179 process.exit(1);
180 }
181 return FIGMA_TOKEN;
182}
183
184/**
185 * Get command - fetch and display existing Figma variables
186 */
187async function runGetCommand(collectionFilter?: string): Promise<void> {
188 const token = getValidatedToken();
189
190 console.log(`Fetching Figma variables from file: ${FIGMA_FILE_KEY}...`);
191
192 const result = await getLocalVariables(FIGMA_FILE_KEY, token);
193
194 if (!result.success) {
195 console.error("Failed to fetch Figma variables:");
196 console.error(result.error);
197 process.exit(1);
198 }
199
200 if (!result.data) {
201 console.log("No variables found in file.");
202 return;
203 }
204
205 const { variables, variableCollections } = result.data;
206 const collections = Object.entries(variableCollections);
207
208 console.log(`\nCollections (${collections.length}):`);
209
210 for (const [collectionId, collection] of collections) {
211 if (collectionFilter && collection.name !== collectionFilter) {
212 continue;
213 }
214
215 console.log(`\n ${collection.name} (${collectionId})`);
216 console.log(` Modes: ${collection.modes.map((m) => m.name).join(", ")}`);
217
218 const collectionVars = Object.entries(variables).filter(
219 ([, v]) => v.variableCollectionId === collectionId,
220 );
221
222 console.log(` Variables (${collectionVars.length}):`);
223
224 for (const [, variable] of collectionVars.slice(0, 10)) {
225 console.log(` - ${variable.name}`);
226 }
227
228 if (collectionVars.length > 10) {
229 console.log(` ... and ${collectionVars.length - 10} more`);
230 }
231 }
232}
233
234/**
235 * Extract color tokens from config.ts
236 * Returns resolved tokens with Figma RGB colors
237 */
238function getColorTokensFromConfig(): {
239 baseTokens: ResolvedToken[];
240 extendedModes: ExtendedMode[];
241} {
242 const baseTokens: ResolvedToken[] = [];
243 const extendedModeOverrides: Record<
244 string,
245 Record<string, FigmaColorInput>
246 > = {};
247
248 // Initialize override maps for non-kumo themes
249 for (const theme of AVAILABLE_THEMES) {
250 if (theme !== "kumo") {
251 extendedModeOverrides[theme] = {};
252 }
253 }
254
255 // Process text color tokens
256 for (const [tokenName, def] of Object.entries(THEME_CONFIG.text)) {
257 const typedDef = def as TokenDefinition;
258
259 // Base kumo theme
260 if (typedDef.theme.kumo) {
261 baseTokens.push({
262 name: `text-color-${tokenName}`,
263 light: resolveColor(typedDef.theme.kumo.light),
264 dark: resolveColor(typedDef.theme.kumo.dark),
265 });
266 }
267
268 // Theme overrides
269 for (const themeName of AVAILABLE_THEMES) {
270 if (themeName !== "kumo" && typedDef.theme[themeName]) {
271 const themeColors = typedDef.theme[themeName]!;
272 extendedModeOverrides[themeName][`text-color-${tokenName}`] =
273 resolveColor(themeColors.light);
274 }
275 }
276 }
277
278 // Process color tokens (bg, border, ring, etc.)
279 for (const [tokenName, def] of Object.entries(THEME_CONFIG.color)) {
280 const typedDef = def as TokenDefinition;
281
282 // Base kumo theme
283 if (typedDef.theme.kumo) {
284 baseTokens.push({
285 name: `color-${tokenName}`,
286 light: resolveColor(typedDef.theme.kumo.light),
287 dark: resolveColor(typedDef.theme.kumo.dark),
288 });
289 }
290
291 // Theme overrides
292 for (const themeName of AVAILABLE_THEMES) {
293 if (themeName !== "kumo" && typedDef.theme[themeName]) {
294 const themeColors = typedDef.theme[themeName]!;
295 extendedModeOverrides[themeName][`color-${tokenName}`] = resolveColor(
296 themeColors.light,
297 );
298 }
299 }
300 }
301
302 // Convert to ExtendedMode array
303 const extendedModes: ExtendedMode[] = [];
304 for (const [themeName, overrides] of Object.entries(extendedModeOverrides)) {
305 if (Object.keys(overrides).length > 0) {
306 extendedModes.push({
307 name: themeName,
308 overrides,
309 });
310 }
311 }
312
313 return { baseTokens, extendedModes };
314}
315
316/**
317 * Extract typography tokens from config.ts
318 * Returns resolved tokens with numeric values
319 */
320function getTypographyTokensFromConfig(): ResolvedTypographyToken[] {
321 const tokens: ResolvedTypographyToken[] = [];
322
323 if (!THEME_CONFIG.typography) {
324 return tokens;
325 }
326
327 for (const [tokenName, def] of Object.entries(THEME_CONFIG.typography)) {
328 const value = def.theme.kumo;
329 if (!value) continue;
330
331 // Resolve the value to a number
332 const resolved = resolveTypographyValue(value);
333 tokens.push({
334 name: tokenName,
335 value: resolved,
336 });
337 }
338
339 return tokens;
340}
341
342/**
343 * Resolve a typography value to a number
344 * Handles: px values, rem values (converts to px at 16px base), calc() expressions
345 */
346function resolveTypographyValue(value: string): number {
347 const trimmed = value.trim();
348
349 // Handle px values
350 if (trimmed.endsWith("px")) {
351 return parseFloat(trimmed);
352 }
353
354 // Handle rem values - convert to px (1rem = 16px)
355 if (trimmed.endsWith("rem")) {
356 return parseFloat(trimmed) * 16;
357 }
358
359 // Handle calc() expressions
360 if (trimmed.startsWith("calc(")) {
361 const expr = trimmed.slice(5, -1).trim();
362 try {
363 // Simple evaluation for division expressions like "1 / 0.75"
364 // eslint-disable-next-line no-eval
365 const result = eval(expr);
366 if (typeof result === "number" && !isNaN(result)) {
367 return result;
368 }
369 } catch {
370 // Fall through to default
371 }
372 }
373
374 // Handle plain numbers
375 const numValue = parseFloat(trimmed);
376 if (!isNaN(numValue)) {
377 return numValue;
378 }
379
380 return 0;
381}
382
383/**
384 * Sync command - purge and recreate all tokens
385 */
386async function runSyncCommand(): Promise<void> {
387 const figmaToken = getValidatedToken();
388 const colorCollectionName = "kumo-colors";
389 const typographyCollectionName = "kumo-typography";
390
391 console.log("Reading tokens from config.ts...\n");
392
393 // Step 1: Get color tokens from config
394 console.log("Color Tokens:");
395 const { baseTokens, extendedModes } = getColorTokensFromConfig();
396 console.log(` Found ${baseTokens.length} base tokens`);
397
398 // Step 2: Generate opacity variants
399 console.log("Generating opacity variants...");
400 const opacityVariants = generateOpacityVariants(baseTokens);
401 console.log(` Found ${opacityVariants.length} opacity variants`);
402
403 // Combine base tokens with opacity variants
404 const resolvedColorTokens = [...baseTokens, ...opacityVariants];
405 console.log(`\nTotal color tokens: ${resolvedColorTokens.length}`);
406
407 // Step 3: Get typography tokens from config
408 console.log("\nTypography Tokens:");
409 const resolvedTypographyTokens = getTypographyTokensFromConfig();
410 console.log(` Found ${resolvedTypographyTokens.length} tokens`);
411
412 if (
413 resolvedColorTokens.length === 0 &&
414 resolvedTypographyTokens.length === 0
415 ) {
416 console.log("No tokens found to sync.");
417 return;
418 }
419
420 // Step 4: Show what we're syncing
421 console.log("\nColor tokens to sync:");
422 for (const token of resolvedColorTokens.slice(0, 10)) {
423 console.log(` - ${token.name}`);
424 }
425 if (resolvedColorTokens.length > 10) {
426 console.log(` ... and ${resolvedColorTokens.length - 10} more`);
427 }
428
429 console.log("\nTypography tokens to sync:");
430 for (const token of resolvedTypographyTokens.slice(0, 10)) {
431 console.log(` - ${token.name}: ${token.value}`);
432 }
433 if (resolvedTypographyTokens.length > 10) {
434 console.log(` ... and ${resolvedTypographyTokens.length - 10} more`);
435 }
436
437 console.log(`\n Color modes: Light, Dark`);
438 console.log(` Typography mode: Desktop`);
439 if (extendedModes.length > 0) {
440 console.log(
441 ` Extension collections: ${extendedModes.map((m) => m.name).join(", ")}`,
442 );
443 for (const mode of extendedModes) {
444 console.log(
445 ` - ${mode.name}: ${Object.keys(mode.overrides).length} overrides`,
446 );
447 }
448 }
449
450 // Step 5: Sync to Figma (purge + create)
451 console.log(`\nSyncing to Figma (file: ${FIGMA_FILE_KEY})...`);
452 console.log(" This will PURGE all existing variables and recreate them.");
453
454 const result = await syncAllToFigma({
455 fileKey: FIGMA_FILE_KEY,
456 token: figmaToken,
457 colors: {
458 collectionName: colorCollectionName,
459 tokens: resolvedColorTokens,
460 extendedModes,
461 },
462 typography: {
463 collectionName: typographyCollectionName,
464 tokens: resolvedTypographyTokens,
465 modeName: "Desktop",
466 },
467 });
468
469 if (!result.success) {
470 console.error("Failed to sync tokens to Figma:");
471 console.error(result.error);
472 process.exit(1);
473 }
474
475 const totalTokens =
476 resolvedColorTokens.length + resolvedTypographyTokens.length;
477 console.log(`\nSuccessfully synced ${totalTokens} tokens to Figma!`);
478 console.log(
479 ` Collection: "${colorCollectionName}" (Light, Dark) - ${resolvedColorTokens.length} color tokens`,
480 );
481 console.log(
482 ` Collection: "${typographyCollectionName}" (Desktop) - ${resolvedTypographyTokens.length} typography tokens`,
483 );
484 if (extendedModes.length > 0) {
485 console.log(
486 ` Extensions: ${extendedModes.map((m) => m.name).join(", ")} (Light, Dark each)`,
487 );
488 }
489
490 if (result.tempIdToRealId) {
491 const mappingCount = Object.keys(result.tempIdToRealId).length;
492 console.log(` Created ${mappingCount} new Figma IDs`);
493 }
494
495 // Verify
496 console.log("\nVerifying sync...");
497 const verification = await getLocalVariables(FIGMA_FILE_KEY, figmaToken);
498
499 if (!verification.success) {
500 console.warn("Could not verify sync:", verification.error);
501 } else if (verification.data) {
502 const collectionCount = Object.keys(
503 verification.data.variableCollections,
504 ).length;
505 const variableCount = Object.keys(verification.data.variables).length;
506 console.log(
507 `Verified: ${collectionCount} collection(s), ${variableCount} variable(s) in file`,
508 );
509 }
510}
511
512/**
513 * Main execution
514 */
515async function main() {
516 const { command, collection } = parseArgs();
517
518 if (command === "get") {
519 await runGetCommand(collection);
520 } else {
521 await runSyncCommand();
522 }
523}
524
525main();
526