Read OSS

プラグインのライフサイクル:コンテンツがルートになるまで

上級

前提知識

  • Docusaurusモノレポの構造およびサーバー/クライアント分離の理解(第1回)
  • TypeScriptのジェネリクスとインターフェース階層
  • Webpackプラグインの基本的な概念

プラグインのライフサイクル:コンテンツがルートになるまで

第1回では、loadSite()loadPlugins() を呼び出してプラグインのライフサイクルを起動することを確認しました。この関数こそが、マークダウンファイル・ブログ記事・ページといった生のコンテンツを、ブラウザでアクセス可能なReactルートへと変換する処理の中核です。

プラグインのライフサイクルは、初期化・loadContent()contentLoaded()allContentLoaded() という4つのフェーズで構成されており、この順序が厳密に守られます。各フェーズを経るごとにプラグインはデータを蓄積し、型システムがその進行を追跡します。この記事では、プラグイン設定からルート生成に至るまでの全工程をたどっていきます。

プラグインの解決と初期化(フェーズ1)

プラグインの設定は、さまざまな形式で記述できます。docusaurus.config.js では以下のような書き方がすべて有効です:

plugins: [
  '@docusaurus/plugin-content-docs',              // string
  ['@docusaurus/plugin-content-blog', {path: 'blog'}], // [string, options]
  function myPlugin(context, options) { ... },     // function
  [myPlugin, {someOption: true}],                  // [function, options]
  false,                                           // disabled
]

configs.ts#L55-L114 の正規化ロジックがこれらすべての形式を処理し、plugin 関数・options オブジェクト・相対パス解決用の entryPath を持つ統一的な NormalizedPluginConfig の形式に変換します。

flowchart TD
    STR["'@docusaurus/plugin-docs'"] --> NORM[normalizePluginConfig]
    TUPLE["['plugin', options]"] --> NORM
    FN["function() {...}"] --> NORM
    FALSE["false / null"] -->|filtered out| SKIP[Skipped]
    NORM --> NPC[NormalizedPluginConfig]
    NPC --> INIT[initializePlugin]
    INIT -->|plugin returns object| IP[InitializedPlugin]
    INIT -->|plugin returns null| SELF_DISABLE[Self-disabled, filtered out]

configs.ts#L157-L163 に定義されたプラグインの読み込み順序も重要です。プリセットのプラグイン → プリセットのテーマ → スタンドアロンのプラグイン → スタンドアロンのテーマ、という順番になっています。後から登録されたエントリが優先されるため、テーマエイリアスのシャドーイングに直接影響します。

init.ts#L124-L177 の初期化処理では、LoadContext と検証済みのオプションを引数にプラグインのコンストラクタが呼び出されます。プラグインが null を返すと自己無効化が行われます。これは、コンテキストに応じてプラグインを停止したい場合(例:本番環境でのみ有効なサイトマッププラグイン)の公式パターンです。undefined を返した場合はエラーになります。意図的な無効化とバグを区別するための設計です。

初期化後のプラグインには、optionsversionpath フィールドが付与されます。init.ts#L79-L89 のバージョン検出では、プラグインがnpmパッケージ由来か、プロジェクトファイル由来か、package.json のないローカルファイルか、あるいはSyntheticな内部プラグインかを判定します。

ヒント: プラグインに validateOptions 関数が定義されている場合、コンストラクタの前に実行されます。docs・blog・pagesプラグインで広く使われているパターンで、Joiスキーマによるオプションの検証をここで行います。

コンテンツの読み込み(フェーズ2:loadContent)

すべてのプラグインが初期化されると、plugins.ts#L139-L151executeAllPluginsContentLoading() が、すべてのプラグインの loadContent() メソッドを並列で実行します:

sequenceDiagram
    participant LP as loadPlugins()
    participant DOCS as docs plugin
    participant BLOG as blog plugin
    participant PAGES as pages plugin

    LP->>DOCS: loadContent()
    LP->>BLOG: loadContent()
    LP->>PAGES: loadContent()
    Note over LP: All run in parallel (Promise.all)
    DOCS-->>LP: docs metadata + sidebar data
    BLOG-->>LP: blog posts + tags
    PAGES-->>LP: page metadata
    LP->>LP: Translate content (if locale requires it)

