Blog

Astro - remarkプラグインを作ってGFMのアラート記法を実現する

私はこのサイトを Astro で構築していて、ブログ記事をマークダウン(.md, .mdx)で記述しています。

例えば、GitHub Flavored Markdown(GFM)には、ノートや警告を視覚的に目立たせるアラート記法があります。

> [!NOTE]
> ここに補足情報を書きます。
> [!WARNING]
> 注意が必要な内容はこちら。

GitHubのREADMEでよく見かけるあの色付きブロックです。

しかし、AstroはMarkdownをHTMLに変換する際、このアラート記法をデフォルトではサポートしていません。変換後は通常の <blockquote> になってしまいます。

既製のプラグインを使う方法もありますが、仕組みさえわかればそれほど難しい実装ではありません。自分で作ればHTML構造、クラス名、アイコン、タイトルなど自由にカスタマイズ可能です。

この記事では、remarkプラグインをゼロから自作する方法を解説します。

remark vs rehype - どちらでプラグインを作るか

AstroのMarkdown処理パイプラインは、unified というエコシステムの上に成り立っています。そのなかで主役となるのが remarkrehype の2つです。

  • remark はMarkdownを扱うプロセッサです。入力したMarkdownテキストを mdast(Markdown Abstract Syntax Tree)と呼ばれるツリー構造に変換し、プラグインはそのツリーを自由に操作できます。「Markdownのテキストを読んで構造を変えたい」場合はremarkプラグインが適しています。

  • rehype はHTMLを扱うプロセッサです。こちらは hast(Hypertext Abstract Syntax Tree)を操作します。mdastがhastに変換されたあとで動作するため、出力HTMLの構造を直接いじりたい場合に使います。

unified

アラート記法の実装には、どちらも使えます。ただし今回は remark を選びました。理由はシンプルで、判定と変換をMarkdownのツリー上で完結させられるからです。blockquoteノードを見つけて別のノードに置き換えるだけなので、remarkのほうが直感的に書けます。

remarkプラグインの作り方

remarkプラグインの要点を整理すると次の通りです。

  • unist-util-visit で対象ノードをたどる
  • 「対象ノードを置き換える」または「新たなノードを追加する」
  • data.hName / data.hProperties でHTML変換後の構造を制御する

プラグインの基本構造

remarkプラグインは、ツリーを受け取って何もreturnしない関数を返す関数です。

関数を返す理由は、オプションを受け取れるようにするためです。今回はオプションなしですが、同じ形に従います。

import type { Root } from 'mdast';
export default function remarkMyPlugin() {
return (tree: Root) => {
// treeを操作する
};
}

ノードをたどる - unist-util-visit

ツリーの特定ノードを探すには unist-util-visit を使います。

第2引数でノードタイプを指定します。ここでは 'blockquote' を指定しているので、Markdownの > から始まるブロックが対象になります。コールバックにはnode(対象ノード)、index(親の中での位置)、parent(親ノード)が渡されます。

