@fragments-sdk/cli 0.9.0 → 0.10.0

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 (166) hide show
  1. package/dist/bin.d.ts +1 -0
  2. package/dist/bin.js +502 -84
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-CJEGT3WD.js → chunk-566BNPQZ.js} +21 -6
  5. package/dist/chunk-566BNPQZ.js.map +1 -0
  6. package/dist/{chunk-WI6SLMSO.js → chunk-CAMXG5HJ.js} +5 -5
  7. package/dist/chunk-D2CDBRNU.js +2 -0
  8. package/dist/{chunk-YMPGYEWK.js → chunk-D5PYOXEI.js} +2 -2
  9. package/dist/{chunk-NGIMCIK2.js → chunk-OQO55NKV.js} +405 -34
  10. package/dist/chunk-OQO55NKV.js.map +1 -0
  11. package/dist/{chunk-TOIE7VXF.js → chunk-PW7QTQA6.js} +2 -2
  12. package/dist/{chunk-AWYCDRPG.js → chunk-WXSR2II7.js} +2 -2
  13. package/dist/chunk-WXSR2II7.js.map +1 -0
  14. package/dist/{chunk-2JIKCJX3.js → chunk-ZDA3PLQ6.js} +17 -14
  15. package/dist/chunk-ZDA3PLQ6.js.map +1 -0
  16. package/dist/core/index.d.ts +1 -2092
  17. package/dist/core/index.js +26 -21
  18. package/dist/{discovery-Z4RDDFVR.js → discovery-NEOY4MPN.js} +3 -3
  19. package/dist/generate-BGKTKO6E.js +459 -0
  20. package/dist/generate-BGKTKO6E.js.map +1 -0
  21. package/dist/index.d.ts +3 -5
  22. package/dist/index.js +7 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/{init-KSAAS7X3.js → init-Q53R5Q2T.js} +66 -76
  25. package/dist/init-Q53R5Q2T.js.map +1 -0
  26. package/dist/mcp-bin.js +5 -7
  27. package/dist/mcp-bin.js.map +1 -1
  28. package/dist/scan-OQU7M4GH.js +14 -0
  29. package/dist/scan-generate-T5QNUG7N.js +691 -0
  30. package/dist/scan-generate-T5QNUG7N.js.map +1 -0
  31. package/dist/{service-A5GIGGGK.js → service-TQYWY65E.js} +4 -5
  32. package/dist/{static-viewer-NSODM5VX.js → static-viewer-NUBFPKWH.js} +4 -5
  33. package/dist/static-viewer-NUBFPKWH.js.map +1 -0
  34. package/dist/{test-RPWZAYSJ.js → test-2CSOSS3B.js} +4 -5
  35. package/dist/{test-RPWZAYSJ.js.map → test-2CSOSS3B.js.map} +1 -1
  36. package/dist/{tokens-NIXSZRX7.js → tokens-DXEGYTOJ.js} +6 -7
  37. package/dist/{tokens-NIXSZRX7.js.map → tokens-DXEGYTOJ.js.map} +1 -1
  38. package/dist/{viewer-SBTJDMP7.js → viewer-DBEPYM3G.js} +245 -23
  39. package/dist/viewer-DBEPYM3G.js.map +1 -0
  40. package/package.json +2 -1
  41. package/src/bin.ts +33 -1
  42. package/src/build.ts +13 -3
  43. package/src/commands/__tests__/scan-generate.test.ts +308 -0
  44. package/src/commands/build.ts +16 -2
  45. package/src/commands/generate.ts +383 -68
  46. package/src/commands/init.ts +81 -56
  47. package/src/commands/perf.ts +1 -1
  48. package/src/commands/scan-generate.ts +1013 -0
  49. package/src/commands/setup.ts +499 -0
  50. package/src/core/auto-props.ts +1 -1
  51. package/src/core/bundle-measurer.ts +2 -2
  52. package/src/core/config.ts +16 -4
  53. package/src/core/discovery.ts +2 -2
  54. package/src/core/generators/context.ts +1 -1
  55. package/src/core/generators/registry.ts +3 -3
  56. package/src/core/generators/typescript-extractor.ts +11 -1
  57. package/src/core/graph-extractor.ts +1 -1
  58. package/src/core/index.ts +3 -190
  59. package/src/core/loader.ts +2 -2
  60. package/src/core/parser.ts +1 -1
  61. package/src/core/previewLoader.ts +1 -1
  62. package/src/index.ts +2 -2
  63. package/src/migrate/converter.ts +9 -1
  64. package/src/migrate/parser.ts +2 -0
  65. package/src/migrate/types.ts +2 -0
  66. package/src/service/snippet-validation.test.ts +1 -1
  67. package/src/service/snippet-validation.ts +2 -2
  68. package/src/setup.ts +69 -24
  69. package/src/viewer/__tests__/viewer-integration.test.ts +4 -10
  70. package/src/viewer/components/AccessibilityPanel.tsx +305 -312
  71. package/src/viewer/components/ActionsPanel.tsx +31 -29
  72. package/src/viewer/components/AllVariantsPreview.tsx +78 -0
  73. package/src/viewer/components/App.tsx +187 -740
  74. package/src/viewer/components/BottomPanel.tsx +228 -132
  75. package/src/viewer/components/CodePanel.tsx +1 -1
  76. package/src/viewer/components/CommandPalette.tsx +7 -10
  77. package/src/viewer/components/ComponentDocView.tsx +164 -0
  78. package/src/viewer/components/ComponentGraph.tsx +111 -142
  79. package/src/viewer/components/ContractPanel.tsx +6 -6
  80. package/src/viewer/components/EmptyVariantMessage.tsx +54 -0
  81. package/src/viewer/components/FigmaEmbed.tsx +20 -18
  82. package/src/viewer/components/FragmentEditor.tsx +92 -115
  83. package/src/viewer/components/HeaderSearch.tsx +24 -0
  84. package/src/viewer/components/HealthDashboard.tsx +16 -2
  85. package/src/viewer/components/Icons.tsx +9 -0
  86. package/src/viewer/components/InteractionsPanel.tsx +101 -117
  87. package/src/viewer/components/IsolatedPreviewFrame.tsx +1 -0
  88. package/src/viewer/components/LandingPage.tsx +3 -3
  89. package/src/viewer/components/LeftSidebar.tsx +141 -63
  90. package/src/viewer/components/LoadErrorMessage.tsx +102 -0
  91. package/src/viewer/components/MultiViewportPreview.tsx +61 -142
  92. package/src/viewer/components/NoVariantsMessage.tsx +59 -0
  93. package/src/viewer/components/PanelShell.tsx +161 -0
  94. package/src/viewer/components/PerformancePanel.tsx +31 -28
  95. package/src/viewer/components/PreviewArea.tsx +1 -1
  96. package/src/viewer/components/PreviewAside.tsx +168 -0
  97. package/src/viewer/components/PreviewFrameHost.tsx +3 -3
  98. package/src/viewer/components/PropsEditor.tsx +70 -156
  99. package/src/viewer/components/ResizablePanel.tsx +103 -263
  100. package/src/viewer/components/RightSidebar.tsx +3 -9
  101. package/src/viewer/components/SkeletonLoader.tsx +13 -13
  102. package/src/viewer/components/TokenStylePanel.tsx +182 -209
  103. package/src/viewer/components/TopToolbar.tsx +159 -0
  104. package/src/viewer/components/VariantMatrix.tsx +42 -86
  105. package/src/viewer/components/VariantTabs.tsx +3 -3
  106. package/src/viewer/components/ViewerHeader.tsx +69 -0
  107. package/src/viewer/components/WebMCPDevTools.tsx +17 -23
  108. package/src/viewer/components/viewer-utils.ts +16 -0
  109. package/src/viewer/entry.tsx +5 -0
  110. package/src/viewer/hooks/useAppState.ts +27 -4
  111. package/src/viewer/hooks/usePreviewBridge.ts +2 -2
  112. package/src/viewer/preview-frame.html +6 -12
  113. package/src/viewer/server.ts +169 -2
  114. package/src/viewer/vendor/shared/src/ComponentDocContent.module.scss +10 -0
  115. package/src/viewer/vendor/shared/src/ComponentDocContent.module.scss.d.ts +2 -0
  116. package/src/viewer/vendor/shared/src/ComponentDocContent.tsx +274 -0
  117. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +6 -18
  118. package/src/viewer/vendor/shared/src/DocsPageShell.tsx +5 -0
  119. package/src/viewer/vendor/shared/src/DocsSidebarNav.tsx +5 -16
  120. package/src/viewer/vendor/shared/src/PropsTable.module.scss +68 -0
  121. package/src/viewer/vendor/shared/src/PropsTable.module.scss.d.ts +2 -0
  122. package/src/viewer/vendor/shared/src/PropsTable.tsx +76 -0
  123. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss +114 -0
  124. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss.d.ts +2 -0
  125. package/src/viewer/vendor/shared/src/VariantPreviewCard.tsx +134 -0
  126. package/src/viewer/vendor/shared/src/index.ts +8 -0
  127. package/src/viewer/vendor/shared/src/types.ts +12 -0
  128. package/src/viewer/vite-plugin.ts +109 -4
  129. package/dist/chunk-2JIKCJX3.js.map +0 -1
  130. package/dist/chunk-AWYCDRPG.js.map +0 -1
  131. package/dist/chunk-CJEGT3WD.js.map +0 -1
  132. package/dist/chunk-EKLMXTWU.js +0 -80
  133. package/dist/chunk-EKLMXTWU.js.map +0 -1
  134. package/dist/chunk-GOVI6COW.js +0 -195
  135. package/dist/chunk-GOVI6COW.js.map +0 -1
  136. package/dist/chunk-NGIMCIK2.js.map +0 -1
  137. package/dist/defineFragment-D0UTve-I.d.ts +0 -665
  138. package/dist/generate-35OIMW4Y.js +0 -252
  139. package/dist/generate-35OIMW4Y.js.map +0 -1
  140. package/dist/init-KSAAS7X3.js.map +0 -1
  141. package/dist/scan-65RH3QMM.js +0 -15
  142. package/dist/viewer-SBTJDMP7.js.map +0 -1
  143. package/src/core/__tests__/preview-runtime.test.tsx +0 -111
  144. package/src/core/composition.test.ts +0 -262
  145. package/src/core/composition.ts +0 -318
  146. package/src/core/constants.ts +0 -114
  147. package/src/core/context.ts +0 -2
  148. package/src/core/defineFragment.ts +0 -141
  149. package/src/core/figma.ts +0 -263
  150. package/src/core/fragment-types.ts +0 -214
  151. package/src/core/performance-presets.ts +0 -142
  152. package/src/core/preview-runtime.tsx +0 -144
  153. package/src/core/schema.ts +0 -221
  154. package/src/core/storyAdapter.test.ts +0 -571
  155. package/src/core/storyAdapter.ts +0 -761
  156. package/src/core/storybook-csf.ts +0 -11
  157. package/src/core/token-parser.ts +0 -321
  158. package/src/core/token-types.ts +0 -287
  159. package/src/core/types.ts +0 -762
  160. /package/dist/{chunk-WI6SLMSO.js.map → chunk-CAMXG5HJ.js.map} +0 -0
  161. /package/dist/{discovery-Z4RDDFVR.js.map → chunk-D2CDBRNU.js.map} +0 -0
  162. /package/dist/{chunk-YMPGYEWK.js.map → chunk-D5PYOXEI.js.map} +0 -0
  163. /package/dist/{chunk-TOIE7VXF.js.map → chunk-PW7QTQA6.js.map} +0 -0
  164. /package/dist/{scan-65RH3QMM.js.map → discovery-NEOY4MPN.js.map} +0 -0
  165. /package/dist/{service-A5GIGGGK.js.map → scan-OQU7M4GH.js.map} +0 -0
  166. /package/dist/{static-viewer-NSODM5VX.js.map → service-TQYWY65E.js.map} +0 -0
