B-159修正計画書: 個別ページ分割による静的化
B-159 修正計画書: 個別ページ分割による静的化
概要
ownerの方針に基づき、UX最優先の判断基準に従い、アプローチA(個別ページ分割)を採用する。動的ルート [slug] を廃止し、各ツール・チートシートに個別のページファイルを作成することで、真のコード分割を実現し、ローディングフラッシュを完全に解消する。
現状の確認結果
ソースコードを実際に読んで確認した結果を以下にまとめる。
現在のアーキテクチャ
- ツール(33個):
src/app/tools/[slug]/page.tsxが動的ルート。ToolRenderer.tsx(use client)がnext/dynamicで全33個のコンポーネントをループ初期化。 - チートシート(7個):
src/app/cheatsheets/[slug]/page.tsxが動的ルート。CheatsheetRenderer.tsx(use client)が同様にnext/dynamicで全7個をループ初期化。 - OGP画像:
[slug]/opengraph-image.tsxがgenerateStaticParamsとtoolsBySlug/cheatsheetsBySlugを使ってメタデータのみ参照(componentImport は未使用)。 - レジストリ:
registry.tsはメタデータ +componentImportを保持。allToolMetas,allCheatsheetMetas,toolsBySlug,cheatsheetsBySlug,getAllToolSlugs,getAllCheatsheetSlugsをエクスポート。 - 一覧ページ:
allToolMetas/allCheatsheetMetasを参照(componentImport は未使用)。 - サイトマップ・検索インデックス:
allToolMetas/allCheatsheetMetasのメタデータのみ参照。
ゲームセクションのパターン(参照実装)
- 各ゲームは
src/app/games/{game-slug}/page.tsxに個別のページファイルを持つ。 - ゲームの
registry.tsにはメタデータのみ(componentImport なし)。 - OGP画像は各ゲームディレクトリに
opengraph-image.tsxを個別に配置。generateStaticParamsは不要(動的ルートではないため)。 - メタデータは各
page.tsxで静的にexport const metadataとして定義している(generateMetadata ではない)。
設計方針
方針1: 共通ヘルパー関数でDRYにする
ゲームのパターンでは各ページに metadata をベタ書きしているが、ツール/チートシートは33個+7個あり、ベタ書きは非現実的かつメンテナンス性が低い。そこで、ページファイルは最小限に抑え、共通ヘルパー関数を使ってDRYにする。
具体的には、既存の generateToolMetadata() / generateCheatsheetMetadata() / generateToolJsonLd() / generateCheatsheetJsonLd() ヘルパー関数(src/lib/seo.ts 内)をそのまま活用する。
方針2: レジストリは維持する(componentImport のみ削除)
レジストリ(registry.ts)はメタデータの一元管理として引き続き有効。一覧ページ、サイトマップ、検索インデックス、OGP画像、SEOテストなど、多くの箇所がレジストリのメタデータを参照している。componentImport フィールドだけを削除する。
方針3: OGP画像は共通ファイルを維持する
OGP画像生成(opengraph-image.tsx)について検討した結果:
- 現在の
[slug]/opengraph-image.tsxはレジストリのメタデータのみ参照し、generateStaticParamsで全スラッグの画像を生成している。 - 個別ページ分割後、OGP画像ファイルも40個作成するのは非効率。
- 解決策: ツール/チートシートの opengraph-image.tsx は引き続き
[slug]ディレクトリに配置する。Next.js の Route Groups や catch-all route を使わず、[slug]ディレクトリ自体は残し、OGP画像生成専用として使う。ただしpage.tsxとToolRenderer.tsx/CheatsheetRenderer.tsxは削除する。
重要: Next.js の App Router では、opengraph-image.tsx は page.tsx と同じルートセグメントに配置される必要がある。[slug] ディレクトリから page.tsx を削除すると、opengraph-image.tsx が正しく動作しない可能性がある。
修正方針: 以下のいずれかを採用する:
- 案A:
[slug]ディレクトリを完全に残し、page.tsxは個別ページへリダイレクトせず、単に個別ページを正として扱う。[slug]/page.tsxを削除し、各個別ページのopengraph-image.tsxは共通関数で生成する。 - 案B(推奨):
[slug]ディレクトリのpage.tsxを、個別ページのコンポーネントをレジストリベースで解決する薄いラッパーとして維持しつつ、実際のページコンテンツは個別ページファイルから提供する。ただしこれは二重ルーティングになるため不適切。
最終方針: 各個別ページディレクトリに opengraph-image.tsx を配置する。40ファイルになるが、内容は共通ヘルパー関数(新規作成)を呼ぶだけの1行関数にする。これがゲームセクションと同じパターンであり、Next.js の正しいパターンである。
個別ページファイルのテンプレート
ツール用テンプレート(例: char-count)
// src/app/tools/char-count/page.tsx
import { notFound } from "next/navigation";
import { toolsBySlug } from "@/tools/registry";
import {
generateToolMetadata,
generateToolJsonLd,
safeJsonLdStringify,
} from "@/lib/seo";
import ToolLayout from "@/tools/_components/ToolLayout";
import ToolErrorBoundary from "@/tools/_components/ErrorBoundary";
import CharCountComponent from "@/tools/char-count/Component";
const SLUG = "char-count";
const tool = toolsBySlug.get(SLUG);
export const metadata = tool ? generateToolMetadata(tool.meta) : {};
export default function CharCountPage() {
if (!tool) notFound();
return (
<ToolLayout meta={tool.meta}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdStringify(generateToolJsonLd(tool.meta)),
}}
/>
<ToolErrorBoundary>
<CharCountComponent />
</ToolErrorBoundary>
</ToolLayout>
);
}
チートシート用テンプレート(例: regex)
// src/app/cheatsheets/regex/page.tsx
import { notFound } from "next/navigation";
import { cheatsheetsBySlug } from "@/cheatsheets/registry";
import {
generateCheatsheetMetadata,
generateCheatsheetJsonLd,
safeJsonLdStringify,
} from "@/lib/seo";
import CheatsheetLayout from "@/cheatsheets/_components/CheatsheetLayout";
import RegexComponent from "@/cheatsheets/regex/Component";
const SLUG = "regex";
const cheatsheet = cheatsheetsBySlug.get(SLUG);
export const metadata = cheatsheet
? generateCheatsheetMetadata(cheatsheet.meta)
: {};
export default function RegexCheatsheetPage() {
if (!cheatsheet) notFound();
return (
<CheatsheetLayout meta={cheatsheet.meta}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdStringify(
generateCheatsheetJsonLd(cheatsheet.meta),
),
}}
/>
<RegexComponent />
</CheatsheetLayout>
);
}
OGP画像テンプレート(ツール用)
// src/app/tools/char-count/opengraph-image.tsx
import { toolsBySlug } from "@/tools/registry";
import {
createOgpImageResponse,
ogpSize,
ogpContentType,
} from "@/lib/ogp-image";
export const alt = "yolos.net tool";
export const size = ogpSize;
export const contentType = ogpContentType;
export default async function OpenGraphImage() {
const tool = toolsBySlug.get("char-count");
const title = tool?.meta.name ?? "Tool";
const subtitle = tool?.meta.shortDescription ?? "";
return createOgpImageResponse({
title,
subtitle,
accentColor: "#0891b2",
icon: "\uD83D\uDEE0\uFE0F",
});
}
OGP画像テンプレート(チートシート用)
// src/app/cheatsheets/regex/opengraph-image.tsx
import { cheatsheetsBySlug } from "@/cheatsheets/registry";
import {
createOgpImageResponse,
ogpSize,
ogpContentType,
} from "@/lib/ogp-image";
export const alt = "yolos.net cheatsheet";
export const size = ogpSize;
export const contentType = ogpContentType;
export default async function OpenGraphImage() {
const cheatsheet = cheatsheetsBySlug.get("regex");
const title = cheatsheet?.meta.name ?? "Cheatsheet";
const subtitle = cheatsheet?.meta.shortDescription ?? "";
return createOgpImageResponse({
title,
subtitle,
accentColor: "#7c3aed",
icon: "\uD83D\uDCCB",
});
}
変更対象ファイルの完全なリスト
新規作成するファイル(80ファイル)
ツール個別ページ(33ファイル x 2 = 66ファイル)
各ツールに page.tsx と opengraph-image.tsx を作成:
| ツール slug | page.tsx | opengraph-image.tsx |
|---|---|---|
| char-count | src/app/tools/char-count/page.tsx | src/app/tools/char-count/opengraph-image.tsx |
| json-formatter | src/app/tools/json-formatter/page.tsx | src/app/tools/json-formatter/opengraph-image.tsx |
| base64 | src/app/tools/base64/page.tsx | src/app/tools/base64/opengraph-image.tsx |
| url-encode | src/app/tools/url-encode/page.tsx | src/app/tools/url-encode/opengraph-image.tsx |
| text-diff | src/app/tools/text-diff/page.tsx | src/app/tools/text-diff/opengraph-image.tsx |
| hash-generator | src/app/tools/hash-generator/page.tsx | src/app/tools/hash-generator/opengraph-image.tsx |
| password-generator | src/app/tools/password-generator/page.tsx | src/app/tools/password-generator/opengraph-image.tsx |
| qr-code | src/app/tools/qr-code/page.tsx | src/app/tools/qr-code/opengraph-image.tsx |
| regex-tester | src/app/tools/regex-tester/page.tsx | src/app/tools/regex-tester/opengraph-image.tsx |
| unix-timestamp | src/app/tools/unix-timestamp/page.tsx | src/app/tools/unix-timestamp/opengraph-image.tsx |
| html-entity | src/app/tools/html-entity/page.tsx | src/app/tools/html-entity/opengraph-image.tsx |
| fullwidth-converter | src/app/tools/fullwidth-converter/page.tsx | src/app/tools/fullwidth-converter/opengraph-image.tsx |
| text-replace | src/app/tools/text-replace/page.tsx | src/app/tools/text-replace/opengraph-image.tsx |
| color-converter | src/app/tools/color-converter/page.tsx | src/app/tools/color-converter/opengraph-image.tsx |
| markdown-preview | src/app/tools/markdown-preview/page.tsx | src/app/tools/markdown-preview/opengraph-image.tsx |
| dummy-text | src/app/tools/dummy-text/page.tsx | src/app/tools/dummy-text/opengraph-image.tsx |
| date-calculator | src/app/tools/date-calculator/page.tsx | src/app/tools/date-calculator/opengraph-image.tsx |
| byte-counter | src/app/tools/byte-counter/page.tsx | src/app/tools/byte-counter/opengraph-image.tsx |
| csv-converter | src/app/tools/csv-converter/page.tsx | src/app/tools/csv-converter/opengraph-image.tsx |
| number-base-converter | src/app/tools/number-base-converter/page.tsx | src/app/tools/number-base-converter/opengraph-image.tsx |
| kana-converter | src/app/tools/kana-converter/page.tsx | src/app/tools/kana-converter/opengraph-image.tsx |
| email-validator | src/app/tools/email-validator/page.tsx | src/app/tools/email-validator/opengraph-image.tsx |
| unit-converter | src/app/tools/unit-converter/page.tsx | src/app/tools/unit-converter/opengraph-image.tsx |
| yaml-formatter | src/app/tools/yaml-formatter/page.tsx | src/app/tools/yaml-formatter/opengraph-image.tsx |
| image-base64 | src/app/tools/image-base64/page.tsx | src/app/tools/image-base64/opengraph-image.tsx |
| age-calculator | src/app/tools/age-calculator/page.tsx | src/app/tools/age-calculator/opengraph-image.tsx |
| bmi-calculator | src/app/tools/bmi-calculator/page.tsx | src/app/tools/bmi-calculator/opengraph-image.tsx |
| sql-formatter | src/app/tools/sql-formatter/page.tsx | src/app/tools/sql-formatter/opengraph-image.tsx |
| cron-parser | src/app/tools/cron-parser/page.tsx | src/app/tools/cron-parser/opengraph-image.tsx |
| image-resizer | src/app/tools/image-resizer/page.tsx | src/app/tools/image-resizer/opengraph-image.tsx |
| business-email | src/app/tools/business-email/page.tsx | src/app/tools/business-email/opengraph-image.tsx |
| keigo-reference | src/app/tools/keigo-reference/page.tsx | src/app/tools/keigo-reference/opengraph-image.tsx |
| traditional-color-palette | src/app/tools/traditional-color-palette/page.tsx | src/app/tools/traditional-color-palette/opengraph-image.tsx |
チートシート個別ページ(7ファイル x 2 = 14ファイル)
| チートシート slug | page.tsx | opengraph-image.tsx |
|---|---|---|
| regex | src/app/cheatsheets/regex/page.tsx | src/app/cheatsheets/regex/opengraph-image.tsx |
| git | src/app/cheatsheets/git/page.tsx | src/app/cheatsheets/git/opengraph-image.tsx |
| markdown | src/app/cheatsheets/markdown/page.tsx | src/app/cheatsheets/markdown/opengraph-image.tsx |
| http-status-codes | src/app/cheatsheets/http-status-codes/page.tsx | src/app/cheatsheets/http-status-codes/opengraph-image.tsx |
| cron | src/app/cheatsheets/cron/page.tsx | src/app/cheatsheets/cron/opengraph-image.tsx |
| html-tags | src/app/cheatsheets/html-tags/page.tsx | src/app/cheatsheets/html-tags/opengraph-image.tsx |
| sql | src/app/cheatsheets/sql/page.tsx | src/app/cheatsheets/sql/opengraph-image.tsx |
削除するファイル(4ファイル)
| # | ファイル | 理由 |
|---|---|---|
| 1 | src/app/tools/[slug]/page.tsx | 動的ルート廃止 |
| 2 | src/app/tools/[slug]/ToolRenderer.tsx | 動的ルート廃止に伴い不要 |
| 3 | src/app/tools/[slug]/opengraph-image.tsx | 個別ページに移行 |
| 4 | src/app/cheatsheets/[slug]/page.tsx | 動的ルート廃止 |
| 5 | src/app/cheatsheets/[slug]/CheatsheetRenderer.tsx | 動的ルート廃止に伴い不要 |
| 6 | src/app/cheatsheets/[slug]/opengraph-image.tsx | 個別ページに移行 |
注: [slug] ディレクトリ自体も空になるので削除する。
変更するファイル(4ファイル)
| # | ファイル | 変更内容 |
|---|---|---|
| 1 | src/tools/types.ts | ToolDefinition から componentImport フィールドを削除 |
| 2 | src/cheatsheets/types.ts | CheatsheetDefinition から componentImport フィールドを削除 |
| 3 | src/tools/registry.ts | 全33エントリから componentImport 行を削除 |
| 4 | src/cheatsheets/registry.ts | 全7エントリから componentImport 行を削除 |
| 5 | docs/new-feature-guide.md | ツール/チートシート追加手順を更新 |
変更不要なファイル(確認済み)
- src/app/tools/page.tsx(一覧ページ):
allToolMetasのみ参照、影響なし - src/app/cheatsheets/page.tsx(一覧ページ):
allCheatsheetMetasのみ参照、影響なし - src/app/sitemap.ts:
allToolMetas/allCheatsheetMetasのメタデータのみ参照、影響なし - src/lib/search/build-index.ts: メタデータのみ参照、影響なし
- src/tools/_components/ToolLayout.tsx: レイアウトコンポーネント、影響なし
- src/tools/_components/ErrorBoundary.tsx: 引き続き使用
- src/cheatsheets/_components/CheatsheetLayout.tsx: レイアウトコンポーネント、影響なし
- src/tools/*/logic.test.ts: ロジックテスト、影響なし
- src/cheatsheets/tests/registry.test.ts: componentImport を直接テストしていない、影響なし
- src/app/tests/seo-coverage.test.ts: ツール/チートシートの個別ページの動的メタデータはテスト対象外、影響なし
主要な設計判断
1. generateStaticParams の扱い
個別ページファイルになるため、generateStaticParams は不要になる。各ページは固定ルート(例: /tools/char-count)であり、動的パラメータが存在しない。Next.js は個別ページファイルを自動的に静的生成する。
2. generateMetadata の扱い
静的 export const metadata に変更する。個別ページでは slug が定数として確定しているため、generateMetadata 関数ではなく、モジュールレベルで export const metadata = generateToolMetadata(tool.meta) とする。これはゲームセクションのパターンと同一であり、Next.js で推奨されるパターンである。
3. opengraph-image.tsx の扱い
各個別ページディレクトリに配置する。Next.js の App Router では、opengraph-image.tsx は同じルートセグメントの page.tsx と対になる必要がある。[slug] ディレクトリを削除するため、OGP画像も個別に配置する必要がある。内容は共通ヘルパー関数を呼ぶだけの定型コードにする。
4. ToolErrorBoundary の扱い
ToolRenderer.tsx が廃止されるため、ErrorBoundary は各ツールページの page.tsx 内で直接使用する。チートシートはサーバーコンポーネントのため ErrorBoundary は不要(現在も CheatsheetRenderer にはなかった)。
5. metadata の型について
ゲームの page.tsx では export const metadata: Metadata = { ... } と型注釈を付けている。ツール/チートシートでもヘルパー関数の戻り値に Metadata 型注釈があるため、型安全性は確保される。ただし念のため export const metadata: Metadata = generateToolMetadata(tool.meta) と明示的に型注釈を付ける。
一覧ページへの影響
影響なし。一覧ページ(/tools, /cheatsheets)は allToolMetas / allCheatsheetMetas を参照しており、これらはレジストリから export されるメタデータ配列である。componentImport の削除はメタデータに影響しない。
既存テストへの影響と対応
影響なし(大多数)
src/tools/*/logic.test.ts(33個): ロジックテスト。変更なし。src/tools/_components/__tests__/ToolLayout.test.tsx: レイアウトテスト。変更なし。src/cheatsheets/_components/__tests__/: CheatsheetLayout, CodeBlock のテスト。変更なし。src/app/__tests__/seo-coverage.test.ts: ツール/チートシート個別ページはテスト対象外。静的ページリスト(/tools,/cheatsheets)はそのまま通る。src/app/__tests__/sitemap.test.ts: メタデータのみ参照。変更なし。
影響が軽微
src/cheatsheets/__tests__/registry.test.ts: componentImport を直接テストしていない。cheatsheetsBySlug.size,allCheatsheetMetas,getAllCheatsheetSlugs()のみ検証。型定義変更後も通る。
推奨する新規テスト
個別ページの網羅性テスト: レジストリに登録された全スラッグに対して、対応する個別ページファイルが存在することを検証するテスト。registry.ts にツールを追加したが個別ページの作成を忘れた場合に検出できる。
テストのイメージ:
// src/app/tools/__tests__/page-coverage.test.ts
import { getAllToolSlugs } from "@/tools/registry";
import { readdirSync } from "fs";
import { join } from "path";
test("全ツールスラッグに対応する個別ページが存在する", () => {
const slugs = getAllToolSlugs();
const toolsDir = join(process.cwd(), "src/app/tools");
const dirs = readdirSync(toolsDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && d.name !== "__tests__" && d.name !== "page" && !d.name.startsWith("["))
.map((d) => d.name);
for (const slug of slugs) {
expect(dirs).toContain(slug);
}
});
新しいツール/チートシート追加時の手順
ツール追加(変更後)
src/tools/{slug}/にディレクトリ作成(Component.tsx, meta.ts, logic.ts)-- 変更なしsrc/tools/registry.tsに meta を登録 -- 変更: componentImport は不要- 新規:
src/app/tools/{slug}/page.tsxを作成(テンプレートに従う) - 新規:
src/app/tools/{slug}/opengraph-image.tsxを作成(テンプレートに従う)
チートシート追加(変更後)
src/cheatsheets/{slug}/にディレクトリ作成(Component.tsx, meta.ts)-- 変更なしsrc/cheatsheets/registry.tsに meta を登録 -- 変更: componentImport は不要- 新規:
src/app/cheatsheets/{slug}/page.tsxを作成(テンプレートに従う) - 新規:
src/app/cheatsheets/{slug}/opengraph-image.tsxを作成(テンプレートに従う)
DX への影響
- 登録箇所が 1箇所(registry.ts のみ)から 3箇所(registry.ts + page.tsx + opengraph-image.tsx)に増える
- ただし page.tsx と opengraph-image.tsx はテンプレートのスラッグ部分を変えるだけの定型作業
- 前述の網羅性テストを導入すれば、ページ作成忘れを CI で検出可能
- ゲームセクションと完全に同じパターンになるため、コードベース全体の一貫性が向上
作業手順
本作業はファイル数が多い(80ファイル新規作成 + 6ファイル削除 + 5ファイル変更)ため、ツールとチートシートで2つのビルダータスクに分割することを推奨する。
タスク1: チートシート(7個)の個別ページ化
このタスクで確実に動作することを確認した後、タスク2に進む。
src/cheatsheets/types.tsから componentImport フィールドを削除src/cheatsheets/registry.tsの全7エントリから componentImport を削除- 7個のチートシート個別ページファイルを作成(page.tsx x 7, opengraph-image.tsx x 7)
src/app/cheatsheets/[slug]/ディレクトリを完全削除(page.tsx, CheatsheetRenderer.tsx, opengraph-image.tsx)- typecheck, test, lint, build で検証
タスク2: ツール(33個)の個別ページ化
src/tools/types.tsから componentImport フィールドを削除src/tools/registry.tsの全33エントリから componentImport を削除- 33個のツール個別ページファイルを作成(page.tsx x 33, opengraph-image.tsx x 33)
src/app/tools/[slug]/ディレクトリを完全削除(page.tsx, ToolRenderer.tsx, opengraph-image.tsx)- typecheck, test, lint, build で検証
タスク3: ドキュメント更新・テスト追加
docs/new-feature-guide.mdのツール/チートシート追加手順を更新- 個別ページ網羅性テストを追加(推奨)
- 最終ビルド検証
リスクと対策
| リスク | 影響 | 対策 |
|---|---|---|
| ツール追加時にページファイルの作成を忘れる | 該当ツールの URL が 404 になる | ドキュメント更新 + 網羅性テスト追加 |
| OGP画像の 40 ファイルがボイラープレート化 | メンテナンスコスト増加 | テンプレート化で差分はスラッグのみ。将来的にスクリプトで自動生成も可能 |
| [slug] ディレクトリ削除時に git 履歴が途切れる | コード追跡が困難に | 影響は軽微。重要なロジックは src/tools/, src/cheatsheets/ 側にある |
| 80 ファイル作成による PR の diff が巨大になる | レビュー困難 | タスク分割で軽減。テンプレートからの差分が slug 名のみであることをレビュアーに伝達 |
ビルドサイズへの影響
- ツール: 各ページに必要なツールコンポーネントのみがバンドルされるようになる。現状は ToolRenderer.tsx(use client)が全33コンポーネントをバンドルしていたため、各ページのバンドルサイズは大幅に削減される。
- チートシート: サーバーコンポーネントとして完全にサーバーサイドでレンダリングされる。クライアントバンドルから CheatsheetRenderer.tsx と全チートシートコンポーネントが除外され、バンドルサイズはゼロに近づく(CodeBlock のコピーボタンを除く)。
- ローディングフラッシュ: 完全に解消。next/dynamic も loading フォールバックも使用しない。