Markdown から HTML への変換アーキテクチャ
MDX コンポーネントオーバーライドによるコンテナスコープ CSS とユーティリティファースト CSS のカスケード競合の解決
問題
ドキュメントサイトやコンテンツ駆動型アプリケーションでは、Markdown/MDX ファイルを HTML ページに変換するのが一般的です。標準的なアプローチは、レンダリングされた HTML をコンテナ要素で囲み、スコープされた CSS でネイティブ HTML 要素をスタイリングする方法です。
/* Container-scoped element styling */
.content :where(h2) {
font-size: 1.5rem;
font-weight: 700;
border-top: 3px solid transparent;
border-image: linear-gradient(to right, currentColor, transparent) 1;
padding-top: 0.75rem;
}
.content :where(h3) {
font-size: 1.2rem;
font-weight: 700;
border-top: 2px solid gray;
padding-top: 0.5rem;
}
.content :where(p) {
line-height: 1.75;
}
.content :where(a) {
color: var(--color-accent);
text-decoration: underline;
}
このパターンはデフォルトのコンテンツページではうまく機能します。.content 内のすべての <h2>、<h3>、<p> が統一的にスタイリングされます。
破綻するケース
このアプローチは、同じ HTML 要素をコンテンツスタイリングのコンテキスト外で使う必要がある場合や、同じコンテナ内で同じ要素に異なるスタイリングが必要な場合に破綻します。
インタラクティブなフォームコンポーネントを埋め込んだドキュメントページを考えてみましょう。
// Inside a doc page that renders within .content
<PresetGenerator />
// The component uses h3 for section labels
function SectionHeading({ children }) {
return (
<h3 className="text-sm font-semibold">
{children}
</h3>
);
}
コンポーネントの <h3> 要素がコンテナの見出しセレクターにマッチしてしまい、border-top のグラデーション、大きいフォントサイズ、余分なパディングが適用されます。これらはドキュメントの見出しではなくフォームのラベルであるにもかかわらずです。コンポーネントのユーティリティクラス(text-sm、font-semibold)がコンテナスタイルをオーバーライドすべきですが、それができません。
ユーティリティクラスが負ける理由
Tailwind CSS v4 では、@import "tailwindcss/utilities" によりユーティリティクラスが @layer 内に配置されます。
@import "tailwindcss/preflight";
@import "tailwindcss/utilities";
/* This comes AFTER the utility import — it's unlayered */
.content :where(h3) {
font-size: 1.2rem;
font-weight: 700;
border-top: 2px solid gray;
}
カスケードレイヤー(cascade layers)は厳格な優先順位に従います。レイヤーに属さないスタイルは、詳細度やソース順序に関係なく、常にレイヤー内のスタイルに勝ちます。.content :where(h3) ルールはレイヤーに属しておらず、Tailwind の .text-sm は @layer utilities 内にあります。:where() の詳細度がゼロであっても、レイヤーに属さないルールが勝ちます。
これにより解決不可能な状況が生まれます。
- ユーティリティクラスでコンテナスタイルをオーバーライドできない
- より詳細度の高い CSS オーバーライド(
.preset-gen :where(h3) { ... })を追加すると、いたちごっこになる <h3>を再利用する新しいコンテキストごとに独自のオーバーライドルールが必要になる- コンテキスト固有のパッチがコードベースに蓄積し、コンテキストが変わると壊れる
根本的な問題は詳細度ではなく、2つのスタイリングシステム(コンテナスコープの CSS とユーティリティファーストの CSS)が異なるカスケードレイヤーに存在し、互いに折り合いをつけられないことです。
解決方法
コンテナスコープの要素スタイリングを、MDX レンダリングレイヤーでのコンポーネントオーバーライドに置き換えます。コンテナ内のすべての <h2> 要素をスタイリングする CSS ルールの代わりに、スタイルを内包した <h2> をレンダリングするコンポーネントを作成します。
モダンフレームワーク(Astro、Next.js、Remix)は MDX コンポーネントオーバーライドをサポートしています。これは MDX が生成するデフォルトの HTML 要素をカスタムコンポーネントに置き換える仕組みです。
// content-h2.tsx — replaces <h2> in MDX content
export function ContentH2({ id, children, ...props }) {
return (
<h2
id={id}
className="text-xl font-bold leading-tight pt-3"
style={{
'--flow-space': 'var(--spacing-2xl)',
borderTop: '3px solid transparent',
borderImage: 'linear-gradient(to right, currentColor, transparent) 1',
}}
{...props}
>
{children}
</h2>
);
}
// content-h3.tsx — replaces <h3> in MDX content
export function ContentH3({ id, children, ...props }) {
return (
<h3
id={id}
className="text-lg font-bold leading-snug pt-2"
style={{
'--flow-space': 'var(--spacing-xl)',
borderTop: '2px solid transparent',
borderImage: 'linear-gradient(to right, gray, transparent) 1',
}}
{...props}
>
{children}
</h3>
);
}
レンダリングレイヤーでオーバーライドを登録します。
// component-map.ts
import { ContentH2 } from './content-h2';
import { ContentH3 } from './content-h3';
import { ContentP } from './content-p';
import { ContentA } from './content-a';
export const htmlOverrides = {
h2: ContentH2,
h3: ContentH3,
p: ContentP,
a: ContentA,
};
フレームワークとの統合
Astro — MDX の <Content> コンポーネントに components プロパティを渡します。
---
import { htmlOverrides } from './component-map';
const { Content } = await entry.render();
---
<article class="content">
<Content components={{ ...htmlOverrides, Note, Tip, Warning }} />
</article>
Next.js — useMDXComponents または next-mdx-remote の components プロパティを使います。
import { MDXRemote } from 'next-mdx-remote/rsc';
import { htmlOverrides } from './component-map';
export default function DocPage({ source }) {
return <MDXRemote source={source} components={htmlOverrides} />;
}
なぜこれで問題が解決するのか
コンポーネントオーバーライドを使うと次のようになります。
- MDX コンテンツの見出しは
ContentH3を通してレンダリングされ、border-top のグラデーション、大きいフォントなどが適用されます - フォームのセクション見出しは素の
<h3 className="text-sm font-semibold">を使い、コンテナスタイルは干渉しません - カスケードの競合がない — 各コンテキストがレンダリングするコンポーネントを通じて自身のスタイリングを制御します
- 単一の情報源 — 見出しのデザインはコンポーネントに存在し、他のルールと競合する CSS ルールには存在しません
コンテナ要素(.content)には要素レベルのスタイリングが不要になります。コンテナとしての関心事のみを扱います。
/* Container-level only — no element styling */
.content {
color: var(--color-fg);
font-size: var(--text-body);
line-height: 1.75;
}
/* Flow spacing (vertical rhythm) */
.content > * + * {
margin-top: var(--flow-space, 1rem);
}
/* Structural rules that depend on sibling adjacency */
.content :where(h2, h3, h4) + :where(:not(h2, h3, h4)) {
--flow-space: 0.5rem;
}
グローバル CSS に残すもの
すべてをコンポーネントに移すわけではありません。要素間の関係(隣接する兄弟要素、親子構造)に依存するルールはグローバル CSS に残します。コンポーネントは自身の隣接要素について知ることができないためです。
| 関心事 | 配置場所 | 理由 |
|---|---|---|
| 見出しのフォント/ボーダー/ウェイト | コンポーネント | 自己完結した外観 |
--flow-space の値 | コンポーネント(style 経由) | 要素レベルのスペーシング宣言 |
> * + * フロースペーシング | コンテナ CSS | 子要素の --flow-space を読み取る |
| 見出し + 見出しの詰め | コンテナ CSS | 兄弟要素の隣接関係に依存 |
| 見出し + コンテンツの詰め | コンテナ CSS | 兄弟要素の隣接関係に依存 |
| プラグインが挿入する要素(自動リンクアンカー) | コンテナ CSS | ビルドプラグインからの横断的関心事 |
サーバーレンダリングコンポーネント = JavaScript ゼロ
よくある懸念として「すべての見出しに React/Preact コンポーネントを使うと JavaScript のオーバーヘッドが増えるのでは?」というものがあります。
いいえ。モダンな SSR フレームワークでは、クライアントサイドのインタラクティブ指示がないコンポーネントはビルド時にレンダリングされ、静的な HTML を生成します。純粋にテンプレートとして機能します。
Astro: client:load や client:visible のない Preact/React コンポーネントはサーバーレンダリングのみで、JavaScript は一切配信されません。
Next.js: Server Components(App Router のデフォルト)はサーバー上でレンダリングされ、クライアントバンドルに影響しません。
コンポーネントはビルド時にのみ存在します。ブラウザが受け取るのは、クラスとインラインスタイルが付いた素の <h2>、<h3>、<p> 要素であり、CSS のみのアプローチが生成するものと区別がつきません。
使い分け
| シナリオ | 推奨アプローチ |
|---|---|
| コンテンツページのみで再利用の競合がない | コンテナスコープ CSS(よりシンプル) |
| 同じ要素をコンテンツとインタラクティブコンポーネントの両方で使用 | コンポーネントオーバーライド(競合を排除) |
Tailwind v4 で @layer とレイヤーに属さないコンテンツスタイルを使用 | コンポーネントオーバーライド(カスケードロックの回避に必須) |
| 異なるスタイリングが必要な複数のコンテンツコンテキスト | コンポーネントオーバーライド(各コンテキストが独自のコンポーネントを持つ) |
| 再利用の競合がないマイナーな要素(li、code、hr) | コンテナ CSS(コンポーネントのオーバーヘッドを回避) |
実践的な判断基準
コンテナスコープ CSS から始めましょう。よりシンプルで、大半のケースで機能します。
コンテンツページで使われている要素が非コンテンツのコンテキスト(フォーム、インタラクティブパネル、埋め込みツール)でも必要になり、コンテナスタイルが競合を引き起こすことが判明した時点で、コンポーネントオーバーライドに切り替えましょう。これは事前に行う判断ではなく、事後的な判断です。
切り替える場合は、主要な要素(h2、h3、h4、p、a、blockquote、ul、ol、table)をすべてコンポーネントに変換しましょう。一部を CSS のまま残し、一部をコンポーネントにすると、メンテナンスが難しい混在状態になります。マイナーな要素(h5、h6、li、インラインコード、hr、img)は CSS のまま残して構いません。コンテンツコンテキスト外で使われることがほとんどないためです。
AI がよくやるミス
- アーキテクチャの根本原因に対処せず、より詳細度の高い CSS オーバーライドでカスケードの競合を修正しようとする
- コンテナスコープスタイルに対してユーティリティクラスの値を強制するために
!importantを使う - Tailwind v4 の
@import "tailwindcss/utilities"がレイヤーに属さない CSS に負ける@layerを作成することに気づかない - 新しいコンテキストごとにラッパー固有の CSS オーバーライド(
.form :where(h3) { ... })を作成する — スケールしないアプローチ - 各コンテキストが自身のスタイリングを制御できるようにする代わりに、
border-top: none; border-image: none; font-size: ...のようなリセットルールを追加して見出しのデザインを完全に剥がしてしまう - コンテンツ要素用の React/Preact コンポーネントが JavaScript のオーバーヘッドを追加すると思い込む(実際にはサーバーレンダリングのみ)
- 明確な境界ルールなしに、一部の要素をコンポーネントに、他をコンテナ CSS にする混在状態にする