import type { Blockquote, Parent, Root } from 'mdast';
import { visit } from 'unist-util-visit';
export default function remarkMyPlugin() {
return (tree: Root) => {
visit(tree, 'blockquote', (node: Blockquote, index: number | undefined, parent: Parent | undefined) => {
// blockquoteノードが見つかるたびに呼ばれる
});
};

ノードを置き換える

visit のコールバック内で parent.children[index] を書き換えると、そのノードを別のノードに置き換えられます。

data.hNamedata.hProperties は、rehypeがこのノードをHTMLに変換するときに使うメタ情報です。hName でタグ名を、hProperties でHTML属性を指定できます。className を配列で渡すと class 属性になります。

TypeScriptでは mdast の型と合わなくなるので、as unknown as が必要になる場面があります。

visit(tree, 'blockquote', (node: Blockquote, index: number | undefined, parent: Parent | undefined) => {
if (!parent || index === undefined) return;
// ここで parent.children[index] に新しいノードを代入する
parent.children[index] = {
type: 'myCustomNode',
data: {
hName: 'div', // HTMLに変換されたときのタグ名
hProperties: {
className: ['my-class'], // HTMLに変換された時のクラス名
},
},
children: node.children, // 元のblockquoteの子ノードを引き継ぐ
} as unknown as Blockquote;
});

子ノードに独自ノードを追加する

children 配列に好きなノードを追加できます。たとえばタイトル用のdivとコンテンツ用のdivに分けたい場合は次のようにします。

type の文字列('alertCard', 'alertTitle' など)は自由に決められます。これらはmdastの世界だけで使われる識別子で、最終的なHTMLには影響しません。HTMLの構造は hNamehProperties が決めます。

parent.children[index] = {
type: 'alertCard',
data: {
hName: 'div',
hProperties: { className: ['alert-card', 'alert-note'] },
},
children: [
{
type: 'alertTitle',
data: {
hName: 'div',
hProperties: { className: ['alert-title'] },
},
children: [{ type: 'text', value: 'Note' }],
},
{
type: 'alertContent',
data: {
hName: 'div',
hProperties: { className: ['alert-content'] },
},
children: node.children,
},
],
} as unknown as Blockquote;

画像ノードを埋め込む

mdastには image ノードがあるため、HTMLの <img> をツリー上で表現できます。

{
type: 'image',
url: '/icons/alert-note.svg',
alt: '',
data: {
hProperties: { className: ['alert-icon'] },
},
}

実装したプラグイン

実装したプラグインと実際に使った場合の表示は次の通りです。

この実装ではタイトルを付与できるようにしました。

実装
import type { BlockContent, Blockquote, Parent, Root } from 'mdast';
import { visit } from 'unist-util-visit';
const ALERT_TYPES = {
NOTE: 'note',
TIP: 'tip',
IMPORTANT: 'important',
WARNING: 'warning',
CAUTION: 'caution',
} as const;
export default function remarkAlert() {
return (tree: Root) => {
visit(tree, 'blockquote', (node: Blockquote, index: number | undefined, parent: Parent | undefined) => {
if (!parent || index === undefined) return;
const firstParagraph = node.children[0];
if (!firstParagraph || firstParagraph.type !== 'paragraph') return;
const firstChild = firstParagraph.children[0];
if (!firstChild || firstChild.type !== 'text') return;
// アラート宣言行を判定
const lines = firstChild.value.split('\n');
const match = lines[0].match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\s+title=(.+))?$/);
if (!match) return;
// アラートのタイプとタイトルを取得
const type = match[1] as keyof typeof ALERT_TYPES;
const className = ALERT_TYPES[type];
const customTitle = match[2]?.trim() ?? '';
// アラート宣言行を除去
lines.shift();
const remaining = lines.join('\n');
if (remaining.trim()) {
firstChild.value = remaining;
} else {
firstParagraph.children.shift();
}
if (firstParagraph.children.length === 0) {
node.children.shift();
}
parent.children[index] = {
type: 'alertCard',
data: {
hName: 'div',
hProperties: {
className: ['alert-card', `alert-${className}`],
},
},
children: [
{
type: 'alertTitle',
data: {
hName: 'div',
hProperties: {
className: ['alert-title'],
},
},
children: [
{
type: 'image',
url: `/icons/alert-${className}.svg`,
alt: '',
data: {
hProperties: {
className: ['alert-icon'],
},
},
},
{
type: 'text',
value: customTitle,
},
],
} as unknown as BlockContent,
{
type: 'alertContent',
data: {
hName: 'div',
hProperties: {
className: ['alert-content'],
},
},
children: node.children,
} as unknown as BlockContent,
],
} as unknown as Blockquote;
});
};
}
マークダウン
> [!NOTE]
> note を記述します。
> タイトルなしです。
> [!TIP] title=tip title
> tip を記述します。
> 英語タイトルありです。
> [!IMPORTANT] title=重要です
>
> important を記述します。
>
> 日本語タイトルありです。
> [!WARNING] title=警告です
>
> warning を記述します。
>
> > blockquote を含みます。
> [!CAUTION] title=危険です
>
> caution を記述します。
>
> > [!NOTE] title=note title
> > アラート記法のNOTEを含みます。

実際の表示:

note を記述します。 タイトルなしです。

tip title

tip を記述します。 英語タイトルありです。

重要です

important を記述します。

日本語タイトルありです。

警告です

warning を記述します。

blockquote を含みます。

危険です

caution を記述します。

note title

アラート記法のNOTEを含みます。

【補足】 Astroへの組み込み

作成したプラグインは、astro.config.mjs に次の通り実装すると使えるようになります。

astro.config.mjs
import { unified } from '@astrojs/markdown-remark';
import remarkAlert from './path/to/directory/remark-alert';
export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkAlert],
}),
},
});

まとめ

仕組みを理解してしまえば、アラートの種類の追加やHTML構造の変更も自由自在です。既製プラグインをそのまま使うより、一度自分で実装してみることをおすすめします。