diff --git a/.spell-dict b/.spell-dict index c82a11dd..ce78dde6 100644 --- a/.spell-dict +++ b/.spell-dict @@ -50,6 +50,7 @@ GSoC hacky HeaderId HTTPS +href html implementers InlineProcessor diff --git a/docs/changelog.md b/docs/changelog.md index 4641a92e..dac7102f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,6 +21,7 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed +* Number nested-block footnotes in document order when `USE_DEFINITION_ORDER` is `False` (#1561). * Fix an issue with excessive backtracking when matching inline code blocks (#1617). ## [3.10.3] - 2026-07-30 diff --git a/markdown/extensions/footnotes.py b/markdown/extensions/footnotes.py index 46bb93c3..3e215d71 100644 --- a/markdown/extensions/footnotes.py +++ b/markdown/extensions/footnotes.py @@ -448,12 +448,43 @@ def __init__(self, footnotes: FootnoteExtension): def run(self, root: etree.Element) -> None: if not self.footnotes.footnotes: return + # Rebuild from the tree: inline processing is not document order. + self.footnotes.footnote_order = self.get_document_order(root) + self.renumber_refs(root) if self.footnotes.footnote_order != list(self.footnotes.footnotes.keys()): for div in root.iter('div'): if div.attrib.get('class', '') == 'footnote': self.reorder_footnotes(div) break + def get_fn_id(self, href: str) -> str: + """ Return the footnote id from a `footnote-ref` href. """ + return href.lstrip('#').split(self.footnotes.get_separator(), 1)[-1] + + def get_document_order(self, root: etree.Element) -> list[str]: + """ Return footnote ids in document order (first reference wins). """ + order: list[str] = [] + for el in root.iter('a'): + if el.attrib.get('class', '') != 'footnote-ref': + continue + fn_id = self.get_fn_id(el.attrib.get('href', '')) + if fn_id not in order: + order.append(fn_id) + return order + + def renumber_refs(self, root: etree.Element) -> None: + """ Rewrite superscript numbers to match document order. """ + numbers = { + fn_id: i for i, fn_id in enumerate(self.footnotes.footnote_order, start=1) + } + fmt = self.footnotes.getConfig("SUPERSCRIPT_TEXT") + for el in root.iter('a'): + if el.attrib.get('class', '') != 'footnote-ref': + continue + fn_id = self.get_fn_id(el.attrib.get('href', '')) + if fn_id in numbers: + el.text = fmt.format(numbers[fn_id]) + def reorder_footnotes(self, parent: etree.Element) -> None: old_list = parent.find('ol') parent.remove(old_list) diff --git a/tests/test_syntax/extensions/test_footnotes.py b/tests/test_syntax/extensions/test_footnotes.py index 896e1fcd..114e39c3 100644 --- a/tests/test_syntax/extensions/test_footnotes.py +++ b/tests/test_syntax/extensions/test_footnotes.py @@ -433,6 +433,108 @@ def test_footnote_order_by_definition(self): extension_configs={'footnotes': {'USE_DEFINITION_ORDER': True}} ) + def test_footnote_order_nested_blocks(self): + """Test document-order numbering when refs are inside nested blocks.""" + + self.assertMarkdownRenders( + self.dedent( + """ + First.[^1] + + 1. Nested list.[^2] + + Third.[^3] + + > Nested quote.[^4] + + Fifth.[^5] + + [^1]: First + [^2]: Second + [^3]: Third + [^4]: Fourth + [^5]: Fifth + """ + ), + '
First.1
\n' + 'Third.3
\n' + '\n' + '\n' + 'Nested quote.4
\n' + '
Fifth.5
\n' + 'First.1
\n' + 'Third.3
\n' + '', + extension_configs={'footnotes': {'USE_DEFINITION_ORDER': False}} + ) + def test_footnote_reference_within_code_span(self): """Test footnote reference within a code span."""