BashでStorybookファイル作成を自動化してみた

# はじめに

業務で既存環境にstorybookを導入する機会があったのですが、その際に一つ一つ手作業で書くのが面倒でしたので楽をするべくbash scriptを書くことにしました。

# やりたいこと

今回vueのコンポーネントを表示するstories.tsを作成します。
下記雛形をベースにファイル名やアッパーキャメルにしたファイル名、args等を置き換えていくことを考えます。

```typescript:.stories.ts
import アッパーキャメルにしたファイル名 from '相対パス/ファイル名.vue'
import type { Meta, StoryObj } from '@storybook/vue3'

type Story = StoryObj<typeof アッパーキャメルにしたファイル名 >

const meta: Meta<typeof アッパーキャメルにしたファイル名 > = {
  title: 'アッパーキャメルにしたファイル名',
  component: アッパーキャメルにしたファイル名,
  render: (args) => ({
    components: { アッパーキャメルにしたファイル名 },
    setup: () => ({ args }),
    template: "<アッパーキャメルにしたファイル名 v-bind='args' />"
  }),
  tags: ['autodocs']
}

export const Default: Story = {
  args: 抜き出したdefault値達
}

export default meta
```

# 先に全体像

中で`source pathlib.bash`を読み込んでいますが、二つのパスから片方のパスから片方のパスまでの相対パスを取得する関数を定義しています。

```bash:create.bash
#!/bin/bash

# 引数の数をチェックし、引数が3つ以上の場合はエラーメッセージを出力して終了
if [ "$#" -ge 3 ]; then
  echo "Usage: $0 <input_folder_path> <output_folder_path>"
  exit 1
fi

# フォルダの相対パスを取得
input_folder_path=${1:-src/components}
output_folder_path=${2:-$input_folder_path}

# 絶対パスに変換
input_folder_absolute_path=$(realpath "$input_folder_path")
output_folder_absolute_path=$(realpath "$output_folder_path")

# output_folder_absolute_pathからinput_folder_absolute_pathへの相対パスを計算
script_dir=$(cd "$(dirname "$0")"; pwd)
PATH="$PATH:$script_dir"
source pathlib.bash
from_output_to_input_relative_path=$(path_get_relative "$output_folder_absolute_path" "$input_folder_absolute_path")

# Vueファイルを再帰的に検索して処理
find "$input_folder_path" -type f -name "*.vue" | while read -r vue_file_path; do

  # ファイル名から拡張子を削除し、ファイル名を取得
  file_name=$(basename -- "$vue_file_path")
  file_name_no_ext="${file_name%.vue}"

  # UpperCamelCaseのファイル名を生成
  upper_camel_case_file_name=$(echo "$file_name_no_ext" | awk -F'[-_]' '{ for(i=1; i<=NF; i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2)); }1' OFS='')

  # 対応するstories.tsファイルのパスを生成
  vue_file="${vue_file_path#$input_folder_path}"
  stories_file_path="${output_folder_path}${vue_file%.*}.stories.ts"

  # 既に同名のstories.tsファイルが存在する場合はスキップ
  if [ -e "$stories_file_path" ]; then
    echo "Skip: $stories_file_path already exists."
    continue
  fi

  # ①と② OptionsAPIの場合
  props_output=$(awk '
  BEGIN { flag=0; key=""; value=""; nestLevel=0; print "{"; }

  /props: {/ { flag=1; nestLevel=1; }

  flag {
    if ($1 ~ /^[a-zA-Z0-9]+:$/ && nestLevel == 2) {
      key = substr($1, 1, length($1)-1);
    }
    if ($1 ~ /^default:/) {
      value = $0;
      sub(/[[:space:]]*default:/, "", value);
      sub(/,.*$/, "", value);
      if (value != "") { printf "%*s%s: %s,\n", (nestLevel - 1) * 2, "", key, value; }
    }
    for(i=1; i<=length($0); i++) {
      char = substr($0, i, 1);
      if (char == "{") { nestLevel++; }
      if (char == "}") { nestLevel--; }
    }
    if (nestLevel == 0) exit;
  }
  END { print "  },"; }
  ' $vue_file_path)

  if [[ -n $props_output ]]; then
    args=$props_output
  fi

  # ③と④ withDefaultを使わないCompositionAPIの場合
  define_props_output=$(awk '
  BEGIN { flag=0; key=""; value=""; nestLevel=0; print "{"; }

  /defineProps.*\({/ { flag=1; }

  flag {
    if ($1 ~ /^[a-zA-Z0-9]+:$/ && nestLevel == 1) {
      key = substr($1, 1, length($1)-1);
    }
    if ($1 ~ /^default:/) {
      value = $0;
      sub(/[[:space:]]*default:/, "", value);
      sub(/,.*$/, "", value);
      if (value != "") { printf "%*s%s:%s,\n", (nestLevel - 1) * 2, "", key, value; }
    }
    for(i=1; i<=length($0); i++) {
      char = substr($0, i, 1);
      if (char == "{") { nestLevel++; }
      if (char == "}") { nestLevel--; }
    }
    if (nestLevel == 0) exit;
  }
  END { print "  },"; }
  ' "$vue_file_path")

  if [[ -n $define_props_output ]]; then
    args=$define_props_output
  fi

  # ⑤ withDefaultを使うCompositionAPIの場合
  with_defaults_output=$(awk '
  BEGIN { flag=0; }
  /withDefaults\(/ { flag=1; next; }
  flag == 1 && /^[^,]+),/ { flag=2; next; }
  flag == 2 {
    print $0;
    if ($0 ~ /^[^}]+}/) {
      exit;
    }
  }
  ' $vue_file_path)
  if [[ -n $with_defaults_output ]]; then
    args=$with_defaults_output
  fi

  # stories.tsファイルを生成
  cat > "$stories_file_path" <<EOL
import $upper_camel_case_file_name from '${from_output_to_input_relative_path:-.}/$file_name_no_ext.vue';
import type { Meta, StoryObj } from '@storybook/vue3';

type Story = StoryObj<typeof $upper_camel_case_file_name>;

const meta: Meta<typeof $upper_camel_case_file_name> = {
  title: '$upper_camel_case_file_name',
  component: $upper_camel_case_file_name,
  render: (args) => ({
    components: { $upper_camel_case_file_name },
    setup: () => ({ args }),
    template: "<$upper_camel_case_file_name v-bind='args' />",
  }),
  tags: ['autodocs'],
};

export const Default: Story = {
  args: $args
};

export default meta;
EOL

  echo "Created: $stories_file_path"
done
```