loadContent() では、各プラグインがファイルシステムからデータを読み込みます。docsプラグインであれば、設定された全バージョンのマークダウンファイルをスキャンし、フロントマターを解析してドキュメントのメタデータを構築します。戻り値は Content 型で、contentLoaded() フェーズに引き渡すデータであれば何でも格納できます。

コンテンツ読み込みの後、現在のロケールで翻訳が必要な場合は、plugins.ts#L35-L71translatePluginContent() が呼ばれます。これは i18n/<locale>/ から翻訳ファイルを読み込み、plugin.translateContent() を通じてコンテンツを翻訳するほか、そのプラグインが管理するテーマ設定のスライスも翻訳します。ここで注意すべき副作用があります。翻訳されたテーマ設定は Object.assign によって context.siteConfig.themeConfig に直接マージされます。

ルートの生成とactionsシステム(フェーズ3:contentLoaded)

コンテンツを読み込んだ後、各プラグインの contentLoaded() には3つのメソッドを持つ actions オブジェクトが渡されます。プラグインが最終的な出力に影響を与えられるのは、この3つだけです。実装は actions.ts#L28-L103 にあります:

addRoute(config) — Reactルートを登録します。ルート設定には pathcomponent@theme/DocItem のようなテーマコンポーネントエイリアス)・modules(コンポーネントとともに読み込まれるデータモジュール)が含まれます。末尾スラッシュの正規化はサイト設定に基づいて自動的に行われます。

createData(name, data) — JSONまたは文字列ファイルを .docusaurus/<pluginName>/<pluginId>/ に書き出します。返された絶対パスは addRoute() のモジュール参照として利用できます。データはプラグイン名とIDでネームスペース管理されるため、衝突を防げます。

setGlobalData(data)useGlobalData()usePluginData() を通じてどのコンポーネントからもアクセスできるデータをセットします。ルート固有のデータとは異なり、全ページで利用可能です。

flowchart TD
    CL[contentLoaded] --> AR[addRoute]
    CL --> CD[createData]
    CL --> SGD[setGlobalData]
    AR --> |route config| ROUTES[Routes array]
    CD --> |JSON file| DOT[.docusaurus/pluginName/pluginId/]
    SGD --> |data| GD[globalData]
    DOT --> |module path used in| AR

各ルートには、actions.ts#L78-L83 で暗黙的にコンテキストモジュールも注入されます。この __plugin.json ファイルには {name, id} が格納されており、テーマコンポーネントから useRouteContext() 経由でアクセスできます。「このルートを生成したのはどのプラグインか」をコンポーネントが知るための仕組みです。

クロスプラグイン通信(フェーズ4:allContentLoaded)

すべてのプラグインがコンテンツを読み込み、ルートを生成し終えると allContentLoaded() が実行されます。このフェーズでは、allContent パラメータを通じて他のプラグインのコンテンツを参照できます。

plugins.ts#L191-L228 のオーケストレーションでは、全コンテンツを pluginNamepluginId をキーとして集約し、各プラグインに新しい actions オブジェクトを渡します。このフェーズで生成されたルートとグローバルデータは、contentLoaded() のものとマージされます。

sequenceDiagram
    participant LP as loadPlugins
    participant AGG as aggregateAllContent
    participant P1 as Plugin A
    participant P2 as Plugin B

    LP->>AGG: Collect all plugins' content
    AGG-->>LP: allContent map
    LP->>P1: allContentLoaded({allContent, actions})
    LP->>P2: allContentLoaded({allContent, actions})
    Note over P1,P2: Plugins can read each other's content
    P1-->>LP: Additional routes/globalData
    P2-->>LP: Additional routes/globalData
    LP->>LP: mergeResults()

このフェーズの利用頻度は高くありませんが、強力な統合処理を実現できます。たとえば、docsとブログ記事の間にクロスリファレンスを生成したり、すべてのコンテンツタイプを横断した統合検索インデックスを構築したりすることが可能です。

プリセット:プラグインのバンドル

多くのDocusaurusサイトは preset-classic を使っています。これは必要なプラグインとテーマを1つの設定エントリにまとめたものです。preset-classic/src/index.ts#L26-L115 のプリセット関数はシンプルで、プリセットオプションに基づいて {themes, plugins} の配列を返すだけです。

