Skip to content

Commit 5a94412

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 e8a799c commit 5a94412

File tree

7 files changed

+132
-50
lines changed

7 files changed

+132
-50
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<a href="#endnote-{{ index }}" id="endnote-source-{{ index }}"><sup>{{ index }}</sup></a>
1+
<a href="#endnote-{{ index }}" id="endnote-source-{{ index }}-{{ reference_index }}"><sup>{{ index }}</sup></a>

tests/test/test_blocks.py

Lines changed: 54 additions & 14 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)
@@ -98,35 +126,47 @@ def test_block_replace_footnote_tags(self):
98126
html = block.replace_footnote_tags(None, "foo")
99127
self.assertEqual(html, "foo")
100128

101-
def test_block_replace_footnote_render_basic(self):
129+
def test_block_replace_footnote_render(self):
102130
rtb = self.test_page_with_footnote.body.stream_block.child_blocks["paragraph"]
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("/"))
105-
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>'
133+
out = rtb.render(value, context=context)
134+
result = (
135+
'<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1-1"><sup>[1]</sup></a>'
136+
"</p>"
137+
)
107138
self.assertHTMLEqual(out, result)
108139

109-
def test_block_replace_footnote_render(self):
110-
rtb = self.test_page_with_footnote.body.stream_block.child_blocks["paragraph"]
111-
value = rtb.get_prep_value(self.test_page_with_footnote.body[0].value)
112-
context = self.test_page_with_footnote.get_context(self.client.get("/"))
140+
def test_block_replace_footnote_with_multiple_references_render(self):
141+
body = self.test_page_with_multiple_references_to_the_same_footnote.body
142+
rtb = body.stream_block.child_blocks["paragraph"]
143+
value = rtb.get_prep_value(body[0].value)
144+
context = (
145+
self.test_page_with_multiple_references_to_the_same_footnote.get_context(
146+
self.client.get("/")
147+
)
148+
)
113149
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>'
150+
result = (
151+
'<p>This is a paragraph with a footnote. <a href="#footnote-1" id="footnote-source-1-1"><sup>[1]</sup></a>'
152+
'</p><p>This is another paragraph with a reference to the same footnote. <a href="#footnote-1" '
153+
'id="footnote-source-1-2"><sup>[1]</sup></a></p>'
154+
)
115155
self.assertHTMLEqual(out, result)
116156

117157
def test_render_footnote_tag(self):
118158
block = RichTextBlockWithFootnotes()
119-
html = block.render_footnote_tag(2)
159+
html = block.render_footnote_tag(index=2, reference_index=1)
120160
self.assertHTMLEqual(
121-
html, '<a href="#footnote-2" id="footnote-source-2"><sup>[2]</sup></a>'
161+
html, '<a href="#footnote-2" id="footnote-source-2-1"><sup>[2]</sup></a>'
122162
)
123163

124164
@override_settings(
125165
WAGTAIL_FOOTNOTES_REFERENCE_TEMPLATE="test/endnote_reference.html"
126166
)
127167
def test_render_footnote_tag_new_template(self):
128168
block = RichTextBlockWithFootnotes()
129-
html = block.render_footnote_tag(2)
169+
html = block.render_footnote_tag(index=2, reference_index=1)
130170
self.assertHTMLEqual(
131-
html, '<a href="#endnote-2" id="endnote-source-2"><sup>2</sup></a>'
171+
html, '<a href="#endnote-2" id="endnote-source-2-1"><sup>2</sup></a>'
132172
)

tests/test/test_functional.py

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

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

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

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

9292
footnotes_string = str(footnotes)
9393
self.assertIn('id="footnote-1"', footnotes_string)
94-
self.assertIn('href="#footnote-source-1"', footnotes_string)
95-
self.assertIn("[1] This is a footnote", footnotes_string)
94+
self.assertIn('href="#footnote-source-1-1"', footnotes_string)
95+
self.assertIn("This is a footnote", footnotes_string)
9696

9797
def test_edit_page_with_footnote(self):
9898
self.client.force_login(self.admin_user)

tests/test/test_translation.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,18 +138,18 @@ def test_translated_page_shows_translated_footnote(self):
138138

139139
# Test that required html tags are present with correct
140140
# attrs that enable the footnotes to respond to clicks
141-
source_anchor = soup.find("a", {"id": "footnote-source-1"})
141+
source_anchor = soup.find("a", {"id": "footnote-source-1-1"})
142142
self.assertTrue(source_anchor)
143143

