Closed23

個人ブログをNuxt 3にアップグレードする

岩淵夕希物智岩淵夕希物智

久しぶりにプロジェクトをyarn devしてみようとして動かないと思ったら、移行する途中で止めてたみたいでいっそのことまっさらなところから作り直す過程をまとめてみます。

岩淵夕希物智岩淵夕希物智

Node.jsは20.11.1が現時点で最新らしいので改めて nvm install

nvm install 20.11.1
nvm use 20.11.1
npx nuxi@latest init nuxt-app

これで「nuxt-app」フォルダにプロジェクトが生成されるけど、最終的に中身をルートに移動するので名前はなんでもいい( package.jsonname もあとで直す)

Which package manager would you like to use?

npm

(最近はyarnよりnpmを好みがち)

node_modulesの構築を含め、インストール時間は数分

nuxi@3.10.1

がインストールされる

岩淵夕希物智岩淵夕希物智

インストール自体はうまくいっているはずだけれど、

nuxt.config.tsで
defineNuxtConfig is not defined というエラーが出る。

VSCodeのクイックフィックスでimport文を自動追加して回避。

nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
})
岩淵夕希物智岩淵夕希物智
Cannot start nuxt:  Cannot set properties of undefined (setting 'host')

というエラーが出る

親フォルダ(Gitのルートフォルダ)に旧ファイルがあったのが原因かもしれない

ルートフォルダのnode_modulesを rm .\node_modules\ -r で削除し、 nuxt-appフォルダ以外に .gitと.gitignoreだけ残してescapeフォルダを作って退避

動いた!

みっちーみっちー


こちらですが、同じように試してみたのですが、直らず何か他に良い方法ご存知でしたら
教えていただきたいです。

試したこと
・ ../../にnode_modulesがあったので削除

岩淵夕希物智岩淵夕希物智

見てくださってありがとうございます。

念の為ですが、空のディレクトリーを作ってそこから構築してみてもエラーが出るか試してみてください。

それでもエラーが出るのであれば、 npx nuxi@latest init nuxt-appnpx nuxi@3.10.1 init nuxt-app など、バージョンを固定してみてください。

岩淵夕希物智岩淵夕希物智

次はVuetifyの導入。

npm install vuetify

Material Design Iconsも同時に導入。

npm install @mdi/font
nuxt.config.ts
    devtools: ... // ここの下にbuild: ...を入れる
    build: {
        transpile: ['vuetify'],
    },

↑追加

プロジェクトルートにpluginsという名前のフォルダを作り、 vuetify.ts を作成。

vuetify.ts
import '@mdi/font/css/materialdesignicons.css'

import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

export default defineNuxtPlugin(app => {
  const vuetify = createVuetify({
    components,
    directives,
})
  app.vueApp.use(vuetify)
})

Tree Shakingという技でcomponentsとdirectivesを部分的に読み込む最適化もあるそうだけれど、同時にNuxtはうまいことやってくれているので小技は不要、みたいな記事も見つけたので、基本的にはvuetify/componentsとvuetify/directivesはまるまる入れてcreateVuetifyする。

岩淵夕希物智岩淵夕希物智

app.vueの <NuxtWelcome /><v-btn>aaa</v-btn> に差し替えて、VuetifyのVBtn (ボタンコンポーネント)が表示されるかテスト。

とても簡素だけれどうまくいっている。

岩淵夕希物智岩淵夕希物智

環境構築でまだ足りないところもあるけれど、いったん中身寄りのところを実装。

app.vue を書き換え

app.vue
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

layouts フォルダを作り、 default.vue ファイルを配置。

layouts/default.vue
<template>
    <v-app>
        <v-app-bar
            fixed
            app
        >
            <v-app-bar-title>
                <nuxt-link to="/" :style="{ 'text-decoration': 'inherit' }">
                    岩淵夕希物智 公式ブログ
                </nuxt-link>
            </v-app-bar-title>
        </v-app-bar>
        <slot />
    </v-app>
</template>

pages フォルダを作り、 index.vue ファイルを配置。

pages/index.vue
<template>
    <v-main>
        <v-container>
            <v-row>
                <v-col>
                    <v-card>
                        <v-card-title>Test</v-card-title>
                        <v-card-text>あーあー、マイクのテスト中。</v-card-text>
                    </v-card>
                </v-col>
            </v-row>
        </v-container>
    </v-main>
</template>
岩淵夕希物智岩淵夕希物智

カラーテーマを入れていないので依然として白い面立ちだけれど、ヘッダーとページコンテンツを表示する枠は完成。

続きはまたのちほど。

岩淵夕希物智岩淵夕希物智

続き。

