Plopでジェネレーターを作ってStoryファイルを自動生成してみたら簡単だった

GMOメイクショップ コアグループでエンジニアをしている池田です。 こちらの記事にもある通り、当プロジェクトではStorybookを導入しました。 Storyファイルを増やしたいということで雛形を自動生成することになりました。 そこでPlopというJavaScriptライブラリを使用して、Storyファイルを自動生成しました。 今回はPlopの使い方について説明したいと思います。

対象

  • ファイルの自動生成を行いたい
  • 対話形式でファイルの自動生成を行いたい

Plopとは?

CLIを用いてプロジェクト内で一貫したファイルやコードを生成するためのJavaScriptのジェネレーターライブラリであり、以下のような特徴を持っています。

  • テンプレート化:Handlebarsなどのテンプレートエンジンを使用して、カスタマイズ可能なファイルテンプレートを作成できます。
  • 対話型プロンプト:ユーザーから入力を受け取り、動的にファイルを生成します。
  • 柔軟性:JavaScript/Node.jsで設定可能で、既存のプロジェクトに簡単に統合できます。
  • 拡張性:プラグインシステムにより機能を拡張できます。

類似のライブラリとして、hygen, scaffdog があります。 hygenPlop と同じようにHandlebarsを用いるのですが、scaffdogMarkdownなのでやや柔軟性がない印象でした。hygen と比較してダウンロード数が多く、JavaScriptで柔軟にジェネレーターを作成できる点を評価して Plop を採用しました。

セットアップ

yarn add -D plop

Storyファイルを自動生成

Storyファイルの雛形自動生成にあたり、必要なのはこちらのファイルです。

  • hbsファイル
  • plopfile.mjs

hbsファイル

hbsファイル(.hbs拡張子)は、Handlebarsテンプレートエンジンで使用されるテンプレートファイルです。Handlebarsは、JavaScriptベースのテンプレートエンジンで、HTMLやその他のテキストベースのドキュメントを動的に生成するために使用されます。Mustache記法(ダブルカーリーブレース {{ }})を使用して、データをテンプレートにバインディングすることができます。

今回用意したのは以下のファイルです。 この雛形を元にして、"{{ }}"で囲われている変数に動的な値を渡して自動生成するファイルを完成させます。 {{pascalCase name}} のように {{pascalCase 変数名}} とすると、渡された値をパスカルケースに変換することが可能です。 ファイルの生成や変数を渡すのが、後述するplopfile.mjsです。

import type { Meta, StoryObj } from '@storybook/vue3'

import {{pascalCase name}} from '@/{{path}}/{{pascalCase name}}.vue'

const meta = {
  title: '{{path}}/{{pascalCase name}}',
  component: {{pascalCase name}},
  tags: ['autodocs'],
  args: {},
} satisfies Meta<typeof {{pascalCase name}}>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {},
}

plopfile.mjs

こちらには自動生成に必要な内容を記述します。基本的には setGenerator という関数を使用します。 第一引数にはアクション名を、第二引数には設定を渡します。設定には3つの内容があります。

  • description:実行内容の簡潔な説明
  • prompts:対話式で実行する場合に使用します。例えば、メッセージを表示して必要な内容を入力してもらうということができます。
  • actions:実行する内容

今回作成したジェネレーターは以下の3つです。3つを使い分けてこれまで作成してきたコンポーネントのStorybook化をスピーディーに行ったり、今後新しいコンポーネントが作成されたら、コミットしたタイミングで自動的にStoryファイルを作成するなど想定して作りました。

  • story
  • stories
    • 入力したディレクトリ配下にあるvueファイルを検索して、存在したvueファイルの分だけStoryファイルの作成をする
  • all-stories
    • プロジェクトのsrc配下にある全てのvueファイル分のStoryファイルを作成する

plopfile.mjsのコード

import fs from 'node:fs'
import path from 'node:path'

const STORY_TEMPLATE_FILE_PATH = 'plop-templates/component.stories.ts.hbs'

/**
 * 指定したディレクトリ配下からVueファイルを再起的に収集する
 * @param {string} dir 対象のディレクトリ
 */
const collectVueFiles = (dir = '') => {
  const files = fs.readdirSync(path.join('./src/', dir), { recursive: true })
  return files.filter((file) => file.endsWith('.vue'))
}

/**
 * 自動生成の設定を作成する
 */
const makeActions = (files, basePath = '') => {
  return files.map((file) => {
    const fileNameExcludedExtension = path.basename(file, '.vue')
    const dirPath = path.join(basePath, path.dirname(file))
    return {
      type: 'add',
      path: `../src/stories/${dirPath}/${fileNameExcludedExtension}.stories.ts`,
      templateFile: STORY_TEMPLATE_FILE_PATH,
      data: {
        name: fileNameExcludedExtension,
        path: dirPath,
      },
      skipIfExists: true,
    }
  })
}

