zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

MDX Components

MDX エントリがレンダリング可能な React/Preact コンポーネントとしてページに届く仕組みと、components prop で個々の要素をオーバーライドする方法。

このページの内容

.mdx コンテンツコレクションのエントリがどのようにレンダリング可能な JSX コンポーネントになるのか、getCollection() を通じてデフォルトで何が届くのか、個々の HTML 要素を自前のコンポーネントでオーバーライドする方法、グローバルな mdx-components.tsx 規約の仕組み、そして各オーバーライドが受け取る完全な props の契約を解説します。

メンタルモデル

コンテンツコレクション内のすべての .mdx ファイルは、ビルド時に JSX モジュールへコンパイルされます。コンパイル後のモジュールは MDXContent({ components }) 関数をエクスポートします。これは投稿本文をレンダリングするだけが役割の通常のコンポーネントで、components prop を通じて要素ごとのオーバーライドを任意に差し込めます。

これは Astro の @astrojs/mdx<Content components={...} /> で公開しているものと同じ契約です。zfb はこれをそのまま採用しているため、Astro エコシステム全体でメンタルモデルがそのまま通用します。

その形は次のとおりです。

// Conceptually, your `hello-zfb.mdx` becomes:
export default function MDXContent({ components }: { components?: Record<string, unknown> }) {
  // ...renders the post body, looking up <p>, <a>, <Note>, etc. in `components`
}

MDX が <p>some text</p>(Markdown の段落がコンパイルされた結果)に出会うと、components マップから p を探します。自前のコンポーネントを渡していればそれがレンダリングされ、渡していなければ素の HTML 要素がレンダリングされます。同じルックアップはカスタム JSX にも適用されます。投稿本文中の <Note>...</Note> は、評価時に components.Note に対して解決されます。

getCollection() がすでに与えてくれるもの

MDXContent を自分で組み立てることはありません。コレクション層が各エントリのコンパイル済みモジュールを Content コンポーネントでラップし、エントリに付与します。つまり次のようになります。

import { getCollection } from "zfb/content";

const posts = getCollection("blog");
const post = posts[0];

// `post.Content` is a renderable component:
<post.Content />;

post.data(パース済みのフロントマター)、post.slug、そして post.Content(レンダリング可能な本文)が得られます。.md.mdx で形は同じです。CommonMark は MDX の厳密なサブセットなので、同じパイプラインが両方を扱います。プレーンな Markdown エントリにはオーバーライドすべき JSX ノードがそもそもありませんが、それでも components prop はそれらが出力する HTML 要素に適用されます。

post.Content を実際のコンパイル済み JSX モジュールに繋ぐブリッジは globalThis.__zfb.content.get(specifier) にあり、ページモジュールが評価される前に Rust 側のレンダラーがインストールします。ブリッジが存在しない場合(ユニットテストや開発用サンドボックスなど)、Content は明示的にマークされた <pre data-zfb-content-fallback> ブロックにフォールバックし、欠落がひと目で分かるようになっています。

defaultComponents をスプレッドする

zfb はルートエクスポートから defaultComponents マップを提供しています。これは MDX が出力する素の HTML タグを、適切なコンポーネント実装でラップする要素レベルのオーバーライド群(htmlOverrides 規約)です。使い方の定石は、まずこれをスプレッドし、その上に自前のオーバーライドを重ねることです。

import { defaultComponents } from "zfb";
import { Note } from "../../components/callout";

<post.Content components={{ ...defaultComponents, ...mine, Note }} />;

スプレッドの順序が重要です。右側のキーが勝ちます。defaultComponents を最初に置くことで、衝突時には個々のオーバーライドが優先され、オーバーライドしていないものはすべてデフォルトの挙動を引き継ぎます。

現在の defaultComponents セットがカバーするのは次のとおりです。

KeyComponentWraps
h2ContentH2<h2>
h3ContentH3<h3>
h4ContentH4<h4>
pContentParagraph<p>
aContentLink<a>
strongContentStrong<strong>
blockquoteContentBlockquote<blockquote>
ulContentUl<ul>
olContentOl<ol>
tableContentTable<table>
codeContentCode<code> (inline; block code is unaffected)

それぞれ個別にもエクスポートされており(import { ContentLink } from "zfb")、マップ全体を引き込まずに単一のオーバーライドだけを使いたい場合に利用できます。

h1 は意図的に含まれていません。ページタイトルは zudo-doc の規約に従ってフロントマターから <h1> をレンダリングします。h1 をオーバーライドに加えると、タイトルが気付かないうちに二重レンダリングされてしまいます。

グローバルな mdx-components.tsx 規約

すべての <entry.Content /> 呼び出しに対して、呼び出しごとのスプレッドなしで適用したいプロジェクト全体の要素オーバーライドには、プロジェクトルート(zfb.config.ts の隣)に mdx-components.tsx ファイルを置き、フラットな { tag: Component } マップをデフォルトエクスポートします。