カラーテーマを入れる。

plugins/vueitfy.ts を書き換えて、ライトテーマとダークテーマを適用。

plugins/vuetify.ts
import '@mdi/font/css/materialdesignicons.css'

import 'vuetify/styles'
import { type ThemeDefinition, createVuetify } from 'vuetify'
import colors from 'vuetify/lib/util/colors'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

const light: ThemeDefinition = {
    dark: false,
    colors: {
        background: colors.blueGrey.lighten5,
        primary: colors.blue.darken3,
        accent: colors.green.darken2,
        secondary: colors.amber.darken3,
        info: colors.teal.lighten1,
        warning: colors.amber.base,
        error: colors.deepOrange.accent4,
        success: colors.green.accent3,
    },
}

const dark: ThemeDefinition = {
    dark: true,
    colors: {
        background: colors.blueGrey.darken3,
        primary: colors.blue.darken3,
        accent: colors.green.darken2,
        secondary: colors.amber.darken3,
        info: colors.teal.lighten1,
        warning: colors.amber.base,
        error: colors.deepOrange.accent4,
        success: colors.green.accent3,
    },
}

export default defineNuxtPlugin(app => {
    const vuetify = createVuetify({
        components,
        directives,
        theme: {
            defaultTheme: 'light',
            themes: {
                light,
                dark,
            },
        },
    })
    app.vueApp.use(vuetify)
})

colorsに設定する色はカラーコードでもいいけれど、colorsから選んだ方がはっきりするのでこちらを使う。

https://vuetifyjs.com/en/styles/colors/#material-colors

岩淵夕希物智岩淵夕希物智

v-app-barcolor="primary" を設定し、
defaultTheme: "light"defaultTheme: "dark" を見比べてみる

岩淵夕希物智岩淵夕希物智

コードフォーマットをきれいに保つように、 .eslintrc.json を設定しておく。

.eslintrc.json
{
    "env": {
        "browser": true
    },
    "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:vue/vue3-recommended"],
    "parserOptions": {
        "ecmaVersion": "latest",
        "parser": "@typescript-eslint/parser",
        "sourceType": "module"
    },
    "plugins": ["@typescript-eslint", "vue"],
    "rules": {
        "comma-dangle": [
            "error",
            {
                "arrays": "always-multiline",
                "objects": "always-multiline"
            }
        ],
        "object-curly-spacing": ["error", "always"],
        "array-bracket-spacing": ["error", "never"],
        "vue/multi-word-component-names": "off",
        "@typescript-eslint/no-unused-vars": "off"
    }
}
岩淵夕希物智岩淵夕希物智

Nuxt Content v2を導入

npm install @nuxt/content

nuxt.config.ts のmodulesに @nuxt/content を追加

nuxt.config.ts
  devtools: ...,
  build: ...,
  modules: ["@nuxt/content"],
})

pages フォルダの post フォルダに [...slug].vue を追加

[...slug].vue
<template>
    <v-main>
        <v-container>
            <v-row>
                <v-col>
                    <ContentDoc />
                </v-col>
            </v-row>
        </v-container>
    </v-main>
</template>

初期状態では ContentDoc の指定だけで読み込んでくれるお手軽設定だけれど、以降カスタマイズでもう少し複雑になる可能性あり

実際に表示するMarkdownを配置すれば完成

content フォルダを作成し、 post/hello.md を配置。

content/hellow.md
# Hello

Hello, world!

できたー!

(前回やったときはここのつなぎ込みがなかなか成功せず大苦戦)

岩淵夕希物智岩淵夕希物智

過去記事表示 ミニマル

pages/index.vue
<template>
    <v-main>
        <ContentList path="/" v-slot="{ list }">
            <v-container>
                <v-row>
                    <v-col cols="12" sm="6" v-for="post in list" :key="post._path">
                        <v-card>
                            <v-sheet color="primary">
                                <v-card-title>
                                    <nuxt-link :to="`${post._path}/`" :style="{ 'color': 'inherit', 'text-decoration': 'inherit' }">
                                        <h2 class="text-subtitle-1 text-truncate" v-text="post.title"></h2>
                                    </nuxt-link>
                                </v-card-title>
                            </v-sheet>
                            <nuxt-link :to="`${post._path}/`">
                                <v-img
                                    v-if="post.hero"
                                    :src="`/img/post/${post._path.split('/')[2]}/hero.png`"
                                    :aspect-ratio="8 / 5"
                                ></v-img>
                            </nuxt-link>
                            <v-card-text data-v-card-text>
                                {{ post.description || post.title }}
                                <nuxt-link :to="`${post._path}/`" :style="{ 'text-decoration': 'inherit' }">
                                    [記事を読む]
                                </nuxt-link>
                            </v-card-text>
                        </v-card>
                    </v-col>
                </v-row>
            </v-container>
        </ContentList>
    </v-main>
