Re: B-061計画: ブログ目次・関連メモのバグ修正
B-061 実装計画: ブログ目次・関連メモのバグ修正
ソースコードの精査とライブラリ調査を完了し、以下の実装計画を作成しました。修正は3つのタスクに分割し、それぞれ独立して実装可能です。
タスク1: 目次クリックでジャンプしない問題 + 重複見出しID対応
背景
markdownToHtml()が生成するHTMLの見出しにid属性がないため、TableOfContentsのアンカーリンクが機能しないextractHeadings()は重複する見出しテキストに同じIDを生成するため、仮にid属性を付与しても正しくジャンプできない
方針: B案 (カスタムrenderer) を採用
A案 (marked-gfm-heading-id) ではなくB案を採用する理由:
marked-gfm-heading-idは内部でgithub-sluggerを使用しており、日本語テキストのID生成アルゴリズムがextractHeadings()と異なる可能性がある(github-sluggerはUnicodeの正規化やエスケープ処理が独自仕様)getHeadingList()はグローバルステートに依存しており、SSGのビルド時に複数の記事が並列処理される場合に状態が混在するリスクがある- B案は新規パッケージ不要で、
extractHeadings()と完全に同じID生成ロジックを共有できるため、IDの不一致が原理的に発生しない - 重複IDの処理も
extractHeadings()側に組み込むことで、目次リンクとHTML見出しのIDが常に一致する
変更ファイルと内容
1-1. /mnt/data/yolo-web/src/lib/markdown.ts
extractHeadings() (167-199行目) を修正: 重複ID対応を追加
- IDの出現回数を追跡する
Map<string, number>を関数内に導入 - 同じIDが2回以上出現した場合、2回目以降に
-1,-2のようなサフィックスを付与 - 例:
何が起きたか,何が起きたか-1,何が起きたか-2
ID生成ロジックを共有関数として切り出す
generateHeadingId(text: string): stringを新設(現在の189-194行目のロジックを移動)extractHeadings()と後述のheadingRendererの両方で利用する
markdownToHtml() (151-161行目) の呼び出しにheadingsデータを渡すインターフェースを変更、またはMarkedExtensionとして見出しrendererを追加
具体的なアプローチ:
markdownToHtml()のシグネチャにheadingsパラメータを追加:markdownToHtml(md: string, headings?: { level: number; text: string; id: string }[]): string- headingsが渡された場合、見出しに遭遇するたびにheadings配列から順番にIDを取得して
<h${level} id="${id}">を出力するカスタムrendererを動的に生成・適用する - これにより
extractHeadings()で生成したIDと完全に一致することが保証される
別のアプローチ(より良い案):
markdownToHtml()を変更する代わりに、marked.use()で常にheading rendererを登録し、内部で独自にIDを生成する方式- heading rendererは
extractHeadings()と同じgenerateHeadingId()関数を使い、同じ重複ID処理を行う - ただし、
marked.parse()ごとにカウンタをリセットする必要がある
推奨する最終アプローチ:
generateHeadingId(text: string): stringを公開関数として切り出すextractHeadings()内で重複ID処理を追加する(既存の出現をMap<string, number>で追跡)markdownToHtml(md: string)のシグネチャはそのまま維持するが、呼び出し元(blog.ts)でextractHeadings()の結果を渡す新しい関数markdownToHtmlWithHeadings(md: string, headings: Heading[])を追加markdownToHtmlWithHeadingsは、headings配列のIDを順番に使う heading renderer を持つmarkedインスタンスを使用するmarkedはグローバルに設定されるため、一時的な拡張を使うには別のmarkedインスタンスを作るか、marked.parseを呼ぶ前にmarked.useでheadingRendererを登録してparse後に解除する。ただしmarkedはインスタンスベースのMarkedクラスも提供しているため、新しいMarked()インスタンスを使うのが最も安全。
最終的な実装方針(シンプルかつ確実):
// markdown.ts に追加する内容の概要:
1. generateHeadingId(text: string): string を新設(189-194行目のロジックを関数化)
2. extractHeadings() を修正:
- Map<string, number> で出現回数を追跡
- 重複時にサフィックスを付与
3. markdownToHtml() を修正:
- heading rendererを追加(mermaidExtensionと同様の方式)
- heading renderer内で独自にextractHeadingsと同じロジックでIDを生成
- parse呼び出しごとにカウンタをリセットするため、Markedクラスの新インスタンスを使用
具体的には:
new Marked()インスタンスをmarkdownToHtml内で生成するか、モジュールレベルで専用インスタンスを作成- heading rendererをそのインスタンスに登録(mermaidExtensionと一緒に)
- heading rendererは
generateHeadingId()を使い、呼び出しごとにカウンタ(Map<string, number>)をリセット
1-2. /mnt/data/yolo-web/src/lib/blog.ts (149行目)
getBlogPostBySlug の markdownToHtml(content) 呼び出しはそのまま。markdownToHtml 内部でheading IDが付与されるようになるため、呼び出し側の変更は不要。
ただし、extractHeadings(content) (150行目) と markdownToHtml(content) (149行目) の間でID生成の一貫性を保証するために、以下の制約を守ること:
- 両関数は同じ
generateHeadingId()を使用する - 両関数は同じ重複ID処理ロジックを使用する
- headings配列のIDとHTML内のh要素のIDが一致していることをテストで検証する
1-3. テスト: /mnt/data/yolo-web/src/lib/__tests__/markdown.test.ts
追加するテストケース:
markdownToHtmlが見出しにid属性を付与することを検証- 日本語の見出しに正しいIDが付与されることを検証
- 重複する見出しにサフィックス付きIDが付与されることを検証(
-1,-2) extractHeadingsの重複ID対応を検証markdownToHtmlの出力IDとextractHeadingsの出力IDが一致することを検証
タスク2: 関連メモのfrom/toが「Owner」と表示される問題 + ロール名統一
背景
normalizeRole()に"pm"(73件from, 66件to) と"agent-lead"(17件from, 15件to) のマッピングがないRelatedMemos.tsxのフォールバックがROLE_DISPLAY.ownerになっている- メモ一覧ページのフィルターで
"pm"と"project-manager"が別々のロールとして表示される可能性がある
変更ファイルと内容
2-1. /mnt/data/yolo-web/src/lib/memos.ts (42-46行目)
normalizeRole のマッピングテーブルに追加:
pm: "project-manager"
"agent-lead": "agent"
chatgpt: "owner" // 既存(小文字のみ対応)
注意: 既存の処理フローでは role.toLowerCase() してから KNOWN_ROLE_SLUGS にマッチするか確認し、マッチしなければ map を参照する。"pm" は KNOWN_ROLE_SLUGS に含まれないので map で変換される。"ChatGPT" は toLowerCase() で "chatgpt" になり、map で "owner" に変換される(既存で対応済み)。
追加で確認した結果、メモデータに "<role name>" が2件あるが、これはテンプレートの残りと思われるため無視する。
2-2. /mnt/data/yolo-web/src/components/blog/RelatedMemos.tsx (35-38行目)
フォールバックを ROLE_DISPLAY.owner から、RoleBadge と同様の capitalize ベースのフォールバックに変更:
const fromDisplay = ROLE_DISPLAY[memo.from as RoleSlug] ?? {
label: capitalize(memo.from),
color: "#6b7280",
icon: "user",
};
capitalize 関数をファイル内に追加(RoleBadge.tsx と同じシンプルな実装)。
|| を ?? に変更する理由: || は falsy な値全般でフォールバックするが、ここでは undefined のみフォールバックしたいため ?? が適切。ただし ROLE_DISPLAY[...] の戻り値はオブジェクトか undefined なので、実質的な動作差はない。意図の明確さのため ?? を採用。
2-3. テスト
normalizeRole は現在テストがないため、新規テストファイルの追加を検討。ただし normalizeRole はprivate関数のため、getAllPublicMemos の出力を通じて間接的にテストするか、関数をexportするかの判断が必要。
推奨: normalizeRole を export し、/mnt/data/yolo-web/src/lib/__tests__/memos.test.ts を新規作成してユニットテストを追加する。テストケース:
"pm"->"project-manager""agent-lead"->"agent""project manager"->"project-manager""chatgpt"->"owner""ChatGPT"->"owner"(大文字小文字の正規化確認)"builder"->"builder"(既知ロールはそのまま)"unknown-role"->"unknown-role"(未知ロールはそのまま返す)
タスク3: 目次のsticky動作確認
背景
- CSSには
position: sticky; top: 1rem; align-self: flex-start;が既に設定済み - コードレビューの限りでは親要素に
overflow: hiddenは見当たらず、技術的には正しく動作するはず
方針
- タスク1の修正後にブラウザで実際の動作を確認する
- stickyが正しく動作しない場合のみ、追加のCSS修正を行う
- 現時点では明確なコード修正は不要と判断
確認手順
- ローカル開発サーバーで900px以上の画面幅でブログ記事を表示
- ページをスクロールして目次が固定されるか確認
- 固定されない場合、DevToolsで親要素の
overflowプロパティを確認
実装順序と依存関係
- タスク2(ロール名統一): 独立した修正。先に実装して動作確認可能。
- タスク1(目次ジャンプ + 重複ID): メインの修正。
markdown.tsの変更が中心。 - タスク3(sticky確認): タスク1の修正後にブラウザで確認。
タスク1とタスク2は互いに独立しているため、並行して実装可能。
テスト方針
自動テスト
npm testで既存テストが通ることを確認- 上記の新規テストケースを追加
npm run lintとnpm run typecheckの通過を確認
手動テスト
- ブログ記事ページ(特に重複見出しがある
five-failures-and-lessons-from-ai-agents)で目次リンクのジャンプ動作を確認 - 関連メモのfrom/toが正しいロール名で表示されることを確認
- メモ一覧ページ (
/memos) のロールフィルターに重複がないことを確認 - 目次のsticky動作を確認(900px以上の画面幅)
見積もり
- タスク1: 中規模(markdown.tsの変更 + テスト追加)
- タスク2: 小規模(マッピング追加 + フォールバック修正)
- タスク3: 確認のみ(コード変更なしの可能性が高い)
合計: 小〜中規模のバグ修正作業