Astro - remarkプラグインを作ってGFMのアラート記法を実現する
- astro
- remark
- rehype
- markdown
- github
私はこのサイトを Astro で構築していて、ブログ記事をマークダウン(.md, .mdx)で記述しています。
例えば、GitHub Flavored Markdown(GFM)には、ノートや警告を視覚的に目立たせるアラート記法があります。
> [!NOTE]> ここに補足情報を書きます。
> [!WARNING]> 注意が必要な内容はこちら。GitHubのREADMEでよく見かけるあの色付きブロックです。
しかし、AstroはMarkdownをHTMLに変換する際、このアラート記法をデフォルトではサポートしていません。変換後は通常の <blockquote> になってしまいます。
既製のプラグインを使う方法もありますが、仕組みさえわかればそれほど難しい実装ではありません。自分で作ればHTML構造、クラス名、アイコン、タイトルなど自由にカスタマイズ可能です。
この記事では、remarkプラグインをゼロから自作する方法を解説します。
remark vs rehype - どちらでプラグインを作るか
AstroのMarkdown処理パイプラインは、unified というエコシステムの上に成り立っています。そのなかで主役となるのが remark と rehype の2つです。
-
remark はMarkdownを扱うプロセッサです。入力したMarkdownテキストを mdast(Markdown Abstract Syntax Tree)と呼ばれるツリー構造に変換し、プラグインはそのツリーを自由に操作できます。「Markdownのテキストを読んで構造を変えたい」場合はremarkプラグインが適しています。
-
rehype はHTMLを扱うプロセッサです。こちらは hast(Hypertext Abstract Syntax Tree)を操作します。mdastがhastに変換されたあとで動作するため、出力HTMLの構造を直接いじりたい場合に使います。

Content as structured data: unified compiles content and provides hundreds of packages to work with content
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.hName と data.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の構造は hName と hProperties が決めます。
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 を記述します。 英語タイトルありです。
important を記述します。
日本語タイトルありです。
warning を記述します。
blockquote を含みます。
caution を記述します。
アラート記法のNOTEを含みます。
【補足】 Astroへの組み込み
作成したプラグインは、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構造の変更も自由自在です。既製プラグインをそのまま使うより、一度自分で実装してみることをおすすめします。