vds-test-empty 1.0.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.
@@ -0,0 +1,880 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * VDS Story generator
4
+ *
5
+ * Reads vds-output.json and generates:
6
+ * - Storybook .stories.tsx files under src/stories for each component
7
+ * - Foundations .stories.tsx: Colors, Typography, Brand under src/stories/foundations/
8
+ *
9
+ * Usage:
10
+ * node vds-core/story-generator.mjs # generate for all components
11
+ * node vds-core/story-generator.mjs Button # generate only for "Button"
12
+ */
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import { fileURLToPath } from "url";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const PROJECT_ROOT = path.join(__dirname, "..");
19
+ const VDS_OUTPUT = path.join(PROJECT_ROOT, "vds-output.json");
20
+ const SRC_DIR = path.join(PROJECT_ROOT, "src");
21
+ const STORIES_DIR = path.join(SRC_DIR, "stories");
22
+
23
+ // CSS is loaded from .storybook/preview.tsx — never add CSS import to story files.
24
+
25
+ // Components we don't want to auto-generate stories for (project-specific dashboards, heavy UIs, etc.)
26
+ const SKIP_LIST = [
27
+ "AnalysisDashboard",
28
+ "ComponentLibrary",
29
+ "EnterprisePushPanel",
30
+ "FigmaLibraryGenerator",
31
+ "IntegrationGuide",
32
+ "ProjectDropzone",
33
+ "RepoConnect",
34
+ "TokensStudioGuide",
35
+ "NavLink",
36
+ "CodeInput",
37
+ "SyncModeSelector",
38
+ "ToggleGroup",
39
+ "Sidebar",
40
+ "TestComponent",
41
+ "Chart",
42
+ "InputOtp",
43
+ "Resizable",
44
+ "Sonner",
45
+ "ImageWithFallback",
46
+ "ConnectionLines",
47
+ "ScrollToTop",
48
+ "Form",
49
+ "Toaster",
50
+ "Toast",
51
+ ];
52
+
53
+ /** shadcn/ui composite component recipes: component name → imports + render. */
54
+ const RECIPES = {
55
+ Accordion: {
56
+ imports: ["AccordionItem", "AccordionTrigger", "AccordionContent"],
57
+ render: `(args) => (
58
+ <ComponentRef type="single" collapsible {...args}>
59
+ <AccordionItem value="item-1">
60
+ <AccordionTrigger>Is it accessible?</AccordionTrigger>
61
+ <AccordionContent>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
62
+ </AccordionItem>
63
+ <AccordionItem value="item-2">
64
+ <AccordionTrigger>Is it styled?</AccordionTrigger>
65
+ <AccordionContent>Yes. It comes with default styles.</AccordionContent>
66
+ </AccordionItem>
67
+ </ComponentRef>
68
+ )`,
69
+ },
70
+ Dialog: {
71
+ imports: ["DialogTrigger", "DialogContent", "DialogHeader", "DialogTitle", "DialogDescription"],
72
+ render: `(args) => (
73
+ <ComponentRef {...args}>
74
+ <DialogTrigger asChild><button>Open Dialog</button></DialogTrigger>
75
+ <DialogContent>
76
+ <DialogHeader>
77
+ <DialogTitle>Dialog Title</DialogTitle>
78
+ <DialogDescription>This is a dialog description.</DialogDescription>
79
+ </DialogHeader>
80
+ </DialogContent>
81
+ </ComponentRef>
82
+ )`,
83
+ },
84
+ AlertDialog: {
85
+ imports: ["AlertDialogTrigger", "AlertDialogContent", "AlertDialogHeader", "AlertDialogTitle", "AlertDialogDescription", "AlertDialogFooter", "AlertDialogCancel", "AlertDialogAction"],
86
+ render: `(args) => (
87
+ <ComponentRef {...args}>
88
+ <AlertDialogTrigger asChild><button>Open</button></AlertDialogTrigger>
89
+ <AlertDialogContent>
90
+ <AlertDialogHeader>
91
+ <AlertDialogTitle>Are you sure?</AlertDialogTitle>
92
+ <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
93
+ </AlertDialogHeader>
94
+ <AlertDialogFooter>
95
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
96
+ <AlertDialogAction>Continue</AlertDialogAction>
97
+ </AlertDialogFooter>
98
+ </AlertDialogContent>
99
+ </ComponentRef>
100
+ )`,
101
+ },
102
+ Alert: {
103
+ imports: ["AlertTitle", "AlertDescription"],
104
+ render: `(args) => (
105
+ <ComponentRef {...args}>
106
+ <AlertTitle>Heads up!</AlertTitle>
107
+ <AlertDescription>You can add components to your app using the CLI.</AlertDescription>
108
+ </ComponentRef>
109
+ )`,
110
+ },
111
+ Tooltip: {
112
+ imports: ["TooltipProvider", "TooltipTrigger", "TooltipContent"],
113
+ render: `(args) => (
114
+ <TooltipProvider>
115
+ <ComponentRef {...args}>
116
+ <TooltipTrigger asChild><button>Hover me</button></TooltipTrigger>
117
+ <TooltipContent><p>Tooltip content</p></TooltipContent>
118
+ </ComponentRef>
119
+ </TooltipProvider>
120
+ )`,
121
+ },
122
+ Popover: {
123
+ imports: ["PopoverTrigger", "PopoverContent"],
124
+ render: `(args) => (
125
+ <ComponentRef {...args}>
126
+ <PopoverTrigger asChild><button>Open Popover</button></PopoverTrigger>
127
+ <PopoverContent>Place content here.</PopoverContent>
128
+ </ComponentRef>
129
+ )`,
130
+ },
131
+ HoverCard: {
132
+ imports: ["HoverCardTrigger", "HoverCardContent"],
133
+ render: `(args) => (
134
+ <ComponentRef {...args}>
135
+ <HoverCardTrigger asChild><button>Hover</button></HoverCardTrigger>
136
+ <HoverCardContent>Card content on hover.</HoverCardContent>
137
+ </ComponentRef>
138
+ )`,
139
+ },
140
+ Tabs: {
141
+ imports: ["TabsList", "TabsTrigger", "TabsContent"],
142
+ render: `(args) => (
143
+ <ComponentRef defaultValue="tab1" {...args}>
144
+ <TabsList>
145
+ <TabsTrigger value="tab1">Tab 1</TabsTrigger>
146
+ <TabsTrigger value="tab2">Tab 2</TabsTrigger>
147
+ </TabsList>
148
+ <TabsContent value="tab1">Content for tab 1</TabsContent>
149
+ <TabsContent value="tab2">Content for tab 2</TabsContent>
150
+ </ComponentRef>
151
+ )`,
152
+ },
153
+ Table: {
154
+ imports: ["TableHeader", "TableBody", "TableRow", "TableHead", "TableCell"],
155
+ render: `(args) => (
156
+ <ComponentRef {...args}>
157
+ <TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
158
+ <TableBody><TableRow><TableCell>Item 1</TableCell><TableCell>Active</TableCell></TableRow></TableBody>
159
+ </ComponentRef>
160
+ )`,
161
+ },
162
+ DropdownMenu: {
163
+ imports: ["DropdownMenuTrigger", "DropdownMenuContent", "DropdownMenuItem"],
164
+ render: `(args) => (
165
+ <ComponentRef {...args}>
166
+ <DropdownMenuTrigger asChild><button>Open Menu</button></DropdownMenuTrigger>
167
+ <DropdownMenuContent>
168
+ <DropdownMenuItem>Profile</DropdownMenuItem>
169
+ <DropdownMenuItem>Settings</DropdownMenuItem>
170
+ <DropdownMenuItem>Logout</DropdownMenuItem>
171
+ </DropdownMenuContent>
172
+ </ComponentRef>
173
+ )`,
174
+ },
175
+ ContextMenu: {
176
+ imports: ["ContextMenuTrigger", "ContextMenuContent", "ContextMenuItem"],
177
+ render: `(args) => (
178
+ <ComponentRef {...args}>
179
+ <ContextMenuTrigger><div style={{padding: 40, border: '1px dashed #ccc'}}>Right click here</div></ContextMenuTrigger>
180
+ <ContextMenuContent>
181
+ <ContextMenuItem>Back</ContextMenuItem>
182
+ <ContextMenuItem>Forward</ContextMenuItem>
183
+ <ContextMenuItem>Reload</ContextMenuItem>
184
+ </ContextMenuContent>
185
+ </ComponentRef>
186
+ )`,
187
+ },
188
+ Select: {
189
+ imports: ["SelectTrigger", "SelectValue", "SelectContent", "SelectItem"],
190
+ render: `(args) => (
191
+ <ComponentRef {...args}>
192
+ <SelectTrigger className="w-[180px]"><SelectValue placeholder="Select a fruit" /></SelectTrigger>
193
+ <SelectContent>
194
+ <SelectItem value="apple">Apple</SelectItem>
195
+ <SelectItem value="banana">Banana</SelectItem>
196
+ <SelectItem value="orange">Orange</SelectItem>
197
+ </SelectContent>
198
+ </ComponentRef>
199
+ )`,
200
+ },
201
+ Card: {
202
+ imports: ["CardHeader", "CardTitle", "CardDescription", "CardContent", "CardFooter"],
203
+ render: `(args) => (
204
+ <ComponentRef className="w-[340px]" {...args}>
205
+ <CardHeader>
206
+ <CardTitle>Card title</CardTitle>
207
+ <CardDescription>Short description.</CardDescription>
208
+ </CardHeader>
209
+ <CardContent><p>Card body content here.</p></CardContent>
210
+ <CardFooter>Footer</CardFooter>
211
+ </ComponentRef>
212
+ )`,
213
+ },
214
+ Avatar: {
215
+ imports: ["AvatarImage", "AvatarFallback"],
216
+ render: `(args) => (
217
+ <ComponentRef {...args}>
218
+ <AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
219
+ <AvatarFallback>JD</AvatarFallback>
220
+ </ComponentRef>
221
+ )`,
222
+ },
223
+ Checkbox: {
224
+ render: `(args) => (
225
+ <div className="flex items-center space-x-2">
226
+ <ComponentRef id="terms" {...args} />
227
+ <label htmlFor="terms" className="text-sm font-medium">Accept terms</label>
228
+ </div>
229
+ )`,
230
+ },
231
+ Switch: {
232
+ render: `(args) => (
233
+ <div className="flex items-center space-x-2">
234
+ <ComponentRef id="switch" {...args} />
235
+ <label htmlFor="switch" className="text-sm font-medium">Enable notifications</label>
236
+ </div>
237
+ )`,
238
+ },
239
+ RadioGroup: {
240
+ imports: ["RadioGroupItem"],
241
+ render: `(args) => (
242
+ <ComponentRef className="flex flex-col space-y-2" {...args}>
243
+ <div className="flex items-center space-x-2">
244
+ <RadioGroupItem value="comfortable" id="comfortable" />
245
+ <label htmlFor="comfortable" className="text-sm font-medium">Comfortable</label>
246
+ </div>
247
+ <div className="flex items-center space-x-2">
248
+ <RadioGroupItem value="compact" id="compact" />
249
+ <label htmlFor="compact" className="text-sm font-medium">Compact</label>
250
+ </div>
251
+ </ComponentRef>
252
+ )`,
253
+ },
254
+ Sheet: {
255
+ imports: ["SheetTrigger", "SheetContent", "SheetHeader", "SheetTitle", "SheetDescription"],
256
+ render: `(args) => (
257
+ <ComponentRef {...args}>
258
+ <SheetTrigger asChild><button>Open Sheet</button></SheetTrigger>
259
+ <SheetContent>
260
+ <SheetHeader>
261
+ <SheetTitle>Sheet Title</SheetTitle>
262
+ <SheetDescription>Sheet description here.</SheetDescription>
263
+ </SheetHeader>
264
+ </SheetContent>
265
+ </ComponentRef>
266
+ )`,
267
+ },
268
+ Drawer: {
269
+ imports: ["DrawerTrigger", "DrawerContent", "DrawerHeader", "DrawerTitle", "DrawerDescription"],
270
+ render: `(args) => (
271
+ <ComponentRef {...args}>
272
+ <DrawerTrigger asChild><button>Open Drawer</button></DrawerTrigger>
273
+ <DrawerContent>
274
+ <DrawerHeader>
275
+ <DrawerTitle>Drawer Title</DrawerTitle>
276
+ <DrawerDescription>Drawer description here.</DrawerDescription>
277
+ </DrawerHeader>
278
+ </DrawerContent>
279
+ </ComponentRef>
280
+ )`,
281
+ },
282
+ Command: {
283
+ imports: ["CommandInput", "CommandList", "CommandEmpty", "CommandGroup", "CommandItem"],
284
+ render: `(args) => (
285
+ <ComponentRef className="rounded-lg border shadow-md" {...args}>
286
+ <CommandInput placeholder="Type a command..." />
287
+ <CommandList>
288
+ <CommandEmpty>No results found.</CommandEmpty>
289
+ <CommandGroup heading="Suggestions">
290
+ <CommandItem>Calendar</CommandItem>
291
+ <CommandItem>Search</CommandItem>
292
+ <CommandItem>Settings</CommandItem>
293
+ </CommandGroup>
294
+ </CommandList>
295
+ </ComponentRef>
296
+ )`,
297
+ },
298
+ Carousel: {
299
+ imports: ["CarouselContent", "CarouselItem", "CarouselPrevious", "CarouselNext"],
300
+ render: `(args) => (
301
+ <ComponentRef className="w-full max-w-xs" {...args}>
302
+ <CarouselContent>
303
+ <CarouselItem><div className="p-4 text-center border rounded">Slide 1</div></CarouselItem>
304
+ <CarouselItem><div className="p-4 text-center border rounded">Slide 2</div></CarouselItem>
305
+ <CarouselItem><div className="p-4 text-center border rounded">Slide 3</div></CarouselItem>
306
+ </CarouselContent>
307
+ <CarouselPrevious />
308
+ <CarouselNext />
309
+ </ComponentRef>
310
+ )`,
311
+ },
312
+ Collapsible: {
313
+ imports: ["CollapsibleTrigger", "CollapsibleContent"],
314
+ render: `(args) => (
315
+ <ComponentRef {...args}>
316
+ <CollapsibleTrigger asChild><button className="text-sm font-medium">Toggle content</button></CollapsibleTrigger>
317
+ <CollapsibleContent className="mt-2 p-2 border rounded">Hidden content revealed.</CollapsibleContent>
318
+ </ComponentRef>
319
+ )`,
320
+ },
321
+ Menubar: {
322
+ imports: ["MenubarMenu", "MenubarTrigger", "MenubarContent", "MenubarItem"],
323
+ render: `(args) => (
324
+ <ComponentRef {...args}>
325
+ <MenubarMenu>
326
+ <MenubarTrigger>File</MenubarTrigger>
327
+ <MenubarContent>
328
+ <MenubarItem>New</MenubarItem>
329
+ <MenubarItem>Open</MenubarItem>
330
+ <MenubarItem>Save</MenubarItem>
331
+ </MenubarContent>
332
+ </MenubarMenu>
333
+ </ComponentRef>
334
+ )`,
335
+ },
336
+ };
337
+
338
+ function ensureDir(dir) {
339
+ if (!fs.existsSync(dir)) {
340
+ fs.mkdirSync(dir, { recursive: true });
341
+ }
342
+ }
343
+
344
+ /** Detect if component uses router (useLocation, useNavigate, or Link from react-router-dom). */
345
+ function needsRouter(source) {
346
+ if (!source || typeof source !== "string") return false;
347
+ if (/\buseLocation\b|\buseNavigate\b/.test(source)) return true;
348
+ if (/from\s+['"]react-router-dom['"]/.test(source) && /\bLink\b/.test(source)) return true;
349
+ return false;
350
+ }
351
+
352
+ /** Void HTML elements: img, input, hr, br — must not receive children. If component wraps one and has children in props, omit children in story args. */
353
+ function componentWrapsVoidElement(source) {
354
+ if (!source || typeof source !== "string") return false;
355
+ const hasVoid = /return\s+<(?:img|input|hr|br)\b|<\s*(?:img|input|hr|br)\s+/.test(source);
356
+ if (!hasVoid) return false;
357
+ return /\bchildren\b/.test(source);
358
+ }
359
+
360
+ function toSafeComponentName(name, file) {
361
+ if (name && typeof name === "string") {
362
+ return name.replace(/[^A-Za-z0-9]+/g, " ").trim().replace(/\s+([a-z])/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toUpperCase()).replace(/\s+/g, "");
363
+ }
364
+ const base = (file || "").replace(/\.[^.]+$/, "");
365
+ const parts = base.split(/[\\/]/g);
366
+ const last = parts[parts.length - 1] || "Component";
367
+ return last.charAt(0).toUpperCase() + last.slice(1);
368
+ }
369
+
370
+ function parseUnionLiterals(type) {
371
+ if (!type) return [];
372
+ const matches = String(type).match(/"([^"]+)"/g);
373
+ if (!matches) return [];
374
+ return matches.map((s) => s.replace(/"/g, ""));
375
+ }
376
+
377
+ function capitalize(str) {
378
+ if (!str) return "";
379
+ return str.charAt(0).toUpperCase() + str.slice(1);
380
+ }
381
+
382
+ function detectExportStyle(source, componentName) {
383
+ if (!source) return "unknown";
384
+ const hasDefault = /export\s+default\b/.test(source);
385
+ const namedPatterns = [
386
+ new RegExp(`export\\s+\\{[^}]*\\b${componentName}\\b[^}]*\\}`),
387
+ new RegExp(`export\\s+const\\s+${componentName}\\b`),
388
+ new RegExp(`export\\s+function\\s+${componentName}\\b`),
389
+ new RegExp(`export\\s+class\\s+${componentName}\\b`),
390
+ ];
391
+ const hasNamed = namedPatterns.some((re) => re.test(source));
392
+ if (hasDefault && hasNamed) return "both";
393
+ if (hasDefault) return "default";
394
+ if (hasNamed) return "named";
395
+ return "unknown";
396
+ }
397
+
398
+ function buildSpecialStories(componentName, variants) {
399
+ const lines = [];
400
+
401
+ // Simple input-like primitives
402
+ if (componentName === "Input") {
403
+ lines.push(`export const Default: Story = {`);
404
+ lines.push(` args: {`);
405
+ lines.push(` placeholder: "Enter your email...",`);
406
+ lines.push(` type: "email",`);
407
+ lines.push(` },`);
408
+ lines.push(`};`);
409
+ return lines.join("\n");
410
+ }
411
+
412
+ if (componentName === "Textarea") {
413
+ lines.push(`export const Default: Story = {`);
414
+ lines.push(` args: {`);
415
+ lines.push(` placeholder: "Write your message...",`);
416
+ lines.push(` rows: 4,`);
417
+ lines.push(` },`);
418
+ lines.push(`};`);
419
+ return lines.join("\n");
420
+ }
421
+
422
+ if (componentName === "Label") {
423
+ lines.push(`export const Default: Story = {`);
424
+ lines.push(` args: {`);
425
+ lines.push(` children: "Email address",`);
426
+ lines.push(` htmlFor: "email",`);
427
+ lines.push(` },`);
428
+ lines.push(`};`);
429
+ return lines.join("\n");
430
+ }
431
+
432
+ if (componentName === "Slider") {
433
+ lines.push(`export const Default: Story = {`);
434
+ lines.push(` args: {`);
435
+ lines.push(` defaultValue: [50],`);
436
+ lines.push(` max: 100,`);
437
+ lines.push(` step: 1,`);
438
+ lines.push(` },`);
439
+ lines.push(`};`);
440
+ return lines.join("\n");
441
+ }
442
+
443
+ if (componentName === "Calendar") {
444
+ lines.push(`export const Default: Story = {`);
445
+ lines.push(` args: {},`);
446
+ lines.push(`};`);
447
+ return lines.join("\n");
448
+ }
449
+
450
+ if (componentName === "InputOtp" || componentName === "InputOTP") {
451
+ lines.push(`export const Default: Story = {`);
452
+ lines.push(` args: {`);
453
+ lines.push(` maxLength: 6,`);
454
+ lines.push(` },`);
455
+ lines.push(`};`);
456
+ return lines.join("\n");
457
+ }
458
+
459
+ if (componentName === "Badge") {
460
+ const vs = variants && variants.length ? variants : ["default", "secondary", "destructive", "outline"];
461
+ vs.forEach((v, idx) => {
462
+ const storyName = capitalize(v);
463
+ lines.push(`export const ${storyName}: Story = {`);
464
+ lines.push(` args: {`);
465
+ lines.push(` variant: "${v}",`);
466
+ lines.push(` children: "New",`);
467
+ lines.push(` },`);
468
+ lines.push(`};`);
469
+ if (idx < vs.length - 1) lines.push("");
470
+ });
471
+ return lines.join("\n");
472
+ }
473
+
474
+ // Fallback: let the generic variant-based logic or RECIPES handle it
475
+ return "";
476
+ }
477
+
478
+ function buildRecipeStoryContent(comp, componentName, importPath, title, source, exportStyle, recipe) {
479
+ const lines = [];
480
+ lines.push(`import type { Meta, StoryObj } from "@storybook/react";`);
481
+ const useRouterDecorator = needsRouter(source);
482
+ if (exportStyle === "default") {
483
+ lines.push(`import ${componentName} from "${importPath}";`);
484
+ if (recipe.imports?.length) {
485
+ lines.push(`import { ${recipe.imports.join(", ")} } from "${importPath}";`);
486
+ }
487
+ lines.push(`const ComponentRef = ${componentName};`);
488
+ } else if (exportStyle === "named") {
489
+ const names = recipe.imports?.length ? [componentName, ...recipe.imports] : [componentName];
490
+ lines.push(`import { ${names.join(", ")} } from "${importPath}";`);
491
+ lines.push(`const ComponentRef = ${componentName};`);
492
+ } else {
493
+ const defaultAlias = `${componentName}Default`;
494
+ const namedAlias = `${componentName}Named`;
495
+ const extra = recipe.imports?.length ? ", " + recipe.imports.join(", ") : "";
496
+ lines.push(`import ${defaultAlias}, { ${componentName} as ${namedAlias}${extra} } from "${importPath}";`);
497
+ lines.push(`const ComponentRef = ${namedAlias} ?? ${defaultAlias};`);
498
+ }
499
+ for (const ext of recipe.extraImports || []) {
500
+ lines.push(`import { ${ext.names.join(", ")} } from "${ext.from}";`);
501
+ }
502
+ if (useRouterDecorator) lines.push(`import { MemoryRouter } from "react-router-dom";`);
503
+ lines.push("");
504
+ lines.push(`const meta = {`);
505
+ lines.push(` title: ${JSON.stringify(title)},`);
506
+ lines.push(` component: ComponentRef,`);
507
+ lines.push(` tags: ["autodocs"],`);
508
+ if (useRouterDecorator) {
509
+ lines.push(` decorators: [(Story) => (`);
510
+ lines.push(` <MemoryRouter>`);
511
+ lines.push(` <Story />`);
512
+ lines.push(` </MemoryRouter>`);
513
+ lines.push(` )],`);
514
+ }
515
+ lines.push(`} satisfies Meta<typeof ComponentRef>;`);
516
+ lines.push("");
517
+ lines.push(`export default meta;`);
518
+ lines.push(`type Story = StoryObj<typeof meta>;`);
519
+ lines.push("");
520
+ lines.push(`export const Default: Story = {`);
521
+ lines.push(` render: ${recipe.render},`);
522
+ lines.push(`};`);
523
+ return lines.join("\n");
524
+ }
525
+
526
+ function buildStoryFileContent(comp) {
527
+ const componentName = toSafeComponentName(comp.name, comp.file);
528
+ const fileNoExt = comp.file.replace(/\.(tsx|jsx)$/, "");
529
+ const importPath = `@/components/${fileNoExt}`;
530
+ const group = comp.group || "Components";
531
+ const category = comp.category || null;
532
+ const titleParts = [group, category, componentName].filter(Boolean);
533
+ const title = titleParts.join("/");
534
+
535
+ const props = Array.isArray(comp.props) ? comp.props : [];
536
+ const variantProp = props.find((p) => p.name === "variant");
537
+ let variants = parseUnionLiterals(variantProp && variantProp.type);
538
+
539
+ // Fallback: if manifest doesn't have variant metadata yet, parse cva() directly from component file.
540
+ if (!variants.length) {
541
+ try {
542
+ const srcPath = path.join(SRC_DIR, "components", comp.file);
543
+ if (fs.existsSync(srcPath)) {
544
+ const code = fs.readFileSync(srcPath, "utf-8");
545
+ // Roughly match shadcn-style: variants: { variant: { ... }, size: { ... } }
546
+ const m = code.match(/variant\s*:\s*{([\s\S]*?)}\s*,\s*size\s*:/);
547
+ if (m) {
548
+ const body = m[1];
549
+ const names = [];
550
+ const lineRe = /^\s*([A-Za-z0-9_]+)\s*:/gm;
551
+ let lm;
552
+ while ((lm = lineRe.exec(body))) {
553
+ names.push(lm[1]);
554
+ }
555
+ if (names.length) variants = names;
556
+ }
557
+ }
558
+ } catch {
559
+ // best-effort; ignore parsing errors
560
+ }
561
+ }
562
+
563
+ // Read component source to detect export style for import
564
+ let source = "";
565
+ try {
566
+ const srcPath = path.join(SRC_DIR, "components", comp.file);
567
+ if (fs.existsSync(srcPath)) {
568
+ source = fs.readFileSync(srcPath, "utf-8");
569
+ }
570
+ } catch {
571
+ // ignore
572
+ }
573
+ const exportStyle = detectExportStyle(source, componentName);
574
+ const omitChildren = componentWrapsVoidElement(source);
575
+
576
+ if (RECIPES[componentName]) {
577
+ return buildRecipeStoryContent(comp, componentName, importPath, title, source, exportStyle, RECIPES[componentName]);
578
+ }
579
+
580
+ const lines = [];
581
+ lines.push(`import type { Meta, StoryObj } from "@storybook/react";`);
582
+
583
+ if (exportStyle === "default") {
584
+ lines.push(`import ${componentName} from "${importPath}";`);
585
+ lines.push(`const ComponentRef = ${componentName};`);
586
+ } else if (exportStyle === "named") {
587
+ lines.push(`import { ${componentName} } from "${importPath}";`);
588
+ lines.push(`const ComponentRef = ${componentName};`);
589
+ } else {
590
+ const defaultAlias = `${componentName}Default`;
591
+ const namedAlias = `${componentName}Named`;
592
+ lines.push(
593
+ `import ${defaultAlias}, { ${componentName} as ${namedAlias} } from "${importPath}";`,
594
+ );
595
+ lines.push(`const ComponentRef = ${namedAlias} ?? ${defaultAlias};`);
596
+ }
597
+
598
+ const useRouterDecorator = needsRouter(source);
599
+ if (useRouterDecorator) {
600
+ lines.push(`import { MemoryRouter } from "react-router-dom";`);
601
+ }
602
+
603
+ lines.push("");
604
+ lines.push(`const meta = {`);
605
+ lines.push(` title: ${JSON.stringify(title)},`);
606
+ lines.push(` component: ComponentRef,`);
607
+ lines.push(` tags: ["autodocs"],`);
608
+ if (useRouterDecorator) {
609
+ lines.push(` decorators: [(Story) => (`);
610
+ lines.push(` <MemoryRouter>`);
611
+ lines.push(` <Story />`);
612
+ lines.push(` </MemoryRouter>`);
613
+ lines.push(` )],`);
614
+ }
615
+ lines.push(`} satisfies Meta<typeof ComponentRef>;`);
616
+ lines.push("");
617
+ lines.push(`export default meta;`);
618
+ lines.push(`type Story = StoryObj<typeof meta>;`);
619
+ lines.push("");
620
+
621
+ // Component-specific stories (inputs, composite components, etc.)
622
+ const specialStories = buildSpecialStories(componentName, variants);
623
+ if (specialStories) {
624
+ lines.push(specialStories);
625
+ return lines.join("\n");
626
+ }
627
+
628
+ // Generic variant-based stories (omit children for img/void components)
629
+ if (!variants.length) {
630
+ lines.push(`export const Default: Story = {`);
631
+ lines.push(` args: {`);
632
+ if (!omitChildren) lines.push(` children: "${componentName}",`);
633
+ lines.push(` },`);
634
+ lines.push(`};`);
635
+ } else {
636
+ const defaultVariant = variants[0];
637
+ lines.push(`export const ${capitalize(defaultVariant)}: Story = {`);
638
+ lines.push(` args: {`);
639
+ lines.push(` variant: "${defaultVariant}",`);
640
+ if (!omitChildren) lines.push(` children: "${componentName}",`);
641
+ lines.push(` },`);
642
+ lines.push(`};`);
643
+ lines.push("");
644
+ for (const v of variants.slice(1)) {
645
+ const storyName = capitalize(v);
646
+ lines.push(`export const ${storyName}: Story = {`);
647
+ lines.push(` args: {`);
648
+ lines.push(` variant: "${v}",`);
649
+ if (!omitChildren) lines.push(` children: "${storyName}",`);
650
+ lines.push(` },`);
651
+ lines.push(`};`);
652
+ lines.push("");
653
+ }
654
+ }
655
+
656
+ return lines.join("\n");
657
+ }
658
+
659
+ /** Build color entries from foundations.colors (skip _dark; flatten to { name, hex }). */
660
+ function getColorEntries(colors) {
661
+ if (!colors || typeof colors !== "object") return [];
662
+ const entries = [];
663
+ for (const [name, v] of Object.entries(colors)) {
664
+ if (name === "_dark") continue;
665
+ const hex = v && (v.hex ?? (typeof v.value === "string" && v.value.startsWith("#") ? v.value : null));
666
+ const value = v && v.value;
667
+ if (hex) entries.push({ name, hex });
668
+ else if (value) entries.push({ name, hex: value });
669
+ }
670
+ return entries;
671
+ }
672
+
673
+ function writeFoundationsStories(foundations) {
674
+ const foundationsDir = path.join(STORIES_DIR, "foundations");
675
+ ensureDir(foundationsDir);
676
+
677
+ const colorEntries = getColorEntries(foundations?.colors);
678
+ const colorsContent =
679
+ [
680
+ "import type { Meta, StoryObj } from \"@storybook/react\";",
681
+ "",
682
+ "const meta = { title: \"Foundations/Colors\" } satisfies Meta;",
683
+ "export default meta;",
684
+ "type Story = StoryObj;",
685
+ "",
686
+ `const colors = ${JSON.stringify(colorEntries)};`,
687
+ "",
688
+ "export const Default: Story = {",
689
+ " render: () => (",
690
+ " <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\", gap: \"1rem\" }}>",
691
+ " {colors.map(({ name, hex }) => (",
692
+ " <div key={name}>",
693
+ " <div style={{ backgroundColor: hex, height: 80, borderRadius: 8, border: \"1px solid #333\" }} />",
694
+ " <p style={{ marginTop: 8, fontSize: 12 }}>{name}</p>",
695
+ " <code style={{ fontSize: 11 }}>{hex}</code>",
696
+ " </div>",
697
+ " ))}",
698
+ " </div>",
699
+ " ),",
700
+ "};",
701
+ ].join("\n");
702
+ fs.writeFileSync(path.join(foundationsDir, "Colors.stories.tsx"), colorsContent, "utf-8");
703
+ console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Colors.stories.tsx")));
704
+
705
+ const typo = foundations?.typography;
706
+ if (typo && typeof typo === "object" && Object.keys(typo).length > 0) {
707
+ const typoRows = Object.entries(typo).map(([k, v]) => ({
708
+ token: k,
709
+ value: Array.isArray(v) ? v.join(", ") : String(v),
710
+ }));
711
+ const typoContent =
712
+ [
713
+ "import type { Meta, StoryObj } from \"@storybook/react\";",
714
+ "",
715
+ "const meta = { title: \"Foundations/Typography\" } satisfies Meta;",
716
+ "export default meta;",
717
+ "type Story = StoryObj;",
718
+ "",
719
+ `const typography = ${JSON.stringify(typoRows)};`,
720
+ "",
721
+ "export const Default: Story = {",
722
+ " render: () => (",
723
+ " <div>",
724
+ " <h2 style={{ marginBottom: 16 }}>Font family & scale</h2>",
725
+ " <table style={{ borderCollapse: \"collapse\", width: \"100%\" }}>",
726
+ " <thead><tr><th style={{ textAlign: \"left\", padding: 8, borderBottom: \"1px solid #333\" }}>Token</th><th style={{ textAlign: \"left\", padding: 8, borderBottom: \"1px solid #333\" }}>Value</th></tr></thead>",
727
+ " <tbody>",
728
+ " {typography.map(({ token, value }) => (",
729
+ " <tr key={token}><td style={{ padding: 8, borderBottom: \"1px solid #222\" }}>{token}</td><td style={{ padding: 8, borderBottom: \"1px solid #222\" }}>{value}</td></tr>",
730
+ " ))}",
731
+ " </tbody>",
732
+ " </table>",
733
+ " </div>",
734
+ " ),",
735
+ "};",
736
+ ].join("\n");
737
+ fs.writeFileSync(path.join(foundationsDir, "Typography.stories.tsx"), typoContent, "utf-8");
738
+ console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Typography.stories.tsx")));
739
+ }
740
+
741
+ const brandAssets = foundations?.brand?.assets;
742
+ const assets = Array.isArray(brandAssets) ? brandAssets : [];
743
+ const brandContent =
744
+ [
745
+ "import type { Meta, StoryObj } from \"@storybook/react\";",
746
+ "",
747
+ "const meta = { title: \"Foundations/Brand\" } satisfies Meta;",
748
+ "export default meta;",
749
+ "type Story = StoryObj;",
750
+ "",
751
+ `const assets = ${JSON.stringify(assets)};`,
752
+ "",
753
+ "export const Default: Story = {",
754
+ " render: () => (",
755
+ " <div>",
756
+ " <h2 style={{ marginBottom: 16 }}>Logo and favicon paths</h2>",
757
+ " <ul style={{ listStyle: \"none\", padding: 0 }}>",
758
+ " {assets.length === 0 ? <li>No brand assets found.</li> : assets.map((a, i) => (",
759
+ " <li key={i} style={{ padding: \"8px 0\", borderBottom: \"1px solid #222\" }}>",
760
+ " <strong>{a.type || \"asset\"}</strong>: <code>{a.path || a.name || \"\"}</code>",
761
+ " </li>",
762
+ " ))}",
763
+ " </ul>",
764
+ " </div>",
765
+ " ),",
766
+ "};",
767
+ ].join("\n");
768
+ fs.writeFileSync(path.join(foundationsDir, "Brand.stories.tsx"), brandContent, "utf-8");
769
+ console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Brand.stories.tsx")));
770
+ }
771
+
772
+ function writeComponentSuggestionsStory(componentSuggestions) {
773
+ if (!Array.isArray(componentSuggestions) || componentSuggestions.length === 0) return;
774
+ const foundationsDir = path.join(STORIES_DIR, "foundations");
775
+ ensureDir(foundationsDir);
776
+ const suggestions = componentSuggestions.map((s) => ({
777
+ suggestedName: s.suggestedName,
778
+ occurrences: s.occurrences,
779
+ foundIn: s.foundIn,
780
+ pattern: s.pattern,
781
+ snippet: s.snippet,
782
+ reason: s.reason,
783
+ }));
784
+ const content =
785
+ [
786
+ "import type { Meta, StoryObj } from \"@storybook/react\";",
787
+ "",
788
+ "const meta = { title: \"Foundations/Component Suggestions\" } satisfies Meta;",
789
+ "export default meta;",
790
+ "type Story = StoryObj;",
791
+ "",
792
+ `const suggestions = ${JSON.stringify(suggestions)};`,
793
+ "",
794
+ "export const Default: Story = {",
795
+ " render: () => (",
796
+ " <div style={{ padding: 24, fontFamily: \"system-ui, sans-serif\" }}>",
797
+ " <h2 style={{ marginBottom: 16 }}>Repeated className patterns (component candidates)</h2>",
798
+ " <p style={{ color: \"#888\", marginBottom: 24 }}>Patterns with 3+ classes appearing 2+ times across src/pages. Consider extracting as components.</p>",
799
+ " <div style={{ display: \"flex\", flexDirection: \"column\", gap: 24 }}>",
800
+ " {suggestions.map((s, i) => (",
801
+ " <div key={i} style={{ border: \"1px solid #333\", borderRadius: 8, padding: 16, background: \"#111\" }}>",
802
+ " <div style={{ fontWeight: 600, marginBottom: 8 }}>{s.suggestedName}</div>",
803
+ " <div style={{ fontSize: 12, color: \"#888\", marginBottom: 8 }}>{s.reason} · {s.foundIn.join(\", \")}</div>",
804
+ " <pre style={{ margin: 0, padding: 12, background: \"#000\", borderRadius: 4, overflow: \"auto\", fontSize: 12 }}><code>{s.snippet}</code></pre>",
805
+ " </div>",
806
+ " ))}",
807
+ " </div>",
808
+ " </div>",
809
+ " ),",
810
+ "};",
811
+ ].join("\n");
812
+ fs.writeFileSync(path.join(foundationsDir, "ComponentSuggestions.stories.tsx"), content, "utf-8");
813
+ console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "ComponentSuggestions.stories.tsx")));
814
+ }
815
+
816
+ function main() {
817
+ if (!fs.existsSync(VDS_OUTPUT)) {
818
+ console.error("[VDS] vds-output.json not found. Run `npm run vds` first.");
819
+ process.exit(1);
820
+ }
821
+ const raw = fs.readFileSync(VDS_OUTPUT, "utf-8");
822
+ const data = JSON.parse(raw);
823
+ const components = Array.isArray(data.components) ? data.components : [];
824
+ const foundations = data.foundations || null;
825
+
826
+ const onlyName = process.argv[2] || null;
827
+
828
+ ensureDir(STORIES_DIR);
829
+ ensureDir(path.join(STORIES_DIR, "foundations"));
830
+ writeFoundationsStories(foundations);
831
+ const componentSuggestions = data.componentSuggestions;
832
+ if (componentSuggestions?.length) {
833
+ writeComponentSuggestionsStory(componentSuggestions);
834
+ }
835
+ try {
836
+ const fd = path.join(STORIES_DIR, "foundations");
837
+ if (fs.existsSync(fd)) {
838
+ for (const name of fs.readdirSync(fd)) {
839
+ if (name.endsWith(".stories.mdx")) fs.unlinkSync(path.join(fd, name));
840
+ }
841
+ }
842
+ } catch {
843
+ // ignore
844
+ }
845
+
846
+ // Clear existing .stories.* files (except foundations/*.mdx) so only VDS-generated stories remain
847
+ try {
848
+ const existing = fs.readdirSync(STORIES_DIR);
849
+ for (const name of existing) {
850
+ if (name === "foundations") continue;
851
+ if (
852
+ name.endsWith(".stories.tsx") ||
853
+ name.endsWith(".stories.ts") ||
854
+ name.endsWith(".stories.jsx") ||
855
+ name.endsWith(".stories.js")
856
+ ) {
857
+ fs.unlinkSync(path.join(STORIES_DIR, name));
858
+ }
859
+ }
860
+ } catch {
861
+ // ignore
862
+ }
863
+
864
+ for (const comp of components) {
865
+ const componentName = toSafeComponentName(comp.name, comp.file);
866
+ if (onlyName && componentName !== onlyName) continue;
867
+ if (SKIP_LIST.includes(componentName)) continue;
868
+ const requiredCount = Array.isArray(comp.props) ? comp.props.filter((p) => p.required === true).length : 0;
869
+ if (requiredCount > 3) continue;
870
+
871
+ const storyFileName = `${componentName}.stories.tsx`;
872
+ const storyPath = path.join(STORIES_DIR, storyFileName);
873
+ const content = buildStoryFileContent(comp);
874
+ fs.writeFileSync(storyPath, content, "utf-8");
875
+ console.log(`[VDS] Wrote ${path.relative(PROJECT_ROOT, storyPath)}`);
876
+ }
877
+ }
878
+
879
+ main();
880
+