</template>

岩淵夕希物智岩淵夕希物智

過去記事一覧 改良

pages/index.vue
<script setup lang="ts">
import type { QueryBuilderParams } from '@nuxt/content/dist/runtime/types'
const query: QueryBuilderParams = { path: '/post/', only: ['_id', '_path', 'title', 'date', 'hero', 'description'], sort: [{ date: -1 }] }
</script>

<template>
    <v-main>
        <ContentList :query="query">
            <template #default="{ list }">
                <v-container>
                    <v-row>
                        <v-col lg="8">
                            <v-row>
                                <v-col cols="12" sm="6" v-for="post in list" :key="post._path">
                                    <v-card>
                                        <v-sheet color="primary">
                                            <v-card-title>
                                                <nuxt-link :to="`${post._path}/`" :style="{ 'color': 'inherit', 'text-decoration': 'inherit' }">
                                                    <h2 class="text-subtitle-1 text-truncate" v-text="post.title"></h2>
                                                </nuxt-link>
                                            </v-card-title>
                                        </v-sheet>
                                        <nuxt-link
                                            v-if="post.hero"
                                            :to="`${post._path}/`"
                                        >
                                            <v-img
                                                v-if="post.hero"
                                                :src="`/img/post/${post._path.split('/')[2]}/hero.png`"
                                                :aspect-ratio="8 / 5"
                                                cover
                                            ></v-img>
                                        </nuxt-link>
                                        <v-card-text v-if="post.description">
                                            {{ post.description }}
                                            <nuxt-link :to="`${post._path}/`" :style="{ 'text-decoration': 'inherit' }">
                                                [記事を読む]
                                            </nuxt-link>
                                        </v-card-text>
                                    </v-card>
                                </v-col>
                            </v-row>
                        </v-col>
                    </v-row>
                </v-container>
            </template>
            <template #not-found>
                <p class="ma-5">コンテンツが見つかりません</p>
            </template>
        </ContentList>
    </v-main>
</template>
岩淵夕希物智岩淵夕希物智

日付を扱いやすくするluxonの導入

npm install luxon
formatDate = (date: string) => {
    return DateTime.fromISO(date).setZone("Asia/Tokyo").toLocaleString(DateTime.DATE_FULL)
}

これを各テンプレート内で都度定義してもいいけれど、どこでも使えるようにするためutilプラグインを作っておく(一度作ってしまえば何かと便利)

plugins フォルダに util.ts を作る

plugins/util.ts
import { DateTime } from 'luxon'

export interface MyPluginInterface {
    formatDate(date: string): string
}

class MyPlugin implements MyPluginInterface {
    formatDate(date: string) {
        return DateTime.fromISO(date).setZone('Asia/Tokyo').toLocaleString(DateTime.DATE_FULL)
    }
}

export default defineNuxtPlugin(nuxtApp => {
    return {
        provide: {
            util: new MyPlugin(),
        },
    }
})

使うときはこう

<script setup lang="ts">
import { useNuxtApp } from "nuxt/app"

const {
    $util: { formatDate },
} = useNuxtApp()
</script>

<template>
...
<div>投稿日: <time :datetime="item.date">{{ formatDate(item.date) }}</time></div>
</template>

追記

nuxt devでは正常に動いていてnuxt generateでエラーが出るよくわからないバグに遭遇して時間を費やしましたが、既存実装ではうまく動いていたutilのプラグインが原因っぽいところまではわかりました。

よく調べてみると、どうやらユーティリティー関数は専用の機構があるそうです。

utils フォルダを作る

utils/index.ts
import { DateTime } from 'luxon'

export const formatDate = (date: string) => {
    return DateTime.fromISO(date).setZone('Asia/Tokyo').toLocaleString(DateTime.DATE_FULL)
}

これだけ。読み込みは自動で行われるみたい。楽ちんだ!

岩淵夕希物智岩淵夕希物智

記事表示部のページを単独テンプレートで作ろうとしていたけれど、次へ/前へのナビゲーションを追加するにあたってqueryContentのfindSurroundに指定するパスがうまく取得できなかった(やりようはありそう)ので、記事表示部の独立レイアウトを作ることに。

pages/post/[...slug].vue
<script setup lang="ts">
import ArticleShow from "@/layouts/article-show.vue"
</script>

