@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,645 @@
1
+ /**
2
+ * fragments link figma - Link Figma components to segments
3
+ */
4
+
5
+ import { readFile, writeFile } from 'node:fs/promises';
6
+ import { relative } from 'node:path';
7
+ import pc from 'picocolors';
8
+ import { BRAND } from '../../core/index.js';
9
+ import { loadConfig, discoverSegmentFiles } from '../../core/node.js';
10
+ import { FigmaClient } from '../../service/index.js';
11
+
12
+ /**
13
+ * Options for link figma command
14
+ */
15
+ export interface LinkFigmaOptions {
16
+ /** Path to config file */
17
+ config?: string;
18
+ /** Auto-link matching components without prompts */
19
+ auto?: boolean;
20
+ /** Show matches without updating files */
21
+ dryRun?: boolean;
22
+ /** Link individual variants to their Figma frames */
23
+ variants?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Result of link figma command
28
+ */
29
+ export interface LinkFigmaResult {
30
+ success: boolean;
31
+ updated: number;
32
+ variantUpdates: number;
33
+ }
34
+
35
+ /**
36
+ * Segment variant info
37
+ */
38
+ interface SegmentVariantInfo {
39
+ name: string;
40
+ hasFigma: boolean;
41
+ }
42
+
43
+ /**
44
+ * Segment info
45
+ */
46
+ interface SegmentInfo {
47
+ name: string;
48
+ filePath: string;
49
+ relativePath: string;
50
+ hasFigma: boolean;
51
+ variants: SegmentVariantInfo[];
52
+ }
53
+
54
+ /**
55
+ * Match result
56
+ */
57
+ interface Match {
58
+ segment: SegmentInfo;
59
+ figmaComponent: {
60
+ name: string;
61
+ description: string;
62
+ key: string;
63
+ file_key: string;
64
+ node_id: string;
65
+ };
66
+ score: number;
67
+ alreadyLinked?: boolean;
68
+ }
69
+
70
+ /**
71
+ * Run the link figma command
72
+ */
73
+ export async function linkFigma(
74
+ figmaUrl: string | undefined,
75
+ options: LinkFigmaOptions = {}
76
+ ): Promise<LinkFigmaResult> {
77
+ const { config: configPath, auto = false, dryRun = false, variants = true } = options;
78
+
79
+ // Check for Figma access token
80
+ if (!process.env.FIGMA_ACCESS_TOKEN) {
81
+ console.error(pc.red('\nFIGMA_ACCESS_TOKEN environment variable required.'));
82
+ console.log(pc.dim('Generate at: https://www.figma.com/developers/api#access-tokens'));
83
+ console.log(pc.dim(' export FIGMA_ACCESS_TOKEN=figd_xxx'));
84
+ process.exit(1);
85
+ }
86
+
87
+ console.log(pc.cyan(`\n${BRAND.name} Link Wizard\n`));
88
+
89
+ // Load config to check for figmaFile
90
+ const { config, configDir } = await loadConfig(configPath);
91
+
92
+ // Get Figma URL: CLI arg > config > interactive prompt
93
+ let fileUrl = figmaUrl || config.figmaFile;
94
+
95
+ if (!fileUrl) {
96
+ const { input } = await import('@inquirer/prompts');
97
+
98
+ console.log(pc.dim('Tip: Add `figmaFile` to fragments.config.ts to skip this prompt\n'));
99
+
100
+ try {
101
+ fileUrl = await input({
102
+ message: 'Enter Figma file URL:',
103
+ validate: (value) => {
104
+ if (!value.trim()) return 'URL is required';
105
+ if (!value.includes('figma.com')) return 'Please enter a valid Figma URL';
106
+ return true;
107
+ },
108
+ });
109
+ } catch {
110
+ // User cancelled (Ctrl+C)
111
+ console.log(pc.dim('\nNo changes made.'));
112
+ process.exit(0);
113
+ }
114
+ } else if (config.figmaFile && !figmaUrl) {
115
+ console.log(pc.dim(`Using Figma file from config: ${config.figmaFile}\n`));
116
+ }
117
+
118
+ // Import FigmaClient
119
+ const figmaClient = new FigmaClient({ accessToken: process.env.FIGMA_ACCESS_TOKEN });
120
+
121
+ // Parse the URL to get file key
122
+ console.log(pc.dim('Parsing Figma URL...'));
123
+ const { fileKey } = figmaClient.parseFileUrl(fileUrl);
124
+
125
+ // Fetch components from Figma
126
+ console.log(pc.dim('Fetching components from Figma...'));
127
+ const figmaData = await figmaClient.getFileComponents(fileKey);
128
+
129
+ // Use component sets (main components) preferentially
130
+ const allFigmaComponents = figmaData.componentSets.length > 0
131
+ ? figmaData.componentSets
132
+ : figmaData.components;
133
+
134
+ if (allFigmaComponents.length === 0) {
135
+ console.log(pc.yellow('\nNo components found in Figma file.'));
136
+ console.log(pc.dim('Make sure the file contains published components.'));
137
+ process.exit(0);
138
+ }
139
+
140
+ const componentType = figmaData.componentSets.length > 0 ? 'component set' : 'component';
141
+ console.log(pc.green(`✓ Found ${allFigmaComponents.length} Figma ${componentType}(s) in "${figmaData.fileName}"`));
142
+ if (figmaData.components.length > 0 && figmaData.componentSets.length > 0) {
143
+ console.log(pc.dim(` (${figmaData.components.length} individual components also available)\n`));
144
+ } else {
145
+ console.log();
146
+ }
147
+
148
+ // Discover local segments
149
+ const segmentFiles = await discoverSegmentFiles(config, configDir);
150
+
151
+ if (segmentFiles.length === 0) {
152
+ console.log(pc.yellow('No segment files found in codebase.'));
153
+ console.log(pc.dim(`Looking for: ${config.include.join(', ')}`));
154
+ process.exit(0);
155
+ }
156
+
157
+ console.log(pc.dim(`Found ${segmentFiles.length} segment file(s)\n`));
158
+
159
+ // Load segments to get names
160
+ const segments: SegmentInfo[] = [];
161
+ for (const file of segmentFiles) {
162
+ try {
163
+ const content = await readFile(file.absolutePath, 'utf-8');
164
+ // Extract name from meta.name in the file
165
+ const nameMatch = content.match(/name:\s*['"]([^'"]+)['"]/);
166
+ // Check if figma is already set in meta
167
+ const hasFigma = /meta:\s*\{[^}]*figma:\s*['"]https?:/.test(content);
168
+
169
+ // Extract variant names and their figma status
170
+ const segmentVariants = extractVariants(content, nameMatch?.[1]);
171
+
172
+ if (nameMatch) {
173
+ segments.push({
174
+ name: nameMatch[1],
175
+ filePath: file.absolutePath,
176
+ relativePath: file.relativePath,
177
+ hasFigma,
178
+ variants: segmentVariants,
179
+ });
180
+ }
181
+ } catch {
182
+ // Skip files that can't be read
183
+ }
184
+ }
185
+
186
+ // Find matches
187
+ const matches: Match[] = [];
188
+ const unmatchedSegments: SegmentInfo[] = [];
189
+
190
+ for (const segment of segments) {
191
+ // Find best matching Figma component
192
+ let bestMatch: typeof allFigmaComponents[0] | null = null;
193
+ let bestScore = 0;
194
+
195
+ for (const figmaComp of allFigmaComponents) {
196
+ const score = calculateMatchScore(segment.name, figmaComp.name);
197
+
198
+ if (score > bestScore) {
199
+ bestMatch = figmaComp;
200
+ bestScore = score;
201
+
202
+ // Perfect match, no need to continue
203
+ if (score === 100) break;
204
+ }
205
+ }
206
+
207
+ // Accept matches with 65%+ score
208
+ if (bestMatch && bestScore >= 65) {
209
+ const alreadyLinked = segment.hasFigma;
210
+ if (alreadyLinked && !auto) {
211
+ console.log(pc.dim(`⏭️ ${segment.name} (already linked)`));
212
+ }
213
+ matches.push({ segment, figmaComponent: bestMatch, score: bestScore, alreadyLinked });
214
+ } else {
215
+ unmatchedSegments.push(segment);
216
+ }
217
+ }
218
+
219
+ if (unmatchedSegments.length > 0) {
220
+ console.log(pc.dim('Unmatched segments:'));
221
+ for (const seg of unmatchedSegments) {
222
+ console.log(` ${pc.dim('•')} ${seg.name}`);
223
+ }
224
+ console.log();
225
+ }
226
+
227
+ // Split matches into new and already-linked
228
+ const newMatches = matches.filter((m) => !m.alreadyLinked);
229
+ const alreadyLinkedMatches = matches.filter((m) => m.alreadyLinked);
230
+
231
+ if (matches.length === 0) {
232
+ console.log(pc.yellow('\nNo automatic matches found.'));
233
+ console.log(pc.dim('You can manually add figma URLs to your segment definitions.'));
234
+ process.exit(0);
235
+ }
236
+
237
+ // Dry run - just show what would be done
238
+ if (dryRun) {
239
+ if (newMatches.length > 0) {
240
+ console.log(pc.bold('\nMatched Components:\n'));
241
+ for (const match of newMatches) {
242
+ const scoreColor = match.score === 100 ? pc.green : pc.yellow;
243
+ console.log(
244
+ ` ${pc.green('✓')} ${pc.bold(match.segment.name)} → ${match.figmaComponent.name} ${scoreColor(`(${Math.round(match.score)}%)`)}`
245
+ );
246
+ }
247
+ }
248
+ console.log(pc.yellow('\n[Dry run - no files were updated]'));
249
+ return { success: true, updated: 0, variantUpdates: 0 };
250
+ }
251
+
252
+ // Interactive selection or auto mode (only for new components)
253
+ let selectedMatches: Match[] = newMatches;
254
+
255
+ if (newMatches.length > 0) {
256
+ if (auto) {
257
+ // Auto mode - show matches and proceed
258
+ console.log(pc.bold('\nMatched Components:\n'));
259
+ for (const match of newMatches) {
260
+ const scoreColor = match.score === 100 ? pc.green : pc.yellow;
261
+ console.log(
262
+ ` ${pc.green('✓')} ${pc.bold(match.segment.name)} → ${match.figmaComponent.name} ${scoreColor(`(${Math.round(match.score)}%)`)}`
263
+ );
264
+ }
265
+ } else {
266
+ // Interactive selection with checkbox
267
+ const { checkbox } = await import('@inquirer/prompts');
268
+
269
+ console.log(pc.bold('\nMatched Components:\n'));
270
+ console.log(pc.dim('Use ↑/↓ to navigate, Space to toggle, Enter to confirm\n'));
271
+
272
+ const choices = newMatches.map((match) => {
273
+ const scoreColor = match.score === 100 ? pc.green : pc.yellow;
274
+ return {
275
+ name: `${pc.bold(match.segment.name)} → ${match.figmaComponent.name} ${scoreColor(`(${Math.round(match.score)}%)`)}`,
276
+ value: match,
277
+ checked: true,
278
+ };
279
+ });
280
+
281
+ try {
282
+ selectedMatches = await checkbox({
283
+ message: 'Select components to link:',
284
+ choices,
285
+ pageSize: 20,
286
+ });
287
+ } catch {
288
+ // User cancelled (Ctrl+C)
289
+ console.log(pc.dim('\nNo changes made.'));
290
+ return { success: true, updated: 0, variantUpdates: 0 };
291
+ }
292
+ }
293
+ }
294
+
295
+ // Include already-linked matches for variant linking
296
+ const allSelectedMatches = [...selectedMatches, ...alreadyLinkedMatches];
297
+
298
+ // Update segment files (only for new matches, not already-linked)
299
+ let updated = 0;
300
+ for (const match of selectedMatches) {
301
+ if (match.alreadyLinked) continue;
302
+
303
+ try {
304
+ let content = await readFile(match.segment.filePath, 'utf-8');
305
+ const figmaUrlToInsert = figmaClient.buildNodeUrl(
306
+ match.figmaComponent.file_key,
307
+ match.figmaComponent.node_id,
308
+ figmaData.fileName
309
+ );
310
+
311
+ // Check if figma field already exists in meta
312
+ if (/meta:\s*\{[^}]*figma:/.test(content)) {
313
+ // Replace existing figma URL
314
+ content = content.replace(
315
+ /(meta:\s*\{[^}]*figma:\s*['"])([^'"]*)['"]/,
316
+ `$1${figmaUrlToInsert}'`
317
+ );
318
+ } else {
319
+ // Add figma field after category in meta
320
+ content = content.replace(
321
+ /(meta:\s*\{[^}]*category:\s*['"][^'"]*['"],?)/,
322
+ `$1\n figma: '${figmaUrlToInsert}',`
323
+ );
324
+ }
325
+
326
+ await writeFile(match.segment.filePath, content);
327
+ updated++;
328
+ console.log(` ${pc.green('✓')} Updated ${match.segment.relativePath}`);
329
+ } catch (error) {
330
+ console.log(` ${pc.red('✗')} Failed to update ${match.segment.relativePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
331
+ }
332
+ }
333
+
334
+ if (updated > 0) {
335
+ console.log(pc.green(`\n✓ Updated ${updated} segment file(s)\n`));
336
+ }
337
+
338
+ // Variant linking
339
+ let variantUpdates = 0;
340
+ if (variants && figmaData.componentSets.length > 0) {
341
+ variantUpdates = await linkVariants(
342
+ allSelectedMatches,
343
+ figmaData,
344
+ figmaClient,
345
+ fileKey
346
+ );
347
+ }
348
+
349
+ // Next steps
350
+ console.log(pc.dim('───────────────────────────────────────'));
351
+ console.log(pc.bold('\nNext steps:'));
352
+ console.log(` 1. Run ${pc.cyan(`${BRAND.cliCommand} compare --all`)} to verify designs`);
353
+ console.log(` 2. Add figmaProps mappings for prop-level linking`);
354
+ console.log();
355
+
356
+ return { success: true, updated, variantUpdates };
357
+ }
358
+
359
+ /**
360
+ * Extract variants from segment file content
361
+ */
362
+ function extractVariants(content: string, componentName?: string): SegmentVariantInfo[] {
363
+ const variants: SegmentVariantInfo[] = [];
364
+
365
+ // Find variants: [ ... ] section
366
+ const variantsArrayMatch = content.match(/variants:\s*\[/);
367
+ if (variantsArrayMatch && variantsArrayMatch.index !== undefined) {
368
+ const variantsStart = variantsArrayMatch.index + variantsArrayMatch[0].length;
369
+ // Find matching closing bracket
370
+ let bracketCount = 1;
371
+ let variantsEnd = variantsStart;
372
+ while (bracketCount > 0 && variantsEnd < content.length) {
373
+ if (content[variantsEnd] === '[') bracketCount++;
374
+ if (content[variantsEnd] === ']') bracketCount--;
375
+ variantsEnd++;
376
+ }
377
+ const variantsSection = content.slice(variantsStart, variantsEnd - 1);
378
+
379
+ // Find all variant objects within the variants array
380
+ const variantNameRegex = /\{\s*\n?\s*name:\s*['"]([^'"]+)['"]/g;
381
+ let variantMatch;
382
+ while ((variantMatch = variantNameRegex.exec(variantsSection)) !== null) {
383
+ const variantName = variantMatch[1];
384
+ // Skip if this matches the component name
385
+ if (componentName && variantName === componentName) continue;
386
+
387
+ // Find the extent of this variant object
388
+ const objectStart = variantMatch.index;
389
+ let braceCount = 1;
390
+ let objectEnd = objectStart + 1;
391
+ while (braceCount > 0 && objectEnd < variantsSection.length) {
392
+ if (variantsSection[objectEnd] === '{') braceCount++;
393
+ if (variantsSection[objectEnd] === '}') braceCount--;
394
+ objectEnd++;
395
+ }
396
+ const objectContent = variantsSection.slice(objectStart, objectEnd);
397
+
398
+ // Check if this variant has a figma URL
399
+ const variantHasFigma = /figma:\s*['"]https?:/.test(objectContent);
400
+
401
+ variants.push({
402
+ name: variantName,
403
+ hasFigma: variantHasFigma,
404
+ });
405
+ }
406
+ }
407
+
408
+ return variants;
409
+ }
410
+
411
+ /**
412
+ * Calculate match score between two names
413
+ */
414
+ function calculateMatchScore(segmentName: string, figmaName: string): number {
415
+ const normalizeForMatch = (s: string) =>
416
+ s.toLowerCase().replace(/[^a-z0-9]/g, '');
417
+
418
+ const normalizedSegment = normalizeForMatch(segmentName);
419
+ const normalizedFigma = normalizeForMatch(figmaName);
420
+
421
+ // Exact match after normalization
422
+ if (normalizedSegment === normalizedFigma) {
423
+ return 100;
424
+ }
425
+
426
+ // Check if segment name appears at the START of figma name
427
+ if (normalizedFigma.startsWith(normalizedSegment)) {
428
+ const coverage = normalizedSegment.length / normalizedFigma.length;
429
+ return Math.max(85, coverage * 100);
430
+ }
431
+
432
+ // Check if figma name appears at the START of segment name
433
+ if (normalizedSegment.startsWith(normalizedFigma)) {
434
+ const coverage = normalizedFigma.length / normalizedSegment.length;
435
+ return Math.max(80, coverage * 100);
436
+ }
437
+
438
+ // Word-based matching
439
+ const getWords = (s: string): string[] => {
440
+ return s
441
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
442
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
443
+ .toLowerCase()
444
+ .split(/\s+/)
445
+ .filter((w) => w.length > 0);
446
+ };
447
+
448
+ const segmentWords = getWords(segmentName);
449
+ const figmaWords = getWords(figmaName);
450
+
451
+ // Check if all segment words appear in figma words
452
+ const allSegmentWordsInFigma = segmentWords.every((sw) =>
453
+ figmaWords.some((fw) => fw === sw || fw.startsWith(sw) || sw.startsWith(fw))
454
+ );
455
+
456
+ if (allSegmentWordsInFigma && segmentWords.length > 0) {
457
+ const wordOverlap = segmentWords.length / Math.max(segmentWords.length, figmaWords.length);
458
+ return Math.max(75, wordOverlap * 95);
459
+ }
460
+
461
+ // Partial containment
462
+ if (normalizedFigma.includes(normalizedSegment)) {
463
+ return 70;
464
+ }
465
+ if (normalizedSegment.includes(normalizedFigma)) {
466
+ return 65;
467
+ }
468
+
469
+ return 0;
470
+ }
471
+
472
+ /**
473
+ * Link variants to Figma frames
474
+ */
475
+ async function linkVariants(
476
+ matches: Match[],
477
+ figmaData: any,
478
+ figmaClient: FigmaClient,
479
+ fileKey: string
480
+ ): Promise<number> {
481
+ console.log(pc.bold('\nLinking variants...\n'));
482
+
483
+ // Fetch variants for matched component sets
484
+ const matchedComponentSets = matches
485
+ .map((m) => m.figmaComponent)
486
+ .filter((c) => figmaData.componentSets.some((cs: any) => cs.key === c.key));
487
+
488
+ if (matchedComponentSets.length === 0) {
489
+ return 0;
490
+ }
491
+
492
+ console.log(pc.dim('Fetching Figma variants...'));
493
+ const componentSetVariants = await figmaClient.getComponentSetVariants(fileKey, matchedComponentSets);
494
+
495
+ let variantUpdates = 0;
496
+ const { select } = await import('@inquirer/prompts');
497
+
498
+ const normalizeForMatch = (s: string) =>
499
+ s.toLowerCase().replace(/[^a-z0-9]/g, '');
500
+
501
+ const escapeRegExp = (s: string) =>
502
+ s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
503
+
504
+ for (const match of matches) {
505
+ const csWithVariants = componentSetVariants.find(
506
+ (cs) => cs.componentSet.key === match.figmaComponent.key
507
+ );
508
+
509
+ if (!csWithVariants || csWithVariants.variants.length === 0) {
510
+ continue;
511
+ }
512
+
513
+ // Match segment variants to Figma variants
514
+ const segmentVariants = match.segment.variants.filter((v) => !v.hasFigma);
515
+ if (segmentVariants.length === 0) {
516
+ console.log(pc.dim(` ⏭️ ${match.segment.name}: all variants already linked`));
517
+ continue;
518
+ }
519
+
520
+ console.log(pc.dim(` ${match.segment.name}: ${csWithVariants.variants.length} Figma variants`));
521
+
522
+ for (const segmentVariant of segmentVariants) {
523
+ // Find matching Figma variants by score
524
+ const variantMatches: Array<{
525
+ figmaVariant: typeof csWithVariants.variants[0];
526
+ score: number;
527
+ }> = [];
528
+
529
+ for (const fv of csWithVariants.variants) {
530
+ // Check if any property value matches the segment variant name
531
+ const normalizedSegment = normalizeForMatch(segmentVariant.name);
532
+
533
+ for (const value of fv.values) {
534
+ const normalizedValue = normalizeForMatch(value);
535
+
536
+ if (normalizedSegment === normalizedValue) {
537
+ variantMatches.push({ figmaVariant: fv, score: 100 });
538
+ break;
539
+ } else if (normalizedValue.includes(normalizedSegment)) {
540
+ variantMatches.push({ figmaVariant: fv, score: 85 });
541
+ } else if (normalizedSegment.includes(normalizedValue)) {
542
+ variantMatches.push({ figmaVariant: fv, score: 75 });
543
+ }
544
+ }
545
+ }
546
+
547
+ // Sort by score
548
+ variantMatches.sort((a, b) => b.score - a.score);
549
+
550
+ // If perfect match, use it automatically
551
+ if (variantMatches.length > 0 && variantMatches[0].score === 100) {
552
+ const bestMatch = variantMatches[0];
553
+ const variantUrl = figmaClient.buildNodeUrl(
554
+ match.figmaComponent.file_key,
555
+ bestMatch.figmaVariant.node_id,
556
+ figmaData.fileName
557
+ );
558
+
559
+ try {
560
+ let content = await readFile(match.segment.filePath, 'utf-8');
561
+
562
+ // Add figma URL after the variant's name field
563
+ const namePattern = new RegExp(
564
+ `(name:\\s*['"]${escapeRegExp(segmentVariant.name)}['"],?)`,
565
+ 'g'
566
+ );
567
+
568
+ let replaced = false;
569
+ content = content.replace(namePattern, (matchedStr) => {
570
+ if (replaced) return matchedStr;
571
+ replaced = true;
572
+ return `${matchedStr}\n figma: '${variantUrl}',`;
573
+ });
574
+
575
+ await writeFile(match.segment.filePath, content);
576
+ variantUpdates++;
577
+ console.log(
578
+ ` ${pc.green('✓')} ${segmentVariant.name} → ${bestMatch.figmaVariant.name}`
579
+ );
580
+ } catch (error) {
581
+ console.log(
582
+ ` ${pc.red('✗')} ${segmentVariant.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
583
+ );
584
+ }
585
+ } else if (variantMatches.length > 0) {
586
+ // Multiple possible matches - ask user
587
+ const choices = [
588
+ ...variantMatches.slice(0, 4).map((m) => ({
589
+ name: `${m.figmaVariant.name} (${m.score}%)`,
590
+ value: m.figmaVariant,
591
+ })),
592
+ { name: 'Skip this variant', value: null },
593
+ ];
594
+
595
+ try {
596
+ const selectedVariant = await select({
597
+ message: ` Match for "${segmentVariant.name}":`,
598
+ choices,
599
+ });
600
+
601
+ if (selectedVariant) {
602
+ const variantUrl = figmaClient.buildNodeUrl(
603
+ match.figmaComponent.file_key,
604
+ selectedVariant.node_id,
605
+ figmaData.fileName
606
+ );
607
+
608
+ let content = await readFile(match.segment.filePath, 'utf-8');
609
+ const namePattern = new RegExp(
610
+ `(name:\\s*['"]${escapeRegExp(segmentVariant.name)}['"],?)`,
611
+ 'g'
612
+ );
613
+
614
+ let replaced = false;
615
+ content = content.replace(namePattern, (matchedStr) => {
616
+ if (replaced) return matchedStr;
617
+ replaced = true;
618
+ return `${matchedStr}\n figma: '${variantUrl}',`;
619
+ });
620
+
621
+ await writeFile(match.segment.filePath, content);
622
+ variantUpdates++;
623
+ console.log(
624
+ ` ${pc.green('✓')} ${segmentVariant.name} → ${selectedVariant.name}`
625
+ );
626
+ } else {
627
+ console.log(` ${pc.dim('⏭️')} ${segmentVariant.name} (skipped)`);
628
+ }
629
+ } catch {
630
+ console.log(` ${pc.dim('⏭️')} ${segmentVariant.name} (cancelled)`);
631
+ }
632
+ } else {
633
+ console.log(` ${pc.yellow('?')} ${segmentVariant.name}: no matching Figma variant`);
634
+ }
635
+ }
636
+ }
637
+
638
+ if (variantUpdates > 0) {
639
+ console.log(pc.green(`\n✓ Linked ${variantUpdates} variant(s)\n`));
640
+ } else {
641
+ console.log(pc.dim('\nNo variant updates made.\n'));
642
+ }
643
+
644
+ return variantUpdates;
645
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * fragments link - Link external resources to segments
3
+ *
4
+ * This module provides subcommands for:
5
+ * - figma: Link Figma components to segments
6
+ * - storybook: Bootstrap segments from Storybook stories
7
+ */
8
+
9
+ export { linkFigma, type LinkFigmaOptions, type LinkFigmaResult } from './figma.js';
10
+ export { linkStorybook, type LinkStorybookOptions, type LinkStorybookResult } from './storybook.js';