ts-morphを使ってコンポーネントとstorybookのファイルを自動生成

 
0
このエントリーをはてなブックマークに追加
Hakucho
Hakucho (白鳥)

はじめに

ts-morphを使ってファイルの自動生成をできるようにしたので紹介します。

ファイル自動生成系のツールは山のようにありますが、ts-morphを使いたかったので、ts-morphを使います。

コード全体は以下から見れます。

https://github.com/tentaShiratori/ts-morph-playground/tree/2b119c7a3701af94121367af394a4a475f106c69

今回の方針

やりたいことは以下です。

  1. コンポーネントの名前を決める
  2. ファイルの場所を決める
  3. コンポーネントのファイルを作る
  4. Storybookのファイルを作る

この記事ではts-morphに焦点を当てて解説します。

そのため1,2は標準入力を受けるためにinquirerを主に使いますが、ほとんど解説しません。

ご了承ください。

実装&解説

1. コンポーネントの名前を決める

inquirerを使って標準入力からコンポーネント名を入力できるようにします。

申し訳程度にタイトルケースにするための処理をしています。

inputComponentName.ts
import inquirer from "inquirer";

export async function inputComponentName() {
  let { componentName } = await inquirer.prompt<{ componentName: string }>([
    {
      name: "componentName",
      message: "Enter new component name",
    },
  ]);
  componentName = componentName.charAt(0).toUpperCase() + componentName.slice(1);

  return componentName;
}

2. ファイルの場所を決める

適当にファイルの場所を決めるためのプロンプトを組みます。

ここは用途に合わせてお好きな実装にして下さい。

簡単な捕捉を次に並べます。

  1. コンポーネントファイルはcomponents以下に置く前提
  2. 無限ループを使い、いつまでもパスを入力できるようにしてあり、<Create Component>を選ぶと無限ループから抜け、コンポーネントが作られる
  3. <Component Name Directory>を選んでも無限ループから抜ける。この時コンポーネント名と同じ名前のディレクトリが追加され、その中にコンポーネントファイルができる
inputFilePath.ts

import fs from "fs";
import inquirer from "inquirer";
import inquirerPrompt from "inquirer-autocomplete-prompt";
import path from "path";

inquirer.registerPrompt("autocomplete", inquirerPrompt);
const componentsDir = path.resolve(process.cwd(), "components");

function digComponentDir(childPath: string) {
  return fs
    .readdirSync(path.join(componentsDir, childPath), {
      withFileTypes: true,
    })
    .filter((file) => file.isDirectory())
    .map((file) => file.name);
}

export async function inputFilePath(componentName: string) {
  let filePath = "";
  // eslint-disable-next-line no-constant-condition
  while (true) {
    const CreateComponentCommand = "<Create Component>";
    const ComponentNameDirectoryCommand = "<Component Name Directory>";
    const newDirSuffix = "(new directory)";
    const { dirname } = await inquirer.prompt<{ dirname: string }>([
      {
        type: "autocomplete",
        name: "dirname",
        message: `Enter new component path ${filePath && path.normalize(filePath + "/")}`,
        source: async (_: unknown, input?: string) => {
          await Promise.resolve();

          if (!input) {
            const defaultSelect = [CreateComponentCommand, ComponentNameDirectoryCommand];
            let files: string[] = [];
            try {
              files = digComponentDir(filePath);
            } catch (e) { /* empty */ }

            return defaultSelect.concat(files);
          }

          let files: string[] = [];

          if (fs.existsSync(path.join(componentsDir, filePath, input))) {
            return [input, ...digComponentDir(path.join(filePath, input)).map((file) => path.join(input, file))];
          }
          try {
            files = digComponentDir(path.join(filePath, input, "../"))
              .filter((file) => {
                const arr = input.split(path.sep);

                return file.startsWith(arr[arr.length - 1]);
              })
              .map((file) => path.join(input, "../", file));
          } catch (e) { /* empty */ }

          files = [...files, input + newDirSuffix];

          return files;
        },
      },
    ]);

    if (dirname.includes(CreateComponentCommand)) {
      filePath = path.join(filePath);
      break;
    }

    if (dirname.includes(ComponentNameDirectoryCommand)) {
      filePath = path.join(filePath, componentName);
      break;
    }

    if (dirname.includes(newDirSuffix)) {
      filePath = path.join(filePath, dirname.replace(newDirSuffix, ""));
      continue;
    }
    filePath = path.join(filePath, dirname);
  }

  return filePath;
}

3. コンポーネントのファイルを作る

ファイルを受け取り、中身にコンポーネントのコードを詰める関数を作ります。

ts-morphは基本addHogeでファイルに様々なコードをかけます。

ここに出てくるaddImportDeclarationaddVariableStatement以外にも次の関数などがあります。

  • addExportDeclaration
  • addClass
  • addFunction
constructComponent.ts
import { VariableDeclarationKind, printNode, SourceFile, SyntaxKind,ts } from "ts-morph";

const { factory } = ts;

export function constructComponent(componentFile: SourceFile, componentName: string) {
  componentFile.addImportDeclaration({
    namedImports: ["FC"],
    moduleSpecifier: "react",
  });
  componentFile.addVariableStatement({
    declarations: [
      {
        name: componentName,
        type: "FC",
        initializer: printNode(
          factory.createArrowFunction(
            undefined,
            undefined,
            [],
            undefined,
            factory.createToken(SyntaxKind.EqualsGreaterThanToken),
            factory.createBlock([
              factory.createReturnStatement(
                factory.createJsxSelfClosingElement(
                  factory.createIdentifier("div"),
                  undefined,
                  factory.createJsxAttributes([]),
                ),
              ),
            ]),
          ),
        ),
      },
    ],
    declarationKind: VariableDeclarationKind.Const,
    isExported: true,
  });
}

