Re: B-077計画: extractHeadingsインラインリンクstrip処理の実施計画作成
B-077 実施計画: extractHeadingsのインラインリンク記法strip処理
1. 目的と価値
誰のために: ブログ記事を読む訪問者。
何を求めているか: 目次(Table of Contents)のリンクをクリックしたとき、対応する見出しに正しくスクロールすること。
提供する価値: 現在、ブログ記事の見出しにMarkdownインラインリンク記法(例: ### 1. [文字数カウント](/tools/char-count))が含まれている場合、目次コンポーネント(TableOfContents)が生成するアンカーリンクのIDと、HTML側の見出しに付与されるIDが不一致になっている。これにより目次リンクが壊れている。/blog/web-developer-tools-guide には20個以上のリンク入り見出しがあり、すべての目次リンクが機能していない。本修正によりこれを解消し、目次の全リンクが正しく動作するようにする。
2. 問題の根本原因
extractHeadings()(目次データ生成)と markdownToHtml() 内のheading renderer(HTML見出しのID生成)で、見出しテキストからプレーンテキストを抽出するロジックが異なる。
- markdownToHtml側: markedのパーサーが
[text](url)を<a href="url">text</a>に変換した後、inner.replace(/<[^>]*>/g, "")でHTMLタグをstripしてプレーンテキスト(例:text)を得る。正しく動作している。 - extractHeadings側: 正規表現で
**,*, バッククォートのみをstripしている。[text](url)はstrip対象外のため、text(url)がそのまま残り、IDにurl部分が含まれてしまう。
結果として、同じ見出し ### 1. [文字数カウント](/tools/char-count) に対し:
- markdownToHtml:
id="1-文字数カウント"(正しい) - extractHeadings:
id="1-文字数カウントtoolschar-count"(誤り)
3. 具体的な変更内容
3-1. 変更対象ファイル: /mnt/data/yolo-web/src/lib/markdown.ts
変更箇所: extractHeadings 関数内、L248-L252のテキストstrip処理部分。
現在のコード (L246-L252):
if (match) {
const level = match[1].length;
const text = match[2]
.replace(/\*\*/g, "")
.replace(/\*/g, "")
.replace(/`/g, "")
.trim();
変更後のコード:
if (match) {
const level = match[1].length;
const text = match[2]
// Strip image syntax:  -> alt (must precede link strip)
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
// Strip link syntax: [text](url) -> text
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
// Strip existing inline formatting
.replace(/\*\*/g, "")
.replace(/\*/g, "")
.replace(/`/g, "")
// Strip HTML tags: <tag> -> empty
.replace(/<[^>]*>/g, "")
.trim();
適用順序の重要性:
- 画像記法
を最初にstripする。リンク記法の正規表現がの[alt](url)部分にもマッチしてしまうため、画像記法を先に処理しなければならない。 - リンク記法
[text](url)を次にstripする。 - 既存のbold/italic/codeのstripはその後。ネストされたケース(例:
[**太字リンク**](url))は、リンクstripで**太字リンク**が残り、続く**stripで正しく処理される。 - HTMLタグstripを最後に追加する。
<br>等のHTMLタグが見出しに含まれる場合にも対応するため。
3-2. 変更対象ファイル: /mnt/data/yolo-web/src/lib/__tests__/markdown.test.ts
変更箇所: describe("extractHeadings", ...) ブロック内(L239-L301付近)に以下のテストケースを追加。また describe("markdownToHtml and extractHeadings ID consistency", ...) ブロック内(L303-L348付近)にID一貫性テストを追加。
追加するテストケース (extractHeadingsブロック内):
test("strips inline link syntax from heading text", () => {
const md = "### [文字数カウント](/tools/char-count)";
const headings = extractHeadings(md);
expect(headings[0].text).toBe("文字数カウント");
expect(headings[0].id).toBe("文字数カウント");
});
test("strips numbered inline link syntax from heading text", () => {
const md = "### 1. [文字数カウント](/tools/char-count)";
const headings = extractHeadings(md);
expect(headings[0].text).toBe("1. 文字数カウント");
});
test("strips image syntax from heading text", () => {
const md = "##  セクション";
const headings = extractHeadings(md);
expect(headings[0].text).toBe("アイコン セクション");
});
test("strips HTML tags from heading text", () => {
const md = "## テスト<br>改行";
const headings = extractHeadings(md);
expect(headings[0].text).toBe("テスト改行");
});
test("strips nested formatting in links", () => {
const md = "## [**太字リンク**](url)";
const headings = extractHeadings(md);
expect(headings[0].text).toBe("太字リンク");
});
追加するテストケース (ID consistencyブロック内):
test("link heading IDs match between markdownToHtml and extractHeadings", () => {
const md = "### 1. [文字数カウント](/tools/char-count)\n\nテキスト\n\n### 2. [バイト数計算](/tools/byte-counter)";
const headings = extractHeadings(md);
const html = markdownToHtml(md);
for (const heading of headings) {
expect(html).toContain(`id="${heading.id}"`);
}
});
test("image heading IDs match between markdownToHtml and extractHeadings", () => {
const md = "##  セクション";
const headings = extractHeadings(md);
const html = markdownToHtml(md);
for (const heading of headings) {
expect(html).toContain(`id="${heading.id}"`);
}
});
test("HTML tag heading IDs match between markdownToHtml and extractHeadings", () => {
const md = "## テスト<br>改行";
const headings = extractHeadings(md);
const html = markdownToHtml(md);
for (const heading of headings) {
expect(html).toContain(`id="${heading.id}"`);
}
});
4. 変更しないもの
- markdownToHtml側 (heading renderer): 既にmarkedのパーサー経由で正しくHTMLタグをstripしている。変更不要。
- generateHeadingId: IDスラグ化ロジック自体は正しく動作している。変更不要。
- TableOfContents コンポーネント: 渡されたheadingsデータを表示するだけ。変更不要。
- blog.ts: extractHeadingsの呼び出し側であり、変更不要。
- 参照リンク
[text][ref]対応: 現在のブログ記事では使用されていないことが調査で確認済み。対応不要。
5. 注意点・リスク
- 正規表現の適用順序: 画像記法 -> リンク記法 -> bold/italic/code -> HTMLタグ の順序を厳守すること。順序を間違えると画像記法
が誤って処理される。 - 既存テストへの影響: 既存のテストは
**,*, バッククォートのみをテストしており、今回追加するstrip処理で壊れるものはない。ただし必ず全テスト実行で確認すること。 - 目次表示テキストへの影響:
extractHeadingsのtextフィールドは、目次コンポーネントの表示テキストとしても使われる(TableOfContents.tsxL27:{heading.text})。strip後のテキストが読者にとって自然な表示になることを確認すること。例えば### 1. [文字数カウント](/tools/char-count)は1. 文字数カウントと表示される。これは適切。
6. 完了条件
以下のすべてを満たしたとき、B-077は完了とする:
extractHeadings関数が、画像記法、リンク記法[text](url)、HTMLタグ<tag>を正しくstripしてプレーンテキストを抽出する。extractHeadingsが生成するIDとmarkdownToHtmlが生成するIDが、上記の全パターンで一致する。- 追加した全テストケース(個別stripテスト5件 + ID一貫性テスト3件)が通る。
- 既存の全テストが通る(リグレッションなし)。
npm run lint && npm run format:check && npm run test && npm run buildがすべて成功する。- 実際のブログ記事
/blog/web-developer-tools-guideで目次リンクが正しく動作することを目視またはテストで確認する(IDの一貫性テストにより論理的には保証されるが、実記事での確認も推奨)。
7. 作業手順のまとめ
| 順序 | 作業内容 | 対象ファイル |
|---|---|---|
| 1 | extractHeadingsのstrip処理に4つの正規表現を追加 | src/lib/markdown.ts (L248-L252) |
| 2 | extractHeadingsの個別stripテスト5件を追加 | src/lib/__tests__/markdown.test.ts |
| 3 | ID一貫性テスト3件を追加 | src/lib/__tests__/markdown.test.ts |
| 4 | lint, format, test, buildの全実行で確認 | - |
| 5 | レビュー依頼 | - |