Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
67 changes: 60 additions & 7 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 @@ -71,6 +72,9 @@ def __init__(self, **kwargs):
self.found_refs: dict[str, int] = {}
self.used_refs: set[str] = set()

# Backward compatibility with old '%d' placeholder
self.setConfig('BACKLINK_TITLE', self.getConfig("BACKLINK_TITLE").replace("%d", "{}"))

self.reset()

def extendMarkdown(self, md):
Expand All @@ -89,6 +93,11 @@ def extendMarkdown(self, md):
# `codehilite`) so they can run on the the contents of the div.
md.treeprocessors.register(FootnoteTreeprocessor(self), 'footnote', 50)

# Insert a tree-processor to reorder the footnotes if necessary. This must be after
# `inline` tree-processor so it can access the footnote reference order
# (`self.footnote_order`) that gets populated by the `FootnoteInlineProcessor`.
md.treeprocessors.register(FootnoteReorderingProcessor(self), 'footnote-reorder', 19)

# Insert a tree-processor that will run after inline is done.
# In this tree-processor we want to check our duplicate footnote tracker
# And add additional `backrefs` to the footnote pointing back to the
Expand All @@ -100,6 +109,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 +160,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 Down Expand Up @@ -180,9 +195,6 @@ def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None:
ol = etree.SubElement(div, "ol")
surrogate_parent = etree.Element("div")

# Backward compatibility with old '%d' placeholder
backlink_title = self.getConfig("BACKLINK_TITLE").replace("%d", "{}")

for index, id in enumerate(self.footnotes.keys(), start=1):
li = etree.SubElement(ol, "li")
li.set("id", self.makeFootnoteId(id))
Expand All @@ -198,7 +210,7 @@ def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None:
backlink.set("class", "footnote-backref")
backlink.set(
"title",
backlink_title.format(index)
self.getConfig('BACKLINK_TITLE').format(index)
)
backlink.text = FN_BACKLINK_TEXT

Expand All @@ -214,7 +226,7 @@ def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None:


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 +238,9 @@ 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)

m = self.RE.search(block)
if m:
id = m.group(1)
Expand Down Expand Up @@ -312,13 +325,15 @@ def __init__(self, pattern: str, footnotes: FootnoteExtension):
def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]:
id = m.group(1)
if id in self.footnotes.footnotes.keys():
self.footnotes.addFootnoteRef(id)

sup = etree.Element("sup")
a = etree.SubElement(sup, "a")
sup.set('id', self.footnotes.makeFootnoteRefId(id, found=True))
a.set('href', '#' + self.footnotes.makeFootnoteId(id))
a.set('class', 'footnote-ref')
a.text = self.footnotes.getConfig("SUPERSCRIPT_TEXT").format(
list(self.footnotes.footnotes.keys()).index(id) + 1
self.footnotes.footnote_order.index(id) + 1
)
return sup, m.start(0), m.end(0)
else:
Expand Down Expand Up @@ -401,6 +416,44 @@ def run(self, root: etree.Element) -> None:
root.append(footnotesDiv)


class FootnoteReorderingProcessor(Treeprocessor):
""" Reorder list items in the footnotes div. """

def __init__(self, footnotes: FootnoteExtension):
self.footnotes = footnotes

def run(self, root: etree.Element) -> None:
if not self.footnotes.footnotes:
return
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 reorder_footnotes(self, parent: etree.Element) -> None:
old_list = parent.find('ol')
parent.remove(old_list)
items = old_list.findall('li')

def order_by_id(li) -> int:
id = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1)[-1]
return (
self.footnotes.footnote_order.index(id)
if id in self.footnotes.footnote_order
else len(self.footnotes.footnotes)
)

items = sorted(items, key=order_by_id)

new_list = etree.SubElement(parent, 'ol')

for index, item in enumerate(items, start=1):
backlink = item.find('.//a[@class="footnote-backref"]')
backlink.set("title", self.footnotes.getConfig("BACKLINK_TITLE").format(index))
new_list.append(item)


class FootnotePostprocessor(Postprocessor):
""" Replace placeholders with html entities. """
def __init__(self, footnotes: FootnoteExtension):
Expand Down
2 changes: 1 addition & 1 deletion markdown/treeprocessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ def run(self, tree: etree.Element, ancestors: list[str] | None = None) -> etree.
stack = [(tree, tree_parents)]

while stack:
currElement, parents = stack.pop()
currElement, parents = stack.pop(0)

self.ancestors = parents
self.__build_ancestors(currElement, self.ancestors)
Expand Down
Loading