Skip to content

Commit 6990138

Browse files
committed
Allow multiple references to the same footnote
Previously every link in the rich text for the same footnote had the same `id` attribute, which breaks the "Back to content" link (or just always returns the user to the first reference). This now generates a unique `id` for each footnote reference and updates the `footnotes.html` template to generate unique links back to each reference in the content.
1 parent df367bc commit 6990138

File tree

6 files changed

+108
-13
lines changed

6 files changed

+108
-13
lines changed

tests/test/test_blocks.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,47 @@ def setUp(self):
3838
[
3939
{
4040
"type": "paragraph",
41-
"value": f'<p>This is a paragraph with a footnote. <footnote id="{uuid}">1</footnote></p>',
41+
"value": (
42+
f'<p>This is a paragraph with a footnote. <footnote id="{uuid}">[{uuid[:6]}]</footnote></p>'
43+
),
4244
},
4345
]
4446
),
4547
)
4648
home_page.add_child(instance=self.test_page_with_footnote)
4749
self.test_page_with_footnote.save_revision().publish()
48-
self.footnote = Footnote.objects.create(
50+
Footnote.objects.create(
4951
page=self.test_page_with_footnote,
5052
uuid=uuid,
5153
text="This is a footnote",
5254
)
5355

56+
self.test_page_with_multiple_references_to_the_same_footnote = TestPageStreamField(
57+
title="Test Page With Multiple References to the Same Footnote",
58+
slug="test-page-with-multiple-references-to-the-same-footnote",
59+
body=json.dumps(
60+
[
61+
{
62+
"type": "paragraph",
63+
"value": (
64+
f'<p>This is a paragraph with a footnote. <footnote id="{uuid}">[{uuid[:6]}]</footnote></p>'
65+
f"<p>This is another paragraph with a reference to the same footnote. "
66+
f'<footnote id="{uuid}">[{uuid[:6]}]</footnote></p>'
67+
),
68+
},
69+
]
70+
),
71+
)
72+
home_page.add_child(
73+
instance=self.test_page_with_multiple_references_to_the_same_footnote
74+
)
75+
self.test_page_with_multiple_references_to_the_same_footnote.save_revision().publish()
76+
Footnote.objects.create(
77+
page=self.test_page_with_multiple_references_to_the_same_footnote,
78+
uuid=uuid,
79+
text="This is a footnote",
80+
)
81+
5482
def test_block_with_no_features(self):
5583
block = RichTextBlockWithFootnotes()
5684
self.assertIsInstance(block, blocks.RichTextBlock)
@@ -103,13 +131,36 @@ def test_block_replace_footnote_render_basic(self):
103131
value = rtb.get_prep_value(self.test_page_with_footnote.body[0].value)
104132
context = self.test_page_with_footnote.get_context(self.client.get("/"))
105133
out = rtb.render_basic(value, context=context)
106-
result = '<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1"><sup>[1]</sup></a></p>'
134+
result = (
135+
'<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1-0"><sup>[1]</sup></a>'
136+
"</p>"
137+
)
107138
self.assertEqual(out, result)
108139

109140
def test_block_replace_footnote_render(self):
110141
rtb = self.test_page_with_footnote.body.stream_block.child_blocks["paragraph"]
111142
value = rtb.get_prep_value(self.test_page_with_footnote.body[0].value)
112143
context = self.test_page_with_footnote.get_context(self.client.get("/"))
113144
out = rtb.render(value, context=context)
114-
result = '<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1"><sup>[1]</sup></a></p>'
145+
result = (
146+
'<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1-0"><sup>[1]</sup></a>'
147+
"</p>"
148+
)
149+
self.assertEqual(out, result)
150+
151+
def test_block_replace_footnote_with_multiple_references_render(self):
152+
body = self.test_page_with_multiple_references_to_the_same_footnote.body
153+
rtb = body.stream_block.child_blocks["paragraph"]
154+
value = rtb.get_prep_value(body[0].value)
155+
context = (
156+
self.test_page_with_multiple_references_to_the_same_footnote.get_context(
157+
self.client.get("/")
158+
)
159+
)
160+
out = rtb.render(value, context=context)
161+
result = (
162+
'<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1-0"><sup>[1]</sup></a>'
163+
'</p><p>This is another paragraph with a reference to the same footnote. <a href="#footnote-1" '
164+
'id="footnote-source-1-1"><sup>[1]</sup></a></p>'
165+
)
115166
self.assertEqual(out, result)

tests/test/test_functional.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,21 @@ def test_with_footnote(self):
7676

7777
# Test that required html tags are present with correct
7878
# attrs that enable the footnotes to respond to clicks
79-
source_anchor = soup.find("a", {"id": "footnote-source-1"})
79+
source_anchor = soup.find("a", {"id": "footnote-source-1-0"})
8080
self.assertTrue(source_anchor)
8181

8282
source_anchor_string = str(source_anchor)
8383
self.assertIn("<sup>[1]</sup>", source_anchor_string)
8484
self.assertIn('href="#footnote-1"', source_anchor_string)
85-
self.assertIn('id="footnote-source-1"', source_anchor_string)
85+
self.assertIn('id="footnote-source-1-0"', source_anchor_string)
8686

8787
footnotes = soup.find("div", {"class": "footnotes"})
8888
self.assertTrue(footnotes)
8989

9090
footnotes_string = str(footnotes)
9191
self.assertIn('id="footnote-1"', footnotes_string)
92-
self.assertIn('href="#footnote-source-1"', footnotes_string)
93-
self.assertIn("[1] This is a footnote", footnotes_string)
92+
self.assertIn('href="#footnote-source-1-0"', footnotes_string)
93+
self.assertIn("This is a footnote", footnotes_string)
9494

9595
def test_edit_page_with_footnote(self):
9696
self.client.force_login(self.admin_user)

wagtail_footnotes/blocks.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import re
22

3+
from collections import defaultdict
4+
35
from django.core.exceptions import ValidationError
46
from django.utils.safestring import mark_safe
57
from wagtail.blocks import RichTextBlock
@@ -24,6 +26,7 @@ def __init__(self, **kwargs):
2426
self.features = []
2527
if "footnotes" not in self.features:
2628
self.features.append("footnotes")
29+
self.footnotes = {}
2730

2831
def replace_footnote_tags(self, value, html, context=None):
2932
if context is None:
@@ -37,17 +40,28 @@ def replace_footnote_tags(self, value, html, context=None):
3740
page = new_context["page"]
3841
if not hasattr(page, "footnotes_list"):
3942
page.footnotes_list = []
43+
if not hasattr(page, "footnotes_references"):
44+
page.footnotes_references = defaultdict(list)
4045
self.footnotes = {
4146
str(footnote.uuid): footnote for footnote in page.footnotes.all()
4247
}
4348

4449
def replace_tag(match):
50+
footnote_uuid = match.group(1)
4551
try:
46-
index = self.process_footnote(match.group(1), page)
52+
index = self.process_footnote(footnote_uuid, page)
4753
except (KeyError, ValidationError):
4854
return ""
4955
else:
50-
return f'<a href="#footnote-{index}" id="footnote-source-{index}"><sup>[{index}]</sup></a>'
56+
# Generate a unique html id for each link in the content to this footnote since the same footnote may be
57+
# referenced multiple times in the page content. For the first reference to the first footnote, it will
58+
# be "footnote-source-1-0" (the index for the footnote is 1-based but the index for the links are
59+
# 0-based) and if it's the second link to the first footnote, it will be "footnote-source-1-1", etc.
60+
# This ensures the ids are unique throughout the page and allows for the template to generate links from
61+
# the footnote back up to the distinct references in the content.
62+
link_id = f"footnote-source-{index}-{len(page.footnotes_references[footnote_uuid])}"
63+
page.footnotes_references[footnote_uuid].append(link_id)
64+
return f'<a href="#footnote-{index}" id="{link_id}"><sup>[{index}]</sup></a>'
5165

5266
# note: we return safe html
5367
return mark_safe(FIND_FOOTNOTE_TAG.sub(replace_tag, html)) # noqa: S308
@@ -61,7 +75,6 @@ def render(self, value, context=None):
6175

6276
def render_basic(self, value, context=None):
6377
html = super().render_basic(value, context)
64-
6578
return self.replace_footnote_tags(value, html, context=context)
6679

6780
def process_footnote(self, footnote_id, page):

wagtail_footnotes/templates/wagtail_footnotes/includes/footnotes.html

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{% load wagtailcore_tags %}
2+
{% load wagtailfootnotes_tags %}
23

34
{% if page.footnotes_list %}
45
<div class="footnotes" id="footnotes">
@@ -8,8 +9,23 @@ <h2 id="footnote-label">
89
<ol>
910
{% for footnote in page.footnotes_list %}
1011
<li id="footnote-{{ forloop.counter }}">
11-
[{{ forloop.counter }}] {{ footnote.text|richtext }}
12-
<a href="#footnote-source-{{ forloop.counter }}" aria-label="Back to content"></a>
12+
{% with reference_ids=page|get_reference_ids:footnote.uuid num=reference_ids|length %}
13+
{% if num == 1 %}{# If there is only a single reference, simply link the return icon back to it #}
14+
<a href="#{{ reference_ids.0 }}" aria-label="Back to content">
15+
16+
</a>
17+
{% else %}
18+
19+
{% for reference_id in reference_ids %}
20+
<a href="#{{ reference_id }}" aria-label="Back to content">
21+
<sup>{# Display a 1-indexed counter for each of the references to this footnote #}
22+
{{ forloop.counter }}
23+
</sup>
24+
</a>
25+
{% endfor %}
26+
{% endif %}
27+
{% endwith %}
28+
{{ footnote.text|richtext }}
1329
</li>
1430
{% endfor %}
1531
</ol>

wagtail_footnotes/templatetags/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from django import template
2+
3+
4+
register = template.Library()
5+
6+
7+
@register.filter
8+
def get_reference_ids(value, footnote_uuid):
9+
"""This takes the current `footnote_uuid` and returns the list of references in the page content to that footnote.
10+
This template tag is only necessary because it is not possible to do dictionary lookups using variables as keys in
11+
Django templates.
12+
"""
13+
if hasattr(value, "footnotes_references"):
14+
return value.footnotes_references.get(footnote_uuid, [])
15+
return []

0 commit comments

Comments
 (0)