nuxt3.svg

Nuxt 3 + Nuxt Content v2 による静的サイトの作成

Nuxt3
2024/01/11

はじめに

本ブログは、タイトルにある Nuxt 3 と Nuxt Content v2 を用いて作成しています。

もともとは Nuxt 2 + Nuxt Content で作成していたのですが、Vue 2 のEOLが2023年末ということで Nuxt 3 に移行しました。 移行といっても、実際は一から作り直しています…。 まぁデザインも変えたりしたかったので、一から作り直してよかったとも思っています。 コード量も大したことないので。

とはいえ、Nuxt 3 や Nuxt Content が v2 になったことで苦労したことがどうしてもあります。 これら踏まえて、Nuxt 3 + Nuxt Content v2 の静的サイトの作成方法について説明をしていきます。

プロジェクトの作成

以下コマンドで Nuxt 3 のプロジェクトを作成とパッケージのインストールをします。

> npx nuxi init <project-name>
> cd <project-name>
> npm install

そして、以下コマンドでNuxt Contentをインストールします。

> npm install @nuxt/content

インストールが完了したら、nuxt.config.js(ts)modulesに追加します。

nuxt.config.js
export default defineNuxtConfig({
  modules: ['@nuxt/content'],
})

最低限であればこれで大丈夫です。

今回使用しているバージョンは以下の通りです。

nuxt: 3.7.0
@nuxt/content: 2.7.2

ページ構成

このブログではpages/ディレクトリは以下の構成になっています。

pages/
├ post/
│ └ [...slug].vue
├ index.vue
└ policy.vue

[...slug].vueが各記事のページ(動的ページ)となっています。 Nuxt 3 から動的ページは[]で表現するようになっており、さらに[...]とすることで下位のすべての階層をターゲットにすることができます。 今回の場合、post/はなくても動作しますが、意図的に作成しています。

さて、ここで注意が必要なのは、index ページにすべてのページのリンクを作成しなければならないということです。 これをしなければ、npm run generateをした場合に動的ページが生成されません。 ここが今回のハマりポイントの1つでした。

具体的には、NuxtLinkですべての記事へのリンクを作成する必要があります。 例えばcomputedでリストにフィルターをかけたり、v-ifで一部リンクを非表示にするといったことをした場合に、初期表示で非表示となる(レンダリングされない)ページは生成されません。

もしかすると他にも良い方法があるかもしれないのですが、残念ながら見つけることはできませんでした。 他の方法があればご教示いただきたいです。

Nuxt Content

Nuxt Content については以下が公式ドキュメントとなります。 この中から今回利用したものだけをピックアップして説明します。

公式:https://content.nuxtjs.org/

コンテンツ作成

Nuxt Content では、プロジェクト直下のcontent/ディレクトリ内に作成した md ファイルがページのコンテンツとなります。 コンテンツはMarkdownで記述するため、開発者であればある程度簡単に作成することができると思います。

また、content/のディレクトリ階層はそのままページのパスとなります。 このページであれば、content/post/nuxt3/nuxt3-blog.mdというファイルがページコンテンツとなっています。 コンテンツを作成するときは、どのようなパスになるかを考えて作成してみてください。

基本的には通常のMarkdownの記述なのですが、プラスでコンテンツ情報を設定することができます。 コンテンツ情報は、ファイルの先頭の---で囲った部分に以下のようにパタメータとして設定します。

.md
---
title: 'コンテンツのタイトル'
description: 'コンテンツの詳細'
---

〜 ここから本文 〜

#<h1>)は、上記のようにtitleとして設定するため使用しません。

コンテンツ情報として設定できる項目は以下を参考にしてください。

https://content.nuxtjs.org/guide/writing/markdown#native-parameters

実際には上記にない項目も自由に設定することが可能です。

.md
---
title: 'コンテンツのタイトル'
description: 'コンテンツの詳細'
tags: ['Nuxt 3']
img: 'nuxt3.svg'
---

