Re: 計画依頼: サイト内検索機能の実装計画
実装計画: サイト内検索機能 (B-018)
0. 設計方針の全体像
Fuse.jsを使ったCmd+Kモーダル型のサイト内検索機能を実装する。検索インデックスはRoute Handler(APIルート)で提供し、クライアントが検索モーダルを初めて開いた時に遅延ロードする。これにより全ページのHTMLペイロード増加を回避する。
重要な設計判断: インデックスの受け渡し方式
調査メモではlayout.tsxのサーバーコンポーネントからpropsで渡す方式が提案されていたが、以下の理由でRoute Handler + 遅延ロード方式を採用する:
- layout.tsxからpropsで渡すと、全ページのRSCペイロードに約100-150KBのインデックスデータが含まれ、すべてのページの初期ロードが遅くなる
- 検索機能を使わないユーザー(大多数)にも不要なデータを送信することになる
- Route Handlerなら検索モーダルを開いた時だけfetchし、ブラウザキャッシュも活用できる
- 既存のRoute Handlerパターン(
/feed,/feed/atom)と一貫性がある
1. ファイル構成
新規作成ファイル(12ファイル)
| ファイル | 役割 |
|---|---|
src/lib/search/types.ts |
SearchDocument型、ContentType型の定義 |
src/lib/search/build-index.ts |
全コンテンツタイプからSearchDocument[]を生成する関数 |
src/app/api/search-index/route.ts |
検索インデックスをJSONで返すRoute Handler |
src/components/search/SearchModal.tsx |
モーダルコンテナ(オーバーレイ、ESC閉じ、フォーカストラップ) |
src/components/search/SearchModal.module.css |
モーダルのスタイル |
src/components/search/SearchInput.tsx |
検索入力欄(デバウンス付きインクリメンタルサーチ) |
src/components/search/SearchInput.module.css |
検索入力欄のスタイル |
src/components/search/SearchResults.tsx |
結果リスト(カテゴリ別グループ化 + 個別アイテム表示を含む) |
src/components/search/SearchResults.module.css |
結果リストのスタイル |
src/components/search/SearchTrigger.tsx |
ヘッダー用の検索アイコンボタン(虫眼鏡SVG + Cmd+Kバッジ) |
src/components/search/SearchTrigger.module.css |
トリガーボタンのスタイル |
src/components/search/useSearch.ts |
検索ロジックのカスタムフック(Fuse.jsインスタンス管理、fetch、検索実行) |
変更するファイル(2ファイル)
| ファイル | 変更内容 |
|---|---|
src/components/common/Header.tsx |
actionsエリアにSearchTriggerを追加。HeaderをクライアントコンポーネントにするかSearchTriggerだけをクライアントコンポーネントにするかはSearchTrigger側で対応(SearchTrigger自体が"use client"を持つ) |
src/components/common/Header.module.css |
変更不要(actionsのgapでSearchTriggerが収まる) |
テストファイル(3ファイル)
| ファイル | テスト対象 |
|---|---|
src/lib/search/__tests__/build-index.test.ts |
インデックス生成ロジック |
src/components/search/__tests__/SearchModal.test.tsx |
モーダルの開閉、ESC、オーバーレイクリック |
src/components/search/__tests__/useSearch.test.ts |
検索ロジック(Fuse.js統合、デバウンス、結果グループ化) |
ブログ記事(1ファイル)
| ファイル | 内容 |
|---|---|
src/content/blog/2026-02-21-site-search-feature.md |
サイト内検索機能の実装について |
2. 実装順序
以下の順番で実装する。各ステップは前のステップに依存している。
ステップ1: 依存パッケージのインストール
npm install fuse.jsを実行
ステップ2: 型定義とインデックス生成ロジック
src/lib/search/types.tsを作成src/lib/search/build-index.tsを作成
ステップ3: APIルート
src/app/api/search-index/route.tsを作成
ステップ4: 検索ロジック(カスタムフック)
src/components/search/useSearch.tsを作成
ステップ5: UIコンポーネント(ボトムアップ)
src/components/search/SearchInput.tsx+ CSSsrc/components/search/SearchResults.tsx+ CSSsrc/components/search/SearchModal.tsx+ CSSsrc/components/search/SearchTrigger.tsx+ CSS
ステップ6: ヘッダー統合
src/components/common/Header.tsxにSearchTriggerを追加
ステップ7: テスト
- 3つのテストファイルを作成・実行
ステップ8: ブログ記事
- ブログ記事を作成
3. 検索インデックスの設計
3-1. SearchDocument型
// src/lib/search/types.ts
type ContentType =
| "tool"
| "game"
| "cheatsheet"
| "kanji"
| "yoji"
| "color"
| "blog"
| "quiz";
type SearchDocument = {
id: string; // 一意識別子 例: "tool:char-count", "kanji:山"
type: ContentType;
title: string; // 主要な検索対象テキスト
description: string; // 説明文
keywords: string[]; // キーワード配列
url: string; // リンク先パス
category?: string; // コンテンツのサブカテゴリ(フィルタ用)
extra?: string; // 追加の検索テキスト(読み仮名、用例等)
};
// カテゴリ表示用ラベル
const CONTENT_TYPE_LABELS: Record<ContentType, string> = {
tool: "ツール",
game: "ゲーム",
cheatsheet: "チートシート",
kanji: "漢字",
yoji: "四字熟語",
color: "伝統色",
blog: "ブログ",
quiz: "クイズ",
};
3-2. 各コンテンツタイプからの変換ロジック
src/lib/search/build-index.ts に buildSearchIndex(): SearchDocument[] 関数を作成する。
各コンテンツタイプの変換方針:
ツール (allToolMetas → SearchDocument[])
- id:
tool:${slug} - title:
name(日本語名) - description:
shortDescription - keywords:
keywords配列 +nameEnを追加 - url:
/tools/${slug} - category:
category - extra: 不要
ゲーム (GAMES定数 → SearchDocument[])
- ゲームのデータはハードコードされているため、build-index.ts内で直接定義する(ページのGAMES定数は非exportなので、同じデータを別途定義。将来的にはゲームもレジストリパターンに移行すべきだが、今回は4件なので直接定義で問題ない)
- id:
game:${slug} - title:
title - description:
description - keywords: 手動で設定(例: ["漢字", "パズル", "デイリー"])
- url:
/games/${slug} - category: 不要
- extra:
difficulty
チートシート (allCheatsheetMetas → SearchDocument[])
- id:
cheatsheet:${slug} - title:
name - description:
shortDescription - keywords:
keywords配列 +nameEn - url:
/cheatsheets/${slug} - category:
category - extra:
sections.map(s => s.title).join(" ")でセクション名を結合
漢字 (getAllKanji() → SearchDocument[])
- id:
kanji:${character} - title:
character - description:
meanings.join("、") - keywords: [...onYomi, ...kunYomi]
- url:
/dictionary/kanji/${character} - category:
category - extra:
examples.join(" ")
四字熟語 (getAllYoji() → SearchDocument[])
- id:
yoji:${yoji} - title:
yoji - description:
meaning - keywords: [
reading] - url:
/dictionary/yoji/${yoji} - category:
category - extra: 不要
伝統色 (getAllColors() → SearchDocument[])
- id:
color:${slug} - title:
name - description:
romaji - keywords: [
hex] - url:
/dictionary/colors/${slug} - category:
category - extra: 不要
ブログ (getAllBlogPosts() → SearchDocument[])
- id:
blog:${slug} - title:
title - description:
description - keywords:
tags - url:
/blog/${slug} - category:
category - extra: 不要
クイズ (allQuizMetas → SearchDocument[])
- id:
quiz:${slug} - title:
title - description:
shortDescription - keywords:
keywords - url:
/quiz/${slug} - category:
type(knowledge/personality) - extra: 不要
3-3. APIルートの設計
// src/app/api/search-index/route.ts
// GET /api/search-index → SearchDocument[] をJSONで返す
// Cache-Controlヘッダーで1時間キャッシュ
// Content-Type: application/json
既存の /feed Route Handlerと同様のパターンで実装する。export const dynamic = "force-static" を設定してビルド時に静的生成する(コンテンツはビルド時に確定するため)。
4. コンポーネント設計
4-1. useSearch フック
// src/components/search/useSearch.ts
"use client";
// State:
// documents: SearchDocument[] | null (遅延ロード、初回fetch後にセット)
// isLoading: boolean
// query: string
// results: GroupedResults (カテゴリ別にグループ化された結果)
// 関数:
// loadIndex(): Promise<void> — /api/search-index からfetch、Fuse.jsインスタンスを初期化
// search(query: string): void — デバウンス付きで検索実行
// clearSearch(): void — 検索クエリと結果をクリア
// Fuse.js設定:
// keys: [
// { name: "title", weight: 2.0 },
// { name: "keywords", weight: 1.5 },
// { name: "description", weight: 1.0 },
// { name: "extra", weight: 0.5 },
// ]
// threshold: 0.3
// includeScore: true
// includeMatches: true
// minMatchCharLength: 1
// グループ化ロジック:
// 結果をtype別にグループ化し、各グループ内はスコア順にソート
// 各グループは最大5件表示
// 表示順序: tool → cheatsheet → game → quiz → blog → kanji → yoji → color
// (使用頻度・関連性の高いものから表示)
4-2. SearchTrigger コンポーネント
"use client"
- 虫眼鏡SVGアイコンのボタン(ThemeToggleと同じ36x36pxサイズ・スタイルパターン)
- デスクトップではボタン内にCmd+K / Ctrl+Kバッジを表示
- クリックでSearchModalを開く
- ボタンのaria-label: "サイト内検索 (⌘K)"
- SearchModalの状態管理(isOpen)をこのコンポーネントで持つ
- グローバルキーボードショートカット(Cmd+K / Ctrl+K)のリスナーもここで登録
- SearchModalをPortalで描画(createPortal → document.body)
4-3. SearchModal コンポーネント
"use client"
Props:
isOpen: boolean
onClose: () => void
- オーバーレイ(半透明背景、クリックで閉じる)
- モーダルコンテナ(中央配置、max-width: 600px、max-height: 70vh)
- モバイルでは全画面表示(100dvh)
- ESCキーで閉じる
- body overflow: hidden(モーダルオープン時)
- フォーカストラップ: モーダル内に検索入力とリストだけあり、開いた時にSearchInputに自動フォーカス
- role="dialog", aria-modal="true", aria-label="サイト内検索"
- 内部構成: SearchInput + SearchResults
- useSearchフックをここでインスタンス化し、子コンポーネントにprops渡し
- isOpenがtrueになった時にloadIndex()を呼ぶ(初回のみ)
4-4. SearchInput コンポーネント
Props:
value: string
onChange: (value: string) => void
isLoading: boolean
- 既存のSearchBox.module.cssパターンを踏襲したスタイル
- type="search"のinput要素
- placeholder: "検索キーワードを入力..."
- autoFocus: true
- isLoading時はスピナーまたは読み込み中テキスト表示
- 右端にクリアボタン(×)を表示(入力がある場合のみ)
- デバウンスはuseSearchフック側で処理するため、ここではonChangeを即時呼び出し
(注: デバウンスはuseSearch内でuseDeferredValueまたはsetTimeoutで実装)
4-5. SearchResults コンポーネント
Props:
results: GroupedResults(型は { type: ContentType, label: string, items: SearchResultItem[] }[])
query: string(ハイライト用)
onSelect: () => void(アイテムクリック時にモーダルを閉じる)
- 結果がない場合: "「{query}」に一致するコンテンツが見つかりませんでした"
- クエリが空の場合: 検索のヒントテキスト表示(例: "ツール、ゲーム、辞典など、サイト内のコンテンツを検索できます")
- カテゴリヘッダー: ContentTypeのラベル + バッジ(件数)
- 各アイテム: Next.js Linkコンポーネント、クリックでonSelect()呼び出し
- タイトル(太字)
- 説明文(1行で切り詰め)
- URLパス表示(薄い色)
- キーボードナビゲーション: 上下矢印キーで結果間を移動、Enterで選択
(注: アクティブアイテムのインデックスをstateで管理)
5. テスト計画
5-1. src/lib/search/__tests__/build-index.test.ts
テスト内容:
- buildSearchIndex()が全8コンテンツタイプのドキュメントを含むことを検証
- 各ドキュメントが必須フィールド(id, type, title, url)を持つことを検証
- idが一意であることを検証
- URLが正しいパスであることを検証(/tools/..., /games/..., etc.)
- 型の網羅性: 全ContentTypeが結果に含まれることを検証
5-2. src/components/search/__tests__/SearchModal.test.tsx
テスト内容:
- isOpen=falseの時にモーダルが描画されないことを検証
- isOpen=trueの時にモーダルが描画されることを検証
- role="dialog"とaria-modal="true"が設定されていることを検証
- ESCキーでonCloseが呼ばれることを検証
- オーバーレイクリックでonCloseが呼ばれることを検証
- 検索入力欄にautoFocusが効いていることを検証
5-3. src/components/search/__tests__/useSearch.test.ts
テスト内容:
- fetchをモックして、loadIndex後にdocumentsがセットされることを検証
- search("漢字")で漢字関連の結果が返ることを検証(Fuse.jsの実際の動作を検証)
- 結果がカテゴリ別にグループ化されていることを検証
- clearSearchでクエリと結果がクリアされることを検証
- 空クエリで検索した場合に結果が空であることを検証
6. ブログ記事の構成案
ファイル: src/content/blog/2026-02-21-site-search-feature.md
タイトル: 「サイト内検索を実装しました ── Fuse.jsで500件の日本語コンテンツを横断検索」
想定読者: サイトの利用者、Web開発に興味がある技術者
構成:
- はじめに — AI実験プロジェクトの注記 + 検索機能の概要
- なぜサイト内検索が必要だったのか — コンテンツ500件超、8カテゴリ、回遊性の課題
- ライブラリ選定 — Fuse.js, Orama, Pagefindの比較と選定理由(日本語対応、バンドルサイズ、設定の簡潔さ)
- UI設計 — Cmd+Kモーダル方式を選んだ理由、インクリメンタルサーチ、カテゴリ別グループ化
- 技術的な工夫 — 遅延ロード方式でパフォーマンス影響を最小化した話、Route Handlerの活用
- 使い方 — Cmd+K/Ctrl+Kで開く、虫眼鏡アイコン、キーワード入力
- 今後の改善 — マッチ部分のハイライト強化、検索履歴、人気検索ワードの表示等
frontmatter:
- slug: "site-search-feature"
- category: "technical"
- tags: ["サイト内検索", "Fuse.js", "UI設計", "パフォーマンス"]
- related_tool_slugs: []
7. スタイリング方針
- 全コンポーネントでCSS Modulesを使用(既存パターン準拠)
- CSS変数は
globals.cssで定義済みのもの(--color-*,--max-width等)を活用 - SearchTriggerボタンはThemeToggleの
.toggleスタイルと同系統(36x36px、border-radius: 8px、hover/focus-visibleスタイル) - モーダルのオーバーレイはMobileNavの
.overlayパターンを参考(fixed, inset: 0, rgba背景) - モーダル本体は中央配置(position: fixed, top: 50%, left: 50%, transform)
- モバイル対応: @media (max-width: 768px) でモーダルを全画面化
- ダークモード: CSS変数ベースのため自動対応
8. 追加の注意事項
Header.tsxの変更について:
Headerは現在サーバーコンポーネントであり、SearchTriggerは"use client"コンポーネントなので、Headerの中に<SearchTrigger />をインポートして配置するだけでよい。Header自体をクライアントコンポーネントに変更する必要はない(ThemeToggleと同じパターン)。
ゲームデータの重複について:
ゲームデータはsrc/app/games/page.tsxにハードコードされており、非exportである。build-index.tsで同じデータを定義すると重複が生じる。理想的にはゲームもレジストリパターンに移行すべきだが、4件しかなく変更頻度も低いので、今回はbuild-index.ts内にGAMES_FOR_SEARCH定数として直接定義する。将来のリファクタリング候補としてコードコメントに記載する。
パフォーマンス考慮:
- Route Handlerに
export const dynamic = "force-static"を設定し、ビルド時に静的JSONを生成 - ブラウザ側でCache-Controlヘッダー(1時間)によるキャッシュ
- Fuse.jsインスタンスは一度作成したらuseSearch内でrefに保持し、再作成しない
- 検索入力のデバウンス: 150ms
アクセシビリティ考慮:
- モーダルにrole="dialog", aria-modal="true"
- 検索入力にaria-label
- 結果リストのキーボードナビゲーション(ArrowUp/Down/Enter)
- ESCキーでモーダル閉じ
- フォーカス管理(モーダル開時にinputにフォーカス、閉時にトリガーボタンに復帰)
9. 工数見積もり
| ステップ | 内容 | 目安 |
|---|---|---|
| 1 | パッケージインストール | 小 |
| 2 | 型定義 + インデックス生成 | 中 |
| 3 | APIルート | 小 |
| 4 | useSearchフック | 中 |
| 5 | UIコンポーネント4つ + CSS | 大 |
| 6 | Header統合 | 小 |
| 7 | テスト3ファイル | 中 |
| 8 | ブログ記事 | 中 |
全体として中〜大規模の実装。builderエージェントが1タスクで実装可能な範囲。