コンテンツへスキップ
Fumadocs

ローカル Markdown

ローカルの Markdown コンテンツを扱うためのコンテンツソースです。

はじめに

@fumadocs/local-md は、ローカルの Markdown/MDX ファイル向けのコンテンツソースです。設計上バンドラーを使用しないため、ランタイムのみで完結して動作します。

MDX Remote と比べると、ローカルファイルに特化している分、パイプラインのより多くの部分(フロントマター、meta ファイル、検索インデックスの作成)を担います。

Fumadocs MDX と比べると、型生成やバンドラーなしで動作しますが、ビルド時の画像最適化は無効になります。

制限事項

  • ビルド時の画像最適化には対応していません。
  • MDX ファイル内ではインポート/エクスポートを使用できませんが、レンダリング時にコンポーネントを渡すことはできます。

セットアップ

パッケージをインストールします。

npm install @fumadocs/local-md shiki

shiki をインストールしているのは、バンドラーで外部化する必要があるためです。

localMd インスタンスを作成し、Fumadocs に接続します。

lib/source.ts
import { dynamicLoader } from 'fumadocs-core/source';
import { localMd } from '@fumadocs/local-md';

const docs = localMd({
  dir: 'content/docs',
  // オプション
});

const docsLoader = dynamicLoader(docs.dynamicSource(), {
  baseUrl: '/docs',
});

export async function getSource() {
  return docsLoader.get();
}

スキーマ

frontmatterSchema と metaSchema を渡すと、バリデーションに使用するスキーマをカスタマイズできます。

lib/source.ts
import { localMd } from '@fumadocs/local-md';
import { pageSchema, metaSchema } from 'fumadocs-core/source/schema';

const docs = localMd({
  dir: 'content/docs',
  frontmatterSchema: pageSchema.extend({
    // ...
  }),
  metaSchema: metaSchema.extend({
    // ...
  }),
});

使い方

getSource() は通常のコンテンツローダーを返します。各ページには page.data.load() があり、ページをコンパイルしてレンダラーを返します。

ローダーでシリアライズし、クライアントでレンダリングします。

src/routes/docs/$.tsx
import { createFileRoute, notFound } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { useMemo } from 'react';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { rendererFromSerialized } from '@fumadocs/local-md/client';
import type { MarkdownRendererSerializedOptions } from '@fumadocs/local-md';
import { getSource } from '@/lib/source';
import { useMDXComponents } from '@/components/mdx';

// server function の戻り値に対する型レベルのチェックでは構文木を検証できないため、
// シリアライズ可能な型として登録します(`@tanstack/router-core` を解決できない場合はインストールしてください)
declare module '@tanstack/router-core' {
  interface SerializableExtensions {
    fumadocsMarkdownRenderer: MarkdownRendererSerializedOptions;
  }
}

export const Route = createFileRoute('/docs/$')({
  component: Page,
  loader: ({ params }) => serverLoader({ data: params._splat?.split('/') ?? [] }),
});

const serverLoader = createServerFn({
  method: 'GET',
})
  .validator((slugs: string[]) => slugs)
  .handler(async ({ data: slugs }) => {
    const source = await getSource();
    const page = source.getPage(slugs);
    if (!page) throw notFound();

    const { serialize } = await page.data.load();
    return {
      title: page.data.title,
      // 構文木(クライアント側でレンダリングされます)
      render: serialize(),
      pageTree: await source.serializePageTree(source.getPageTree()),
    };
  });

function Page() {
  const { title, render, pageTree } = useFumadocsLoader(Route.useLoaderData());
  const renderer = useMemo(() => rendererFromSerialized(render), [render]);
  const { body, toc } = renderer.renderSync(useMDXComponents());

  return (
    <DocsLayout tree={pageTree}>
      <DocsPage toc={toc}>
        <DocsTitle>{title}</DocsTitle>
        <DocsBody>{body}</DocsBody>
      </DocsPage>
    </DocsLayout>
  );
}

インクルード

<include> はデフォルトで有効になっています。

content/docs/page.mdx
<include>../shared/note.md</include>

Markdown ファイルでは、remark-directive を設定したうえで ::include[../shared/note.md] ディレクティブを使用します。

lib/source.ts
import remarkDirective from 'remark-directive';