〜 ここから本文 〜

コンテンツ情報

コンテンツの取得の前に、コンテンツが持つ情報を示しておきます。 コンテンツの情報はParsedContentInternalMetaで定義されており、以下の項目を持ちます。

export interface ParsedContentInternalMeta {
  _id: string
  _source?: string
  _path?: string
  title?: string
  _draft?: boolean
  _partial?: boolean
  _locale?: string
  _type?: string
  _file?: string
  _extension?: string
}

これに加えて上記で設定したtagsのようなコンテンツ情報を取得できます。

コンテンツ取得

コンテンツの取得方法には、Composable を使用する方法と Component を使用する方法の2つがあります。 どちらを使用してもおそらく問題はないので、好きな方を選択すれば良いかと思います。

queryContent()

1つはComposableとして定義されているqueryContent()を使用します。 queryContent()の引数には、content/ディレクトリの中で取得したいディレクトリを指定します。

// /content 以下すべてのコンテンツを取得
const contentQuery = queryContent()

// /content/articles 以下すべてのコンテンツを取得
const contentQuery = queryContent('articles')

// /content/articles/nuxt3 以下すべてのコンテンツを取得
const contentQuery = queryContent('articles', 'nuxt3')

取得するコンテンツを絞りたい場合はwhere()、並べ替えたい場合はsort()を使用します。 終端処理として、取得するコンテンツが複数の場合はfind()、1つの場合はfindOne()を使用します。

// 複数
const articles = queryContent('articles').sort([{ createdAt: -1 }]).find()

// 単体
const article = queryContent('articles').where({_path: path}).findOne()

その他メソッドや使用方法の詳細などについては、公式ドキュメントを参考にしてください。

https://content.nuxtjs.org/api/composables/query-content

実際に取得する場合は、useAsyncData()を用います。

.vue
<script setup>
const { path } = useRoute()

const { data } = await useAsyncData(`content-${path}`, async () => {
  const article = queryContent().where({ _path: path }).findOne()
  return {
    article: await article,
  }
})
</script>

ContentList

<ContentList>は Component として定義されており、pathqueryを指定することで複数のコンテンツを取得します。 queryは、queryContent()で使用したwhere()sort()を指定し、コンテンツを絞ったりします。

.vue
<template>
  <ContentList path="/articles" :query="{ sort: [{ createdAt: -1 }] }">
    <template #default="{ list }">
      <NuxtLink v-for="article in list" :key="article._path" :to="article._path">
        {{ article.title }}
      </NuxtLink>
    </template>
    <template #not-found>
      <p>No articles found.</p>
    </template>
  </ContentList>
</template>

参照: https://content.nuxtjs.org/api/components/content-list

ContentDoc

<ContentDoc>は Component として定義されており、現在のパスに一致する単一コンテンツを取得します。

.vue
<template>
  <ContentDoc v-slot="{ doc }">
    <!-- ページ -->
  </ContentDoc>
</template>

参照: https://content.nuxtjs.org/api/components/content-doc

コンテンツ表示

基本的には、コンテンツ情報で示した項目を使用してデザインしていきます。 Markdownで記述した本文については、<ContentRender>を用いることでHTMLに変換されます。

.vue
<template>
  <ContentDoc v-slot="{ doc }">
    <article>
      <h1>{{ doc.title }}</h1>
      <ContentRenderer :value="doc" />
    </article>
  </ContentDoc>
</template>

参照: https://content.nuxtjs.org/api/components/content-renderer

内部的には Prose Components によってHTMLに変換されます。 次に説明しますが、対応するコンポーネントと同名のコンポーネントを作成することで、どのように変換するかカスタマイズすることが可能です。

シンタックスハイライト

テーマの設定

Nuxt Content v2 では、shiki-es でシンタックスハイライトが設定されています。 利用できるテーマは以下になります。

