## 背景 1年前に以下のようなSPAベースを作成した。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" /> <span class="link-card-v2-site-name">Minerva</span> </div> <div class="link-card-v2-title"> 📜2024-04-28 Nuxt3 x TypeScript x shadcn-vue x TailwindCSS x Pinia x Zod x Bunで最強のSPAベースをつくってみる </div> <div class="link-card-v2-content">本業で1からWebプロダクトをつくることになった。重要視されるのは開発スピードとデザインの2つ。品質はまだそこまで優先されていない。また、初期フェーズのあとは担当チームに引き継ぐ必要があるため、あまりリスクの大きい技術は利用しない。</div> <img class="link-card-v2-image" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/activity.webp" /> <a data-href="📜2024-04-28 Nuxt3 x TypeScript x shadcn-vue x TailwindCSS x Pinia x Zod x Bunで最強のSPAベースをつくってみる" class="internal-link"></a> </div> %%[[📜2024-04-28 Nuxt3 x TypeScript x shadcn-vue x TailwindCSS x Pinia x Zod x Bunで最強のSPAベースをつくってみる]]%% 今回もほぼ同じような動機でSPAベースを作成してみることになった。 ## 利用する技術 1年前と似ているが若干変化がある。 | 技術 | 理由 | | ----------------- | -------------------------------------- | | [[Nuxt3]] | 社内のプロダクトの大半が利用しており、自身の知見も深い | | [[TypeScript]] | [[JavaScript]]にする理由は全くない | | [[shadcn-vue]] | 詳細なデザインカスタマイズが可能 かつ 初期スピードも出せる | | [[VueUse]] | [[Vue]]とセットの鉄板 | | [[VeeValidate]] | UIと分離できて使い勝手もよい. [[Zod]]との連携も強力 | | [[Zod]] | フォームなどのバリデート用 (APIは使わない) | | [[Pinia]] | 公式推奨のストア | | [[Tailwind CSS]] | [[shadcn-vue]]使えばついてくる | | [[OpenAPI fetch]] | [[HTTPクライアント]] | | [[pnpm]] | フロントエンドで[[Deno]]や[[Bun]]はエッジケースでリスクがある | | [[🦉Owlelia]] | 日付系のユーテリティーとして | | 対象 | バージョン | | ------------------------------------ | ------- | | [[Node.js]] | v22.8.0 | | [[pnpm]] | 9.15.4 | | [[Nuxt]] | 3.16.2 | | [[Vue]] | 3.5.13 | | [[Vue Router]] | 4.5.0 | | [[Prettier]] | 3.5.3 | | [[Prettier Plugin Organize Imports]] | 4.1.0 | | [[vue-tsc]] | 2.2.8 | | [[shadcn-nuxt]] | 2.1.0 | | [[Reka UI]] | 2.2.0 | | [[Tailwind CSS]] | 4.1.4 | | [[openapi-typescript]] | 7.6.1 | | [[OpenAPI fetch]] | 0.13.5 | | [[🦉Owlelia]] | 0.49.0 | ## 個人的準備 作業をしていて[[Neovim]]の調子が悪かったため、事前にやったこと。 - [[📝NeovimでVueのtemplate内で補完候補を選択すると挿入場所がおかしい]] - [[📜2025-04-20 nvim-lspconfigでVolarのHybrid Modeを有効にする]] ## プロジェクトベース作成 ### [[Nuxt]]のインストール ```console pnpm create nuxt harmonious ``` 以下をインストールした。 ```console dependencies: + nuxt 3.16.2 + vue 3.5.13 + vue-router 4.5.0 dependencies: + @nuxt/eslint 1.3.0 + @nuxt/fonts 0.11.1 + @nuxt/icon 1.12.0 + @nuxt/image 1.10.0 ``` ### [[TypeScript]]のインストール ```console pnpm add -D typescript ``` ### [[Prettier]]のインストール import文を最適化するため [[Prettier Plugin Organize Imports]] も入れる。 ```console pnpm add -D prettier prettier-plugin-organize-imports vue-tsc ``` `.prettierrc.json` ```json { "plugins": ["prettier-plugin-organize-imports"] } ``` ### 型チェックやimportを堅牢にする 以下の記事で紹介した手法を用いる。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" /> <span class="link-card-v2-site-name">Minerva</span> </div> <div class="link-card-v2-title"> 📘完全武装をしてNeovimでも安全で快適なNuxt3の開発をするのだ </div> <div class="link-card-v2-content">NeovimでNuxt3とTypeScriptを型安全かつ快適に開発するための設定方法を解説します。Auto-importsの無効化や未使用importの自動削除、未定義タグの検知、フォールスルー属性対応など、VSCodeやWebStormでは得られないNeovim特有の課題とその解決策を詳しく紹介しています。詳しい手順や設定例は記事でご確認ください。</div> <img class="link-card-v2-image" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/%F0%9F%93%98Articles/attachments/2024-07-26.webp" /> <a data-href="📘完全武装をしてNeovimでも安全で快適なNuxt3の開発をするのだ" class="internal-link"></a> </div> %%[[📘完全武装をしてNeovimでも安全で快適なNuxt3の開発をするのだ]]%% `nuxt.config.ts` ```ts export default defineNuxtConfig({ // インポート用(Auto-imports含む)の型定義に追加するためスキャンしない imports: { scan: false }, // コンポーネントとして扱うディレクトリを指定しない components: { dirs: [] } }) ``` `tsconfig.json` ```ts { // https://nuxt.com/docs/guide/concepts/typescript "extends": "./.nuxt/tsconfig.json", // これを追加 "vueCompilerOptions": { "strictTemplates": true } } ``` `allow-fallthrough-props.d.ts` ```ts import "vue"; declare module "vue" { // for vue components interface AllowedComponentProps { // inputにフォールスルー属性として利用 type?: unknown; dataSlot?: unknown; // kebab-caseはcamelCaseで書く(data-slot) // LargeInput以外の箇所でフォールスルー属性として利用と仮定 placeholder?: unknown; // @changeのフォールスルー属性 onChange?: unknown; // TODO: フォールスルー属性が増えたら追加していく } // for native html elements interface HTMLAttributes { // allow any data-* attr [key: `data-${string}`]: string; } } export {}; ``` キャッシュ周りを一度綺麗にする。 ```console rm -rf .nuxt pnpm dev ``` ## ESLintとPrettierの共存 [[ESLint]]と[[Prettier]]が相互干渉するのを防ぐため、[[eslint-config-prettier]]を入れる。 ```console pnpm add -D eslint-config-prettier ``` `eslint.config.mjs` に 以下を追加。 ```js // @ts-check // ★追加 import eslintConfigPrettier from "eslint-config-prettier/flat"; import withNuxt from "./.nuxt/eslint.config.mjs"; // ★変更: eslintConfigPrettierを指定 export default withNuxt(eslintConfigPrettier); ``` 以下のような `<img />` に対して[[ESLint]]が何も警告を出さなければOK。 ```html <script setup lang="ts"></script> <template> <img /> </template> ``` ## [[shadcn-vue]]の導入 [[Nuxt]]のインストールガイドを見て進める。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://www.shadcn-vue.com/favicon-16x16.png" /> <span class="link-card-v2-site-name">www.shadcn-vue.com</span> </div> <div class="link-card-v2-title"> Nuxt - shadcn/vue </div> <div class="link-card-v2-content"> Install and configure Nuxt. </div> <img class="link-card-v2-image" src="https://shadcn-vue.com/og.png" /> <a href="https://www.shadcn-vue.com/docs/installation/nuxt.html"></a> </div> 1は完了しているので [[Tailwind CSS]] の追加から。 ### [[Tailwind CSS]]の導入 ```console pnpm add tailwindcss @tailwindcss/vite ``` ```console dependencies: + @tailwindcss/vite 4.1.4 + tailwindcss 4.1.4 ``` `assets/css/tailwind.css` ```css @import "tailwindcss"; ``` `nuxt.config.ts` ```ts // ★追加 import tailwindcss from "@tailwindcss/vite"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: "2024-11-01", ssr: false, // ★追加 css: ["~/assets/css/tailwind.css"], vite: { plugins: [tailwindcss()], }, // modules: ["@nuxt/eslint", "@nuxt/fonts", "@nuxt/image", "@nuxt/icon"], // インポート用(Auto-imports含む)の型定義に追加するためスキャンしない imports: { scan: false }, // コンポーネントとして扱うディレクトリを指定しない components: { dirs: [] }, }); ``` ### Nuxt moduleの追加 ```console pnpm dlx nuxi@latest module add shadcn-nuxt ``` `nuxt.config.ts` ```ts import tailwindcss from "@tailwindcss/vite"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: "2024-11-01", ssr: false, css: ["~/assets/css/tailwind.css"], vite: { plugins: [tailwindcss()], }, modules: [ "@nuxt/eslint", "@nuxt/fonts", "@nuxt/image", "@nuxt/icon", // ★追加 "shadcn-nuxt", ], // modules: ["@nuxt/eslint", "@nuxt/fonts", "@nuxt/image", "@nuxt/icon"], // インポート用(Auto-imports含む)の型定義に追加するためスキャンしない imports: { scan: false }, // コンポーネントとして扱うディレクトリを指定しない components: { dirs: [] }, // ★追加 shadcn: { prefix: '', componentDir: './components/ui' } }); ``` ### Nuxt Prepareの実行 `.nuxt` を作るために必要。 ```console pnpm dlx nuxi prepare ``` ### CLIの実行 ```console pnpm dlx shadcn-vue@latest init ``` ```console ✔ Preflight checks. ✔ Verifying framework. Found Nuxt. ✔ Validating Tailwind CSS config. Found v4. ✔ Validating import alias. ✔ Which color would you like to use as the base color? › Neutral ✔ Writing components.json. ✔ Checking registry. ✔ Updating CSS variables in assets/css/tailwind.css Packages: +5 +++++ Progress: resolved 1113, reused 992, downloaded 5, added 5, doneownloaded 5, added 4 ⠴ Installing dependencies. dependencies: + class-variance-authority 0.7.1 + clsx 2.1.1 + lucide-vue-next 0.501.0 + tailwind-merge 3.2.0 + tw-animate-css 1.2.5 ``` ### ボタンコンポーネント追加 ```console pnpm dlx shadcn-vue@latest add button ``` ```console dependencies: + reka-ui 2.2.0 Done in 3.9s ✔ Installing dependencies. ✔ Created 2 files: - components/ui/button/Button.vue 3:25:46 PM - components/ui/button/index.ts ``` > [!info] > [[Radix Vue]]がv2で[[Reka UI]]という名前に変わったらしい。[[shadcn-vue]]と同じ[[unovue]]によって引き続き開発されているので安心感はある。 `Button` を使ってみる。 `app.vue` ```html <script setup lang="ts"> import Button from "./components/ui/button/Button.vue"; </script> <template> <Button variant="destructive" size="lg" class="m-12">Hello</Button> </template> ``` こんな感じに表示されればOK。 ![[Pasted image 20250420154050.png]] ## [[OpenAPI fetch]]の導入 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://static-production.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png" /> <span class="link-card-v2-site-name">npm</span> </div> <div class="link-card-v2-title"> openapi-fetch </div> <div class="link-card-v2-content"> Fast, type-safe fetch client for your OpenAPI schema. Only 6 kb (min). Works with React, Vue, Svelte, or vanilla ... </div> <img class="link-card-v2-image" src="https://static-production.npmjs.com/338e4905a2684ca96e08c7780fc68412.png" /> <a href="https://www.npmjs.com/package/openapi-fetch"></a> </div> ### インストール ```console pnpm add openapi-fetch pnpm add -D openapi-typescript ``` ``` dependencies: + openapi-fetch 0.13.5 ``` ``` devDependencies: + openapi-typescript 7.6.1 ``` ### OpenAPIファイルを用意 [[Reqres]]の定義を[[JSON]]ファイルとして利用してみる。エラーになっているところは対処済。 > [!code]- `openapi.json` > > ```json > { > "openapi": "3.1.0", > "info": { > "title": "ReqRes API", > "description": "Fake data CRUD API", > "version": 1 > }, > "servers": [ > { > "url": "https://reqres.in/api" > } > ], > "components": { > "schemas": { > "User": { > "type": "object", > "properties": { > "id": { > "type": "integer" > }, > "email": { > "type": "string" > }, > "first_name": { > "type": "string" > }, > "last_name": { > "type": "string" > }, > "avatar": { > "type": "string" > } > } > }, > "UnknownResource": { > "type": "object", > "properties": { > "id": { > "type": "integer" > }, > "name": { > "type": "string" > }, > "year": { > "type": "integer" > }, > "color": { > "type": "string" > }, > "pantone_value": { > "type": "string" > } > } > } > } > }, > "paths": { > "/{resource}": { > "get": { > "summary": "Fetches a resource list", > "parameters": [ > { > "in": "query", > "name": "page", > "schema": { > "type": "integer" > } > }, > { > "in": "query", > "name": "per_page", > "schema": { > "type": "integer" > } > } > ], > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "page": { > "type": "integer" > }, > "per_page": { > "type": "integer" > }, > "total": { > "type": "integer" > }, > "total_pages": { > "type": "integer" > }, > "data": { > "type": "array", > "items": { > "$ref": "#/components/schemas/UnknownResource" > } > } > } > } > } > } > } > } > } > }, > "/users": { > "get": { > "summary": "Fetches a user list", > "parameters": [ > { > "in": "query", > "name": "page", > "schema": { > "type": "integer" > } > }, > { > "in": "query", > "name": "per_page", > "schema": { > "type": "integer" > } > } > ], > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "page": { > "type": "integer" > }, > "per_page": { > "type": "integer" > }, > "total": { > "type": "integer" > }, > "total_pages": { > "type": "integer" > }, > "data": { > "type": "array", > "items": { > "$ref": "#/components/schemas/User" > } > } > } > } > } > } > } > } > } > }, > "/users/{id}": { > "get": { > "summary": "Fetches a user", > "parameters": [ > { > "in": "path", > "name": "id", > "schema": { > "type": "integer" > } > } > ], > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "data": { > "$ref": "#/components/schemas/User" > } > } > } > } > } > } > } > }, > "put": { > "summary": "Updates a user", > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "updatedAt": { > "type": "string" > } > } > } > } > } > } > } > }, > "patch": { > "summary": "Updates a user", > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "updatedAt": { > "type": "string" > } > } > } > } > } > } > } > }, > "delete": { > "summary": "Deletes a user", > "responses": { > "204": { > "description": "Success" > } > } > } > }, > "/{resource}/{id}": { > "get": { > "summary": "Fetches an unknown resource", > "parameters": [ > { > "in": "path", > "name": "id", > "schema": { > "type": "integer" > } > } > ], > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "data": { > "$ref": "#/components/schemas/UnknownResource" > } > } > } > } > } > } > } > }, > "put": { > "summary": "Updates an unknown resource", > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "updatedAt": { > "type": "string" > } > } > } > } > } > } > } > }, > "patch": { > "summary": "Updates an unknown resource", > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "updatedAt": { > "type": "string" > } > } > } > } > } > } > } > }, > "delete": { > "summary": "Deletes an unknown resource", > "responses": { > "204": { > "description": "Success" > } > } > } > }, > "/login": { > "post": { > "summary": "Creates a session", > "requestBody": { > "required": true, > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "username": { > "type": "string" > }, > "email": { > "type": "string" > }, > "password": { > "type": "string" > } > } > } > } > } > }, > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "token": { > "type": "string" > } > } > } > } > } > }, > "400": { > "description": "Login error", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "error": { > "type": "string" > } > } > } > } > } > } > } > } > }, > "/register": { > "post": { > "summary": "Creates a user", > "requestBody": { > "required": true, > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "username": { > "type": "string" > }, > "email": { > "type": "string" > }, > "password": { > "type": "string" > } > } > } > } > } > }, > "responses": { > "200": { > "description": "Success", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "id": { > "type": "string" > }, > "token": { > "type": "string" > } > } > } > } > } > }, > "400": { > "description": "Register error", > "content": { > "application/json": { > "schema": { > "type": "object", > "properties": { > "error": { > "type": "string" > } > } > } > } > } > } > } > } > }, > "/logout": { > "post": { > "summary": "Ends a session", > "responses": { > "200": { > "description": "Success" > } > } > } > } > } > } > ``` ### 定義ファイルを作成 ```console pnpm exec openapi-typescript ./openapi.json -o reqres.d.ts ``` ### 呼び出してみる `app.vue` で動作確認。 ```html <script setup lang="ts"> import createClient from "openapi-fetch"; import Button from "./components/ui/button/Button.vue"; import type { paths } from "./reqres"; const client = createClient<paths>({ baseUrl: "https://reqres.in/api" }); const userNames = ref<string[]>([]); const handleClick = async () => { const { data, error } = await client.GET("/users"); if (error) { console.error(error); return; } userNames.value = data.data?.map((user) => `${user.first_name} ${user.last_name}`) ?? []; }; </script> <template> <Button variant="destructive" size="lg" class="m-12" @click="handleClick" >Search</Button > <div class="flex flex-col items-center"> <h1 class="text-2xl font-bold">User List</h1> <ul class="list-disc"> <li v-for="(user, index) in userNames" :key="index">{{ user }}</li> </ul> </div> </template> ``` これで `Search` ボタンを押したら結果が表示されるようになる。もちろん型安全。 ![[Pasted image 20250420182547.png]] ## [[Composable]]とドメインモデルの分離 設計で責務を分離する。 | パス | 役目 | 備考 | | -------------------------- | -------------- | ----------------------------------------------------------------------------- | | `composables/useOOO.ts` | [[Composable]] | ドメインモデルのクラスみたいなもの | | `composables/useReqres.ts` | [[Composable]] | [[Reqres]]とのクライアントラッパ | | `domain/entities/OOO.ts` | エンティティ | - [[DDD]]の[[エンティティ (DDD)\|エンティティ]]相当<br>- 基本的に[[BFF]]のモデルに従順<br>- 必要に応じて拡張は可能 | ドメインモデルは基本的に[[型エイリアス (TypeScript)|型エイリアス]]。 `domain/entities/User.ts` ```ts import type { paths } from "~/reqres"; export type User = NonNullable< paths["/users"]["get"]["responses"]["200"]["content"]["application/json"]["data"] >[number]; ``` [[OpenAPI fetch]]のクライアントを生成する薄いラッパ。ミドルウェアを実装する場合はこのレイヤーに。 `composables/useReqres.ts` ```ts import createClient from "openapi-fetch"; import type { paths } from "~/reqres"; // TODO: 共通処理やラップ処理はここで吸収したい export async function useReqres() { const client = ref(createClient<paths>({ baseUrl: "https://reqres.in/api" })); return { client }; } ``` [[OpenAPI fetch]]のクライアントを利用し、必要なやりとりを行うコアな[[Composable]]。ドメインモデルをインターフェースとしているのが最大の特徴。 `composables/useUsers.ts` ```ts import { useReqres } from "~/composables/useReqres"; import type { User } from "~/domain/entities/User"; export function useUsers() { const { client } = useReqres(); const users = ref<User[] | null>(null); const error = ref<Error | null>(null); const loading = ref(false); const fetchUsers = async () => { loading.value = true; // WARN: 本来ならOASでdelayを定義しておく(定義がないので型エラーになる) const { data: res, error: _error } = await client.GET("/users", { params: { query: { delay: 1 } }, }); loading.value = false; // WARN: 本来ならOASの方でエラー定義をしておく if (_error) { error.value = _error; return; } // WARN: 本来ならOASの方でrequiredにしておく users.value = res.data!; }; return { users, error, loading, fetchUsers }; } ``` ドメインモデルベースの[[Composable]]を使ってシンプルにUIを表現する。 `app.vue` ```html <script setup lang="ts"> import Button from "~/components/ui/button/Button.vue"; import { useUsers } from "~/composables/useUsers"; const { users, loading, fetchUsers } = useUsers(); </script> <template> <Button variant="destructive" size="lg" class="m-12" @click="fetchUsers"> {{ loading ? "Loading..." : "Fetch Users" }} </Button> <div class="flex flex-col items-center"> <h1 class="text-2xl font-bold">User List</h1> <ul class="list-disc"> <li v-for="user in users" :key="user.id"> {{ user.first_name }} {{ user.last_name }} </li> </ul> </div> </template> ``` こんな感じになればOK。 ![[2025-04-21_00h10_59.mp4]] ## レイアウトとルーティング 今は `app.vue` のみのルートページなので、`/users` ページをつくる。中身は `app.vue` をそのまま移植。(ルート要素だけ追加) `pages/users.vue` ```html <script setup lang="ts"> import Button from "~/components/ui/button/Button.vue"; import { useUsers } from "~/composables/useUsers"; const { users, loading, fetchUsers } = useUsers(); </script> <template> <div> <Button variant="destructive" size="lg" class="m-12" @click="fetchUsers"> {{ loading ? "Loading..." : "Fetch Users" }} </Button> <div class="flex flex-col items-center"> <h1 class="text-2xl font-bold">User List</h1> <ul class="list-disc"> <li v-for="user in users" :key="user.id"> {{ user.first_name }} {{ user.last_name }} </li> </ul> </div> </div> </template> ``` `layouts/default.vue` ```html <template> <div> <p class="bg-green-300 p-2 text-lg font-bold"> Some default layout content shared across all pages </p> <slot /> </div> </template> ``` `app.vue` ```html <template> <NuxtLayout> <NuxtPage /> </NuxtLayout> </template> ``` `localhost:3000/users` で以下にアクセスできればOK。 ![[Pasted image 20250421002531.png]] ## Tailwind CSSのクラス名並び順最適化 いつも並び順が気になるので、[[PrettierでTailwind CSSのクラス名ソートを最適化]]する。[[prettier-plugin-tailwindcss]]を使う。 ```console pnpm add -D prettier-plugin-tailwindcss ``` `.prettierrc.json` ```json { "plugins": [ "prettier-plugin-organize-imports", // ★追加 (追加場所はpluginsの最後でなければいけない) "prettier-plugin-tailwindcss" ] } ``` これで保存したらクラス名順序が自動で変わるようになった。 ## Piniaの導入 [[#Composable とドメインモデルの分離]]で作成したクライアントを[[Pinia]]でstore化してみる。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://pinia.vuejs.org/logo.svg" /> <span class="link-card-v2-site-name">pinia.vuejs.org</span> </div> <div class="link-card-v2-title"> Nuxt | Pinia </div> <div class="link-card-v2-content"> Intuitive, type safe, light and flexible Store for Vue </div> <a href="https://pinia.vuejs.org/ssr/nuxt.html"></a> </div> ```console pnpm add pinia @pinia/nuxt ``` `nuxt.config.ts` の `modules` に追加。 ```ts modules: [ "@nuxt/eslint", "@nuxt/fonts", "@nuxt/image", "@nuxt/icon", "shadcn-nuxt", // ★追加 "@pinia/nuxt", ], ``` `store/useReqres.ts` ```ts import createClient from "openapi-fetch"; import { defineStore } from "pinia"; import type { paths } from "~/reqres"; export const useReqresStore = defineStore("reqres", () => { const client = createClient<paths>({ baseUrl: "https://reqres.in/api", }); return { client }; }); ``` `composables/useReqres` は削除。 `composables/useUsers.ts` ```ts import type { User } from "~/domain/entities/User"; // ★storeに変更 import { useReqresStore } from "~/store/useReqres"; export function useUsers() { // ★変更 const rqStore = useReqresStore(); const users = ref<User[] | null>(null); const error = ref<Error | null>(null); const loading = ref(false); const fetchUsers = async () => { loading.value = true; // WARN: 本来ならOASでdelayを定義しておく(定義がないので型エラーになる) // ★変更 const { data: res, error: _error } = await rqStore.client.GET("/users", { params: { query: { delay: 1 } }, }); loading.value = false; // WARN: 本来ならOASの方でエラー定義をしておく if (_error) { error.value = _error; return; } // WARN: 本来ならOASの方でrequiredにしておく users.value = res.data!; }; return { users, error, loading, fetchUsers }; } ``` ## ページベースの作成 [[shadcn-vue]]でブロック実装の例が提供されているのでこれを参考にしていく。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://www.shadcn-vue.com/favicon-16x16.png" /> <span class="link-card-v2-site-name">www.shadcn-vue.com</span> </div> <div class="link-card-v2-title"> Building Blocks - shadcn/vue </div> <div class="link-card-v2-content"> Beautifully designed components built with Radix Vue and Tailwind CSS. </div> <img class="link-card-v2-image" src="https://shadcn-vue.com/og.png" /> <a href="https://www.shadcn-vue.com/blocks.html"></a> </div> `Featured` がカッコイイので拝借する。 ### 必要なコンポーネントのインストール - [ ] Avater - [ ] Breadcrumb - [ ] Separator - [ ] Sidebar - [ ] Dropdown Menu - [ ] Collapsible ```console pnpm dlx shadcn-vue@latest add breadcrumb separator sidebar dropdown-menu collapsible avatar ``` ## GitHub Actionsでデプロイ [[GitHub Actions]]で[[GitHub Pages]]にデプロイする。 ```yaml name: Deploy Nuxt site to Pages on: push: branches: ["master"] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: false jobs: # Build job build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" - run: corepack enable - run: pnpm install - run: pnpm run generate - uses: actions/upload-pages-artifact@v3 with: path: ./dist deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - id: deployment uses: actions/deploy-pages@v4 ``` https://tadashi-aikawa.github.io/harmonious/ にアクセスしてみるがリソースが根こそぎ404になる。 ## ベースURLの設定 `/harmonious` がベースにくっついてくるので、`nuxt.config.ts` に `app.baseURL` を指定する必要がある。 `nuxt.config.ts` ```ts export default defineNuxtConfig({ app: { baseURL: "/harmonious", }, ``` ## Nuxt 3.17にアップデート 数日前に新バージョンが出ていたのでアップデートする。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://nuxt.com/icon.png" /> <span class="link-card-v2-site-name">Nuxt</span> </div> <div class="link-card-v2-title"> Nuxt 3.17 · Nuxt Blog </div> <div class="link-card-v2-content"> Nuxt 3.17 is out - bringing a major reworking of the async data layer, a new built-in component, better warnings ... </div> <img class="link-card-v2-image" src="https://nuxt.com/assets/blog/v3.17.png" /> <a href="https://nuxt.com/blog/v3-17"></a> </div> ```console pnpm dlx nuxi@latest upgrade --dedupe ``` ``` dependencies: - nuxt 3.16.2 + nuxt 3.17.1 ``` ## [[TS-Pattern]]の導入 パフォーマンスにクリティカルでなければ便利そうなので。 ```console pnpm add ts-pattern ```