// mdx-components.tsx (project root)
import MyH2 from "./components/my-h2";

export default {
  h2: MyH2,
};

ビルドパイプラインはこのファイルを検出してシャドウバンドルにコピーし、ページルーターが走る前にそのデフォルトエクスポートを globalThis.__zfb.mdxComponents にインストールします。その時点以降、すべての <entry.Content /> 呼び出しが自動的にこれを拾います。呼び出し側でのスプレッドは不要です。

優先順位

コンポーネントがマージされるとき、後のエントリが勝ちます。マージの順序は次のとおりです。

  1. defaultComponents(最も優先度が低い。組み込みの 11 個のパススルー)

  2. mdx-components.tsx のデフォルトエクスポート(プロジェクト全体のオーバーライド)

  3. <entry.Content />components prop(最も優先度が高い。呼び出しごとのオーバーライド)

したがって、呼び出しごとの components={{ h2: MyCallSiteH2 }} はプロジェクトルートのファイルに勝ち、そのファイルは組み込みのデフォルトに勝ちます。内部的には mergeMdxComponents"zfb" からエクスポート)がこの三方向のスプレッドを実行します。

{ ...defaultComponents, ...globalSlot, ...perCall }

実行可能な例 — <h2><span> でラップする

よくあるパターンは、見出しをスタイル付きのコンテナでラップすることです。

// mdx-components.tsx (project root)
type Props = { id?: string; children?: unknown };

function MyH2({ id, children }: Props) {
  return (
    <h2 id={id}>
      <span>{children}</span>
    </h2>
  );
}

export default { h2: MyH2 };

これで、すべてのコンテンツエントリのすべての ## 見出しが、ページごとの配線なしにプロジェクト全体で <h2 id="…"><span>…</span></h2> としてレンダリングされます。

標準的にマップ可能な要素の集合

次の HTML タグは MDX エミッターによって _components.<tag> 経由でルーティングされ、components マップでオーバーライド可能になります。このリストにないタグは、そもそも Markdown から出力されないか、生の HTML リテラルとして出力されます(後者は components マップではオーバーライドできません)。

コア CommonMark:

a blockquote br code em h1 h2 h3 h4 h5 h6 hr img li ol p pre strong ul

GFM 拡張zfb.config.ts で GFM 構文が有効なときに利用可能):

del input section sup table tbody td th thead tr

defaultComponents マップはこのうち 11 個(h2–h4、p、a、strong、blockquote、ul、ol、table、code)をあらかじめ配線します。残りは素の HTML 文字列のままですが、キーを components マップに含めればどれでもオーバーライドできます。

Props の契約

各オーバーライドコンポーネントが受け取るのは、その要素に対して Markdown パイプラインが実際に生成する属性だけです。任意の HTML 属性を {...props} で網羅的にスプレッドして渡すような仕組みはありません。渡されるのは下表に挙げたフィールドだけです。

Element(s)Props received
ahreftitle(任意)、children
imgsrcalttitle(任意) — void、children なし
h2h6id(見出しリンクプラグインが有効で、その見出しに slug が付与されている場合に存在)、children
h1children のみ — h1 には slug は付与されない
prechildren<code> の子をラップする)
codeclassName="language-*"(フェンス付きブロックのみ。インラインコードは children のみ受け取る)
olstart(1 以外のときのみ)、children
その他すべてchildren のみ

オーバーライドに {...rest} を加えて HTML 要素にスプレッドしても、未知の属性は届きません。エミッターは Markdown ソースから任意の属性を転送しないからです。

小文字と PascalCase の非対称性

components マップにおける小文字の HTML タグと PascalCase のコンポーネント名のあいだには、意図的な挙動の違いがあります。

  • 小文字のタグh2pa など)は、出力される _components マップの中で素の HTML 文字列をデフォルトとします。小文字のキーを省略した場合、生の要素がレンダリングされます。エラーにはなりません。

  • PascalCase の名前NoteCallout など)は、呼び出し側の components prop からルックアップされ、見つからなければ即座に throw します。

    // Inside compiled MDX output:
    const Note = _components.Note ?? components.Note;
    if (!Note) throw new Error("MDX requires `Note` to be passed via the `components` prop");

    .mdx ファイルで参照されている PascalCase コンポーネントが、レンダリング時に components マップに存在しない場合、ビルド時ではなく実行時に throw します。これは意図的なものです。コンパイル済みモジュールはポータブルなまま保たれ、必要なコンポーネントをすべて渡す責任は呼び出し側にあります。

Islands による制約

components マップは、SSR 時に解決されるプレーンな JavaScript オブジェクトです。値が関数参照であるため、JSON としてハイドレーション境界を越えられません。これは次のことを意味します。

  • マップ内のコンポーネントはサーバーサイド専用です。サーバー上でレンダリングされ、その HTML が静的なマークアップとしてブラウザに届けられます。

  • マップ内のコンポーネントがクライアントサイドのインタラクティブ性(状態、イベントハンドラ、ブラウザ API)を必要とする場合は、そのインタラクティブな部分を <Island> コンポーネントでラップし、インタラクティブなコンポーネントを直接マップに入れないでください。

