📝

remark プラグインを作って Astro で使う

2024/08/08に公開

モチベーション

私は個人サイトのコンテンツをすべて自分で管理しておきたい気持ちがあるので、基本的にコンテンツを markdown で書いている。

Astro では MDX integration を使うと markdown 内で Astro コンポーネントを使えるので、markdown で表現できない部分は Astro コンポーネントを使っていた。
しかし何となく違和感があったし、せっかく markdown という標準的な記法を採用しているのに Astro にロックインされてしまうのも嫌だった。

そんな中、最近 remark/rehype プラグインを作って markdown を拡張するほうが筋がいいのではないかと考えた。
いつか他のフレームワークに移植するとして、それが remark/rehype を使っているならばきっとプラグインごと移植できると思うので、コンテンツ移行の手間を減らせるはずである。

そこで勉強のため簡単なプラグインを自作してみることにした。

環境

  • remark-directive: 3.0.0
  • unist-util-is: 6.0.0
  • unist-util-visit: 5.0.0
  • @types/hast: 3.0.4
  • @types/mdast: 4.0.4
  • mdast-util-directive: 3.0.0
  • Astro: 4.13.1

またスタイリングに tailwindcss v3 を使用している。

前提知識

Astro の markdown に関するプラグインには remark のプラグインと rehype のプラグインがある。

remark と rehype についての詳しい説明は既に記事があると思うので譲るとして、今回は remark のプラグインを作ることにする。

remark では mdast
rehype では hast

の型を使うため、必要に応じて参照する。

実装

事前準備

remark-directive をインストールして読み込む。

astro.config.mjs
  import { defineConfig } from "astro/config";
  import tailwind from "@astrojs/tailwind";
+ import remarkDirective from "remark-directive";

  export default defineConfig({
    site: "https://example.com",
    integrations: [
      tailwind(),
    ],
+   markdown: {
+     remarkPlugins: [
+       remarkDirective,
+     ],
+   },
  });

これにより generic directives proposal で提唱されている記法を扱えるようになり、mdast の型に

  • ContainerDirective
  • LeafDirective
  • TextDirective

の三つが追加される。

directive 記法自体も使っていくが、個人的には上記の三つの型が追加されることによる恩恵が大きいと思っている。

というのも mdast には markdown で記述可能な要素についての型しか無い。
そのため <div><video> などは Html 型で直接 HTML を書く以外に表現できないと思われる。

しかし remark-directive で追加される型は data プロパティに hast の文法で記述することができる。
そのため「YouTube のリンクを動画埋め込みに変換したい」というようなプラグインを remark プラグインとして書くことが容易になるのである。
(もちろんこのケースなら rehype プラグインとして作ることも可能だが、処理の順番の都合で remark 側で処理したいということもあろうかと思うので、書けて悪いことはない。)

video プラグインを作る

早速 remark-directive で追加された記法を使い

::video{src=/videos/test.mp4}

というように書いたら

<div class="mb-8 flex justify-center">
  <video controls="" preload="metadata">
    <source src="/videos/test.webm" type="video/webm">
    <source src="/videos/test.mp4" type="video/mp4">
  </video>
</div>

という HTML になるようにする。
webm の動画ファイルは mp4 の動画ファイルと同一ディレクトリに置いてあるものとするが、もし存在しなかったら webm の source タグは記述されないようにする。

実装は以下のようになる。

src/remarkVideo/index.ts
/// <reference types="mdast-util-directive" />

import { visit } from "unist-util-visit";
import type { Root } from "mdast";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import type { ElementContent } from "hast";

interface Props {
  baseURL: string;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default function remarkVideoPlugin({ baseURL }: Props) {
  return function (tree: Root) {
    visit(tree, "leafDirective", (node) => {
      if (node.name !== "video") return;
      if (!node.attributes?.src) return;

      // webm の存在チェックのためにパスを構築する
      const mp4VideoPath = path.join(
        __dirname,
        "../../../public",
        node.attributes.src,
      );
      const webmVideoPath = `${mp4VideoPath.slice(0, -3)}webm`;
      const mp4VideoUrl = path.join(baseURL, node.attributes.src);

      const sources: ElementContent[] = [];
      if (fs.existsSync(webmVideoPath)) {
        sources.push({
          type: "element",
          tagName: "source",
          properties: {
            src: `${mp4VideoUrl.slice(0, -3)}webm`,
            type: "video/webm",
          },
          children: [],
        });
      }
      sources.push({
        type: "element",
        tagName: "source",
        properties: {
          src: mp4VideoUrl,
          type: "video/mp4",
        },
        children: [],
      });

      node.data = {
        hName: "div",
        hProperties: {
          class: ["mb-8", "flex", "justify-center"],
        },
        hChildren: [
          {
            type: "element",
            tagName: "video",
            properties: {
              controls: true,
              preload: "metadata",
            },
            children: sources,
          },
        ],
      };
    });
  };
}

/// <reference types="mdast-util-directive" /> を書いておかないと、unist-util-visit で "leafDirective" の絞り込みを行った結果の node の型が never になってしまう。
TypeScript で開発している場合は記述必須である。

また node.data にどのようなデータを指定すればいいかは remark-directive の README に書いてあるが、そこでは hastscript を使っている。
しかし別に使わなくても問題はない。

