@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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +4783 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-4FDQSGKX.js +786 -0
- package/dist/chunk-4FDQSGKX.js.map +1 -0
- package/dist/chunk-7H2MMGYG.js +369 -0
- package/dist/chunk-7H2MMGYG.js.map +1 -0
- package/dist/chunk-BSCG3IP7.js +619 -0
- package/dist/chunk-BSCG3IP7.js.map +1 -0
- package/dist/chunk-LY2CFFPY.js +898 -0
- package/dist/chunk-LY2CFFPY.js.map +1 -0
- package/dist/chunk-MUZ6CM66.js +6636 -0
- package/dist/chunk-MUZ6CM66.js.map +1 -0
- package/dist/chunk-OAENNG3G.js +1489 -0
- package/dist/chunk-OAENNG3G.js.map +1 -0
- package/dist/chunk-XHNKNI6J.js +235 -0
- package/dist/chunk-XHNKNI6J.js.map +1 -0
- package/dist/core-DWKLGY4N.js +68 -0
- package/dist/core-DWKLGY4N.js.map +1 -0
- package/dist/generate-4LQNJ7SX.js +249 -0
- package/dist/generate-4LQNJ7SX.js.map +1 -0
- package/dist/index.d.ts +775 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/init-EMVI47QG.js +416 -0
- package/dist/init-EMVI47QG.js.map +1 -0
- package/dist/mcp-bin.d.ts +1 -0
- package/dist/mcp-bin.js +1117 -0
- package/dist/mcp-bin.js.map +1 -0
- package/dist/scan-4YPRF7FV.js +12 -0
- package/dist/scan-4YPRF7FV.js.map +1 -0
- package/dist/service-QSZMZJBJ.js +208 -0
- package/dist/service-QSZMZJBJ.js.map +1 -0
- package/dist/static-viewer-MIPGZ4Z7.js +12 -0
- package/dist/static-viewer-MIPGZ4Z7.js.map +1 -0
- package/dist/test-SQ5ZHXWU.js +1067 -0
- package/dist/test-SQ5ZHXWU.js.map +1 -0
- package/dist/tokens-HSGMYK64.js +173 -0
- package/dist/tokens-HSGMYK64.js.map +1 -0
- package/dist/viewer-YRF4SQE4.js +11101 -0
- package/dist/viewer-YRF4SQE4.js.map +1 -0
- package/package.json +107 -0
- package/src/ai.ts +266 -0
- package/src/analyze.ts +265 -0
- package/src/bin.ts +916 -0
- package/src/build.ts +248 -0
- package/src/commands/a11y.ts +302 -0
- package/src/commands/add.ts +313 -0
- package/src/commands/audit.ts +195 -0
- package/src/commands/baseline.ts +221 -0
- package/src/commands/build.ts +144 -0
- package/src/commands/compare.ts +337 -0
- package/src/commands/context.ts +107 -0
- package/src/commands/dev.ts +107 -0
- package/src/commands/enhance.ts +858 -0
- package/src/commands/generate.ts +391 -0
- package/src/commands/init.ts +531 -0
- package/src/commands/link/figma.ts +645 -0
- package/src/commands/link/index.ts +10 -0
- package/src/commands/link/storybook.ts +267 -0
- package/src/commands/list.ts +49 -0
- package/src/commands/metrics.ts +114 -0
- package/src/commands/reset.ts +242 -0
- package/src/commands/scan.ts +537 -0
- package/src/commands/storygen.ts +207 -0
- package/src/commands/tokens.ts +251 -0
- package/src/commands/validate.ts +93 -0
- package/src/commands/verify.ts +215 -0
- package/src/core/composition.test.ts +262 -0
- package/src/core/composition.ts +255 -0
- package/src/core/config.ts +84 -0
- package/src/core/constants.ts +111 -0
- package/src/core/context.ts +380 -0
- package/src/core/defineSegment.ts +137 -0
- package/src/core/discovery.ts +337 -0
- package/src/core/figma.ts +263 -0
- package/src/core/fragment-types.ts +214 -0
- package/src/core/generators/context.ts +389 -0
- package/src/core/generators/index.ts +23 -0
- package/src/core/generators/registry.ts +364 -0
- package/src/core/generators/typescript-extractor.ts +374 -0
- package/src/core/importAnalyzer.ts +217 -0
- package/src/core/index.ts +149 -0
- package/src/core/loader.ts +155 -0
- package/src/core/node.ts +63 -0
- package/src/core/parser.ts +551 -0
- package/src/core/previewLoader.ts +172 -0
- package/src/core/schema/fragment.schema.json +189 -0
- package/src/core/schema/registry.schema.json +137 -0
- package/src/core/schema.ts +182 -0
- package/src/core/storyAdapter.test.ts +571 -0
- package/src/core/storyAdapter.ts +761 -0
- package/src/core/token-types.ts +287 -0
- package/src/core/types.ts +754 -0
- package/src/diff.ts +323 -0
- package/src/index.ts +43 -0
- package/src/mcp/__tests__/projectFields.test.ts +130 -0
- package/src/mcp/bin.ts +36 -0
- package/src/mcp/index.ts +8 -0
- package/src/mcp/server.ts +1310 -0
- package/src/mcp/utils.ts +54 -0
- package/src/mcp-bin.ts +36 -0
- package/src/migrate/__tests__/argTypes/argTypes.test.ts +189 -0
- package/src/migrate/__tests__/args/args.test.ts +452 -0
- package/src/migrate/__tests__/meta/meta.test.ts +198 -0
- package/src/migrate/__tests__/stories/stories.test.ts +278 -0
- package/src/migrate/__tests__/utils/utils.test.ts +371 -0
- package/src/migrate/__tests__/values/values.test.ts +303 -0
- package/src/migrate/bin.ts +108 -0
- package/src/migrate/converter.ts +658 -0
- package/src/migrate/detect.ts +196 -0
- package/src/migrate/index.ts +45 -0
- package/src/migrate/migrate.ts +163 -0
- package/src/migrate/parser.ts +1136 -0
- package/src/migrate/report.ts +624 -0
- package/src/migrate/types.ts +169 -0
- package/src/screenshot.ts +249 -0
- package/src/service/__tests__/ast-utils.test.ts +426 -0
- package/src/service/__tests__/enhance-scanner.test.ts +200 -0
- package/src/service/__tests__/figma/figma.test.ts +652 -0
- package/src/service/__tests__/metrics-store.test.ts +409 -0
- package/src/service/__tests__/patch-generator.test.ts +186 -0
- package/src/service/__tests__/props-extractor.test.ts +365 -0
- package/src/service/__tests__/token-registry.test.ts +267 -0
- package/src/service/analytics.ts +659 -0
- package/src/service/ast-utils.ts +444 -0
- package/src/service/browser-pool.ts +339 -0
- package/src/service/capture.ts +267 -0
- package/src/service/diff.ts +279 -0
- package/src/service/enhance/aggregator.ts +489 -0
- package/src/service/enhance/cache.ts +275 -0
- package/src/service/enhance/codebase-scanner.ts +357 -0
- package/src/service/enhance/context-generator.ts +529 -0
- package/src/service/enhance/doc-extractor.ts +523 -0
- package/src/service/enhance/index.ts +131 -0
- package/src/service/enhance/props-extractor.ts +665 -0
- package/src/service/enhance/scanner.ts +445 -0
- package/src/service/enhance/storybook-parser.ts +552 -0
- package/src/service/enhance/types.ts +346 -0
- package/src/service/enhance/variant-renderer.ts +479 -0
- package/src/service/figma.ts +1008 -0
- package/src/service/index.ts +249 -0
- package/src/service/metrics-store.ts +333 -0
- package/src/service/patch-generator.ts +349 -0
- package/src/service/report.ts +854 -0
- package/src/service/storage.ts +401 -0
- package/src/service/token-fixes.ts +281 -0
- package/src/service/token-parser.ts +504 -0
- package/src/service/token-registry.ts +721 -0
- package/src/service/utils.ts +172 -0
- package/src/setup.ts +241 -0
- package/src/shared/command-wrapper.ts +81 -0
- package/src/shared/dev-server-client.ts +199 -0
- package/src/shared/index.ts +8 -0
- package/src/shared/segment-loader.ts +59 -0
- package/src/shared/types.ts +147 -0
- package/src/static-viewer.ts +715 -0
- package/src/test/discovery.ts +172 -0
- package/src/test/index.ts +281 -0
- package/src/test/reporters/console.ts +194 -0
- package/src/test/reporters/json.ts +190 -0
- package/src/test/reporters/junit.ts +186 -0
- package/src/test/runner.ts +598 -0
- package/src/test/types.ts +245 -0
- package/src/test/watch.ts +200 -0
- package/src/validators.ts +152 -0
- package/src/viewer/__tests__/jsx-parser.test.ts +502 -0
- package/src/viewer/__tests__/render-utils.test.ts +232 -0
- package/src/viewer/__tests__/style-utils.test.ts +404 -0
- package/src/viewer/bin.ts +86 -0
- package/src/viewer/cli/health.ts +256 -0
- package/src/viewer/cli/index.ts +33 -0
- package/src/viewer/cli/scan.ts +124 -0
- package/src/viewer/cli/utils.ts +174 -0
- package/src/viewer/components/AccessibilityPanel.tsx +1404 -0
- package/src/viewer/components/ActionCapture.tsx +172 -0
- package/src/viewer/components/ActionsPanel.tsx +371 -0
- package/src/viewer/components/App.tsx +638 -0
- package/src/viewer/components/BottomPanel.tsx +224 -0
- package/src/viewer/components/CodePanel.tsx +589 -0
- package/src/viewer/components/CommandPalette.tsx +336 -0
- package/src/viewer/components/ComponentGraph.tsx +394 -0
- package/src/viewer/components/ComponentHeader.tsx +85 -0
- package/src/viewer/components/ContractPanel.tsx +234 -0
- package/src/viewer/components/ErrorBoundary.tsx +85 -0
- package/src/viewer/components/FigmaEmbed.tsx +231 -0
- package/src/viewer/components/FragmentEditor.tsx +485 -0
- package/src/viewer/components/HealthDashboard.tsx +452 -0
- package/src/viewer/components/HmrStatusIndicator.tsx +71 -0
- package/src/viewer/components/Icons.tsx +417 -0
- package/src/viewer/components/InteractionsPanel.tsx +720 -0
- package/src/viewer/components/IsolatedPreviewFrame.tsx +321 -0
- package/src/viewer/components/IsolatedRender.tsx +111 -0
- package/src/viewer/components/KeyboardShortcutsHelp.tsx +89 -0
- package/src/viewer/components/LandingPage.tsx +441 -0
- package/src/viewer/components/Layout.tsx +22 -0
- package/src/viewer/components/LeftSidebar.tsx +391 -0
- package/src/viewer/components/MultiViewportPreview.tsx +429 -0
- package/src/viewer/components/PreviewArea.tsx +404 -0
- package/src/viewer/components/PreviewFrameHost.tsx +310 -0
- package/src/viewer/components/PreviewPane.tsx +150 -0
- package/src/viewer/components/PreviewToolbar.tsx +176 -0
- package/src/viewer/components/PropsEditor.tsx +512 -0
- package/src/viewer/components/PropsTable.tsx +98 -0
- package/src/viewer/components/RelationsSection.tsx +57 -0
- package/src/viewer/components/ResizablePanel.tsx +328 -0
- package/src/viewer/components/RightSidebar.tsx +118 -0
- package/src/viewer/components/ScreenshotButton.tsx +90 -0
- package/src/viewer/components/Sidebar.tsx +169 -0
- package/src/viewer/components/SkeletonLoader.tsx +156 -0
- package/src/viewer/components/StoryRenderer.tsx +128 -0
- package/src/viewer/components/ThemeProvider.tsx +96 -0
- package/src/viewer/components/Toast.tsx +67 -0
- package/src/viewer/components/TokenStylePanel.tsx +708 -0
- package/src/viewer/components/UsageSection.tsx +95 -0
- package/src/viewer/components/VariantMatrix.tsx +350 -0
- package/src/viewer/components/VariantRenderer.tsx +131 -0
- package/src/viewer/components/VariantTabs.tsx +84 -0
- package/src/viewer/components/ViewportSelector.tsx +165 -0
- package/src/viewer/components/_future/CreatePage.tsx +836 -0
- package/src/viewer/composition-renderer.ts +381 -0
- package/src/viewer/constants/index.ts +1 -0
- package/src/viewer/constants/ui.ts +185 -0
- package/src/viewer/entry.tsx +299 -0
- package/src/viewer/hooks/index.ts +2 -0
- package/src/viewer/hooks/useA11yCache.ts +383 -0
- package/src/viewer/hooks/useA11yService.ts +498 -0
- package/src/viewer/hooks/useActions.ts +138 -0
- package/src/viewer/hooks/useAppState.ts +124 -0
- package/src/viewer/hooks/useFigmaIntegration.ts +132 -0
- package/src/viewer/hooks/useHmrStatus.ts +109 -0
- package/src/viewer/hooks/useKeyboardShortcuts.ts +222 -0
- package/src/viewer/hooks/usePreviewBridge.ts +347 -0
- package/src/viewer/hooks/useScrollSpy.ts +78 -0
- package/src/viewer/hooks/useUrlState.ts +330 -0
- package/src/viewer/hooks/useViewSettings.ts +125 -0
- package/src/viewer/index.html +28 -0
- package/src/viewer/index.ts +14 -0
- package/src/viewer/intelligence/healthReport.ts +505 -0
- package/src/viewer/intelligence/styleDrift.ts +340 -0
- package/src/viewer/intelligence/usageScanner.ts +309 -0
- package/src/viewer/jsx-parser.ts +485 -0
- package/src/viewer/postcss.config.js +6 -0
- package/src/viewer/preview-frame-entry.tsx +25 -0
- package/src/viewer/preview-frame.html +109 -0
- package/src/viewer/render-template.html +68 -0
- package/src/viewer/render-utils.ts +170 -0
- package/src/viewer/server.ts +276 -0
- package/src/viewer/style-utils.ts +414 -0
- package/src/viewer/styles/globals.css +355 -0
- package/src/viewer/tailwind.config.js +37 -0
- package/src/viewer/types/a11y.ts +197 -0
- package/src/viewer/utils/a11y-fixes.ts +471 -0
- package/src/viewer/utils/actionExport.ts +372 -0
- package/src/viewer/utils/colorSchemes.ts +201 -0
- package/src/viewer/utils/detectRelationships.ts +256 -0
- 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
|
+
}
|