@@ -1,17 +1,16 @@
1
1
  /**
2
- * fragments generate - AI-assisted fragment generation
2
+ * fragments generate - Generate fragment files from component source code
3
3
  *
4
- * Analyzes component source code and generates fragment files with:
5
- * - Extracted props from TypeScript
6
- * - Inferred usage patterns from story names
7
- * - Generated description
4
+ * Analyzes component source code and generates proper defineFragment() TSX files
5
+ * colocated next to the component source. These files are parseable by the
6
+ * build command and renderable by the dev viewer.
8
7
  */
9
8
 
10
- import { readFile, writeFile, mkdir, access } from "node:fs/promises";
11
- import { resolve, join, basename, dirname, relative } from "node:path";
9
+ import { readFile, writeFile, access } from "node:fs/promises";
10
+ import { resolve, basename, dirname, relative, join } from "node:path";
12
11
  import pc from "picocolors";
13
12
  import fg from "fast-glob";
14
- import { BRAND, type Fragment } from "../core/index.js";
13
+ import { BRAND } from "../core/index.js";
15
14
  import { extractPropsFromFile } from "../core/node.js";
16
15
 
17
16
  export interface GenerateOptions {
@@ -43,10 +42,6 @@ export async function generate(options: GenerateOptions = {}): Promise<GenerateR
43
42
 
44
43
  console.log(pc.cyan(`\n${BRAND.name} Generate\n`));
45
44
 
46
- // Ensure .fragments/components directory exists
47
- const fragmentsDir = join(projectRoot, BRAND.dataDir, BRAND.componentsDir);
48
- await mkdir(fragmentsDir, { recursive: true });
49
-
50
45
  // Find component files
51
46
  const componentPattern =
52
47
  options.componentPattern ||
@@ -73,15 +68,15 @@ export async function generate(options: GenerateOptions = {}): Promise<GenerateR
73
68
  absolute: true,
74
69
  });
