Appearance
Metanaka Nuxt Style Rule
この repository の Nuxt code を書く・直す・レビューするときの総合ルール。
Architecture and Placement
Feature Slice Architecture を採用する。 Nuxt project root の app/features/ に feature 実装を置き、Nuxt で慣習的な app/composables や app/components に feature code を集めない。
Nuxt の route file は app/pages/ に置き、薄く保つ。 Feature の状態、validation、API result handling、主要な状態遷移は app/features/<feature>/ に置く。
feature directory はフラットに保つ。feature の中に components/、composables/、model/、tests/ のような役割ディレクトリを作らない。 例外として、feature-local な翻訳ファイルだけは i18n/ に置く。
text
apps/nuxt/app/features/<feature>/
FeatureView.vue
use-feature.ts
use-feature.test.ts
model.ts
i18n/
ja.json
en.jsonファイルの役割は名前で表す。
.vue: feature の UI component、または feature の page 相当 component。表示、props/emits、軽い computed、event handler、feature composable の呼び出しを担当する。use-*.ts: UI から使う stateful composable。外部境界は shared composable や adapter を module mock してuseXxxの public contract で検証する。model.ts/*-model.ts: Zod schema、domain type、factory、状態遷移、validation 関連型、feature 固有の API response / result 型。*.test.ts: 対象 code と同じ slice に置く Vitest unit test。- その他の kebab-case
.ts: feature-local な utility または adapter。 i18n/{ja,en}.json: feature が所有する文言。
shared/ に移すのは、少なくとも2つ以上の feature から必要になったとき、または design-system primitive のようにリポジトリ全体の contract として定義する意図があるときに限る。 利用箇所が1箇所であっても、特定 feature の業務知識を持たず、今後も app 全体の contract として扱いたいものは shared 配下に置いてよい。
app/shared/ は、共有概念ごとの slice として整理する。 新規 shared code や移動を伴う変更では、components/、composables/、model/、utils/ のような役割別ディレクトリを shared 直下に増やさず、共有概念の app/shared/<kebab-case-concept>/ に model、utility、composable、adapter、unit test、i18n を同じ slice として置く。 既存の role directory にある code を小さく修正するだけなら、不要な移動を避けて近傍に合わせてよい。
text
apps/nuxt/app/shared/
components/
ui/
DadsButton.vue
DadsTextInput.vue
server-error/
model.ts
get-server-error-keys.ts
get-server-error-keys.test.ts
i18n/
ja.json
en.json
auth-api/
create-auth-api.ts
create-auth-api.test.ts
model.tsshared/components/ui/: リポジトリ全体の UI primitive と design-system wrapper。Reka UI primitive はここで wrap し、feature code から Reka UI を直接 import しない。shared/<concept>/model.ts: その共有概念の domain type、value object、公開型。shared/<concept>/use-*.ts: その共有概念の composable。shared/<concept>/*.ts: その共有概念の utility、adapter、resolver。shared/<concept>/*.test.ts: 対象 file と同じ slice に置く unit test。shared/<concept>/i18n/{ja,en}.json: その共有概念が所有する app-wide な文言。
例:
- shared: server error code と表示用 message key の対応、ProblemDetails を表示用 key に変換する処理。
- feature: host の room create validation error を扱う処理。
命名は次の方針に揃える。
- feature directory と Vue 以外の TypeScript file は kebab-case。
- Vue component file は PascalCase。
- export する TypeScript symbol は用途に応じて camelCase または PascalCase。
- composable は
use-で始める。 - test は対象 file 名に合わせる。例:
use-room-create.tsとuse-room-create.test.ts。 - feature-specific API adapter は、1 feature だけで使う間は
app/features/<feature>/<feature>-api.tsのように feature slice に置く。2 feature 以上から使う、または app-wide contract として扱う意図が出た時点で shared concept へ移す。
Vue component
Vue component は domain logic や主要な状態遷移を抱え込まず、composable と shared UI wrapper を組み合わせる。
<script setup lang="ts">を使う。<script setup lang="ts">内の handler は arrow function にする。computedの getter、watchcallback、event handler も arrow function にする。- Reka UI primitive を feature から直接 import せず、
app/shared/components/ui/などの wrapper を使う。 - form submit や click handler は小さく保ち、validation、API result handling、主要な状態遷移は composable に委譲する。
- component 内に domain logic を溜めず、純粋関数または composable に切り出す。
- composable の結果を受けた画面固有の routing や query 組み立ては、handler が薄い限り Vue component に残してよい。
- ユーザーに見える文言は
useI18n().t(...)で key から表示する。 - validation error は composable/model が持つ i18n key を UI 境界で
t(key)する。 - 成功時の単純な route 遷移は Vue component の薄い handler に残してよい。server result の分類、error mapping、retry、pending 制御は composable に置く。
RoomSetup.vue のように、画面は「選択肢」「submit handler」「template の接続」程度に留める。 ただし、画面固有の event handler、routing、query 組み立てまで何でも composable に押し込まない。 domain / state / API 境界は composable に寄せ、button click から composable を呼び、成功時にその画面の遷移先へ進む程度の処理は Vue component に書いてよい。
vue
<script setup lang="ts">
import DadsButton from "../../shared/components/ui/DadsButton.vue"
import DadsField from "../../shared/components/ui/DadsField.vue"
import DadsHeading from "../../shared/components/ui/DadsHeading.vue"
import DadsPanel from "../../shared/components/ui/DadsPanel.vue"
import DadsTextInput from "../../shared/components/ui/DadsTextInput.vue"
import { useRoomCode } from "./use-room-code.js"
const { t } = useI18n()
const { roomCode, error, getRoomId } = useRoomCode()
const handleJoin = async () => {
const roomId = await getRoomId()
if (!roomId) return
await navigateTo({ path: "/room/join", query: { roomId } })
}
</script>この例では、room code の状態と roomId 解決は useRoomCode が持ち、どの route へ遷移するかは画面の責務として Vue component に残す。 同じ flow を複数画面で再利用する、遷移条件が複雑になる、pending / toast / analytics などが増えて handler が読みにくくなる場合にだけ、routing を含む composable への切り出しを検討する。
Composable
composable は、まず UI から見える useXxx の public contract を小さく保つ。 外部境界は shared composable、API wrapper、repository adapter などの小さな module に寄せ、unit test では vi.mock で差し替えて useXxx を直接検証する。 テストのために useXxx とは別の composable を作らない。
ts
export const useFeature = () => {
const { $authApi } = useNuxtApp()
const serverErrorKey = ref<ServerErrorKey[]>([])
const submit = async (): Promise<FeatureResult> => {
const result = await $authApi.requestApi<FeatureResponse>("/things", {
method: "POST",
})
serverErrorKey.value = getServerErrorKeys(result)
return result
}
return { submit, serverErrorKey: readonly(serverErrorKey) }
}composable の state は次の方針で扱う。
- UI が編集する form は writable な
refとして返す。 - UI が読むだけの state は
readonly(...)で返す。 refやreactiveの更新は UI state の境界として扱い、計算可能な値はcomputedまたは純粋関数に寄せる。- 現在時刻、random、router、HTTP、Firebase、localStorage は直接 pure function に入れず、引数または依存として渡す。
pendingguard を入れ、多重送信時は client result を返す。- request 前に server error を clear し、validation 失敗時も server error を clear する。
try/finallyで pending を必ず戻す。- server result は呼び出し元へ返し、UI は成功時の遷移など画面固有の処理だけを行う。
Model and Validation
form、validation、error shape は model に寄せる。
- form、request body、URL/query 由来などの user input の shape・値制約 validation は、原則として必ず Zod schema を通す。
- composable 側で
trim()、空文字チェック、数値範囲チェック、field name からの error key 組み立てを個別に実装しない。 - pending guard、認証状態の確認、同一値なら API を呼ばないなどの flow control は Zod validation ではなく composable の責務として扱う。
- Zod schema の message には表示文ではなく i18n key を入れる。
z.infer<typeof schema>から form type を作る。- field が 1 つだけの form でも object として扱い、default form と empty error は factory function で作る。
- field error は
Record<keyof Form, string>を基本にし、空文字で error なしを表す。 - API response type と、その response を包む feature 固有の Result type は feature の public contract として
model.tsまたは*-model.tsに置く。use-*.tsの中に閉じた local type として定義しない。
Zod issue から field error へ変換するときは shared utility の applyZodFieldErrors を使う。 composable 側で validation.error.issues[0]?.message を直接読む、field name から i18n key を組み立てる、fallback の i18n key を直書きする、といった処理は避ける。 field が 1 つだけの form でも、model に form factory、Record<keyof Form, string> の error shape、empty error factory を置き、同じ shared utility で error key を取り出す。
優先する書き方:
ts
const validation = userAccountFormSchema.safeParse(form.value)
if (!validation.success) {
formError.value = applyZodFieldErrors(
createEmptyUserAccountFormError(),
validation.error.issues,
)
return { ok: false, source: "client", reason: "validation" }
}避ける書き方:
ts
const validation = userAccountFormSchema.safeParse(form.value)
if (!validation.success) {
errorKey.value =
validation.error.issues[0]?.message ??
"userAccount.error.displayNameRequired"
return { ok: false, source: "client", reason: "validation" }
}Result and Errors
server request は shared の Result 型と request helper の contract に合わせる。
- 成功は
{ ok: true, data }。 - validation や pending のような client-side failure は
{ ok: false, source: "client", reason }。 - server failure は
{ ok: false, source: "server", error }。 - server error code は shared の resolver で i18n key に変換する。
- form field error と server error key は別 state にする。
UI が raw ProblemDetails や HTTP status を直接解釈し始めたら、composable または shared concept に責務を戻す。
Unit Test
composable、model、utility の振る舞いは Vitest で直接検証する。
- test file は対象 file と同じ feature/shared slice に置く。
describeは対象 composable / module 名にする。itのタイトルは日本語で、前提または操作と期待値が分かる形にする。- 外部境界は shared composable や adapter module を
vi.mockで差し替える。 - validation failure、pending guard、成功 request、server error mapping、error clear、reject 時の cleanup を確認する。
- async の途中状態を検証する場合は deferred Promise を最後まで解決し、未解決の Promise を残さない。
TDD 対象なら、最初にテストリストを作り、ひとつずつ RED/GREEN/REFACTOR を進める。
i18n
i18n source は文言を所有する概念の近くに置く。
feature 固有の文言は
apps/nuxt/app/features/<feature>/i18n/{ja,en}.jsonに置く。共有概念の文言は
apps/nuxt/app/shared/<concept>/i18n/{ja,en}.jsonに置く。生成先の locale file だけを Nuxt project root の
i18n/locales/{ja,en}.jsonに置く。既存の横断 i18n directory と concept slice が混在している場合、新規 source と移動を伴う変更では
app/shared/<concept>/i18n/{ja,en}.jsonを優先する。既存横断 directory の小修正だけなら不要な移動を避ける。namespace は所有する feature または shared concept 名を camelCase にしたものを使う。
leaf key は短い camelCase にする。
Zod validation message も同じ namespace の key にする。
ja.jsonとen.jsonの key set を揃える。generated locale file を直接編集しない。
i18n source を変更したら Nuxt project root で pnpm run i18n:merge を実行する。
Verification
変更内容に応じて狭く確認する。
- composable/model/utility/test:
pnpm test:unit -- <path/to/test.test.ts> - TypeScript、Vue props/emits、composable 戻り値、i18n key、route、Firestore 保存型、test type に触れた場合:
pnpm tc - i18n source を触った場合:
pnpm run i18n:merge - UI の見た目や主要操作を変えた場合: dev server と Playwright / screenshot で確認する。
Reference Examples
references/room-setup/ に、現在の apps/nuxt/app/features/room-setup を同梱している。
RoomSetup.vue: Vue component が composable と shared UI に委譲する例。use-room-setup.ts:useRoomSetupの public contract を直接 test する例。use-room-setup.test.ts: validation、pending、server error、cleanup を検証する例。model.ts: Zod schema、form type、error type、factory、response type の例。i18n/ja.json/i18n/en.json: feature-owned locale source の例。