export default function (
  /** @type {import('plop').NodePlopAPI} */
  plop,
) {
  plop.setGenerator('story', {
    description: 'Storyファイルを生成します',
    prompts: [
      {
        type: 'input',
        name: 'path',
        message: 'コンポーネントのディレクトリをsrcより下層から入力してください(e.g. PButtonの場合はcomponents/button)',
      },
      {
        type: 'input',
        name: 'name',
        message: 'コンポーネントの名前(.vueは不要)を入力してください(e.g. PButton)',
      },
    ],
    actions: [
      {
        type: 'add',
        path: '../src/stories/{{path}}/{{name}}.stories.ts',
        templateFile: STORY_TEMPLATE_FILE_PATH,
      },
    ],
  })

  plop.setGenerator('stories', {
    description: '入力したディレクトリ配下のコンポーネントのStoryファイルを一括生成します',
    prompts: [
      {
        type: 'input',
        name: 'path',
        message: '対象ディレクトリをsrcより下層から入力してください(e.g. components/button)',
      },
    ],
    actions: (answers) => {
      // 入力がない場合は処理をせず終了
      if (!answers?.path) {
        console.error('Please input path.')
        return []
      }

      return makeActions(collectVueFiles(answers.path), answers.path)
    },
  })

  plop.setGenerator('all-stories', {
    description: '全コンポーネントのStoryファイルを一括生成します',
    prompts: [
      {
        type: 'confirm',
        name: 'shouldExecute',
        message: '全コンポーネントのStoryファイルを一括生成します。よろしいですか?',
        default: false,
      },
    ],
    actions: (answer) => {
      if (!answer?.shouldExecute) {
        console.log('Canceled.')
        return []
      }

      return makeActions(collectVueFiles())
    },
  })
}

prompts

入力内容を対話形式で取得するためのプロンプトを設定できます。
設定内容はオブジェクトで設定します。

プロパティ 説明
type プロンプトの種類で input,confirm,checkbox などを指定します。指定できるtypeはこちらに記載されています。
name 入力させた内容を actions 内や template で参照するための変数名です。
message コンソールに表示するメッセージです。

input を設定すると、下記のようにメッセージ表示後に自由入力を促すことができます。

confirm を設定すると、下記のように実行の確認をすることが可能です。

actions

actions には実行内容が設定されたオブジェクトを配列で定義します。prompts から渡されるデータに基づいて内容を分岐させたい場合は関数で定義し、最終的に実行内容のオブジェクトを返すことも可能です。実行内容のオブジェクトに設定できるプロパティは type によって異なります。今回使用してるものを説明すると以下の通りになります。

プロパティ 説明
type アクションで実行する操作の種類です。新しくファイルを追加する add, 1つのアクションで複数のファイルを追加する addMany, ファイルの内容を置換したりする modify などがあります。
data hbsファイル内の変数に渡すデータを定義します。プロンプトの回答内容を元に渡すデータを作成する場合などに使用します。
path ファイルの作成や更新などを行う場所です。
templateFile 使用するテンプレートファイルを指定します。
skipIfExists typeに add を指定した場合のみ使用可能です。作成対象のファイルが存在する場合に操作をスキップします。

実行

package.jsonに以下を追記します。

{
// ...
  scripts: {
+  "plop": "plop --plopfile ./generators/plopfile.mjs" 
  }
// ...
}

下記を実行します。

yarn run plop

実行するジェネレーターを選択します。今回は指定した配下のvueファイルのStoryファイルを作成する stories を選択します。

対象のコンポーネントのあるディレクトリを入力すると、指定したディレクトリ配下にある複数のコンポーネントのStoryファイルが作成されています!

まとめ

今回Plopを使ってみて、誰でも簡単に扱えるシンプルさがありつつ、実行内容も柔軟にカスタマイズできる便利なライブラリだと感じました。
少ないコードでジェネレーターが作成できますし、シンプルな作りなのでドキュメントを読めばすぐに使い方が分かります。prompts, actions の内容に関数を利用できるので、入力された値に基づいて処理・操作を行うことも簡単でした。 今回の事例ではStoryファイルの作成でしたが、コンポーネントの雛形、テストコードの雛形作成など様々な用途で利用できそうです。また、JavaScriptのライブラリではありますが、他の言語で使用するファイルを自動生成することもできるので、ぜひ試してみてください!

参考

plopjs.com

zenn.dev