はじめに
最近、個人的にNuxt3を使って趣味の範疇でWebサービスを作ったりしています。サイト内のドキュメントをマークダウン形式でカジュアルに書きたいと思いNuxt Contentを触ることがあったので、備忘録のためにメモに残しておきたいと思います。
Nuxt Content
Nuxt ContentのGitHubの評価は以下のとおりとなっています。
| Package Name | GitHub Stars | GitHub Link |
| @nuxt/content | 2.9k | https://github.com/nuxt/content |
(2024/2/11時点、日本時間)
Nuxt Contentは、MarkdownやJSON、YAML、CSVで書かれたドキュメントをページコンテンツとして表示することができるNuxt上で動作するライブラリです。Nuxt Contentは、v1.0.0がMay 22、2020となっており本記事執筆時点で4年弱となるプロジェクトとなっています。コミット履歴を見ると現在もアクティブに開発されていることが分かります。NuxtのOfficialモジュールでもあります。
使用方法
ここからは、Nuxt Contentの使用方法について見ていきます。
最初に、サンプルを動かす環境について記述します。
環境
- mac OS Catalina 10.15.7
- NodeJS 20.11.0
- nuxt@3.10.1
- @nuxt/content@2.11.0
インストール
まずは、テンプレートプロジェクトを作成しVSCodeでプロジェクトを開きます。
npx nuxi@latest init content-app -t content cd content-app code .
構成
プロジェクトを開くとサンプルドキュメントが2ファイルあることが分かります。以下のような構成になっています。以下関連のあるディレクトリにのみフォーカスします。
$ tree content pages content ├── about.md └── index.md pages └── [...slug].vue 0 directories, 3 files
app.vue
<template>
<div>
<NuxtPage />
</div>
</template>
nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['@nuxt/content']
})
pages/[...slug].vue
<template>
<main>
<ContentDoc />
</main>
</template>
テンプレートプロジェクトを起動
pnpm dev
サンプルプロジェクトを起動すると、以下のようにindexページとaboutページを見ることができます。ページにアクセスする際には拡張子は不要です。
http://localhost:3000

Nuxt Contentでは、contentというディレクトリにドキュメントを格納する規約になっています。これはconfiguratioinで変えることも可能です。
テンプレートの例では、pagesでcatch-all routeの設定をしています。全てのページアクセスをcatch-all routeのページコンポーネントが処理し、そしてContentDocコンポーネントが処理します。ContentDocコンポーネントは、ContentQueryとContentRendererコンポーネントを内部で呼び出し、要求されたページリクエストに対する結果を返します。
ContentDocコンポーネント
最もベーシックであろうContentDocコンポーネントを見ていきます。
404のページ処理(#not-found)
ContentDocのnot-foundスロットが使えます。以下のようにするとページが見つからない場合の表示を変えることができます。
pages/[...slug].vue
<template>
<main>
<ContentDoc>
<template #not-found>
<h1>404 - Page Not Found</h1>
</template>
</ContentDoc>
</main>
</template>
空ページの表示(#empty)
ContentDocのemptyスロットで可能です。ドキュメントが空の場合にデフォルト表示を設定できます。
pages/[...slug].vue
<template>
<main>
<ContentDoc>
<template #not-found>
<h1>404 - Page Not Found</h1>
</template>
<template #empty> empty </template>
</ContentDoc>
</main>
</template>
独自のHTMLの定義
デフォルトスロットで可能です。
pages/[...slug].vue
<template>
<main>
<ContentDoc>
<template #not-found>
<h1>404 - Page Not Found</h1>
</template>
<template #empty> empty </template>
<template #default="{ doc }">
<article>
<h1>{{ doc.title }}</h1>
<ContentRenderer :value="doc" />
</article>
</template>
</ContentDoc>
</main>
</template>
content/test.md
--- title: test --- test body

Props
path
指定したパスのドキュメントを返します。デフォルトは、$route.pathとなっているためpathのドキュメントが動的に返るようになっています。以下ではabout.mdに固定しています。
pages/[...slug].vue
<template>
<main>
<ContentDoc path="/about">
</ContentDoc>
</main>
</template>
excerpt
サマリの表示をするか否かを設定します。
content/test.md
--- title: test --- test body <!--more--> more page
pages/[...slug].vue
<template>
<main>
<ContentDoc :excerpt="true"> </ContentDoc>
</main>
</template>

more以降が表示されません。falseにすると表示されます。
query
content/test.md
--- title: "Title of the page" description: "meta description of the page" categories: - nuxt --- test.md
pages/[...slug].vue
queryContentの条件を指定します。条件式はmongo queryを参考にします。

<template>
<main>
<ContentDoc
:path="/"
:query="{
where: {
categories: { $contains: ['nuxt'] },
},
}"
>
<template #not-found>
<p>No authors found.</p>
</template>
</ContentDoc>
<button @click="refresh()">Refresh</button>
{{ contents }}
</main>
</template>
<script setup>
const contents = ref();
async function refresh() {
const data = await queryContent("/")
.only(["_path", "title"])
.where({
title: { $contains: ["the"] },
})
.find();
contents.value = data;
}
</script>
head
falseにするとuseHead Composableとのバインディングがされず、例えばtitleが紐付けされなくなります。
ドキュメントの順序
ドキュメントは、ファイル名の先頭に数値.とつけることで、優先順位付けができます。
1.first.md 2.second.md
ドキュメントに対するクエリでsortすることができます。
const data = await queryContent("/").sort({ _id: 1 }).find();
YAML、JSON、CSV
YAML、JSON、CSVについてはクエリの結果を受け取れます。YAMLで試してみます。
content/aa.yml
title: Hello Yaml description: Yaml description category: content
ContentDocコンポーネントの出力は以下のようになりました。ContentDocが呼び出しているContentRendererコンポーネントは、ContentRendererMarkdownを使用します。パースした結果のASTがrendererの形式にマッチしていない場合、fallbackされjson形式の文字列が返ってくるようです。
{
"message": "You should use slots with <ContentRenderer>",
"value": {
"_path": "/aa",
"_dir": "",
"_draft": false,
"_partial": false,
"_locale": "",
"title": "Hello Yaml",
"description": "Yaml description",
"category": "content",
"_id": "content:aa.yml",
"_type": "yaml",
"_source": "content",
"_file": "aa.yml",
"_extension": "yml"
},
"excerpt": false,
"tag": "div"
}
画像の表示
画像等はpublicディレクトリの下に置けばマークダウンの形式で参照できます。
content/test.md
--- title: "Title of the page" --- 画像 
$ tree public/
public/
├── favicon.ico
└── img
└── avatar.png
1 directory, 2 files

コードハイライト
nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ["@nuxt/content"],
content: {
highlight: {
theme: "github-light", // GitHubライクなテーマに設定
},
},
});
content/test.md
--- title: "Title of the page" ---javascript function HelloWorld() { console.log("HelloWorld"); }
以下のようにコードハイライトされました。

