@fragments-sdk/cli 0.2.2

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.
Files changed (259) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/bin.d.ts +1 -0
  4. package/dist/bin.js +4783 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/chunk-4FDQSGKX.js +786 -0
  7. package/dist/chunk-4FDQSGKX.js.map +1 -0
  8. package/dist/chunk-7H2MMGYG.js +369 -0
  9. package/dist/chunk-7H2MMGYG.js.map +1 -0
  10. package/dist/chunk-BSCG3IP7.js +619 -0
  11. package/dist/chunk-BSCG3IP7.js.map +1 -0
  12. package/dist/chunk-LY2CFFPY.js +898 -0
  13. package/dist/chunk-LY2CFFPY.js.map +1 -0
  14. package/dist/chunk-MUZ6CM66.js +6636 -0
  15. package/dist/chunk-MUZ6CM66.js.map +1 -0
  16. package/dist/chunk-OAENNG3G.js +1489 -0
  17. package/dist/chunk-OAENNG3G.js.map +1 -0
  18. package/dist/chunk-XHNKNI6J.js +235 -0
  19. package/dist/chunk-XHNKNI6J.js.map +1 -0
  20. package/dist/core-DWKLGY4N.js +68 -0
  21. package/dist/core-DWKLGY4N.js.map +1 -0
  22. package/dist/generate-4LQNJ7SX.js +249 -0
  23. package/dist/generate-4LQNJ7SX.js.map +1 -0
  24. package/dist/index.d.ts +775 -0
  25. package/dist/index.js +41 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/init-EMVI47QG.js +416 -0
  28. package/dist/init-EMVI47QG.js.map +1 -0
  29. package/dist/mcp-bin.d.ts +1 -0
  30. package/dist/mcp-bin.js +1117 -0
  31. package/dist/mcp-bin.js.map +1 -0
  32. package/dist/scan-4YPRF7FV.js +12 -0
  33. package/dist/scan-4YPRF7FV.js.map +1 -0
  34. package/dist/service-QSZMZJBJ.js +208 -0
  35. package/dist/service-QSZMZJBJ.js.map +1 -0
  36. package/dist/static-viewer-MIPGZ4Z7.js +12 -0
  37. package/dist/static-viewer-MIPGZ4Z7.js.map +1 -0
  38. package/dist/test-SQ5ZHXWU.js +1067 -0
  39. package/dist/test-SQ5ZHXWU.js.map +1 -0
  40. package/dist/tokens-HSGMYK64.js +173 -0
  41. package/dist/tokens-HSGMYK64.js.map +1 -0
  42. package/dist/viewer-YRF4SQE4.js +11101 -0
  43. package/dist/viewer-YRF4SQE4.js.map +1 -0
  44. package/package.json +107 -0
  45. package/src/ai.ts +266 -0
  46. package/src/analyze.ts +265 -0
  47. package/src/bin.ts +916 -0
  48. package/src/build.ts +248 -0
  49. package/src/commands/a11y.ts +302 -0
  50. package/src/commands/add.ts +313 -0
  51. package/src/commands/audit.ts +195 -0
  52. package/src/commands/baseline.ts +221 -0
  53. package/src/commands/build.ts +144 -0
  54. package/src/commands/compare.ts +337 -0
  55. package/src/commands/context.ts +107 -0
  56. package/src/commands/dev.ts +107 -0
  57. package/src/commands/enhance.ts +858 -0
  58. package/src/commands/generate.ts +391 -0
  59. package/src/commands/init.ts +531 -0
  60. package/src/commands/link/figma.ts +645 -0
  61. package/src/commands/link/index.ts +10 -0
  62. package/src/commands/link/storybook.ts +267 -0
  63. package/src/commands/list.ts +49 -0
  64. package/src/commands/metrics.ts +114 -0
  65. package/src/commands/reset.ts +242 -0
  66. package/src/commands/scan.ts +537 -0
  67. package/src/commands/storygen.ts +207 -0
  68. package/src/commands/tokens.ts +251 -0
  69. package/src/commands/validate.ts +93 -0
  70. package/src/commands/verify.ts +215 -0
  71. package/src/core/composition.test.ts +262 -0
  72. package/src/core/composition.ts +255 -0
  73. package/src/core/config.ts +84 -0
  74. package/src/core/constants.ts +111 -0
  75. package/src/core/context.ts +380 -0
  76. package/src/core/defineSegment.ts +137 -0
  77. package/src/core/discovery.ts +337 -0
  78. package/src/core/figma.ts +263 -0
  79. package/src/core/fragment-types.ts +214 -0
  80. package/src/core/generators/context.ts +389 -0
  81. package/src/core/generators/index.ts +23 -0
  82. package/src/core/generators/registry.ts +364 -0
  83. package/src/core/generators/typescript-extractor.ts +374 -0
  84. package/src/core/importAnalyzer.ts +217 -0
  85. package/src/core/index.ts +149 -0
  86. package/src/core/loader.ts +155 -0
  87. package/src/core/node.ts +63 -0
  88. package/src/core/parser.ts +551 -0
  89. package/src/core/previewLoader.ts +172 -0
  90. package/src/core/schema/fragment.schema.json +189 -0
  91. package/src/core/schema/registry.schema.json +137 -0
  92. package/src/core/schema.ts +182 -0
  93. package/src/core/storyAdapter.test.ts +571 -0
  94. package/src/core/storyAdapter.ts +761 -0
  95. package/src/core/token-types.ts +287 -0
  96. package/src/core/types.ts +754 -0
  97. package/src/diff.ts +323 -0
  98. package/src/index.ts +43 -0
  99. package/src/mcp/__tests__/projectFields.test.ts +130 -0
  100. package/src/mcp/bin.ts +36 -0
  101. package/src/mcp/index.ts +8 -0
  102. package/src/mcp/server.ts +1310 -0
  103. package/src/mcp/utils.ts +54 -0
  104. package/src/mcp-bin.ts +36 -0
  105. package/src/migrate/__tests__/argTypes/argTypes.test.ts +189 -0
  106. package/src/migrate/__tests__/args/args.test.ts +452 -0
  107. package/src/migrate/__tests__/meta/meta.test.ts +198 -0
  108. package/src/migrate/__tests__/stories/stories.test.ts +278 -0
  109. package/src/migrate/__tests__/utils/utils.test.ts +371 -0
  110. package/src/migrate/__tests__/values/values.test.ts +303 -0
  111. package/src/migrate/bin.ts +108 -0
  112. package/src/migrate/converter.ts +658 -0
  113. package/src/migrate/detect.ts +196 -0
  114. package/src/migrate/index.ts +45 -0
  115. package/src/migrate/migrate.ts +163 -0
  116. package/src/migrate/parser.ts +1136 -0
  117. package/src/migrate/report.ts +624 -0
  118. package/src/migrate/types.ts +169 -0
  119. package/src/screenshot.ts +249 -0
  120. package/src/service/__tests__/ast-utils.test.ts +426 -0
  121. package/src/service/__tests__/enhance-scanner.test.ts +200 -0
  122. package/src/service/__tests__/figma/figma.test.ts +652 -0
  123. package/src/service/__tests__/metrics-store.test.ts +409 -0
  124. package/src/service/__tests__/patch-generator.test.ts +186 -0
  125. package/src/service/__tests__/props-extractor.test.ts +365 -0
  126. package/src/service/__tests__/token-registry.test.ts +267 -0
  127. package/src/service/analytics.ts +659 -0
  128. package/src/service/ast-utils.ts +444 -0
  129. package/src/service/browser-pool.ts +339 -0
  130. package/src/service/capture.ts +267 -0
  131. package/src/service/diff.ts +279 -0
  132. package/src/service/enhance/aggregator.ts +489 -0
  133. package/src/service/enhance/cache.ts +275 -0
  134. package/src/service/enhance/codebase-scanner.ts +357 -0
  135. package/src/service/enhance/context-generator.ts +529 -0
  136. package/src/service/enhance/doc-extractor.ts +523 -0
  137. package/src/service/enhance/index.ts +131 -0
  138. package/src/service/enhance/props-extractor.ts +665 -0
  139. package/src/service/enhance/scanner.ts +445 -0
  140. package/src/service/enhance/storybook-parser.ts +552 -0
  141. package/src/service/enhance/types.ts +346 -0
  142. package/src/service/enhance/variant-renderer.ts +479 -0
  143. package/src/service/figma.ts +1008 -0
  144. package/src/service/index.ts +249 -0
  145. package/src/service/metrics-store.ts +333 -0
  146. package/src/service/patch-generator.ts +349 -0
  147. package/src/service/report.ts +854 -0
  148. package/src/service/storage.ts +401 -0
  149. package/src/service/token-fixes.ts +281 -0
  150. package/src/service/token-parser.ts +504 -0
  151. package/src/service/token-registry.ts +721 -0
  152. package/src/service/utils.ts +172 -0
  153. package/src/setup.ts +241 -0
  154. package/src/shared/command-wrapper.ts +81 -0
  155. package/src/shared/dev-server-client.ts +199 -0
  156. package/src/shared/index.ts +8 -0
  157. package/src/shared/segment-loader.ts +59 -0
  158. package/src/shared/types.ts +147 -0
  159. package/src/static-viewer.ts +715 -0
  160. package/src/test/discovery.ts +172 -0
  161. package/src/test/index.ts +281 -0
  162. package/src/test/reporters/console.ts +194 -0
  163. package/src/test/reporters/json.ts +190 -0
  164. package/src/test/reporters/junit.ts +186 -0
  165. package/src/test/runner.ts +598 -0
  166. package/src/test/types.ts +245 -0
  167. package/src/test/watch.ts +200 -0
  168. package/src/validators.ts +152 -0
  169. package/src/viewer/__tests__/jsx-parser.test.ts +502 -0
  170. package/src/viewer/__tests__/render-utils.test.ts +232 -0
  171. package/src/viewer/__tests__/style-utils.test.ts +404 -0
  172. package/src/viewer/bin.ts +86 -0
  173. package/src/viewer/cli/health.ts +256 -0
  174. package/src/viewer/cli/index.ts +33 -0
  175. package/src/viewer/cli/scan.ts +124 -0
  176. package/src/viewer/cli/utils.ts +174 -0
  177. package/src/viewer/components/AccessibilityPanel.tsx +1404 -0
  178. package/src/viewer/components/ActionCapture.tsx +172 -0
  179. package/src/viewer/components/ActionsPanel.tsx +371 -0
  180. package/src/viewer/components/App.tsx +638 -0
  181. package/src/viewer/components/BottomPanel.tsx +224 -0
  182. package/src/viewer/components/CodePanel.tsx +589 -0
  183. package/src/viewer/components/CommandPalette.tsx +336 -0
  184. package/src/viewer/components/ComponentGraph.tsx +394 -0
  185. package/src/viewer/components/ComponentHeader.tsx +85 -0
  186. package/src/viewer/components/ContractPanel.tsx +234 -0
  187. package/src/viewer/components/ErrorBoundary.tsx +85 -0
  188. package/src/viewer/components/FigmaEmbed.tsx +231 -0
  189. package/src/viewer/components/FragmentEditor.tsx +485 -0
  190. package/src/viewer/components/HealthDashboard.tsx +452 -0
  191. package/src/viewer/components/HmrStatusIndicator.tsx +71 -0
  192. package/src/viewer/components/Icons.tsx +417 -0
  193. package/src/viewer/components/InteractionsPanel.tsx +720 -0
  194. package/src/viewer/components/IsolatedPreviewFrame.tsx +321 -0
  195. package/src/viewer/components/IsolatedRender.tsx +111 -0
  196. package/src/viewer/components/KeyboardShortcutsHelp.tsx +89 -0
  197. package/src/viewer/components/LandingPage.tsx +441 -0
  198. package/src/viewer/components/Layout.tsx +22 -0
  199. package/src/viewer/components/LeftSidebar.tsx +391 -0
  200. package/src/viewer/components/MultiViewportPreview.tsx +429 -0
  201. package/src/viewer/components/PreviewArea.tsx +404 -0
  202. package/src/viewer/components/PreviewFrameHost.tsx +310 -0
  203. package/src/viewer/components/PreviewPane.tsx +150 -0
  204. package/src/viewer/components/PreviewToolbar.tsx +176 -0
  205. package/src/viewer/components/PropsEditor.tsx +512 -0
  206. package/src/viewer/components/PropsTable.tsx +98 -0
  207. package/src/viewer/components/RelationsSection.tsx +57 -0
  208. package/src/viewer/components/ResizablePanel.tsx +328 -0
  209. package/src/viewer/components/RightSidebar.tsx +118 -0
  210. package/src/viewer/components/ScreenshotButton.tsx +90 -0
  211. package/src/viewer/components/Sidebar.tsx +169 -0
  212. package/src/viewer/components/SkeletonLoader.tsx +156 -0
  213. package/src/viewer/components/StoryRenderer.tsx +128 -0
  214. package/src/viewer/components/ThemeProvider.tsx +96 -0
  215. package/src/viewer/components/Toast.tsx +67 -0
  216. package/src/viewer/components/TokenStylePanel.tsx +708 -0
  217. package/src/viewer/components/UsageSection.tsx +95 -0
  218. package/src/viewer/components/VariantMatrix.tsx +350 -0
  219. package/src/viewer/components/VariantRenderer.tsx +131 -0
  220. package/src/viewer/components/VariantTabs.tsx +84 -0
  221. package/src/viewer/components/ViewportSelector.tsx +165 -0
  222. package/src/viewer/components/_future/CreatePage.tsx +836 -0
  223. package/src/viewer/composition-renderer.ts +381 -0
  224. package/src/viewer/constants/index.ts +1 -0
  225. package/src/viewer/constants/ui.ts +185 -0
  226. package/src/viewer/entry.tsx +299 -0
  227. package/src/viewer/hooks/index.ts +2 -0
  228. package/src/viewer/hooks/useA11yCache.ts +383 -0
  229. package/src/viewer/hooks/useA11yService.ts +498 -0
  230. package/src/viewer/hooks/useActions.ts +138 -0
  231. package/src/viewer/hooks/useAppState.ts +124 -0
  232. package/src/viewer/hooks/useFigmaIntegration.ts +132 -0
  233. package/src/viewer/hooks/useHmrStatus.ts +109 -0
  234. package/src/viewer/hooks/useKeyboardShortcuts.ts +222 -0
  235. package/src/viewer/hooks/usePreviewBridge.ts +347 -0
  236. package/src/viewer/hooks/useScrollSpy.ts +78 -0
  237. package/src/viewer/hooks/useUrlState.ts +330 -0
  238. package/src/viewer/hooks/useViewSettings.ts +125 -0
  239. package/src/viewer/index.html +28 -0
  240. package/src/viewer/index.ts +14 -0
  241. package/src/viewer/intelligence/healthReport.ts +505 -0
  242. package/src/viewer/intelligence/styleDrift.ts +340 -0
  243. package/src/viewer/intelligence/usageScanner.ts +309 -0
  244. package/src/viewer/jsx-parser.ts +485 -0
  245. package/src/viewer/postcss.config.js +6 -0
  246. package/src/viewer/preview-frame-entry.tsx +25 -0
  247. package/src/viewer/preview-frame.html +109 -0
  248. package/src/viewer/render-template.html +68 -0
  249. package/src/viewer/render-utils.ts +170 -0
  250. package/src/viewer/server.ts +276 -0
  251. package/src/viewer/style-utils.ts +414 -0
  252. package/src/viewer/styles/globals.css +355 -0
  253. package/src/viewer/tailwind.config.js +37 -0
  254. package/src/viewer/types/a11y.ts +197 -0
  255. package/src/viewer/utils/a11y-fixes.ts +471 -0
  256. package/src/viewer/utils/actionExport.ts +372 -0
  257. package/src/viewer/utils/colorSchemes.ts +201 -0
  258. package/src/viewer/utils/detectRelationships.ts +256 -0
  259. package/src/viewer/vite-plugin.ts +2143 -0
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * Figma API client for fetching design frames.
3
+ * Includes caching for performance during iteration loops.
4
+ *
5
+ * Uses official types from @figma/rest-api-spec for type safety.
6
+ */
7
+
8
+ import { BRAND } from '../core/index.js';
9
+ import { ServiceError } from './utils.js';
10
+ import type {
11
+ GetFileResponse,
12
+ GetFileNodesResponse,
13
+ Node as FigmaAPINode,
14
+ } from '@figma/rest-api-spec';
15
+
16
+ /**
17
+ * Configuration for FigmaClient
18
+ */
19
+ export interface FigmaClientConfig {
20
+ /** Figma Personal Access Token */
21
+ accessToken: string;
22
+
23
+ /** Cache TTL in milliseconds (default: 5 minutes) */
24
+ cacheTtlMs?: number;
25
+ }
26
+
27
+ /**
28
+ * Result of fetching a Figma image
29
+ */
30
+ export interface FigmaImageResult {
31
+ /** PNG image buffer */
32
+ data: Buffer;
33
+
34
+ /** Original CDN URL (expires after ~30 days) */
35
+ cdnUrl: string;
36
+
37
+ /** Whether this result was served from cache */
38
+ fromCache: boolean;
39
+ }
40
+
41
+ /**
42
+ * Parsed Figma URL components
43
+ */
44
+ export interface FigmaUrlParts {
45
+ /** File key from URL */
46
+ fileKey: string;
47
+
48
+ /** Node ID from URL */
49
+ nodeId: string;
50
+ }
51
+
52
+ /**
53
+ * Extended Figma component metadata.
54
+ * Compatible with both Component and ComponentSet from @figma/rest-api-spec.
55
+ */
56
+ export interface FigmaComponent {
57
+ /** Component key */
58
+ key: string;
59
+
60
+ /** Component name */
61
+ name: string;
62
+
63
+ /** Description */
64
+ description: string;
65
+
66
+ /** File key (added by us - not in API response directly) */
67
+ file_key: string;
68
+
69
+ /** Node ID within the file (from response object keys) */
70
+ node_id: string;
71
+
72
+ /** Component set ID if component belongs to one */
73
+ componentSetId?: string;
74
+
75
+ /** Documentation links (optional for compatibility with ComponentSet) */
76
+ documentationLinks?: Array<{ uri: string }>;
77
+
78
+ /** Whether this is a remote component */
79
+ remote?: boolean;
80
+ }
81
+
82
+ /**
83
+ * Result of fetching file components
84
+ */
85
+ export interface FigmaFileComponents {
86
+ /** All components in the file */
87
+ components: FigmaComponent[];
88
+
89
+ /** Component sets (variants) */
90
+ componentSets: FigmaComponent[];
91
+
92
+ /** File name */
93
+ fileName: string;
94
+ }
95
+
96
+ /**
97
+ * A variant within a component set
98
+ */
99
+ export interface FigmaVariant {
100
+ /** Node ID of the variant */
101
+ node_id: string;
102
+
103
+ /** Full variant name (e.g., "State=Primary, Size=Medium") */
104
+ name: string;
105
+
106
+ /** Parsed properties from the name */
107
+ properties: Record<string, string>;
108
+
109
+ /** Individual property values for matching */
110
+ values: string[];
111
+ }
112
+
113
+ /**
114
+ * Component set with its variants
115
+ */
116
+ export interface FigmaComponentSetWithVariants {
117
+ /** The component set */
118
+ componentSet: FigmaComponent;
119
+
120
+ /** All variants within this component set */
121
+ variants: FigmaVariant[];
122
+ }
123
+
124
+ // ============================================================================
125
+ // Design Property Types (for CSS comparison)
126
+ // ============================================================================
127
+
128
+ /**
129
+ * RGBA color in Figma format (0-1 range)
130
+ */
131
+ export interface FigmaColor {
132
+ r: number;
133
+ g: number;
134
+ b: number;
135
+ a: number;
136
+ }
137
+
138
+ /**
139
+ * Figma fill/paint object
140
+ */
141
+ export interface FigmaFill {
142
+ type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' | 'IMAGE' | 'EMOJI';
143
+ color?: FigmaColor;
144
+ opacity?: number;
145
+ visible?: boolean;
146
+ }
147
+
148
+ /**
149
+ * Figma stroke object
150
+ */
151
+ export interface FigmaStroke {
152
+ type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND';
153
+ color?: FigmaColor;
154
+ opacity?: number;
155
+ visible?: boolean;
156
+ }
157
+
158
+ /**
159
+ * Figma effect (shadow, blur, etc.)
160
+ */
161
+ export interface FigmaEffect {
162
+ type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR';
163
+ visible?: boolean;
164
+ color?: FigmaColor;
165
+ offset?: { x: number; y: number };
166
+ radius?: number;
167
+ spread?: number;
168
+ }
169
+
170
+ /**
171
+ * Figma typography properties
172
+ */
173
+ export interface FigmaTypography {
174
+ fontFamily: string;
175
+ fontStyle: string;
176
+ fontSize: number;
177
+ fontWeight?: number;
178
+ lineHeight?: { value: number; unit: 'PIXELS' | 'PERCENT' | 'AUTO' };
179
+ letterSpacing?: number;
180
+ textAlignHorizontal?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED';
181
+ }
182
+
183
+ /**
184
+ * Extracted design properties from a Figma node
185
+ */
186
+ export interface FigmaDesignProperties {
187
+ /** Node ID */
188
+ nodeId: string;
189
+
190
+ /** Node name */
191
+ name: string;
192
+
193
+ /** Node type */
194
+ type: string;
195
+
196
+ /** Dimensions */
197
+ width?: number;
198
+ height?: number;
199
+
200
+ /** Fill colors */
201
+ fills?: FigmaFill[];
202
+
203
+ /** Stroke/border */
204
+ strokes?: FigmaStroke[];
205
+ strokeWeight?: number;
206
+ strokeAlign?: 'INSIDE' | 'CENTER' | 'OUTSIDE';
207
+
208
+ /** Corner radius */
209
+ cornerRadius?: number;
210
+ topLeftRadius?: number;
211
+ topRightRadius?: number;
212
+ bottomLeftRadius?: number;
213
+ bottomRightRadius?: number;
214
+
215
+ /** Effects (shadows, blur) */
216
+ effects?: FigmaEffect[];
217
+
218
+ /** Typography (for text nodes) */
219
+ typography?: FigmaTypography;
220
+
221
+ /** Auto-layout properties */
222
+ padding?: {
223
+ top?: number;
224
+ right?: number;
225
+ bottom?: number;
226
+ left?: number;
227
+ };
228
+ itemSpacing?: number;
229
+
230
+ /** Opacity */
231
+ opacity?: number;
232
+ }
233
+
234
+ /**
235
+ * CSS-formatted design properties for comparison
236
+ */
237
+ export interface CSSDesignProperties {
238
+ /** Background color */
239
+ backgroundColor?: string;
240
+
241
+ /** Border */
242
+ borderColor?: string;
243
+ borderWidth?: string;
244
+ borderRadius?: string;
245
+
246
+ /** Typography */
247
+ fontFamily?: string;
248
+ fontSize?: string;
249
+ fontWeight?: string;
250
+ lineHeight?: string;
251
+ letterSpacing?: string;
252
+ textAlign?: string;
253
+
254
+ /** Shadow */
255
+ boxShadow?: string;
256
+
257
+ /** Spacing */
258
+ padding?: string;
259
+ gap?: string;
260
+
261
+ /** Opacity */
262
+ opacity?: string;
263
+
264
+ /** Dimensions */
265
+ width?: string;
266
+ height?: string;
267
+ }
268
+
269
+ /**
270
+ * Style comparison result
271
+ */
272
+ export interface StyleDiffResult {
273
+ /** Property name */
274
+ property: string;
275
+
276
+ /** Expected value from Figma */
277
+ figma: string;
278
+
279
+ /** Actual value from rendered component */
280
+ rendered: string;
281
+
282
+ /** Whether values match (within tolerance) */
283
+ match: boolean;
284
+ }
285
+
286
+ interface CacheEntry {
287
+ data: Buffer;
288
+ cdnUrl: string;
289
+ timestamp: number;
290
+ }
291
+
292
+ /**
293
+ * Figma API client with caching.
294
+ * Fetches design frames as PNG images for comparison.
295
+ */
296
+ export class FigmaClient {
297
+ private readonly accessToken: string;
298
+ private readonly cacheTtlMs: number;
299
+ private readonly cache = new Map<string, CacheEntry>();
300
+
301
+ constructor(config: FigmaClientConfig) {
302
+ this.accessToken = config.accessToken;
303
+ this.cacheTtlMs = config.cacheTtlMs ?? 5 * 60 * 1000; // 5 minutes default
304
+ }
305
+
306
+ /**
307
+ * Parse a Figma URL to extract file key and node ID.
308
+ *
309
+ * Supported formats:
310
+ * - https://figma.com/file/abc123/name?node-id=1-2
311
+ * - https://figma.com/design/abc123/name?node-id=1-2
312
+ * - https://www.figma.com/file/abc123/name?node-id=1%3A2 (URL-encoded)
313
+ */
314
+ parseUrl(url: string): FigmaUrlParts {
315
+ // Match both /file/ and /design/ paths
316
+ const urlPattern = /figma\.com\/(?:file|design)\/([^\/]+)\/[^?]*\?.*node-id=([^&]+)/i;
317
+ const match = url.match(urlPattern);
318
+
319
+ if (!match) {
320
+ throw new FigmaError(
321
+ `Invalid Figma URL format: ${url}`,
322
+ 'INVALID_URL',
323
+ 'Expected format: https://figma.com/file/{fileKey}/name?node-id={nodeId}'
324
+ );
325
+ }
326
+
327
+ const fileKey = match[1];
328
+ // Decode URL-encoded node IDs (1%3A2 -> 1:2, 1%2D2 -> 1-2)
329
+ const nodeId = decodeURIComponent(match[2]);
330
+
331
+ return { fileKey, nodeId };
332
+ }
333
+
334
+ /**
335
+ * Fetch an image from Figma by URL.
336
+ * Uses cache if available and not expired.
337
+ */
338
+ async getImageFromUrl(
339
+ url: string,
340
+ options: { scale?: number; format?: 'png' | 'jpg' } = {}
341
+ ): Promise<FigmaImageResult> {
342
+ const { fileKey, nodeId } = this.parseUrl(url);
343
+ return this.getNodeImage(fileKey, nodeId, options);
344
+ }
345
+
346
+ /**
347
+ * Fetch an image for a specific node.
348
+ */
349
+ async getNodeImage(
350
+ fileKey: string,
351
+ nodeId: string,
352
+ options: { scale?: number; format?: 'png' | 'jpg' } = {}
353
+ ): Promise<FigmaImageResult> {
354
+ const { scale = 2, format = 'png' } = options;
355
+
356
+ // Check cache first
357
+ const cacheKey = `${fileKey}:${nodeId}:${scale}:${format}`;
358
+ const cached = this.cache.get(cacheKey);
359
+
360
+ if (cached && Date.now() - cached.timestamp < this.cacheTtlMs) {
361
+ return {
362
+ data: cached.data,
363
+ cdnUrl: cached.cdnUrl,
364
+ fromCache: true,
365
+ };
366
+ }
367
+
368
+ // Fetch from Figma API
369
+ const cdnUrl = await this.fetchImageUrl(fileKey, nodeId, scale, format);
370
+ const data = await this.downloadImage(cdnUrl);
371
+
372
+ // Update cache
373
+ this.cache.set(cacheKey, {
374
+ data,
375
+ cdnUrl,
376
+ timestamp: Date.now(),
377
+ });
378
+
379
+ return {
380
+ data,
381
+ cdnUrl,
382
+ fromCache: false,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Clear the cache (useful for forcing fresh fetches)
388
+ */
389
+ clearCache(): void {
390
+ this.cache.clear();
391
+ }
392
+
393
+ /**
394
+ * Get cache stats for debugging
395
+ */
396
+ getCacheStats(): { size: number; oldestMs: number } {
397
+ let oldest = Date.now();
398
+ for (const entry of this.cache.values()) {
399
+ oldest = Math.min(oldest, entry.timestamp);
400
+ }
401
+ return {
402
+ size: this.cache.size,
403
+ oldestMs: this.cache.size > 0 ? Date.now() - oldest : 0,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Parse a Figma file URL to extract the file key.
409
+ * Works with URLs that may or may not have a node-id.
410
+ */
411
+ parseFileUrl(url: string): { fileKey: string; nodeId?: string } {
412
+ // Match both /file/ and /design/ paths
413
+ const urlPattern = /figma\.com\/(?:file|design)\/([^\/]+)/i;
414
+ const match = url.match(urlPattern);
415
+
416
+ if (!match) {
417
+ throw new FigmaError(
418
+ `Invalid Figma URL format: ${url}`,
419
+ 'INVALID_URL',
420
+ 'Expected format: https://figma.com/file/{fileKey}/...'
421
+ );
422
+ }
423
+
424
+ const fileKey = match[1];
425
+
426
+ // Try to extract node-id if present
427
+ const nodeMatch = url.match(/node-id=([^&]+)/i);
428
+ const nodeId = nodeMatch ? decodeURIComponent(nodeMatch[1]) : undefined;
429
+
430
+ return { fileKey, nodeId };
431
+ }
432
+
433
+ /**
434
+ * Fetch all components from a Figma file.
435
+ * Uses the /v1/files/:key endpoint to get component metadata.
436
+ */
437
+ async getFileComponents(fileKey: string): Promise<FigmaFileComponents> {
438
+ const apiUrl = `https://api.figma.com/v1/files/${fileKey}`;
439
+
440
+ const response = await fetch(apiUrl, {
441
+ headers: {
442
+ 'X-Figma-Token': this.accessToken,
443
+ },
444
+ });
445
+
446
+ if (!response.ok) {
447
+ const errorBody = await response.text().catch(() => 'Unknown error');
448
+
449
+ if (response.status === 403) {
450
+ throw new FigmaError(
451
+ 'Figma access denied',
452
+ 'ACCESS_DENIED',
453
+ 'Check your access token and ensure the file is shared with you'
454
+ );
455
+ }
456
+
457
+ if (response.status === 404) {
458
+ throw new FigmaError(
459
+ `Figma file not found: ${fileKey}`,
460
+ 'NOT_FOUND',
461
+ 'Verify the file key is correct'
462
+ );
463
+ }
464
+
465
+ throw new FigmaError(
466
+ `Figma API error (${response.status}): ${errorBody}`,
467
+ 'API_ERROR'
468
+ );
469
+ }
470
+
471
+ // Use official GetFileResponse type
472
+ const data = (await response.json()) as GetFileResponse;
473
+
474
+ // Convert the component maps to arrays with extended info
475
+ const components: FigmaComponent[] = Object.entries(data.components || {}).map(
476
+ ([nodeId, comp]) => ({
477
+ key: comp.key,
478
+ name: comp.name,
479
+ description: comp.description,
480
+ file_key: fileKey,
481
+ node_id: nodeId,
482
+ componentSetId: comp.componentSetId,
483
+ documentationLinks: comp.documentationLinks,
484
+ remote: comp.remote,
485
+ })
486
+ );
487
+
488
+ const componentSets: FigmaComponent[] = Object.entries(data.componentSets || {}).map(
489
+ ([nodeId, comp]) => ({
490
+ key: comp.key,
491
+ name: comp.name,
492
+ description: comp.description,
493
+ file_key: fileKey,
494
+ node_id: nodeId,
495
+ documentationLinks: comp.documentationLinks,
496
+ remote: comp.remote,
497
+ })
498
+ );
499
+
500
+ return {
501
+ components,
502
+ componentSets,
503
+ fileName: data.name,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Fetch variants for specific component sets.
509
+ * Uses the /v1/files/:key/nodes endpoint to get children of component sets.
510
+ */
511
+ async getComponentSetVariants(
512
+ fileKey: string,
513
+ componentSets: FigmaComponent[]
514
+ ): Promise<FigmaComponentSetWithVariants[]> {
515
+ if (componentSets.length === 0) {
516
+ return [];
517
+ }
518
+
519
+ // Fetch nodes for all component sets
520
+ const nodeIds = componentSets.map((cs) => cs.node_id).join(',');
521
+ const apiUrl = `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${nodeIds}`;
522
+
523
+ const response = await fetch(apiUrl, {
524
+ headers: {
525
+ 'X-Figma-Token': this.accessToken,
526
+ },
527
+ });
528
+
529
+ if (!response.ok) {
530
+ const errorBody = await response.text().catch(() => 'Unknown error');
531
+ throw new FigmaError(
532
+ `Figma API error (${response.status}): ${errorBody}`,
533
+ 'API_ERROR'
534
+ );
535
+ }
536
+
537
+ // Use official GetFileNodesResponse type
538
+ const data = (await response.json()) as GetFileNodesResponse;
539
+
540
+ const results: FigmaComponentSetWithVariants[] = [];
541
+
542
+ for (const componentSet of componentSets) {
543
+ const nodeData = data.nodes[componentSet.node_id];
544
+ if (!nodeData?.document) {
545
+ continue;
546
+ }
547
+
548
+ // Type guard: check if document has children (ComponentSetNode does)
549
+ const doc = nodeData.document as FigmaAPINode & { children?: FigmaAPINode[] };
550
+ if (!doc.children) {
551
+ continue;
552
+ }
553
+
554
+ const variants: FigmaVariant[] = [];
555
+
556
+ for (const child of doc.children) {
557
+ // Only process COMPONENT type nodes (the variants)
558
+ if (child.type !== 'COMPONENT') {
559
+ continue;
560
+ }
561
+
562
+ // Parse the variant name (e.g., "State=Primary, Size=Medium")
563
+ const properties = this.parseVariantName(child.name);
564
+ const values = Object.values(properties);
565
+
566
+ variants.push({
567
+ node_id: child.id,
568
+ name: child.name,
569
+ properties,
570
+ values,
571
+ });
572
+ }
573
+
574
+ results.push({
575
+ componentSet,
576
+ variants,
577
+ });
578
+ }
579
+
580
+ return results;
581
+ }
582
+
583
+ /**
584
+ * Parse a Figma variant name into properties.
585
+ * "State=Primary, Size=Medium" → { State: "Primary", Size: "Medium" }
586
+ */
587
+ parseVariantName(name: string): Record<string, string> {
588
+ const properties: Record<string, string> = {};
589
+
590
+ // Split by comma and parse each property
591
+ const parts = name.split(',').map((p) => p.trim());
592
+
593
+ for (const part of parts) {
594
+ const eqIndex = part.indexOf('=');
595
+ if (eqIndex > 0) {
596
+ const key = part.slice(0, eqIndex).trim();
597
+ const value = part.slice(eqIndex + 1).trim();
598
+ properties[key] = value;
599
+ }
600
+ }
601
+
602
+ return properties;
603
+ }
604
+
605
+ /**
606
+ * Build a Figma URL for a specific node in a file.
607
+ */
608
+ buildNodeUrl(fileKey: string, nodeId: string, fileName?: string): string {
609
+ // URL-encode the node ID (: -> %3A, - is fine)
610
+ const encodedNodeId = encodeURIComponent(nodeId);
611
+ const name = fileName ? encodeURIComponent(fileName.replace(/\s+/g, '-')) : 'Design';
612
+ return `https://www.figma.com/design/${fileKey}/${name}?node-id=${encodedNodeId}`;
613
+ }
614
+
615
+ /**
616
+ * Fetch design properties from a Figma node.
617
+ * Extracts colors, typography, spacing, borders, shadows, etc.
618
+ */
619
+ async getNodeProperties(
620
+ fileKey: string,
621
+ nodeId: string
622
+ ): Promise<FigmaDesignProperties> {
623
+ const apiNodeId = nodeId.replace(/-/g, ':');
624
+ const apiUrl = `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${apiNodeId}`;
625
+
626
+ const response = await fetch(apiUrl, {
627
+ headers: {
628
+ 'X-Figma-Token': this.accessToken,
629
+ },
630
+ });
631
+
632
+ if (!response.ok) {
633
+ const errorBody = await response.text().catch(() => 'Unknown error');
634
+ throw new FigmaError(
635
+ `Figma API error (${response.status}): ${errorBody}`,
636
+ 'API_ERROR'
637
+ );
638
+ }
639
+
640
+ const data = (await response.json()) as GetFileNodesResponse;
641
+ const nodeData = data.nodes[apiNodeId];
642
+
643
+ if (!nodeData?.document) {
644
+ throw new FigmaError(
645
+ `Node not found: ${nodeId}`,
646
+ 'NOT_FOUND',
647
+ 'Verify the node ID is correct'
648
+ );
649
+ }
650
+
651
+ const node = nodeData.document as FigmaAPINode & Record<string, unknown>;
652
+
653
+ // Extract design properties from the node
654
+ const properties: FigmaDesignProperties = {
655
+ nodeId: node.id,
656
+ name: node.name,
657
+ type: node.type,
658
+ };
659
+
660
+ // Dimensions
661
+ if ('absoluteBoundingBox' in node) {
662
+ const bbox = node.absoluteBoundingBox as { width?: number; height?: number };
663
+ properties.width = bbox?.width;
664
+ properties.height = bbox?.height;
665
+ }
666
+
667
+ // Fills (background colors)
668
+ if ('fills' in node && Array.isArray(node.fills)) {
669
+ properties.fills = (node.fills as FigmaFill[]).filter((f) => f.visible !== false);
670
+ }
671
+
672
+ // Strokes (borders)
673
+ if ('strokes' in node && Array.isArray(node.strokes)) {
674
+ properties.strokes = (node.strokes as FigmaStroke[]).filter((s) => s.visible !== false);
675
+ }
676
+ if ('strokeWeight' in node) {
677
+ properties.strokeWeight = node.strokeWeight as number;
678
+ }
679
+ if ('strokeAlign' in node) {
680
+ properties.strokeAlign = node.strokeAlign as 'INSIDE' | 'CENTER' | 'OUTSIDE';
681
+ }
682
+
683
+ // Corner radius
684
+ if ('cornerRadius' in node) {
685
+ properties.cornerRadius = node.cornerRadius as number;
686
+ }
687
+ if ('topLeftRadius' in node) {
688
+ properties.topLeftRadius = node.topLeftRadius as number;
689
+ }
690
+ if ('topRightRadius' in node) {
691
+ properties.topRightRadius = node.topRightRadius as number;
692
+ }
693
+ if ('bottomLeftRadius' in node) {
694
+ properties.bottomLeftRadius = node.bottomLeftRadius as number;
695
+ }
696
+ if ('bottomRightRadius' in node) {
697
+ properties.bottomRightRadius = node.bottomRightRadius as number;
698
+ }
699
+
700
+ // Effects (shadows, blur)
701
+ if ('effects' in node && Array.isArray(node.effects)) {
702
+ properties.effects = (node.effects as FigmaEffect[]).filter((e) => e.visible !== false);
703
+ }
704
+
705
+ // Typography (for text nodes)
706
+ if (node.type === 'TEXT') {
707
+ const fontName = node.fontName as { family?: string; style?: string } | undefined;
708
+ const fontSize = node.fontSize as number | undefined;
709
+ const lineHeight = node.lineHeight as { value?: number; unit?: 'PIXELS' | 'PERCENT' | 'AUTO' } | undefined;
710
+ const letterSpacing = node.letterSpacing as number | undefined;
711
+ const textAlignHorizontal = node.textAlignHorizontal as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED' | undefined;
712
+
713
+ if (fontName && fontSize) {
714
+ properties.typography = {
715
+ fontFamily: fontName.family || 'sans-serif',
716
+ fontStyle: fontName.style || 'Regular',
717
+ fontSize,
718
+ lineHeight: lineHeight ? { value: lineHeight.value || 0, unit: lineHeight.unit || 'AUTO' } : undefined,
719
+ letterSpacing,
720
+ textAlignHorizontal,
721
+ };
722
+ }
723
+ }
724
+
725
+ // Auto-layout padding
726
+ if ('paddingTop' in node || 'paddingRight' in node || 'paddingBottom' in node || 'paddingLeft' in node) {
727
+ properties.padding = {
728
+ top: node.paddingTop as number | undefined,
729
+ right: node.paddingRight as number | undefined,
730
+ bottom: node.paddingBottom as number | undefined,
731
+ left: node.paddingLeft as number | undefined,
732
+ };
733
+ }
734
+ if ('itemSpacing' in node) {
735
+ properties.itemSpacing = node.itemSpacing as number;
736
+ }
737
+
738
+ // Opacity
739
+ if ('opacity' in node) {
740
+ properties.opacity = node.opacity as number;
741
+ }
742
+
743
+ return properties;
744
+ }
745
+
746
+ /**
747
+ * Convert Figma design properties to CSS-formatted values.
748
+ */
749
+ convertToCSS(props: FigmaDesignProperties): CSSDesignProperties {
750
+ const css: CSSDesignProperties = {};
751
+
752
+ // Background color (first visible solid fill)
753
+ if (props.fills && props.fills.length > 0) {
754
+ const solidFill = props.fills.find(
755
+ (f) => f.type === 'SOLID' && f.color && f.visible !== false
756
+ );
757
+ if (solidFill?.color) {
758
+ css.backgroundColor = this.colorToCSS(solidFill.color, solidFill.opacity);
759
+ }
760
+ }
761
+
762
+ // Border color (first visible solid stroke)
763
+ if (props.strokes && props.strokes.length > 0) {
764
+ const solidStroke = props.strokes.find(
765
+ (s) => s.type === 'SOLID' && s.color && s.visible !== false
766
+ );
767
+ if (solidStroke?.color) {
768
+ css.borderColor = this.colorToCSS(solidStroke.color, solidStroke.opacity);
769
+ }
770
+ }
771
+
772
+ // Border width
773
+ if (props.strokeWeight !== undefined) {
774
+ css.borderWidth = `${props.strokeWeight}px`;
775
+ }
776
+
777
+ // Border radius
778
+ if (props.cornerRadius !== undefined) {
779
+ css.borderRadius = `${props.cornerRadius}px`;
780
+ } else if (
781
+ props.topLeftRadius !== undefined ||
782
+ props.topRightRadius !== undefined ||
783
+ props.bottomRightRadius !== undefined ||
784
+ props.bottomLeftRadius !== undefined
785
+ ) {
786
+ css.borderRadius = `${props.topLeftRadius || 0}px ${props.topRightRadius || 0}px ${props.bottomRightRadius || 0}px ${props.bottomLeftRadius || 0}px`;
787
+ }
788
+
789
+ // Box shadow (visible drop shadows only)
790
+ if (props.effects && props.effects.length > 0) {
791
+ const shadows = props.effects
792
+ .filter(
793
+ (e) =>
794
+ e.type === 'DROP_SHADOW' &&
795
+ e.color &&
796
+ e.offset &&
797
+ e.visible !== false
798
+ )
799
+ .map((e) => {
800
+ const color = this.colorToCSS(e.color!, 1);
801
+ const x = e.offset?.x || 0;
802
+ const y = e.offset?.y || 0;
803
+ const blur = e.radius || 0;
804
+ const spread = e.spread || 0;
805
+ return `${x}px ${y}px ${blur}px ${spread}px ${color}`;
806
+ });
807
+
808
+ if (shadows.length > 0) {
809
+ css.boxShadow = shadows.join(', ');
810
+ }
811
+ }
812
+
813
+ // Typography
814
+ if (props.typography) {
815
+ css.fontFamily = props.typography.fontFamily;
816
+ css.fontSize = `${props.typography.fontSize}px`;
817
+
818
+ // Map font style to weight
819
+ const styleToWeight: Record<string, string> = {
820
+ Thin: '100',
821
+ ExtraLight: '200',
822
+ Light: '300',
823
+ Regular: '400',
824
+ Medium: '500',
825
+ SemiBold: '600',
826
+ Bold: '700',
827
+ ExtraBold: '800',
828
+ Black: '900',
829
+ };
830
+ css.fontWeight = styleToWeight[props.typography.fontStyle] || '400';
831
+
832
+ if (props.typography.lineHeight) {
833
+ if (props.typography.lineHeight.unit === 'PIXELS') {
834
+ css.lineHeight = `${props.typography.lineHeight.value}px`;
835
+ } else if (props.typography.lineHeight.unit === 'PERCENT') {
836
+ css.lineHeight = `${props.typography.lineHeight.value}%`;
837
+ }
838
+ }
839
+
840
+ if (props.typography.letterSpacing !== undefined) {
841
+ css.letterSpacing = `${props.typography.letterSpacing}px`;
842
+ }
843
+
844
+ if (props.typography.textAlignHorizontal) {
845
+ css.textAlign = props.typography.textAlignHorizontal.toLowerCase();
846
+ }
847
+ }
848
+
849
+ // Padding
850
+ if (props.padding) {
851
+ const { top = 0, right = 0, bottom = 0, left = 0 } = props.padding;
852
+ if (top === right && right === bottom && bottom === left) {
853
+ css.padding = `${top}px`;
854
+ } else if (top === bottom && left === right) {
855
+ css.padding = `${top}px ${right}px`;
856
+ } else {
857
+ css.padding = `${top}px ${right}px ${bottom}px ${left}px`;
858
+ }
859
+ }
860
+
861
+ // Gap
862
+ if (props.itemSpacing !== undefined) {
863
+ css.gap = `${props.itemSpacing}px`;
864
+ }
865
+
866
+ // Opacity
867
+ if (props.opacity !== undefined && props.opacity !== 1) {
868
+ css.opacity = props.opacity.toFixed(2);
869
+ }
870
+
871
+ // Dimensions
872
+ if (props.width !== undefined) {
873
+ css.width = `${Math.round(props.width)}px`;
874
+ }
875
+ if (props.height !== undefined) {
876
+ css.height = `${Math.round(props.height)}px`;
877
+ }
878
+
879
+ return css;
880
+ }
881
+
882
+ /**
883
+ * Convert Figma RGBA color (0-1 range) to CSS rgba() string.
884
+ */
885
+ colorToCSS(color: FigmaColor, opacity?: number): string {
886
+ const r = Math.round(color.r * 255);
887
+ const g = Math.round(color.g * 255);
888
+ const b = Math.round(color.b * 255);
889
+ const a = opacity !== undefined ? opacity * color.a : color.a;
890
+
891
+ if (a === 1) {
892
+ // Use hex for fully opaque colors
893
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
894
+ }
895
+
896
+ return `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`;
897
+ }
898
+
899
+ /**
900
+ * Fetch image URL from Figma API
901
+ */
902
+ private async fetchImageUrl(
903
+ fileKey: string,
904
+ nodeId: string,
905
+ scale: number,
906
+ format: string
907
+ ): Promise<string> {
908
+ // Figma API expects node IDs with colons, but URLs may use dashes
909
+ // Both formats should work, but let's normalize to what Figma expects
910
+ const apiNodeId = nodeId.replace(/-/g, ':');
911
+
912
+ const apiUrl = new URL(`https://api.figma.com/v1/images/${fileKey}`);
913
+ apiUrl.searchParams.set('ids', apiNodeId);
914
+ apiUrl.searchParams.set('scale', scale.toString());
915
+ apiUrl.searchParams.set('format', format);
916
+
917
+ const response = await fetch(apiUrl.toString(), {
918
+ headers: {
919
+ 'X-Figma-Token': this.accessToken,
920
+ },
921
+ });
922
+
923
+ if (!response.ok) {
924
+ const errorBody = await response.text().catch(() => 'Unknown error');
925
+
926
+ if (response.status === 403) {
927
+ throw new FigmaError(
928
+ 'Figma access denied',
929
+ 'ACCESS_DENIED',
930
+ 'Check your access token and ensure the file is shared with you'
931
+ );
932
+ }
933
+
934
+ if (response.status === 404) {
935
+ throw new FigmaError(
936
+ `Figma file or node not found: ${fileKey}/${nodeId}`,
937
+ 'NOT_FOUND',
938
+ 'Verify the file key and node ID are correct'
939
+ );
940
+ }
941
+
942
+ throw new FigmaError(
943
+ `Figma API error (${response.status}): ${errorBody}`,
944
+ 'API_ERROR'
945
+ );
946
+ }
947
+
948
+ const data = await response.json() as {
949
+ images: Record<string, string | null>;
950
+ err?: string;
951
+ };
952
+
953
+ if (data.err) {
954
+ throw new FigmaError(
955
+ `Figma API error: ${data.err}`,
956
+ 'API_ERROR'
957
+ );
958
+ }
959
+
960
+ // The images object uses the node ID as key
961
+ const imageUrl = data.images[apiNodeId];
962
+
963
+ if (!imageUrl) {
964
+ throw new FigmaError(
965
+ `No image returned for node ${nodeId}`,
966
+ 'NO_IMAGE',
967
+ 'The node may not be exportable or may be empty'
968
+ );
969
+ }
970
+
971
+ return imageUrl;
972
+ }
973
+
974
+ /**
975
+ * Download image from CDN URL
976
+ */
977
+ private async downloadImage(url: string): Promise<Buffer> {
978
+ const response = await fetch(url);
979
+
980
+ if (!response.ok) {
981
+ throw new FigmaError(
982
+ `Failed to download Figma image: ${response.status}`,
983
+ 'DOWNLOAD_ERROR',
984
+ 'The CDN URL may have expired. Try again.'
985
+ );
986
+ }
987
+
988
+ const arrayBuffer = await response.arrayBuffer();
989
+ return Buffer.from(arrayBuffer);
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Error class for Figma-related errors
995
+ */
996
+ export class FigmaError extends ServiceError {
997
+ constructor(message: string, code: string, suggestion?: string) {
998
+ super(message, code, suggestion);
999
+ this.name = `${BRAND.name}FigmaError`;
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Create a FigmaClient instance
1005
+ */
1006
+ export function createFigmaClient(accessToken: string): FigmaClient {
1007
+ return new FigmaClient({ accessToken });
1008
+ }