144144
source_anchor_string = str(source_anchor)
145145
self.assertIn("<sup>[1]</sup>", source_anchor_string)
146146
self.assertIn('href="#footnote-1"', source_anchor_string)
147-
self.assertIn('id="footnote-source-1"', source_anchor_string)
147+
self.assertIn('id="footnote-source-1-1"', source_anchor_string)
148148

149149
footnotes = soup.find("div", {"class": "footnotes"})
150150
self.assertTrue(footnotes)
151151

152152
footnotes_string = str(footnotes)
153153
self.assertIn('id="footnote-1"', footnotes_string)
154-
self.assertIn('href="#footnote-source-1"', footnotes_string)
155-
self.assertIn("[1] This is a French translated footnote", footnotes_string)
154+
self.assertIn('href="#footnote-source-1-1"', footnotes_string)
155+
self.assertIn("This is a French translated footnote", footnotes_string)

wagtail_footnotes/blocks.py

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import re
22

33
from django.conf import settings
4-
from django.core.exceptions import ValidationError
54
from django.template.loader import get_template
65
from django.utils.safestring import mark_safe
76
from wagtail.blocks import RichTextBlock
87
from wagtail.models import Page
98

9+
from wagtail_footnotes.models import Footnote
10+
1011

1112
FIND_FOOTNOTE_TAG = re.compile(r'<footnote id="(.*?)">.*?</footnote>')
1213

@@ -15,69 +16,93 @@ class RichTextBlockWithFootnotes(RichTextBlock):
1516
"""
1617
Rich Text block that renders footnotes in the format
1718
'<footnote id="long-id">short-id</footnote>' as anchor elements. It also
18-
adds the Footnote object to the 'page' object for later use. It uses
19+
adds the Footnote object(s) to the 'page' object for later use. It uses
1920
'page' because variables added to 'context' do not persist into the
2021
final template context.
2122
"""
2223

24+
all_footnotes: dict[str, Footnote]
25+
2326
def __init__(self, **kwargs):
2427
super().__init__(**kwargs)
2528
if not self.features:
2629
self.features = []
2730
if "footnotes" not in self.features:
2831
self.features.append("footnotes")
2932

30-
def render_footnote_tag(self, index):
33+
def render_footnote_tag(self, index: int, reference_index: int):
3134
template_name = getattr(
3235
settings,
3336
"WAGTAIL_FOOTNOTES_REFERENCE_TEMPLATE",
3437
"wagtail_footnotes/includes/footnote_reference.html",
3538
)
3639
template = get_template(template_name)
37-
return template.render({"index": index})
40+
return template.render({"index": index, "reference_index": reference_index})
3841

3942
def replace_footnote_tags(self, value, html, context=None):
4043
if context is None:
4144
new_context = self.get_context(value)
4245
else:
4346
new_context = self.get_context(value, parent_context=dict(context))
4447

45-
if not isinstance(new_context.get("page"), Page):
48+
page = new_context.get("page")
49+
if page is None or not isinstance(page, Page):
4650
return html
4751

