Read OSS

コンテンツ Plugin と MDX パイプライン:Markdown から React へ

上級

前提知識

  • plugin ライフサイクルとアクションシステムの理解(第 2 回)
  • ビルドパイプラインと webpack loader の理解(第 3 回)
  • MDX・remark・rehype エコシステムの基礎知識

コンテンツ Plugin と MDX パイプライン:Markdown から React へ

plugin ライフサイクルはルートを生成し、ビルドパイプラインはバンドルをコンパイルします。では、ディスク上の .md ファイルがブラウザでレンダリングされる React コンポーネントになるまで、いったい何が起きているのでしょうか?それを担うのが MDX パイプラインです。remark と rehype の plugin チェーンを webpack loader が束ね、3 つのコンテンツ plugin がファイルを読み込んでルートにマッピングする、洗練された処理機構です。

この記事では、生の markdown ファイルがレンダリングされたページになるまでの処理フロー全体を追います。まず MDX loader を取り上げ、各 remark plugin を順番に見ていきます。その後、docs plugin を題材に、ファイルの読み込み・バージョン管理・サイドバー生成・ルート生成がどのように連携するかを詳しく解説します。

MDX Webpack Loader

markdown 処理の入り口は、packages/docusaurus-mdx-loader/src/loader.ts にある webpack loader です。webpack が .md または .mdx ファイルに遭遇すると、この loader が次の処理を順に実行します。

  1. フロントマターの解析 — 設定可能な parseFrontMatter 関数による解析(44〜48 行目)
  2. MDX から JSX へのコンパイルcompileToJSX() の呼び出しで remark/rehype チェーン全体を実行
  3. コンテンツタイトルの抽出 — 最初の見出しからタイトルを取得
  4. MDX partial の検出_ で始まるファイルは partial として扱われ、フロントマターを含む場合は警告を出力
  5. アセット参照の出力 — Markdown 内の画像やリンクを webpack の require() 呼び出しに変換
flowchart TD
    MD["document.md"] --> LOADER["MDX Loader"]
    LOADER --> FM["Parse front matter"]
    FM --> COMPILE["compileToJSX()"]
    COMPILE --> REMARK["Remark plugins"]
    REMARK --> REHYPE["Rehype plugins"]
    REHYPE --> JSX["JSX output"]
    JSX --> ASSETS["Asset require() emission"]
    ASSETS --> MODULE["Webpack module"]

loader は webpack のコンパイラ名(clientserver)を区別し、mdxCrossCompilerCache が有効な場合はコンパイル結果をコンパイラ間でキャッシュできます。ビルド中に同じ MDX ファイルがクライアントバンドル用と SSG 用の 2 回コンパイルされることを考えると、これは大きな最適化です。

Remark/Rehype Plugin チェーン

processor.ts にある processor ファクトリが、変換チェーン全体を組み立てます。各 plugin は次の plugin のために AST を変換するため、実行順序が重要です。

remark plugin は次の順で実行されます。

flowchart TD
    A["beforeDefaultRemarkPlugins (user)"] --> B["remark-frontmatter"]
    B --> C["remark-directive"]
    C --> D["contentTitle (extract h1)"]
    D --> E["admonitions (:::note, :::tip)"]
    E --> F["headings (extract + generate IDs)"]
    F --> G["remark-emoji"]
    G --> H["toc (table of contents)"]
    H --> I["details (HTML details/summary)"]
    I --> J["head (HTML head injection)"]
    J --> K["mermaid (if enabled)"]
    K --> L["transformImage (require assets)"]
    L --> M["resolveMarkdownLinks"]
    M --> N["transformLinks (require assets)"]
    N --> O["remark-gfm"]
    O --> P["remark-comment (if mdx1Compat)"]
    P --> Q["remarkPlugins (user)"]
    Q --> R["unusedDirectivesWarning"]
    R --> S["codeCompatPlugin (MDX1 code blocks)"]

各 plugin にはそれぞれ明確な役割があります。特に注目すべきものを見ていきましょう。

headingsremark/headings)はすべての見出しを抽出し、安定したアンカー ID を生成します。anchorsMaintainCase オプションで ID を小文字に変換するかどうかを制御できます。

tocremark/toc)は見出しから目次データ構造を構築し、テーマコンポーネントからアクセスできる toc メタデータとして注入します。