# 要所解説

## 1. パスの取り扱い

引数をinput_folder_pathとoutput_folder_pathに控えます。
input_folder_pathは`$1`を受け取り、`$1`がない時は初期値として`src/componests`を代入します。
output_folder_pathは`$2`を受け取り、`$2`がない時は初期値として`$1`を代入します。
output_folder_absolute_pathからinput_folder_absolute_pathへの相対パスを計算して、from_output_to_input_relative_pathに格納します。

```bash:create.bash
# フォルダの相対パスを取得
input_folder_path=${1:-src/components}
output_folder_path=${2:-$input_folder_path}

# 絶対パスに変換
input_folder_absolute_path=$(realpath "$input_folder_path")
output_folder_absolute_path=$(realpath "$output_folder_path")

# output_folder_absolute_pathからinput_folder_absolute_pathへの相対パスを計算
script_dir=$(cd "$(dirname "$0")"; pwd)
PATH="$PATH:$script_dir"
source pathlib.bash
from_output_to_input_relative_path=$(path_get_relative "$output_folder_absolute_path" "$input_folder_absolute_path")
```

<br>

## 2. vueファイル群を読み込んで各種処理を行なう

このセクションの大まかな流れは下記の通りです。

1. 読み込んでいるvueファイルのパスからファイル名とUpperCamelCaseに変換したファイル名を取得する
2. 1で取得したファイル名と先程取得したoutput_folder_pathから出力先のパスを作成する
3. 既に出力予定のstories.tsファイルと同名のstories.tsファイルが存在する場合は処理をスキップして次のvueファイルに移る
4. vueファイルからpropsのdefault達を抜き出す（ここは次のセクションで詳説します）
5. ここまで整理した情報達をテンプレートに流し込んでファイルとして出力する

```bash:create.bash
# Vueファイルを再帰的に検索して処理
find "$input_folder_path" -type f -name "*.vue" | while read -r vue_file_path; do

  # ファイル名から拡張子を削除し、ファイル名を取得
  file_name=$(basename -- "$vue_file_path")
  file_name_no_ext="${file_name%.vue}"

  # UpperCamelCaseのファイル名を生成
  upper_camel_case_file_name=$(echo "$file_name_no_ext" | awk -F'[-_]' '{ for(i=1; i<=NF; i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2)); }1' OFS='')

  # 対応するstories.tsファイルのパスを生成
  vue_file="${vue_file_path#$input_folder_path}"
  stories_file_path="${output_folder_path}${vue_file%.*}.stories.ts"

  # 既に同名のstories.tsファイルが存在する場合はスキップ
  if [ -e "$stories_file_path" ]; then
    echo "Skip: $stories_file_path already exists."
    continue
  fi

  # ①と② OptionsAPIの場合
  props_output=$(awk '
  <中略>
  ' $vue_file_path)

  if [[ -n $props_output ]]; then
    args=$props_output
  fi

  # ③と④ withDefaultを使わないCompositionAPIの場合
  define_props_output=$(awk '
  <中略>
  ' "$vue_file_path")

  if [[ -n $define_props_output ]]; then
    args=$define_props_output
  fi

  # ⑤ withDefaultを使うCompositionAPIの場合
  with_defaults_output=$(awk '
  <中略>
  ' $vue_file_path)
  if [[ -n $with_defaults_output ]]; then
    args=$with_defaults_output
  fi

  # stories.tsファイルを生成
  cat > "$stories_file_path" <<EOL
<中略>
EOL

  echo "Created: $stories_file_path"
done
```

