なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

HonoX で MDX コンテンツでも Island コンポーネントを使いたい

HonoX、便利ですよね。 Next.js ほど巨大なフレームワークを使いたくない場面などで、セットアップも簡単、さっと初めてさっとデプロイできるという点で大活躍です。

github.com

そんな HonoX ですが、 vite.config.ts に設定を加えることで MDX もルーティングに使用することが出来ます。 例えばこのように設定することで、 localhost:5173/foo にアクセスするとコンテンツが表示されます。

// vite.config.ts

import build from "@hono/vite-build/cloudflare-pages";
import adapter from "@hono/vite-dev-server/cloudflare";
import honox from "honox/vite";
import { defineConfig } from "vite";

// この辺を追加
import  mdx  from "@mdx-js/rollup'";
import remarkFrontmatter from "remark-frontmatter";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";

const jsxImportSource = "hono/jsx";

export default defineConfig({
  plugins: [
    honox({ devServer: { adapter } }),
    mdx({
      jsxImportSource,
      remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
    }),
    build(),
  ],
});
<!-- app/routes/foo.mdx -->
# Hello from MDX

It works!

こんな感じ。

http://localhost:5173/foo

便利ですね。ところで、せっかくの MDX なので React コンポーネントを動かしたいですよね。さらに言えば Island コンポーネントとして作成して必要な時だけ JS がダウンロードされて欲しいですよね。 しかしながら、上記設定では、例えば次のような Island コンポーネントを作成、インポートしたとしても、デプロイすると動かなくなります。

// app/islands/counter.tsx
import { useState } from 'hono/jsx'

export default function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}
<!-- app/routes/counter.mdx -->
import Counter from "../../islands/counter";

# Hello from MDX

<Counter />

npm run dev ではうごくが......

npm run deploy などでデプロイすると動かなくなる

これでは困りますね。ということでなんとかしましょう!というのが今回の記事です。

まず HonoX では、 islands ディレクトリや、 *.island.tsx などのファイルが参照に含まれていると Island コンポーネントとして動作する、という仕組みがあります。 そして、参照に含まれていた場合はコンパイル時に export const __importing_islands = true; が差し込まれることによってルーターミドルウェアで Context に Island コンポーネントがあることが設定され最終的にはクライアントへ JS が配信される、という仕組みになっています。
※ちなみにこの仕組みはプロダクションビルド時にのみ動作する (開発ビルド時には常に配信される) ので、デプロイ後に動かなくなってしまうわけですね。

なので、同じ事を MDX ファイルに対してもやってあげれば、自動的にクライアントへ JS が配信されるはず、ということでやっていきましょう。 現状一番簡単なのはわたしが昨日作ったパッケージを使うことですが、それでは面白くないので自作していきましょう。 やることとしては、 MDX の参照に Island コンポーネントが含まれていれば export const __importing_islands = true; を差し込むこと、なので、下記のような Vite (Rollup) プラグインを書くことで動作します。

// src/plugins/mdx-island.ts
import { compile, type CompileOptions } from "@mdx-js/mdx";
import precinct from "precinct";
import { type Plugin } from "vite";

const mdx = (opts: Readonly<CompileOptions>): Plugin => {
  return {
    name: "mdx-island",
    async transform(source, id) {
      if (id.endsWith(".mdx")) {
        const code = await compile(source, opts);
        const deps = precinct(code.value, { type: "tsx" }) as string[];
        const hasIslands = deps.some((w) => /\/islands\/.*$/.test(w));
        if (hasIslands) {
          return {
            code: `${code.value}\nexport const __importing_islands = true;`,
          };
        }

        return { code: code.value };
      }
    },
  };
};

precinct は JS/TS コードから依存だけを取得してくれるパッケージです。これに JSX へコンパイル済み MDX を渡すことで、 MDX の参照が取得できます。 その取得できた依存に対して、 islands/* があればとりあえず export const __importing_islands = true; を差し込むだけのコードです。 これをデプロイすると動きます。簡単ですね。

うごくよ~

これで完成です。あとは依存の依存に対応だとか、 .island.tsx のときなどの対応を入れれば使うことが出来るハズです。 ということで、使いたい系記事でした。ではでは。