React Router v7 でお手軽ドキュメントサイトジェネレータ

# はじめに

2024/11/22に [React Router v7](https://remix.run/blog/react-router-v7) がリリースされました。
2024/5に[RemixとReact Routerの統合](https://remix.run/blog/merging-remix-and-react-router)が発表されてから約半年、どのような形で統合されるか気になっていた方も多いのではないでしょうか。

React Router v7は、Remixの延長でframeworkとして、ReactRouerの延長でライブラリとして、どのように利用するか開発者の選択の余地を残しつつ、

- 型安全性の向上
- `routes.ts`による設定ベースのルーティング定義 Route Module をデフォルトに
- `prerender()`によるSSGのサポート

あたりが大きな変化と感じています。

個人的には、新機能であるSSGのサポートは興味深いものでした。
そこでこの記事では、その動作検証も兼ねてSSGの活用事例でよくあるドキュメントサイトジェネレータ[^1]を作ってみたいと思います。

なお、実際のコードはこちらにありますので是非ご利用ください。

https://github.com/ytori/mdsite-stack 

また、作ってみたもののデモは以下から確認できます。

https://mdsite-stack.vercel.app

# つくるものと方針

- Markdown、MDXで作成したコンテンツからドキュメントサイトを構築できる
- React Router v7 を活用して
- なるべくシンプル、お手軽に

# 基本機能の作成

## React Router v7 のセットアップ

### インストール
[公式ドキュメント](https://reactrouter.com/start/framework/installation)に従い`create-react-router`を利用してインストールします。

### prerender の有効化

react-router-config.ts に `prerender:true` を追加して、prerender を有効にします。

```diff_typescript:react-router.config.ts
export default {
  ssr: true,
+ prerender: true,
} satisfies Config;
```

:::note info
prerenderにパスの配列を与えたり関数を与えて、特定のパスのみprerenderする、ことも可能です。
https://reactrouter.com/how-to/pre-rendering
:::

## MDXのセットアップ

[MDX](https://mdxjs.com/)を利用して、.mdx(.md)をコンパイルしてReactのコンポーネントを生成できるようにします。.mdxをコンパイルするタイミングは
 
 - ビルド
 - ランタイム

の2つがあります。実装の容易性とスケーラビリティのトレードオフではありますが、ここでは前者をとります。

:::note info
**Rmiex**のガイドにもこの議論がもう少し詳しく記載されています。
https://remix.run/docs/en/main/guides/mdx
:::



### `@mdx-js/rollup`のインストールと設定

React Router v7 は [vite](https://vite.dev/)を利用しているので、MDXのRollup pluginである、[`@mdx-js/rollup`](https://mdxjs.com/packages/rollup/) を利用します。

```
npm install @mdx-js/rollup
```

でインストールした後、[MDXのガイド](https://mdxjs.com/docs/getting-started/#vite)に従い設定します。

```diff_typescript:vite.config.ts
export default defineConfig({
  plugins: [
+   {
+     enforce: 'pre',
+     ...mdx(),
+   },
    reactRouter(),
    tsconfigPaths(),
  ],
});
```
### 何のための設定か

この後の手順の理解のために、この設定により何が起きるかを補足しておきます。
この設定により、.mdx(.md)を、通常のコンポーネントと同様に扱えるようになります。

例えば、以下の.mdxがある場合、

```md
# Hello

Hello world!
```

イメージ的には、以下のようなコンポーネントに変換し、それを `default export`してくれます。

```jsx
export default function MDXContent() {
    return (
        <>
            <h1>Hello</h1>
            <p>Hello world!</p>
        </>
    )
}
```
他のファイルやコンポーネントからは、jsxやtsxで定義したコンポーネント同様にimportして利用できます。

## ルーティングの設定

React Router v7 では、`routes.ts`にルーティング定義を行うアプローチをデフォルトで採用しています。そこから参照している各ルートファイルを[Route Module](https://reactrouter.com/start/framework/route-module)と呼びます。

Remixと同様 File Based Routing も可能ですが、[Architecture Decision Record](https://github.com/remix-run/react-router/blob/main/decisions/0011-routes-ts.md)によれば、React Router v7では、以下の理由からRoute Moduleがデフォルトです。
 - ファイルやディレクトリ構造は好みが分かれる
 - ファイル名の規約は制約が大きく、ファイルベースは高度なルーティングがやりづらい
 - 設定ベースの柔軟なルーティングのほうが、React Routerの当初の哲学に沿う

Route Moduleを利用して、.mdxで作成したコンテンツが表示されるようにしていきましょう。

### コンテンツの用意

`app/routes`以下にmdxを用意していきます。ここでは、`app/routes/hello.mdx` とします。

```mdx:app/routes/hello.mdx
# Hello

Hello world!
```

### `app/routes.ts` の設定

/hello にアクセスすると、`app/routes/hello.mdx`のコンテンツが画面に表示されるようにしてみましょう。

```diff_typescript:app/routes.ts
export default [
  index('routes/home.tsx'),
+ route('routes/hello.mdx', '/hello'), 
] satisfies RouteConfig;

```

## (Optional) 型エラーへの対応

React Router v7 では型安全性を高めるために、`routes.ts`に定義したルート定義から各ルート定義の型ファイルを生成できます。

```sh
npm run typecheck
```

こうすると、.react-routerディレクトリ内に各ルートの型を定義した、.tsファイルが生成されます。これを利用することで、例えば以下のように、コンポーネント内で`loader`が返した値を型安全に利用することができます。

https://reactrouter.com/explanation/type-safety より引用：
```tsx
import type { Route } from "./+types/product";
// types generated for this route 👆

export function loader({ params }: Route.LoaderArgs) {
  //                      👆 { id: string }
  return { planet: `world #${params.id}` };
}

export default function Component({
  loaderData, // 👈 { planet: string }
}: Route.ComponentProps) {
  return <h1>Hello, {loaderData.planet}!</h1>;
}
```

ただし、本記事執筆時の2024/11/30時点（ReactRouter@7.0.1）では、.mdxで定義したルートから生成されたファイルでエラーが起きてしまいます。既にissueとして上がっており、対応が検討されています。

https://github.com/remix-run/react-router/issues/12362

### パッチを当てる

.mdxファイルのルートで本機能を利用することは考えにくく、実際に動作にあたって害はないのですが、エラーが出るのは気持ち悪いですよね。本件修正されるまで、.mdx(.md)ファイルをルートとする場合は型ファイルを生成しないようにパッチを当ててみましょう。

```patch
diff --git a/node_modules/@react-router/dev/dist/cli/index.js b/node_modules/@react-router/dev/dist/cli/index.js
index 266c57f..0300f63 100644
--- a/node_modules/@react-router/dev/dist/cli/index.js
+++ b/node_modules/@react-router/dev/dist/cli/index.js
@@ -744,6 +744,12 @@ async function writeAll(ctx) {
   const typegenDir = getTypesDir(ctx);
   import_node_fs2.default.rmSync(typegenDir, { recursive: true, force: true });
   Object.values(ctx.config.routes).forEach((route) => {
+
+    if([".md", ".mdx"].includes(Path2.extname(route.file))){
+      console.warn(`[patch-package] Skipping type generation for "${route.file}" until the issue is resolved. For more details, please refer to https://github.com/remix-run/react-router/issues/12362.`)
+      return;
+    }
+
     const typesPath = getTypesPath(ctx, route);
     const content = generate(ctx, route);
     import_node_fs2.default.mkdirSync(Path4.dirname(typesPath), { recursive: true });

```

[patch-package](https://github.com/ds300/patch-package) を利用して、`npm install`時にパッチがあたるようにしておくと便利です。


## 動作確認

### 開発時

```
npm run dev
```

`hello.mdx`を修正すると、変更内容が画面にも反映されて、HMRが有効になっていることが確認できます。


### ビルドと起動

```
npm run build
npm run start
```

`npm run build`のタイミングで、prerenderが走って、`build/client/hello.html` が生成されていることが確認できると思います。


# Tips

ここからはより機能を充実させていきましょう。

## コンポーネントのカスタマイズ

.mdxのコンテンツはReactのコンポーネントに変換されます。デフォルトでは[MDXの公式ガイド](https://mdxjs.com/table-of-components/)にある通り、

- `# 見出し` -> `<h1>見出し</h1>`
- `[リンク](/hello)` -> `<a href="/hello">リンク</a>`

のようにHTMLのエレメントに対応するコンポーネントになりますが、独自のコンポーネントに変換したい場合があると思います。

例えば、React Routerはリンクのために、`Link`コンポーネントを提供しているので、

- `[リンク](/hello)` -> `<Link to="/hello">リンク</Link>`

のようにしてみましょう。

まず、`@mdx-js/react`をインストールして、設定します。

```
npm install `@mdx-js/react`
```


```diff_typescript:vite.config.ts
export default defineConfig({
  plugins: [
    {enforce: "pre", ...mdx({
+     providerImportSource: '@mdx-js/react',
    })},
    reactRouter(),
    tsconfigPaths()],
});
```

次に、カスタマイズするコンポーネントを定義します。今回は、アンカーリンクの`<a>`を`<Link>`にしてみます。


```tsx:app/components/mdx-components.tsx
import { ComponentProps, forwardRef } from 'react';
import { Link } from 'react-router';

const Anchor = forwardRef<HTMLAnchorElement, ComponentProps<'a'>>(
  function ReactRouterLink({ href = '', ...props }, forwardedRef) {
    return <Link {...props} to={href} ref={forwardedRef} />;
  },
);

Anchor.displayName = 'Anchor';

export const components = {
  a: Anchor,
}
```

そして、ここでexportしたオブジェクトを、以下のように `<MDXProvider>`で渡してあげます。

```diff_typescript:app/root.tsx 
import { MDXProvider } from '@mdx-js/react';
+ import { components } from '~/components/mdx-components';

//(略)

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning={true}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
+       <MDXProvider components={components}>
            {children}
+       </MDXProvider>
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
```

これだけでOKです。便利ですね。

## スタイリング

基本機能で生成されたHTMLは味気が無いので、スタイルを当てたいですね。

[コンポーネントのカスタマイズ](#コンポーネントのカスタマイズ) を利用して、classを当てたコンポーネントを用意すれば実現できます。例えばtailwindを利用している場合は、

```ts:app/components/mdx-components.ts

function MyHeading1(props: React.ComponentProps<"h1">) {
    return <h1 {...props} className="font-bold text-4xl" />
}

export const components = {
    h1: MyHeading1,
    //...他のコンポーネントも同様
}

```

このような塩梅です。これでなんでもできますが、Markdownで表現できるコンポーネント全てカスタムコンポーネントを用意するのは骨が折れます。

「お手軽にいい感じ」にスタイルを当てたい場合は、HTMLの要素に対して程よくスタイルを当ててくれるCSSフレームワークで、[Simple.css](https://simplecss.org/)や[MVP.css](https://github.com/andybrewer/mvp/)を使うと良いと思います。Tailwindでは、[Tailwind Typography](https://github.com/tailwindlabs/tailwindcss-typography) があります。


## .mdxファイルの追加と`routes.ts`への反映

.mdxを追加するたびに、`routes.ts`を更新するのは面倒なので、.mdxファイルのファイル、ディレクトリ構造から、`routes.ts`のルーティング定義を作成できるようにしましょう。

React Router v7 のベースとなる vite ではこういうケースで[`import.meta.glob`](https://ja.vite.dev/guide/features#glob-%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%9B%E3%82%9A%E3%83%BC%E3%83%88)が便利です。


例えば、以下のようにすれば、routes/docs/ 配下の.md、.mdxファイルをRuote Moduleとして登録してくれます。

```ts:app/routes.ts
import type { RouteConfig } from '@react-router/dev/routes';
import { index, route } from '@react-router/dev/routes';

const docsRoutes = Object.keys(
  import.meta.glob('./routes/docs/**/*.{md,mdx}', {
    query: '?url',
    eager: true,
  }),
)
  .map((file) => {
    return {
      path: file
        .replace('./routes/docs/', 'docs/')
        .replace(/\.(mdx|md)$/, ''),
      file,
    };
  })
  .map(({ path, file }) => route(path, file));

export default [
  index('routes/home.tsx'),
  ...docsRoutes,
] satisfies RouteConfig;
```


## Frontmatterの利用

.mdxで作成した記事にメタ情報を付与できるようにしましょう。Frontmatterとは、

```md:hello.md
---
title: This is my first article
date: 2024/11/29
---

# Hello

Hello world

```
の`---`～`---` で囲んである部分です。YAML等で任意の情報を定義できます。


[remark-frontmatter](https://github.com/remarkjs/remark-frontmatter)、[remark-mdx-frontmatter](https://github.com/remcohaszing/remark-mdx-frontmatter)を利用すれば、簡単にFrontmatterを扱うことができます。


```
npm install remark-frontmatter remark-mdx-frontmatter
```

```diff_typescript:vite.config.ts
export default defineConfig({
  plugins: [
    {enforce: "pre", ...mdx({
    providerImportSource: '@mdx-js/react',
+     remarkPlugins: [
+       [remarkFrontmatter],
+       [remarkMdxFrontmatter],
+     ],  
    })},
    reactRouter(),
    tsconfigPaths()],
});

```

こうすると、先の.mdxは以下のように変換されます。


```jsx

export frontmatter = {
  title: 'This is my first article',
  date: '2024/11/29'
}

export default function MDXContent() {
    return (
        <>
            <h1>Hello</h1>
            <p>Hello world!</p>
        </>
    )
}
```

したがって、mdxファイルをインポートすれば、好きにFrontmatterを扱うことができます。


```ts

const { frontmatter } = await import('./routes/hello.mdx')

console.log(frontmatter.title);

```


## Route Module と Frontmatter の統合

[Route Module](https://reactrouter.com/start/framework/route-module)で解説されている通り、各ルートのファイルには規約があります。

例えば、`meta`という名前の関数を`export`すると`<head>`の中の`<meta>`タグとしてレンダリングしてくれる機能があります。

そうなると、例えば、

```mdx
---
title: Hello World
description: This is the article about Hello World.
---

This is my first article.

```

と書いた記事のページは最終的に、


```html

<head>
  <title>Hello World</title>
  <meta name="description" content="This is the article about Hello World.">
  <meta property="og:title" content="HelloWorld">
  <meta property="og:description" content="This is the article about Hello World.">
  ...
</head>  

```
のようになると嬉しいですね。

これを実現するには、.mdxをコンパイルする際に、以下のように変換するようにすればよいわけです。


```jsx

export meta = () => {
  return [
    { title: "Hello World" },
    {
      name: "description",
      content: "This is the article about Hello World.",
    },
    {
      name: "og:title",
      content: "Hello World",
    },
    {
      name: "og:description",
      content: "This is the article about Hello World.",
    },
  ];
}

export default function MDXContent() {
    return (
        <>
            <p>This is my first article.</p>
        </>
    )
}
```

この変換は、[Remark](https://github.com/remarkjs/remark)プラグインの出番です。[Frontmatterの利用](#Frontmatterの利用)でもご紹介した、[remark-mdx-frontmatter](https://github.com/remcohaszing/remark-mdx-frontmatter) を参考に以下の通り実装してみます。

Remarkが.mdxの内容をAST（抽象構文木）に変換してくれるので、Frontmatterの内容をRoute Moduleの規約に合わせて`export function meta(){...}`で返すように、ASTをいじります。

ちょっと長いですが、


```ts:scripts/remark-react-router-frontmatter
import { valueToEstree } from 'estree-util-value-to-estree';
import { type Literal, type Root } from 'mdast';
import { type Plugin } from 'unified';
import { parse as parseYaml } from 'yaml';

type RemarkReactRouerFrontmatterOptions = {
  meta: (frontmatter: unknown) => Record<string, string>[];
};

const remarkReactRouerFrontmatter: Plugin<
  [RemarkReactRouerFrontmatterOptions],
  Root
> = ({ meta }) => {
  const allParsers: Record<string, (value: string) => unknown> = {
    yaml: parseYaml,
  };

  return (ast) => {
    let data: unknown;
    const node = ast.children.find((child) =>
      Object.hasOwn(allParsers, child.type),
    );

    if (node) {
      const parser = allParsers[node.type];
      const { value } = node as Literal;
      data = parser(value);
    }

    const metaData = data ? meta(data) : undefined;

    if (metaData) {
      ast.children.unshift({
        type: 'mdxjsEsm',
        value: '',
        data: {
          estree: {
            type: 'Program',
            sourceType: 'module',
            body: [
              {
                type: 'ExportNamedDeclaration',
                specifiers: [],
                declaration: {
                  type: 'VariableDeclaration',
                  kind: 'const',
                  declarations: [
                    {
                      type: 'VariableDeclarator',
                      id: { type: 'Identifier', name: 'meta' },
                      init: {
                        type: 'ArrowFunctionExpression',
                        expression: false,
                        generator: false,
                        async: false,
                        params: [],
                        body: {
                          type: 'BlockStatement',
                          body: [
                            {
                              type: 'ReturnStatement',
                              argument: valueToEstree(metaData, {
                                preserveReferences: true,
                              }),
                            },
                          ],
                        },
                      },
                    },
                  ],
                },
              },
            ],
          },
        },
      });
    }
  };
};

export default remarkReactRouerFrontmatter;
```

使うときは、`@mdx-js/rollup` プラグインに設定します。

```diff_typescript:vite.config.ts
import remarkReactRouerFrontmatter from './scripts/remark-react-router-frontmatter';

export default defineConfig({
  plugins: [
    {
      enforce: 'pre',
      ...mdx({
        providerImportSource: '@mdx-js/react',
        remarkPlugins: [
          [remarkFrontmatter],
+          [
+            remarkReactRouerFrontmatter,
+            {
+              meta: (frontmatter: Record<string, string>) => {
+                return [
+                  { title: `${frontmatter.title} | MySite` },
+                  {
+                    name: 'description',
+                    content: frontmatter.description,
+                  },
+                  {
+                    property: 'og:title',
+                    content: `${frontmatter.title} | MySite`,
+                  },
+               ];
+              },
+            },
+          ],
        ],
      }),
    },
    reactRouter(),
    tsconfigPaths(),
  ],
});

```

これにより、各記事のfrontmatterのプロパティをmetaタグで設定することが可能になりました。

同様にして、React Routerの[`handle`](https://reactrouter.com/start/framework/route-module#handle)にFrontmatterの内容を設定しておくと、`useMatches`で各記事のメタ情報が取得できるので便利です。

また、ASTをどういじれば良いかは、こちらのプレイグランドがとても役に立ちます。

https://mdxjs.com/playground/


## シンタックスハイライト

シンタックスハイライターを導入しましょう。[shiki](https://shiki.style/)を使ってみます。
[Rehypeプラグイン](https://shiki.style/packages/rehype)があるので設定しましょう。


```diff_typescript:vite.config.ts
import rehypeShiki from '@shikijs/rehype';

export default defineConfig({
  plugins: [
    {
      enforce: 'pre',
      ...mdx({
        providerImportSource: '@mdx-js/react',
        remarkPlugins: [
          //略
        ],
+        rehypePlugins: [
+          [
+            rehypeShiki,
+            {
+              themes: {
+                light: 'github-light-default',
+                dark: 'github-dark-default',
+              },
+            },
+          ],
+        ],
      }),
    },
    reactRouter(),
    tsconfigPaths(),
  ],

```

## ドキュメント一覧のリンク作成

記事の一覧からメニューを作成したいですよね。
作成した.mdxのURLとそのタイトルが取得できれば、リンク一覧が作れます。

まず思いつくのが、viteの[`import.meta.glab`](https://ja.vite.dev/guide/features#glob-%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%9B%E3%82%9A%E3%83%BC%E3%83%88) を利用する方法。.mdxのパス、.mdxのモジュール（つまり、Frontmatterで定義したタイトル）が取得できるので、.mdxのパスからURLを組み立てればリンク一覧を作ることができます。

もっと楽なやり方は、React Routerが公開している[仮想モジュール](https://ja.vite.dev/guide/api-plugin#virtual-modules-convention)を利用する方法です。

仮想モジュールは、ビルド時の情報をランタイム時に利用できる機構です。仮想モジュールの`virtual:react-router/server-build`から、ルーティング定義の情報が以下の通り取得できるので、この情報を利用します。


```ts
  const {routes} = await import('virtual:react-router/server-build');
```

```json:routesの中身の例

{
    "root": {
        "id": "root",
        "path": "",
        "module": {}
    },
    "routes/home": {
        "id": "routes/home",
        "parentId": "root",
        "index": true,
        "module": {}
    },
    "routes/docs/00-hello": {
        "id": "routes/docs/hello",
        "path": "docs/hello",
        "module": {
            "handle": {
                "title": "Hello World",
                "description": "This is the article about Hello World."
            }
        }
    }
}
```

先ほど少し言及しましたが、`handle`にタイトル（frontmatterの情報）を入れておくと、記事一覧のリンクに必要なURL（上の例でいう`path`）とタイトル（上の例でいう`module.handle.title`）が一発で取得できるようになります。

なお、`virtual:react-router/server-build`にはサーバサイドのみアクセスできるため、`loader`等サーバで実行されるコードからアクセスしてください。


:::note info
他の仮想モジュールも含めて、このあたりで定義されています。
https://github.com/remix-run/react-router/blob/72f9af9d9ba2ab7b9eb3c7f5a270ad28a5c7f0e3/packages/react-router-dev/vite/plugin.ts#L166
:::


# おわりに

この記事では、最近リリースされたReact Router v7 の機能を簡単に紹介しつつ、シンプルなドキュメントサイトジェネレータを作ってみました。

今後は、React Router v7 を実践で活用してノウハウをためていくとともに、どう進化していくか引き続きウォッチしていきたいです。

個人的には、Tanstack Routerのような型安全性のさらなる向上（例えば、`Link`の`to`やSerchParmsに型補完が効く）、LoaderによるRSCのサポート、SPAモード + prerenderでの Loaderのサポート、あたりができると嬉しいなと思っています。

そして最後になにより、、、
今年も NRI によるアドベントカレンダーを是非ともよろしくお願いします！

- [NRI OpenStandia Advent Calendar 2024 (シリーズ 1〜3)](https://qiita.com/advent-calendar/2024/nri-openstandia)
- [NRI OpenStandia (IAM編) Advent Calendar 2024](https://qiita.com/advent-calendar/2024/nri-openstandia-iam)
- [一歩ずつRustに慣れていくTypeScriptエンジニアの記録 Advent Calendar 2024 @k4nd4 の個人カレンダー](https://qiita.com/advent-calendar/2024/rust-from-ts)


[^1]: [Docusaurus](https://docusaurus.io/)や[VitePress](https://vitepress.dev/)など、ドキュメント作成にフォーカスした静的サイトジェネレータを指しています