
Nuxt Contentで静的サイトの初期設定をする話
Nuxt と Nuxt Content モジュール
以前にも軽く触れていますが、このブログサイトは Nuxt と Nuxt Content モジュール で作られています。 今回のお話はその初期設定周りの紹介が主なテーマです。
Nuxt Content って何
公式の冒頭説明をそのまま引用します。
content/ディレクトリに書き込むことで、MongoDBのようなAPIを使ってMarkdown、JSON、YAML、XML、CSVファイルを取得します。これはGitベースのヘッドレスCMSとして動作します。
--- https://content.nuxtjs.org/ja
ざっくりと紹介すると :
- content/ ディレクトリにあるMarkdownファイル等をパースしてデータベースを構築してくれる
- そのデータベースからタイトルやら本文やらを取得してページを表示する
- ブログなどの静的サイトをNuxtで構築したい時に便利
Vueベースの静的サイトジェネレータといえば Gridsome がありますが、慣れ親しんだNuxt上で構築したかったり、Gridsomeほどリッチな機能は求めていなかったり、Gridsomeのドキュメントのフォントが気に入らなかったり、あるいはNuxtで構築された既存のアプリケーションに相乗りして静的コンテンツを展開したい様な場合に、Nuxt Content という選択肢が浮かび上がりそうです。
create-nuxt-app で環境構築
とりあえず環境構築。ここではゼロから create-nuxt-app
で環境を初期化します。
既にインストール済みのNuxtにNuxt Contentを追加したい場合は このあたり を参考にしてください。
$ yarn create nuxt-app ./sandbox-project
構成はこんな感じでいきます。
create-nuxt-app v3.4.0
✨ Generating Nuxt.js project in sandbox-project/
? Project name: sandbox-project
? Programming language: TypeScript
? Package manager: Yarn
? UI framework: None
? Nuxt.js modules: Axios, Content
? Linting tools: ESLint, Prettier, Lint staged files, StyleLint
? Testing framework: Jest
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/JAMStack hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Continuous integration: GitHub Actions (GitHub only)
? What is your GitHub username? your_name
? Version control system: Git
ポイントは Nuxt.js modules
で Content
を選択し、 Universal (SSR / SSG)
で target に Static
を選択するぐらいで、ほかはお好みでOKかと。
コンテンツの取得
content/
に配置されたドキュメントの情報を Nuxt 側で取得するには asyncData
フックを活用します。
create-nuxt-app
で Content
モジュールを導入した場合は、content/hello.md
という例示用のファイルが設置されているので、試しにトップページ( pages/index.vue
)からそのファイルを取得してみましょう。
import Vue from 'vue'
export default Vue.extend({
async asyncData(context) {
const content = await context.$content('hello').fetch()
console.log({ content })
return {
content,
}
},
})
取得されるのはこんな感じのデータです。
{
content: {
slug: 'hello',
description: 'Empower your NuxtJS application with @nuxt/content module: write in a content/ directory and fetch your Markdown, JSON, YAML and CSV files through a MongoDB like API, acting as a Git-based Headless CMS.',
title: 'Getting started',
toc: [ [Object], [Object], [Object] ],
body: { type: 'root', children: [Array] },
dir: '/',
path: '/hello',
extension: '.md',
createdAt: '2021-01-09T12:41:31.220Z',
updatedAt: '2021-01-09T12:41:31.221Z'
}
}
$content
でファイル名(スラッグ)を指定して fetch()
すると、そのファイルをパースした結果オブジェクトを渡す Promise
が返ってきます。(上の例では async/await
で同期的に処理していますが)
ここのファイル名は content/
ディレクトリからの相対パスになるので、サブディレクトリ内に格納した場合、例えば content/article/my-first-post.md
の内容を取得したい場合は以下のようになります。スラッグに拡張子は不要です。
const content = await context.$content('article/my-first-post').fetch()
asyncData
で return
したオブジェクトに content
があるわけですが、この content
は data
同様にVueのテンプレートから参照出来ます。
<template>
<article>
<h1>{content.title}</h1>
<p>{content.description}</p>
</article>
</template>
TypeScriptの設定
TypeScriptを使っている場合は $content
を asyncData
の引数から参照する際に Property '$content' does not exist on type 'Context'
などと怒られる事があります。tsconfig.json
に以下の設定を追記して回避しましょう。
"types": [
"@types/node",
- "@nuxt/types"
+ "@nuxt/types",
+ "@nuxt/content"
]
本文の描画
title
や description
等は content
オブジェクトからそのまま参照して出力すれば良いですが、本文はそうはいきません。
上の例で取得された content
オブジェクトの中に body
というプロパティがありますが、これがMarkdownの本文をパースして得られた、本文を構築する為の要素達です。単純な文字列ではないので、このまま出力するわけにもいきません。このオブジェクトを元に本文を描画するわけですが、その為の便利コンポーネントが用意されています。その名も NuxtContent
と言います。
<h1>{{ content.title }}</h1>
<nuxt-content :document="content" />
この <nuxt-content />
というコンポーネントに document
プロパティとして content
オブジェクトをまるっと渡す事で本文部分をHTMLとして描画してくれます。
動的ルーティング
記事を追加するたびに pages/
にページを追加するのは明らかに無駄な行為なので、URLにスラッグを使いたいところ。
例えば、content/article/my-first-post.md
を設置したら http://localhost:3000/article/my-first-post
で見られるようにしたい。それも自動的にそうなって欲しい。
.
└── content/
└── article/
└── my-first-post.md
これはNuxtのルーティングで設定する必要があります。Nuxt Content さんが勝手にやってくれたりはしません。残念ながらマニュアル作業です。
まずはページ用のvueファイルを作りましょう。こんな具合に。
.
└── pages/
├── article/
│ └── _slug.vue
└── index.vue
Nuxtのルーティングは pages/
以下のディレクトリ名・ファイル名で設定します。ここでは _slug
というのが動的なURLの部品となって、Nuxtから参照出来る様になります。上の例で言うと my-first-post
の部分ですね。
_slug
のようにアンダースコアではじまるファイル名は動的ルーティングのスラッグを格納する変数名となり、 params.slug
のように参照する事ができます。ここでの params
は route.params
のエイリアスです。 Nuxt のルーターについてより詳しくは このあたり が参考になるかと。
export default Vue.extend({
async asyncData({ $content, params }) {
const content = await $content(`article/${params.slug}`).fetch()
return {
content,
}
},
})
そしてこの content
を元にタイトルと本文を出力しましょう。
<h1>{{ content.title }}</h1>
<nuxt-content :document="content" />
あとは yarn dev
して localhost:3000/article/my-first-post
にアクセスすれば、ページが表示されるはずです。
nuxt generate でファイルを出力する
では静的サイト化するために、この状態で yarn generate
してみましょう。無事、記事ページがHTMLファイルとして出力されましたでしょうか。
はい、されませんね。 この辺も勝手に面倒を見てくれるわけではないのでマニュアル作業にて対応します。
nuxt.config.js
の modules
の後あたりに generate
の設定を追加します。基本的には公式の通りですが、ページ内で明示的に参照されていない記事ファイルについて、静的ファイルが出力されない事がありますため、これを回避するためオプションに { deep: true }
を追加しています。これを指定しておくと全て再帰的に取得してくれるようになります(多分)。
export default {
...
modules: [
'@nuxtjs/axios',
'@nuxt/content',
],
generate: {
async routes() {
const { $content } = require('@nuxt/content')
const files = await $content({ deep: true }).only(['path']).fetch()
return files.map((file) => (file.path === '/index' ? '/' : file.path))
},
},
...
}
こうしてあらためて yarn generate
すれば、記事の全てのページが静的なHTMLファイルとして出力されるようになるはずです。
インデックスの出力
記事ページが出来たので、トップページで記事の一覧を出力してみます。 pages/article/index.vue
で $content
を介して記事のリストを参照してみましょう。
.
└── pages/
├── article/
│ ├── _slug.vue
│ └── index.vue
└── index.vue
要領は本文の出力と同じで、 asyncData
フック内で $content
を介して取得します。
export default Vue.extend({
async asyncData({ $content }) {
const list = await $content({ deep: true }).fetch()
return { list }
},
})
$content()
で返される QueryBuilder
オブジェクトは、結果をソートしたり絞り込んだり件数を制限したりといった便利なメソッドが沢山備わっているので、出力を制御したい場合に活用しましょう。
- コンテンツを取得する - Nuxt Content
※ このページではメソッドは網羅的に紹介されていません
これで list
として記事の一覧が配列で取得出来るようになったので、それを基に v-for
でまわしてインデックスを出力します。
<template>
<div class="ArticleIndexPage">
<ul>
<li v-for="item in list" :key="item.slug">
<a :href="item.path">
{{ item.title }}
</a>
</li>
</ul>
</div>
</template>
/article
にアクセスして、 content/
配下の記事がリストで表示されればOKです。
meta要素の設定
せっかく静的サイトとして展開するのであれば、当然ページ毎のタイトルやOpengraph、Twitter Cards などに対応しておきたいところです。Nuxtで <head>
内のmeta要素を変更するには、 head()
メソッドが有用です。 _slug.vue
で試してみましょう。
import { MetaInfo } from 'vue-meta'
export default Vue.extend({
...
head(): MetaInfo {
return {
title: `${this.content?.title} | EXAMPLE.COM`,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: `${this.content?.title} | EXAMPLE.COM`,
},
{
hid: 'og:url',
name: 'og:url',
content: `https://example.com${this.content?.path}`,
},
{
hid: 'og:type',
name: 'og:type',
content: 'website',
},
{
hid: 'description',
name: 'description',
content: this.content?.description,
},
{
hid: 'og:description',
name: 'og:description',
content: this.content?.description,
},
{
hid: 'og:image',
name: 'og:image',
content: `https://example.com/images/${this.content?.image}`,
},
{
hid: 'twitter:card',
name: 'twitter:card',
content: 'summary_large_image',
},
{
hid: 'twitter:creator',
name: 'twitter:creator',
content: '@[your-twitter-account-name]',
},
],
}
},
})
head()
の返り値の型は MetaInfo
が良さそうです。
Markdownドキュメントのヘッダセクションに image
という項目を勝手に追加して、それをもってシェア用の画像を指定しています。
---
title: ...
description: ...
image: profile_image.png
---
画像自体は static/images
に格納しておいて、それを参照する想定です。記事詳細ページで画像を参照する場合は、画像ファイルまでのパス( /images/
)を補完してやりましょう。
.
└── static/
└── images/
└── profile_image.png
yarn generate
して出力されたHTMLファイルに指定した meta
要素が埋め込まれていれば成功です。
今後の課題: 検索機能とインデックス
今回は Vercel で運用しているのですが、一つ課題として浮かんだのは記事の検索機能です。 Nuxt Content は記事データを一つのJSONファイルとして保存するのですが、これが記事数が膨大になった場合にどうなってしまうのかという懸念があります。 できれば必要な分だけを返すAPIを用意したいところですが、現在のところ Vercel の functions からこの記事データにアクセスする方法が見つかっていません。
普通にnode.jsで配信する分には serverMiddleware でうまく動きはしたのですが、そうした場合 Vercel に代わる置き場所を考えねばなりません。 検索ページも未実装なのでこれから考える内容ではあるのですが。 そもそもこのブログの記事が果たして膨大な数になるのかというと、絶対にならない自信はあるので杞憂に終わりそうではあります。
まとめ・感想
とりあえず初期設定としてはこんな按配だと思います。
コードフェンス等はNuxt Contentさんが勝手に PrismJS でコードハイライティングしてくれます。テーマも prism-themes
をインストールすれば選べます。この辺りを見ると、やはり開発者がブログを書く為のモジュールなのだなという印象です。そもそも開発者じゃなければ Markdown で記事書いて git で push したりはしないだろうと。
コンテンツが開発者ブログである必要はないですが、運用者は明らかに開発者をターゲットとしている、そんな Nuxt Content さんでした。どっとはらい。