nuxt/content の記事の日付を Git ベースにする

nuxt/content はコンテンツのメタデータを Markdown ファイルの front-matter から取得する用になってますが、それ以外にもファイルの状態から自動で設定されるものもあり、createdAt や updatedAt などもその1つのようです。

ファイルそのものがもつデータをみるのでデータベースなどが必要なく、front-matter とかに書かずとも取得できるため便利そうに見えるのですが、これを GitHub Actions などを使ってデプロイしようとすると、CI のデプロイ環境に Actions が Clone してくると、その日時が作成日となってしまうため、記事の日付がすべてデプロイした日になってしまうという課題を抱えていました。

そのためかはわからないのですが、現時点では、ファイルからの取得は行っていないっぽく、front-matter に設定してない場合は createdAtupdatedAt は出ないようです。

Git ベースにする

これに対して、たとえば VuePress などは、コミット情報を使うようになってるようですが、これを nuxt/content でできるようにする方法がいくつかあり、最もかんたんなのは、nuxt-content-git を使う方法みたいですが、Nuxt3 だと仕様が異なるため、このままでは使えませrん。

そこで、Nuxt2 での実装を読みつつ、これを Nuxt3 に移植してみます。

Git ログから 作成日・更新日を求める

対象のファイルを指定して git log することで、そのファイルの更新履歴を取得できます。
これにさらにオプションをつけていくことで、目的のログに絞り込んでいきます。

updatedAt

わかりやすいので順番は前後しますがまずは updatedAt から。
更新日ということは、最新のコミットの日時がわかれば良いので

  • -1 で最新1件のみにする
  • --format=%at で Author Date を Unix Timestamp で得る

といった感じで良さそうです。

git log -1 --format=%at content/posts/2023022801.md
1677563050

createdAt

作成日は、一番古いログに対して同様のことをすれば良さそうですので、 基本的な部分は updatedAt と同じで、そこにさらにいくつか条件を追加します。

  • --diff-filter=A で「追加」したときのログに絞り込む
  • --follow でリネーム前の状態まで追跡

とすれば、いけそうです

git log -1 --format=%at --follow --diff-filter=A content/posts/2023022801.md
1677563050

これでコマンドはわかりましたので、これをスクリプトに組み込んでいきます。

Nitro プラグインとして実装する

コマンドがわかったら、これを nuxt/content が記事をパースするタイミングで実行できるようにします。
サーバーサイドでの処理ですので、 Nitro のプラグインとして実装します。
フックする場所は content:file:afterParse でよさそうです。

Git のデータを取る部分は、spawnSync で Git コマンドを同期的に実行します。

また、今回は、フロントマターにデータがあればそれを優先、
さらに、ファイルがまだコミットされてない場合は、現在の日時を返すようにしてみます。

/server/plugins/content.ts

import { spawnSync } from 'child_process'
import path from 'path'
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('content:file:afterParse', (document) => {
    const filePath = 'content/' + document._file

    try {
      if (!document.createdAt) {
        const firstCommitDate = parseInt(spawnSync(
          'git',
          ['log', '-1', '--format=%at', '--follow', '--diff-filter=A', path.basename(filePath)],
          { cwd: path.dirname(filePath) }
        ).stdout.toString('utf-8')) * 1000

        if (!isNaN(firstCommitDate)) {
          document.createdAt = new Date(firstCommitDate)
        } else {
          document.createdAt = new Date(Date.now())
        }
      }
    } catch (e) { /* todo: add handling */ }

    try {
      if (!document.updatedAt) {
        const latestCommitDate = parseInt(spawnSync(
          'git',
          ['log', '-1', '--format=%at', path.basename(filePath)],
          { cwd: path.dirname(filePath) }
        ).stdout.toString('utf-8')) * 1000
        if (!isNaN(latestCommitDate)) {
          document.updatedAt = new Date(latestCommitDate)
        } else {
          document.updatedAt = new Date(Date.now())
        }
      }
    } catch (e) { /* todo: add handling */ }
  })
})

まだエラーハンドリングは付いてませんが、とりあえずこれでデータの取得はできるようになりました。

試してみる

フロント側から呼び出してみます。

pages/posts/[...id].vue

<script setup lang="ts">
import dayjs from 'dayjs'

const { page } = useContent()
const createdAt = dayjs(page.value.createdAt)
const updatedAt = dayjs(page.value.updatedAt)
const postIsUpdated = !createdAt.isSame(updatedAt, 'seconds')
</script>

<template>
  <div class="post-date-display">
    created: <time>{{ createdAt.format() }}</time>
    <template v-if="postIsUpdated">
      updated: <time>{{ updatedAt.format() }}</time>
    </template>
  </div>
</template>

さいごに

当たり前ですが、記事を作ったり、ビルドしたりする環境に Git が入ってないと使えません。
この方法で日付を管理するにはコミットしないといけないので、記事を書く環境に Git が無いってのはなしにしても、デプロイ方法には気をつける必要があるかもです。
少なくとも GitHub Actions とか使えば問題はないはず。

また、ログをたどって日付を求めるため、CI 上でビルドする際に Shallow Clone だとダメで、ちゃんと全部の履歴を含めてビルド環境に展開する必要があります。

処理の高速化のためにデフォルトだと shallow clone ってのは結構ある話みたいです。
通常は最新のソースの状態さえわかれば良くて、そこまでの経過はビルド時は不要ですからね。

たとえば、GitHub Actions とかだと、こういう感じ

- uses: actions/checkout
  with:
    fetch-depth: 0

参考

git log --format=%ct:%s

:add post 2023022801