Markdown パイプラインの拡張
zfb の Markdown / MDX パイプラインに新しい機能を追加する方法 — ディレクティブで足りるとき、Rust の visitor を書くとき、AST コンバーター自体に新しいアームが必要なとき
このページで扱う内容
zfb-content の Markdown パイプラインをエンジン側で拡張するための面(サーフェス)です。新しい構文レベルの機能 — admonition のバリエーション、見出しの書き換え、独自のコードブロック処理、リンクリゾルバなど — を追加したいとき、それをどこに置き、どう組み込むかをこのページで説明します。
Markdown / MDX を HTML に変換するパイプラインは
crates/zfb-content にあります。これはプラグイン的な構造になっています。すなわち、mdast にパースし、mdast visitor を実行し、hast に変換し、hast visitor を実行し、シリアライズする、という流れです。ランタイム / ユーザーランドのプラグインローダーは存在せず、すべての visitor はバイナリにコンパイルされます。ただし、新しい visitor を in-tree で追加するための面は小さく安定しており、「新しい Markdown 機能がほしい」という変更のほとんどはここに収まります。
機能が単に新しいディレクティブ名(:::callout、
::youtube、:badge)であれば、このページは不要です。ディレクティブレジストリが処理してくれるため、一行の
register 呼び出し以上に Rust へ手を入れる必要はありません。そのパスについては Custom Directives
を参照してください。
判断: ディレクティブ、visitor、それとも AST アーム
| やりたいこと | パス | 置き場所 |
|---|---|---|
JSX コンポーネントにコンパイルされる :::name / ::name / :name 構文を追加する | ディレクティブを登録する | Pipeline::with_defaults + DirectiveRegistry(Custom Directives) |
既存の AST ノードを書き換える(見出しのスラッグ化、コードブロックのラップ、<img> の JSX マーカーへの置き換え、リンクの正規化) | visitor を書く | crates/ 配下の新規ファイル |
| markdown-rs はパースするが zfb が現状捨てている Markdown 構文を表面化する(テーブル、脚注、math、定義、参照スタイルのリンク) | AST コンバーターを拡張する | crates/ の mdast_to_hast |
| markdown-rs がパースできない、まったく新しい Markdown 構文を追加する | 対象外 | markdown クレートへのアップストリーム変更、その後 zfb のコンバーターアーム |
ほとんどの貢献は 2 行目 — 新しい visitor — に収まります。
2 段階パイプライン
パイプラインは、2 つの異なる AST に対して 2 つの異なるパスを実行します。
markdown-rs と MDX を意識したオプションを使って、入力を mdast(
markdown::mdast::Node)にパースします。mdast visitor —
MdastVisitor実装が markdown AST をその場で書き換えます。一部の変換は HTML 構造が存在する前にしか意味をなさないため、最初に実行されます。ディレクティブレジストリは mdast visitor です。:::name/:::で区切られた段落の並びを単一の MDX JSX 要素に畳み込みますが、これは<p>タグが現れた後よりも mdast 上のほうがはるかに扱いやすいからです。mdast_to_hastを介して mdast → hast に変換します。hast は zfb の最小限の HTML AST(HastNode)です。hast visitor —
HastVisitor実装が HTML AST をその場で書き換えます。HTML 要素構造を対象とする書き換え(見出しアンカー、<figure>ラッパー、<img>→ JSX マーカー、シンタックスハイライト)のほとんどはここにあります。zfb_content::serializerで hast を HTML 文字列にシリアライズします。
操作対象に合った段階を選んでください。経験則として、元の
Markdown 構造(ディレクティブ、段落の並び、特定のリンク参照スタイル)を見る必要があるなら
mdast を、HTML 要素構造(<pre>、見出しレベル、width 属性を持つ
<img>)を見る必要があるなら hast を使います。
visitor トレイトの形
両方の visitor トレイトは意図的に小さく作られており、メソッドは 1 つだけで、ノードに対して一度だけ呼ばれ、その場で変更を行います。
pub trait MdastVisitor {
fn visit(&mut self, node: &mut MdastNode);
}
pub trait HastVisitor {
fn visit(&mut self, node: &mut HastNode);
}パイプラインは visit をルートノードに対して厳密に一度だけ呼び出します。
再帰は visitor の責任であり、自動的なウォークはありません。
典型的な hast visitor は次のようになります。
use crate::pipeline::{HastNode, HastVisitor};
pub struct MyPlugin;
impl HastVisitor for MyPlugin {
fn visit(&mut self, node: &mut HastNode) {
match node {
HastNode::Root { children }
| HastNode::Element { children, .. } => {
for child in children {
// mutate `child` here, then recurse
self.visit(child);
}
}
_ => {}
}
}
}visitor は状態を持つことができます(ドキュメントごとのスラッグカウンタ、設定オプション、共有リソースへの参照など)。HeadingLinksPlugin は
github-slugger 相当の重複排除のために HashMap<String, usize> を保持し、SyntectPlugin
は Arc<Highlighter> を保持して、ビルド内のすべてのコードブロックでシンタックステーマを共有します。
Core にするか Opt-in 機能にするか
どちらも Rust の visitor として存在しますが、どこに組み込むかは、何人の消費者がその機能を必要とするかによって決まります。
Core(Pipeline::with_defaults に組み込む) にするのは、その動作が普遍的なとき
です。つまり、すべてのコンテンツコレクション消費者が同じ形で必要とし、オプトアウトする正当な理由がないときです。例: HeadingLinksPlugin、CodeTitlePlugin、SyntectPlugin。
Opt-in(Pipeline::with_defaults_and_features に組み込む) にするのは、その
機能が価値はあるものの普遍的には必要とされないとき、あるいはプロジェクト固有の設定(ソースマップ、フィーチャーフラグ、独自オプション)を必要とするときです。例: zfb-md-extras の 12 個すべての機能。
昇格のしきい値は 3 消費者ルールに従います。 すなわち、同じパターンが 3 つの異なる zfb 消費者プロジェクトで手書きされるまでは抽出しないということです。1 つのプロジェクトの利便性はレシピにすぎません。
ファイルの置き場所
Core プラグインは crates/ 配下に、Opt-in
機能は crates/ 配下に置きます。慣例として、機能ごとに 1 ファイルです。
crates/ zfb- content/ src/ plugins/
├── cjk_ friendly. rs
├── code_ title. rs
├── directives. rs
├── external_ links. rs
├── hard_ breaks. rs
├── heading_ links. rs
├── mermaid. rs
├── resolve_ links. rs
├── strip_ md_ ext. rs
├── syntect_ plugin. rs
├── toc. rs # heading- marker TOC (wired via features)
└── util/
crates/ zfb- md- extras/ src/
├── code_ enrichment. rs
├── code_ tabs. rs
├── github_ alerts. rs
├── github_ autolinks. rs
├── heading_ marker_ toc. rs
├── image_ dimensions. rs
├── link_ validation. rs
├── mermaid. rs
├── reading_ time. rs
├── ruby. rs
├── toc_ export. rs
└── transclude. rsCore プラグインの場合は、ファイルを追加し、crates/
から再エクスポートします。
// in plugins.rs
pub mod my_plugin;
pub use my_plugin::MyPlugin;Opt-in 機能の場合は、ファイルを追加し、対応する
MarkdownConfig::features フラグでゲートしたうえで、crates/
からその機能を公開します。
テストは通常、プラグインと同じファイル内の #[cfg(test)] mod tests {} ブロックに置き、プラグインをまたぐ統合ケースは
crates/ に置きます。既存の
tests/ がリファレンスとなる形です。
デフォルトパイプラインへの組み込み
Pipeline::with_defaults() はプロジェクト全体のデフォルトプラグインチェーンです。
ここに visitor を追加すると、デフォルトを使うすべての呼び出し元が自動的にそれを取り込みます。正しい段階に追加してください。
// in crates/zfb-content/src/pipeline.rs, inside Pipeline::with_defaults()
let mut p = Self::with_mdx();
// mdast phase
p.add_mdast_visitor(Box::new(AdmonitionsPlugin::new()));
// hast phase
p.add_hast_visitor(Box::new(HeadingLinksPlugin::new()));
p.add_hast_visitor(Box::new(CodeTitlePlugin::new()));
p.add_hast_visitor(Box::new(MyPlugin)); // <-- new
p.add_hast_visitor(Box::new(MermaidPlugin::new()));
p.add_hast_visitor(Box::new(SyntectPlugin::new(highlighter)));
pプラグインが opt-in の場合は、with_defaults() に入れないでください。
Pipeline::with_defaults_and_features() に組み込みます。これは MarkdownFeatures
設定構造体を受け取り、フラグがセットされている visitor だけを追加します。zfb-md-extras の 12 個の機能はすべてこの方法で組み込まれています。
ResolveLinksPlugin と StripMdExtensionPlugin は、単なるフィーチャーフラグではなくプロジェクト固有のソースマップを必要とするため、別途扱われます。
順序が重要
visitor の順序は負荷を担う(load-bearing)ものです。デフォルトの完全な根拠は
Pipeline::with_defaults_and_features のドキュメントコメントに記載されていますが、最もよく問題になるルールは次のとおりです。
HeadingLinksPluginは hast フェーズで最初に実行されます。 後で見出しを変更するものはすべて、スラッグ化されたid属性を見ることになります。TocPluginとTocExportPluginはこれらのid値に依存します。CodeTitlePluginはSyntectPluginより前に実行されます。 SyntectPlugin は<pre>要素全体をHastNode::Rawの HTML フラグメントに置き換えます。これが起きると、title="…"を運ぶdata-meta属性は構造化された AST としてはもう到達できなくなります。MermaidPluginはSyntectPluginより前に実行されます。 MermaidPlugin は mermaid コードブロックにdata-mermaid="true"のフラグを立て、SyntectPlugin はそのフラグを使って、図のソースをシンタックスハイライトせずスキップします。CodeEnrichmentPluginはSyntectPluginの後に実行されます。 これは syntect が出力する行ごとの<span class="line">構造を後処理します。syntect がそれらの span を生成する前には実行できません。ImageDimensionsPluginは hast フェーズでSyntectPluginより前に実行されます。<img>要素だけを扱い、見出し / コードブロックの visitor に対しては順序非依存です。GithubAlertsPluginは mdast フェーズで実行されます。mdast → hast 変換より前です。blockquote ノードを書き換え、AdmonitionsPlugin(これも mdast フェーズで実行される)はその結果を独立して読み取ります。TranscludePluginは mdast フェーズで最初に実行されます。 取り込まれた コンテンツは、他のどの visitor が見るよりも前に AST にマージされなければなりません。
新しいプラグインを挿入するときは、こう問いかけてください。後続のプラグインが消し去る要素の形を見る必要があるか? あるなら、そのプラグインより前に実行する。先行プラグインの書き換え結果(生成された id、合成された JSX 要素)が必要か? あるなら、その後に実行する。
本当に新しい構文を追加する
markdown-rs がパースする Markdown 構文の一部は、mdast → hast
コンバーターによって現状捨てられています。crates/
の mdast_to_hast を見てください。
// Unhandled: degrade to empty Raw so we never crash on
// unsupported input. Tables, footnotes, definitions, math,
// reference links/images, ESM, frontmatter, etc. fall here.
_ => HastNode::Raw(String::new()),テーブル、脚注、定義、math、参照スタイルのリンク、その他キャッチオールに落ちているものを zfb で表面化したい場合は、2 つの変更が必要です。
match アームを
mdast_to_hastに追加し、その mdast バリアントを適切なHastNode::Element(パススルーの JSX/HTML ならRaw)に変換します。既存のアームに倣い、子はconvert_childrenで処理し、属性はVec<(String, String)>として構築します。markdown::ParseOptionsの切り替えが必要な場合もあります。その構文が拡張フラグを必要とするときです。現在のPipeline::with_mdx()はmarkdown::ParseOptions::mdx()を使っています。追加のconstructs.*フィールドを有効にしたカスタムのParseOptionsが必要になることがあります。 正確なフラグについてはmarkdownクレートのドキュメントを確認してください。
コンバーター変更のテストは、pipeline.rs 自身の
#[cfg(test)] mod tests {} ブロック(このファイルはすでに見出し、コードブロック、リンク、画像、リスト、blockquote、MDX JSX をカバーしています)に加え、crates/ 内の対応するラウンドトリップケースに置きます。
ランタイム / ユーザーランドプラグインについては?
Markdown パイプラインのプラグインローダーは存在しません。すべての visitor はバイナリにコンパイルされます。zfb.config の plugins: [] フィールドは
ビルドオーケストレーション用のプラグインシステム(setup、preBuild、postBuild、devMiddleware
の 4 つのフック)であり、エイリアス登録、仮想モジュール、追加ファイルの出力、開発サーバーのルートを扱います。これは
Markdown のパース&visitor チェーンには手を出しません。そのチェーンは Rust エンジンの内部で完結して実行されます。
実用的な Markdown パイプライン拡張モデルは、visitor を in-tree
で追加することです。visitor トレイトはワークスペース全体で安定しているため、新しい plugins/
ファイルとして書かれた機能は、コードベースの他の部分が動いても、追従的な変更が必要になることはめったにありません。
関連項目
Markdown Features — Core と Opt-in 機能の完全なカタログ。機能ごとの順序に関する注記つき。
Custom Directives —
:::name/::name/:name構文の作者向けの解説。Rust 不要。Customizing Markdown — コンテンツコレクション消費者の視点から見た Markdown レンダリングパイプライン。
crates/—zfb- content/ src/ pipeline. rs Pipeline、MdastVisitor/HastVisitorトレイト、およびPipeline::with_defaults()の順序の根拠(ドキュメントコメント内)。crates/— 小さくステートレスなzfb- content/ src/ plugins/ code_ title. rs HastVisitorの例。crates/— ステートフルなzfb- content/ src/ plugins/ heading_ links. rs HastVisitor(ドキュメントごとのスラッグカウンタ)の例。crates/—zfb- content/ src/ plugins/ directives. rs DirectiveRegistryとそのMdastVisitor実装。