Skip to content

Fix footnote ordering #1546

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ See the [Contributing Guide](contributing.md) for details.
### Fixed

* Fix handling of incomplete HTML tags in code spans in Python 3.14.
* Fix issue with footnote ordering (#1367).

## [3.8.2] - 2025-06-19

Expand Down
34 changes: 32 additions & 2 deletions markdown/extensions/footnotes.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
FN_BACKLINK_TEXT = util.STX + "zz1337820767766393qq" + util.ETX
NBSP_PLACEHOLDER = util.STX + "qq3936677670287331zz" + util.ETX
RE_REF_ID = re.compile(r'(fnref)(\d+)')
RE_REFERENCE = re.compile(r'(?<!!)\[\^([^\]]*)\](?!\s*:)')


class FootnoteExtension(Extension):
Expand Down Expand Up @@ -100,6 +101,7 @@ def extendMarkdown(self, md):

def reset(self) -> None:
""" Clear footnotes on reset, and prepare for distinct document. """
self.footnote_order: list[str] = []
self.footnotes: OrderedDict[str, str] = OrderedDict()
self.unique_prefix += 1
self.found_refs = {}
Expand Down Expand Up @@ -150,6 +152,11 @@ def setFootnote(self, id: str, text: str) -> None:
""" Store a footnote for later retrieval. """
self.footnotes[id] = text

def addFootnoteRef(self, id: str) -> None:
""" Store a footnote reference id in order of appearance. """
if id not in self.footnote_order:
self.footnote_order.append(id)

def get_separator(self) -> str:
""" Get the footnote separator. """
return self.getConfig("SEPARATOR")
Expand All @@ -174,6 +181,8 @@ def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None:
if not list(self.footnotes.keys()):
return None

self.reorderFootnoteDict()

div = etree.Element("div")
div.set('class', 'footnote')
etree.SubElement(div, "hr")
Expand Down Expand Up @@ -212,9 +221,24 @@ def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None:
p.append(backlink)
return div

def reorderFootnoteDict(self) -> None:
""" Reorder the footnotes dict based on the order of references found. """
ordered_footnotes = OrderedDict()

for ref in self.footnote_order:
if ref in self.footnotes:
ordered_footnotes[ref] = self.footnotes[ref]

# Add back any footnotes that were defined but not referenced.
for id, text in self.footnotes.items():
if id not in ordered_footnotes:
ordered_footnotes[id] = text

self.footnotes = ordered_footnotes


class FootnoteBlockProcessor(BlockProcessor):
""" Find all footnote references and store for later use. """
""" Find footnote definitions and references, storing both for later use. """

RE = re.compile(r'^[ ]{0,3}\[\^([^\]]*)\]:[ ]*(.*)$', re.MULTILINE)

Expand All @@ -226,8 +250,14 @@ def test(self, parent: etree.Element, block: str) -> bool:
return True

def run(self, parent: etree.Element, blocks: list[str]) -> bool:
""" Find, set, and remove footnote definitions. """
""" Find, set, and remove footnote definitions. Find footnote references."""
block = blocks.pop(0)

# Find any footnote references in the block to determine order.
for match in RE_REFERENCE.finditer(block):
ref_id = match.group(1)
self.footnotes.addFootnoteRef(ref_id)

m = self.RE.search(block)
if m:
id = m.group(1)
Expand Down
217 changes: 217 additions & 0 deletions tests/test_syntax/extensions/test_footnotes.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,220 @@ def test_superscript_text(self):
'</div>',
extension_configs={'footnotes': {'SUPERSCRIPT_TEXT': '[{}]'}}
)

def test_footnote_order(self):
"""Test that footnotes occur in order of reference appearance."""