<br>

## 3. vueファイルからpropsのdefault達を抜き出す

vueファイルからpropsのdefault達を抜き出す箇所に関しては大きく三つの部分から成ります。それぞれで指定する条件にマッチした場合はdefault値達を抜き出して変数argsに格納しています。

ここで考えなければいけないのはvueにおけるpropsの指定方法です。OptionsAPIとCompositionAPI、typescriptを使う・使わない、CompositionAPIのみwithDefaultsを使う使わないの組み合わせで計５パターンあります。

それぞれ例を見てみましょう。

**①TSを使わないOptionsAPI**

```vue:options-js.vue
<script>
import { defineComponent } from 'vue'
export default defineComponent({
  props: {
    text: {
      type: String,
      required: true
    },
    user:{
      type: Object,
      default: () => ({})
    },
    active: {
      type: Boolean,
      default: false
    }
  }
})
</script>
```

**②TSを使ったOptionsAPI**

```vue:options-ts.vue
<script lang="ts">
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
interface User {
  name: string;
  age: number;
}
export default defineComponent({
  props: {
    text: {
      type: String,
      required: true
    },
    user:{
      type: Object as PropType<User>,
      default: () => ({})
    },
    active: {
      type: Boolean,
      default: false
    }
  }
})
</script>
```

**③TSを使わないCompositionAPI**

```vue:composition-js.vue
<script setup>
import { defineProps } from 'vue';
const props = defineProps(['text', 'user', 'active']);
// ↑の書き方もできるがdefault値を設定する場合は結局↓の形
const props = defineProps({
  text: {
    type: String,
    required: true
  },
  user:{
    type: Object,
    default: () => ({})
  },
  active: {
    type: Boolean,
    default: false
  },
});
</script>
```

**④TSを使うCompositionAPI**

```vue:composition-ts.vue
<script setup lang="ts">
import type { ComponentObjectPropsOptions } from 'vue'
interface User {
  name?: string
  age?: number
}
interface Props {
  text: string
  user: User
  active: boolean
}
const props = defineProps<ComponentObjectPropsOptions<Props>>({
  text: {
    required: true
  },
  user: {
    default: () => ({})
  },
  active: {
    default: false
  }
})
</script>
```

**⑤TSを使いwithDefaultsも使うCompositionAPI**

```vue:withDefaults-composition-ts.vue
<script setup lang="ts">
import { defineProps } from 'vue';
interface User {
  name: string;
  age: number;
}
interface Props {
  text: string;
  user: User;
  active: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  text: '',
  user: () => ({}),
  active: false
})
</script>
```

こうやって見比べてみると、propsを抜き出すにあたって①と②、③と④、⑤の３つに場合分けできることが分かります。

1. ①と② OptionsAPIの場合
2. ③と④ withDefaultを使わないCompositionAPIの場合
3. ⑤ withDefaultを使うCompositionAPIの場合

続いて各場合の処理を説明します

