Nuxt Content v2でリスト、タグページやページングを実現する方法
本記事では主にNuxt Content v2においてmarkdownファイルの中身をどのように扱うかや、ブログを構築する際によくあるページの作り方を紹介します。
内容は以前に書いた記事の続きになるので、設定などの部分で気になることがあれば以下を参照ください。
Nuxt Content v2が提供するコンポーネント
Nuxt Content v2ではブログ作成でよく使うであろうコンポーネントがいくつか用意されています。 以下が現時点(2023/9/26)でドキュメントに記載されているコンポーネントです。
<ContentDoc>
<ContentRenderer>
<ContentList>
<ContentNavigation>
<ContentQuery>
<ContentSlot>
- Prose Components
本記事ではよく使うであろうコンポーネントにのみいくつか説明します。
ContentDocの使い方
ContentDoc
コンポーネントがおそらく一番基本的なコンポーネントとなります。このコンポーネントは指定されたパスのmdファイルを読み取りHTMLにレンダリングしてくれます。
使用パターンは大きく以下の3つになると思います。
- デフォルト状態で使う
- この場合読み込まれたページのパスを自動で取得してくれてレンダリングされます(例:/posts/testにアクセスするとcontent/posts/test.mdが読み込まれる)
- 引数にパスを指定して使う
- 1では自動で取得するようにしましたが、特定のページだけは別のルールで表示したいケースは
ContentDoc
に指定のパスを与えます。
- 1では自動で取得するようにしましたが、特定のページだけは別のルールで表示したいケースは
- スロットpropsである
doc
を受け取り、ContentDoc
のタグ内でdoc
から参照可能なデータを使う。基本的にContentRederer
とセットで使います。- 少し自分で書く量が増加しますが、表示をカスタマイズするには一番やりやすいです。このブログも3の方法を採用しています。
上記は全て公式サイトに例が載ってるのでそちらをご参照ください。
参考:ContentDoc
以下は単純な例です。コード内のdoc
変数からフロントマターで設定した変数を参照できます。これと先ほどの3の仕組みを使うことでブログでよくあるパンクズリストやタグの表示を行っています。
<template>
<main class="prose">
<ContentDoc v-slot="{ doc }">
<div class="flex flex-col">
<div>{{ doc.title }}</div>
<div>{{ doc.description }}</div>
<div>{{ doc.createdAt }}</div>
</div>
<article>
<ContentRenderer :value="doc" />
</article>
</ContentDoc>
</main>
</template>
こうすると以下のようなページになります。
元のMarkdownはこちらです。
---
title: Hoge
description: '詳細'
createdAt: '2022-09-26'
---
# h1タイトル
## h2タイトル
CotentListの使い方
こちらもほぼ必須となるコンポーネントで、名前の通り記事のリスト表示を目的としたコンポーネントです。
使い方もContentDoc
と似ていて、パスの指定とスロットpropsであるlist
を受け取ることが可能です。
リスト表示はある程度デザインする必要があるので、おそらくスロットpropsを使うパターンがほとんどだと思います。
ContentDoc
と少し異なるのはquery
を引数にとれることです。リストで記事を取得する際にはさまざまな条件をつけたくなります。例えば、取得件数やソート、タグ、カテゴリーによる絞り込みなどです。
それらの条件をコンポーネントに渡す手段がquery
になります。
こちらに関しては公式に例があるのでそちらをみるでOKです。
参考:ContentList
Prose Componentsの使い方
Prose ComponentsとはマークダウンをHTMLにレンダリングする際に使用されるコンポーネントを定義したものです。
このProse Componentsはcomponents/content
配下にProse + <HTMLのタグ>
で定義することで、既存のタグを上書きできます(例:ProseH1.vue
を作るとh1タグは定義したコンポーネントが利用される)。
個々のタグの定義は少し面倒に思うかもですが、このProse ComponentsはTailwindととても相性が良く、それぞれのタグに対してTailwindで直感的にデザインできるので使いやすいです。
また、コンポーネント単位になっているのもポイントでTailwindだけでは表現しづらいこともかなり柔軟にできます。例えば、h2のタグ内にaタグを入れて見出しをリンクにしたりなどが容易にできます。
queryContentの使い方
主にスクリプト部分で多用します。これを使うことで記事データを柔軟に取り出すことができます。
公式サイトで推奨されているようにNuxt 3のuseAsyncData
で囲って使用します。
<script setup>
const { data } = await useAsyncData('home', () => queryContent('/').findOne())
</script>
queryContent
は上記にもあるfindOne()
のように関数をつなげて用途に合ったレスポンスを得ることができます。
よく使うのは条件を記述するwhere
とfind~
です。基本的にqueryContent
はfind
系の関数呼び出しもしくはcount
で終わる必要があります。
公式サイトにかなり具体的な例が載っているのでそこで大体の使い方は把握できます。
以下はフロントマターに記載されたタグを集計して数を表示する際の書き方です。こちらが実際のTagsページです。
<script setup lang="ts">
const { data: articles } = await useAsyncData('tags', () => queryContent('contents').only('tags').where({ draft: { $eq: false } }).find())
let tags = []
if (articles.value) {
tags = articles.value.filter((page) => page?.tags).flatMap((filteredPage) => { return filteredPage.tags })
}
const countedTags = tags.reduce((acc, str) => {
const found = acc.find((item: { name: any; }) => item.name === str);
if (found) {
found.count++;
} else {
acc.push({ name: str, count: 1 });
}
return acc;
}, [] as { name: string, count: number }[])
</script>
<template>
<div>
<h1>Tags</h1>
<div class="flex flex-row flex-wrap gap-3">
<div v-for="tag in countedTags" :key="tag.name">
<a :href="'tags/' + tag.name">{{ tag.name }}</a> ({{ tag.count }})
</div>
</div>
</div>
</template>
queryContent
は上記のように使いやすいのですが、多用するとパフォーマンスが悪くなるので注意です。基本的にNuxt Contentが提供するコンポーネントで実現できるのであればそちらを使った方がいいです。
よくあるエラー
自分がよく遭遇したエラーです。
[nuxt] [request error] [unhandled] [500] input.endsWith is not a function
at withTrailingSlash (./node_modules/ufo/dist/index.mjs:316:18)
at joinURL (./node_modules/ufo/dist/index.mjs:378:13)
at <anonymous> (./node_modules/@nuxt/content/dist/runtime/query/match/pipeline.mjs:57:60)
at Array.find (<anonymous>)
at fetchDirConfig (./node_modules/@nuxt/content/dist/runtime/query/match/pipeline.mjs:57:30)
at <anonymous> (./node_modules/@nuxt/content/dist/runtime/query/match/pipeline.mjs:90:63)
at Array.reduce (<anonymous>)
at <anonymous> (./node_modules/@nuxt/content/dist/runtime/query/match/pipeline.mjs:90:39)
at <anonymous> (./node_modules/@nuxt/content/dist/runtime/server/api/query.mjs:13:21)
at async Object.handler (./node_modules/h3/dist/index.mjs:1630:19)
こちらの原因は自分の場合、useRoute().path
で取得したパスをもとにwhere
で_path
を指定した絞り込みをした際に該当のパスがサイト内に存在しないと発生します。
解決方法としてはwhere
に渡すパスが存在することを事前に確認するかエラーハンドリングです。
ページングの実装
今回の実装で結構悩みました。最適ではないかもですが、現状の構成を紹介します。
本ブログのページングではページごとにページ番号に応じたページに飛ぶように実装しました。これを実装するためには以下のようにpages
ディレクトリを構成します。
pages
├── [...slug].vue
├── page
│ ├── [id].vue
│ └── index.vue
└── tags
├── [id].vue
└── index.vue
tagsはなくても大丈夫です。このようにすることでページ番号ごとに/page/1
のようなパスで遷移できます。
[...sulg].vue
というファイル名はCatch-all Routeと呼ばれ、配下の全てのルートとマッチするようになります。
ここで少し疑問に思うのは、[...sulg].vue
と同じ階層にある他のpageがちゃんと機能するかです。今のところpageが存在する部分についてはちゃんとpageの方を優先してくれているようですが正しいのかどうか。
ちなみにcontent
配下にpages
と同じフォルダ構造があるとうまく行かないです。今回で言うとtags
やpage
という名前のフォルダがcontent
フォルダにあると意図しない動作になります。
ページング用のコンポーネントは以下になります。
<script setup lang="ts">
interface Props {
currentPage: number
perPage: number
pageNum: number
totalCount: number
}
const pagePath = '/page/'
const props = withDefaults(defineProps<Props>(), {
currentPage: 1,
perPage: 10,
pageNum: 1,
totalCount: 1
})
const pageCounts = computed(() => {
if (props.totalCount !== 1) {
return Math.floor(props.totalCount / props.perPage)
}
else {
return props.totalCount
}
})
</script>
<template>
<nav aria-label="Page navigation">
<ul class="inline-flex -space-x-px text-sm list-none p-0">
<li v-if="currentPage > 1">
<NuxtLink :to="pagePath + (currentPage - 1)"
class="flex items-center justify-center px-3 h-8 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-100 hover:text-gray-700 ">
Prev</NuxtLink>
</li>
<li v-for="page in pageCounts">
<NuxtLink v-if="page < 3 || page > pageCounts - 2 || currentPage === page" :to="pagePath + page"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 border border-gray-300 hover:bg-gray-100 hover:text-gray-700 d"
:class="{
'font-bold': currentPage === page,
'bg-gray-100': currentPage === page
}">
{{
page }}</NuxtLink>
<span v-else class=" line-clamp-1">.</span>
</li>
<li v-if="currentPage < pageCounts">
<NuxtLink :to="pagePath + (currentPage + 1)"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-100 hover:text-gray-700 ">
Next</NuxtLink>
</li>
</ul>
</nav>
</template>
基本的に呼び出し元で必要な値を計算して渡す形にしています。呼び出し元で計算が必要になるのはトータルのページ数です。これは上で紹介したqueryContent
で計算できます。
以下が呼び出し元のリスト表示コンポーネントです。
<script setup lang="ts">
import { QueryBuilderParams } from '@nuxt/content/dist/runtime/types';
const route = useRoute()
const routeName = route.path
const appConfig = useAppConfig()
const { data: pageCount } = await useAsyncData('page', () => queryContent('contents').count())
const perPage = appConfig.siteMetadata.perPage
const pageNum = 1
let totalArticleCount = 0
if (pageCount.value) {
totalArticleCount = pageCount.value
}
const listQuery: QueryBuilderParams = {
path: '/contents',
where: [
{ title: { $ne: 'All Posts' } },
{ draft: { $eq: false } }
],
skip: (pageNum - 1) * perPage,
limit: perPage,
sort: [{ createdAt: -1 }]
}
</script>
<template>
<ProseH1>Latest</ProseH1>
<ContentList :query="listQuery">
<template #default="{ list }">
<div v-for="article in list " :key="article._path" class="my-5">
<Card v-if="article.title" :title="article.title" :description="article.description" :path="article._path"
:tags="article.tags" :created-at="article.createdAt" :updatedAt="article.updatedAt">
</Card>
</div>
</template>
<template #not-found>
<p>No articles found.</p>
</template>
</ContentList>
<div class="flex justify-center">
<Pagenation :currentPage="pageNum" :perPage="perPage" :pageNum="pageNum" :tag="routeName"
:totalCount="totalArticleCount">
</Pagenation>
</div>
</template>
まとめ
本記事では、Nuxt Content v2を使用したブログの構築方法について詳細に解説しました。Nuxt Content v2には、ブログ作成に便利な多くのコンポーネントが用意されており、それぞれのコンポーネントの利用方法やカスタマイズの手法を具体的に示しました。
まだ多少不鮮明な部分もあるので後々解消していこうと思います。