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/figma-api.ts

723lines · modecode

1/**
2 * Figma Variables API client for syncing design tokens
3 *
4 * This module provides a unidirectional sync from code to Figma.
5 * It purges all existing variables and recreates them fresh,
6 * making the codebase the single source of truth.
7 */
8
9/** Color with optional alpha (input format) */
10export type FigmaColorInput = { r: number; g: number; b: number; a?: number };
11
12/** Color with required alpha (Figma API format) */
13type FigmaColor = { r: number; g: number; b: number; a: number };
14
15export type ResolvedToken = {
16 name: string;
17 light: FigmaColorInput;
18 dark: FigmaColorInput;
19};
20
21/**
22 * A resolved typography token with numeric value
23 */
24export type ResolvedTypographyToken = {
25 name: string;
26 value: number;
27};
28
29export type FigmaConfig = {
30 fileKey: string;
31 token: string;
32};
33
34/**
35 * Extended theme mode configuration
36 */
37export type ExtendedMode = {
38 name: string;
39 /** Map of token name -> color value for this mode (overrides light values) */
40 overrides: Record<string, FigmaColorInput>;
41};
42
43/**
44 * Full sync configuration
45 */
46export type SyncConfig = {
47 fileKey: string;
48 token: string;
49 collectionName: string;
50 /** Base tokens with Light/Dark values */
51 tokens: ResolvedToken[];
52 /** Additional modes (e.g., fedramp) that extend the base tokens */
53 extendedModes?: ExtendedMode[];
54};
55
56export type SyncResult = {
57 success: boolean;
58 error?: string;
59 tempIdToRealId?: Record<string, string>;
60};
61
62/**
63 * Normalize color to Figma format (0-1 range, always include alpha)
64 */
65function normalizeFigmaColor(color: FigmaColorInput): FigmaColor {
66 return {
67 r: color.r,
68 g: color.g,
69 b: color.b,
70 a: color.a ?? 1,
71 };
72}
73
74/**
75 * Generate a stable ID for a variable based on its name
76 */
77function generateVariableId(name: string): string {
78 return `var_${name.replace(/-/g, "_")}`;
79}
80
81/**
82 * Generate a stable ID for a mode based on its name
83 */
84function generateModeId(name: string): string {
85 return `mode_${name.toLowerCase().replace(/\s+/g, "_")}`;
86}
87
88type FigmaPayload = {
89 variableCollections?: Array<{
90 action: "CREATE" | "UPDATE" | "DELETE";
91 id: string;
92 name?: string;
93 /** Initial mode ID for base collections (not used for extensions) */
94 initialModeId?: string;
95 /** For extension collections: the parent collection ID */
96 parentVariableCollectionId?: string;
97 /** For extension collections: maps extension mode IDs to parent mode IDs */
98 initialModeIdToInitialParentModeIdMap?: Record<string, string>;
99 }>;
100 variableModes?: Array<{
101 action: "CREATE" | "UPDATE" | "DELETE";
102 id: string;
103 name?: string;
104 variableCollectionId?: string;
105 }>;
106 variables?: Array<{
107 action: "CREATE" | "UPDATE" | "DELETE";
108 id: string;
109 name?: string;
110 variableCollectionId?: string;
111 resolvedType?: "COLOR" | "FLOAT" | "STRING";
112 }>;
113 variableModeValues?: Array<{
114 variableId: string;
115 modeId: string;
116 value: FigmaColor | number | string;
117 }>;
118};
119
120/**
121 * Get local variables from a Figma file
122 */
123export async function getLocalVariables(
124 fileKey: string,
125 token: string,
126): Promise<{
127 success: boolean;
128 error?: string;
129 data?: {
130 variables: Record<
131 string,
132 { id: string; name: string; variableCollectionId: string }
133 >;
134 variableCollections: Record<
135 string,
136 {
137 id: string;
138 name: string;
139 modes: Array<{ modeId: string; name: string }>;
140 }
141 >;
142 };
143}> {
144 const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
145
146 try {
147 const response = await fetch(url, {
148 method: "GET",
149 headers: { "X-Figma-Token": token },
150 });
151
152 const responseText = await response.text();
153 let responseJson: unknown;
154
155 try {
156 responseJson = JSON.parse(responseText);
157 } catch {
158 return {
159 success: false,
160 error: `Failed to parse response: ${responseText}`,
161 };
162 }
163
164 if (!response.ok) {
165 const errorMessage =
166 responseJson &&
167 typeof responseJson === "object" &&
168 responseJson !== null
169 ? (responseJson as Record<string, unknown>).message ||
170 (responseJson as Record<string, unknown>).error ||
171 responseText
172 : responseText;
173 return {
174 success: false,
175 error: `Figma API error (${response.status}): ${errorMessage}`,
176 };
177 }
178
179 const meta =
180 responseJson &&
181 typeof responseJson === "object" &&
182 responseJson !== null &&
183 "meta" in responseJson
184 ? (
185 responseJson as {
186 meta: {
187 variables: Record<string, unknown>;
188 variableCollections: Record<string, unknown>;
189 };
190 }
191 ).meta
192 : null;
193
194 return {
195 success: true,
196 data: (meta as {
197 variables: Record<
198 string,
199 { id: string; name: string; variableCollectionId: string }
200 >;
201 variableCollections: Record<
202 string,
203 {
204 id: string;
205 name: string;
206 modes: Array<{ modeId: string; name: string }>;
207 }
208 >;
209 }) ?? { variables: {}, variableCollections: {} },
210 };
211 } catch (error) {
212 const message = error instanceof Error ? error.message : String(error);
213 return { success: false, error: `Network error: ${message}` };
214 }
215}
216
217/**
218 * Send a payload to Figma Variables API
219 */
220async function sendFigmaPayload(
221 fileKey: string,
222 token: string,
223 payload: FigmaPayload,
224): Promise<SyncResult> {
225 const url = `https://api.figma.com/v1/files/${fileKey}/variables`;
226
227 try {
228 const response = await fetch(url, {
229 method: "POST",
230 headers: {
231 "X-Figma-Token": token,
232 "Content-Type": "application/json",
233 },
234 body: JSON.stringify(payload),
235 });
236
237 const responseText = await response.text();
238 let responseJson: unknown;
239
240 try {
241 responseJson = JSON.parse(responseText);
242 } catch {
243 responseJson = null;
244 }
245
246 if (!response.ok) {
247 const errorMessage =
248 responseJson &&
249 typeof responseJson === "object" &&
250 responseJson !== null
251 ? (responseJson as Record<string, unknown>).message ||
252 (responseJson as Record<string, unknown>).error ||
253 responseText
254 : responseText;
255 return {
256 success: false,
257 error: `Figma API error (${response.status}): ${errorMessage}`,
258 };
259 }
260
261 const meta =
262 responseJson &&
263 typeof responseJson === "object" &&
264 responseJson !== null &&
265 "meta" in responseJson
266 ? (
267 responseJson as {
268 meta: { tempIdToRealId?: Record<string, string> };
269 }
270 ).meta
271 : null;
272
273 return { success: true, tempIdToRealId: meta?.tempIdToRealId };
274 } catch (error) {
275 const message = error instanceof Error ? error.message : String(error);
276 return { success: false, error: `Network error: ${message}` };
277 }
278}
279
280/**
281 * Delete all variables and collections from a Figma file
282 */
283export async function purgeAllVariables(
284 fileKey: string,
285 token: string,
286): Promise<SyncResult> {
287 // First, get all existing variables
288 const existing = await getLocalVariables(fileKey, token);
289 if (!existing.success || !existing.data) {
290 return existing;
291 }
292
293 const { variables, variableCollections } = existing.data;
294 const variableIds = Object.keys(variables);
295 const collectionIds = Object.keys(variableCollections);
296
297 if (variableIds.length === 0 && collectionIds.length === 0) {
298 return { success: true };
299 }
300
301 // Delete all variables first, then collections
302 const payload: FigmaPayload = {
303 variables: variableIds.map((id) => ({ action: "DELETE", id })),
304 variableCollections: collectionIds.map((id) => ({ action: "DELETE", id })),
305 };
306
307 return sendFigmaPayload(fileKey, token, payload);
308}
309
310/**
311 * Sync tokens to Figma (purge + create)
312 *
313 * This is a unidirectional sync that makes the codebase the single source of truth.
314 * It deletes all existing variables and recreates them from scratch.
315 *
316 * Creates:
317 * 1. Base collection with Light and Dark modes
318 * 2. Extension collections for each extended mode (e.g., "fedramp") that inherit
319 * from the base collection and can override specific token values.
320 * Each extension also has Light/Dark modes mapped to the parent's modes.
321 */
322export async function syncToFigma(config: SyncConfig): Promise<SyncResult> {
323 const { fileKey, token, collectionName, tokens, extendedModes = [] } = config;
324
325 if (!tokens.length) {
326 return { success: false, error: "No tokens to sync" };
327 }
328
329 // Step 1: Purge all existing variables
330 const purgeResult = await purgeAllVariables(fileKey, token);
331 if (!purgeResult.success) {
332 return { success: false, error: `Failed to purge: ${purgeResult.error}` };
333 }
334
335 // Step 2: Build the base collection payload
336 const baseCollectionId = "kumo_collection";
337 const lightModeId = generateModeId("Light");
338 const darkModeId = generateModeId("Dark");
339
340 // Build base mode definitions (Light and Dark only)
341 const variableModes: FigmaPayload["variableModes"] = [
342 {
343 action: "UPDATE",
344 id: lightModeId,
345 name: "Light",
346 variableCollectionId: baseCollectionId,
347 },
348 {
349 action: "CREATE",
350 id: darkModeId,
351 name: "Dark",
352 variableCollectionId: baseCollectionId,
353 },
354 ];
355
356 // Build variables
357 const variables: FigmaPayload["variables"] = tokens.map((t) => ({
358 action: "CREATE",
359 id: generateVariableId(t.name),
360 name: t.name,
361 variableCollectionId: baseCollectionId,
362 resolvedType: "COLOR",
363 }));
364
365 // Build mode values for base collection
366 const variableModeValues: FigmaPayload["variableModeValues"] = [];
367
368 for (const t of tokens) {
369 const varId = generateVariableId(t.name);
370
371 // Light mode
372 variableModeValues.push({
373 variableId: varId,
374 modeId: lightModeId,
375 value: normalizeFigmaColor(t.light),
376 });
377
378 // Dark mode
379 variableModeValues.push({
380 variableId: varId,
381 modeId: darkModeId,
382 value: normalizeFigmaColor(t.dark),
383 });
384 }
385
386 // Build base collection
387 const variableCollections: NonNullable<FigmaPayload["variableCollections"]> =
388 [
389 {
390 action: "CREATE",
391 id: baseCollectionId,
392 name: collectionName,
393 initialModeId: lightModeId,
394 },
395 ];
396
397 // Step 3: Build extension collections for each extended mode
398 // Extension collections inherit from base and override specific tokens
399 // They still have Light/Dark modes that map to the parent's Light/Dark
400 for (const extMode of extendedModes) {
401 const extCollectionId = `ext_${extMode.name.toLowerCase()}`;
402 const extLightModeId = `${extCollectionId}_light`;
403 const extDarkModeId = `${extCollectionId}_dark`;
404
405 // Create extension collection with mode mapping to parent
406 variableCollections.push({
407 action: "CREATE",
408 id: extCollectionId,
409 name: extMode.name,
410 parentVariableCollectionId: baseCollectionId,
411 initialModeIdToInitialParentModeIdMap: {
412 [extLightModeId]: lightModeId,
413 [extDarkModeId]: darkModeId,
414 },
415 });
416
417 // Add variable mode values for overrides in the extension collection
418 // Only add values for tokens that have overrides
419 for (const t of tokens) {
420 const varId = generateVariableId(t.name);
421 const override = extMode.overrides[t.name];
422
423 if (override) {
424 // Light mode override
425 variableModeValues.push({
426 variableId: varId,
427 modeId: extLightModeId,
428 value: normalizeFigmaColor(override),
429 });
430
431 // Dark mode override (use same override - fedramp overrides apply to both modes)
432 variableModeValues.push({
433 variableId: varId,
434 modeId: extDarkModeId,
435 value: normalizeFigmaColor(override),
436 });
437 }
438 }
439 }
440
441 const createPayload: FigmaPayload = {
442 variableCollections,
443 variableModes,
444 variables,
445 variableModeValues,
446 };
447
448 return sendFigmaPayload(fileKey, token, createPayload);
449}
450
451/**
452 * Configuration for syncing typography tokens
453 */
454export type TypographySyncConfig = {
455 fileKey: string;
456 token: string;
457 collectionName: string;
458 /** Typography tokens with numeric values */
459 tokens: ResolvedTypographyToken[];
460 /** Mode name (e.g., "Desktop") */
461 modeName?: string;
462};
463
464/**
465 * Sync typography tokens to Figma as FLOAT variables
466 *
467 * Creates a separate collection for typography with a single mode.
468 * Unlike color tokens, typography tokens don't have light/dark variants.
469 */
470export async function syncTypographyToFigma(
471 config: TypographySyncConfig,
472): Promise<SyncResult> {
473 const {
474 fileKey,
475 token,
476 collectionName,
477 tokens,
478 modeName = "Desktop",
479 } = config;
480
481 if (!tokens.length) {
482 return { success: false, error: "No typography tokens to sync" };
483 }
484
485 // Build the typography collection payload
486 const typographyCollectionId = "typography_collection";
487 const desktopModeId = generateModeId(modeName);
488
489 // Build collection
490 const variableCollections: NonNullable<FigmaPayload["variableCollections"]> =
491 [
492 {
493 action: "CREATE",
494 id: typographyCollectionId,
495 name: collectionName,
496 initialModeId: desktopModeId,
497 },
498 ];
499
500 // Build mode (just rename the initial mode)
501 const variableModes: FigmaPayload["variableModes"] = [
502 {
503 action: "UPDATE",
504 id: desktopModeId,
505 name: modeName,
506 variableCollectionId: typographyCollectionId,
507 },
508 ];
509
510 // Build variables
511 const variables: FigmaPayload["variables"] = tokens.map((t) => ({
512 action: "CREATE",
513 id: generateVariableId(t.name),
514 name: t.name,
515 variableCollectionId: typographyCollectionId,
516 resolvedType: "FLOAT",
517 }));
518
519 // Build mode values
520 const variableModeValues: FigmaPayload["variableModeValues"] = tokens.map(
521 (t) => ({
522 variableId: generateVariableId(t.name),
523 modeId: desktopModeId,
524 value: t.value,
525 }),
526 );
527
528 const createPayload: FigmaPayload = {
529 variableCollections,
530 variableModes,
531 variables,
532 variableModeValues,
533 };
534
535 return sendFigmaPayload(fileKey, token, createPayload);
536}
537
538/**
539 * Combined sync configuration for both colors and typography
540 */
541export type CombinedSyncConfig = {
542 fileKey: string;
543 token: string;
544 /** Color collection configuration */
545 colors: {
546 collectionName: string;
547 tokens: ResolvedToken[];
548 extendedModes?: ExtendedMode[];
549 };
550 /** Typography collection configuration */
551 typography?: {
552 collectionName: string;
553 tokens: ResolvedTypographyToken[];
554 modeName?: string;
555 };
556};
557
558/**
559 * Sync both color and typography tokens to Figma in a single operation
560 *
561 * This purges all existing variables and recreates both collections.
562 */
563export async function syncAllToFigma(
564 config: CombinedSyncConfig,
565): Promise<SyncResult> {
566 const { fileKey, token, colors, typography } = config;
567
568 // Step 1: Purge all existing variables
569 const purgeResult = await purgeAllVariables(fileKey, token);
570 if (!purgeResult.success) {
571 return { success: false, error: `Failed to purge: ${purgeResult.error}` };
572 }
573
574 // Step 2: Build combined payload for both collections
575 const colorCollectionId = "kumo_collection";
576 const lightModeId = generateModeId("Light");
577 const darkModeId = generateModeId("Dark");
578
579 // Build color collection
580 const variableCollections: NonNullable<FigmaPayload["variableCollections"]> =
581 [
582 {
583 action: "CREATE",
584 id: colorCollectionId,
585 name: colors.collectionName,
586 initialModeId: lightModeId,
587 },
588 ];
589
590 // Build color modes
591 const variableModes: FigmaPayload["variableModes"] = [
592 {
593 action: "UPDATE",
594 id: lightModeId,
595 name: "Light",
596 variableCollectionId: colorCollectionId,
597 },
598 {
599 action: "CREATE",
600 id: darkModeId,
601 name: "Dark",
602 variableCollectionId: colorCollectionId,
603 },
604 ];
605
606 // Build color variables
607 const variables: FigmaPayload["variables"] = colors.tokens.map((t) => ({
608 action: "CREATE",
609 id: generateVariableId(t.name),
610 name: t.name,
611 variableCollectionId: colorCollectionId,
612 resolvedType: "COLOR",
613 }));
614
615 // Build color mode values
616 const variableModeValues: FigmaPayload["variableModeValues"] = [];
617
618 for (const t of colors.tokens) {
619 const varId = generateVariableId(t.name);
620
621 // Light mode
622 variableModeValues.push({
623 variableId: varId,
624 modeId: lightModeId,
625 value: normalizeFigmaColor(t.light),
626 });
627
628 // Dark mode
629 variableModeValues.push({
630 variableId: varId,
631 modeId: darkModeId,
632 value: normalizeFigmaColor(t.dark),
633 });
634 }
635
636 // Add extended modes for colors
637 const extendedModes = colors.extendedModes ?? [];
638 for (const extMode of extendedModes) {
639 const extCollectionId = `ext_${extMode.name.toLowerCase()}`;
640 const extLightModeId = `${extCollectionId}_light`;
641 const extDarkModeId = `${extCollectionId}_dark`;
642
643 variableCollections.push({
644 action: "CREATE",
645 id: extCollectionId,
646 name: extMode.name,
647 parentVariableCollectionId: colorCollectionId,
648 initialModeIdToInitialParentModeIdMap: {
649 [extLightModeId]: lightModeId,
650 [extDarkModeId]: darkModeId,
651 },
652 });
653
654 for (const t of colors.tokens) {
655 const varId = generateVariableId(t.name);
656 const override = extMode.overrides[t.name];
657
658 if (override) {
659 variableModeValues.push({
660 variableId: varId,
661 modeId: extLightModeId,
662 value: normalizeFigmaColor(override),
663 });
664
665 variableModeValues.push({
666 variableId: varId,
667 modeId: extDarkModeId,
668 value: normalizeFigmaColor(override),
669 });
670 }
671 }
672 }
673
674 // Add typography collection if provided
675 if (typography && typography.tokens.length > 0) {
676 const typographyCollectionId = "typography_collection";
677 const typographyModeId = generateModeId(typography.modeName ?? "Desktop");
678
679 variableCollections.push({
680 action: "CREATE",
681 id: typographyCollectionId,
682 name: typography.collectionName,
683 initialModeId: typographyModeId,
684 });
685
686 variableModes.push({
687 action: "UPDATE",
688 id: typographyModeId,
689 name: typography.modeName ?? "Desktop",
690 variableCollectionId: typographyCollectionId,
691 });
692
693 for (const t of typography.tokens) {
694 const varId = generateVariableId(`typography_${t.name}`);
695
696 variables.push({
697 action: "CREATE",
698 id: varId,
699 name: t.name,
700 variableCollectionId: typographyCollectionId,
701 resolvedType: "FLOAT",
702 });
703
704 variableModeValues.push({
705 variableId: varId,
706 modeId: typographyModeId,
707 value: t.value,
708 });
709 }
710 }
711
712 const createPayload: FigmaPayload = {
713 variableCollections,
714 variableModes,
715 variables,
716 variableModeValues,
717 };
718
719 return sendFigmaPayload(fileKey, token, createPayload);
720}
721
722// Legacy export for backwards compatibility
723export { syncToFigma as syncToFigmaLegacy };
724