nuxt/content で作成した Web サイトで Markdown 内の画像を Native Lazy-loading を行う

普段ブログや技術メモを書くとき、お手軽な画像ホスティングとして Gyazo を利用していたりします。

Patreon を開設していた頃に OSS の README に貼る GIF をサクッと取りたいという動機で Gyazo Pro を契約後、もう2,3年 Pro を継続。画像アセットの管理が結構面倒なのもあって、無限に使えてちゃんとホスティングされることが担保されていることから、このブログでも画像は全て Gyazo に載せていたり。

あと個人的に増井さんのファンなのもあって、長らく愛用しています。

Gyazo 自体は便利で愛用している一方、無計画にスクショを撮って Copy Markdown Snippet をし続けると当たり前にサイトが重くなるという課題がありました。

これ自体は Markdown 内で <img> タグを使ってしまうみたいな力技もないことはないのですが、あまりスマートではないということで、 nuxt/content をちょっとカスタマイズして lazy-loading を可能にしとしました。

需要は不明ですが、一応メモとして残しておきます。ちなみに実際に動かすとこんな感じ。

Image from Gyazo

nuxt/content のマークダウン制御

nuxt/content は、内部的には Markdown ファイルに対しては remark / rehype による Markdown のパースと HTML 化を行っており、その設定も外部に露出しています。

nuxt.config.js の content > markdown > remarkPlugins / rehypePlugins がそれにあたり、ここにパッケージ名あるいはファイルパスをしているすることで、該当のファイルを import します。

デフォルトでもいくつかプラグインが入っていますが、ここに追加した場合でも、明示的に上書きをとる場合以外では、これらは削除されずに上から設定を追加する形となります。

ちなみに執筆時点ではデフォルトはこんな感じ。

// https://github.com/nuxt/content/blob/9e5ba558782a70269997576ed4c4242af3d0e87c/packages/content/lib/utils.js#L14-L18

{
  // ...
  markdown: {
    remarkPlugins: [
      'remark-squeeze-paragraphs',
      'remark-slug',
      'remark-autolink-headings',
      'remark-external-links',
      'remark-footnotes'
    ],
    rehypePlugins: [
      'rehype-sort-attribute-values',
      'rehype-sort-attributes',
      'rehype-raw'
    ],
    prism: {
      theme: 'prismjs/themes/prism.css'
    }
  },
  // ...
}

rehype プラグインの記述

今回は <img> タグをそのまま記述ケースも考慮し、 markdown 制御の remark ではなく、 rehype のプラグインを記述することとしました。

スナップショットテストをとりつつガンガン書き進めたのでコード自体は 10 分くらいで書けたのですが、試しに型定義ちゃんと定義してみるかと思ったらそっちで1時間くらい取られたりしました。

remark / rehype の型定義情報なさすぎてつらいので同じ悩みがある人の助けになれば幸いです。ちなみに unist-util-visit と hast / mdast の存在に気づくとほぼ勝ちです。

// ~/plugins/remark/lazyload.ts

import { Transformer } from 'unified'
import { Node } from 'unist'
import visit from 'unist-util-visit'
import hast from 'hast'

function lazyloadPlugin(): Transformer {
  function visitor(el: hast.Element) {
    if (el.tagName !== 'img') {
      return
    }
    el.properties = {
      ...(el.properties || {}),
      loading: 'lazy'
    }
  }

  function transformer(htmlAST: Node): Node {
    visit<hast.Element>(htmlAST, 'element', visitor)
    return htmlAST
  }

  return transformer
}


export default lazyloadPlugin
module.exports = lazyloadPlugin

一応注意点として、 nuxt/content のコードを読んだ感じだと require で Node.js のユーザーランド上で動的に読み込んでいるので、CommonJS 形式の module.exports が必要そう。

テストコードは雑にこんな感じでスナップショット見つつやってました。正しくない使い方だが何より楽。

// lazyload.spec.ts
import unified from 'unified'
import markdown from 'remark-parse'
import remark2rehype from 'remark-rehype'
import html from 'rehype-stringify'
import plugin from './lazyload'

async function process(rawMarkdown: string) {
  return new Promise((resolve, reject) => {
    unified()
    .use(markdown)
    .use(remark2rehype)
    .use(plugin as any)
    .use(html)
    .process(rawMarkdown, function (err, file) {
      if (err) {
        return reject(err)
      }
      return resolve(file.toString())
    })
  })
}

describe('lazyload.spec.ts', () => {
  test('snapshot', () => {
    const rawMarkdown = `### test
[![Image from Gyazo](https://i.gyazo.com/2234a6d5bf486e316752d4eb9322c75f.png)](https://gyazo.com/2234a6d5bf486e316752d4eb9322c75f)
`
    return process(rawMarkdown).then((fileString) => {
      expect(fileString).toMatchSnapshot()
    })
  })
})
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`lazyload.spec.ts snapshot 1`] = `
"<h3>test</h3>
<p><a href=\\"https://gyazo.com/2234a6d5bf486e316752d4eb9322c75f\\"><img src=\\"https://i.gyazo.com/2234a6d5bf486e316752d4eb9322c75f.png\\" alt=\\"Image from Gyazo\\" loading=\\"lazy\\"></a></p>"
`;

これでデプロイすると、実際に lazy がついています。解決。

Image from Gyazo

終わりに

手元ですぐ書けるコードですが、ありそうでなかったので需要があればパッケージ化します。