diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php
index 466ae8e..91bc7ec 100644
--- a/src/Renderer/HtmlRenderer.php
+++ b/src/Renderer/HtmlRenderer.php
@@ -942,17 +942,24 @@ protected function renderFootnotesSection(): string
foreach ($renderedContents as $number => $content) {
$html .= '
' . "\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 , insert before it
// Otherwise add as separate paragraph
if ($content !== '' && preg_match('/^(.*)(<\/p>\n?)$/s', $content, $matches)) {
- $content = $matches[1] . '↩︎';
+ $content = $matches[1] . $backlinks . '';
$html .= $content . "\n";
} else {
// Content doesn't end with paragraph (e.g., code block or empty)
if ($content !== '') {
$html .= $content . "\n";
}
- $html .= '↩︎
' . "\n";
+ $html .= '' . $backlinks . '
' . "\n";
}
$html .= '' . "\n";
@@ -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 '↩︎';
+ }
+
+ // Multiple references - generate numbered backlinks
+ $links = [];
+ for ($i = 1; $i <= $refCount; $i++) {
+ $refId = 'fnref' . $number;
+ if ($i > 1) {
+ $refId .= '-' . $i;
+ }
+ $links[] = '↩︎' . $i . '';
+ }
+
+ return implode(' ', $links);
+ }
+
protected function renderFootnoteRef(FootnoteRef $node): string
{
$label = $node->getLabel();
@@ -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: 1
- return '' . $number . '';
+ return '' . $number . '';
}
protected function renderMath(Math $node): string
diff --git a/tests/TestCase/DjotConverterTest.php b/tests/TestCase/DjotConverterTest.php
index e534674..558e97d 100644
--- a/tests/TestCase/DjotConverterTest.php
+++ b/tests/TestCase/DjotConverterTest.php
@@ -2380,4 +2380,60 @@ public function testBackticksWithClosingFenceIsCodeBlock(): void
$this->assertStringContainsString('', $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('1 test1 and another2.
-another ref to the first note1.
+another ref to the first note1.
-
This is a note.
-Second paragraph.↩︎
+Second paragraph.↩︎1 ↩︎2
-
code
@@ -85,7 +85,7 @@ text[^footnote].
very long footnote2↩︎
-
-
bla bla2↩︎
+bla bla2↩︎1 ↩︎2