🥤

ReactのコンポーネントからStorybookのファイルを自動生成してみた

2023/09/11に公開

はじめに

フロントエンドのプロジェクトでは、UIコンポーネントのカタログとしてStorybookが用いられるケースがあると思います。
StorybookはコンポーネントベースのUI開発の助けとなるツールで、Reactのコンポーネントを独立して視覚的に確認できます。
しかしながら繁忙時や規模の大きいプロジェクトになると、メンバーの増減や開発工数等でStorybookの開発コストが高く感じられる状況があると思います。

そこで本記事では、なるべくStorybookの開発コストを軽減して、Storybookのファイルを自動生成する仕組みを考案してみました。

Storybookとは

Storybookは、UIコンポーネントを独立して開発・表示するためのツールです。
React, Vue, Angularなど、様々なフレームワークに対応しており、各コンポーネントの異なる状態やバリエーションを一覧で見ることができます。例えば、ボタンコンポーネントであれば、活性状態、非活性状態、ローディング状態などを一つの画面で確認することができます。

https://storybook.js.org/

storybookを導入するメリットについて

  • コンポーネントの再利用:
    Storybookを使用すると、すでに開発されたコンポーネントを容易に再利用できます。これにより、再開発の必要が減少し、開発速度が向上します。

  • コンポーネントの状態の可視化:
    各コンポーネントの異なる状態やバリエーションを一覧で確認できるため、デザイナーや他の開発者とのコミュニケーションが効率的になります。

  • 独立した開発:
    UIコンポーネントをアプリケーションから独立して開発することができるため、ユニットテストやChromaticを使用したビジュアルリグレッションテストが容易になります。

  • ドキュメントとしての利用:
    Storybookはコンポーネントのカタログとしても機能します。また新しい開発者がチームに参加した際の既存コンポーネントの理解も円滑になります。

自動化する前の課題点

フロントエンド開発を進める中で、UIコンポーネントが増えるにつれ、Storybook用のファイルも同時に増加していきました。

当初はコンポーネントの数が少なかったのでファイルの管理もしやすく、手動での作成もそれほど負担に感じなかったのですが、プロジェクトの規模が大きくなるにつれ、以下のような課題が浮き彫りになってきました。

  • 手間の増加:
    新しいコンポーネントを作成するたびに、ストーリーファイルの作成やそれに伴うStorybookで必要な設定の記述が必要になり、同様の記述を書き続けることが億劫に感じる瞬間が増加していました。

  • 一貫性の欠如:
    手動でのファイル作成は、特にテンプレートやガイドラインが不足していると、開発者によってファイルの記述内容が異なる場合がありました。
    これにより、プロジェクト全体の一貫性が低下し、後からコードを見直す際に混乱が生じる可能性が想定されました。

  • ミスの増加:
    手動での作業は、どれだけ慎重に行ってもヒューマンエラーが発生する可能性があります。
    特に繁忙な開発状況下では、ファイルの作成忘れや、必要な設定の見落としなどのミスが発生しやすくなることが想定されました。

  • 開発速度の低下:
    上記の手間やミスを修正する時間が増加することで、本来の開発に割ける時間が減少し、結果として開発速度が低下してしまうことが懸念されました。

これらの課題を解決するため、Storybook用のファイルの生成を自動化する方法を検討しました。

自動化したこと

本記事で自動化できることは下記の4点になります。

  1. 対象のコンポーネントが作成されたら、そのコンポーネントと同階層に自動でストーリーファイルを作成する。
  2. 作成されたストーリーファイルに共通的な雛形を記述する。
  3. import文や型情報の記述を対象のコンポーネントからトレースして、自動生成されたストーリーファイルに記述する。
  4. gitのhooks(pre-commit)にストーリーファイルの自動生成スクリプトを混ぜ込むことで、対象のコンポーネントがcommitされたタイミングでスクリプトを実行する。

動作検証

