個人ブログをNuxt 3にアップグレードする
Vue + Nuxt + Vuetifyで作っていたのを2から3にアップグレード
久しぶりにプロジェクトをyarn devしてみようとして動かないと思ったら、移行する途中で止めてたみたいでいっそのことまっさらなところから作り直す過程をまとめてみます。
Get Started ここからはじまる
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.json
の name
もあとで直す)
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文を自動追加して回避。
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フォルダを作って退避
動いた!
次はVuetifyの導入。
npm install vuetify
Material Design Iconsも同時に導入。
npm install @mdi/font
devtools: ... // ここの下にbuild: ...を入れる
build: {
transpile: ['vuetify'],
},
↑追加
プロジェクトルートにpluginsという名前のフォルダを作り、 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
を書き換え
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
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
ファイルを配置。
<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
を書き換えて、ライトテーマとダークテーマを適用。
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から選んだ方がはっきりするのでこちらを使う。
v-app-bar
に color="primary"
を設定し、
defaultTheme: "light"
と defaultTheme: "dark"
を見比べてみる
コードフォーマットをきれいに保つように、 .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
を追加
devtools: ...,
build: ...,
modules: ["@nuxt/content"],
})
pages
フォルダの post
フォルダに [...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
を配置。
# Hello
Hello, world!
できたー!
(前回やったときはここのつなぎ込みがなかなか成功せず大苦戦)
過去記事表示 ミニマル
<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>
過去記事一覧 改良
<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
を作る
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
フォルダを作る
import { DateTime } from 'luxon'
export const formatDate = (date: string) => {
return DateTime.fromISO(date).setZone('Asia/Tokyo').toLocaleString(DateTime.DATE_FULL)
}
これだけ。読み込みは自動で行われるみたい。楽ちんだ!
記事表示部のページを単独テンプレートで作ろうとしていたけれど、次へ/前へのナビゲーションを追加するにあたってqueryContentのfindSurroundに指定するパスがうまく取得できなかった(やりようはありそう)ので、記事表示部の独立レイアウトを作ることに。
<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>
呼び出し側はこれだけ。
そしてレイアウト側がこう。
<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> 記事一覧
</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> 記事一覧
</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
---
のように入れておくと
<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>
とすればタグが表示できる。
Xのポスト埋め込みはこちらが参考になった
<script setup lang="ts">
declare global {
interface Window {
twttr: any
}
}
onMounted(() => {
if (process.client) {
window!.twttr!.widgets!.load()
}
})
</script>
他にもやったこと&やりたいことはあるけれど、とりあえずアップグレードに関することはひととおり終えたのでこれでいったん閉じておきます。