Nuxt Contentで静的サイトの初期設定をする話

この記事は公開されてから2年以上経過しています。 情報が古い可能性がありますのでご注意ください。

Nuxt と Nuxt Content モジュール

以前にも軽く触れていますが、このブログサイトは NuxtNuxt 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 modulesContent を選択し、 Universal (SSR / SSG) で target に Static を選択するぐらいで、ほかはお好みでOKかと。

コンテンツの取得

content/ に配置されたドキュメントの情報を Nuxt 側で取得するには asyncData フックを活用します。

create-nuxt-appContent モジュールを導入した場合は、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()

asyncDatareturn したオブジェクトに content があるわけですが、この contentdata 同様にVueのテンプレートから参照出来ます。

<template>
  <article>
    <h1>{content.title}</h1>
    <p>{content.description}</p>
  </article>
</template>

TypeScriptの設定

TypeScriptを使っている場合は $contentasyncData の引数から参照する際に Property '$content' does not exist on type 'Context' などと怒られる事があります。tsconfig.json に以下の設定を追記して回避しましょう。

    "types": [
      "@types/node",
-     "@nuxt/types"
+     "@nuxt/types",
+     "@nuxt/content"
    ]

本文の描画

titledescription 等は 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 のように参照する事ができます。ここでの paramsroute.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.jsmodules の後あたりに 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 オブジェクトは、結果をソートしたり絞り込んだり件数を制限したりといった便利なメソッドが沢山備わっているので、出力を制御したい場合に活用しましょう。

これで 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 さんでした。どっとはらい。

ざっくりと紹介すると :

  • content/ ディレクトリにあるMarkdownファイル等をパースしてデータベースを構築してくれる
  • そのデータベースからタイトルやら本文やらを取得してページを表示する
  • ブログなどの静的サイトをNuxtで構築したい時に便利

Vueベースの静的サイトジェネレータといえば Gridsome がありますが、慣れ親しんだNuxt上で構築したかったり、Gridsomeほどリッチな機能は求めていなかったり、Gridsomeのドキュメントのフォントが気に入らなかったり、あるいはNuxtで構築された既存のアプリケーションに相乗りして静的コンテンツを展開したい様な場合に、Nuxt Content という選択肢が浮かび上がりそうです。