対象のコンポーネントがコミット時された後にストーリーファイルが生成されることが確認できる

storyファイル自動生成の検証

使用技術・バージョン

使用技術 バージョン 備考
node.js v18.15.0 JavaScript実行環境
typescript 5.0.4 型付けを持ったJavaScriptのスーパーセット
storybook ^7.1.0 UIコンポーネントの開発・ドキュメンテーションツール
ts-morph ^19.0.0 TypeScriptのASTを操作するライブラリ
prettier ^3.0.0 コードのフォーマットを行うライブラリ
esbuild ^0.18.12 高速なJavaScriptとCSSのバンドラーとminifyを行うライブラリ
esbuild-register ^3.4.2 Node.jsでのesbuildのトランスパイルサポート
husky ^8.0.3 Gitのフックを設定するツール
lint-staged ^13.2.3 Gitでステージングされたファイルに対して、Linterやフォーマッタを実行するツール

ディレクトリ構成

本記事では、下記のようなディレクトリ構成で自動化スクリプトを実装していきます。

├── .storybook
│   ├── main.ts
│   └── preview.tsx
│
├── public
│   └── XXX.png
│
├── scripts
│   ├── createStory.ts
│   ├── package.json
│   └── tsconfig.json
│
├── src
│   └── components
│        └── Base
│           └── TestCreateStoryFile
│               ├── TestCreateStoryFile.story.tsx ← このストーリーファイルを自動生成する
│               └── index.tsx
│		
├── package.json
├── tsconfig.json

実装

初期設定

scripts/package.json
scripts/package.json
{
  "type": "modules",
  "devDependencies": {
    "@types/node": "^20.4.2",
    "esbuild": "^0.18.12",
    "esbuild-register": "^3.4.2",
  },
}
scripts/tsconfig.json
scripts/tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */

    /* Strict Type-Checking Options */
    "strict": true,                           /* Enable all strict type-checking options. */
   
    "esModuleInterop": true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    
    /* Advanced Options */
    "skipLibCheck": true,                     /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
    "resolveJsonModule": true
  }
}
package.json
package.json
{
  "name": "xxx",
  "version": "0.1.0",
  "private": true,
  "workspaces": [
    "scripts"
  ],
  "scripts": {
    "dev": "next dev",
    "build": "env-cmd -f .env.build next build",
    "start": "next start",
    "lint": "eslint './src/**/*.{js,ts,tsx}'",
    "lint:fix": "eslint --fix './src/**/*.{js,ts,tsx}'",
    "format": "prettier --check './src/**/*.{js,ts,tsx}'",
    "format:write": "prettier --write './src/**/*.{js,ts,tsx}'",
    "husky": "npx husky install",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "auto-create-storybook": "node -r esbuild-register scripts/createStory.ts"
  },
  "dependencies": {
    "@emotion/react": "^11.11.0",
    "@emotion/styled": "^11.11.0",
    "@mantine/core": "^6.0.13",
    "@mantine/dates": "^6.0.13",
    "@mantine/form": "^6.0.13",
    "@mantine/hooks": "^6.0.13",
    "@types/node": "20.1.7",
    "@types/react": "18.2.6",
    "@types/react-dom": "18.2.4",
    "next": "13.4.2",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "5.0.4"
  },
  "devDependencies": {
    "@storybook/addon-essentials": "^7.1.0",
    "@storybook/addon-interactions": "^7.1.0",
    "@storybook/addon-links": "^7.1.0",
    "@storybook/blocks": "^7.1.0",
    "@storybook/nextjs": "^7.1.0",
    "@storybook/react": "^7.1.0",
    "@storybook/testing-library": "^0.2.0",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.4.3",
    "@types/eslint": "^8",
    "@types/jest": "^29.5.2",
    "@types/js-yaml": "^4",
    "@types/react-slick": "^0",
    "@types/slick-carousel": "^1",
    "@types/testing-library__jest-dom": "^5.14.6",
    "@typescript-eslint/eslint-plugin": "^5.59.6",
    "chromatic": "^6.20.0",
    "eslint": "^8.44.0",
    "eslint-config-next": "^13.4.9",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-storybook": "^0.6.13",
    "husky": "^8.0.3",
    "jest": "^29.5.0",
    "jest-environment-jsdom": "^29.5.0",
    "lint-staged": "^13.2.3",
    "next-intercept-stdout": "^1.0.1",
    "next-router-mock": "^0.9.7",
    "prettier": "^3.0.0",
    "storybook": "^7.1.0",
    "ts-jest": "^29.1.0",
    "ts-morph": "^19.0.0"
  },
  "lint-staged": {
    "src/**/*.{js,ts,tsx}": [
      "yarn run lint:fix",
      "yarn run format:write",
      "yarn run auto-create-storybook"
    ]
  }
}
tsconfig.json
tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    "baseUrl": "./"
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/types/**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

.storybookの配下にmain.tspreview.tsxを作成します。

.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs'
const config: StorybookConfig = {
  stories: ['../src/components/**/*.story.tsx'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  staticDirs: ['../public'],
}
export default config