const docs = localMd({
  dir: 'content/docs',
  mdOptions: { remarkPlugins: [remarkDirective] },
});

ホットリロード

@fumadocs/local-md はランタイムにファイルを読み込むため、デフォルトではファイルの変更は監視されません。

開発中は、Vite プラグインが Vite のファイルウォッチャーを通じてソースを無効化するため、追加のプロセスやポートは不要です。設定にプラグインを追加し、ソースを接続してください。

import { defineConfig } from 'vite';
import { localContentPlugin } from '@fumadocs/local-md/dev/vite';

export default defineConfig({
  plugins: [localContentPlugin()],
});

コンテンツファイルはモジュールグラフの外にあるため、プラグインはコンテンツディレクトリを Vite のウォッチャーに追加します。無効化されるモジュールがないので、プラグインは代わりにブラウザをリロードします。リロードを自分で制御したい場合は、localContentPlugin({ reload: false }) を渡してください。

Markdown や MDX ファイル (それらがインクルードするファイルを含む) が変更されると、ソースがリロードされます。独自のウォッチャーを組み込む場合は、docs.dir と docs.onDependency() に渡されたファイルを監視し、ファイルの変更時に docs.invalidateFile(path) を呼び出してください。

Nitro

Nitro でデプロイする場合は、Vite の設定に次の 2 つの変更を加えてください。

vite.config.ts
import { defineConfig } from 'vite';
import { nitro } from 'nitro/vite';

export default defineConfig({
  plugins: [
    // Nitro は Shiki の Wasm インポートをバンドルできません。Shiki のデフォルトのエントリーはバイナリをインライン化しています
    nitro({ wasm: false }),
  ],
  resolve: {
    alias: [
      // Nitro はこの shim の `require('react')` を残すため、ランタイムで 2 つ目の React が読み込まれてしまいます
      { find: /^use-sync-external-store\/shim$/, replacement: 'react' },
    ],
  },
});

JavaScript エンジン

MDX ファイル(*.mdx)は new Function で実行されるため、eval() が禁止されている環境(Cloudflare Workers など)では動作しません。

Markdown ファイル(*.md)は eval() を使わずに構文木からレンダリングされます。プラグインが生成した JSX は、組み込みの仮想 JavaScript エンジンで評価されます。このエンジンは rendererOptions.executor で差し替えられます。

信頼できない MDX ファイルは dir に置かないでください。アプリ内で JavaScript として実行されます。

再検証を無効にする

staticSource() は、再検証を行わない一度きりのスナップショットを返します。dynamicLoader() ではなく loader() と組み合わせて使用します:

lib/source.ts
import { loader } from 'fumadocs-core/source';
import { localMd } from '@fumadocs/local-md';

const docs = localMd({
  dir: 'content/docs',
});

export const source = loader(await docs.staticSource(), {
  baseUrl: '/docs',
});

Fumadocs MDX からの移行

移行するには、Fumadocs MDX のセットアップを @fumadocs/local-md のセットアップに置き換えます。

移行前

lib/source.ts
import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro';

const docs = defineDocs({
  dir: 'content/docs',
});

export const source = loader({
  baseUrl: '/docs',
  source: docs.toFumadocsSource(),
});

移行後

lib/source.ts
import { dynamicLoader } from 'fumadocs-core/source';
import { localMd } from '@fumadocs/local-md';

const docs = localMd({
  dir: 'content/docs',
});

const docsLoader = dynamicLoader(docs.dynamicSource(), {
  baseUrl: '/docs',
});

export async function getSource() {
  return docsLoader.get();
}

続いて、次の作業を行います。

最後に、ローダーなどで source オブジェクトを参照している箇所を getSource() を使うように書き換えます。

import { getSource } from '@/lib/source';

const source = await getSource();
const page = source.getPage(slugs);

ページデータの API も変わります。

const page = source.getPage(['...']);

// タイトルと説明
page.data.title;

// カスタムのフロントマタープロパティ
page.data.full;

// getText() API
await page.data.getText('processed');

// コンパイル済みのプロパティ:
page.data.structuredData;
page.data.toc;
return (
  <div>
    <page.data.body components={{}} />
  </div>
);

How is this guide?

最終更新日