29
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NRI OpenStandiaAdvent Calendar 2024

Day 1

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

Last updated at Posted at 2024-12-01

はじめに

2024/11/22に React Router v7 がリリースされました。
2024/5にRemixとReact Routerの統合が発表されてから約半年、どのような形で統合されるか気になっていた方も多いのではないでしょうか。

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

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

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

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

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

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

つくるものと方針

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

基本機能の作成

React Router v7 のセットアップ

インストール

公式ドキュメントに従いcreate-react-routerを利用してインストールします。

prerender の有効化

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

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

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

MDXのセットアップ

MDXを利用して、.mdx(.md)をコンパイルしてReactのコンポーネントを生成できるようにします。.mdxをコンパイルするタイミングは

  • ビルド
  • ランタイム

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

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

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

React Router v7 は viteを利用しているので、MDXのRollup pluginである、@mdx-js/rollup を利用します。

npm install @mdx-js/rollup

でインストールした後、MDXのガイドに従い設定します。

vite.config.ts
export default defineConfig({
  plugins: [
+   {
+     enforce: 'pre',
+     ...mdx(),
+   },
    reactRouter(),
    tsconfigPaths(),
  ],
});

何のための設定か

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

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

# Hello

Hello world!

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

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

他のファイルやコンポーネントからは、jsxやtsxで定義したコンポーネント同様にimportして利用できます。

ルーティングの設定

React Router v7 では、routes.tsにルーティング定義を行うアプローチをデフォルトで採用しています。そこから参照している各ルートファイルをRoute Moduleと呼びます。

Remixと同様 File Based Routing も可能ですが、Architecture Decision Recordによれば、React Router v7では、以下の理由からRoute Moduleがデフォルトです。

  • ファイルやディレクトリ構造は好みが分かれる
  • ファイル名の規約は制約が大きく、ファイルベースは高度なルーティングがやりづらい
  • 設定ベースの柔軟なルーティングのほうが、React Routerの当初の哲学に沿う

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

コンテンツの用意

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

app/routes/hello.mdx
# Hello

Hello world!

app/routes.ts の設定

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

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

(Optional) 型エラーへの対応

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

npm run typecheck

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

https://reactrouter.com/explanation/type-safety より引用:

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として上がっており、対応が検討されています。

パッチを当てる

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

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 を利用して、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の公式ガイドにある通り、

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

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

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

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

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

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

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

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

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>で渡してあげます。

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を利用している場合は、

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.cssMVP.cssを使うと良いと思います。Tailwindでは、Tailwind Typography があります。

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

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

React Router v7 のベースとなる vite ではこういうケースでimport.meta.globが便利です。

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

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とは、

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

# Hello

Hello world

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

remark-frontmatterremark-mdx-frontmatterを利用すれば、簡単にFrontmatterを扱うことができます。

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

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


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を扱うことができます。


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

console.log(frontmatter.title);

Route Module と Frontmatter の統合

Route Moduleで解説されている通り、各ルートのファイルには規約があります。

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

そうなると、例えば、

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

This is my first article.

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


<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をコンパイルする際に、以下のように変換するようにすればよいわけです。


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プラグインの出番です。Frontmatterの利用でもご紹介した、remark-mdx-frontmatter を参考に以下の通り実装してみます。

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

ちょっと長いですが、

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 プラグインに設定します。

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にFrontmatterの内容を設定しておくと、useMatchesで各記事のメタ情報が取得できるので便利です。

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

シンタックスハイライト

シンタックスハイライターを導入しましょう。shikiを使ってみます。
Rehypeプラグインがあるので設定しましょう。

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 を利用する方法。.mdxのパス、.mdxのモジュール(つまり、Frontmatterで定義したタイトル)が取得できるので、.mdxのパスからURLを組み立てればリンク一覧を作ることができます。

もっと楽なやり方は、React Routerが公開している仮想モジュールを利用する方法です。

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

  const {routes} = await import('virtual:react-router/server-build');
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等サーバで実行されるコードからアクセスしてください。

他の仮想モジュールも含めて、このあたりで定義されています。
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のような型安全性のさらなる向上(例えば、LinktoやSerchParmsに型補完が効く)、LoaderによるRSCのサポート、SPAモード + prerenderでの Loaderのサポート、あたりができると嬉しいなと思っています。

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

  1. DocusaurusVitePressなど、ドキュメント作成にフォーカスした静的サイトジェネレータを指しています

29
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?