storybook/main.tsはStorybookの設定ファイルとなっており、各プロパティの詳細は下記のようになっています。

プロパティ 説明
stories ストーリーのファイルパスを指定します。
../src/components/**/*.story.tsxを設定することで、src/componentsディレクトリ配下の全ての .story.tsxファイルをストーリーとして読み込むように設定しています。
addons 追加機能や拡張機能を指定します。
下記が本記事で利用されているアドオンになります。

@storybook/addon-links: ストーリー間のリンクを作成する
@storybook/addon-essentials: 主要なアドオンセット
@storybook/addon-interactions: インタラクションテスト用のアドオン
framework 使用するフレームワークやライブラリとそのオプションを指定します。@storybook/nextjs は、Next.js向けのStorybookを使用することを示しています。
docs Storybookのドキュメント関連の設定です。
autodocs: 'tag'は、タグをベースにドキュメントを自動生成する設定を意味します。
staticDirs ストーリーで利用する静的ファイルが格納されているディレクトリのパスを指定します。
../publicは、プロジェクトの public ディレクトリを指定しています。

.storybook/preview.tsx
import React from 'react'
import type { Preview } from '@storybook/react'
import { MantineProvider } from '@mantine/core'
import { theme } from '../src/plugins/mantine'

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
  decorators: [
    (Story) => (
      <MantineProvider theme={theme}>
          <Story />
      </MantineProvider>
    ),
  ],
}

export default preview

preview.tsxファイルは、Storybookのプレビューウィンドウの挙動や見た目を設定するファイルです。

  • parameters:

    プロパティ 説明
    actions argTypesRegexを使用して、特定の命名パターンを持つ定義にアクションを自動的に関連付けます。
    この例では、on[大文字から始まるワード]という形式の名前を持つ全ての定義に対してアクションを関連付けています。
    controls ストーリーのコントロール(インタラクティブな部分)の動作を設定します。
    ここでは、色や日付に関連する名前を持つ定義を特定のコントロールタイプに関連付けるためのマッチャーを設定しています。
  • decorators:
    すべてのストーリーに共通のラッピング要素やデコレーションを追加するためのプロパティです。
    本記事では、全てのストーリーをMantineProviderでラップして、カスタムされたMantineテーマを適用しています。

作成した自動化スクリプト

scripts/createStory.ts
import fs from 'fs'
import path from 'path'
import { Project } from 'ts-morph'
import { exec } from 'child_process'

const componentsDir = path.join(__dirname, '../src/components')

const project = new Project({
  tsConfigFilePath: path.join(process.cwd(), 'tsconfig.json'),
})

const createdStoryFileNames: string[] = []