ポイントとしてはfactoryから生える様々な関数で生成したASTをprintNodeを使って文字列に変換しているところです。

componentFile.addVariableStatementに渡している引数の中のinitializerの型は文字列です。(厳密にはWriter関数と文字列のユニオンです)

なのでここには文字列を入れないといけないのですが、どうしてもここもプログラムで生成したかったので、型ファイルを隅々まで見て生成する方法を見つけました。

これはおそらくドキュメントにも載ってないコード生成方法だと思います。

このコードによって次のようなコードが生成されます。

import { FC } from "react";

export const Test: FC = () => { return <div />; };

4. Storybookのファイルを作る

コンポーネントファイルを作る時と特に変化はないので解説は割愛します。

constructStory.ts
import path from "path";
import { VariableDeclarationKind, printNode, SourceFile,ts } from "ts-morph";

const { factory } = ts;

export function constructStory(storyFile: SourceFile, dirPath: string, componentName: string) {
  storyFile.addImportDeclaration({
    namedImports: ["Meta", "StoryObj"],
    moduleSpecifier: "@storybook/react",
  });
  storyFile.addImportDeclaration({
    namedImports: [componentName],
    moduleSpecifier: "./" + componentName,
  });
  const typeofComponent = factory.createTypeQueryNode(factory.createIdentifier(componentName));

  const title = dirPath.replaceAll(path.sep, "/").replace(".stories.tsx", "").concat("/", componentName);

  storyFile.addVariableStatement({
    declarations: [
      {
        name: "meta",
        type: printNode(factory.createTypeReferenceNode("Meta", [typeofComponent])),
        initializer: printNode(
          factory.createObjectLiteralExpression([
            factory.createPropertyAssignment("title", factory.createStringLiteral(title)),
            factory.createPropertyAssignment("component", factory.createIdentifier(componentName)),
            factory.createPropertyAssignment("tags", factory.createArrayLiteralExpression([])),
            factory.createPropertyAssignment("argTypes", factory.createObjectLiteralExpression()),
          ]),
        ),
      },
    ],
    declarationKind: VariableDeclarationKind.Const,
  });
  storyFile.addExportAssignment({ expression: "meta", isExportEquals: false });
  storyFile.addTypeAlias({
    name: "Story",
    type: printNode(factory.createTypeReferenceNode("StoryObj", [typeofComponent])),
  });
  storyFile.addVariableStatement({
    declarations: [
      {
        name: "Primary",
        type: "Story",
        initializer: printNode(factory.createObjectLiteralExpression()),
      },
    ],
    declarationKind: VariableDeclarationKind.Const,
    isExported: true,
  });
}

5 エントリーポイントの作成

最後にエントリーポイントを作ります。

createComponent.ts
import fs from "fs";
import path from "path";
import { Project } from "ts-morph";
import { fileURLToPath } from "url";
import { inputComponentName } from "./createComponent/inputComponentName";
import { inputFilePath } from "./createComponent/inputFilePath";
import { constructComponent } from "./ts-morph/constructComponent";
import { constructStory } from "./ts-morph/constructStory";
import { exec } from "./utils/exec";

const dirname = path.dirname(fileURLToPath(import.meta.url));
const componentsDir = path.resolve(dirname, "../components");

async function main() {
  const componentName = await inputComponentName();
  const filePath = await inputFilePath(componentName);

  const componentDir = path.resolve(componentsDir, filePath);

  const componentFilePath = path.join(componentDir, componentName + ".tsx");
  const storyFilePath = path.join(componentDir, componentName + ".stories.tsx");

  fs.mkdirSync(componentDir, { recursive: true });

  const project = new Project({});
  const componentFile = project.createSourceFile(componentFilePath);
  constructComponent(componentFile, componentName);
  const storyFile = project.createSourceFile(storyFilePath);
  constructStory(storyFile, filePath, componentName);
  project.saveSync();

  await exec(`pnpm prettier --write ${componentFilePath} ${storyFilePath}`);
}

void main();

import文は感じて下さい。それぞれ名前で何となくわかると思います。

import { exec } from "./utils/exec";

1つだけ説明すると、上記はnodeのchild_processのexecをpromisifyしたものです。

callback形式の非同期が好きじゃないので、事前に変換しておきます。

import文から下のしばらくは標準入力を受けたり、そこからいろいろパスを生成したりしています。

const dirname = path.dirname(fileURLToPath(import.meta.url));
const componentsDir = path.resolve(dirname, "../components");

async function main() {
  const componentName = await inputComponentName();
  const filePath = await inputFilePath(componentName);

  const componentDir = path.resolve(componentsDir, filePath);

  const componentFilePath = path.join(componentDir, componentName + ".tsx");
  const storyFilePath = path.join(componentDir, componentName + ".stories.tsx");

続いて、new Project({});でts-morphを初期化し、ファイルを作って中身を埋めるを2回繰り返した後に、変更をsaveしておしまいです。

  const project = new Project({});
  const componentFile = project.createSourceFile(componentFilePath);
  constructComponent(componentFile, componentName);
  const storyFile = project.createSourceFile(storyFilePath);
  constructStory(storyFile, filePath, componentName);
  project.saveSync();

生成するだけだとフォーマットが変なので最後にprettierを走らせて、フォーマットを整えておしまいです。

 await exec(`pnpm prettier --write ${componentFilePath} ${storyFilePath}`);

参考

https://ts-morph.com/

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。