From 889fef650d9a4c7965a898ee71ff6ad93ed4d330 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 12:59:08 -0400 Subject: [PATCH 1/8] feat: add ts-morph dependency for Storybook story generation Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + pnpm-lock.yaml | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/package.json b/package.json index 7e92226..7786431 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", "prettier": "^3.8.1", + "ts-morph": "^28.0.0", "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbbf6f3..fbb97c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 vitest: specifier: ^4.1.2 version: 4.1.2(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) @@ -658,6 +661,9 @@ packages: '@stryker-mutator/core': 9.6.1 vitest: '>=2.0.0' + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -843,6 +849,9 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -1750,6 +1759,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -2054,6 +2066,9 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2962,6 +2977,12 @@ snapshots: tslib: 2.8.1 vitest: 4.1.2(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.5 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3149,6 +3170,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + code-block-writer@13.0.3: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -4015,6 +4038,8 @@ snapshots: parse-ms@4.0.0: {} + path-browserify@1.0.1: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -4300,6 +4325,11 @@ snapshots: trim-newlines@3.0.1: {} + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + tslib@2.8.1: {} tunnel@0.0.6: {} From 94e822ee262b13a4fb31feffd084bcef968c7361 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:03:01 -0400 Subject: [PATCH 2/8] test: add comprehensive failing tests for Node.js story generator (RED phase) Replace bash-based generate-stories tests with 22 tests targeting the new Node.js generate-stories.js script using ts-morph AST parsing. Tests cover backward compatibility, argTypes/controls generation, action args, variant stories, default values, MDX documentation, JSON output, and maxVariantsPerProp config. All tests fail as expected since generate-stories.js does not exist yet. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/__tests__/generate-stories.test.js | 677 +++++++++++++++++++-- 1 file changed, 610 insertions(+), 67 deletions(-) diff --git a/scripts/__tests__/generate-stories.test.js b/scripts/__tests__/generate-stories.test.js index d9d02d8..dc221d5 100644 --- a/scripts/__tests__/generate-stories.test.js +++ b/scripts/__tests__/generate-stories.test.js @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, afterAll } from "vitest"; import { execFileSync } from "child_process"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; -import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync } from "fs"; +import { + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + readdirSync, +} from "fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const SCRIPT = join(__dirname, "..", "generate-stories.sh"); +const SCRIPT = join(__dirname, "..", "generate-stories.js"); let counter = 0; +/** + * Creates a unique temp directory under scripts/__tests__/fixtures/gen-stories-* + */ function createTmpDir() { counter++; const dir = join(__dirname, "fixtures", `gen-stories-${counter}-${Date.now()}`); @@ -16,23 +26,52 @@ function createTmpDir() { return dir; } +/** + * Runs `node scripts/generate-stories.js` with given args in given cwd. + */ function run(cwd, args = []) { try { - const stdout = execFileSync("bash", [SCRIPT, ...args], { + const stdout = execFileSync("node", [SCRIPT, ...args], { encoding: "utf-8", - timeout: 15000, + timeout: 30000, cwd, }); - return { stdout, exitCode: 0 }; + return { stdout, stderr: "", exitCode: 0 }; } catch (err) { return { stdout: err.stdout || "", stderr: err.stderr || "", - exitCode: err.status, + exitCode: err.status ?? 1, }; } } +/** + * Writes a minimal tsconfig.json so ts-morph can parse .tsx files. + */ +function writeTsConfig(dir) { + writeFileSync( + join(dir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2020", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + esModuleInterop: true, + skipLibCheck: true, + outDir: "dist", + }, + include: ["src"], + }, + null, + 2, + ), + ); +} + afterAll(() => { const fixturesDir = join(__dirname, "fixtures"); if (existsSync(fixturesDir)) { @@ -48,25 +87,22 @@ afterAll(() => { } }); -describe("generate-stories.sh — no components directory", () => { - let dir; - - beforeAll(() => { - dir = createTmpDir(); - }); +// ============================================================================= +// 1. Backward compatibility tests +// ============================================================================= +describe("generate-stories.js — backward compatibility", () => { it("exits 0 with skip message when no src/components", () => { + const dir = createTmpDir(); + writeTsConfig(dir); const result = run(dir); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("No src/components directory found"); }); -}); -describe("generate-stories.sh — generates story for component", () => { - let dir; - - beforeAll(() => { - dir = createTmpDir(); + it("generates a .stories.tsx file with correct structure", () => { + const dir = createTmpDir(); + writeTsConfig(dir); mkdirSync(join(dir, "src", "components"), { recursive: true }); writeFileSync( @@ -80,9 +116,7 @@ export function Button({ children, variant = 'primary' }: ButtonProps) { return ; }`, ); - }); - it("generates a .stories.tsx file with correct structure", () => { const result = run(dir); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Generated:"); @@ -98,21 +132,18 @@ export function Button({ children, variant = 'primary' }: ButtonProps) { expect(content).toContain("export const Default: Story"); expect(content).toContain("tags: ['autodocs']"); }); -}); -describe("generate-stories.sh — dry run mode", () => { - let dir; - - beforeAll(() => { - dir = createTmpDir(); + it("reports what would be generated without writing files", () => { + const dir = createTmpDir(); + writeTsConfig(dir); mkdirSync(join(dir, "src", "components"), { recursive: true }); + writeFileSync( join(dir, "src", "components", "Card.tsx"), - `export const Card = () =>
Card
;`, + `export interface CardProps { title: string; } +export function Card({ title }: CardProps) { return
{title}
; }`, ); - }); - it("reports what would be generated without writing files", () => { const result = run(dir, ["--dry-run"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Would generate:"); @@ -121,77 +152,72 @@ describe("generate-stories.sh — dry run mode", () => { const storyFile = join(dir, "src", "components", "Card.stories.tsx"); expect(existsSync(storyFile)).toBe(false); }); -}); - -describe("generate-stories.sh — skips existing stories", () => { - let dir; - beforeAll(() => { - dir = createTmpDir(); + it("skips components that already have stories", () => { + const dir = createTmpDir(); + writeTsConfig(dir); mkdirSync(join(dir, "src", "components"), { recursive: true }); + writeFileSync( join(dir, "src", "components", "Nav.tsx"), `export function Nav() { return ; }`, ); - writeFileSync(join(dir, "src", "components", "Nav.stories.tsx"), `// existing story`); - }); + writeFileSync( + join(dir, "src", "components", "Nav.stories.tsx"), + `// existing story`, + ); - it("skips components that already have stories", () => { const result = run(dir); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Skipped (story exists)"); }); -}); -describe("generate-stories.sh — force regeneration", () => { - let dir; - - beforeAll(() => { - dir = createTmpDir(); + it("regenerates stories with --force flag", () => { + const dir = createTmpDir(); + writeTsConfig(dir); mkdirSync(join(dir, "src", "components"), { recursive: true }); + writeFileSync( join(dir, "src", "components", "Nav.tsx"), `export function Nav() { return ; }`, ); - writeFileSync(join(dir, "src", "components", "Nav.stories.tsx"), `// old story content`); - }); + writeFileSync( + join(dir, "src", "components", "Nav.stories.tsx"), + `// old story content`, + ); - it("regenerates stories with --force flag", () => { const result = run(dir, ["--force"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Generated:"); - const content = readFileSync(join(dir, "src", "components", "Nav.stories.tsx"), "utf-8"); + const content = readFileSync( + join(dir, "src", "components", "Nav.stories.tsx"), + "utf-8", + ); expect(content).toContain("import type { Meta, StoryObj }"); }); -}); -describe("generate-stories.sh — skips non-component files", () => { - let dir; - - beforeAll(() => { - dir = createTmpDir(); + it("skips files with no exported React component", () => { + const dir = createTmpDir(); + writeTsConfig(dir); mkdirSync(join(dir, "src", "components"), { recursive: true }); - // File with no exported component (lowercase function) + + // lowercase function — not a React component writeFileSync( join(dir, "src", "components", "utils.tsx"), `export function formatDate(d: Date) { return d.toISOString(); }`, ); - }); - it("skips files with no exported React component", () => { const result = run(dir); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Skipped (no exported component)"); }); -}); - -describe("generate-stories.sh — summary counts", () => { - let dir; - beforeAll(() => { - dir = createTmpDir(); + it("shows correct generated and skipped counts", () => { + const dir = createTmpDir(); + writeTsConfig(dir); mkdirSync(join(dir, "src", "components"), { recursive: true }); + writeFileSync( join(dir, "src", "components", "A.tsx"), `export function Alpha() { return
A
; }`, @@ -205,13 +231,530 @@ describe("generate-stories.sh — summary counts", () => { join(dir, "src", "components", "C.tsx"), `export function Charlie() { return
C
; }`, ); - writeFileSync(join(dir, "src", "components", "C.stories.tsx"), `// existing`); - }); + writeFileSync( + join(dir, "src", "components", "C.stories.tsx"), + `// existing`, + ); - it("shows correct generated and skipped counts", () => { const result = run(dir); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Generated: 2"); expect(result.stdout).toContain("Skipped: 1"); }); }); + +// ============================================================================= +// 2. argTypes generation +// ============================================================================= + +describe("generate-stories.js — argTypes generation", () => { + it("maps string props to text control", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Label.tsx"), + `export interface LabelProps { + /** The label text */ + text: string; +} + +export function Label({ text }: LabelProps) { + return {text}; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Label.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + expect(content).toContain("argTypes"); + expect(content).toContain("text"); + expect(content).toContain("control: 'text'"); + }); + + it("maps number props to number control", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Counter.tsx"), + `export interface CounterProps { + count: number; +} + +export function Counter({ count }: CounterProps) { + return {count}; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Counter.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + expect(content).toContain("argTypes"); + expect(content).toContain("count"); + expect(content).toContain("control: 'number'"); + }); + + it("maps boolean props to boolean control", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Toggle.tsx"), + `export interface ToggleProps { + enabled: boolean; +} + +export function Toggle({ enabled }: ToggleProps) { + return ; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Toggle.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + expect(content).toContain("argTypes"); + expect(content).toContain("enabled"); + expect(content).toContain("control: 'boolean'"); + }); + + it("maps string literal union to select control with options", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Badge.tsx"), + `export interface BadgeProps { + variant: 'info' | 'success' | 'warning' | 'error'; +} + +export function Badge({ variant }: BadgeProps) { + return badge; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Badge.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + expect(content).toContain("argTypes"); + expect(content).toContain("variant"); + expect(content).toContain("control: 'select'"); + expect(content).toContain("options: ['info', 'success', 'warning', 'error']"); + }); + + it("includes description from JSDoc comments", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Tooltip.tsx"), + `export interface TooltipProps { + /** The tooltip message to display */ + message: string; +} + +export function Tooltip({ message }: TooltipProps) { + return
{message}
; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Tooltip.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + expect(content).toContain("argTypes"); + expect(content).toContain("description: 'The tooltip message to display'"); + }); +}); + +// ============================================================================= +// 3. Action args for callbacks +// ============================================================================= + +describe("generate-stories.js — action args for callbacks", () => { + it("wires onClick and onChange as action args", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Btn.tsx"), + `export interface BtnProps { + label: string; + onClick?: () => void; + onChange?: (value: string) => void; +} + +export function Btn({ label, onClick, onChange }: BtnProps) { + return ; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Btn.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + // onClick -> action: 'clicked' + expect(content).toContain("onClick"); + expect(content).toContain("action: 'clicked'"); + // onChange -> action: 'changed' + expect(content).toContain("onChange"); + expect(content).toContain("action: 'changed'"); + }); +}); + +// ============================================================================= +// 4. Variant story generation +// ============================================================================= + +describe("generate-stories.js — variant story generation", () => { + it("generates one story per string literal union value", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Chip.tsx"), + `export interface ChipProps { + size: 'sm' | 'md' | 'lg'; + label: string; +} + +export function Chip({ size, label }: ChipProps) { + return {label}; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Chip.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + // One story per union value + expect(content).toContain("export const Sm: Story"); + expect(content).toMatch(/Sm.*args.*size.*['"]sm['"]/s); + expect(content).toContain("export const Md: Story"); + expect(content).toMatch(/Md.*args.*size.*['"]md['"]/s); + expect(content).toContain("export const Lg: Story"); + expect(content).toMatch(/Lg.*args.*size.*['"]lg['"]/s); + }); + + it("generates True/False stories for boolean props", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Checkbox.tsx"), + `export interface CheckboxProps { + checked: boolean; + label: string; +} + +export function Checkbox({ checked, label }: CheckboxProps) { + return ; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Checkbox.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + expect(content).toContain("export const CheckedTrue: Story"); + expect(content).toContain("export const CheckedFalse: Story"); + }); +}); + +// ============================================================================= +// 5. Default values from destructuring +// ============================================================================= + +describe("generate-stories.js — default values from destructuring", () => { + it("populates args with default values", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Tag.tsx"), + `export interface TagProps { + label: string; + size: 'sm' | 'md' | 'lg'; +} + +export function Tag({ label = 'tag', size = 'md' }: TagProps) { + return {label}; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Tag.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + // Default story should contain default values in args + expect(content).toContain("label: 'tag'"); + expect(content).toContain("size: 'md'"); + }); +}); + +// ============================================================================= +// 6. MDX documentation generation +// ============================================================================= + +describe("generate-stories.js — MDX documentation generation", () => { + it("generates an .mdx file alongside the story", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Alert.tsx"), + `/** A dismissible alert banner */ +export interface AlertProps { + /** The alert message */ + message: string; + /** Visual severity */ + severity: 'info' | 'warning' | 'error'; +} + +export function Alert({ message, severity }: AlertProps) { + return
{message}
; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const mdxFile = join(dir, "src", "components", "Alert.mdx"); + expect(existsSync(mdxFile)).toBe(true); + + const content = readFileSync(mdxFile, "utf-8"); + // MDX should contain standard Storybook doc blocks + expect(content).toContain("import { Meta"); + expect(content).toContain("import { Canvas"); + expect(content).toContain("import { Controls"); + expect(content).toContain("import { ArgTypes"); + expect(content).toContain(" { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Banner.tsx"), + `/** A promotional banner component */ +export interface BannerProps { + text: string; +} + +export function Banner({ text }: BannerProps) { + return
{text}
; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const mdxFile = join(dir, "src", "components", "Banner.mdx"); + expect(existsSync(mdxFile)).toBe(true); + + const content = readFileSync(mdxFile, "utf-8"); + expect(content).toContain("A promotional banner component"); + }); + + it("embeds variant stories in MDX", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Pill.tsx"), + `export interface PillProps { + color: 'red' | 'blue' | 'green'; +} + +export function Pill({ color }: PillProps) { + return pill; +}`, + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const mdxFile = join(dir, "src", "components", "Pill.mdx"); + expect(existsSync(mdxFile)).toBe(true); + + const content = readFileSync(mdxFile, "utf-8"); + expect(content).toContain(""); + expect(content).toContain(""); + expect(content).toContain(""); + }); + + it("skips MDX generation with --no-mdx flag", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Link.tsx"), + `export interface LinkProps { + href: string; + label: string; +} + +export function Link({ href, label }: LinkProps) { + return {label}; +}`, + ); + + const result = run(dir, ["--no-mdx"]); + expect(result.exitCode).toBe(0); + + // Story file should still be generated + const storyFile = join(dir, "src", "components", "Link.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + // MDX file should NOT be generated + const mdxFile = join(dir, "src", "components", "Link.mdx"); + expect(existsSync(mdxFile)).toBe(false); + }); +}); + +// ============================================================================= +// 7. JSON output mode +// ============================================================================= + +describe("generate-stories.js — JSON output mode", () => { + it("outputs results as JSON with --json flag", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Heading.tsx"), + `export interface HeadingProps { + level: number; + text: string; +} + +export function Heading({ level, text }: HeadingProps) { + const Tag = \`h\${level}\` as keyof JSX.IntrinsicElements; + return {text}; +}`, + ); + + // Pre-existing story to get a skip count + writeFileSync( + join(dir, "src", "components", "Footer.tsx"), + `export function Footer() { return
Footer
; }`, + ); + writeFileSync( + join(dir, "src", "components", "Footer.stories.tsx"), + `// existing`, + ); + + const result = run(dir, ["--json"]); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json).toHaveProperty("generated"); + expect(json).toHaveProperty("skipped"); + expect(json).toHaveProperty("files"); + expect(Array.isArray(json.files)).toBe(true); + expect(json.generated).toBe(1); + expect(json.skipped).toBe(1); + expect(json.files.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================= +// 8. maxVariantsPerProp config +// ============================================================================= + +describe("generate-stories.js — maxVariantsPerProp config", () => { + it("caps variant stories when union has many values", () => { + const dir = createTmpDir(); + writeTsConfig(dir); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + mkdirSync(join(dir, ".claude"), { recursive: true }); + + writeFileSync( + join(dir, "src", "components", "Icon.tsx"), + `export interface IconProps { + name: 'home' | 'star' | 'heart' | 'bell' | 'gear' | 'user'; +} + +export function Icon({ name }: IconProps) { + return ; +}`, + ); + + // Pipeline config with maxVariantsPerProp = 3 + writeFileSync( + join(dir, ".claude", "pipeline.config.json"), + JSON.stringify( + { + storybook: { + autoGenerate: true, + maxVariantsPerProp: 3, + }, + }, + null, + 2, + ), + ); + + const result = run(dir); + expect(result.exitCode).toBe(0); + + const storyFile = join(dir, "src", "components", "Icon.stories.tsx"); + expect(existsSync(storyFile)).toBe(true); + + const content = readFileSync(storyFile, "utf-8"); + + // Count exported stories: Default + at most 3 variant stories = 4 max + const storyExports = content.match(/export const \w+: Story/g) || []; + expect(storyExports.length).toBeLessThanOrEqual(4); // Default + 3 capped variants + expect(storyExports.length).toBeGreaterThanOrEqual(2); // At least Default + 1 variant + }); +}); From 4ae0e978bf62a3cfe0a985e34bfd0bb69e365b5d Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:08:15 -0400 Subject: [PATCH 3/8] feat: add AST-based Storybook story generator with prop controls, variants, and MDX docs Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/generate-stories.js | 790 ++++++++++++++++++++++++++++++++++++ 1 file changed, 790 insertions(+) create mode 100644 scripts/generate-stories.js diff --git a/scripts/generate-stories.js b/scripts/generate-stories.js new file mode 100644 index 0000000..2a2313b --- /dev/null +++ b/scripts/generate-stories.js @@ -0,0 +1,790 @@ +#!/usr/bin/env node +/** + * generate-stories.js — AST-based Storybook story generator + * + * Uses ts-morph to parse React component files and generate: + * - .stories.tsx files with Meta, argTypes/controls, variant stories, action args, default args + * - .mdx documentation files with Meta, Canvas, Controls, ArgTypes blocks + * + * Phase 4.5 of the Figma-to-React pipeline (non-blocking) + */ + +import { Project, SyntaxKind } from "ts-morph"; +import { + existsSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, +} from "fs"; +import { join, relative, basename, dirname } from "path"; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +function getFlag(name) { + return args.includes(name); +} + +function getOption(name, defaultValue) { + const idx = args.indexOf(name); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return defaultValue; +} + +const srcDir = getOption("--src-dir", "src/components"); +const configPath = getOption("--config", ".claude/pipeline.config.json"); +const force = getFlag("--force"); +const dryRun = getFlag("--dry-run"); +const noMdx = getFlag("--no-mdx"); +const jsonOutput = getFlag("--json"); + +// --------------------------------------------------------------------------- +// Logging (suppressed in --json mode) +// --------------------------------------------------------------------------- + +function log(msg) { + if (!jsonOutput) { + console.log(msg); + } +} + +// --------------------------------------------------------------------------- +// Config loading +// --------------------------------------------------------------------------- + +function loadConfig(configFilePath) { + const defaults = { + autoGenerate: true, + includeResponsiveViewports: false, + viewports: ["mobile", "tablet", "desktop"], + skipPatterns: [], + generateMdx: true, + maxVariantsPerProp: 10, + }; + + if (!existsSync(configFilePath)) { + return { storybook: defaults, visualDiff: { breakpoints: {} } }; + } + + try { + const raw = JSON.parse(readFileSync(configFilePath, "utf-8")); + const sb = { ...defaults, ...raw.storybook }; + return { storybook: sb, visualDiff: raw.visualDiff || { breakpoints: {} } }; + } catch { + return { storybook: defaults, visualDiff: { breakpoints: {} } }; + } +} + +// --------------------------------------------------------------------------- +// File scanning — recursively find .tsx files +// --------------------------------------------------------------------------- + +function walkDir(dir) { + const results = []; + if (!existsSync(dir)) return results; + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + if (stat.isDirectory()) { + // Skip known non-component directories + if ( + entry === "node_modules" || + entry === "__tests__" || + entry === "__mocks__" + ) { + continue; + } + results.push(...walkDir(fullPath)); + } else if (stat.isFile()) { + results.push(fullPath); + } + } + return results; +} + +function findComponentFiles(srcDirectory) { + const allFiles = walkDir(srcDirectory); + return allFiles.filter((f) => { + const name = basename(f); + if (!name.endsWith(".tsx")) return false; + if (name.endsWith(".test.tsx")) return false; + if (name.endsWith(".stories.tsx")) return false; + if (name.endsWith(".d.ts") || name.endsWith(".d.tsx")) return false; + if (name === "index.tsx") return false; + return true; + }); +} + +// --------------------------------------------------------------------------- +// Glob-like pattern matching for skipPatterns +// --------------------------------------------------------------------------- + +function matchesSkipPattern(filePath, patterns) { + const name = basename(filePath); + for (const pattern of patterns) { + // Strip leading **/ for basename matching + const simple = pattern.replace(/^\*\*\//, ""); + // Convert glob to regex + const regexStr = simple + .replace(/\./g, "\\.") + .replace(/\*/g, ".*"); + const re = new RegExp(`^${regexStr}$`); + if (re.test(name)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// AST Analysis helpers +// --------------------------------------------------------------------------- + +/** + * Find the first exported component (function or const starting with uppercase) + * Returns { name, isDefault, node } or null + */ +function findExportedComponent(sourceFile) { + // Check exported function declarations + for (const fn of sourceFile.getFunctions()) { + const name = fn.getName(); + if (!name || !/^[A-Z]/.test(name)) continue; + if (fn.isExported() || fn.isDefaultExport()) { + return { + name, + isDefault: fn.isDefaultExport(), + node: fn, + }; + } + } + + // Check exported variable declarations (arrow function components) + for (const varStatement of sourceFile.getVariableStatements()) { + if (!varStatement.isExported()) continue; + for (const decl of varStatement.getDeclarations()) { + const name = decl.getName(); + if (/^[A-Z]/.test(name)) { + return { + name, + isDefault: varStatement.isDefaultExport(), + node: decl, + }; + } + } + } + + // Check default export assignments + const defaultExport = sourceFile.getDefaultExportSymbol(); + if (defaultExport) { + const name = defaultExport.getName(); + if (name && /^[A-Z]/.test(name) && name !== "default") { + return { name, isDefault: true, node: null }; + } + } + + return null; +} + +/** + * Find the props interface for a component. + * Convention: ComponentNameProps + */ +function findPropsInterface(sourceFile, componentName) { + const propsName = `${componentName}Props`; + + // Check interfaces + for (const iface of sourceFile.getInterfaces()) { + if (iface.getName() === propsName) { + return iface; + } + } + + // Check type aliases + for (const typeAlias of sourceFile.getTypeAliases()) { + if (typeAlias.getName() === propsName) { + return typeAlias; + } + } + + return null; +} + +/** + * Extract prop information from an interface declaration + */ +function extractPropsFromInterface(iface) { + const props = []; + if (!iface) return props; + + // Handle interface declarations + if (iface.getKind() === SyntaxKind.InterfaceDeclaration) { + for (const prop of iface.getProperties()) { + const info = extractPropInfo(prop); + if (info) props.push(info); + } + } + + return props; +} + +/** + * Extract individual prop info from a property signature + */ +function extractPropInfo(prop) { + const name = prop.getName(); + const isOptional = prop.hasQuestionToken(); + const typeNode = prop.getTypeNode(); + + // Get JSDoc comment + const jsDocs = prop.getJsDocs(); + let description = ""; + if (jsDocs.length > 0) { + description = jsDocs[0].getDescription().trim(); + } + + // Determine control type + let controlType = "text"; // default + let options = null; + let isCallback = false; + let actionName = ""; + let literalValues = []; + + if (typeNode) { + const typeText = typeNode.getText(); + + if (typeText === "string") { + controlType = "text"; + } else if (typeText === "number") { + controlType = "number"; + } else if (typeText === "boolean") { + controlType = "boolean"; + } else if (isStringLiteralUnion(typeNode)) { + controlType = "select"; + literalValues = extractLiteralValues(typeNode); + options = literalValues; + } else if (isFunctionType(typeNode, typeText)) { + controlType = "function"; + isCallback = /^on[A-Z]/.test(name); + if (isCallback) { + // onClick -> clicked, onChange -> changed + const eventPart = name.slice(2); // Remove 'on' + const lowerEvent = eventPart.charAt(0).toLowerCase() + eventPart.slice(1); + // Make past tense: click -> clicked, change -> changed + if (lowerEvent.endsWith("e")) { + actionName = lowerEvent + "d"; + } else { + actionName = lowerEvent + "ed"; + } + } + } + } else { + // No explicit type node — try to infer from the type string + const typeStr = prop.getType().getText(); + if (typeStr === "string") controlType = "text"; + else if (typeStr === "number") controlType = "number"; + else if (typeStr === "boolean") controlType = "boolean"; + } + + return { + name, + isOptional, + controlType, + options, + description, + isCallback, + actionName, + literalValues, + }; +} + +function isStringLiteralUnion(typeNode) { + if (typeNode.getKind() === SyntaxKind.UnionType) { + const types = typeNode.getTypeNodes(); + return types.every( + (t) => t.getKind() === SyntaxKind.LiteralType + && t.getLiteral().getKind() === SyntaxKind.StringLiteral, + ); + } + return false; +} + +function extractLiteralValues(typeNode) { + if (typeNode.getKind() === SyntaxKind.UnionType) { + return typeNode.getTypeNodes().map((t) => { + const literal = t.getLiteral(); + return literal.getLiteralText(); + }); + } + return []; +} + +function isFunctionType(typeNode, typeText) { + if (typeNode.getKind() === SyntaxKind.FunctionType) return true; + if (typeText.includes("=>")) return true; + return false; +} + +/** + * Extract default values from function parameter destructuring patterns. + * e.g., ({ label = 'tag', size = 'md' }: Props) => { label: 'tag', size: 'md' } + */ +function extractDefaultValues(sourceFile, componentName) { + const defaults = {}; + + // Find the component function + for (const fn of sourceFile.getFunctions()) { + if (fn.getName() === componentName) { + extractDefaultsFromParams(fn, defaults); + return defaults; + } + } + + // Check variable declarations for arrow functions + for (const varStatement of sourceFile.getVariableStatements()) { + for (const decl of varStatement.getDeclarations()) { + if (decl.getName() === componentName) { + const initializer = decl.getInitializer(); + if (initializer && ( + initializer.getKind() === SyntaxKind.ArrowFunction + || initializer.getKind() === SyntaxKind.FunctionExpression + )) { + extractDefaultsFromParams(initializer, defaults); + } + return defaults; + } + } + } + + return defaults; +} + +function extractDefaultsFromParams(fnNode, defaults) { + const params = fnNode.getParameters(); + if (params.length === 0) return; + + const firstParam = params[0]; + const nameNode = firstParam.getNameNode(); + + if (nameNode && nameNode.getKind() === SyntaxKind.ObjectBindingPattern) { + for (const element of nameNode.getElements()) { + const propName = element.getName(); + const initializer = element.getInitializer(); + if (initializer) { + const text = initializer.getText(); + // Parse string literals + if ( + (text.startsWith("'") && text.endsWith("'")) + || (text.startsWith('"') && text.endsWith('"')) + ) { + defaults[propName] = text.slice(1, -1); + } else if (text === "true") { + defaults[propName] = true; + } else if (text === "false") { + defaults[propName] = false; + } else if (!isNaN(Number(text))) { + defaults[propName] = Number(text); + } else { + defaults[propName] = text; + } + } + } + } +} + +/** + * Get JSDoc description from an interface or function + */ +function getComponentDescription(sourceFile, componentName) { + // Check for JSDoc on the props interface + const propsName = `${componentName}Props`; + for (const iface of sourceFile.getInterfaces()) { + if (iface.getName() === propsName) { + const jsDocs = iface.getJsDocs(); + if (jsDocs.length > 0) { + return jsDocs[0].getDescription().trim(); + } + } + } + + // Check for JSDoc on the component function itself + for (const fn of sourceFile.getFunctions()) { + if (fn.getName() === componentName) { + const jsDocs = fn.getJsDocs(); + if (jsDocs.length > 0) { + return jsDocs[0].getDescription().trim(); + } + } + } + + return ""; +} + +// --------------------------------------------------------------------------- +// Story content generation +// --------------------------------------------------------------------------- + +function generateStoryContent( + componentName, + componentBasename, + storyTitle, + isDefault, + props, + defaults, + config, +) { + const lines = []; + const maxVariants = config.storybook.maxVariantsPerProp || 10; + const includeViewports = config.storybook.includeResponsiveViewports || false; + const viewports = config.storybook.viewports || []; + const breakpoints = (config.visualDiff && config.visualDiff.breakpoints) || {}; + + // Imports + lines.push("import type { Meta, StoryObj } from '@storybook/react';"); + if (isDefault) { + lines.push(`import ${componentName} from './${componentBasename}';`); + } else { + lines.push(`import { ${componentName} } from './${componentBasename}';`); + } + lines.push(""); + + // Meta + lines.push(`const meta: Meta = {`); + lines.push(` title: '${storyTitle}',`); + lines.push(` component: ${componentName},`); + lines.push(" tags: ['autodocs'],"); + + // argTypes + if (props.length > 0) { + lines.push(" argTypes: {"); + for (const prop of props) { + if (prop.isCallback) { + lines.push(` ${prop.name}: { action: '${prop.actionName}' },`); + } else if (prop.controlType === "select" && prop.options) { + const optStr = prop.options.map((o) => `'${o}'`).join(", "); + if (prop.description) { + lines.push(` ${prop.name}: {`); + lines.push(` control: 'select',`); + lines.push(` options: [${optStr}],`); + lines.push(` description: '${prop.description}',`); + lines.push(" },"); + } else { + lines.push(` ${prop.name}: { control: 'select', options: [${optStr}] },`); + } + } else { + if (prop.description) { + lines.push(` ${prop.name}: { control: '${prop.controlType}', description: '${prop.description}' },`); + } else { + lines.push(` ${prop.name}: { control: '${prop.controlType}' },`); + } + } + } + lines.push(" },"); + } + + lines.push("};"); + lines.push(""); + lines.push("export default meta;"); + lines.push(`type Story = StoryObj;`); + lines.push(""); + + // Default story + const hasDefaults = Object.keys(defaults).length > 0; + if (hasDefaults) { + lines.push("export const Default: Story = {"); + lines.push(" args: {"); + for (const [key, value] of Object.entries(defaults)) { + if (typeof value === "string") { + lines.push(` ${key}: '${value}',`); + } else { + lines.push(` ${key}: ${value},`); + } + } + lines.push(" },"); + lines.push("};"); + } else { + lines.push("export const Default: Story = {};"); + } + + // Variant stories + const variantNames = []; + + for (const prop of props) { + if (prop.controlType === "select" && prop.literalValues.length > 0) { + const values = prop.literalValues.slice(0, maxVariants); + for (const val of values) { + const storyName = val.charAt(0).toUpperCase() + val.slice(1); + variantNames.push(storyName); + lines.push(""); + lines.push(`export const ${storyName}: Story = {`); + lines.push(" args: {"); + lines.push(` ${prop.name}: '${val}',`); + lines.push(" },"); + lines.push("};"); + } + } else if (prop.controlType === "boolean") { + const trueName = `${prop.name.charAt(0).toUpperCase() + prop.name.slice(1)}True`; + const falseName = `${prop.name.charAt(0).toUpperCase() + prop.name.slice(1)}False`; + variantNames.push(trueName, falseName); + lines.push(""); + lines.push(`export const ${trueName}: Story = {`); + lines.push(" args: {"); + lines.push(` ${prop.name}: true,`); + lines.push(" },"); + lines.push("};"); + lines.push(""); + lines.push(`export const ${falseName}: Story = {`); + lines.push(" args: {"); + lines.push(` ${prop.name}: false,`); + lines.push(" },"); + lines.push("};"); + } + } + + // Responsive viewport stories + if (includeViewports) { + for (const vp of viewports) { + const width = breakpoints[vp]; + if (!width) continue; + const name = vp.charAt(0).toUpperCase() + vp.slice(1); + variantNames.push(name); + lines.push(""); + lines.push(`export const ${name}: Story = {`); + lines.push(" parameters: {"); + lines.push(" viewport: {"); + lines.push(` defaultViewport: '${vp}',`); + lines.push(" },"); + lines.push(" },"); + lines.push("};"); + } + } + + lines.push(""); + return { content: lines.join("\n"), variantNames }; +} + +// --------------------------------------------------------------------------- +// MDX content generation +// --------------------------------------------------------------------------- + +function generateMdxContent( + componentName, + componentBasename, + description, + variantNames, +) { + const lines = []; + + lines.push("import { Meta } from '@storybook/blocks';"); + lines.push("import { Canvas } from '@storybook/blocks';"); + lines.push("import { Controls } from '@storybook/blocks';"); + lines.push("import { ArgTypes } from '@storybook/blocks';"); + lines.push(`import * as Stories from './${componentBasename}.stories';`); + lines.push(""); + lines.push(""); + lines.push(""); + lines.push(`# ${componentName}`); + lines.push(""); + + if (description) { + lines.push(description); + lines.push(""); + } + + lines.push(""); + lines.push(""); + lines.push(""); + lines.push(""); + + // Variant canvases + for (const varName of variantNames) { + lines.push(``); + lines.push(""); + } + + lines.push(""); + lines.push(""); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const cwd = process.cwd(); + const fullSrcDir = join(cwd, srcDir); + const fullConfigPath = join(cwd, configPath); + + // Check source directory exists + if (!existsSync(fullSrcDir)) { + log(`No ${srcDir} directory found`); + if (jsonOutput) { + console.log(JSON.stringify({ generated: 0, skipped: 0, files: [] })); + } + process.exit(0); + } + + // Load config + const config = loadConfig(fullConfigPath); + const skipPatterns = config.storybook.skipPatterns || []; + const generateMdxFlag = !noMdx && (config.storybook.generateMdx !== false); + + // Find component files + const componentFiles = findComponentFiles(fullSrcDir); + + if (componentFiles.length === 0) { + log("No component files found"); + if (jsonOutput) { + console.log(JSON.stringify({ generated: 0, skipped: 0, files: [] })); + } + process.exit(0); + } + + // Set up ts-morph project + const tsconfigPath = join(cwd, "tsconfig.json"); + let project; + if (existsSync(tsconfigPath)) { + project = new Project({ + tsConfigFilePath: tsconfigPath, + skipAddingFilesFromTsConfig: true, + }); + } else { + project = new Project({ + compilerOptions: { + target: 99, // ESNext + module: 99, // ESNext + jsx: 4, // react-jsx + strict: true, + esModuleInterop: true, + skipLibCheck: true, + }, + }); + } + + let generated = 0; + let skipped = 0; + const generatedFiles = []; + + for (const filePath of componentFiles) { + const relPath = relative(cwd, filePath); + const fileName = basename(filePath); + const fileDir = dirname(filePath); + const componentBasename = basename(filePath, ".tsx"); + + // Check skip patterns + if (matchesSkipPattern(filePath, skipPatterns)) { + log(`Skipped (config pattern): ${relPath}`); + skipped++; + continue; + } + + // Check for existing story file + const storyFilePath = join(fileDir, `${componentBasename}.stories.tsx`); + if (existsSync(storyFilePath) && !force) { + log(`Skipped (story exists): ${relPath}`); + skipped++; + continue; + } + + // Parse with ts-morph + let sourceFile; + try { + const fileContent = readFileSync(filePath, "utf-8"); + sourceFile = project.createSourceFile(filePath, fileContent, { + overwrite: true, + }); + } catch { + log(`Skipped (parse error): ${relPath}`); + skipped++; + continue; + } + + // Find exported component + const component = findExportedComponent(sourceFile); + if (!component) { + log(`Skipped (no exported component): ${relPath}`); + skipped++; + continue; + } + + const { name: componentName, isDefault } = component; + + // Find props interface and extract prop info + const propsInterface = findPropsInterface(sourceFile, componentName); + const props = extractPropsFromInterface(propsInterface); + const defaults = extractDefaultValues(sourceFile, componentName); + + // Build story title from relative path + const relToSrc = relative(fullSrcDir, filePath); + const storyTitle = `Components/${relToSrc.replace(/\.tsx$/, "").replace(/\\/g, "/")}`; + + // Generate story content + const { content: storyContent, variantNames } = generateStoryContent( + componentName, + componentBasename, + storyTitle, + isDefault, + props, + defaults, + config, + ); + + if (dryRun) { + log(`Would generate: ${relative(cwd, storyFilePath)}`); + } else { + writeFileSync(storyFilePath, storyContent, "utf-8"); + log(`Generated: ${relative(cwd, storyFilePath)}`); + } + generatedFiles.push(relative(cwd, storyFilePath)); + + // Generate MDX + if (generateMdxFlag) { + const mdxFilePath = join(fileDir, `${componentBasename}.mdx`); + const description = getComponentDescription(sourceFile, componentName); + const mdxContent = generateMdxContent( + componentName, + componentBasename, + description, + variantNames, + ); + + if (dryRun) { + log(`Would generate: ${relative(cwd, mdxFilePath)}`); + } else { + writeFileSync(mdxFilePath, mdxContent, "utf-8"); + } + generatedFiles.push(relative(cwd, mdxFilePath)); + } + + generated++; + } + + // Output + if (jsonOutput) { + console.log( + JSON.stringify({ generated, skipped, files: generatedFiles }), + ); + } else { + log(""); + if (dryRun) { + log(`Generated: ${generated}`); + log(`Skipped: ${skipped}`); + log("(dry run — no files written)"); + } else { + log(`Generated: ${generated}`); + log(`Skipped: ${skipped}`); + } + } +} + +main(); From 8aae65818b329a7f1a04a75b66e016cf8eec3806 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:16:16 -0400 Subject: [PATCH 4/8] fix: escape quotes in generated stories and sanitize variant identifiers Addresses code review findings: - Single quotes in JSDoc descriptions are now escaped in generated argTypes - String default values are escaped in generated args - Variant story names from union values are sanitized to valid JS identifiers (handles numeric prefixes like '2xl' and hyphens like 'left-to-right') Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/generate-stories.js | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/generate-stories.js b/scripts/generate-stories.js index 2a2313b..7f09133 100644 --- a/scripts/generate-stories.js +++ b/scripts/generate-stories.js @@ -19,6 +19,26 @@ import { } from "fs"; import { join, relative, basename, dirname } from "path"; +// --------------------------------------------------------------------------- +// String helpers +// --------------------------------------------------------------------------- + +/** Escape single quotes for safe interpolation into single-quoted strings */ +function esc(s) { + return s.replace(/'/g, "\\'"); +} + +/** Convert a string to a valid JS identifier for story export names */ +function toIdentifier(s) { + // Capitalize first letter + let name = s.charAt(0).toUpperCase() + s.slice(1); + // Replace non-alphanumeric with underscore + name = name.replace(/[^a-zA-Z0-9_$]/g, "_"); + // Prefix with _ if starts with a digit + if (/^\d/.test(name)) name = `_${name}`; + return name; +} + // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- @@ -474,14 +494,14 @@ function generateStoryContent( lines.push(` ${prop.name}: {`); lines.push(` control: 'select',`); lines.push(` options: [${optStr}],`); - lines.push(` description: '${prop.description}',`); + lines.push(` description: '${esc(prop.description)}',`); lines.push(" },"); } else { lines.push(` ${prop.name}: { control: 'select', options: [${optStr}] },`); } } else { if (prop.description) { - lines.push(` ${prop.name}: { control: '${prop.controlType}', description: '${prop.description}' },`); + lines.push(` ${prop.name}: { control: '${prop.controlType}', description: '${esc(prop.description)}' },`); } else { lines.push(` ${prop.name}: { control: '${prop.controlType}' },`); } @@ -503,7 +523,7 @@ function generateStoryContent( lines.push(" args: {"); for (const [key, value] of Object.entries(defaults)) { if (typeof value === "string") { - lines.push(` ${key}: '${value}',`); + lines.push(` ${key}: '${esc(value)}',`); } else { lines.push(` ${key}: ${value},`); } @@ -521,7 +541,7 @@ function generateStoryContent( if (prop.controlType === "select" && prop.literalValues.length > 0) { const values = prop.literalValues.slice(0, maxVariants); for (const val of values) { - const storyName = val.charAt(0).toUpperCase() + val.slice(1); + const storyName = toIdentifier(val); variantNames.push(storyName); lines.push(""); lines.push(`export const ${storyName}: Story = {`); From 2f7700b1bb53ceecbbfe41d13120429e1cde4b76 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:16:58 -0400 Subject: [PATCH 5/8] refactor: replace generate-stories.sh with thin Node.js wrapper --- scripts/generate-stories.sh | 276 +----------------------------------- 1 file changed, 2 insertions(+), 274 deletions(-) diff --git a/scripts/generate-stories.sh b/scripts/generate-stories.sh index 5dd3318..ecd61a7 100644 --- a/scripts/generate-stories.sh +++ b/scripts/generate-stories.sh @@ -1,277 +1,5 @@ #!/usr/bin/env bash -# generate-stories.sh — Auto-generate Storybook .stories.tsx files for React components +# generate-stories.sh — Thin wrapper for generate-stories.js # Phase 4.5 of the Figma-to-React pipeline (non-blocking) set -euo pipefail - -SRC_DIR="src/components" -CONFIG_FILE=".claude/pipeline.config.json" -FORCE=false -DRY_RUN=false -GENERATED=0 -SKIPPED=0 - -# --- Parse flags --- -for arg in "$@"; do - case "$arg" in - --force) FORCE=true ;; - --dry-run) DRY_RUN=true ;; - *) echo "Unknown flag: $arg"; exit 1 ;; - esac -done - -echo "=== Storybook Story Generation ===" -echo "" - -# --- Check for src/components directory --- -if [[ ! -d "$SRC_DIR" ]]; then - echo "⊘ No $SRC_DIR directory found — skipping story generation" - exit 0 -fi - -# --- Read config from pipeline.config.json --- -SKIP_PATTERNS="[]" -INCLUDE_VIEWPORTS=false -VIEWPORT_NAMES="[]" -BREAKPOINTS="{}" - -if [[ -f "$CONFIG_FILE" ]]; then - echo "▸ Reading config from $CONFIG_FILE" - - # Parse all storybook config in one node call - CONFIG_VALUES=$(node -e " - const fs = require('fs'); - const cfg = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); - const sb = cfg.storybook || {}; - const vd = cfg.visualDiff || {}; - console.log(JSON.stringify({ - skipPatterns: sb.skipPatterns || [], - includeViewports: sb.includeResponsiveViewports || false, - viewports: sb.viewports || [], - breakpoints: vd.breakpoints || {} - })); - " 2>/dev/null || echo '{}') - - if [[ "$CONFIG_VALUES" != '{}' ]]; then - SKIP_PATTERNS=$(node -e "console.log(JSON.parse(process.argv[1]).skipPatterns.join('|'))" "$CONFIG_VALUES" 2>/dev/null || echo "") - INCLUDE_VIEWPORTS=$(node -e "console.log(JSON.parse(process.argv[1]).includeViewports)" "$CONFIG_VALUES" 2>/dev/null || echo "false") - VIEWPORT_NAMES=$(node -e "console.log(JSON.stringify(JSON.parse(process.argv[1]).viewports))" "$CONFIG_VALUES" 2>/dev/null || echo "[]") - BREAKPOINTS=$(node -e "console.log(JSON.stringify(JSON.parse(process.argv[1]).breakpoints))" "$CONFIG_VALUES" 2>/dev/null || echo "{}") - fi - - echo " includeResponsiveViewports: $INCLUDE_VIEWPORTS" - echo "" -else - echo "▸ No $CONFIG_FILE found — using defaults" - echo "" -fi - -# --- Scan for component files --- -echo "▸ Scanning $SRC_DIR for React components..." - -COMPONENT_FILES=$(find "$SRC_DIR" -name "*.tsx" \ - ! -name "*.test.tsx" \ - ! -name "*.test.ts" \ - ! -name "*.stories.tsx" \ - ! -name "*.stories.ts" \ - ! -name "*.d.ts" \ - ! -name "index.tsx" \ - ! -name "index.ts" \ - ! -path "*/node_modules/*" \ - ! -path "*/__tests__/*" \ - ! -path "*/__mocks__/*" \ - 2>/dev/null || true) - -if [[ -z "$COMPONENT_FILES" ]]; then - echo " ⊘ No component files found in $SRC_DIR" - echo "" - echo "=== Summary ===" - echo " Generated: 0" - echo " Skipped: 0" - exit 0 -fi - -# --- Process each component file --- -while IFS= read -r component_file; do - filename=$(basename "$component_file") - - # Check skip patterns from config - if [[ -n "$SKIP_PATTERNS" && "$SKIP_PATTERNS" != "[]" ]]; then - should_skip=false - # Check each pattern individually - IFS='|' read -ra PATTERNS <<< "$SKIP_PATTERNS" - for pattern in "${PATTERNS[@]}"; do - # Convert glob pattern to a simple check - # Strip leading **/ for basename matching - simple_pattern="${pattern##**/}" - if [[ "$filename" == $simple_pattern ]]; then - should_skip=true - break - fi - done - if [[ "$should_skip" == "true" ]]; then - echo " ⊘ Skipped (config pattern): $component_file" - SKIPPED=$((SKIPPED + 1)) - continue - fi - fi - - # Check for existing story file - story_file="${component_file%.tsx}.stories.tsx" - if [[ -f "$story_file" && "$FORCE" != "true" ]]; then - echo " ⊘ Skipped (story exists): $component_file" - SKIPPED=$((SKIPPED + 1)) - continue - fi - - # Detect exported React components - EXPORTS=$(grep -E 'export\s+(default\s+)?(function|const)\s+[A-Z]' "$component_file" 2>/dev/null || true) - - if [[ -z "$EXPORTS" ]]; then - echo " ⊘ Skipped (no exported component): $component_file" - SKIPPED=$((SKIPPED + 1)) - continue - fi - - # Extract the first component name - COMPONENT_NAME=$(echo "$EXPORTS" | head -1 \ - | grep -oE '(function|const)\s+[A-Z][A-Za-z0-9]*' \ - | head -1 \ - | sed 's/^function\s\+//' \ - | sed 's/^const\s\+//') - - if [[ -z "$COMPONENT_NAME" ]]; then - echo " ⊘ Skipped (could not parse component name): $component_file" - SKIPPED=$((SKIPPED + 1)) - continue - fi - - # Extract props interface name (look for interface/type ending in Props) - PROPS_INTERFACE=$(grep -oE '(interface|type)\s+[A-Za-z]*Props' "$component_file" 2>/dev/null \ - | head -1 \ - | sed 's/^interface\s\+//' \ - | sed 's/^type\s\+//' || true) - - # Derive the import path (relative from story to component) - component_basename=$(basename "$component_file" .tsx) - - # Build the Storybook title from the file path - # e.g. src/components/ui/Button.tsx -> Components/ui/Button - relative_path="${component_file#src/components/}" - story_title="Components/${relative_path%.tsx}" - - # --- Generate story content --- - STORY_CONTENT="import type { Meta, StoryObj } from '@storybook/react'; -import { ${COMPONENT_NAME} } from './${component_basename}';" - - # Add props type import if detected - if [[ -n "$PROPS_INTERFACE" ]]; then - STORY_CONTENT="import type { Meta, StoryObj } from '@storybook/react'; -import { ${COMPONENT_NAME} } from './${component_basename}'; -import type { ${PROPS_INTERFACE} } from './${component_basename}';" - fi - - STORY_CONTENT="${STORY_CONTENT} - -const meta: Meta = { - title: '${story_title}', - component: ${COMPONENT_NAME}, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {};" - - # Add responsive viewport stories if configured - if [[ "$INCLUDE_VIEWPORTS" == "true" ]]; then - # Build viewport stories from config - VIEWPORT_STORIES=$(node -e " - const viewports = ${VIEWPORT_NAMES}; - const breakpoints = ${BREAKPOINTS}; - const stories = []; - for (const vp of viewports) { - const width = breakpoints[vp]; - if (!width) continue; - const name = vp.charAt(0).toUpperCase() + vp.slice(1); - stories.push(''); - stories.push('export const ' + name + ': Story = {'); - stories.push(' parameters: {'); - stories.push(' viewport: {'); - stories.push(' defaultViewport: \'${'\'' + name.toLowerCase() + '\\'' }'); - stories.push(' },'); - stories.push(' },'); - stories.push('};'); - } - console.log(stories.join('\n')); - " 2>/dev/null || true) - - # Use a more reliable approach for viewport stories - VIEWPORT_STORIES=$(node -e " - const viewports = ${VIEWPORT_NAMES}; - const breakpoints = ${BREAKPOINTS}; - const lines = []; - for (const vp of viewports) { - const width = breakpoints[vp]; - if (!width) continue; - const name = vp.charAt(0).toUpperCase() + vp.slice(1); - lines.push(''); - lines.push('export const ' + name + ': Story = {'); - lines.push(' parameters: {'); - lines.push(' viewport: {'); - lines.push(' defaultViewport: ' + JSON.stringify(vp) + ','); - lines.push(' },'); - lines.push(' },'); - lines.push('};'); - } - console.log(lines.join('\n')); - " 2>/dev/null || true) - - if [[ -n "$VIEWPORT_STORIES" ]]; then - STORY_CONTENT="${STORY_CONTENT} -${VIEWPORT_STORIES}" - fi - fi - - # Add trailing newline - STORY_CONTENT="${STORY_CONTENT} -" - - # --- Write or report --- - if [[ "$DRY_RUN" == "true" ]]; then - echo " ✓ Would generate: $story_file (component: ${COMPONENT_NAME})" - else - echo "$STORY_CONTENT" > "$story_file" - echo " ✓ Generated: $story_file (component: ${COMPONENT_NAME})" - fi - GENERATED=$((GENERATED + 1)) - -done <<< "$COMPONENT_FILES" -echo "" - -# --- Storybook smoke test --- -if [[ "$DRY_RUN" != "true" && $GENERATED -gt 0 ]]; then - if [[ -f "package.json" ]] && grep -q '"storybook"' package.json 2>/dev/null; then - echo "▸ Running Storybook build smoke test..." - if pnpm build-storybook --quiet 2>/dev/null; then - echo " ✓ Storybook build succeeded" - else - echo " ⚠ Storybook build failed — stories may need manual review" - fi - echo "" - else - echo "▸ Storybook not found in package.json — skipping smoke test" - echo "" - fi -fi - -# --- Summary --- -echo "=== Summary ===" -if [[ "$DRY_RUN" == "true" ]]; then - echo " Would generate: $GENERATED" - echo " Skipped: $SKIPPED" - echo " (dry run — no files written)" -else - echo " Generated: $GENERATED" - echo " Skipped: $SKIPPED" -fi +exec node "$(dirname "$0")/generate-stories.js" "$@" From 5d3d9e05835c725816e658245e16440ec8d84fdb Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:23:07 -0400 Subject: [PATCH 6/8] feat: add generateMdx and maxVariantsPerProp to storybook pipeline config --- .claude/pipeline.config.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/pipeline.config.json b/.claude/pipeline.config.json index c01390c..1ae6827 100644 --- a/.claude/pipeline.config.json +++ b/.claude/pipeline.config.json @@ -210,7 +210,9 @@ "**/index.ts", "**/*.test.*", "**/*.stories.*" - ] + ], + "generateMdx": true, + "maxVariantsPerProp": 10 }, "tokenSync": { "autoCheck": true, From 8d3573612acc609353d336aeabda265cd7d751b7 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:24:02 -0400 Subject: [PATCH 7/8] docs: update CLAUDE.md with new generate-stories flags and description --- CLAUDE.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e28e8a..e19bb0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,8 +80,12 @@ node scripts/visual-diff.js --batch [--output-dir di # Dark mode visual verification ./scripts/check-dark-mode.sh http://localhost:3000 -# Storybook story generation -./scripts/generate-stories.sh +# Storybook story + MDX generation (AST-based) +./scripts/generate-stories.sh # Generate stories + MDX for all components +./scripts/generate-stories.sh --force # Overwrite existing stories +./scripts/generate-stories.sh --dry-run # Report without writing +./scripts/generate-stories.sh --no-mdx # Skip MDX documentation +./scripts/generate-stories.sh --json # JSON output # Token drift detection ./scripts/sync-tokens.sh [--dry-run] [--json] @@ -274,7 +278,7 @@ Autonomous 9-phase pipeline that converts a Figma design into a working, tested - `visual-diff.js` — Pixel-level screenshot comparison with region analysis - `sync-tokens.sh` — Detects token drift between lockfile and source - `check-dark-mode.sh` — Dark mode screenshot capture and visual comparison -- `generate-stories.sh` — Auto-generates Storybook stories from components +- `generate-stories.sh` — AST-based Storybook story + MDX generation with prop controls, variants, and action args - `generate-component-docs.sh` — Generates MDX component documentation **Features:** @@ -515,7 +519,7 @@ gh issue create # Create issue ./scripts/verify-tokens.sh # Design token enforcement ./scripts/sync-tokens.sh # Token drift detection ./scripts/check-dark-mode.sh # Dark mode verification -./scripts/generate-stories.sh # Storybook generation +./scripts/generate-stories.sh # Storybook story + MDX generation ./scripts/generate-component-docs.sh # Component documentation ./scripts/check-dead-code.sh # Dead code detection (knip) ./scripts/check-security.sh # Security audit From e8e4e1ec391e3801cede45f6d3fb04a6ac5765ca Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 15 Apr 2026 13:27:25 -0400 Subject: [PATCH 8/8] style: format generate-stories files with Prettier --- scripts/__tests__/generate-stories.test.js | 34 +++----------- scripts/generate-stories.js | 53 ++++++++-------------- 2 files changed, 25 insertions(+), 62 deletions(-) diff --git a/scripts/__tests__/generate-stories.test.js b/scripts/__tests__/generate-stories.test.js index dc221d5..a6a1ed8 100644 --- a/scripts/__tests__/generate-stories.test.js +++ b/scripts/__tests__/generate-stories.test.js @@ -2,14 +2,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { execFileSync } from "child_process"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; -import { - mkdirSync, - writeFileSync, - readFileSync, - rmSync, - existsSync, - readdirSync, -} from "fs"; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync } from "fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(__dirname, "..", "generate-stories.js"); @@ -162,10 +155,7 @@ export function Card({ title }: CardProps) { return
{title}
; }`, join(dir, "src", "components", "Nav.tsx"), `export function Nav() { return ; }`, ); - writeFileSync( - join(dir, "src", "components", "Nav.stories.tsx"), - `// existing story`, - ); + writeFileSync(join(dir, "src", "components", "Nav.stories.tsx"), `// existing story`); const result = run(dir); expect(result.exitCode).toBe(0); @@ -181,19 +171,13 @@ export function Card({ title }: CardProps) { return
{title}
; }`, join(dir, "src", "components", "Nav.tsx"), `export function Nav() { return ; }`, ); - writeFileSync( - join(dir, "src", "components", "Nav.stories.tsx"), - `// old story content`, - ); + writeFileSync(join(dir, "src", "components", "Nav.stories.tsx"), `// old story content`); const result = run(dir, ["--force"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Generated:"); - const content = readFileSync( - join(dir, "src", "components", "Nav.stories.tsx"), - "utf-8", - ); + const content = readFileSync(join(dir, "src", "components", "Nav.stories.tsx"), "utf-8"); expect(content).toContain("import type { Meta, StoryObj }"); }); @@ -231,10 +215,7 @@ export function Card({ title }: CardProps) { return
{title}
; }`, join(dir, "src", "components", "C.tsx"), `export function Charlie() { return
C
; }`, ); - writeFileSync( - join(dir, "src", "components", "C.stories.tsx"), - `// existing`, - ); + writeFileSync(join(dir, "src", "components", "C.stories.tsx"), `// existing`); const result = run(dir); expect(result.exitCode).toBe(0); @@ -688,10 +669,7 @@ export function Heading({ level, text }: HeadingProps) { join(dir, "src", "components", "Footer.tsx"), `export function Footer() { return ; }`, ); - writeFileSync( - join(dir, "src", "components", "Footer.stories.tsx"), - `// existing`, - ); + writeFileSync(join(dir, "src", "components", "Footer.stories.tsx"), `// existing`); const result = run(dir, ["--json"]); expect(result.exitCode).toBe(0); diff --git a/scripts/generate-stories.js b/scripts/generate-stories.js index 7f09133..45c9e97 100644 --- a/scripts/generate-stories.js +++ b/scripts/generate-stories.js @@ -10,13 +10,7 @@ */ import { Project, SyntaxKind } from "ts-morph"; -import { - existsSync, - readFileSync, - writeFileSync, - readdirSync, - statSync, -} from "fs"; +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs"; import { join, relative, basename, dirname } from "path"; // --------------------------------------------------------------------------- @@ -118,11 +112,7 @@ function walkDir(dir) { } if (stat.isDirectory()) { // Skip known non-component directories - if ( - entry === "node_modules" || - entry === "__tests__" || - entry === "__mocks__" - ) { + if (entry === "node_modules" || entry === "__tests__" || entry === "__mocks__") { continue; } results.push(...walkDir(fullPath)); @@ -156,9 +146,7 @@ function matchesSkipPattern(filePath, patterns) { // Strip leading **/ for basename matching const simple = pattern.replace(/^\*\*\//, ""); // Convert glob to regex - const regexStr = simple - .replace(/\./g, "\\.") - .replace(/\*/g, ".*"); + const regexStr = simple.replace(/\./g, "\\.").replace(/\*/g, ".*"); const re = new RegExp(`^${regexStr}$`); if (re.test(name)) return true; } @@ -330,8 +318,9 @@ function isStringLiteralUnion(typeNode) { if (typeNode.getKind() === SyntaxKind.UnionType) { const types = typeNode.getTypeNodes(); return types.every( - (t) => t.getKind() === SyntaxKind.LiteralType - && t.getLiteral().getKind() === SyntaxKind.StringLiteral, + (t) => + t.getKind() === SyntaxKind.LiteralType && + t.getLiteral().getKind() === SyntaxKind.StringLiteral, ); } return false; @@ -373,10 +362,11 @@ function extractDefaultValues(sourceFile, componentName) { for (const decl of varStatement.getDeclarations()) { if (decl.getName() === componentName) { const initializer = decl.getInitializer(); - if (initializer && ( - initializer.getKind() === SyntaxKind.ArrowFunction - || initializer.getKind() === SyntaxKind.FunctionExpression - )) { + if ( + initializer && + (initializer.getKind() === SyntaxKind.ArrowFunction || + initializer.getKind() === SyntaxKind.FunctionExpression) + ) { extractDefaultsFromParams(initializer, defaults); } return defaults; @@ -402,8 +392,8 @@ function extractDefaultsFromParams(fnNode, defaults) { const text = initializer.getText(); // Parse string literals if ( - (text.startsWith("'") && text.endsWith("'")) - || (text.startsWith('"') && text.endsWith('"')) + (text.startsWith("'") && text.endsWith("'")) || + (text.startsWith('"') && text.endsWith('"')) ) { defaults[propName] = text.slice(1, -1); } else if (text === "true") { @@ -501,7 +491,9 @@ function generateStoryContent( } } else { if (prop.description) { - lines.push(` ${prop.name}: { control: '${prop.controlType}', description: '${esc(prop.description)}' },`); + lines.push( + ` ${prop.name}: { control: '${prop.controlType}', description: '${esc(prop.description)}' },`, + ); } else { lines.push(` ${prop.name}: { control: '${prop.controlType}' },`); } @@ -595,12 +587,7 @@ function generateStoryContent( // MDX content generation // --------------------------------------------------------------------------- -function generateMdxContent( - componentName, - componentBasename, - description, - variantNames, -) { +function generateMdxContent(componentName, componentBasename, description, variantNames) { const lines = []; lines.push("import { Meta } from '@storybook/blocks';"); @@ -657,7 +644,7 @@ function main() { // Load config const config = loadConfig(fullConfigPath); const skipPatterns = config.storybook.skipPatterns || []; - const generateMdxFlag = !noMdx && (config.storybook.generateMdx !== false); + const generateMdxFlag = !noMdx && config.storybook.generateMdx !== false; // Find component files const componentFiles = findComponentFiles(fullSrcDir); @@ -791,9 +778,7 @@ function main() { // Output if (jsonOutput) { - console.log( - JSON.stringify({ generated, skipped, files: generatedFiles }), - ); + console.log(JSON.stringify({ generated, skipped, files: generatedFiles })); } else { log(""); if (dryRun) {