ブログをnuxt/contentに移行 - 2. descriptionの自動生成 の続き。
やっと過去の投稿を移行して Blogger のような一覧表示ができたので PageSpeed Insight でパフォーマンスをチェックしたところ、なんと33点。
トップページでの各記事の一覧/前半のHTMLを v-html
+ ClientOnly
でレンダリングしているせいか、DCL (DOMContentLoaded) や LCP (Largest Contentful Paint) がすごく遅くなっている模様。
CLS (Cumulative Layout Shift) も大きく、後からコンテンツを表示しているせいでずれが起きていそうだ。
VuePress に関連する話題はないだろうかと探したが、解決策は見つからない。
上記のようなケースを含めていわゆる SSG ができるものでないと厳しそう。
その中で nuxt/content を使っている例をいくつか見かけ、ちょっと試してみたらすごく良かったので、主要な機能を検証した上で切り替えていった。
ページ一覧があって、ページの要約があって、ページングができて…という感じのものを SSG でできるかどうかを確認した。
VuePress の場合に自前で実装した <!--more-->
の記法もサポートされていて、これを description として扱ってくれる1ので、比較的簡単に実現できそうだった。
変更の commit log を追いながら説明できることは多々あるのだが、重要な部分をかいつまんで書いておく。
Vue.js や VuePress を試していたこともあってか、nuxt/content の冒頭で説明されているチュートリアル的な説明で、簡単に動かしてみることができた。
VuePress とはだいぶ構成も違うので、いきなり切り替えようとせずに、1からインストールして組み立てながら主要なパーツを組み立てて検証していった。
細かい部分をさておいて一番重要なのが、VuePress で問題になっていた、加工したコンテンツを表示しても静的なページとして生成できるかどうかという点。
Blogger のように一覧ページ上で各エントリの冒頭部分が表示されるのは維持したかったので、前述の <!--more-->
による description 生成を活用したかったが、description は HTML としての表示には使えなかった。
そこで見つけたのが excerpt
だった。
これは <!--more-->
を使って作られた要約だが、description のようなテキストではなく、JSON AST になっているようだった2。
これを、通常の Markdown の表示と同じように <nuxt-content>
にわたすことで要約部分を HTML として表示することができた。
<nuxt-content :document="{body: post.excerpt}" />
そしてこれは nuxt generate
で生成すると HTML に静的に出力できた 🎉
この excerpt
だと実は <!--more-->
がない記事では値が空になり一覧上にコンテンツが表示されなくなってしまうのだが、これに対しては <!--more-->
が存在しないファイルに対して以下のコマンドで <!--more-->
を一括追記することで対処した。
for i in $(rg '<!--more-->' --files-without-match content); do echo '<!--more-->' >> $i; done
チュートリアルで理解した基本的な機能とこれができたことで、nuxt/content に移行して問題ないだろうという気持ちになっていたが、さらに検証を進めていった。
/2021/02/slug
のようなパスを維持したかったため、これができるか確認した。
これは以下などを参考に構成できる。
https://qiita.com/125Dal/items/51c4c058256d6b349921#content
VuePress と違ったのは、slug
の部分が slug.md
でも slug/README.md
でも良かったのに対して、 nuxt/content だと slug.md
にしかできなかったということ。
これも同様にコマンドで一括変換した。
for i in $(rg "title:" --files-with-matches content/ja/post); do mv $i `dirname $i`.md; done
for i in $(rg "title:" --files-with-matches content/ja/post); do rmdir `echo -n $i | sed -e 's/.md//'`; done
slug
のディレクトリ内に該当記事用の画像を格納している例があったが、これは共通の場所 (static/
) に移動してリネームし、参照するURLを置き換える作業をした。(幸いまだ1ファイルしかなかった)
旧ブログから移行した旧記事については、created
というヘッダを設定することで作成日時を表現していたが、これは nuxt/content では createdAt
となるため置換した。ただしこれは後述の通り別名にすることになる。
rg "created:" --files-with-matches content | xargs sed -i '' -e "s/created:/createdAt:/g"
VuePress では git の commit の日付を使って最終更新日時を表現している。
しかし、残念ながら nuxt/content は git の情報を見てくれないようだった。
VuePress では最終更新日時のほか、作成日時も同じ方法で git の履歴から取得するように plugin を実装していた3が、これらを移行した。
content:file:beforeInsert
の hook を使って対象ファイルの git log を確認し、ページのオブジェクトに createdAt
のフィールドで設定するようにした。
ただ、これを正常に動作させるためには frontmatter に埋め込まれている createdAt
を別名に変換しておく必要がある。この hook のタイミングでは、frontmatter の createdAt
の有無によらずページのオブジェクトの createdAt
は存在していて、これがファイルの作成日時を示すのか frontmatter に書かれていた値なのか区別ができないためだ。
frontmatter では originalCreatedAt
という名前を使って作成日時を定義することにして、これがあればその値を尊重し、なければ git の履歴を辿ることにした。
hooks: {
'content:file:beforeInsert': (document) => {
const filePath = 'content' + document.path + document.extension
try {
if (document.originalCreatedAt) {
document.createdAt = document.originalCreatedAt
delete document.originalCreatedAt
} else {
document.createdAt = parseInt(spawn.sync(
'git',
['log', '-1', '--format=%at', '--follow', '--diff-filter=A', path.basename(filePath)],
{ cwd: path.dirname(filePath) }
).stdout.toString('utf-8')) * 1000
}
} catch (e) { /* do not handle for now */ }
try {
document.updatedAt = parseInt(spawn.sync(
'git',
['log', '-1', '--format=%at', path.basename(filePath)],
{ cwd: path.dirname(filePath) }
).stdout.toString('utf-8')) * 1000
} catch (e) { /* do not handle for now */ }
},
そもそもブログを移行しようとしたきっかけが英語で書くことで、Blogger では難しく VuePress で簡単に実現できたからだったが、nuxt/content でも似た構成を取れるか確認した。
Markdown の構成としては以下のように単純に en/ja のディレクトリを作って格納する。
content/
en/
post/
2021/
01/
post1.md
ja/
post/
2021/
01/
post1.md
ページの描画については当初 pages 以下で en/ja それぞれのページを用意しようとしていたが、これは1つにまとめることができた。
nuxt-i18n を使うと、デフォルトのロケール(ここでは en
)の場合に/en/
のようなロケールを示すディレクトリを挟まない構成になる4。
これによって現在表示しているページのロケールが何なのかは決まっているはず、ということになるが、これは asyncData()
で app.i18n.locale
で取得できた。
async asyncData ({ app, $content, params }) {
const lang = app.i18n.locale
...
const pages = await $content(lang + '/post', { deep: true })
.sortBy('createdAt', 'desc')
.fetch()
...
return {
lang,
...
}
nuxt-i18n で追加される localePath()
を使って言語を問わないパスから対象言語のパスへ変換することができるので、同一言語の他のページへのリンク生成も問題なくできる。
dropdown で言語を選択するような UI は用意されていないため (テーマにはあるかもしれないがカスタマイズしたかったため試していない)、自分で実装する必要はある。
末尾にスペース2つで改行するのではなく、ただの改行で HTML の改行にしたい。
これは nuxt.config.js に remark-breaks の plugin を追加するだけだった。
content: {
markdown: {
remarkPlugins: [
'remark-breaks',
],
English のリンクが残っていると nuxt generate
時にエラーが発生するため、過去の記事については noEnglish: true
というカスタムの frontmatter を挿入して英語ページへのリンクを生成しないようにした。
# 2010~2020の記事は英語版なし
find content/ja/post/20{10..20} -type f | xargs sed -i '' -e 's,^\(title:.*\),\1\nnoEnglish: true,'
noEnglish
プロパティが true
であれば英語版なし。二重否定でわかりにくいが、逆にしてしまうと今後の記事すべてに hasEnglish: true
のような frontmatter を記述することになるので、過去分だけ対処すれば良いようにこのようにした。
<nav-bar :path="toPath(article.path)" :has-english="!article.noEnglish" />
ここでいうページネーションは一覧上のもの。
すぐ使えそうな library が見当たらなかったため、前後のページに移るだけの簡単なものを自前で実装。
asyncData ですべてのページを取得して、computed で 対象ページに限定した post を取り出す。現在のページは data で持たせておく。
ページが遷移したら上部にスクロールしてほしいので、 window.scrollTo
を呼び出すようにした。
export default {
async asyncData ({ app, $content, params }) {
const pages = await $content(lang + '/post', { deep: true })
.sortBy('createdAt', 'desc')
.fetch()
return {
lang,
pages,
}
},
data () {
return {
perPage: 20,
page: 1
}
},
computed: {
paginated () {
return this.pages.slice((this.page - 1) * this.perPage, this.page * this.perPage)
}
},
methods: {
setPage (page) {
this.page = page
window.scrollTo({ top: 0 })
},
前後のページのリンクは Pagination という component として作成。
現在のページ、最大ページ数、ページが選択されたときのイベントハンドラを設定できるようにする。
<div v-for="p of paginated" :key="p.path" class="post">
<!-- ページ内のpostをレンダリング -->
</div>
<pagination :page="page" :max-page="Math.ceil(pages.length / perPage)" @setPage="setPage" />
Paginationの内容は以下のようなもの。
前後に遷移するが、遷移できるページがない場合は disabled になるようにした。
<template>
<div class="pagination">
<div>
<a :class="{'is-disabled': prevDisabled}" @click="setPage(page - 1)">
&lt;
</a>
</div>
<div>
<a :class="{'is-disabled': nextDisabled}" @click="setPage(page + 1)">
&gt;
</a>
</div>
</div>
</template>
<script>
export default {
props: {
page: {
type: Number,
required: true
},
maxPage: {
type: Number,
required: true
}
},
computed: {
prevDisabled () {
return this.page === 1
},
nextDisabled () {
return this.page === this.maxPage
}
},
methods: {
setPage (page) {
this.$emit('setPage', page)
}
}
}
</script>
sitemap は @nuxtjs/sitemap
で追加した。
コンテンツのパスを routes で定義する必要がある。
日英のパスを、英語なら /en/
は入らない点に注意して生成する。
sitemap: {
hostname: baseUrl,
gzip: true,
routes: async () => {
let routes = []
const { $content } = require('@nuxt/content')
const langs = ['en', 'ja']
for (const lang of langs) {
const posts = await $content(lang, 'post', { deep: true }).fetch()
for (const post of posts) {
const path = post.path.startsWith('/en/') ? post.path.replace(/^\/en/, '') : post.path
routes.push(path + '/')
}
}
return routes
}
},
@nuxtjs/feed を試したが、どうやらこれはちゃんと更新されていない模様。
2.0.0 まで npm にはリリースされているが、GitHub のリポジトリ上には 1.1.0 までしか定義されていない。さらには、2.0.0 では生成先のディレクトリが存在しないというエラーが発生してしまい、それに対する PR がマージされているのだが、その修正版はリリースされていないようだった。
これに依存するのは微妙だと思い、feed を直接使って実装。
generate:done
の hook を使った。
ここで、本文の HTML を Feed に含めたい場合に <nuxt-content>
タグではなく JavaScript で HTML を取得する方法がわからなかった。
JSON AST を変換して生成するのが良いと考えて、hast-util-to-html での変換を試みたが、これは hast と若干異なっていて、tagName
の代わりに tag
というプロパティが定義されている。
AST を deep copy して tagName
に置き換える操作をして hast として解釈できる形に補正して、その上で hast-util-to-html を使用して HTML に変換した。
'generate:done': async () => {
const { $content } = require('@nuxt/content')
const langs = ['en', 'ja']
for (const lang of langs) {
const posts = await $content(lang, 'post', { deep: true })
.sortBy('createdAt', 'desc')
.limit(20)
.fetch()
const feed = new Feed({
id: baseUrl,
title: 'ブログのタイトル',
})
for (const post of posts) {
const postPath = post.path.startsWith('/en/') ? post.path.replace(/^\/en/, '') : post.path
const url = baseUrl + postPath
const cloned = clonedeep(post.body)
function processNode(node) {
if (node.tag) {
const tag = node.tag
delete node.tag
node.tagName = tag
}
if (node.children) {
node.children.map(child => processNode(child))
}
}
cloned.children.map(child => processNode(child))
feed.addItem({
title: post.title,
description: post.description,
id: url,
link: url,
content: toHtml(cloned),
date: new Date(post.createdAt),
updated: new Date(post.updatedAt)
})
}
const localePath = lang === 'en' ? '' : '/' + lang
const dir = __dirname + '/dist' + localePath
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(dir + '/feed.xml', feed.atom1())
}
}
長い道のりだったが、これで以前とほぼ同様の内容を維持して移行することができた。
ちなみに、PageSpeed Insightのパフォーマンスのスコアは約70。だいぶ改善したが、さらに改善が必要だ 😅