【React Router v7】.mdファイルベースの静的サイトをビルドする

# 序
2024/11/22に[React Router v7がリリース](https://remix.run/blog/react-router-v7)されました。
個人的に[Remix v2](https://remix.run/)でいくつかサイトを作ってきたこともあり、統合の話はずっと気になっていたのですが、新機能の[Static Pre-rendering
](https://reactrouter.com/start/framework/rendering#static-pre-rendering)でプリレンダリングが可能になったのが予想外でうれしかったので、これを使った静的サイトを考えてみます。

React Router v7のSSGについては、すでに先駆者の方がいらっしゃいますので二番煎じではありますが、ちょっと違ったアプローチでやっていこうと思います。

↓先駆者の方

https://qiita.com/_ytori/items/6479e5263ec167e0957b

## 完成イメージ

#### トップページ
![トップページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524683/582b82c9-6139-e6d5-84a3-0b4b92787c67.png)

#### 記事一覧
![記事一覧](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524683/7a228842-745b-5f74-47e6-676e3e2028e7.png)

#### 記事単独ページ
![記事単独ページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524683/0401de45-fac9-6b8f-2b70-dfde08d124cb.png)

## プロジェクトを作る

この記事ではパッケージマネージャーとして[`bun`](https://bun.sh/)を使っていますが、コードにおいてBun固有の機能は使ってないので、`npm`でも行けると思います。

```sh
bunx create-react-router@latest react-router-prerendering
cd react-router-prerendering
```

## ファイルベースルーティングを使う

せっかくReact-Routerでもできるようになったので、ファイルベースルーティングにしてみます。

https://reactrouter.com/how-to/file-route-conventions

```sh
bun add @react-router/fs-routes
```

```ts:app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

export default flatRoutes() satisfies RouteConfig;
```

デフォルトでは`app/routes`ディレクトリがルートになります。

```diff:app/routes
- home.tsx
+ _index.tsx
```

`home.tsx`を`_index.tsx`にリネームして`bun run dev`をすると、`http://localhost:5173`で`_index.tsx`が表示されるはずです。

## Markdownの読み込みに必要なパッケージのインストール

Markdownの安全なレンダリングのために[`react-markdown`](https://github.com/remarkjs/react-markdown)、フロントマターの切り出しのために[`gray-matter`](https://github.com/jonschlinkert/gray-matter)、GFMのシンタクスも使いたいので[`remark-gfm`](https://github.com/remarkjs/remark-gfm)をインストールします。

```sh
bun add react-markdown gray-matter remark-gfm
```

今回は簡易的にスタイリングするために、[`daisyUI`](https://daisyui.com/)と[`@tailwindcss/typography`](https://github.com/tailwindlabs/tailwindcss-typography)を使いました

```sh
# スタイリング用
bun add -D daisyui@latest @tailwindcss/typography
```

`tailwind.config.ts`にプラグインとして追加しておきます。

```diff_typescript:tailwind.config.ts
  import type { Config } from "tailwindcss";
  
  export default {
    content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
    theme: {
      extend: {
        fontFamily: {
          sans: [
            '"Inter"',
            "ui-sans-serif",
            "system-ui",
            "sans-serif",
            '"Apple Color Emoji"',
            '"Segoe UI Emoji"',
            '"Segoe UI Symbol"',
            '"Noto Color Emoji"',
          ],
        },
      },
    },
    plugins: [
+     require('@tailwindcss/typography'),
+     require('daisyui'),
    ],
  } satisfies Config;

```

## Markdownの配置先
こんな感じで、`app`と同じ階層に`articles`フォルダを作ります

```diff_yaml:プロジェクトルート
  - /app
      - /routes
          - _index.tsx
+ - /articles
+     - /blog
+         - 01.md
+     - /programming
+         - 01.md
```

`articles`フォルダ以下の構成が、そのままURLのパスになるようなものを目指します。

## Markdown読み込み用ユーティリティ関数を作る

`app/util/loadMarkdown.ts`を作ります。
- ファイルシステムを操作して、`./articles`ディレクトリの中身をあれこれ取得します

```ts:app/util/loadMarkdown.ts
import fs from "node:fs";
import path from "node:path";

// Markdownコンテンツを読み込み
export const loadContent = (dir: string, slug: string) => {
    const combinedPath = path.join(
        dir,
        `${slug.endsWith("/") ? slug.slice(0, -1) : slug}.md`,
    );
    const content = fs.readFileSync(combinedPath, "utf-8");
    return content;
};

// ディレクトリ内のMarkdownファイルを一括取得
export const listContents = (dir: string, page = 0, itemsParPage = 6) => {
    const list = fs
        .readdirSync(dir, {
            encoding: "utf8",
            recursive: true,
            withFileTypes: true,
        })
        // ファイルかつ拡張子が.mdのファイルのみを抽出
        .filter((file) => file.isFile() && file.name.endsWith(".md"))
        // タイムスタンプの降順でソート
        .sort(
            (a, b) =>
                fs.statSync(`${b.parentPath}/${b.name}`).mtimeMs -
                fs.statSync(`${a.parentPath}/${a.name}`).mtimeMs
        )
        .map((file) => {
            const url = `/${file.parentPath}/${path.basename(file.name, ".md")}`;
            const fileName = `${file.parentPath}/${file.name}`;
            return { url, content: fs.readFileSync(fileName, "utf-8") };
        });
    // ページネーション
    const contents = list.slice(page * itemsParPage, (page + 1) * itemsParPage);
    // 次のページがあるか
    const hasNext = list.length > (page + 1) * itemsParPage;
    return { contents, hasNext };
};
```

## `app/routes`の編集
このユーティリティ関数を使って、ルートモジュールを実装していきます。

`app/routes`ディレクトリに以下の2ファイルを追加します。

```diff:app/routes
  _index.tsx
+ articles.page.$page.tsx
+ articles.$.tsx
```

こうすると、トップページは`_index.tsx`、`/articles/pages/{page}`にアクセスすると`articles.page.$page.tsx`、`/articles/{...slug}`にアクセスすると、`articles.$.tsx`がレンダリングされます

- `articles.$.tsx`のようなルートは[`Splat Route`](https://reactrouter.com/start/framework/routing#splats)といい、通常のダイナミックルートより優先度が下がります
    - そのため、`/articles/page/{page}`にアクセスした時、`articles.page.$page.tsx`と`articles.$.tsx`の両方に同時にマッチしていますが、優先度の高い前者のルートモジュールが使われます

### `app/routes/_index.tsx`
これはトップページなので、Markdownの読み出しはしません。

```tsx:app/routes/_index.tsx
export const meta = () => {
  return [{ title: 'React-Routerブログ | トップページ' }];
};

export default function Home() {
  return (
    <>
      <p>トップページのコンテンツはここに書く</p>
    </>
  );
}

```

### `app/routes/articles.page.$page.tsx`
記事一覧です。`listContents`を使って、`./articles`フォルダの中身を総ざらいして表示します

- `gray-matter`を使って、Markdownファイルの中身を、コンテンツ(`content`)とフロントマター(`data`)に分離しています

```tsx:app/routes/articles.page.$page.tsx
import matter from 'gray-matter';
import { Link, type LoaderFunctionArgs, useLoaderData } from 'react-router';
import { listContents } from '~/util/loadMarkdown';

export const meta = () => {
  return [{ title: 'React-Routerブログ | 記事一覧' }];
};

export const loader = async (args: LoaderFunctionArgs) => {
  const page = Number(args.params.page);
  const { contents, hasNext } = listContents('./articles', page);
  const articles = contents.map((article) => {
    const { content, data } = matter(article.content);
    return { url: article.url, content, data };
  });
  return { articles, hasNext, page };
};

export default function Articles() {
  const { articles, hasNext, page } = useLoaderData<typeof loader>();
  return (
    <>
      <ul className="flex flex-row flex-wrap gap-2 mb-2 justify-center">
        {articles.map(({ url, data }) => (
          <li className="card bg-base-100 w-96 shadow-xl" key={url}>
            <div className="card-body">
              <h2 className="card-title">{data.title}</h2>
              <p className="text-info text-xs">{url}</p>
              {data.description && <p>{data.description}</p>}
              <Link to={url} className="btn btn-primary text-md">
                読む
              </Link>
            </div>
          </li>
        ))}
      </ul>
      <div className="flex flex-row gap-2 w-full justify-center">
        {page >= 1 && (
          <Link className="btn btn-primary" to={`/articles/page/${page - 1}`}>
            前のページ
          </Link>
        )}
        {hasNext && (
          <Link className="btn btn-primary" to={`/articles/page/${page + 1}`}>
            次のページ
          </Link>
        )}
      </div>
    </>
  );
}
```

### `app/routes/articles.$.tsx`
記事の個別ページです
`react-markdown`を使って、Markdownコンテンツをレンダリングしています。

```tsx
import {
  type LoaderFunctionArgs,
  type MetaFunction,
  useLoaderData,
} from 'react-router';
import matter from 'gray-matter';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { loadContent } from '~/util/loadMarkdown';

export const meta: MetaFunction<typeof loader> = ({ data: loaderData }) => {
  return [
    { title: `React-Routerブログ | ${loaderData?.data.title}` },
    { description: loaderData?.data.description },
  ];
};

export const loader = async (args: LoaderFunctionArgs) => {
  const path = args.params['*'] as string;
  const mdContent = loadContent('./articles', path);
  const { content, data } = matter(mdContent);
  return { url: path, content, data };
};

export default function Article() {
  const { url, content, data } = useLoaderData<typeof loader>();

  return (
    <article className="prose lg:prose-xl w-11/12 m-auto">
      <h1>{data.title}</h1>
      <p className="text-info text-xs">{url}</p>
      <Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
    </article>
  );
}
```

## `app/root.tsx`

このコンポーネントは全ページ共通で表示されるレイアウトを定義するものです。
こんな感じでナビゲーションリンクを追加します。

```diff_tsx
  export function Layout({ children }: { children: React.ReactNode }) {
    return (
      <html lang="en">
        <head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          <Meta />
          <Links />
        </head>
-       <body>
+      <body className="container bg-base-100 m-auto">
+         <header className="navbar bg-base-100 shadow-xl mb-8">
+           <div className="navbar-start">
+             <Link to="/" className="btn btn-ghost text-xl">
+               React-Router Pre rendering
+             </Link>
+           </div>
+           <div className="navbar-center">
+             <Link to="/articles/page/0" className="btn btn-ghost text-md">
+               記事一覧
+             </Link>
+           </div>
+         </header>
          {children}
          <ScrollRestoration />
          <Scripts />
        </body>
      </html>
    );
  }
```

## ルーティングは完成

ここまでで一通り骨組みができました。
`bun run dev`とすると、トップページ、記事一覧、記事個別ページが見えるはずです。

## プリレンダリングの設定

`react-router.config.ts`でプリレンダリングの設定をしていきます。
- `prerender : true`だと、ダイナミックルートはプリレンダしてくれません
- なので、`./articles`ディレクトリの内容から、有り得るURLを割り出す必要があります

```ts:react-router.config.ts
import type { Config } from "@react-router/dev/config";
import fs from "node:fs";
import path from "node:path";

export default {
  async prerender({ getStaticPaths }) {
    // _index.tsxなど、静的ルート
    const staticPaths = getStaticPaths();
    // ダイナミックルートの割り出し
    const dynamicPaths = fs
      .readdirSync("./articles", {
        encoding: "utf8",
        recursive: true,
        withFileTypes: true,
      })
      .filter((file) => file.isFile() && file.name.endsWith(".md"))
      .map((file) => {
        return `/${file.parentPath}/${path.basename(file.name, ".md")}`;
      });
    // ページ数を割り出してページネーション用のページもレンダリングする
    const pages = Array(Math.ceil(dynamicPaths.length / 6))
      .fill(null)
      .map((_, i) => `/articles/page/${i}`);

    return [...staticPaths, ...dynamicPaths, ...pages];
  },
} satisfies Config;
```

ページネーションのマジックナンバーやディレクトリパスの定数があちこちにあるのがアレですが、目を瞑ってください…　

これで、`bun run build`すると、`./build/clinet`にレンダリング後の静的サイトが配置されます。

## 実行してみる
```sh
bunx http-server ./build/client -c-1
```

これで`http://127.0.0.1:8080`にアクセスすると、レンダリング後のhtmlがホストされていることが確認できます。

## GitHub Pagesにホストしてみました

URLパスにリポジトリ名が入る関係で`base`、`basename`を調整したり、ビルド後に色々フォルダ構成をガチャガチャやらざるを得なかったのですが、なんとかGitHub Pagesでのホストもできました。

#### サイト

https://kedama-t.github.io/react-router-prerendering/

#### リポジトリ

https://github.com/kedama-t/react-router-prerendering


## 終わりに
実は以前、Remixを無理やり静的ビルドできないか試したことがあり、結果的にあきらめたのですが、今回プリレンダリング機能が追加されたことで、書き慣れたRemix（≒ React Router v7）のファイル構造で静的ドキュメントサイトが作れそうだということで、とてもうれしく思っています。

今回はかなり**ごり押し**で全ページをレンダリングしましたが、プリレンダするパスを柔軟に設定できるので、既存サイトで更新頻度が低いページだけピックして設定しておくだけでも、体感速度向上に一役買ってくれることでしょう。

今後がますます楽しみなフレームワークです。