transformImagetransformLinks は、![](./img.png)[link](./doc.md) の相対パスを webpack の require() 呼び出しに変換します。これにより、バンドラーがアセットを処理してフィンガープリントを付与できるようになります。

admonitionsremark/admonitions)は :::note / :::tip / :::warning などのディレクティブブロックを <Admonition> JSX コンポーネントに変換します。

unusedDirectivesWarning は、どの plugin にも処理されなかったディレクティブ構文(例::::something)を検知し、タイポの可能性をユーザーに警告します。

codeCompatPlugin は MDX v1 のコードブロック互換性を担当します。npm2yarn のようなユーザー plugin の後に確実に実行されるよう、常に最後に適用されます。

remark plugin の後は rehype plugin が実行されます。mdx 形式ではなく md 形式の場合、生の HTML ブロックを処理するために rehype-raw が先頭に追加されます(MDX 式ノードはそのまま通過)。

ヒント: docusaurus.config.js のコンテンツ plugin オプション(例:docs: {remarkPlugins: [...]})からカスタムの remark/rehype plugin を追加できます。追加した plugin はデフォルトの remark plugin と unusedDirectivesWarning plugin の間に挿入されるため、整理された状態の AST にアクセスできます。

Docs Plugin:コンテンツの読み込みとバージョン管理

plugin-content-docs/src/index.ts#L82-L108 にある docs plugin は、コンテンツ plugin の中でもっとも複雑です。コンストラクタはすぐにサイドバーのパスを解決し、バージョンメタデータを読み込みます。

const versionsMetadata = await readVersionsMetadata({context, options});

バージョン管理システムは、複数のドキュメントバージョンを同時にサポートします。バージョンメタデータには、バージョン名・コンテンツパス・サイドバーパス・URL プレフィックスが含まれます。"current" バージョンはデフォルトで docs/ を参照し、リリース済みバージョンは versioned_docs/v<X>/ に格納されて versions.json で管理されます。

flowchart TD
    VM["readVersionsMetadata()"] --> CURRENT["Current version<br/>docs/"]
    VM --> V2["Version 2.0<br/>versioned_docs/version-2.0/"]
    VM --> V1["Version 1.0<br/>versioned_docs/version-1.0/"]
    
    CURRENT --> LOAD["loadVersion()"]
    V2 --> LOAD
    V1 --> LOAD
    LOAD --> DOCS["Per-version docs array"]
    LOAD --> SIDEBARS["Per-version sidebars"]

loadContent() の実行中、plugin は各バージョンに対して loadVersion() を呼び出します。この関数はコンテンツディレクトリをスキャンして markdown ファイルを見つけ、フロントマターを解析し、タイトル・説明・タグ・サイドバー位置などのメタデータを抽出して、ドキュメントの完全なデータ構造を構築します。各バージョンは独自のサイドバー設定と独立したコンテンツパスを持ちます。

id オプションを使うと複数インスタンスもサポートできます。メインドキュメント用の docs と、異なるコンテンツパスを持つ community のような 2 つ目のインスタンスを同時に運用することが可能です。各インスタンスは独立したバージョン管理・サイドバー・ルートを持ちます。

サイドバーの生成

sidebars/index.ts のサイドバーシステムは、2 つのモードをサポートしています。

自動生成サイドバー — デフォルトの動作です。sidebarPathundefined の場合、Docusaurus はファイルシステムの構造からサイドバーを自動生成します。22〜29 行目の DefaultSidebars 設定が、ルートディレクトリを指す type: 'autogenerated' のサイドバーを 1 つ作成します。

自動生成時は _category_.json または _category_.yml ファイルを参照し、カテゴリのラベル・位置・説明をカスタマイズできます。readCategoriesMetadata() 関数(43〜68 行目)がグロブパターンでこれらのファイルをスキャンします。

手動サイドバー — サイドバー設定をエクスポートする sidebars.js ファイルを使用します。TypeScript や ESM 形式をサポートするために loadFreshModule() で読み込まれます。

どちらのモードも、正規化 → 処理 → 後処理という同じパイプラインを経由します。処理ステップでは autogenerated アイテムがファイルシステムのスキャンによって実際のドキュメント参照に解決され、後処理ではリンクターゲットの検証と並び順の適用が行われます。