```bash:create.bash
<中略>
# Vueファイルを再帰的に検索して処理
find "$input_folder_path" -type f -name "*.vue" | while read -r vue_file_path; do
  <中略>
  # ①と② OptionsAPIの場合
  props_output=$(awk '
  BEGIN { flag=0; key=""; value=""; nestLevel=0; print "{"; }

  /props: {/ { flag=1; nestLevel=1; }

  flag {
    if ($1 ~ /^[a-zA-Z0-9]+:$/ && nestLevel == 2) {
      key = substr($1, 1, length($1)-1);
    }
    if ($1 ~ /^default:/) {
      value = $0;
      sub(/[[:space:]]*default:/, "", value);
      sub(/,.*$/, "", value);
      if (value != "") { printf "%*s%s: %s,\n", (nestLevel - 1) * 2, "", key, value; }
    }
    for(i=1; i<=length($0); i++) {
      char = substr($0, i, 1);
      if (char == "{") { nestLevel++; }
      if (char == "}") { nestLevel--; }
    }
    if (nestLevel == 0) exit;
  }
  END { print "  },"; }
  ' $vue_file_path)

  if [[ -n $props_output ]]; then
    args=$props_output
  fi

  # ③と④ withDefaultを使わないCompositionAPIの場合
  define_props_output=$(awk '
  BEGIN { flag=0; key=""; value=""; nestLevel=0; print "{"; }

  /defineProps.*\({/ { flag=1; }

  flag {
    if ($1 ~ /^[a-zA-Z0-9]+:$/ && nestLevel == 1) {
      key = substr($1, 1, length($1)-1);
    }
    if ($1 ~ /^default:/) {
      value = $0;
      sub(/[[:space:]]*default:/, "", value);
      sub(/,.*$/, "", value);
      if (value != "") { printf "%*s%s:%s,\n", (nestLevel - 1) * 2, "", key, value; }
    }
    for(i=1; i<=length($0); i++) {
      char = substr($0, i, 1);
      if (char == "{") { nestLevel++; }
      if (char == "}") { nestLevel--; }
    }
    if (nestLevel == 0) exit;
  }
  END { print "  },"; }
  ' "$vue_file_path")

  if [[ -n $define_props_output ]]; then
    args=$define_props_output
  fi

  # ⑤ withDefaultを使うCompositionAPIの場合
  with_defaults_output=$(awk '
  BEGIN { flag=0; }
  /withDefaults\(/ { flag=1; next; }
  flag == 1 && /^[^,]+),/ { flag=2; next; }
  flag == 2 {
    print $0;
    if ($0 ~ /^[^}]+}/) {
      exit;
    }
  }
  ' $vue_file_path)
  if [[ -n $with_defaults_output ]]; then
    args=$with_defaults_output
  fi
  <中略>
done
```

### ①と② OptionsAPIの場合

まず初期化します

```awk
BEGIN { flag=0; key=""; value=""; nestLevel=0; print "{"; }
```

`props: {`(トリガーとなる文字列)を検知するとflagをオンにする
nestLevelも1にしておく

```awk
/props: {/ { flag=1; nestLevel=1; }
```

flagが1以上だった場合に行う処理が４つあります
keyを抜き出す処理・valueを抜き出してkeyと合わせて出力する処理・`{`や`}`があった場合にnestLevelを増減する処理・nestLevelが0の場合＝propsのエリアが終わった場合に処理を抜ける処理

```awk
flag {
  # nestLevelが2の時にkeyを抜き出す処理
  if ($1 ~ /^[a-zA-Z0-9]+:$/ && nestLevel == 2) {
    key = substr($1, 1, length($1)-1);
  }
  # valueを抜き出してkeyと合わせて出力する処理
  if ($1 ~ /^default:/) {
    value = $0; # $0=行全体をvalueに代入
    sub(/[[:space:]]*default:/, "", value); # valueより手前を削除
    sub(/,.*$/, "", value); # valueより後=カンマ以降を削除
    if (value != "") { printf "%*s%s: %s,\n", (nestLevel - 1) * 2, "", key, value; } # インデント等を調整しつつ出力
  }
  # {や}があった場合にnestLevelを増減する処理
  for(i=1; i<=length($0); i++) {
    char = substr($0, i, 1); # 1文字ずつ確認
    if (char == "{") { nestLevel++; }
    if (char == "}") { nestLevel--; }
  }
  # nestLevelが0の場合＝propsのエリアが終わった場合に処理を抜ける処理
  if (nestLevel == 0) exit;
}
```

末尾の` },`を出力して終了

```awk
END { print "  },"; }
```

### ③と④ withDefaultを使わないCompositionAPIの場合

概ね ①と② OptionsAPIの場合と同じ
`props: {`の代わりに`defineProps.\*\({`を検知する
propsのネストが無い分、keyを抜き出すのは`nestLevel == 2`ではなく`nestLevel == 1`に変わる

### ⑤ withDefaultを使うCompositionAPIの場合

初期化して、`withDefaults(`を検知したらflagをオンにする

```awk
BEGIN { flag=0; }
/withDefaults\(/ { flag=1; next; }
```

先頭から`^`カンマが無い`[^,]`行だった場合flagを2にする
flagが2の場合、withDefaultの第二引数、default値の定義箇所なのでそのまま出力する
ただし、先頭から`^`、`}`が無い`[^}]`文字が続いた後に一つだけ`}`があった場合、そこはdefault値の定義箇所の終了地点なので処理を終了する

```awk
flag == 1 && /^[^,]+),/ { flag=2; next; }
flag == 2 {
  print $0;
  if ($0 ~ /^[^}]+}/) {
    exit;
  }
}
```

# おわりに

今回chatGPT先生に大変助けられました。新たに何かを学ぶ際には必須ですね。

# 参考記事

https://ja.vuejs.org/guide/typescript/composition-api.html
https://ja.vuejs.org/guide/typescript/options-api.html