From 527667dd9c1dae47c9a986179300269ddedc476d Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang Date: Wed, 2 Sep 2026 05:41:23 +0000 Subject: [PATCH 1/2] fix: number nested-block footnotes in document order With USE_DEFINITION_ORDER=False, footnote refs were numbered by breadth-first inline walk order, so refs inside lists/blockquotes got wrong superscripts. Rebuild footnote_order from the tree in document order and renumber refs before reordering the footnote list. Fixes #1561 --- markdown/extensions/footnotes.py | 31 ++++++ .../test_syntax/extensions/test_footnotes.py | 102 ++++++++++++++++++ 2 files changed, 133 insertions(+) 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' + '
    \n' + '
  1. Nested list.2
  2. \n' + '
\n' + '

Third.3

\n' + '
\n' + '

Nested quote.4

\n' + '
\n' + '

Fifth.5

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '

    First 

    \n' + '
  2. \n' + '
  3. \n' + '

    Second 

    \n' + '
  4. \n' + '
  5. \n' + '

    Third 

    \n' + '
  6. \n' + '
  7. \n' + '

    Fourth 

    \n' + '
  8. \n' + '
  9. \n' + '

    Fifth 

    \n' + '
  10. \n' + '
\n' + '
', + extension_configs={'footnotes': {'USE_DEFINITION_ORDER': False}} + ) + + def test_footnote_order_nested_blocks_not_definition_order(self): + """Test that nested refs follow document order, not definition order.""" + + self.assertMarkdownRenders( + self.dedent( + """ + First.[^b] + + 1. Nested.[^a] + + Third.[^c] + + [^c]: C + [^a]: A + [^b]: B + """ + ), + '

First.1

\n' + '
    \n' + '
  1. Nested.2
  2. \n' + '
\n' + '

Third.3

\n' + '
\n' + '
\n' + '
    \n' + '
  1. \n' + '

    \n' + '
  2. \n' + '
  3. \n' + '

    \n' + '
  4. \n' + '
  5. \n' + '

    \n' + '
  6. \n' + '
\n' + '
', + extension_configs={'footnotes': {'USE_DEFINITION_ORDER': False}} + ) + def test_footnote_reference_within_code_span(self): """Test footnote reference within a code span.""" From 8f18e04cfb862fcc01d52142fe03abe2c1cf4057 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang Date: Wed, 2 Sep 2026 19:04:48 +0000 Subject: [PATCH 2/2] ci: add changelog entry and href to spell-dict Satisfy changelog-enforcer and checkspelling for the #1561 footnote order fix. --- .spell-dict | 1 + docs/changelog.md | 1 + 2 files changed, 2 insertions(+) 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