ブログにNotion風のCalloutを導入してみた

記事を書いていると「ここは補足」「ここは注意してほしい」といった、本文とは少し性質の違う情報を差し込みたくなる。これまでは引用(>)や太字で代用していたが、Notionのcalloutのように色とアイコンで種類が伝わるボックスが欲しくなったので導入してみた。

この記事では、以下をまとめている。

  • [How] Astroのブログにcalloutを導入する手順
  • [What] 実際にどう書けて、どう表示されるのか
  • [Note] 現状の制限

記法

:::<種類>::: で囲むだけ。種類はnote / info / tip / warning / dangerの5つ。

:::warning
ここに注意書きを書く。
:::

導入手順

1. remark-directiveを入れる

Markdownの標準記法には「ブロックを囲んで意味を与える」構文がない。そこでremark-directiveを使う。これはgeneric directives proposalで提案されている:::記法をremarkのパーサーに追加するプラグインで、:::note:::containerDirectiveというノードとして解析してくれる。

pnpm add remark-directive

ただしremark-directiveがやるのは解析だけで、HTMLへの変換方法は何も決めない。どんなタグ・どんなクラスで出力するかは自分で書く必要がある。

2. ノードにクラスを付けるプラグインを書く

containerDirectiveノードのdata.hName / data.hPropertiesを設定すると、mdast→hast変換時にそのタグ・属性で出力される。ここでは<div class="callout callout--note">にしたいので、そう指定する。

// src/plugins/remark-callout.ts
const CALLOUT_TYPES = new Set(['note', 'info', 'tip', 'warning', 'danger']);

function transform(node: Root | RootContent): void {
  const children = (node as { children?: RootContent[] }).children;
  if (!children) return;

  for (const child of children) {
    if (isContainerDirective(child)) {
      const directive = child as unknown as ContainerDirectiveNode;
      if (CALLOUT_TYPES.has(directive.name)) {
        const data = directive.data ?? (directive.data = {});
        data.hName = 'div';
        data.hProperties = {
          ...data.hProperties,
          className: ['callout', `callout--${directive.name}`],
        };
      }
    }
    transform(child);
  }
}

export default function remarkCallout() {
  return (tree: Root): void => {
    transform(tree);
  };
}

ポイントは子ノードには一切手を触れないこと。中身は通常のMarkdownブロックとしてそのまま処理されるので、リストもコードブロックもテーブルも書ける。ネストしたcalloutにも対応するため、再帰的に走査している。

3. astro.configに登録する

remarkDirective(解析)→remarkCallout(変換)の順に並べる。

// astro.config.mjs
markdown: {
  remarkPlugins: [
    remarkMermaid,
    remarkDirective,
    remarkCallout,
    remarkTwitterEmbed,
    remarkLinkCard,
  ],
},

4. CSSを書く

種類ごとの色とアイコンをCSS変数で切り替える。背景色はcolor-mix()でテーマの背景色にアクセントカラーを12%混ぜて作っているので、ライト/ダークどちらでも自然な濃さになる。

.prose .callout {
  position: relative;
  padding: 1rem 1.25rem 1rem 3rem;
  border-left: 4px solid var(--callout-color);
  border-radius: 0 var(--radius) var(--radius) 0;
  /* color-mix() 未対応ブラウザ向けのフォールバック */
  background-color: var(--bg-secondary);
  background-color: color-mix(in srgb, var(--callout-color) 12%, var(--bg-secondary));
}

.prose .callout::before {
  content: var(--callout-icon);
  position: absolute;
  top: 1rem;
  left: 1rem;
}

.prose .callout--warning {
  --callout-color: var(--callout-warning-color);
  --callout-icon: '⚠️';
}

アイコンは::beforecontentに絵文字を入れているだけなので、SVGアイコンを使いたくなったら差し替えられる。左パディングを3rem取ってアイコンを絶対配置しているため、2行目以降もテキストの左端が揃う。

以下、実際の表示を種類ごとに並べてみる。

5種類の基本形

noteは補足やメモ。グレー系で主張が弱い。

infoは前提知識や仕様の説明。青系。

tipはコツや小技。緑系。

warningは注意点や落とし穴。オレンジ系。

dangerは破壊的操作や非推奨。赤系。

複数段落を入れる

1つ目の段落。calloutの中身は通常のMarkdownブロックとして解釈される。

2つ目の段落。段落を分けても左のアイコンは先頭に1つだけ表示される。

3つ目の段落。最後の要素の下マージンは消しているので、余白が間延びしない。

インライン記法を混ぜる

太字斜体打ち消しインラインコード外部リンクまで、通常の段落と同じように書ける。

外部リンクはrehype-external-linksが効くので、callout内でも別タブで開く。

リストを入れる

箇条書き:

  • 1つ目
  • 2つ目
    • ネストした項目
    • もう1つ
  • 3つ目

番号付き:

  1. まずこれ
  2. つぎにこれ
  3. 最後にこれ

コードブロックを入れる

callout内のコードブロックもShikiのハイライトが効く。

const CALLOUT_TYPES = new Set(['note', 'info', 'tip', 'warning', 'danger']);

長い1行はcalloutの内側で横スクロールする。

pnpm astro build --outDir ./dist --site https://example.com/very/long/path/to/check/horizontal/scroll/inside/callout

見出しを入れる

callout内のh3

長い解説をcalloutに閉じ込めたいときは見出しも使える。ただし目次には拾われるので、使いすぎると目次が散らかる。

さらにh4

本文。

引用とテーブルを入れる

引用ブロック。calloutの枠線と引用の枠線が二重になるので、あまり相性は良くない。

種類用途
note補足・メモグレー
info前提・仕様
tipコツ・小技
warning注意・落とし穴オレンジ
danger破壊的操作

calloutをネストする

外側を4つ、内側を3つのコロンで書き分ける(外側のコロンを内側より多くしないと、先に閉じられてしまう)。

外側のwarning

内側のdanger。ボーダーとアイコンが二重に入るので、読みやすさのために基本は使わないほうがいい。

外側の続き。

連続して並べる

1つ目のcallout。

2つ目のcallout。同じ種類を並べるくらいなら、1つにまとめたほうが読みやすい。

種類を変えて3つ目。

長文を入れる

長めの文章を入れたときの見た目。日本語の長い文章を流し込んだときに折り返しが自然か、句読点や英単語pnpm buildが混ざっても崩れないかを確認しておきたい。The quick brown fox jumps over the lazy dog. 英数字が混在してもベースラインは揃う。

現状の制限

導入してみて分かった制限もいくつかある。

remark-directiveの属性記法(:::info{.custom-class}:::note{#anchor})は、書いても無視される。プラグイン側でnode.attributesを読んでいないため、クラスやidは出力に反映されない。個別にスタイルを当てたくなったら、プラグインを直す必要がある。

  • 未定義の種類: :::memoのように定義外の名前を書くと、クラスの付かない素の<div>として出力される。中身は消えないが、見た目はただの段落になる。
  • リーフ/インラインディレクティブ: ::note(コロン2つ)や:note[テキスト]はcalloutの対象外。前者は空の<div>になり、後者は段落の中に<div>が入り込むので、使わないほうがいい。
  • 目次との相性: callout内の見出しも目次に載る。

まとめ

remark-directiveを入れて、containerDirectivedata.hName / data.hPropertiesを付けるだけなので、実装は50行程度で済んだ。中身の処理をremarkに任せられるおかげで、リストでもコードブロックでもそのまま書けるのが気持ちいい。

:::記法はZennやDocusaurusでも使われているので、書き慣れた記法のまま自分のブログでも使えるようになったのが一番の収穫だった。