const createStoryFiles = (dir: string) => {
  const items = fs.readdirSync(dir)

  items.forEach((item: string) => {
    const fullPath = path.join(dir, item)
    const stats = fs.statSync(fullPath)

    if (stats.isDirectory()) {
      createStoryFiles(fullPath)
    }

    if (
      stats.isFile() &&
      path.extname(item) === '.tsx' &&
      !item.endsWith('.test.tsx') &&
      !item.endsWith('.story.tsx')
    ) {
      let componentName = path.basename(item, '.tsx')

      if (componentName === 'index') {
        componentName = path.basename(dir)
      }

      const storyFilePath = path.join(dir, `${componentName}.story.tsx`)

      if (fs.existsSync(storyFilePath)) return

      const relativeDir = path
        .relative(componentsDir, dir)
        .split(path.sep)
        .join('/')

      const title = `${relativeDir}/${componentName}`

      const sourceFile = project.getSourceFile(fullPath)

      if (!sourceFile) return

      const typeAliases = sourceFile.getTypeAliases()
      const typeAliasesTypeNames: { getName: any }[] = []
      const typeAliasesTypeTexts: { getText: any }[] = []

      typeAliases.forEach((typeAlias: { getText: any; getName: any }) => {
        typeAliasesTypeNames.push(typeAlias.getName())
        typeAliasesTypeTexts.push(typeAlias.getText())
      })

      const typeItems = JSON.stringify(typeAliasesTypeTexts)
        .replace('"type Props = {\\n', '')
        .replace('\\n}"', '')
        .slice(1, -1)
        .replace(/\\n/g, '\n')
        .split('\n')
        .map((item) => item.trim())

      const argsObj: Record<string, unknown> = {}

      typeItems.forEach((item) => {
        if (!item.includes(':')) return
        const key = item.split(':')[0].trim()
        const value = item.split(':')[1].trim()

        if (key.includes('?')) return

        switch (!!value) { // ここでストーリーファイルの args: {} に当て込む初期値を設定しています
          case key.includes('width'):
          case key.includes('height'):
            argsObj[key] = '100px'
            break
          case key.includes('color'):
            argsObj[key] = 'white'
            break
          case key.includes('href'):
          case key.includes('src'):
          case key.includes('url'):
            argsObj[key] = 'https://example.com'
            break
          case key.includes('backgroundColor'):
            argsObj[key] = '#3460C5'
            break
          case key.includes('margin'):
          case key.includes('padding'):
            argsObj[key] = '10px'
            break
          case key.includes('children'):
            argsObj[key] = 'ここにchildrenの内容が表示されます。'
            break
          case value.includes('number'):
            argsObj[key] = 1
            break
          case value.includes('string'):
            argsObj[key] = 'ダミーデータ'
            break
          case value.includes('boolean'):
            argsObj[key] = false
            break
          default:
            // 未知の型または複雑な型の場合、一時的に null を割り当てる
            argsObj[key] = null
        }
      })

      const importPath = path.relative(dir, fullPath).replace('.tsx', '')
      const typeName = typeAliasesTypeNames[0]
      const typeContent = typeAliasesTypeTexts.join('\n')

      const imports = sourceFile.getImportDeclarations()
      const importTypeTexts: string[] = []
      const mantineCoreImportNames: string[] = []
      const mantineTargetTypes = ['Sx', 'MantineSize']

      imports.forEach(
        (imp: {
          getModuleSpecifierValue: any
          getText: any
          getNamedImports: any
        }) => {
          const importModule = imp.getModuleSpecifierValue()

          if (
            (typeContent && importModule.includes('@/types')) ||
            importModule === '@mantine/form/lib/types'
          ) {
            importTypeTexts.push(imp.getText())
          }
          if (importModule === '@mantine/core') {
            const namedImports = imp.getNamedImports()
            const importNames = namedImports.map(
              (namedImport: { getName: any }) => namedImport.getName(),
            )
            const filteredImportNames = importNames.filter((name: string) =>
              mantineTargetTypes.includes(name),
            )
            mantineCoreImportNames.push(...filteredImportNames)
          }
        },
      )

      const importTypePath = importTypeTexts.join('\n')
      const importMantineCore =
        mantineCoreImportNames.length > 0
          ? `import { ${mantineCoreImportNames.join(
              ', ',
            )} } from '@mantine/core';`
          : ''

      const argsBlock = typeName
        ? `args:
        ${JSON.stringify(argsObj, null, 2)}
      ,`
        : ''

      const hasChildren = typeItems.some((item) => /^children:/.test(item))

      const renderContent = hasChildren
        ? `<${componentName} {...args}>{args.children}</${componentName}>`
        : `<${componentName} {...args} />`

      const content = `
  import { Meta, StoryObj } from '@storybook/react';
  import ${componentName} from './${importPath}';
  ${importTypePath}
  ${importMantineCore}

  ${typeContent}

  export default {
    title: '${title}',
    component: ${componentName},
    tags: ['autodocs'],
    ${argsBlock}
    // Add your own control here
  } as Meta;

  type Story = StoryObj<typeof ${componentName}>;

  export const Default: Story = {
    render: ${typeName ? `(args: ${typeName})` : '()'} => {
      /* eslint-disable react-hooks/rules-of-hooks */
      return (${renderContent});
    },
  };
  `

      fs.writeFileSync(storyFilePath, content)
      createdStoryFileNames.push(storyFilePath)
    }
  })
}