graph TD
    PC[preset-classic] --> TC[theme-classic]
    PC -->|if algolia configured| TSA[theme-search-algolia]
    PC -->|if docs !== false| DOCS[plugin-content-docs]
    PC -->|if blog !== false| BLOG[plugin-content-blog]
    PC -->|if pages !== false| PAGES[plugin-content-pages]
    PC -->|debug mode| DEBUG[plugin-debug]
    PC -->|if sitemap !== false| SM[plugin-sitemap]
    PC -->|if svgr !== false| SVGR[plugin-svgr]
    PC -->|if gtag option| GTAG[plugin-google-gtag]

条件付きインクルードのパターンは注目に値します。docs: false を渡すとdocsプラグインが完全に無効になります。ブログ専用サイトはこの仕組みを利用して、docsを無効にしてブログプラグインだけで動作させています。

プリセットの展開処理は presets.ts#L25-L66 にあります。プリセット関数は (context, presetOptions) を引数として呼び出され、返されたプラグインとテーマはスタンドアロンのプラグイン・テーマよりもに、メインのプラグインリストへ展開されます。そのため、テーマエイリアス解決の優先度はスタンドアロンのプラグインより低くなります。

Syntheticプラグインとブートストラップ層

すべてのユーザープラグインが初期化された後、2つのハードコードされた「Synthetic」プラグインが plugins.ts#L274-L277 で末尾に追加されます:

docusaurus-bootstrap-pluginsynthetic.ts#L22-L70)は、docusaurus.config.js に宣言されたサイトレベルのクライアントモジュール・スクリプト・スタイルシートを注入します。stylesheetsscripts の設定配列を injectHtmlTags() 経由でHTMLタグに変換します。

docusaurus-mdx-fallback-pluginsynthetic.ts#L78-L130)は、どのコンテンツプラグインのディレクトリにも含まれない .md および .mdx ファイル向けにwebpackのMDXローダーを追加します。これにより、リポジトリルートの README.md のようなスタンドアロンのマークダウンファイルをReactコンポーネントとしてインポートできるようになります。既存のwebpackルールを検査して、コンテンツプラグインがすでに処理しているパスを除外する実装になっており、rule.include 配列をチェックするという現実的なアプローチが採用されています。

ヒント: 両方のSyntheticプラグインは version: {type: 'synthetic'} をセットしており、サイトメタデータ上でユーザープラグインと区別できます。

プラグインの型階層

型システムは、plugin.d.ts の3つのインターフェースを通じてライフサイクルの進行をモデル化しています:

graph TD
    P["Plugin&lt;Content&gt;<br/>name, loadContent, contentLoaded,<br/>allContentLoaded, configureWebpack, ..."] --> IP["InitializedPlugin<br/>+ options, version, path"]
    IP --> LP["LoadedPlugin<br/>+ content, globalData,<br/>routes, defaultCodeTranslations"]

Plugin<Content>(125行目)— コンストラクタが返す生のプラグインインターフェースです。name・ライフサイクルメソッド・オプションのフックを持ちます。

InitializedPlugin(196行目)— 初期化後に options(検証済み)・version(検出済み)・path(解決済みのディレクトリ)が追加されます。

LoadedPlugin(203行目)— コンテンツ読み込み後に contentglobalDataroutesdefaultCodeTranslations が追加されます。

この段階的な型の拡張により、型を見るだけでどのライフサイクルフェーズまで完了しているかが一目でわかります。LoadedPlugin を受け取る関数であれば、コンテンツとルートが必ず利用可能であることが保証されます。

全体の流れを整理する

plugins.ts#L264-L297loadPlugins() オーケストレーションは、4つのフェーズすべてをつなぎ合わせています。プラグインの初期化 → Syntheticプラグインの追加 → 全プラグインの loadContent()contentLoaded() を並列実行 → クロスプラグインデータ連携のための allContentLoaded() → ルートとグローバルデータのマージ、という流れです。

その結果は loadSite() に返され、コード生成処理へと渡されます。ルートは @generated/routes に、グローバルデータは @generated/globalData.json になり、クライアントのReactアプリがレンダリングに必要なすべてのデータを手にします。

次の記事では、これらのルートがビルドパイプラインへと渡る流れを追います。webpackまたはRspackがクライアント・サーバーのバンドルをコンパイルし、SSGエンジンが全ページを静的なHTMLとしてレンダリングする過程を見ていきましょう。