/ scripts / generate-component.ts
generate-component.ts
  1  #!/usr/bin/env tsx
  2  /**
  3   * Component Generator
  4   * Creates new components with consistent structure and patterns
  5   *
  6   * Usage:
  7   *   yarn generate ComponentName
  8   *   yarn generate ComponentName --variant    # Creates CVA variant component
  9   *   yarn generate ComponentName --compound   # Creates compound component (with subcomponents)
 10   */
 11  
 12  import { mkdirSync, writeFileSync, existsSync } from 'fs';
 13  import { join } from 'path';
 14  
 15  const args = process.argv.slice(2);
 16  const componentName = args[0];
 17  const hasVariants = args.includes('--variant') || args.includes('-v');
 18  const isCompound = args.includes('--compound') || args.includes('-c');
 19  
 20  if (!componentName) {
 21    console.error('Usage: yarn generate ComponentName [--variant] [--compound]');
 22    process.exit(1);
 23  }
 24  
 25  // Validate component name (PascalCase)
 26  if (!/^[A-Z][a-zA-Z0-9]*$/.test(componentName)) {
 27    console.error('Component name must be PascalCase (e.g., MyComponent)');
 28    process.exit(1);
 29  }
 30  
 31  const componentsDir = join(process.cwd(), 'src', 'components');
 32  const componentDir = join(componentsDir, componentName);
 33  const storiesDir = join(process.cwd(), 'stories');
 34  
 35  // Check if component already exists
 36  if (existsSync(componentDir)) {
 37    console.error(`Component ${componentName} already exists at ${componentDir}`);
 38    process.exit(1);
 39  }
 40  
 41  // Create component directory
 42  mkdirSync(componentDir, { recursive: true });
 43  
 44  // Generate component file
 45  const componentTemplate = hasVariants
 46    ? generateVariantComponent(componentName)
 47    : isCompound
 48      ? generateCompoundComponent(componentName)
 49      : generateBasicComponent(componentName);
 50  
 51  // Generate test file
 52  const testTemplate = generateTest(componentName);
 53  
 54  // Generate story file
 55  const storyTemplate = generateStory(componentName, hasVariants);
 56  
 57  // Generate index file
 58  const indexTemplate = `export * from './${componentName}';\n`;
 59  
 60  // Write files
 61  writeFileSync(join(componentDir, `${componentName}.tsx`), componentTemplate);
 62  writeFileSync(join(componentDir, `${componentName}.test.tsx`), testTemplate);
 63  writeFileSync(join(componentDir, 'index.ts'), indexTemplate);
 64  writeFileSync(join(storiesDir, `${componentName}.stories.tsx`), storyTemplate);
 65  
 66  console.log(`✓ Created ${componentName} component`);
 67  console.log(`  - src/components/${componentName}/${componentName}.tsx`);
 68  console.log(`  - src/components/${componentName}/${componentName}.test.tsx`);
 69  console.log(`  - src/components/${componentName}/index.ts`);
 70  console.log(`  - stories/${componentName}.stories.tsx`);
 71  console.log('');
 72  console.log('Next steps:');
 73  console.log(`  1. Export from src/index.ts: export * from './components/${componentName}';`);
 74  console.log('  2. Implement component logic');
 75  console.log('  3. Add stories for all variants');
 76  console.log('  4. Run: yarn storybook');
 77  
 78  // ============ Template Generators ============
 79  
 80  function generateBasicComponent(name: string): string {
 81    return `import * as React from 'react';
 82  import { cn } from '../../lib/utils';
 83  
 84  export interface ${name}Props extends React.HTMLAttributes<HTMLDivElement> {
 85    /** Component children */
 86    children?: React.ReactNode;
 87  }
 88  
 89  /**
 90   * ${name} - Description here
 91   */
 92  const ${name} = React.forwardRef<HTMLDivElement, ${name}Props>(
 93    ({ className, children, ...props }, ref) => {
 94      return (
 95        <div
 96          ref={ref}
 97          className={cn(
 98            // Base styles
 99            'bg-[var(--bg-primary)]',
100            'text-[var(--text-primary)]',
101            className
102          )}
103          {...props}
104        >
105          {children}
106        </div>
107      );
108    }
109  );
110  
111  ${name}.displayName = '${name}';
112  
113  export { ${name} };
114  `;
115  }
116  
117  function generateVariantComponent(name: string): string {
118    const variantName = name.charAt(0).toLowerCase() + name.slice(1);
119    return `import * as React from 'react';
120  import { cva, type VariantProps } from 'class-variance-authority';
121  import { cn } from '../../lib/utils';
122  
123  /**
124   * ${name} variants using ACDC design system tokens
125   */
126  const ${variantName}Variants = cva(
127    // Base styles
128    [
129      'inline-flex items-center justify-center',
130      'transition-all duration-150 ease-out',
131      'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
132    ],
133    {
134      variants: {
135        variant: {
136          default: [
137            'bg-[var(--bg-tertiary)]',
138            'text-[var(--text-primary)]',
139            'border border-[var(--border-default)]',
140          ],
141          alpha: [
142            'bg-[var(--alpha-500)]',
143            'text-white',
144            'hover:bg-[var(--alpha-600)]',
145          ],
146          delta: [
147            'bg-[var(--delta-500)]',
148            'text-white',
149            'hover:bg-[var(--delta-600)]',
150          ],
151        },
152        size: {
153          sm: 'h-8 px-3 text-sm',
154          md: 'h-10 px-4 text-base',
155          lg: 'h-12 px-6 text-lg',
156        },
157      },
158      defaultVariants: {
159        variant: 'default',
160        size: 'md',
161      },
162    }
163  );
164  
165  export interface ${name}Props
166    extends React.HTMLAttributes<HTMLDivElement>,
167      VariantProps<typeof ${variantName}Variants> {}
168  
169  /**
170   * ${name} - Description here
171   */
172  const ${name} = React.forwardRef<HTMLDivElement, ${name}Props>(
173    ({ className, variant, size, ...props }, ref) => {
174      return (
175        <div
176          ref={ref}
177          className={cn(${variantName}Variants({ variant, size, className }))}
178          {...props}
179        />
180      );
181    }
182  );
183  
184  ${name}.displayName = '${name}';
185  
186  export { ${name}, ${variantName}Variants };
187  `;
188  }
189  
190  function generateCompoundComponent(name: string): string {
191    return `import * as React from 'react';
192  import { cn } from '../../lib/utils';
193  
194  // ============ Main Component ============
195  
196  export interface ${name}Props extends React.HTMLAttributes<HTMLDivElement> {
197    /** Component children */
198    children?: React.ReactNode;
199  }
200  
201  const ${name} = React.forwardRef<HTMLDivElement, ${name}Props>(
202    ({ className, children, ...props }, ref) => {
203      return (
204        <div
205          ref={ref}
206          className={cn(
207            'bg-[var(--bg-elevated)]',
208            'border border-[var(--border-subtle)]',
209            'rounded-[var(--radius-md)]',
210            className
211          )}
212          {...props}
213        >
214          {children}
215        </div>
216      );
217    }
218  );
219  ${name}.displayName = '${name}';
220  
221  // ============ Sub Components ============
222  
223  const ${name}Header = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
224    ({ className, ...props }, ref) => (
225      <div
226        ref={ref}
227        className={cn(
228          'p-4 border-b border-[var(--border-subtle)]',
229          className
230        )}
231        {...props}
232      />
233    )
234  );
235  ${name}Header.displayName = '${name}Header';
236  
237  const ${name}Title = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
238    ({ className, ...props }, ref) => (
239      <h3
240        ref={ref}
241        className={cn(
242          'text-lg font-semibold text-[var(--text-primary)]',
243          className
244        )}
245        {...props}
246      />
247    )
248  );
249  ${name}Title.displayName = '${name}Title';
250  
251  const ${name}Content = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
252    ({ className, ...props }, ref) => (
253      <div
254        ref={ref}
255        className={cn('p-4', className)}
256        {...props}
257      />
258    )
259  );
260  ${name}Content.displayName = '${name}Content';
261  
262  const ${name}Footer = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
263    ({ className, ...props }, ref) => (
264      <div
265        ref={ref}
266        className={cn(
267          'p-4 border-t border-[var(--border-subtle)]',
268          'flex items-center justify-end gap-2',
269          className
270        )}
271        {...props}
272      />
273    )
274  );
275  ${name}Footer.displayName = '${name}Footer';
276  
277  export { ${name}, ${name}Header, ${name}Title, ${name}Content, ${name}Footer };
278  `;
279  }
280  
281  function generateTest(name: string): string {
282    return `import { describe, it, expect } from 'vitest';
283  import { render, screen } from '@testing-library/react';
284  import { ${name} } from './${name}';
285  
286  describe('${name}', () => {
287    it('renders children', () => {
288      render(<${name}>Test content</${name}>);
289      expect(screen.getByText('Test content')).toBeInTheDocument();
290    });
291  
292    it('applies custom className', () => {
293      render(<${name} className="custom-class">Content</${name}>);
294      expect(screen.getByText('Content')).toHaveClass('custom-class');
295    });
296  
297    it('forwards ref', () => {
298      const ref = { current: null };
299      render(<${name} ref={ref}>Content</${name}>);
300      expect(ref.current).toBeInstanceOf(HTMLDivElement);
301    });
302  });
303  `;
304  }
305  
306  function generateStory(name: string, hasVariants: boolean): string {
307    if (hasVariants) {
308      return `import type { Meta, StoryObj } from '@storybook/react';
309  import { ${name} } from '../src/components/${name}';
310  
311  const meta: Meta<typeof ${name}> = {
312    title: 'Components/${name}',
313    component: ${name},
314    parameters: {
315      layout: 'centered',
316    },
317    tags: ['autodocs'],
318    argTypes: {
319      variant: {
320        control: 'select',
321        options: ['default', 'alpha', 'delta'],
322      },
323      size: {
324        control: 'select',
325        options: ['sm', 'md', 'lg'],
326      },
327    },
328  };
329  
330  export default meta;
331  type Story = StoryObj<typeof ${name}>;
332  
333  export const Default: Story = {
334    args: {
335      children: '${name}',
336    },
337  };
338  
339  export const Alpha: Story = {
340    args: {
341      children: 'Alpha',
342      variant: 'alpha',
343    },
344  };
345  
346  export const Delta: Story = {
347    args: {
348      children: 'Delta',
349      variant: 'delta',
350    },
351  };
352  
353  export const Small: Story = {
354    args: {
355      children: 'Small',
356      size: 'sm',
357    },
358  };
359  
360  export const Large: Story = {
361    args: {
362      children: 'Large',
363      size: 'lg',
364    },
365  };
366  
367  export const AllVariants: Story = {
368    render: () => (
369      <div className="flex flex-wrap gap-4">
370        <${name} variant="default">Default</${name}>
371        <${name} variant="alpha">Alpha</${name}>
372        <${name} variant="delta">Delta</${name}>
373      </div>
374    ),
375  };
376  
377  export const AllSizes: Story = {
378    render: () => (
379      <div className="flex items-center gap-4">
380        <${name} size="sm">Small</${name}>
381        <${name} size="md">Medium</${name}>
382        <${name} size="lg">Large</${name}>
383      </div>
384    ),
385  };
386  `;
387    }
388  
389    return `import type { Meta, StoryObj } from '@storybook/react';
390  import { ${name} } from '../src/components/${name}';
391  
392  const meta: Meta<typeof ${name}> = {
393    title: 'Components/${name}',
394    component: ${name},
395    parameters: {
396      layout: 'centered',
397    },
398    tags: ['autodocs'],
399  };
400  
401  export default meta;
402  type Story = StoryObj<typeof ${name}>;
403  
404  export const Default: Story = {
405    args: {
406      children: '${name} content',
407    },
408  };
409  
410  export const WithCustomClass: Story = {
411    args: {
412      children: 'Custom styled',
413      className: 'p-4 bg-gray-100',
414    },
415  };
416  `;
417  }