Skip to content

Commit 1a4fe7e

Browse files
authored
Fix duplicate IDs for multiple footnote references (#6)
When the same footnote is referenced multiple times, generate unique HTML-compliant IDs with suffixes (fnref1, fnref1-2, fnref1-3, etc.) and multiple backlinks with numbered superscripts. Fixes jgm/djot#348
1 parent aa2c64d commit 1a4fe7e

File tree

3 files changed

+108
-6
lines changed

3 files changed

+108
-6
lines changed

src/Renderer/HtmlRenderer.php

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -942,17 +942,24 @@ protected function renderFootnotesSection(): string
942942
foreach ($renderedContents as $number => $content) {
943943
$html .= '<li id="fn' . $number . '">' . "\n";
944944

945+
// Find the label for this footnote number to get ref count
946+
$label = array_search($number, $this->footnoteNumbers, true);
947+
$refCount = $label !== false ? ($this->footnoteRefCounts[$label] ?? 1) : 1;
948+
949+
// Generate backlinks - multiple if footnote referenced multiple times
950+
$backlinks = $this->generateBacklinks($number, $refCount);
951+
945952
// Add backlink - if content ends with </p>, insert before it
946953
// Otherwise add as separate paragraph
947954
if ($content !== '' && preg_match('/^(.*)(<\/p>\n?)$/s', $content, $matches)) {
948-
$content = $matches[1] . '<a href="#fnref' . $number . '" role="doc-backlink">↩︎</a></p>';
955+
$content = $matches[1] . $backlinks . '</p>';
949956
$html .= $content . "\n";
950957
} else {
951958
// Content doesn't end with paragraph (e.g., code block or empty)
952959
if ($content !== '') {
953960
$html .= $content . "\n";
954961
}
955-
$html .= '<p><a href="#fnref' . $number . '" role="doc-backlink">↩︎</a></p>' . "\n";
962+
$html .= '<p>' . $backlinks . '</p>' . "\n";
956963
}
957964

958965
$html .= '</li>' . "\n";
@@ -964,6 +971,32 @@ protected function renderFootnotesSection(): string
964971
return $html;
965972
}
966973

974+
/**
975+
* Generate backlink(s) for a footnote
976+
*
977+
* @param int $number Footnote number
978+
* @param int $refCount Number of times footnote was referenced
979+
*/
980+
protected function generateBacklinks(int $number, int $refCount): string
981+
{
982+
if ($refCount <= 1) {
983+
// Single reference - simple backlink
984+
return '<a href="#fnref' . $number . '" role="doc-backlink">↩︎</a>';
985+
}
986+
987+
// Multiple references - generate numbered backlinks
988+
$links = [];
989+
for ($i = 1; $i <= $refCount; $i++) {
990+
$refId = 'fnref' . $number;
991+
if ($i > 1) {
992+
$refId .= '-' . $i;
993+
}
994+
$links[] = '<a href="#' . $refId . '" role="doc-backlink">↩︎<sup>' . $i . '</sup></a>';
995+
}
996+
997+
return implode(' ', $links);
998+
}
999+
9671000
protected function renderFootnoteRef(FootnoteRef $node): string
9681001
{
9691002
$label = $node->getLabel();
@@ -975,8 +1008,21 @@ protected function renderFootnoteRef(FootnoteRef $node): string
9751008
}
9761009
$number = $this->footnoteNumbers[$label];
9771010

1011+
// Track reference count for this footnote to generate unique IDs
1012+
if (!isset($this->footnoteRefCounts[$label])) {
1013+
$this->footnoteRefCounts[$label] = 0;
1014+
}
1015+
$this->footnoteRefCounts[$label]++;
1016+
$refCount = $this->footnoteRefCounts[$label];
1017+
1018+
// Generate unique ID: fnref1 for first, fnref1-2 for second, etc.
1019+
$refId = 'fnref' . $number;
1020+
if ($refCount > 1) {
1021+
$refId .= '-' . $refCount;
1022+
}
1023+
9781024
// Format: <a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a>
979-
return '<a id="fnref' . $number . '" href="#fn' . $number . '" role="doc-noteref"><sup>' . $number . '</sup></a>';
1025+
return '<a id="' . $refId . '" href="#fn' . $number . '" role="doc-noteref"><sup>' . $number . '</sup></a>';
9801026
}
9811027