日本語ファイル名
_path属性を定義することで表示させることができました。_path属性を指定しないと404になってしまいます。
--- _path: /こんにちは --- # 🎨 こんにちは
ページのデザイン
マークダウンのドキュメントではコンポーネント呼び出しが可能です。アクションやデザインを独自に適用したい場合に使えます。
また、Prose Componentsというマークダウン形式が変換され適用されるコンポーネントを上書きすることでも振る舞いを返ることができます。
Prose Components – Nuxt Content
H1タグを上書きして見ます。
content/test.md
--- title: "Title of the page" --- # JavaScriptjavascript function HelloWorld() { console.log("HelloWorld"); }
components/content/ProseH1.vue
公式のものを複製し「タイトル:」という文字を先頭につけるようにしています。
<template>
<h1 :id="id">
タイトル:
<a v-if="generate" :href="#${id}">
<slot />
</a>
<slot v-else />
</h1>
</template>
<script setup lang="ts">
import { computed, useRuntimeConfig } from "#imports";
const props = defineProps<{ id?: string }>();
const { headings } = useRuntimeConfig().public.mdc;
const generate = computed(() => props.id && headings?.anchorLinks?.h1);
</script>
referenced by mdc/src/runtime/components/prose/ProseH1.vue at main · nuxt-modules/mdc (github.com)
pages/[...slug].vue
<template>
<main>
<ContentDoc>
<template #not-found>
<p>No authors found.</p>
</template>
<template #empty>
<p>No authors found.</p>
</template>
<template #default="{ doc }">
<h1>{{ doc.title }}</h1>
<ContentRenderer :value="doc" />
</template>
</ContentDoc>
</main>
</template>
ついでに、ProsePreコンポーネントも上書きし、preタグの前にも「上書き」という文字列をつけています。

上書き用のコンポーネントが動作していることを確認できました。
特定のディレクトリをドキュメント用のページ構成にする
テンプレートプロジェクトの例ではpagesの直下をcatch-all routeとしましたが、manualやexamples、docsのようなディレクトリ名にマッチする場合だけドキュメントとした場合は、pages/examples/[...slug].vueのようにすれば可能です。ドキュメント側は、content/examples/1.introduction.md のようにします。
アプリケーショとマニュアルページを1つのプロジェクトで一元管理ができるため便利です。/docsや/blog、/newsのように目的に合わせたページを作成し、それぞれレイアウトを変えることもできるでしょう。
pages/blog/[...slug].vue
<script setup>
definePageMeta({ layout: "blog" });
// do something
</script>
Document-driven mode
マークダウンベースのWebサイト運用のための機能で、本モードを有効にすると、contentディレクトリの下にドキュメント置くだけですぐにドキュメントページを作成できます。本記事の執筆時点ではexperimental featureとなっています。
document-driven modeは、デフォルトでcatch-all routeページをinjectしてくれます。そのため、自分でpagesの下にcatch-all routeのページコンポーネントを設置する必要がありません。また、ドキュメントごとにlayout属性を定義できるようになり、ドキュメントごとにレイアウトを変えることができます。
プロジェクト全部をマークダウンベースのWebサイトとするようなケースで便利な機能ではないでしょうか。
まとめ
Nuxt Contentを使ってマークダウン形式のドキュメントをWebページで表示する方法を見てきました。コンテンツはコンポーネントやCSSを定義して調整する必要がありますが、セットアップも容易でアプリケーション内にサクッとマニュアルページのようなドキュメントを組み込むことができます。
今回は、一部の機能の使用例について記載しました。他にも便利なAPIが提供されているため、機会があればまた記事にあげたいと思います。


コメント