Skip to content

Commit f99ad75

Browse files
simonhampclaude
andauthored
Fix empty 'On this page' menu caused by duplicate/empty heading IDs (#352)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9ae13eb commit f99ad75

4 files changed

Lines changed: 80 additions & 12 deletions

File tree

app/Support/CommonMark/CommonMark.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ class CommonMark
2020
{
2121
protected static ?MarkdownConverter $converter = null;
2222

23+
protected static ?HeadingRenderer $headingRenderer = null;
24+
2325
public static function convertToHtml(string $markdown, array $data = []): string
2426
{
2527
// Pre-process to render any Blade components in the markdown
2628
$markdown = BladeMarkdownPreprocessor::process($markdown, $data);
2729

28-
return static::getConverter()->convert($markdown)->getContent();
30+
// Reset heading ID tracking to ensure unique IDs per conversion
31+
static::getConverter();
32+
static::$headingRenderer->resetIds();
33+
34+
return static::$converter->convert($markdown)->getContent();
2935
}
3036

3137
protected static function getConverter(): MarkdownConverter
@@ -45,7 +51,8 @@ protected static function getConverter(): MarkdownConverter
4551

4652
$environment->addExtension(new CommonMarkCoreExtension);
4753
$environment->addExtension(new GithubFlavoredMarkdownExtension);
48-
$environment->addRenderer(Heading::class, new HeadingRenderer);
54+
static::$headingRenderer = new HeadingRenderer;
55+
$environment->addRenderer(Heading::class, static::$headingRenderer);
4956
$environment->addExtension(new TableExtension);
5057

5158
$environment->addExtension(new EmbedExtension);

app/Support/CommonMark/HeadingRenderer.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010

1111
class HeadingRenderer implements NodeRendererInterface
1212
{
13+
/** @var array<string, int> */
14+
protected array $usedIds = [];
15+
16+
public function resetIds(): void
17+
{
18+
$this->usedIds = [];
19+
}
20+
1321
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
1422
{
1523
$tag = 'h'.$node->getLevel();
@@ -20,6 +28,17 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer)
2028

2129
$id = Str::slug($element->getContents());
2230

31+
if ($id === '') {
32+
$id = 'heading';
33+
}
34+
35+
if (isset($this->usedIds[$id])) {
36+
$this->usedIds[$id]++;
37+
$id = $id.'-'.$this->usedIds[$id];
38+
} else {
39+
$this->usedIds[$id] = 0;
40+
}
41+
2342
$element->setAttribute('id', $id);
2443

2544
if ($node->getLevel() === 1 || $node->getLevel() === 2 || $node->getLevel() === 3) {

resources/views/components/plugin-toc.blade.php

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,28 @@
55
const article = document.querySelector('article')
66
if (! article) return
77
8+
const seen = new Set()
89
const elements = article.querySelectorAll('h2[id], h3[id]')
9-
this.headings = Array.from(elements).map(el => {
10-
const clone = el.cloneNode(true)
11-
clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
12-
return {
13-
id: el.id,
14-
text: clone.textContent.trim(),
15-
level: parseInt(el.tagName.substring(1)),
16-
}
17-
})
10+
this.headings = Array.from(elements)
11+
.filter(el => el.id !== '')
12+
.map(el => {
13+
const clone = el.cloneNode(true)
14+
clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
15+
16+
let id = el.id
17+
let suffix = 1
18+
while (seen.has(id)) {
19+
id = el.id + '-toc-' + suffix++
20+
}
21+
seen.add(id)
22+
23+
return {
24+
id: el.id,
25+
tocKey: id,
26+
text: clone.textContent.trim(),
27+
level: parseInt(el.tagName.substring(1)),
28+
}
29+
})
1830
},
1931
}"
2032
x-show="headings.length > 0"
@@ -28,7 +40,7 @@
2840

2941
<flux:popover class="w-64">
3042
<nav class="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
31-
<template x-for="heading in headings" :key="heading.id">
43+
<template x-for="heading in headings" :key="heading.tocKey">
3244
<a
3345
:href="'#' + heading.id"
3446
x-on:click.prevent="document.getElementById(heading.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })"

tests/Feature/HeadingRendererTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,34 @@ public function test_h4_does_not_get_anchor(): void
5757

5858
$this->assertStringNotContainsString('heading-anchor', $html);
5959
}
60+
61+
public function test_duplicate_headings_get_unique_ids(): void
62+
{
63+
$html = CommonMark::convertToHtml("## Installation\n\nSome text.\n\n## Installation");
64+
65+
preg_match_all('/id="([^"]+)"/', $html, $matches);
66+
$ids = $matches[1];
67+
68+
$this->assertCount(2, $ids);
69+
$this->assertCount(2, array_unique($ids), 'Heading IDs should be unique');
70+
$this->assertSame('installation', $ids[0]);
71+
$this->assertSame('installation-1', $ids[1]);
72+
}
73+
74+
public function test_empty_slug_gets_fallback_id(): void
75+
{
76+
// A heading with only special characters that Str::slug strips
77+
$html = CommonMark::convertToHtml('## !!!');
78+
79+
$this->assertStringContainsString('id="heading"', $html);
80+
}
81+
82+
public function test_ids_reset_between_conversions(): void
83+
{
84+
CommonMark::convertToHtml('## Installation');
85+
$html = CommonMark::convertToHtml('## Installation');
86+
87+
$this->assertStringContainsString('id="installation"', $html);
88+
$this->assertStringNotContainsString('id="installation-1"', $html);
89+
}
6090
}

0 commit comments

Comments
 (0)