Nuxt Content v2でリスト、タグページやページングを実現する方法

2023-09-26
2023-09-26

本記事では主に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つになると思います。

  1. デフォルト状態で使う
    • この場合読み込まれたページのパスを自動で取得してくれてレンダリングされます(例:/posts/testにアクセスするとcontent/posts/test.mdが読み込まれる)
  2. 引数にパスを指定して使う
    • 1では自動で取得するようにしましたが、特定のページだけは別のルールで表示したいケースはContentDocに指定のパスを与えます。
  3. スロット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>

こうすると以下のようなページになります。

hoge

元の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()のように関数をつなげて用途に合ったレスポンスを得ることができます。

よく使うのは条件を記述するwherefind~です。基本的にqueryContentfind系の関数呼び出しもしくは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と同じフォルダ構造があるとうまく行かないです。今回で言うとtagspageという名前のフォルダが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には、ブログ作成に便利な多くのコンポーネントが用意されており、それぞれのコンポーネントの利用方法やカスタマイズの手法を具体的に示しました。

まだ多少不鮮明な部分もあるので後々解消していこうと思います。