console.log('Creating story files...')
createStoryFiles(componentsDir)
console.log('Done!')

console.log('Running Prettier...')
createdStoryFileNames.forEach((fileName) => {
  exec(
    `prettier --write ${fileName}`,
    (error: any, stdout: any, stderr: any) => {
      if (error) {
        console.log(`error: ${error.message}`)
        return
      }
      if (stderr) {
        console.log(`stderr: ${stderr}`)
        return
      }
      console.log(`stdout: ${stdout}`)
    },
  )
})

console.log('Prettier completed!')

createStory.tsの概要

  • 初期設定:

    • ソースコードのコンポーネントディレクトリのパスを定義します。
    • TypeScriptプロジェクトの設定をロードします。
    • 作成されたストーリーファイルの名前を保持する配列を初期化します。
  • createStoryFiles関数:

    • この関数は、指定されたディレクトリ内のすべての.tsxファイルを走査し、それぞれのコンポーネントファイルに対応するストーリーファイルを生成します。
    • すでにストーリーファイルが存在する場合、新しいファイルは生成されません。
    • 各コンポーネントのPropsの型を読み取り、Storybookのargsオブジェクトを生成します。
  • Storybookのファイル内容の生成:

    • 上記の関数内で、対応するストーリーファイルの内容が生成されます。
    • 生成されたコードは、必要なインポート、コンポーネントの型定義、およびStorybookのデフォルトのエクスポートなど、一般的なStorybookのストーリーファイルの構造に従って、トレースを行います。
  • Prettierの実行:
    最後に、生成されたすべてのストーリーファイルにPrettierを実行して、コードをフォーマットします。

(上記コードに対する補足説明)

絶対パスの生成
 const componentsDir = path.join(__dirname, '../src/components')

上記部分はファイルシステムのパス操作を行っています。
カレントファイル(このコードが存在するファイル)のディレクトリと指定された相対パスを結合して、新しい絶対パスを生成しています。