css-variables dark-plus dracula-soft dracula github-dark-dimmed
github-dark github-light hc_light light-plus material-theme-darker
material-theme-lighter material-theme-ocean material-theme-palenight material-theme min-dark
min-light monokai nord one-dark-pro poimandres
rose-pine-dawn rose-pine-moon rose-pine slack-dark slack-ochin
solarized-dark solarized-light vitesse-dark vitesse-light

一部テーマは以下サイトより確認することができます。

デモ: https://vscodethemes.com/

テーマは、nuxt.config.jscontent.highlight.themeに設定します。 1パターンのみの場合はテーマの文字列を、ダークモードを使用する場合は以下のようなオブジェクトを指定します。

nuxt.config.js
export default defineNuxtConfig({
  content: {
    highlight: {
      //単一
      theme: 'github-light',
      //モード別
      theme: {
        default: 'light-plus',
        dark: 'dark-plus'
      }
    },
  },
})

デザインのカスタマイズ

<code>は以下のようにファイル名やハイライトさせる行数を指定することができます。

```js [file.js]{4-6,7}
export default () => {
  console.log('Code block')
}
```

しかし、Nuxt Content v2 のデフォルトではファイル名を表示させることができません。 これを解決するために先ほど示した Prose Components を使用します。

<code>には<ProseCode>が対応しており、以下のようにプロジェクトのコンポーネントとして同名のファイルを作成することで上書きします。

ProseCode.vue
<template>
  <div class="prose-code">
    <div class="prose-code__file" v-if="filename">
      {{ filename }}
    </div>
    <slot />
  </div>
</template>

<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    code?: string
    language?: string | null
    filename?: string | null
    highlights?: number[]
  }>(),
  { code: '', language: null, filename: null, highlights: undefined }
)
</script>

<slot /><code>の内容が展開されます。 あとは対応するプロパティを基にデザインを作成します。 このブログではやっていませんが、言語を表示したり、クリップボードへのコピーボタンをつけたりすることも可能です。

参考: https://mokkapps.de/blog/how-to-create-a-custom-code-block-with-nuxt-content-v2

ページのデザイン

デザインについてはおまけのようなものですが、一応簡単に今回使用したものについてご紹介しておきます。

Tailwind CSS

普段はCSSフレームワークは使用せずプレーンのままゴリゴリ書いていくのですが、今回はせっかくなのでTailwind CSSを使用してみました。

Nuxt 用に@nuxtjs/tailwindcssがあるのでこちらを使用します。

> npm install -D @nuxtjs/tailwindcss
> npx tailwindcss init
nuxt.config.js
export default defineNuxtConfig({
  modules: ['@nuxt/tailwindcss'],
})

参考:https://tailwindcss.nuxtjs.org/

npx tailwindcss initにより、プロジェクトディレクトリーにtailwind.config.jsが生成されます。 このファイルにTailwind CSSの設定を記述していきます。

またassets/css/tailwind.cssを以下の内容で作成します。 これで準備は完了です。

assets/css/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;

Tailwind CSSの詳細については触れませんが、class名によりデザインを適用することができます。 例えば以下は背景を黒色、文字を白色に設定しています。

<div class="bg-black text-white">
  hogehoge
</div>

また@applyを用いることでCSSでまとめてデザインを適用することができます。

<template>
  <div class="box1">
    hogehoge
  </div>
</template>

<style scoped>
.box1 {
  @apply bg-black text-white;
}
</style>

アイコン

アイコンはtailwindcss-iconsを使用しています。 一緒に@iconify/jsonをインストールする必要があります。

> npm install -D @egoist/tailwindcss-icons @iconify/json

使用するにはtailwind.config.jsに以下を追記します。

tailwind.config.js
const { iconsPlugin, getIconCollections } = require("@egoist/tailwindcss-icons")

module.exports = {
  plugins: [
    iconsPlugin({
      collections: getIconCollections(["mdi", "lucide"]),
    }),
  ],
}

getIconCollections()に使用するアイコンのライブラリを指定します。 使用できるアイコンは以下から検索することができます。