<template>
    <v-main>
        <v-container>
            <v-row>
                <v-col sm="8" lg="6">
                    <ContentDoc>
                        <template #default="{ doc }">
                            <ArticleShow :item="doc"></ArticleShow>
                        </template>
                        <template #not-found>
                            <p>
                                コンテンツが見つかりません
                            </p>
                        </template>
                    </ContentDoc>
                </v-col>
            </v-row>
        </v-container>
    </v-main>
</template>

呼び出し側はこれだけ。

そしてレイアウト側がこう。

layouts/article-show.vue
<script setup lang="ts">
import { useNuxtApp, useRouter } from "nuxt/app"
import { toRefs } from "vue"

const {
    $util: { formatDate },
} = useNuxtApp()

const props = defineProps({
    item: { type: Object, required: true },
})

const { item } = toRefs(props)

const [next, prev] = await queryContent(`/post/`)
    .only(["_id", "_path"])
    .sort({ date: -1 })
    .findSurround(item.value._path)
</script>

<template>
<article>
    <header>
        <slot name="header">
            <v-row class="mb-3">
                <v-col cols="4">
                    <v-btn class="w-100" v-if="next" color="accent" :href="next._path">
                        <v-icon>mdi-chevron-left</v-icon>次の記事
                    </v-btn>
                    <v-btn class="w-100" v-else color="accent" disabled>
                        <v-icon>mdi-chevron-left</v-icon>次の記事
                    </v-btn>
                </v-col>
                <v-col cols="4">
                    <v-btn class="w-100" color="accent" href="/">
                        <v-icon>mdi-view-list</v-icon>&nbsp;記事一覧
                    </v-btn>
                </v-col>
                <v-col cols="4">
                    <v-btn class="w-100" v-if="prev" color="accent" :href="prev._path">前の記事
                        <v-icon>mdi-chevron-right</v-icon>
                    </v-btn>
                    <v-btn class="w-100" v-else color="accent" disabled>前の記事
                        <v-icon>mdi-chevron-right</v-icon>
                    </v-btn>
                </v-col>
            </v-row>
            <h1 class="text-h5">{{ item.title }}</h1>
            <div>投稿日: <time :datetime="item.date">{{ formatDate(item.date) }}</time></div>
        </slot>
    </header>
    <article class="mt-3">
        <slot name="item">
            <v-card>
                <v-card-text>
                    <ContentRenderer :value="item">
                        <template #not-found>
                            <h2>
                                Article slug ({{ $route.params.slug }}) not found
                            </h2>
                        </template>
                    </ContentRenderer>
                </v-card-text>
            </v-card>
        </slot>
    </article>
    <footer>
        <slot name="footer">
            <v-row class="mt-3">
                <v-col cols="4">
                    <v-btn class="w-100" v-if="next" color="accent" :href="next._path">
                        <v-icon>mdi-chevron-left</v-icon>次の記事
                    </v-btn>
                    <v-btn class="w-100" v-else color="accent" disabled>
                        <v-icon>mdi-chevron-left</v-icon>次の記事
                    </v-btn>
                </v-col>
                <v-col cols="4">
                    <v-btn class="w-100" color="accent" href="/">
                        <v-icon>mdi-view-list</v-icon>&nbsp;記事一覧
                    </v-btn>
                </v-col>
                <v-col cols="4">
                    <v-btn class="w-100" v-if="prev" color="accent" :href="prev._path">前の記事
                        <v-icon>mdi-chevron-right</v-icon>
                    </v-btn>
                    <v-btn class="w-100" v-else color="accent" disabled>前の記事
                        <v-icon>mdi-chevron-right</v-icon>
                    </v-btn>
                </v-col>
            </v-row>
        </slot>
    </footer>
</article>
</template>
岩淵夕希物智岩淵夕希物智
{
    "manga-anime-game": "マンガ・アニメ・ゲーム",
    "kimikoe": "キミコエ",
    "prime-qk": "素数大富豪",
    "mahjong": "麻雀",
    "music": "音楽",
}

このようにタグのslugとタグ名の対応リスト作っておき、Markdownのfrontmatterに

---
tagArr:
    - prime-qk
    - mahjong
---

のように入れておくと

layouts/article-show.vue
<script setup lang="ts">
const { data: tagLi } = await useAsyncData('tag-list', _ => queryContent('/tag/').findOne())
</script>

<template>
...
<v-row>
    <v-col>
        <v-chip v-for="tag in item.tagArr" :key="tag" class="mr-1">{{ tagLi[tag] }}</v-chip>
    </v-col>
</v-row>

とすればタグが表示できる。

岩淵夕希物智岩淵夕希物智

他にもやったこと&やりたいことはあるけれど、とりあえずアップグレードに関することはひととおり終えたのでこれでいったん閉じておきます。

このスクラップは2024/03/25にクローズされました