zfb
GitHub リポジトリ

検索したい単語を入力

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

MDX Components

MDX エントリがレンダリング可能な zudo-react コンポーネントとしてページに届く仕組みと、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 "@takazudo/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> ブロックにフォールバックし、欠落がひと目で分かるようになっています。プロダクションビルドでも、コンパイル済みの .md / .mdx モジュールをブリッジ用にパースできない場合は同じフォールバックになり、バンドラは警告を出してエントリをスキップします。strictContentBridge を true にするか --strict-content-bridge を渡すと、該当するエントリがある場合にビルドを失敗させられます。このゲートは build 専用で、zfb dev は引き続き警告を出してフォールバックを配信します。

defaultComponents をスプレッドする

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

import { defaultComponents } from "@takazudo/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 "@takazudo/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
ahref、title(任意)、children
imgsrc、alt、title(任意) — void、children なし
h2–h6id(見出しリンクプラグインが有効で、その見出しに slug が付与されている場合に存在)、children
h1children のみ — h1 には slug は付与されない
prechildren(<code> の子をラップする)
codeclass="language-*"(フェンス付きブロックのみ。インラインコードは children のみ受け取る)
olstart(1 以外のときのみ)、children
その他すべてchildren のみ

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

小文字と PascalCase の非対称性

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

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

  • PascalCase の名前(Note、Callout など)は、呼び出し側の 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");

    A PascalCase component referenced in an .mdx file that is not present in the components map at render time throws at runtime — not at build time. This is intentional: the compiled module stays portable; the caller is responsible for passing all required components.

Islands の制約

components map は SSR 時に解決される通常の JavaScript object です。 その関数値は JSON として hydration boundary を越えられません。map と wrapper は server 側に置き、対話的な固定 client component を <Island> に入れます。その境界を越えられるのは JSON に シリアライズできる props だけです。

パッケージ alias を使わない新しい consumer では、公開 entrypoint を使います。

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@takazudo/zfb/zudo-react"
  }
}

この設定では JSX 構文が、production では @takazudo/zfb/zudo-react/jsx-runtime、development では @takazudo/zfb/zudo-react/jsx-dev-runtime を通ります。runtime helper は @takazudo/zfb/zudo-react から、Island は @takazudo/zfb から import します。以下の検証済み map recipe は同じパッケージを指す既存の bare alias zfb を使います。この alias を使う場合は jsxImportSource を zfb/zudo-react に設定します。後続の移行例は 公開 specifier を使います。

// components/counter.tsx
"use client";
import { signal } from "zfb/zudo-react";

export default function Counter() {
  const count = signal(0);
  return (
    <button on:click={() => { count.value += 1; }}>
      Count: {count}
    </button>
  );
}

export function NamedCounter() {
  const count = signal(0);
  return (
    <button on:click={() => { count.value += 1; }}>
      Named counter: {count}
    </button>
  );
}

利用できる map の形は、名前付き server wrapper、固定ターゲット、 Island を囲む Fragment です。Fragment は DOM 要素を追加せず、 具体的な境界をスキャナーから見える状態にします。

// mdx-components.tsx (project root)
import { Island } from "zfb";
import Counter from "./components/counter";

function WrappedCounter() {
  return (
    <>
      <Island when="load">
        <Counter />
      </Island>
    </>
  );
}

export default { Counter: WrappedCounter };

ページ単位の map も同じ形にします。各ターゲットを固定します。

// pages/_mdx-components.tsx
import { Island } from "zfb";
import { NamedCounter } from "../components/counter";

function WrappedNamedCounter() {
  return (
    <>
      <Island when="load">
        <NamedCounter />
      </Island>
    </>
  );
}

export const components = { NamedCounter: WrappedNamedCounter };

スキャナーから固定ターゲットへ到達できるよう、map を到達可能な ページから import して entry renderer に渡します。

// pages/blog/[slug].tsx — post は選択した collection entry
import defaultComponents from "../../mdx-components";
import { components as pageComponents } from "../_mdx-components";

<post.Content components={{ ...defaultComponents, ...pageComponents }} />

map の値は server-only です。map 自体はシリアライズせず、JSON 対応の Island props だけを client に渡します。子の関数、server component、 描画済み server child は Island JSON prop ではありません。

bare return の固定 wrapper や汎用転送 boundary wrapper を opaque map に格納しないでください。どちらも scanner の探索対象から escape します。

import { Island } from "@takazudo/zfb";
import Counter from "./components/counter";

function BareCounterIsland() {
  return <Island when="load"><Counter /></Island>;
}
const rejectedBareMap = { Counter: BareCounterIsland };
// Build reason: boundary wrapper escapes into an opaque container.

function ForwardBoundary({ component: Target }) {
  return <Island when="load"><Target /></Island>;
}
const rejectedForwardMap = { Island: ForwardBoundary };
// Build reason: boundary wrapper escapes into an opaque container.
const rejectedShorthand = { ForwardBoundary };
// Build reason: boundary wrapper escapes into an opaque object.

上記の Fragment で囲んだ固定ターゲット map を使います。 function IslandWrapper({ children }) { return children ?? null; } のような wrapper は通常の SSR passthrough です。map key が Island でも、 その関数が SDK Island boundary になるわけではありません。

<Island> の内側でコンポーネントを条件選択する形は、条件が プレーンな object 内にあっても引き続き未対応です。安全な object 構文でも、 選択される component が静的になるわけではありません。zudo-doc の downstream 修正は公開 composition seam migration です。package の設定を ネストした server factory に残しながら、呼び出し側が host/default の boundary 選択を所有します。この migration は consumer で検証する必要が あり、このドキュメントは downstream browser parity を主張しません。 ファクトリから渡すターゲット、 カスタマイズ可能なパッケージ島の合成境界、 issue #3824 を参照してください。 locked factory-map-default、factory-map-host-override、 factory-map-settings-disabled の行を含む全 matrix は Islandsにあります。

Deferred open item — wrapper

MDX supports a special wrapper key in the components map: a component that wraps the entire rendered output. zfb's emitter does not recognise wrapper — the body is wrapped in <_Fragment> directly, and no lookup against a wrapper key is performed. Support for wrapper is a tracked open item and is not implemented in the current release.

Authoring .mdx with custom components

The bundled basic-blog template demonstrates the end-to-end shape. Its hello-zfb.mdx post writes a <Tip> admonition as JSX directly in the body:

---
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 a shared stylesheet.

<Tip>

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

</Tip>

Nothing in the per-post route wires <Tip> up. The route passes defaultComponents and nothing else:

// 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>
  );
}

The admonitions arrive through the project-root mdx-components.tsx, which zfb merges into every entry:

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

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

Putting the five names there rather than in each route is what makes them work in plain .md posts too. The template enables markdown.features.githubAlerts in its zfb.config.ts, so a > [!NOTE] blockquote is rewritten into a <Note> element before rendering — and a PascalCase name missing from the map throws at render time. Wiring them per route would mean every route that renders a post has to remember all five.

The components themselves are ordinary synchronous zudo-react components. components/callout.tsx exports one base component plus five named wrappers, each taking only children:

// components/callout.tsx
import type { Child } from "@takazudo/zfb/zudo-react";

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: Child }) {
  return (
    <aside data-component={variant} role="note">
      <p>{LABELS[variant]}</p>
      <div>{children}</div>
    </aside>
  );
}

export function Note({ children }: { children: Child }) {
  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

作成更新