[remark] remark-directiveでZenn風のcalloutブロック(:::message / :::info / :::warn / :::alert)を自作した

[remark] remark-directiveでZenn風のcalloutブロック(:::message / :::info / :::warn / :::alert)を自作した

illustrated by soto

やりたかったこと

Zennのような :::message 記法でcalloutブロックを書きたかった。

具体的には、MDXの中でこう書くと:

:::message
シンプルなメッセージ
:::
 
:::info
補足情報
:::
 
:::warn
注意事項
:::
 
:::alert
強い警告
:::

それぞれスタイル付きのブロックとして表示される、という仕組みを作りたい。

このサイトはNext.js(App Router)+ MDXで構成されていて、記事はビルド時に unified パイプラインでHTMLにコンパイルしている。つまりMDXの変換パイプラインにremarkプラグインを差し込めば実現できる。

remark-directiveとは

remark-directive は、Markdownにディレクティブ構文を追加するremarkプラグインである。

ディレクティブには3種類ある:

種類 構文 用途
テキスト :name[content]{attributes} インライン要素
リーフ ::name{attributes} 自己完結ブロック
コンテナ :::name\ncontent\n::: 子要素を持つブロック

今回使うのはコンテナディレクティブ:::で囲む形式)。

remark-directive 自体はMarkdownをAST(mdast)にパースするところまでしかやらない。パースされたディレクティブノードをどうHTMLに変換するかは、自分でremarkプラグインを書く必要がある

実装の流れ

1. パッケージのインストール

yarn add remark-directive

remark-directive-rehype という変換用のパッケージもあるが、今回はアイコンの挿入やクラス名の付与など独自の変換が必要なのでカスタムプラグインを書く。

2. カスタムremarkプラグインの作成

scripts/remark-callout.mjs を新規作成した。

import { visit } from "unist-util-visit";
 
const CALLOUT_TYPES = {
  message: { icon: "sticky_note_2" },
  info: { icon: "info" },
  warn: { icon: "warning" },
  alert: { icon: "error" },
};
 
export default function remarkCallout() {
  return (tree) => {
    visit(tree, (node) => {
      if (node.type !== "containerDirective") return;
 
      const config = CALLOUT_TYPES[node.name];
      if (!config) return;
 
      // ディレクティブノードをdivに変換
      const data = node.data || (node.data = {});
      data.hName = "div";
      data.hProperties = {
        className: ["callout", `callout-${node.name}`],
      };
 
      // blockquote はブロック子要素を受け取れる MDAST ノード型として利用。
      // hName: "div" で実際の HTML 出力は <div class="callout-body"> になる。
      const body = {
        type: "blockquote",
        data: {
          hName: "div",
          hProperties: { className: ["callout-body"] },
        },
        children: node.children,
      };
 
      if (config.icon) {
        // Material Symbolsのアイコンを先頭に挿入
        node.children = [
          {
            type: "paragraph",
            data: {
              hName: "span",
              hProperties: {
                className: [
                  "callout-icon",
                  "material-symbols-rounded",
                ],
              },
            },
            children: [{ type: "text", value: config.icon }],
          },
          body,
        ];
      } else {
        node.children = [body];
      }
    });
  };
}

ポイントは以下の通り:

  • node.data.hNamenode.data.hProperties を設定することで、remark-rehype がHTMLに変換する際のタグ名と属性を指定できる
  • visitcontainerDirective ノードだけを探索し、CALLOUT_TYPES に定義された名前(message, info, warn, alert)にマッチするものだけ変換する
  • 全タイプにMaterial Symbolsのアイコン名を <span class="material-symbols-rounded"> として先頭に挿入している(messagesticky_note_2infoinfowarnwarningalerterror

3. unifiedパイプラインへの組み込み

generate-posts.mjscreateProcessor()remark-directiveremark-callout を追加した。

import remarkDirective from "remark-directive";
import remarkCallout from "./remark-callout.mjs";
 
function createProcessor() {
  return unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkDirective)  // ディレクティブ構文のパース
    .use(remarkCallout)    // パース結果をHTML要素に変換
    .use(remarkRehype, { allowDangerousHtml: true })
    // ... 以降のrehypeプラグイン
}

プラグインの順序が重要で、remarkDirective(パース) → remarkCallout(変換) → remarkRehype(HTML化)の順にする必要がある。remarkRehype より後に置くとremarkプラグインなので動作しない。

4. CSSでスタイリング

.callout {
  display: flex;
  align-items: flex-start;
  gap: 0.65rem;
  margin: 1.25rem 0;
  padding: 0.85rem 1rem 0.85rem 1.75rem;
  border-radius: 8px;
  font-size: 0.92em;
  line-height: 1.65;
  position: relative;
  overflow: hidden;
}
 
.callout::before {
  content: "";
  position: absolute;
  background: var(--callout-color);
  width: 8px;
  height: 100%;
  top: 0;
  left: 0;
}
 
.callout-icon {
  flex-shrink: 0;
  font-size: 1.2em;
  line-height: 0;
  height: calc(1.65 / 1.2 * 1em);
  display: inline-flex;
  align-items: center;
  color: var(--callout-color);
}
 