  • node.data.hName は hast の tagName
  • node.data.hProperties は hast の properties
  • node.data.hChildren は hast の ElementContent の配列

であるということを把握しておけば自分で書くことができる。
個人的には自分で書くほうがわかりやすかった。

このプラグインは引数を持っているので、astro.config.mjs で読み込む際に引数を渡す。

astro.config.mjs
  import { defineConfig } from "astro/config";
  import tailwind from "@astrojs/tailwind";
  import remarkDirective from "remark-directive";
+ import remarkVideoPlugin from "./src/plugins/remarkVideo";

  export default defineConfig({
    site: "https://example.com",
    integrations: [
      tailwind(),
    ],
   markdown: {
      remarkPlugins: [
        remarkDirective,
+       [remarkVideoPlugin, { baseURL: import.meta.env.BASE_URL }],
      ],
    },
  });

YouTube のリンクを動画埋め込みに変換するプラグインを作る

https://www.youtube.com/watch?v=id

というように YouTube の URL だけの行があったら、それを動画埋め込みに変換する。

実装は以下のようになる。

src/remarkEmbedYouTube/index.ts
import { visit } from "unist-util-visit";
import type { Root } from "mdast";
import { is } from "unist-util-is";
import type { ContainerDirective } from "mdast-util-directive";

export default function remarkEmbedYouTubePlugin() {
  return function (tree: Root) {
    visit(tree, "paragraph", (node, index, parent) => {
      if (index === undefined || parent === undefined) return;

      // https://www.youtube.com/watch から始まる URL のみ記述した場合が置換対象
      const firstChild = node.children[0];
      if (!is(firstChild, "link")) return;
      if (!firstChild.url.startsWith("https://www.youtube.com/watch")) return;
      const firstLinkChild = firstChild.children[0];
      if (!is(firstLinkChild, "text")) return;
      if (firstLinkChild.value !== firstChild.url) return;

      const id = new URL(firstChild.url).searchParams.get("v");
      if (id === null) return;

      const newNode: ContainerDirective = {
        type: "containerDirective",
        name: "youtube",
        data: {
          hName: "div",
          hProperties: {
            class: ["mb-8", "relative", "block", "aspect-video"],
          },
          hChildren: [
            {
              type: "element",
              tagName: "iframe",
              properties: {
                src: `https://www.youtube-nocookie.com/embed/${id}`,
                allowFullscreen: true,
                title: "YouTube video player",
                class: [
                  "absolute",
                  "left-0",
                  "top-0",
                  "h-full",
                  "w-full",
                  "border-0",
                ],
              },
              children: [],
            },
          ],
        },
        children: [],
      };
      parent.children[index] = newNode;
    });
  };
}

元の Paragraph Node を ContainerDirective Node で置き換えている。
前述の通り hast の記法で書けるので、mdast では表現できない要素への書き換えをするのに便利である。

あと unist-util-is を使うと「対象の Node が link か?」というような判定&型ガードを自力で書かなくて良い。
rehype だと hast-util-is-element が同様のことができるユーティリティなので、頑張って型ガードを定義する前に導入すると良いだろう。
(私は最初、存在を知らずに型ガードを自力で定義していた。)

このプラグインは引数を持たないので、astro.config.mjs には引数無しで読み込ませればよい。

astro.config.mjs
  import { defineConfig } from "astro/config";
  import tailwind from "@astrojs/tailwind";
  import remarkDirective from "remark-directive";
  import remarkVideoPlugin from "./src/plugins/remarkVideo";
+ import remarkEmbedYouTubePlugin from "./src/plugins/remarkEmbedYouTube";

  export default defineConfig({
    site: "https://example.com",
    integrations: [
      tailwind(),
    ],
   markdown: {
      remarkPlugins: [
        remarkDirective,
        [remarkVideoPlugin, { baseURL: import.meta.env.BASE_URL }],
+       remarkEmbedYouTubePlugin,
      ],
    },
  });

おわりに

remark-directive を導入するまではどう書けばいいのか結構悩んでいたのだが、導入したらとても楽に書けるようになった。
難しそうで敬遠している方がいたらぜひ一度書いてみると良いと思う。

chot Inc. tech blog

Discussion