Reactのコンポーネントを生成するCLIアプリをNode.jsの標準機能のみで作ってみた話

この記事は[TDU CPSLab Advent Calendar 2021 - Adventar](https://adventar.org/calendars/6489)の1日目の記事です

https://adventar.org/calendars/6489

前の記事はありません、次の記事は[こちら](https://qiita.com/narusei/items/03d1630533c8ab56bf53)から。

#### 追記
研究室で共有したところ、
Typescriptでのリファクタリングを[narumincho](https://github.com/narumincho)が早速してくれました！！！

シンプルに自分が知らなかったNode.jsの機能使っていたり、
Typescriptの型定義の工夫も学べるかと思いますので、
是非[こちら](https://github.com/narumincho/narusei-code-gen/pull/1/files)から御一読ください！！

## はじめに

```
amplify codegen
```
（生成されるファイル達）
![appsync.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417686/bc391d42-cfab-2399-6939-c111be28326c.gif)

**かっこいい〜！！！**

codegenってお前。。genってめっちゃかっこいいじゃん。。
generateだよね？それをgenってなんか良くない？

俺もgenしたいな！！
なんかこうバシッとgenしたいな！！

...という良くわかんない動機があって、
普段私が手動で作っていたReactコンポーネントをgenするCLI(脳筋)を作るに至り、
そのご紹介をしたいと思います。
![compcodegen.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417686/9cc50339-72c0-042b-e3b5-ff5c6cc95f7e.gif)

以下、回避運動ですが。
筆者は(ガチで)雰囲気でプログラミングしているので書き方がダサい・デフォルトでそういう機能がある・実現できる便利なパッケージがあるなどが起きていましたら申し訳ありません。追記しますので、コメント欄でボソッと教えてくださると嬉しいです。...記事は作ってみた記事的に供養として残させていただけたら嬉しいです。

## 目次
* 開発環境
* どんなことができるの？
* 実装内容
    * 入力受け取り処理の作成
    * コード生成処理の作成
    * package.jsonに登録

## 開発環境

実現目標的に筆者の土台は

* create-react-appで生成されたプロジェクト
* 及びそれを駆動させているNode.js環境
* yarnを使っている

という感じですが。
もちろん、Node.jsが入っていればそれだけで大丈夫です。


## どんなことができるの？

用意されているコマンドと、その挙動の対応は以下の通りです。

```
yarn page:codegen　　　　　 → pageフォルダ配下にコンポーネントを生成する
yarn layout:codegen　 → layoutフォルダ配下にコンポーネントを生成する
yarn comp:codegen　　　　　 → componentフォルダ配下にコンポーネントを生成する
``` 

そもそもの前提としまして、私は再利用可能なコンポーネントが集まるcomponentフォルダ・ページ毎のUIを整形するlayoutフォルダ・APIとの通信を行うpageフォルダといった具合に階層構造で分けております。なので上記のような3つのコマンドが定義されております。

続いて、以下の質問を答えることでコードを生成します。

```
yarn comp:codegen

yarn run v1.22.11
$ node ./scripts/comp-codegen.mjs
Code-gen : Create Presentational Component
? What file path  ex) common/Button > common/Button
? What class name  ex) Button > Button
? Create story(Y/n) > Y
{ filepath: 'common/Button', classname: 'Button', story: true }
Start Code Generate...
Succeded!!
✨  Done in 11.76s.
```

生成されるコードはStorybookの定義ファイルとReactコンポーネントのファイルです。
上記の例では`components/common/Button`フォルダ内に生成されます。

## 実装内容

大きな流れは以下の通りです。

* ユーザの入力を対話的に受け取る処理の作成
* ファイル生成を行う処理の作成
* package.jsonに登録

特筆したいところは、何か便利なフレームワークやパッケージの導入はせずに、Node.jsの標準的な機能のみで作成したいという欲求がありました。そこで参考記事を探していたところ、napoporitataso様の[個人ブログの記事](https://note.kiriukun.com/entry/20200313-interactive-cli-example-with-nodejs)に出会うことができ、参考にさせていただきました。本当にありがとうございます！

https://note.kiriukun.com/entry/20200313-interactive-cli-example-with-nodejs

(自分初めて記事投稿するのでよくわかってないのですが、こういうのってご本人様に連絡差し上げたほうが良いのですかね？)

### 入力受け取り処理の作成
コードは以下のような感じ。

```JavaScript:cli.mjs
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import * as readline from 'readline';
import * as process from 'process';

const reader = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

// ④で使用
const getAnswer = (msg) =>
  new Promise((res) => reader.question('? ' + msg + ' > ', res)); 

// ① 複数の質問を引数に取る
export const cli = async (questions = []) => {
  const answers = {};
  let answer, question;
  let name, type, message; // ② 各質問の構成要素
  let result;
  
  // ③ それぞれ質問をしていく
  for (question of questions) {
    result = '';
    ({ name, type, message } = question);

    // ④　ユーザの入力を待ち受ける
    result = await getAnswer(message);

    // ⑤ typeに合わせて簡単に値のチェックを行う
    switch (type) {
      case 'text':
        if (result !== '') {
          answer = result;
        } else {
          console.log('Invalid value');
        }
        break;

      case 'number':
        if (result !== '' && parseInt(result)) {
          answer = parseInt(result);
        } else {
          console.log('Invalid value');
        }
        break;

      case 'boolean':
        if (result.toLowerCase() === 'y' || result.toLowerCase() === 'n') {
          switch (result.toLowerCase()) {
            case 'y':
              answer = true;
              break;

            case 'n':
              answer = false;
              break;
          }
        } else {
          console.log('Invalid value');
        }
        break;
    }
    // ⑥ answersに追加していく
    answers[name] = answer;
  }

  // ⑦ 返す
  return answers;
};

```

**ざっくり解説**
① 複数の質問を受け取り、その答えを返却する関数、cliをエクスポートしてます。

② それぞれの質問は name,type,message の三要素の存在を保証します。例えばコンポーネント作成の際は以下のようなquestionsが入ってきます。

```JavaScript
const questions = [
  {
    type: 'text',
    name: 'filepath',
    message: 'What file path  ex) common/Button',
  },
  {
    type: 'text',
    name: 'classname',
    message: 'What class name  ex) Button',
  },
  {
    type: 'boolean',
    name: 'story',
    message: 'Create story(Y/n)',
  },
];
```

③ 上記のquestions配列をforで回して聞いていきます。

④ getAnswer関数でユーザからの入力を待ち受けます。こちらの関数のほうはめちゃくちゃ参考記事様の方を参考させていただきました。

⑤ questionsのtypeを参照して、簡単にですが値のチェックをしています。numberは使ってないから要らないんですけどもね。。。

⑥ 値チェックが通過したらanswersに格納されていきます。

⑦ 全ての質問が終わったらanswersを返して終了です。

### ファイル生成処理の作成
コードは以下のような感じ。コンポーネントを作る時のスクリプトを代表して書きます。

```JavaScript:comp-codegen.mjs
import * as fs from 'fs';
import * as process from 'process';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import { cli } from './cli.mjs';

const questions = /*上で書いたやつと同じなので省略*/;

const __dirname = dirname(fileURLToPath(import.meta.url));

const main = async () => {
  console.log('Code-gen : Create Presentational Component');

  // ① 対話処理を使用
  const answers = await cli(questions);
  console.log(answers);

  console.log('Start Code Generate...');
  const from_dir = path.join(__dirname, '../scripts/template/component/');
  const to_dir = path.join(
    __dirname,
    '../src/components/' + answers['filepath'] + '/'
  );

  // ② フォルダ生成
  fs.mkdirSync(to_dir, { recursive: true });

  // ③ テンプレート生成（脳筋ポイント）
  let readcomp = fs.readFileSync(from_dir + 'index.tsx', 'utf-8');
  readcomp = readcomp.replace(/COMPONENTTEMPLATE/g, answers['classname']);
  fs.writeFileSync(to_dir + 'index.tsx', readcomp);

  // ④ ストーリーテンプレート生成（脳筋ポイント2）
  if (answers['story']) {
    let readstory = fs.readFileSync(
      from_dir + 'componenttemplate.stories.tsx',
      'utf-8'
    );
    readstory = readstory.replace(/COMPONENTFILEPATH/g, answers['filepath']);
    readstory = readstory.replace(/COMPONENTTEMPLATE/g, answers['classname']);
    fs.writeFileSync(
      to_dir + answers['classname'].toLowerCase() + '.stories.tsx',
      readstory
    );
  }
  console.log('Succeded!!');
  process.exit();
};

(async () => {
  await main();
})();
```
**ざっくり解説**
① 先ほど紹介した対話的にユーザの入力を取得する関数を使っています。

② 質問で取得した「ファイルパス」の情報に従ってフォルダを作成しています。この`mkdirSync`ですが、Syncじゃない方も存在します。

③ 作成したフォルダの中にReactコンポーネントを記述する`index.tsx`を生成しています。そしてここからが脳筋(だと思っている)ポイントです。やっていることは以下の通りです。

1. 予め`../scripts/template/component`に用意された`index.tsx`の記述内容を読み込む。
2. 正規表現を使って`COMPONENTTEMPLATE`という文字列を質問で取得した「クラス名」で置き換える。
3. 書き出す

つまり、テンプレート用のファイルが用意されており、それを複製しているような感じなんです。手動コピペを機械的にコピペできるようにしただけ..。もっといい感じの方法あるかなぁという気持ちがあります。テンプレートがどんな感じなのかは折り畳みで書いておきます。
<details><summary>index.tsxのテンプレート</summary><div>

```JavaScript:index.tsx
import React from 'react';

export const COMPONENTTEMPLATE = (): JSX.Element => {
  return <div></div>;
};

```
</div></details>
④ 作成したフォルダの中にStorybookの定義ファイルを生成しています。やってることは③と全く同じです。
<details><summary>Storybookのテンプレート</summary><div>

```JavaScript:componenttemplate.stories.tsx
import React from 'react';
// also exported from '@storybook/react' if you can deal with breaking changes in 6.1
import { Story, Meta } from '@storybook/react/types-6-0';

import { COMPONENTTEMPLATE } from '.';

export default {
  title: 'component/COMPONENTFILEPATH',
  component: COMPONENTTEMPLATE,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
} as Meta;

const Template: Story = (args) => <COMPONENTTEMPLATE {...args} />;

export const Default = Template.bind({});
Default.args = {};

```
</div></details>

### package.jsonに登録

あとはpackage.jsonのscriptsの中で、以下のように追記すればOKです！

```Json:package.json
{
  "scripts": {
    ~省略~,
    "page:codegen": "node ./scripts/page-codegen.mjs",
    "layout:codegen": "node ./scripts/layout-codegen.mjs",
    "comp:codegen": "node ./scripts/comp-codegen.mjs"
  },
}
```

**そんなこんなで**

いつも手動でファイル作っていたのですが、cliで作れるようになりました。
![compcodegen.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417686/9cc50339-72c0-042b-e3b5-ff5c6cc95f7e.gif)

## まとめ

今回はよくわかんないモチベーションから非常に泥臭い感じの自己満なcliが完成しました。
特に予め用意したファイル読み込んで書き出す処理とか、なんかもっといいのないかなって思っております。

まあでも、こういうちょっとした自動化するのはなんか楽しいです。楽しいのでヨシ！

補足ですが、簡単にcliの作り方を調べた際に、もっとカラフルであったりアニメーションを付けたりできる高機能なcliアプリを作るためのフレームワークやライブラリがありました。npmに登録してってやる方はそういったもの使われた方が良い感じかと思います。

初めての記事作成で、お目汚し失礼しました。
記事書くの楽しかったのでまた書きたいと思います。

**参考記事**

* [Node.jsの標準機能で超単純な対話的CLIを作るサンプル](https://note.kiriukun.com/entry/20200313-interactive-cli-example-with-nodejs)
* [Node.jsでのCLIの作り方と便利なライブラリまとめ](https://qiita.com/toshi-toma/items/ea76b8894e7771d47e10)