9821028
protected function renderMath(Math $node): string

tests/TestCase/DjotConverterTest.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,4 +2380,60 @@ public function testBackticksWithClosingFenceIsCodeBlock(): void
23802380
$this->assertStringContainsString('<pre><code class="language-python">', $result);
23812381
$this->assertStringContainsString('x = y + 3', $result);
23822382
}
2383+
2384+
/**
2385+
* Test that multiple references to the same footnote get unique IDs
2386+
*
2387+
* Fix for GitHub issue jgm/djot#348: Multiple calls to the same note
2388+
* should generate unique HTML-compliant IDs with suffixes.
2389+
*/
2390+
public function testMultipleFootnoteReferencesGetUniqueIds(): void
2391+
{
2392+
$djot = <<<'DJOT'
2393+
First ref[^note].
2394+
2395+
Second ref[^note].
2396+
2397+
Third ref[^note].
2398+
2399+
[^note]: The footnote.
2400+
DJOT;
2401+
2402+
$result = $this->converter->convert($djot);
2403+
2404+
// Each reference should have a unique ID
2405+
$this->assertStringContainsString('id="fnref1"', $result);
2406+
$this->assertStringContainsString('id="fnref1-2"', $result);
2407+
$this->assertStringContainsString('id="fnref1-3"', $result);
2408+
2409+
// All should link to the same footnote
2410+
$this->assertSame(3, substr_count($result, 'href="#fn1"'));
2411+
2412+
// Footnote should have multiple backlinks
2413+
$this->assertStringContainsString('href="#fnref1"', $result);
2414+
$this->assertStringContainsString('href="#fnref1-2"', $result);
2415+
$this->assertStringContainsString('href="#fnref1-3"', $result);
2416+
2417+
// Single footnote (no duplicates)
2418+
$this->assertSame(1, substr_count($result, 'id="fn1"'));
2419+
}
2420+
2421+
public function testSingleFootnoteReferenceNoSuffix(): void
2422+
{
2423+
$djot = <<<'DJOT'
2424+
Single ref[^note].
2425+
2426+
[^note]: The footnote.
2427+
DJOT;
2428+
2429+
$result = $this->converter->convert($djot);
2430+
2431+
// Single reference - no suffix needed
2432+
$this->assertStringContainsString('id="fnref1"', $result);
2433+
$this->assertStringNotContainsString('fnref1-', $result);
2434+
2435+
// Simple backlink without numbering
2436+
$this->assertStringContainsString('href="#fnref1"', $result);
2437+
$this->assertStringNotContainsString('<sup>1</sup></a> <a', $result);
2438+
}
23832439
}

tests/official/footnotes.test

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ test[^a] and another[^foo_bar].
1313
another ref to the first note[^a].
1414
.
1515
<p>test<a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a> and another<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a>.</p>
16-
<p>another ref to the first note<a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a>.</p>
16+
<p>another ref to the first note<a id="fnref1-2" href="#fn1" role="doc-noteref"><sup>1</sup></a>.</p>
1717
<section role="doc-endnotes">
1818
<hr>
1919
<ol>
2020
<li id="fn1">
2121
<p>This is a note.</p>
22-
<p>Second paragraph.<a href="#fnref1" role="doc-backlink">↩︎</a></p>
22+
<p>Second paragraph.<a href="#fnref1" role="doc-backlink">↩︎<sup>1</sup></a> <a href="#fnref1-2" role="doc-backlink">↩︎<sup>2</sup></a></p>
2323
</li>
2424
<li id="fn2">
2525
<pre><code>code
@@ -85,7 +85,7 @@ text[^footnote].
8585
<p>very long footnote<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a><a href="#fnref1" role="doc-backlink">↩︎</a></p>
8686
</li>
8787
<li id="fn2">
88-
<p>bla bla<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a><a href="#fnref2" role="doc-backlink">↩︎</a></p>
88+
<p>bla bla<a id="fnref2-2" href="#fn2" role="doc-noteref"><sup>2</sup></a><a href="#fnref2" role="doc-backlink">↩︎<sup>1</sup></a> <a href="#fnref2-2" role="doc-backlink">↩︎<sup>2</sup></a></p>
8989
</li>
9090
</ol>
9191
</section>

0 commit comments

Comments
 (0)