48-
page = new_context["page"]
49-
if not hasattr(page, "footnotes_list"):
50-
page.footnotes_list = []
51-
self.footnotes = {
52+
# Map Footnote UUIDs to Footnote instances to simplify lookups once a reference has been found in the text.
53+
# NOTE: Footnotes may exist in the database for a given page but this does not necessarily mean that the
54+
# footnote was referenced in the text.
55+
self.all_footnotes = {
5256
str(footnote.uuid): footnote for footnote in page.footnotes.all()
5357
}
5458

59+
# Patch the page to track the footnotes that are actually referenced in the text, so that they can be rendered
60+
# in footnotes.html
61+
if not hasattr(page, "footnotes_list"):
62+
page.footnotes_list = []
63+
5564
def replace_tag(match):
65+
footnote_uuid = match.group(1)
5666
try:
57-
index = self.process_footnote(match.group(1), page)
58-
except (KeyError, ValidationError):
67+
footnote = self.attach_footnote_to_page(footnote_uuid, page)
68+
except KeyError:
5969
return ""
6070
else:
61-
return self.render_footnote_tag(index)
71+
# Add 1 to the footnote index as footnotes are rendered in footnotes.html using `{{ forloop.counter }}`
72+
# which is 1-based.
73+
footnote_index = page.footnotes_list.index(footnote) + 1
74+
reference_index = footnote.references[-1]
75+
# Supplying both indexes allows for unique id values to be generated in the HTML. E.g., the first
76+
# reference to the first footnote will have `id="footnote-source-1-1"`, and the second reference to the
77+
# first footnote will have `id="footnote-source-1-2"`, etc.
78+
return self.render_footnote_tag(footnote_index, reference_index)
6279

6380
# note: we return safe html
6481
return mark_safe(FIND_FOOTNOTE_TAG.sub(replace_tag, html)) # noqa: S308
6582

6683
def render(self, value, context=None):
67-
if not self.get_template(value=value, context=context):
68-
return self.render_basic(value, context=context)
69-
7084
html = super().render(value, context=context)
7185
return self.replace_footnote_tags(value, html, context=context)
7286

73-
def render_basic(self, value, context=None):
74-
html = super().render_basic(value, context)
87+
def attach_footnote_to_page(self, footnote_uuid: str, page: Page) -> Footnote:
88+
"""Finds the Footnote object matching `footnote_uuid`, then modifies it to track how many times it has been
89+
referenced, and attaches it to the `page` so the footnote can be rendered in the page template.
90+
"""
91+
# Fetch the unmodified Footnote
92+
footnote = self.all_footnotes[footnote_uuid]
7593

76-
return self.replace_footnote_tags(value, html, context=context)
77-
78-
def process_footnote(self, footnote_id, page):
79-
footnote = self.footnotes[footnote_id]
94+
# If this is the first time the Footnote has been referenced, modify it to track references before appending it
95+
# to the page
8096
if footnote not in page.footnotes_list:
97+
footnote.references = [1]
8198
page.footnotes_list.append(footnote)
82-
# Add 1 to the index as footnotes are indexed starting at 1 not 0.
83-
return page.footnotes_list.index(footnote) + 1
99+
else:
100+
# If this Footnote has been processed by a previous reference, fetch the modified Footnote from the page and
101+
# update its reference tracking
102+
footnote_index = page.footnotes_list.index(footnote)
103+
footnote = page.footnotes_list[footnote_index]
104+
# Update the references e.g., [1, 2]
105+
footnote.references.append(footnote.references[-1] + 1)
106+
# Update the page with the updated footnote
107+
page.footnotes_list[footnote_index] = footnote
108+
return footnote
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<a href="#footnote-{{ index }}" id="footnote-source-{{ index }}"><sup>[{{ index }}]</sup></a>
1+
<a href="#footnote-{{ index }}" id="footnote-source-{{ index }}-{{ reference_index }}"><sup>[{{ index }}]</sup></a>

wagtail_footnotes/templates/wagtail_footnotes/includes/footnotes.html

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,27 @@ <h2 id="footnote-label">
77
</h2>
88
<ol>
99
{% for footnote in page.footnotes_list %}
10-
<li id="footnote-{{ forloop.counter }}">
11-
[{{ forloop.counter }}] {{ footnote.text|richtext }}
12-
<a href="#footnote-source-{{ forloop.counter }}" aria-label="{% translate "Back to content" %}"></a>
10+
{% with footnote_index=forloop.counter %}
11+
<li id="footnote-{{ footnote_index }}">
12+
{% if footnote.references|length == 1 %}
13+
{# If there is only a single reference, link the return icon back to it #}
14+
<a href="#footnote-source-{{ footnote_index }}-{{ footnote.references.0 }}" aria-label={% translate "Back to content" %}>
15+
16+
</a>
17+
{% else %}
18+
19+
{% for reference_index in footnote.references %}
20+
{# If there are multiple references, generate unique links per reference #}
21+
<a href="#footnote-source-{{ footnote_index }}-{{ reference_index }}" aria-label={% translate "Back to content" %}>
22+
<sup>{# Display a 1-indexed counter for each of the references to this footnote #}
23+
{{ forloop.counter }}
24+
</sup>
25+
</a>
26+
{% endfor %}
27+
{% endif %}
28+
{{ footnote.text|richtext }}
1329
</li>
30+
{% endwith %}
1431
{% endfor %}
1532
</ol>
1633
</div>

0 commit comments

Comments
 (0)