75
70
 
76
- // Build story map for pattern inference
77
- const storyMap = new Map<string, string[]>();
71
+ // Build story map for pattern inference (names + args)
72
+ const storyMap = new Map<string, StoryVariant[]>();
78
73
  for (const storyFile of storyFiles) {
79
74
  try {
80
75
  const content = await readFile(storyFile, "utf-8");
81
76
  const componentName = extractComponentNameFromStory(content, storyFile);
82
77
  if (componentName) {
83
- const storyNames = extractStoryNames(content);
84
- storyMap.set(componentName, storyNames);
78
+ const variants = extractStoryVariants(content);
79
+ storyMap.set(componentName, variants);
85
80
  }
86
81
  } catch {
87
82
  // Ignore parsing errors
@@ -106,8 +101,11 @@ export async function generate(options: GenerateOptions = {}): Promise<GenerateR
106
101
  continue;
107
102
  }
108
103
 
109
- // Check if fragment already exists
110
- const fragmentPath = join(fragmentsDir, `${componentName}${BRAND.fileExtension}`);
104
+ // Write fragment file colocated next to the component source
105
+ const componentDir = dirname(filePath);
106
+ const componentBaseName = basename(filePath, ".tsx");
107
+ const fragmentPath = join(componentDir, `${componentBaseName}${BRAND.fileExtension}`);
108
+
111
109
  let fragmentExists = false;
112
110
  try {
113
111
  await access(fragmentPath);
@@ -122,16 +120,18 @@ export async function generate(options: GenerateOptions = {}): Promise<GenerateR
122
120
  continue;
123
121
  }
124
122
 
125
- // Generate fragment from analysis
126
- const fragment = generateFragmentFromComponent(
123
+ // Generate proper defineFragment() TSX content
124
+ const storyVariants = storyMap.get(componentName) || [];
125
+ const fragmentContent = generateFragmentTsx(
127
126
  componentName,
127
+ componentBaseName,
128
128
  extracted,
129
129
  filePath,
130
- storyMap.get(componentName) || []
130
+ storyVariants
131
131
  );
132
132
 
133
133
  // Write fragment file
134
- await writeFile(fragmentPath, JSON.stringify(fragment, null, 2), "utf-8");
134
+ await writeFile(fragmentPath, fragmentContent, "utf-8");
135
135
  generated.push({ name: componentName, path: relative(projectRoot, fragmentPath) });
136
136
  console.log(pc.green(` ✓ Generated ${componentName}${BRAND.fileExtension}`));
137
137
  } catch (e) {
@@ -166,59 +166,235 @@ export async function generate(options: GenerateOptions = {}): Promise<GenerateR
166
166
  }
167
167
 
168
168
  /**
169
- * Generate a Fragment from extracted component data
169
+ * Generate proper defineFragment() TSX file content
170
170
  */
171
- function generateFragmentFromComponent(
171
+ function generateFragmentTsx(
172
172
  componentName: string,
173
+ componentBaseName: string,
173
174
  extracted: ReturnType<typeof extractPropsFromFile> & { componentName: string },
174
175
  filePath: string,
175
- storyNames: string[]
176
- ): Fragment {
177
- // Infer usage from story names
176
+ storyVariants: StoryVariant[]
177
+ ): string {
178
+ const storyNames = storyVariants.map((v) => v.name);
178
179
  const whenToUse = inferUsageFromStories(storyNames);
179
-
180
- // Generate description from component name
181
180
  const description = generateDescription(componentName, extracted.props);
182
-
183
- // Infer accessibility from props
181
+ const status = inferStatus(filePath);
184
182
  const accessibility = inferAccessibility(extracted.props);
185
183
 
186
- // Infer status from file path
187
- const status = inferStatus(filePath);
184
+ // Build props object for defineFragment
185
+ const propsEntries = Object.entries(extracted.props || {});
186
+
187
+ // Format when array
188
+ const whenLines = whenToUse.length > 0
189
+ ? whenToUse.map((w) => ` '${escapeQuotes(w)}',`).join("\n")
190
+ : ` 'TODO: describe when to use ${componentName}',`;
191
+
192
+ // Format props
193
+ let propsBlock = "{}";
194
+ if (propsEntries.length > 0) {
195
+ const propLines = propsEntries.map(([name, info]) => {
196
+ const propInfo = info as Record<string, unknown>;
197
+ const rawType = propInfo.type ? String(propInfo.type) : "";
198
+ const classified = classifyPropType(rawType);
199
+ const desc = propInfo.description ? String(propInfo.description).replace(/\n/g, " ") : "";
200
+ const required = propInfo.required ? "true" : "false";
201
+ const parts = [` type: '${classified.type}'`];
202
+ if (desc) parts.push(` description: '${escapeQuotes(desc)}'`);
203
+ parts.push(` required: ${required}`);
204
+ if (propInfo.default !== undefined) {
205
+ parts.push(` default: ${JSON.stringify(propInfo.default)}`);
206
+ }
207
+ // Use classified values (from string literal unions) or existing values
208
+ const values = classified.values || (propInfo.values && Array.isArray(propInfo.values) ? propInfo.values : null);
209
+ if (values && values.length > 0) {
210
+ parts.push(` values: ${JSON.stringify(values)}`);
211
+ }
212
+ return ` ${name}: {\n${parts.join(",\n")},\n }`;
213
+ });
214
+ propsBlock = `{\n${propLines.join(",\n")},\n }`;
215
+ }
216
+
217
+ // Build accessibility section
218
+ let accessibilityBlock = "";
219
+ if (accessibility.role || (accessibility.requirements && accessibility.requirements.length > 0)) {
220
+ const parts: string[] = [];
221
+ if (accessibility.role) {
222
+ parts.push(` role: '${accessibility.role}'`);
223
+ }
224
+ if (accessibility.requirements && accessibility.requirements.length > 0) {
225
+ const reqs = accessibility.requirements.map((r) => `'${escapeQuotes(r)}'`).join(", ");
226
+ parts.push(` requirements: [${reqs}]`);
227
+ }
228
+ accessibilityBlock = `
229
+
230
+ accessibility: {
231
+ ${parts.join(",\n")},
232
+ },`;
233
+ }
234
+
235
+ // Build variants from story args or fallback to bare render
236
+ const variants = buildVariants(componentName, storyVariants);
237
+
238
+ // Use default import when the source component uses export default
239
+ const componentImportStatement = extracted.isDefaultExport
240
+ ? `import ${componentName} from './${componentBaseName}';`
241
+ : `import { ${componentName} } from './${componentBaseName}';`;
242
+
243
+ return `import React from 'react';
244
+ import { defineFragment } from '@fragments-sdk/cli/core';
245
+ ${componentImportStatement}
246
+
247
+ export default defineFragment({
248
+ component: ${componentName},
249
+
250
+ meta: {
251
+ name: '${escapeQuotes(componentName)}',
252
+ description: '${escapeQuotes(description)}',
253
+ category: '${inferCategory(componentName, extracted.props)}',
254
+ status: '${status}',
255
+ },
256
+
257
+ usage: {
258
+ when: [
259
+ ${whenLines}
260
+ ],
261
+ whenNot: [],
262
+ },
263
+
264
+ props: ${propsBlock},${accessibilityBlock}
265
+
266
+ variants: [
267
+ ${variants}
268
+ ],
269
+ });
270
+ `;
271
+ }
272
+
273
+ /**
274
+ * Escape single quotes in strings
275
+ */
276
+ function escapeQuotes(str: string): string {
277
+ return str.replace(/'/g, "\\'");
278
+ }
279
+
280
+ /**
281
+ * Classify a raw TypeScript type string into a valid fragment prop type enum value.
282
+ * Returns the classified type and optionally extracted enum values.
283
+ */
284
+ function classifyPropType(rawType: string): { type: string; values?: string[] } {
285
+ const t = rawType.replace(/\s+/g, " ").trim();
286
+ const lower = t.toLowerCase();
287
+
288
+ // Direct primitive matches
289
+ if (lower === "string") return { type: "string" };
290
+ if (lower === "number") return { type: "number" };
291
+ if (lower === "boolean" || lower === "bool") return { type: "boolean" };
292
+
293
+ // String/number literal unions → enum with values
294
+ // e.g., '"primary" | "secondary"' or "'default' | 'modal'"
295
+ if (/^["'][^"']*["'](\s*\|\s*["'][^"']*["'])*$/.test(t)) {
296
+ const values = [...t.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
297
+ if (values.length > 0) return { type: "enum", values };
298
+ }
299
+
300
+ // Function types — arrow functions, callbacks, handlers, dispatchers
301
+ if (
302
+ lower.includes("=>") ||
303
+ lower.includes("handler") ||
304
+ lower.includes("callback") ||
305
+ lower.includes("dispatch") ||
306
+ lower.includes("listener") ||
307
+ /^\(/.test(t)
308
+ ) {
309
+ return { type: "function" };
310
+ }
311
+
312
+ // React node types
313
+ if (
314
+ lower.includes("reactnode") ||
315
+ lower.includes("react.reactnode") ||
316
+ lower === "node"
317
+ ) {
318
+ return { type: "node" };
319
+ }
188
320
 
189
- const fragment: Fragment = {
190
- $schema: "https://fragments.dev/schema/v1.json",
191
- name: componentName,
192
- description,
193
- usage: {
194
- when: whenToUse,
195
- doNot: [],
196
- },
197
- meta: {
198
- status,
199
- },
321
+ // React element types
322
+ if (
323
+ lower.includes("reactelement") ||
324
+ lower.includes("react.reactelement") ||
325
+ lower.includes("jsx.element")
326
+ ) {
327
+ return { type: "element" };
328
+ }
329
+
330
+ // Array types
331
+ if (lower.includes("[]") || lower.startsWith("array<") || lower.startsWith("readonly ")) {
332
+ return { type: "array" };
333
+ }
334
+
335
+ // Object types (must come after array check since objects can contain [])
336
+ if (lower.startsWith("{") || lower.includes("record<") || lower === "object") {
337
+ return { type: "object" };
338
+ }
339
+
340
+ // Union types (mixed types with |)
341
+ if (lower.includes("|")) {
342
+ // Check if it's a union of just string literals (enum)
343
+ const parts = t.split("|").map((p) => p.trim());
344
+ const allStringLiterals = parts.every((p) => /^["'].*["']$/.test(p));
345
+ if (allStringLiterals) {
346
+ const values = parts.map((p) => p.replace(/^["']|["']$/g, ""));
347
+ return { type: "enum", values };
348
+ }
349
+ return { type: "union" };
350
+ }
351
+
352
+ // Anything else
353
+ return { type: "custom" };
354
+ }
355
+
356
+ /**
357
+ * Infer category from component name and props
358
+ */
359
+ function inferCategory(componentName: string, props: Record<string, unknown>): string {
360
+ const lower = componentName.toLowerCase();
361
+
362
+ const categoryPatterns: Record<string, string[]> = {
363
+ "Actions": ["button", "action", "cta", "fab", "floatingaction"],
364
+ "Forms": ["form", "input", "select", "checkbox", "radio", "textarea", "field", "textfield", "datepicker", "switch", "slider", "segmented"],
365
+ "Layout": ["layout", "container", "grid", "flex", "stack", "box", "divider", "spacer", "sidebar"],
366
+ "Navigation": ["nav", "menu", "breadcrumb", "tab", "link", "pagination", "stepper", "topbar"],
367
+ "Feedback": ["alert", "toast", "notification", "message", "badge", "indicator", "progress", "spinner", "loading", "loader", "lozenge", "chip"],
368
+ "Data Display": ["table", "list", "card", "avatar", "stat", "timeline", "tree", "datalist", "datacard"],
369
+ "Overlays": ["modal", "dialog", "drawer", "popover", "tooltip", "dropdown", "slidepanel"],
370
+ "Typography": ["text", "heading", "title", "label", "paragraph"],
371
+ "Media": ["image", "video", "icon", "carousel"],
200
372
  };
201
373
 
202
- // Add accessibility if any was inferred
203
- if (accessibility.role || (accessibility.requirements && accessibility.requirements.length > 0)) {
204
- fragment.accessibility = accessibility;
374
+ for (const [category, patterns] of Object.entries(categoryPatterns)) {
375
+ for (const pattern of patterns) {
376
+ if (lower.includes(pattern)) {
377
+ return category;
378
+ }
379
+ }
205
380
  }
206
381
 
207
- return fragment;
382
+ if ("onClick" in props || "onPress" in props) return "Actions";
383
+ if ("value" in props || "defaultValue" in props) return "Forms";
384
+ if ("children" in props) return "Layout";
385
+
386
+ return "Components";
208
387
  }
209
388
 
210
389
  /**
211
390
  * Extract component name from story file
212
391
  */
213
392
  function extractComponentNameFromStory(content: string, filePath: string): string | null {
214
- // Try to extract from title in default export
215
- // e.g., export default { title: 'Components/Button' }
216
393
  const titleMatch = content.match(/title:\s*['"](?:[^'"]+\/)?([^'"]+)['"]/);
217
394
  if (titleMatch) {
218
395
  return titleMatch[1];
219
396
  }
220
397
 
221
- // Try to extract from file name
222
398
  const fileName = basename(filePath);
223
399
  const componentName = fileName.replace(/\.stories\.(tsx?|jsx?)$/, "");
224
400
  if (/^[A-Z]/.test(componentName)) {
@@ -228,26 +404,174 @@ function extractComponentNameFromStory(content: string, filePath: string): strin
228
404
  return null;
229
405
  }
230
406
 
407
+ interface StoryVariant {
408
+ name: string;
409
+ args: Record<string, unknown>;
410
+ }
411
+
231
412
  /**
232
- * Extract story names from story file content
413
+ * Extract story names and their args from story file content (CSF3 format).
414
+ * Parses patterns like:
415
+ * export const Primary: Story = { args: { variant: 'primary', children: 'Click me' } }
233
416
  */
234
- function extractStoryNames(content: string): string[] {
235
- const names: string[] = [];
417
+ function extractStoryVariants(content: string): StoryVariant[] {
418
+ const variants: StoryVariant[] = [];
236
419
 
237
- // Match named exports that look like stories
238
- // e.g., export const Primary = ...
239
420
  const exportMatches = content.matchAll(
240
421
  /export\s+const\s+([A-Z][a-zA-Z0-9]*)\s*[=:]/g
241
422
  );
242
423
 
243
424
  for (const match of exportMatches) {
244
425
  const name = match[1];
245
- if (name !== "default" && !name.endsWith("Args") && !name.endsWith("Meta")) {
246
- names.push(name);
426
+ if (name === "default" || name.endsWith("Args") || name.endsWith("Meta")) {
427
+ continue;
428
+ }
429
+
430
+ const args = extractStoryArgs(content, name);
431
+ variants.push({ name, args });
432
+ }
433
+
434
+ return variants;
435
+ }
436
+
437
+ /**
438
+ * Extract the `args` object from a named story export.
439
+ * Uses balanced-brace matching to handle nested objects.
440
+ */
441
+ function extractStoryArgs(content: string, storyName: string): Record<string, unknown> {
442
+ // Find the story export and look for an args block
443
+ const storyPattern = new RegExp(
444
+ `export\\s+const\\s+${storyName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`,
445
+ );
446
+ const storyMatch = content.match(storyPattern);
447
+ if (!storyMatch) return {};
448
+
449
+ const storyBody = storyMatch[1];
450
+
451
+ // Find the args block within the story body
452
+ const argsStart = storyBody.indexOf('args:');
453
+ if (argsStart === -1) return {};
454
+
455
+ // Find the opening brace after "args:"
456
+ const braceStart = storyBody.indexOf('{', argsStart);
457
+ if (braceStart === -1) return {};
458
+
459
+ // Balanced brace matching to find the full args object
460
+ let depth = 0;
461
+ let braceEnd = -1;
462
+ for (let i = braceStart; i < storyBody.length; i++) {
463
+ if (storyBody[i] === '{') depth++;
464
+ else if (storyBody[i] === '}') {
465
+ depth--;
466
+ if (depth === 0) {
467
+ braceEnd = i;
468
+ break;
469
+ }
470
+ }
471
+ }
472
+ if (braceEnd === -1) return {};
473
+
474
+ const argsBlock = storyBody.slice(braceStart + 1, braceEnd).trim();
475
+ return parseArgsBlock(argsBlock);
476
+ }
477
+
478
+ /**
479
+ * Parse a simplified args block into key-value pairs.
480
+ * Handles string literals, numbers, booleans, and simple expressions.
481
+ */
482
+ function parseArgsBlock(argsBlock: string): Record<string, unknown> {
483
+ const args: Record<string, unknown> = {};
484
+
485
+ // Match key: value pairs (handles string, number, boolean values)
486
+ const pairPattern = /(\w+)\s*:\s*(?:['"]([^'"]*?)['"]|(true|false)|(\d+(?:\.\d+)?))/g;
487
+ let pairMatch: RegExpExecArray | null;
488
+
489
+ while ((pairMatch = pairPattern.exec(argsBlock)) !== null) {
490
+ const key = pairMatch[1];
491
+ if (pairMatch[2] !== undefined) {
492
+ // String value
493
+ args[key] = pairMatch[2];
494
+ } else if (pairMatch[3] !== undefined) {
495
+ // Boolean
496
+ args[key] = pairMatch[3] === 'true';
497
+ } else if (pairMatch[4] !== undefined) {
498
+ // Number
499
+ args[key] = Number(pairMatch[4]);
247
500
  }
248
501
  }
249
502
 
250
- return names;
503
+ return args;
504
+ }
505
+
506
+ /**
507
+ * Build variant entries from story args.
508
+ * If stories have args, generates JSX with those props.
509
+ * Falls back to a bare `<Component />` for the Default variant.
510
+ */
511
+ function buildVariants(componentName: string, storyVariants: StoryVariant[]): string {
512
+ // Filter to variants with args, plus always include a Default
513
+ const hasDefault = storyVariants.some((v) => v.name === 'Default');
514
+ const entries: string[] = [];
515
+
516
+ if (!hasDefault) {
517
+ entries.push(formatVariantEntry(componentName, 'Default', `Default ${componentName}`, {}));
518
+ }
519
+
520
+ for (const variant of storyVariants) {
521
+ const description = variant.name
522
+ .replace(/([A-Z])/g, ' $1')
523
+ .trim();
524
+ entries.push(formatVariantEntry(componentName, variant.name, `${description} ${componentName}`, variant.args));
525
+ }
526
+
527
+ // Deduplicate: if stories provided a Default, don't double-add
528
+ return entries.join('\n');
529
+ }
530
+
531
+ /**
532
+ * Format a single variant entry with JSX code and render function.
533
+ * Handles `children` as JSX children, all other args as props.
534
+ */
535
+ function formatVariantEntry(
536
+ componentName: string,
537
+ name: string,
538
+ description: string,
539
+ args: Record<string, unknown>
540
+ ): string {
541
+ const jsxCode = buildJsxString(componentName, args);
542
+ return ` {
543
+ name: '${escapeQuotes(name)}',
544
+ description: '${escapeQuotes(description)}',
545
+ code: \`${jsxCode}\`,
546
+ render: () => ${jsxCode},
547
+ },`;
548
+ }
549
+
550
+ /**
551
+ * Build a JSX string from component name and args.
552
+ * `children` string args become JSX children, others become props.
553
+ */
554
+ function buildJsxString(componentName: string, args: Record<string, unknown>): string {
555
+ const { children, ...restArgs } = args;
556
+ const propParts: string[] = [];
557
+
558
+ for (const [key, value] of Object.entries(restArgs)) {
559
+ if (typeof value === 'string') {
560
+ propParts.push(`${key}="${escapeQuotes(value)}"`);
561
+ } else if (typeof value === 'boolean') {
562
+ propParts.push(value ? key : `${key}={false}`);
563
+ } else if (typeof value === 'number') {
564
+ propParts.push(`${key}={${value}}`);
565
+ }
566
+ }
567
+
568
+ const propsStr = propParts.length > 0 ? ' ' + propParts.join(' ') : '';
569
+
570
+ if (typeof children === 'string') {
571
+ return `<${componentName}${propsStr}>${children}</${componentName}>`;
572
+ }
573
+
574
+ return `<${componentName}${propsStr} />`;
251
575
  }
252
576
 
253
577
  /**
@@ -257,13 +581,11 @@ function inferUsageFromStories(storyNames: string[]): string[] {
257
581
  const usage: string[] = [];
258
582
 
259
583
  for (const name of storyNames) {
260
- // Convert PascalCase to sentence
261
584
  const sentence = name
262
585
  .replace(/([A-Z])/g, " $1")
263
586
  .trim()
264
587
  .toLowerCase();
265
588
 
266
- // Skip generic names
267
589
  if (
268
590
  ["default", "primary", "basic", "example", "playground"].includes(
269
591
  sentence.toLowerCase()
@@ -272,7 +594,6 @@ function inferUsageFromStories(storyNames: string[]): string[] {
272
594
  continue;
273
595
  }
274
596
 
275
- // Generate usage from story name
276
597
  if (sentence.includes("loading")) {
277
598
  usage.push("Showing loading states");
278
599
  } else if (sentence.includes("disabled")) {
@@ -284,7 +605,6 @@ function inferUsageFromStories(storyNames: string[]): string[] {
284
605
  } else if (sentence.includes("empty")) {
285
606
  usage.push("Handling empty states");
286
607
  } else if (sentence.includes("with")) {
287
- // "WithIcon" -> "Adding icons"
288
608
  const withPart = sentence.replace("with ", "");
289
609
  usage.push(`Displaying with ${withPart}`);
290
610
  }
@@ -300,13 +620,11 @@ function generateDescription(
300
620
  componentName: string,
301
621
  props: Record<string, unknown>
302
622
  ): string {
303
- // Convert PascalCase to words
304
623
  const words = componentName
305
624
  .replace(/([A-Z])/g, " $1")
306
625
  .trim()
307
626
  .toLowerCase();
308
627
 
309
- // Detect component type from name or props
310
628
  const hasOnClick = "onClick" in props || "onPress" in props;
311
629
  const hasValue = "value" in props || "defaultValue" in props;
312
630
  const hasChildren = "children" in props;
@@ -337,18 +655,15 @@ function inferAccessibility(props: Record<string, unknown>): {
337
655
 
338
656
  const hasOnClick = "onClick" in props || "onPress" in props;
339
657
  const hasAriaLabel = "ariaLabel" in props || "aria-label" in props;
340
- const hasRole = "role" in props;
341
658
  const hasDisabled = "disabled" in props;
342
659
  const hasHref = "href" in props;
343
660
 
344
- // Infer role
345
661
  if (hasOnClick && !hasHref) {
346
662
  accessibility.role = "button";
347
663
  } else if (hasHref) {
348
664
  accessibility.role = "link";
349
665
  }
350
666
 
351
- // Infer requirements
352
667
  const requirements: string[] = [];
353
668
 
354
669
  if (hasOnClick && !hasAriaLabel) {