BashでStorybookファイル作成を自動化してみた
はじめに
業務で既存環境にstorybookを導入する機会があったのですが、その際に一つ一つ手作業で書くのが面倒でしたので楽をするべくbash scriptを書くことにしました。
やりたいこと
今回vueのコンポーネントを表示するstories.tsを作成します。
下記雛形をベースにファイル名やアッパーキャメルにしたファイル名、args等を置き換えていくことを考えます。
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
を読み込んでいますが、二つのパスから片方のパスから片方のパスまでの相対パスを取得する関数を定義しています。
#!/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
に格納します。
# フォルダの相対パスを取得
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")
2. vueファイル群を読み込んで各種処理を行なう
このセクションの大まかな流れは下記の通りです。
- 読み込んでいるvueファイルのパスからファイル名とUpperCamelCaseに変換したファイル名を取得する
- 1で取得したファイル名と先程取得したoutput_folder_pathから出力先のパスを作成する
- 既に出力予定のstories.tsファイルと同名のstories.tsファイルが存在する場合は処理をスキップして次のvueファイルに移る
- vueファイルからpropsのdefault達を抜き出す(ここは次のセクションで詳説します)
- ここまで整理した情報達をテンプレートに流し込んでファイルとして出力する
# 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
3. vueファイルからpropsのdefault達を抜き出す
vueファイルからpropsのdefault達を抜き出す箇所に関しては大きく三つの部分から成ります。それぞれで指定する条件にマッチした場合はdefault値達を抜き出して変数argsに格納しています。
ここで考えなければいけないのはvueにおけるpropsの指定方法です。OptionsAPIとCompositionAPI、typescriptを使う・使わない、CompositionAPIのみwithDefaultsを使う使わないの組み合わせで計5パターンあります。
それぞれ例を見てみましょう。
①TSを使わないOptionsAPI
<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
<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
<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
<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
<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を抜き出すにあたって①と②、③と④、⑤の3つに場合分けできることが分かります。
- ①と② OptionsAPIの場合
- ③と④ withDefaultを使わないCompositionAPIの場合
- ⑤ withDefaultを使うCompositionAPIの場合
続いて各場合の処理を説明します
<中略>
# 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の場合
まず初期化します
BEGIN { flag=0; key=""; value=""; nestLevel=0; print "{"; }
props: {
(トリガーとなる文字列)を検知するとflagをオンにする
nestLevelも1にしておく
/props: {/ { flag=1; nestLevel=1; }
flagが1以上だった場合に行う処理が4つあります
keyを抜き出す処理・valueを抜き出してkeyと合わせて出力する処理・{
や}
があった場合にnestLevelを増減する処理・nestLevelが0の場合=propsのエリアが終わった場合に処理を抜ける処理
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;
}
末尾の },
を出力して終了
END { print " },"; }
③と④ withDefaultを使わないCompositionAPIの場合
概ね ①と② OptionsAPIの場合と同じ
props: {
の代わりにdefineProps.\*\({
を検知する
propsのネストが無い分、keyを抜き出すのはnestLevel == 2
ではなくnestLevel == 1
に変わる
⑤ withDefaultを使うCompositionAPIの場合
初期化して、withDefaults(
を検知したらflagをオンにする
BEGIN { flag=0; }
/withDefaults\(/ { flag=1; next; }
先頭から^
カンマが無い[^,]
行だった場合flagを2にする
flagが2の場合、withDefaultの第二引数、default値の定義箇所なのでそのまま出力する
ただし、先頭から^
、}
が無い[^}]
文字が続いた後に一つだけ}
があった場合、そこはdefault値の定義箇所の終了地点なので処理を終了する
flag == 1 && /^[^,]+),/ { flag=2; next; }
flag == 2 {
print $0;
if ($0 ~ /^[^}]+}/) {
exit;
}
}
おわりに
今回chatGPT先生に大変助けられました。新たに何かを学ぶ際には必須ですね。
参考記事
Discussion
1年越しですが本当に本当に助かります。
それぞれの記法パターンに対応しているのがありがたいです。
私はBashは全く詳しくないのですが、WindowsのGit Bashで動かしてます。
さて、アッパーキャメルへの変換が元々キャメルケースの場合に対応していないように見えました。
以下記事を参考に「echo "helloWorld-hoge_fuga" | sed -r 's/(^|_|-)(.)/\U\2\E/g'」みたいな感じにしたら良い感じに動きました。
また、WebStormだと「const meta: Meta<typeof Hoge> = {...}」の終わりに「as」を付けてあげないと型のエラーを起こすので無理やりですがmetaの最後の行を
「} as Meta<typeof $upper_camel_case_file_name>;」こんな感じに。
後は実行したディレクトリのvueファイルのみを対象とするように「input_folder_path=${1:-"./"}」等々
改造報告でした。