// Correct: interactive content goes through Island
import { Island } from "zfb";
import MyCounter from "./my-counter";

function WrappedCounter() {
  return (
    <Island>
      <MyCounter />
    </Island>
  );
}

export default { div: WrappedCounter };

components マップは SSR を通ります。<Island> はブラウザへ抜けるための脱出口です。

保留中の未解決項目 — wrapper

MDX は components マップ内の特別な wrapper キーをサポートしています。これはレンダリング結果全体をラップするコンポーネントです。zfb のエミッターは wrapper を認識しません。本文は直接 <_Fragment> でラップされ、wrapper キーに対するルックアップは行われません。wrapper のサポートは追跡中の未解決項目であり、現在のリリースでは実装されていません。

カスタムコンポーネントで .mdx を書く

同梱の basic-blog テンプレートが、エンドツーエンドの形を実演しています。その hello-zfb.mdx は、本文に直接 JSX として <Tip> admonition を書いています。

---
title: Hello, zfb
date: 2026-04-20
description: What this starter contains and how the pieces fit together.
---

Welcome to **basic-blog**. This is a working zfb site: a content collection,
a handful of routes, one interactive island, and Tailwind for styling.

<Tip>

Run `zfb check` (or `pnpm typecheck`) to typecheck the project **and**
validate every post's frontmatter against the collection schema.

</Tip>

投稿ごとのルートには <Tip> を配線している箇所はありません。ルートが渡しているのは defaultComponents だけです。

// pages/blog/[slug].tsx
import { defaultComponents } from "@takazudo/zfb";

export default function BlogPostPage({ post }: Props) {
  return (
    <article>
      <h1>{post.data.title}</h1>
      <post.Content components={{ ...defaultComponents }} />
    </article>
  );
}

admonition は、zfb がすべてのエントリにマージするプロジェクトルートの mdx-components.tsx から届きます。

// mdx-components.tsx (project root)
import { Note, Tip, Important, Warning, Caution } from "~/components/callout";

export default { Note, Tip, Important, Warning, Caution };

5 つの名前をルートごとではなくここに置いていることが、素の .md 記事でもこれらを使えるようにしています。テンプレートは zfb.config.tsmarkdown.features.githubAlerts を有効にしているため、> [!NOTE] の引用ブロックはレンダリング前に <Note> 要素へ書き換えられます。そしてマップに存在しない PascalCase の名前は、レンダリング時に例外を投げます。ルートごとに配線する形にすると、記事をレンダリングするすべてのルートが 5 つすべてを覚えておかなければなりません。

コンポーネント自体は、ごく普通の Preact(または React)コンポーネントです。components/callout.tsx はベースとなるコンポーネント 1 つと、children だけを受け取る 5 つの名前付きラッパーをエクスポートします。

// components/callout.tsx
import type { ComponentChildren } from "preact";

type Variant = "note" | "tip" | "important" | "warning" | "caution";

const LABELS: Record<Variant, string> = {
  note: "Note",
  tip: "Tip",
  important: "Important",
  warning: "Warning",
  caution: "Caution",
};

export function Callout({ variant, children }: { variant: Variant; children: ComponentChildren }) {
  return (
    <aside data-component={variant} role="note">
      <p>{LABELS[variant]}</p>
      <div>{children}</div>
    </aside>
  );
}

export function Note({ children }: { children: ComponentChildren }) {
  return <Callout variant="note">{children}</Callout>;
}

// …Tip・Important・Warning・Caution も Note と同じく 1 行ずつ。

5 つのコンポーネント名は機能側で固定されており、こちらで選べるものではありません。アラートの記法そのものは GitHub アラートを、これらのファイルがスキャフォールドのどこに置かれるかは プロジェクト構造 を参照してください。

リファレンス

  • defaultComponents セットは、zudo-doc が確立した htmlOverrides 規約を zfb に移植したものです。zudo-doc では、同じマップの形が同じ問題をそのドキュメントフレームワーク向けに解決しています。

  • Astro の上流パターンは Astro: Assigning custom components to HTML elements に記載されています。契約は意図的に同一です。要素名 → コンポーネントのフラットなレコードを components prop で渡すというものです。

あわせて読む

  • Content Collections — エントリがどのように発見され、getCollection() を通じて公開されるか。

  • Build Pipeline — MDX から JSX へのコンパイルがエンドツーエンドのフローのどこに位置するか。

  • Custom Directives — 自前の JSX コンポーネントにマッピングされる新しい MDX ディレクティブ構文(例: :::callout)を追加する。

  • Islands — コンテンツエントリ内でインタラクティブなコンポーネントを動かす方法。

Revision History

作成更新