.tsxファイルのみを対象にする
const createStoryFiles = (dir: string) => {
 const items = fs.readdirSync(dir)

 items.forEach((item: string) => {
   const fullPath = path.join(dir, item)
   const stats = fs.statSync(fullPath)

   if (stats.isDirectory()) {
     createStoryFiles(fullPath)
   }

   if (
     stats.isFile() &&
     path.extname(item) === '.tsx' &&
     !item.endsWith('.test.tsx') &&
     !item.endsWith('.story.tsx')
   ) {
   ・・・
   }
   

上記では、与えられたディレクトリとアイテム名から絶対パスを作成し、そのパスがディレクトリかどうかを確認しています。もしディレクトリだった場合、同じ関数を再帰的に呼び出して、そのディレクトリ内のアイテム(.tsxファイル)を処理するように実装しています。

また自動生成スクリプトの実行の際に、テストファイル(.test.tsx)や既存のストーリーファイル(.story.tsx)を解釈させず、.tsxファイルのみを対象にしています。

相対パスの算出からストーリーファイルのタイトルの決定
const storyFilePath = path.join(dir, `${componentName}.story.tsx`)

     if (fs.existsSync(storyFilePath)) return

     const relativeDir = path
       .relative(componentsDir, dir)
       .split(path.sep)
       .join('/')

上記コードでは、ストーリーファイルまでのパスを算出しています。
もし既存のパスとしてストーリーファイルが存在していた場合、関数の実行から外れるように実装しています。
ストーリーファイルが存在していなかった場合、与えられたディレクトリのパス(dir)を基にして、それがcomponentsDirからどれだけの相対的な位置にあるかを示す相対パスを計算します。
その後、その相対パスのセパレータを常に/に統一する操作を行っています。

対象となる .tsxファイルに記述されている型情報からストーリーファイルのargs部分を算出する
const sourceFile = project.getSourceFile(fullPath)

    if (!sourceFile) return

    const typeAliases = sourceFile.getTypeAliases()
    const typeAliasesTypeNames: { getName: any }[] = []
    const typeAliasesTypeTexts: { getText: any }[] = []

    typeAliases.forEach((typeAlias: { getText: any; getName: any }) => {
      typeAliasesTypeNames.push(typeAlias.getName())
      typeAliasesTypeTexts.push(typeAlias.getText())
    })

    const typeItems = JSON.stringify(typeAliasesTypeTexts)
      .replace('"type Props = {\\n', '')
      .replace('\\n}"', '')
      .slice(1, -1)
      .replace(/\\n/g, '\n')
      .split('\n')
      .map((item) => item.trim())
  
    const argsObj: Record<string, unknown> = {}

    typeItems.forEach((item) => {
      if (!item.includes(':')) return
      const key = item.split(':')[0].trim()
      const value = item.split(':')[1].trim()

      if (key.includes('?')) return

      switch (!!value) {
        case key.includes('children'):
          argsObj[key] = 'ここにchildrenの内容が表示されます。'
          break
        case value.includes('number'):
          argsObj[key] = 1
          break
        case value.includes('string'):
          argsObj[key] = 'ダミーデータ'
          break
        case value.includes('boolean'):
          argsObj[key] = false
          break
    ・・・
        default:
          // 未知の型または複雑な型の場合、一時的に null を割り当てる
          argsObj[key] = null
      }
    })

上記コードでは、対象の.tsxファイルのtype Props の定義に記述されたkeyやvalueを基にして、下記のような生成されるストーリーファイルのargs部分(初期値)の算出を行っています。

生成されるストーリーファイル

export default {
title: '',
component: ,
tags: ['autodocs'],
args: {}, // ← 初期値の算出を行う
} as Meta

対象の.tsxファイルのimport文のトレースと型定義の取得
const importPath = path.relative(dir, fullPath).replace('.tsx', '')
     const typeName = typeAliasesTypeNames[0]
     const typeContent = typeAliasesTypeTexts.join('\n')

     const imports = sourceFile.getImportDeclarations()
     const importTypeTexts: string[] = []
     const mantineCoreImportNames: string[] = []
     const mantineTargetTypes = ['Sx', 'MantineSize']

     imports.forEach(
       (imp: {
         getModuleSpecifierValue: any
         getText: any
         getNamedImports: any
       }) => {
         const importModule = imp.getModuleSpecifierValue()

         if (
           (typeContent && importModule.includes('@/types')) ||
           importModule === '@mantine/form/lib/types'
         ) {
           importTypeTexts.push(imp.getText())
         }
         if (importModule === '@mantine/core') {
           const namedImports = imp.getNamedImports()
           const importNames = namedImports.map(
             (namedImport: { getName: any }) => namedImport.getName(),
           )
           const filteredImportNames = importNames.filter((name: string) =>
             mantineTargetTypes.includes(name),
           )
           mantineCoreImportNames.push(...filteredImportNames)
         }
       },
     )

     const importTypePath = importTypeTexts.join('\n')
     const importMantineCore =
       mantineCoreImportNames.length > 0
         ? `import { ${mantineCoreImportNames.join(
             ', ',
           )} } from '@mantine/core';`
         : ''

上記のコードでは、.tsxファイルの情報からimport文を取得しています。
またMantineUIからimportされている型や自分で作成した独自の型(@/types配下の型)の情報を抽出しています。

生成するストーリーファイルに記述する雛形の作成と書き出し
const hasChildren = typeItems.some((item) => /^children:/.test(item))

      const renderContent = hasChildren
        ? `<${componentName} {...args}>{args.children}</${componentName}>`
        : `<${componentName} {...args} />`

      const content = `
  import { Meta, StoryObj } from '@storybook/react';
  import ${componentName} from './${importPath}';
  ${importTypePath}
  ${importMantineCore}

  ${typeContent}

  export default {
    title: '${title}',
    component: ${componentName},
    tags: ['autodocs'],
    ${argsBlock}
    // Add your own control here
  } as Meta;

  type Story = StoryObj<typeof ${componentName}>;

  export const Default: Story = {
    render: ${typeName ? `(args: ${typeName})` : '()'} => {
      /* eslint-disable react-hooks/rules-of-hooks */
      return (${renderContent});
    },
  };
  `

      fs.writeFileSync(storyFilePath, content)
      createdStoryFileNames.push(storyFilePath)

上記コードでは、.tsxファイルの型情報からchildrenを持つコンポーネントかどうかを判断し、雛形を作成し、吐き出されるDOM形式を算出しています。
また算出された雛形をストーリーパスに従って、書き出す実装を行っています。

自動化のメリット

自動化を行い、実際に筆者が感じたメリットは下記のようになります。

  • 効率性の向上:
    新しいコンポーネントのためのストーリーファイルを手動で作成する手間を省くことができ、実装者の手を煩わせることなくストーリーファイルの大枠を作成できました。

  • 一貫性の確保:
    自動生成スクリプトにより、すべてのストーリーファイルが統一されたフォーマットで生成されるため、記述形式や内容の差異を最小限に抑えることができました。

  • エラーの減少:
    人が手動でコードを書く場合、コンポーネントの構造を判断しながらストーリーファイルを記述する必要があるが、自動生成スクリプトによって、propsの各プロパティやchildrenの有無を判断できるようにしてエラーが発生しづらい環境を構築できました。

  • アダプティブなコンテンツ生成:
    コンポーネントのpropsの型に基づいて適切なデフォルトのarg値を自動的に算出することで、スクリプトで生成されるストーリーはすぐに動作する状態になりました。

  • コードの整形:
    スクリプトの最後にはPrettierが実行されており、生成されたすべてのストーリーファイルが自動的に整形されることで、コードの品質と読みやすさを担保しています。

  • 開発速度の向上:
    スクリプトを使用することで、開発者は新しいコンポーネントを迅速にStorybookに統合でき、Chromaticを使用したVRT等に活用できました。

まとめ

本記事では、ReactのコンポーネントからStorybookのストーリーファイルを効率的に自動生成する方法を紹介しました。
本記事で紹介している実装は一例に過ぎず、スクリプトの実装面やパフォーマンス部分はまだ改善の余地が多数あります。
これからもより効率的なStorybookの構築を目指して、改善を続けていきたいと考えています。

参考文献

https://nodejs.org/api/path.html

https://ts-morph.com/

OT Official

Discussion