ymmooot

@nuxt/content でタグに紐づく記事数を数えておく

環境

  • @nuxt/content: 3.0.1
  • nuxt: 3.15.4

概要

@nuxt/content は Markdown の先頭に、以下のような形式で情報を付与できる。

---
title: "ポラーノの広場"
description: "そのころわたくしは、モリーオ市の博物局に勤めて居りました。"
published_at: "2025/2/4 15:00"
tags: ["イーハトーブ", "ヤギ"]
---

この時、この tags に設定されているすべてのタグを https://{siteDomain}/tags/ などで一覧表示する際に、そのタグに何記事ずつ紐づいているかを表示したい。
そのために、ビルド時のフックを利用して記事の数を数え、結果を json としてファイルに書き出して保存しておく。

hooks

@nuxt/content はフックを利用して、ビルド時に何かしらの処理を行うことができる。
執筆時点で用意されているフックは二つで、ファイルをパースする前と後に呼ばれるものが用意されている。


以下のように利用できる。

import type { FileBeforeParseHook, FileAfterParseHookv } from '@nuxt/content';

defineNuxtConfig({
  hooks: {
    'content:file:beforeParse': (ctx: FileBeforeParseHook) => {},
    'content:file:afterParse': (ctx: FileAfterParseHook) => {},
  },
});

参照: Hooks - Nuxt Content

これを利用してタグに紐づく記事を数える。

実装

ファイル生成を行うようなスクリプトを nuxt.config.ts に置きたくないので、modules/content.ts というモジュールを用意する。Nuxt は modules/ ディレクトリに配置したモジュールを自動的に読み込んでくれる。

参照: modules/ · Nuxt Directory Structure

雛形は以下。

modules/content.tsimport { defineNuxtModule } from "@nuxt/kit";
import type { FileAfterParseHook, FileBeforeParseHook, ModuleHooks } from "@nuxt/content";

declare module "nuxt/schema" {
  interface NuxtHooks extends ModuleHooks {}
}

export default defineNuxtModule({
  setup(options, nuxt) {
    nuxt.hook("content:file:beforeParse", (ctx: FileBeforeParseHook) => {
    });

    nuxt.hook("content:file:afterParse", (ctx: FileAfterParseHook) => {
    });
  },
});

ここに実装を追加していく。
なお、@nuxt/content はコンテンツのパース結果をキャッシュし、キャッシュヒットしたものに対してはフックが呼ばれない。そのため、フックの開発時はキャッシュを無効にするか、キャッシュを削除してからビルドする必要がある。

modules/content.tsimport { defineNuxtModule } from "@nuxt/kit";
import { promises as fs } from "fs";
import type { FileAfterParseHook, FileBeforeParseHook, ModuleHooks } from "@nuxt/content";

declare module "nuxt/schema" {
  interface NuxtHooks extends ModuleHooks {}
}

export default defineNuxtModule({
  setup(_options, nuxt) {
    let totalFiles = 0;
    let processedFiles = 0;
    const tagCount: Record<string, number> = {};

    // ファイルの数を数える
    nuxt.hook("content:file:beforeParse", async () => {
      totalFiles++;
    });

    nuxt.hook("content:file:afterParse", async (ctx: FileAfterParseHook) => {
      // ctx.content.tags のバリデーション
      const tags = validateTags(ctx.content.tags);
      
      // タグの数を数える
      tags.forEach((tag) => {
        tagCount[tag] = (tagCount[tag] || 0) + 1;
      });

      // 処理済みのファイル数を数える
      processedFiles++;
      
      // 最後のファイルの処理が終わったら、結果を保存する
      if (processedFiles === totalFiles) {
        await fs.writeFile("./app/data/tags.gen.json", JSON.stringify(tagCount, null, 2));
      }
    });
  },
});

このようにすることで、ビルド時に app/data/tags.gen.json というファイルが生成され、その中にタグに何記事ずつ紐づいているかが保存される。
*.gen.json は .gitignore に追加しておくと良い。


上に出てくる validateTagsctx.content.tagsstring[] であることを保証する。
事前にタグ一覧を作り、その中からタグを選択して付与する運用にしたい場合も、ここでバリデーションを行うことで、スペルミスや勝手にタグを追加することを防ぐことができる。


自分は以下のような app/data/tags.ts を作成し、Nuxt アプリケーション内でタグの情報を利用している。
Markdown の tags は slug で指定するようにしている。

app/data/tags.tsexport type Tag = {
  name: string,
  slug: string,
  emoji: string,
};

export const tags = [
  {name: 'コーヒー', slug: 'coffee', emoji: '☕'},
  {name: '紅茶', slug: 'tea', emoji: '🫖'},
  {name: '緑茶', slug: 'greentea', emoji: '🍵'},
  {name: 'ビール', slug: 'beer', emoji: '🍺'},
  {name: 'ワイン', slug: 'wine', emoji: '🍷'},
] satisfies Tag[];
contents/coffee-tea.md---
title: "コーヒーと紅茶の健康効果"
description: "コーヒーと紅茶の健康効果について、それぞれの含有成分に着目して解説します。"
published_at: "2025/2/4 15:00"
tags: ["coffee", "tea"]
---

まとめ

@nuxt/content を静的にホスティングしてDBを利用しない場合、このような情報を積極的に事前ビルドすることで、クライアントサイドでの処理を減らすことができる。この仕組みを応用すれば、「タグページの最終更新日」を「紐づく記事の最終更新日」として設定することで、更新が新しい順にタグを表示したりできる。


@nuxt/content のフックではなく、自前のスクリプトをビルド時に呼び出すこともできるが、こちらの方法だと Markdown の Front Matter のパース等を自分でする必要がないので横着ができる上、もし @nuxt/content のコレクションやコンテンツのパス情報が必要になった場合にフックのコンテキストから取得できる。