graph TD
    INPUT["sidebarPath option"] -->|undefined| AUTO["DefaultSidebars<br/>autogenerated from filesystem"]
    INPUT -->|false| DISABLED["DisabledSidebars<br/>no sidebar"]
    INPUT -->|path| MANUAL["Load sidebars.js"]
    AUTO --> NORM["normalizeSidebars()"]
    MANUAL --> NORM
    NORM --> PROC["processSidebars()"]
    PROC --> POST["postProcessSidebars()"]
    POST --> FINAL["Final sidebar data"]

ルートの生成:コンテンツとテーマコンポーネントをつなぐ

コンテンツ plugin の本質がもっともよく現れるのが contentLoaded() の実装です。routes.ts にある docs plugin のルート生成コードを読むと、コンテンツがどのようにルートになるかがよくわかります。

各ドキュメントに対して、plugin は次の処理を行います。

  1. actions.createData() を呼び出してドキュメントメタデータを JSON module として書き出す
  2. component: options.docItemComponent@theme/DocItem に解決される)を指定してルートを生成する
  3. modules: {content: doc.source} を設定する — これにより MDX ファイルが module として読み込まれるようルートに伝える
  4. 親コンポーネントが正しいサイドバーをレンダリングできるよう、sidebar をルート属性として付加する
sequenceDiagram
    participant DOC as docs plugin
    participant ACT as actions
    participant GEN as .docusaurus/
    participant THEME as @theme/DocItem

    DOC->>ACT: createData('abc123.json', docMetadata)
    ACT->>GEN: Write JSON to .docusaurus/docs/default/abc123.json
    ACT-->>DOC: modulePath
    DOC->>ACT: addRoute({path, component: '@theme/DocItem', modules: {content: doc.source}})
    Note over DOC,THEME: At runtime, DocItem receives doc content and metadata as props

ルート構造は階層的です。各バージョンは component: docRootComponent@theme/DocRoot に解決)を持つ親ルートを生成し、個々のドキュメントページはその子ルートとしてネストされます。この設計により、サイドバーコンポーネントは DocRoot レベルでレンダリングされ、ドキュメント間をナビゲートしても unmount されることなく表示され続けます。

ヒント: addRoute()component フィールドは常に '@theme/DocItem' のような文字列で、実際の import ではありません。この文字列が実際の React コンポーネントに解決される仕組みは、テーマのエイリアスシステム(第 5 回で解説)が担っています。

Blog Plugin と Pages Plugin

docs plugin がもっとも複雑ですが、blog plugin と pages plugin も同じパターンに従っています。

plugin-content-blog はブログ投稿(日付の命名規則を持つ markdown ファイル)をスキャンし、個別の投稿・ブログ一覧・タグページ・アーカイブページのルートを生成します。@theme/BlogPostPage@theme/BlogListPage などのテーマコンポーネントを使用します。

plugin-content-pages はもっともシンプルです。src/pages/ にある React コンポーネントと MDX ファイルをスキャンし、MDX コンテンツには @theme/MDXPage を使ってルートを生成します。バージョン管理もサイドバーもなく、ファイルからルートへのシンプルなマッピングです。

3 つの plugin はすべて共通のパターンを持っています。getPathsToWatch() で dev サーバーにリビルドトリガーとなるファイルを伝え、configureWebpack() で対象コンテンツディレクトリ用の MDX loader ルールを登録し、getTranslationFiles() で i18n をサポートします。

MDX loader ルールこそが、コンテンツ plugin と MDX パイプラインをつなぐ要です。各 plugin は自身のコンテンツディレクトリ内の .md/.mdx ファイルにマッチする webpack ルールを登録し、MDX loader と固有の設定(remark plugin、admonition 設定など)を紐づけます。第 2 回で紹介した MDX フォールバック plugin がこれらのパスを 除外 しなければならない理由はここにあります。フォールバック plugin はそれ以外のすべてを処理する役割を担っているからです。

次回予告

markdown ファイルから MDX パイプラインを経てルート生成に至るまでの流れを追いました。しかし、ルートは @theme/DocItem のようなテーマコンポーネントを参照しています。この文字列が実際の React コンポーネントになるまで、何が起きているのでしょうか?次回は、テーマシステムのエイリアス解決、theme-classic が持つ 100 以上のコンポーネント、そして Docusaurus に独自のカスタマイズ性をもたらす swizzle の仕組みを解説します。