.callout-body {
  min-width: 0;
  word-break: break-word;
}
 
.callout-body > * {
  margin: 0;
}
 
.callout-body > * + * {
  margin-top: 0.3em;
}
 
.callout-body p {
  white-space: pre-wrap;
}
 
.callout-body ul,
.callout-body ol {
  padding-left: 1.4em;
  list-style-type: disc;
}
 
/* message: テキスト色から自動導出 */
.callout-message {
  --callout-color: oklch(from var(--color-text) l c h / 0.45);
  background-color: oklch(from var(--color-text) l c h / 0.06);
}
 
/* info: くすんだブルー */
.callout-info {
  --callout-color: oklch(0.62 0.1 255);
  background-color: oklch(0.62 0.1 255 / 0.1);
}
 
/* warn: くすんだアンバー */
.callout-warn {
  --callout-color: oklch(0.72 0.12 85);
  background-color: oklch(0.72 0.12 85 / 0.1);
}
 
/* alert: くすんだレッド */
.callout-alert {
  --callout-color: oklch(0.58 0.14 25);
  background-color: oklch(0.58 0.14 25 / 0.1);
}
 
/* ライトモード */
@media (prefers-color-scheme: light) {
  .callout-info {
    --callout-color: oklch(0.52 0.12 255);
    background-color: oklch(0.52 0.12 255 / 0.08);
  }
  .callout-warn {
    --callout-color: oklch(0.62 0.14 85);
    background-color: oklch(0.62 0.14 85 / 0.08);
  }
  .callout-alert {
    --callout-color: oklch(0.5 0.16 25);
    background-color: oklch(0.5 0.16 25 / 0.08);
  }
}

各タイプでCSS変数 --callout-color を定義し、background-color に使い回すことでコードの重複を抑えた。色はサイト全体のくすんだトーンに合わせてoklchで指定している。

左のカラーバーは border-left ではなく ::before 疑似要素で実装している。.calloutposition: relative; overflow: hidden; を設定し、::beforeposition: absolute で左端に配置することで、border-radius による角丸が自然にバーにも適用される。

callout-message はサイトのテキスト色(--color-text)をベースにCSS相対カラー構文で自動導出しており、テーマカラーの変更に追従する。

callout-icon はアイコンフォントの行高を0にして inline-flex で中央揃えするテクニックで、テキストの行高(1.65)に対して正確に縦中央揃えしている。

callout-bodywhite-space: pre-wrapp タグに限定して適用することで、リストや他のブロック要素への意図しない干渉を防いでいる。ライトモードでは視認性のため透過度をやや下げた値(8%)を使用している。

ハマったポイント

:::note info 構文が動かない

当初はZennと同じ :::note info という構文にしたかった。しかし remark-directive では、ディレクティブ名の後ろにスペース区切りでテキストを書いても属性として認識されない。

remark-directive の属性構文は :::note{.info} のようにブレース {} で囲む必要がある。

:::note{.info}
これなら動く
:::

だが記述の手軽さを考えて、:::info / :::warn / :::alert をそれぞれ独立したディレクティブとして定義する方式に変更した。結果としてよりシンプルになった。

<pre> にすると .markdown-body pre のスタイルが当たる

callout内の改行を保持するために、callout-bodyを <pre> タグで出力したところ、コードブロック用の .markdown-body pre のスタイル(背景色やpadding)が意図せず適用されてしまった。

解決策として <pre> は使わず、<div>white-space: pre-wrap を指定することで改行を保持しつつスタイルの衝突を回避した。

.callout-body {
  white-space: pre-wrap;
  word-break: break-word;
}

出力されるHTML

最終的に、以下のようなHTMLが生成される。

<!-- :::message -->
<div class="callout callout-message">
  <span class="callout-icon material-symbols-rounded">sticky_note_2</span>
  <div class="callout-body">メッセージ</div>
</div>
 
<!-- :::info -->
<div class="callout callout-info">
  <span class="callout-icon material-symbols-rounded">info</span>
  <div class="callout-body">インフォメーション</div>
</div>

デモ

実際にこの記事内で動いている様子がこちら。

sticky_note_2

これは :::message で書いたブロック。シンプルなメモや補足に使う。

info

これは :::info で書いたブロック。補足情報や参考情報を伝えたいときに使う。

warning

これは :::warn で書いたブロック。注意してほしいポイントがあるときに使う。

error

これは :::alert で書いたブロック。やってはいけないことや重大な注意事項に使う。

まとめ

  • remark-directive はディレクティブ構文のパースのみを担当する。変換ロジックはカスタムプラグインで書く
  • node.data.hName / node.data.hProperties を設定すれば remark-rehype が適切なHTML要素に変換してくれる
  • ディレクティブの属性構文({.class})は直感的でないので、用途に応じて独立ディレクティブにする方がシンプルな場合もある
  • 既存のMarkdownスタイル(.markdown-body pre 等)との衝突には注意が必要

参考リンク

[Next.js MDX] Markdown形式のテーブルを表示する(remark-gfm + next-mdx-remote)