Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -942,17 +942,24 @@ protected function renderFootnotesSection(): string
foreach ($renderedContents as $number => $content) {
$html .= '<li id="fn' . $number . '">' . "\n";

// Find the label for this footnote number to get ref count
$label = array_search($number, $this->footnoteNumbers, true);
$refCount = $label !== false ? ($this->footnoteRefCounts[$label] ?? 1) : 1;

// Generate backlinks - multiple if footnote referenced multiple times
$backlinks = $this->generateBacklinks($number, $refCount);

// Add backlink - if content ends with </p>, insert before it
// Otherwise add as separate paragraph
if ($content !== '' && preg_match('/^(.*)(<\/p>\n?)$/s', $content, $matches)) {
$content = $matches[1] . '<a href="#fnref' . $number . '" role="doc-backlink">↩︎</a></p>';
$content = $matches[1] . $backlinks . '</p>';
$html .= $content . "\n";
} else {
// Content doesn't end with paragraph (e.g., code block or empty)
if ($content !== '') {
$html .= $content . "\n";
}
$html .= '<p><a href="#fnref' . $number . '" role="doc-backlink">↩︎</a></p>' . "\n";
$html .= '<p>' . $backlinks . '</p>' . "\n";
}

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

/**
* Generate backlink(s) for a footnote
*
* @param int $number Footnote number
* @param int $refCount Number of times footnote was referenced
*/
protected function generateBacklinks(int $number, int $refCount): string
{
if ($refCount <= 1) {
// Single reference - simple backlink
return '<a href="#fnref' . $number . '" role="doc-backlink">↩︎</a>';
}

// Multiple references - generate numbered backlinks
$links = [];
for ($i = 1; $i <= $refCount; $i++) {
$refId = 'fnref' . $number;
if ($i > 1) {
$refId .= '-' . $i;
}
$links[] = '<a href="#' . $refId . '" role="doc-backlink">↩︎<sup>' . $i . '</sup></a>';
}

return implode(' ', $links);
}

protected function renderFootnoteRef(FootnoteRef $node): string
{
$label = $node->getLabel();
Expand All @@ -975,8 +1008,21 @@ protected function renderFootnoteRef(FootnoteRef $node): string
}
$number = $this->footnoteNumbers[$label];

// Track reference count for this footnote to generate unique IDs
if (!isset($this->footnoteRefCounts[$label])) {
$this->footnoteRefCounts[$label] = 0;
}
$this->footnoteRefCounts[$label]++;
$refCount = $this->footnoteRefCounts[$label];

// Generate unique ID: fnref1 for first, fnref1-2 for second, etc.
$refId = 'fnref' . $number;
if ($refCount > 1) {
$refId .= '-' . $refCount;
}

// Format: <a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a>
return '<a id="fnref' . $number . '" href="#fn' . $number . '" role="doc-noteref"><sup>' . $number . '</sup></a>';
return '<a id="' . $refId . '" href="#fn' . $number . '" role="doc-noteref"><sup>' . $number . '</sup></a>';
}

protected function renderMath(Math $node): string
Expand Down
56 changes: 56 additions & 0 deletions tests/TestCase/DjotConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2380,4 +2380,60 @@ public function testBackticksWithClosingFenceIsCodeBlock(): void
$this->assertStringContainsString('<pre><code class="language-python">', $result);
$this->assertStringContainsString('x = y + 3', $result);
}

/**
* Test that multiple references to the same footnote get unique IDs
*
* Fix for GitHub issue jgm/djot#348: Multiple calls to the same note
* should generate unique HTML-compliant IDs with suffixes.
*/
public function testMultipleFootnoteReferencesGetUniqueIds(): void
{
$djot = <<<'DJOT'
First ref[^note].

Second ref[^note].

Third ref[^note].

[^note]: The footnote.
DJOT;

$result = $this->converter->convert($djot);

// Each reference should have a unique ID
$this->assertStringContainsString('id="fnref1"', $result);
$this->assertStringContainsString('id="fnref1-2"', $result);
$this->assertStringContainsString('id="fnref1-3"', $result);

// All should link to the same footnote
$this->assertSame(3, substr_count($result, 'href="#fn1"'));

// Footnote should have multiple backlinks
$this->assertStringContainsString('href="#fnref1"', $result);
$this->assertStringContainsString('href="#fnref1-2"', $result);
$this->assertStringContainsString('href="#fnref1-3"', $result);

// Single footnote (no duplicates)
$this->assertSame(1, substr_count($result, 'id="fn1"'));
}

public function testSingleFootnoteReferenceNoSuffix(): void
{
$djot = <<<'DJOT'
Single ref[^note].

[^note]: The footnote.
DJOT;

$result = $this->converter->convert($djot);

// Single reference - no suffix needed
$this->assertStringContainsString('id="fnref1"', $result);
$this->assertStringNotContainsString('fnref1-', $result);

// Simple backlink without numbering
$this->assertStringContainsString('href="#fnref1"', $result);
$this->assertStringNotContainsString('<sup>1</sup></a> <a', $result);
}
}
6 changes: 3 additions & 3 deletions tests/official/footnotes.test
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ test[^a] and another[^foo_bar].
another ref to the first note[^a].
.
<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>
<p>another ref to the first note<a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a>.</p>
<p>another ref to the first note<a id="fnref1-2" href="#fn1" role="doc-noteref"><sup>1</sup></a>.</p>
<section role="doc-endnotes">
<hr>
<ol>
<li id="fn1">
<p>This is a note.</p>
<p>Second paragraph.<a href="#fnref1" role="doc-backlink">↩︎</a></p>
<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>
</li>
<li id="fn2">
<pre><code>code
Expand Down Expand Up @@ -85,7 +85,7 @@ text[^footnote].
<p>very long footnote<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a><a href="#fnref1" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn2">
<p>bla bla<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a><a href="#fnref2" role="doc-backlink">↩︎</a></p>
<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>
</li>
</ol>
</section>
Expand Down