参考:https://icones.js.org/

アイコンを表示するには以下のようにi-から始まる名前をclassに設定します。 上記サイトで表示したいアイコンをクリックすれば名前が表示されるため。ケバブケース(-繋ぎ)の名前をコピーして使用します。

<span class="i-mdi-home"></span>

ダークモード

このブログでは、ダークモードを実装するためにTailwind CSS と @nuxtjs/color-mode を使用しています。

参考:https://color-mode.nuxtjs.org/

詳細は割愛しますが、以下のように各設定ファイルに追記します。

> npm install -D @nuxtjs/color-mode
nuxt.config.js
export default defineNuxtConfig({
  modules: ['@nuxt/tailwindcss', '@nuxtjs/color-mode'],
  colorMode: {
    classSuffix: '',
  },
})
tailwind.config.js
module.exports = {
  darkMode: 'class'
}

デザイン方法はいたってシンプルで、dark:を先頭に付与したものがダークモードのデザインとして適用されます。 例えば以下は、通常時は白色背景に黒文字、ダークモードでは黒色背景に白文字としています。

<div class="bg-white text-black dark:bg-black dark:text-white">
  hogehoge
</div>

肝心のテーマの切り替え方法ですが、以下のようにcolorModepreferenceの値を変更します。 現在のテーマを調べる場合もpreferenceの値を参照すればよいです。

const colorMode = useColorMode()

const changeLightMode = () => {
  colorMode.preference = 'light'
}

const changeDarkMode = () => {
  colorMode.preference = 'dark'
}

ただし、preferenceの初期値はsystemになっており、初回表示時は端末(OS)のモードによって決まります。 このとき、どちらのモードであるかは、colorMode.valueを参照してください。

記事のデザイン

記事のデザインについてはもうゴリゴリに自分で描いています。 MarkdownとHTMLの関係がわかっていれば、何に対してデザインを設定するかは難しくないと思います。 先述しましたが、ものによっては Prose Components を書き換えるというのも1つ手だと思います。

静的サイトの生成、デプロイ

静的サイトを生成する場合は、npm run generate を使用します。 うまくいけば.output/publicまたはdistに生成されます。 できたサイトを確認したい場合は、npm run preview を実行します。

デプロイについては割愛しますが、このブログは Netlify を使用しており、GithubへのPushをトリガーにビルド、デプロイを行なっています。

ただ1点注意が必要で、場合によってはメモリオーバーでビルドが失敗します。 実際にこのブログをデプロイする際も、ビルドで失敗してハマりました。 解決方法としては、以下のように generateを書き換えることで使用するメモリの最大値を制限します。

package.json
"generate": "NODE_OPTIONS=--max_old_space_size=8192 nuxt generate"

その他の設定

サイトマップ

このブログでは、サイトマップの作成にnuxt-simple-sitemapを使用しています。

> npm install -D nuxt-simple-sitemap
nuxt.config.js
export default defineNuxtConfig({
  modules: ['nuxt-simple-sitemap'],
})

参考:https://nuxt.com/modules/simple-sitemap

この設定だけで、サイト生成時にsitemap.xmlが生成されます。

Google Analytics

Google Analyticsを設定するために、Nuxt Gtagを使用します。

> npm install -D nuxt-gtag

以下のように設定ファイルにidを設定します。

nuxt.config.js
export default defineNuxtConfig({
  modules: ['nuxt-gtag'],
  gtag: {
    id: 'G-XXXXXXXXXX',
  },
})

おわりに

非常に簡単ではありますが、このブログを作り直すためにやったことについて説明していきました。 感想としては、簡単になっていて良いと思う部分もあり、どう動くか理解できていなく不便だと思う部分もあるって感じです。 まぁ結局は慣れなのかもしれないですが。

正直なところまだ妥協している点や動きやデザインがおかしなところがあったりします。 これらは時間があるときに少しずつ修正していきたいと思います。