cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
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 | |
| 16 | import { readFileSync, existsSync } from "node:fs"; |
| 17 | import { dirname, resolve } from "node:path"; |
| 18 | import { fileURLToPath } from "node:url"; |
| 19 | import { |
| 20 | THEME_CONFIG, |
| 21 | AVAILABLE_THEMES, |
| 22 | } from "../../kumo/scripts/theme-generator/config.js"; |
| 23 | import type { TokenDefinition } from "../../kumo/scripts/theme-generator/types.js"; |
| 24 | import { resolveColor } from "./color-utils.js"; |
| 25 | import { |
| 26 | syncAllToFigma, |
| 27 | getLocalVariables, |
| 28 | type ResolvedToken, |
| 29 | type ResolvedTypographyToken, |
| 30 | type ExtendedMode, |
| 31 | type FigmaColorInput, |
| 32 | } from "./figma-api.js"; |
| 33 | |
| 34 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 35 | const ENV_PATH = resolve(__dirname, ".env"); |
| 36 | |
| 37 | // Load .env file if it exists |
| 38 | if (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 |
| 53 | const FIGMA_TOKEN = process.env.FIGMA_TOKEN; |
| 54 | const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY || "sKKZc6pC6W1TtzWBLxDGSU"; |
| 55 | |
| 56 | /** |
| 57 | * Parse CLI arguments |
| 58 | */ |
| 59 | function 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 | */ |
| 82 | function printHelp(): void { |
| 83 | console.log(` |
| 84 | Figma Token Sync - Unidirectional sync from code to Figma |
| 85 | |
| 86 | This script uses config.ts as the SINGLE SOURCE OF TRUTH for design tokens. |
| 87 | Running sync will PURGE all existing variables and recreate them. |
| 88 | |
| 89 | Usage: |
| 90 | npx tsx sync-tokens-to-figma.ts [command] [options] |
| 91 | |
| 92 | Commands: |
| 93 | sync (default) Purge and recreate all tokens in Figma |
| 94 | get Fetch and display existing Figma variables |
| 95 | |
| 96 | Options: |
| 97 | --collection <name> Filter get results by collection name |
| 98 | --help, -h Show this help message |
| 99 | |
| 100 | Environment Variables: |
| 101 | FIGMA_TOKEN (required) Figma personal access token |
| 102 | FIGMA_FILE_KEY Target Figma file key |
| 103 | |
| 104 | Examples: |
| 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 | */ |
| 120 | const 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 | */ |
| 138 | function 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 | */ |
| 167 | function 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 | */ |
| 187 | async 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 | */ |
| 238 | function 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 | */ |
| 320 | function 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 | */ |
| 346 | function 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 | */ |
| 386 | async 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 | */ |
| 515 | async function main() { |
| 516 | const { command, collection } = parseArgs(); |
| 517 | |
| 518 | if (command === "get") { |
| 519 | await runGetCommand(collection); |
| 520 | } else { |
| 521 | await runSyncCommand(); |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | main(); |
| 526 | |