self.assertMarkdownRenders(
'First footnote reference[^first]. Second footnote reference[^last].\n\n'
'[^last]: Second footnote.\n[^first]: First footnote.',
'<p>First footnote reference<sup id="fnref:first"><a class="footnote-ref" '
'href="#fn:first">1</a></sup>. Second footnote reference<sup id="fnref:last">'
'<a class="footnote-ref" href="#fn:last">2</a></sup>.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:first">\n'
'<p>First footnote.&#160;<a class="footnote-backref" href="#fnref:first" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'<li id="fn:last">\n'
'<p>Second footnote.&#160;<a class="footnote-backref" href="#fnref:last" '
'title="Jump back to footnote 2 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)
Comment on lines +340 to +362
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tests the new behavior (footnotes ordered by reference appearence).


def test_footnote_order_tricky(self):
"""Test that tricky sequence of footnote references."""

self.assertMarkdownRenders(
'`Footnote reference in code spans should be ignored[^tricky]`. '
'A footnote reference[^ordinary]. '
'Another footnote reference[^tricky].\n\n'
'[^ordinary]: This should be the first footnote.\n'
'[^tricky]: This should be the second footnote.',
'<p><code>Footnote reference in code spans should be ignored[^tricky]</code>. '
'A footnote reference<sup id="fnref:ordinary"><a class="footnote-ref" '
'href="#fn:ordinary">1</a></sup>. Another footnote reference<sup id="fnref:tricky">'
'<a class="footnote-ref" href="#fn:tricky">2</a></sup>.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:ordinary">\n'
'<p>This should be the first footnote.&#160;<a class="footnote-backref" '
'href="#fnref:ordinary" title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'<li id="fn:tricky">\n'
'<p>This should be the second footnote.&#160;<a class="footnote-backref" '
'href="#fnref:tricky" title="Jump back to footnote 2 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)

def test_footnote_reference_within_code_span(self):
"""Test footnote reference within a code span."""

self.assertMarkdownRenders(
'A `code span with a footnote[^1] reference`.',
'<p>A <code>code span with a footnote[^1] reference</code>.</p>'
)

def test_footnote_reference_within_link(self):
"""Test footnote reference within a link."""

self.assertMarkdownRenders(
'A [link with a footnote[^1] reference](http://example.com).',
'<p>A <a href="http://example.com">link with a footnote[^1] reference</a>.</p>'
)

def test_footnote_reference_within_footnote_definition(self):
"""Test footnote definition containing another footnote reference."""

self.assertMarkdownRenders(
'Main footnote[^main].\n\n'
'[^main]: This footnote references another[^nested].\n'
'[^nested]: Nested footnote.',
'<p>Main footnote<sup id="fnref:main"><a class="footnote-ref" href="#fn:main">1</a></sup>.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:main">\n'
'<p>This footnote references another<sup id="fnref:nested"><a class="footnote-ref" '
'href="#fn:nested">2</a></sup>.&#160;<a class="footnote-backref" href="#fnref:main" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'<li id="fn:nested">\n'
'<p>Nested footnote.&#160;<a class="footnote-backref" href="#fnref:nested" '
'title="Jump back to footnote 2 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)

def test_footnote_reference_within_blockquote(self):
"""Test footnote reference within a blockquote."""

self.assertMarkdownRenders(
'> This is a quote with a footnote[^quote].\n\n[^quote]: Quote footnote.',
'<blockquote>\n'
'<p>This is a quote with a footnote<sup id="fnref:quote">'
'<a class="footnote-ref" href="#fn:quote">1</a></sup>.</p>\n'
'</blockquote>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:quote">\n'
'<p>Quote footnote.&#160;<a class="footnote-backref" href="#fnref:quote" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)

def test_footnote_reference_within_list(self):
"""Test footnote reference within a list item."""

self.assertMarkdownRenders(
'1. First item with footnote[^note]\n1. Second item\n\n[^note]: List footnote.',
'<ol>\n'
'<li>First item with footnote<sup id="fnref:note">'
'<a class="footnote-ref" href="#fn:note">1</a></sup></li>\n'
'<li>Second item</li>\n'
'</ol>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:note">\n'
'<p>List footnote.&#160;<a class="footnote-backref" href="#fnref:note" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)

def test_footnote_reference_within_html(self):
"""Test footnote reference within HTML tags."""

self.assertMarkdownRenders(
'A <span>footnote reference[^1] within a span element</span>.\n\n[^1]: The footnote.',
'<p>A <span>footnote reference<sup id="fnref:1">'
'<a class="footnote-ref" href="#fn:1">1</a>'
'</sup> within a span element</span>.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:1">\n'
'<p>The footnote.&#160;<a class="footnote-backref" href="#fnref:1" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)
Comment on lines +392 to +490
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These test parsing of footnote references within other structures.


def test_duplicate_footnote_references(self):
"""Test multiple references to the same footnote."""

self.assertMarkdownRenders(
'First[^dup] and second[^dup] reference.\n\n[^dup]: Duplicate footnote.',
'<p>First<sup id="fnref:dup">'
'<a class="footnote-ref" href="#fn:dup">1</a></sup> and second<sup id="fnref2:dup">'
'<a class="footnote-ref" href="#fn:dup">1</a></sup> reference.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:dup">\n'
'<p>Duplicate footnote.&#160;'
'<a class="footnote-backref" href="#fnref:dup" '
'title="Jump back to footnote 1 in the text">&#8617;</a>'
'<a class="footnote-backref" href="#fnref2:dup" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)

def test_footnote_reference_without_definition(self):
"""Test footnote reference without corresponding definition."""

self.assertMarkdownRenders(
'This has a missing footnote[^missing].',
'<p>This has a missing footnote[^missing].</p>'
)

def test_footnote_definition_without_reference(self):
"""Test footnote definition without corresponding reference."""

self.assertMarkdownRenders(
'No reference here.\n\n[^orphan]: Orphaned footnote.',
'<p>No reference here.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:orphan">\n'
'<p>Orphaned footnote.&#160;<a class="footnote-backref" href="#fnref:orphan" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)

def test_footnote_id_with_special_chars(self):
"""Test footnote id containing special and Unicode characters."""

self.assertMarkdownRenders(
'Special footnote id[^!#¤%/()=?+}{§øé].\n\n[^!#¤%/()=?+}{§øé]: The footnote.',
'<p>Special footnote id<sup id="fnref:!#¤%/()=?+}{§øé">'
'<a class="footnote-ref" href="#fn:!#¤%/()=?+}{§øé">1</a></sup>.</p>\n'
'<div class="footnote">\n'
'<hr />\n'
'<ol>\n'
'<li id="fn:!#¤%/()=?+}{§øé">\n'
'<p>The footnote.&#160;<a class="footnote-backref" href="#fnref:!#¤%/()=?+}{§øé" '
'title="Jump back to footnote 1 in the text">&#8617;</a></p>\n'
'</li>\n'
'</ol>\n'
'</div>'
)