今年 2025 年の 1 月にメジャーバージョンの v3 がリリースされたので、メジャーバージョンの v2 を利用しているこのブログでも v3 へマイグレートしました。(参考: offich.me をささえる技術)
Announcing Nuxt Content version 3
https://content.nuxt.com
予想以上に詰まったことが多く、せっかくなら作業ログを残す形で、同じく現在 v2 を利用していて v3 へマイグレートする方へのヒントになるような記事を目指します。
そもそも Nuxt Content
とは、Nuxt 用のヘッドレス CMS ライクなコンテンツ管理モジュールです。Markdown(.md)、JSON(.json)、YAML(.yml)、CSV(.csv)などのファイルを簡単に管理・表示できます。それらのファイルの内容をそのままページコンテンツにでき、検索やフィルタ、ソート機能などコンテンツを管理する方法を多く提供しています。マークダウンの中でカスタム Vue コンポーネントを呼び出すことができ、文章だけではなくリッチなコンテンツを構築できます。そのため、ブログや企業公式サイトで使われることが多いです。
マイグレーションガイドを読んで、v2 と比較しての一番の大きな変更点はコンテンツのデータの管理と取得方法です。
Announcing Nuxt Content version 3
https://content.nuxt.com
SQL Storage - Nuxt Content
https://content.nuxt.com
今までの nuxt content v2 は、コンテンツ管理をストレージシステムをファイルベースとしており、データについてはすべてプロジェクトの中で完結するのが魅力でした。それぞれのコンテンツに対してキャッシュファイルを作成するのですが、必然とすべてのキャッシュファイルをロードすることになり、プロダクション環境での I/O が大幅になっていました。
そのため、多くのコンテンツを持つプロジェクトだと、リクエストの度にゼロからサーバーを起動するサーバーレス環境とは相性が悪かったみたいです。状態を持たないサーバーレス環境の中でサーバー内のメモリやファイルベースのデータベースは使えないので、v3 からは永続化されたデータベースからデータを取得する変更にしました。
今回 v3 では、AST へ変換したコンテンツを事前に定義したスキーマと沿っているかを確かめるのを兼ねて、テーブルに入れ、dump ファイルに落とし込みます。そして、コードスタート時にその dump ファイルをデータベースへデータをリストアするとのことです。コンテンツはもちろん最新のものだったり、重複を弾くなどの工夫はしているようです。
このデータベースにデータをリストアするアプローチがクライアントのナビゲーションでも応用されていることについて魅力的に感じました。最初クエリが発行されたときに、dump ファイルを取得し、ブラウザ内のデータベースへデータをリストアすることで、サーバーへの通信がなくなります。これにより、データ取得がクライアント内で完結します。つまり、レスポンスも早くなり、オフライン環境でもコンテンツを見ることが可能になります。
マイグレーションガイド自体は nuxt content の公式サイトにあるので、まずはそれに目を通すことを推奨します。v2 のころから提供されているコンポーネントのリネームや削除、上記に記載したようにコンテンツが SQL データベースで保存されることから、取得 API の名前や方法がまるっと変更されています。
The git-based CMS for Nuxt projects.
https://content.nuxt.com
以下には、このブログにおいて印象だった変更をまとめます。
v3 からコンテンツを関連したものを Collection
でグルーピングするという考えができました。これからコンテンツをデータベースで管理するというのも相まって、それぞれのグループのスキーマを定義します。その定義をするために、v3 から content.config.ts
というファイルをルートに置く必要があります。
以下はこのブログでの内容です。
import path from 'path'
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
posts: defineCollection({
type: 'page',
source: {
cwd: path.resolve('src/content'),
include: '**',
},
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.string(),
updatedAt: z.string(),
categories: z.array(z.string()),
draft: z.boolean(),
secret: z.boolean(),
priority: z.number(),
}),
}),
},
})
zod
を使ってコンテンツの型を定義できるようになったのは、非常に便利だと感じました。今までだと自分で interface を定義して、queryContent
に型情報を渡して初めて、型が付与されたのですが最初から型が付与される形となりました。
Nuxt content ではデフォルトでコンテンツの保管場所を content
という名前で Nuxt プロジェクトのルートディレクトリの中を指定しています。しかし、その保管場所を変更でき、source
プロパティにその保管場所を伝えることで可能になります。このブログでは、コンテンツの場所は src/content
ディレクトリになっているので、source
プロパティの cwd
を書き換えています。ここで注意すべきなのは、cwd
プロパティには 絶対パス を渡すことです。最初 Nuxt プロジェクトの相対パスを渡していましたが、dev server 起動中の hot reload が効かなくて、数時間溶かしました。コンテンツの中身に変更があったときは、以下のようなログが出力されます。
[@nuxt/content 18:02:45] ℹ File tech/nuxt-content-v3-migration.md changed on posts collection
Collection の定義が書けたら、nuxi prepare
コマンドを実行して、型を生成します。これからコンテンツについてのプロパティ参照するときは、PostsCollectionItem
からプロパティを選択します。
import type { PageCollectionItemBase, DataCollectionItemBase } from '@nuxt/content'
declare module '@nuxt/content' {
interface PostsCollectionItem extends PageCollectionItemBase {
title: string;
description: string;
publishedAt: string;
updatedAt: string;
categories: string[];
draft: boolean;
secret: boolean;
priority: number;
}
interface PageCollections {
posts: PostsCollectionItem
}
interface Collections {
posts: PostsCollectionItem
}
}
markdown
に関する設定を build
プロパティでラップするようにマイグレーションガイドには特に記載はなかったのですが、nuxt.config.ts
に書くマークダウンに関する設定たちを build
プロパティでラップします。
diff --git a/nuxt.config.ts b/nuxt.config.ts
index c1858fd..c8995e4 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -42,45 +42,45 @@ export default defineNuxtConfig({
},
content: {
- markdown: {
- rehypePlugins: [
- [
- 'rehype-external-links', {
+ build: {
+ markdown: {
+ rehypePlugins: {
+ 'rehype-external-links': {
target: '_blank',
rel: 'nofollow noopener noreferrer',
},
- ],
- ],
- },
+ },
- highlight: {
- theme: {
- default: 'material-theme',
- },
+ highlight: {
+ theme: {
+ default: 'material-theme',
+ },
- langs: [
- 'json',
- 'js',
- 'ts',
- 'html',
- 'css',
- 'vue',
- 'shell',
- 'mdc',
- 'md',
- 'yaml',
- 'dart',
- 'xml',
- 'csv',
- 'ruby',
- 'go',
- 'diff',
- 'docker',
- 'dotenv',
- 'python',
- 'scss',
- 'sql',
- ],
+ langs: [
+ 'json',
+ 'js',
+ 'ts',
+ 'html',
+ 'css',
+ 'vue',
+ 'shell',
+ 'mdc',
+ 'md',
+ 'yaml',
+ 'dart',
+ 'xml',
+ 'csv',
+ 'ruby',
+ 'go',
+ 'diff',
+ 'docker',
+ 'dotenv',
+ 'python',
+ 'scss',
+ 'sql',
+ ],
+ },
+ },
},
},
取得したコンテンツの内部プロパティは接頭語を外したものにリネームされました。このブログでは記事へのパスを参照しているので、プロパティを _path
から path
に変更しました。
diff --git a/src/components/post/PostCard.vue b/src/components/post/PostCard.vue
index f119d51..085f0c1 100644
--- a/src/components/post/PostCard.vue
+++ b/src/components/post/PostCard.vue
@@ -9,7 +9,7 @@ const props = defineProps<{
}>()
const thumbnailPath = computed(() => {
- return `/content/${props.content._path?.split('/')[2]}/thumbnail.png`
+ return `/content/${props.content.path?.split('/')[2]}/thumbnail.png`
})
</script>
@@ -21,7 +21,7 @@ const thumbnailPath = computed(() => {
<ULink
:tabindex="-1"
:aria-hidden="true"
- :to="`/posts${content._path}`"
+ :to="`/posts${content.path}`"
class="absolute inset-0"
/>
表示するコンテンツを変更するには定義した Collection
から動的に取得します。queryContent
から queryCollection
にリネームされ、collection を指定することで使えます。MongoDB
のような NoSQL ベースのクエリから、SQL ベースのクエリに変更されました。
const fetchAllCountByCategories = async (): Promise<number> => {
- return queryContent<CustomParsedContent>()
- .where({
- categories: { $contains: cateogryParams.value },
- draft: { $not: true },
- secret: { $not: true },
- })
+ return queryCollection('posts')
+ .where('categories', 'LIKE', `%${cateogryParams.value}%`)
+ .andWhere((query) => query.where('draft', 'IS NULL').where('secret', 'IS NULL'))
.count()
}
nitro のサーバー側でもリネームの変更を受け、同じく queryCollection
を使います。このブログでは、Google Search Console に sitemap を登録しており、nitro を利用して sitemap.xml を返却するエンドポイントを用意しています。
diff --git a/src/server/routes/sitemap.xml.ts b/src/server/routes/sitemap.xml.ts
index ab867c1..050a821 100644
--- a/src/server/routes/sitemap.xml.ts
+++ b/src/server/routes/sitemap.xml.ts
@@ -1,13 +1,10 @@
import { join } from 'path'
-import { defineEventHandler } from '#imports'
+import { defineEventHandler, queryCollection } from '#imports'
import { SitemapStream, streamToPromise } from 'sitemap'
-import { serverQueryContent } from '#content/server'
-import type { CustomParsedContent } from '~/types/content'
-
export default defineEventHandler(async (event) => {
- const docs = await serverQueryContent<CustomParsedContent>(event).find()
+ const docs = await queryCollection(event, 'posts').all()
const sitemap = new SitemapStream({
hostname: 'https://offich.me',
lastmodDateOnly: true,
コンテンツの前後のものを取得する findSurround
も queryCollectionItemSurroundings
という API と使って取得します。
diff --git a/src/pages/posts/[...slug].vue b/src/pages/posts/[...slug].vue
index cfde233..02e684f 100644
--- a/src/pages/posts/[...slug].vue
+++ b/src/pages/posts/[...slug].vue
@@ -38,9 +36,9 @@ useSeoMeta({
const { data: surround } = await useAsyncData(
`docs-${routePath.value}-surround`,
() => {
- return queryContent<CustomParsedContent>()
- .where({ _extension: 'md', navigation: { $ne: false }, draft: { $not: true }, secret: { $not: true } })
- .findSurround(routePath.value)
+ return queryCollectionItemSurroundings('posts', routePath.value, { fields: ['title', 'description'] })
+ .andWhere((query) => query.where('draft', 'IS NULL').where('secret', 'IS NULL'))
},
{ default: () => [] },
)
ProsePre
、ProseCode
、ProseCodeInline
コンポーネントの統合Nuxt content は MDC (Markdown Components) syntax を採用しており、コンテンツの内容に応じてコンポーネントが割り当てられ、HTML タグが描画されます。マークダウンの記法で、`
(1 点バッグスラッシュ) と ```
(3 点バックスラッシュ)を使うと、MDC がそれぞれインラインかブロックコードを描画します。
v3 は改めてそのコードを描画するコンポーネントたちの整理がされました。Nuxt content は @nuxtjs/mdc
が提供しているコード描画のコンポーネント(= Prose Components) を使用しています。components/content
ディレクトリに同じ名前のコンポーネントを作成することで、どう描画するかを自分で決められます。このとき ProseCodeInline
、ProseCode
、ProsePre
をコンポーネントを自前で用意している人はマイグレート作業が発生します。
v2 で ProseCodeInline
がただの ProseCode
のラッパーであることから、v3 ではインラインで書いてあるコードは ProseCode
と処理されます。またブロックコードは ProseCode
の代わりに、ProsePre
として処理されます。そのため、ProseCodeInline
を ProseCode
にリネームし、ProseCode
にあるロジックを ProsePre
に移動させれば、v3 へマイグレートできます。
Migration - Nuxt Content
https://content.nuxt.com
いかがだったでしょうか。Nuxt content についてのマイグレーションガイドを書きましたが、変更点が多いものとなりました。上記に書いたのはほんの一部で、場合によっては記載しなかったマイグレート作業が発生するので、注意が必要です。
特にデータベースの準備は破壊的変更だと感じました。幸いこのブログは SSG でビルドしている(Static Hosting)ので、データベースを用意する必要はありませんでしたが、大きな作業かと感じるのでリリースする前に動作検証をおすすめします。
v3 がリリースされたのは 1 月ですが、3 月末の現在でも新しいバージョンが次々とリリースされています。個人的にはブログを完成させてくれた技術というのもあり、これから使い続けるので、何かまた変更があれば、このブログでまとめたいと考えています。