monolithic kernel

Astro のブログに remark-embedder で外部コンテンツを埋め込む

oEmbed で YouTube や Twitter などの外部のコンテンツを取得して埋め込むために、remark-embedder を導入した。

この手のプラグインはいくつか存在するが、取得したコンテンツをキャッシュして次回以降のビルドを高速化する仕組みがあるのが採用のポイント。ただ、公式のキャッシュの実装については、コンテンツごとにファイルが作られてキャッシュの検索性が悪かったり、バックエンドのライブラリが要件に対して過剰な感があって好みではなかったため、シンプルなものを自作した。

出力をシンプルに単一の JSON ファイルにしているというのと、ビルド中はメモリ上の操作だけで完結して、Astro Integration のライフサイクルを使って最後にファイルに書き出すというのが若干トリッキーではあるけど効率はよくて気に入ってる。

出力されたキャッシュを git commit してしまうことで、ビルド時にインターネットに出る必要がなくなって速度的にも安定性的にもよくなった。

さらに、handleHTML を使って Astro のコンポーネントでラップするということもしている。MDX を使うことで、記事の中に Astro や React のコンポーネントがあってもレンダリングできる。取得した HTML を直接配置するよりも、コンポーネントになっていることで Astro のスコープ付きスタイルが使えるので調整しやすくて便利。

# handleHTMLcache を組み合わせたとき、キャッシュが存在すると handleHTML が効かない問題があったんだけど、Pull Request を投げたら反映してもらえた。よかった。

src/remark/remark-embedder/cache.ts
import type { RemarkEmbedderOptions } from "@remark-embedder/core"
import type { AstroIntegration } from "astro"
import fs from "fs"

type Cache = RemarkEmbedderOptions["cache"] & {
  save(): Promise<void>
}

export const cache = (path: string): Cache => {
  const cache: Record<string, string> = (() => {
    try {
      return JSON.parse(fs.readFileSync(path, "utf-8"))
    } catch (e) {
      /* ignore */
    }
    return {}
  })()
  return {
    async get(key: string) {
      return cache[key]
    },

    async set(key: string, value: string) {
      cache[key] = value
    },

    async save() {
      const sorted: Record<string, string> = {}
      Object.keys(cache)
        .sort()
        .forEach((key) => {
          sorted[key] = cache[key]
        })
      await fs.promises.writeFile(path, JSON.stringify(sorted, null, 2))
    },
  }
}

export const cacheSave = (cache: Cache): AstroIntegration => {
  return {
    name: "cache-save",
    hooks: {
      "astro:build:generated": async () => {
        await cache.save()
      },
    },
  }
}
astro.config.mjs
import mdx from "@astrojs/mdx"
import remarkEmbedder from "@remark-embedder/core"
import { defineConfig } from "astro/config"
import { cache as remarkEmbedderCache, cacheSave } from "./src/remark/remark-embedder/cache"

const cache = remarkEmbedderCache(".cache/remark-embedder.json")

// https://astro.build/config
export default defineConfig({
  integrations: [
    mdx(),
    cacheSave(cache),
  ],
  markdown: {
    remarkPlugins: [
      [
        remarkEmbedder,
        {
          cache,
          async handleHTML(html) {
            if (html == null) {
              return null
            }
            return `<embed-container>${html}</embed-container>`
          },
        },
      ],
    ],
  },
})
src/components/EmbedContainer.astro
---
---

<figure>
  <slot />
</figure>

<style>
  figure {
    display: flex;
    justify-content: center;
    max-width: 100%;
    max-height: none;
    width: auto;
    height: auto;
  }
</style>
src/pages/[...slug].astro
---
import EmbedContainer from "../components/EmbedContainer.astro"

const { Content } await entry.render()
---

<Content
  components={{
    "embed-container": EmbedContainer,
  }}
/>

Related articles