ts-morphを使ってコンポーネントとstorybookのファイルを自動生成
はじめに
ts-morphを使ってファイルの自動生成をできるようにしたので紹介します。
ファイル自動生成系のツールは山のようにありますが、ts-morphを使いたかったので、ts-morphを使います。
コード全体は以下から見れます。
今回の方針
やりたいことは以下です。
- コンポーネントの名前を決める
- ファイルの場所を決める
- コンポーネントのファイルを作る
- Storybookのファイルを作る
この記事ではts-morphに焦点を当てて解説します。
そのため1,2は標準入力を受けるためにinquirerを主に使いますが、ほとんど解説しません。
ご了承ください。
実装&解説
1. コンポーネントの名前を決める
inquirerを使って標準入力からコンポーネント名を入力できるようにします。
申し訳程度にタイトルケースにするための処理をしています。
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. ファイルの場所を決める
適当にファイルの場所を決めるためのプロンプトを組みます。
ここは用途に合わせてお好きな実装にして下さい。
簡単な捕捉を次に並べます。
- コンポーネントファイルは
components
以下に置く前提 - 無限ループを使い、いつまでもパスを入力できるようにしてあり、
<Create Component>
を選ぶと無限ループから抜け、コンポーネントが作られる <Component Name Directory>
を選んでも無限ループから抜ける。この時コンポーネント名と同じ名前のディレクトリが追加され、その中にコンポーネントファイルができる
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
でファイルに様々なコードをかけます。
ここに出てくるaddImportDeclaration
やaddVariableStatement
以外にも次の関数などがあります。
addExportDeclaration
addClass
addFunction
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のファイルを作る
コンポーネントファイルを作る時と特に変化はないので解説は割愛します。
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 エントリーポイントの作成
最後にエントリーポイントを作ります。
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}`);
参考