Skip to content

Commit 29443e6

Browse files
committed
🐛 FIX: reorder footnotes
1 parent 1c2b7a2 commit 29443e6

4 files changed

Lines changed: 255 additions & 10 deletions

File tree

mdformat_footnote/_reorder.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Footnote ID and subId normalization logic."""
2+
3+
from __future__ import annotations
4+
5+
from markdown_it.rules_core import StateCore
6+
7+
8+
def reorder_footnotes_by_definition(state: StateCore) -> None:
9+
"""Reorder footnotes to match definition order and normalize subIds.
10+
11+
The mdit-py-plugins footnote plugin assigns IDs and subIds based on the
12+
order references are encountered during inline parsing. This causes HTML
13+
to differ when footnote definitions are reordered by the formatter.
14+
15+
This rule:
16+
1. Preserves orphan footnotes (defined but never referenced)
17+
2. Reorders the footnote list to match definition order
18+
3. Updates all token IDs to match the new ordering
19+
4. Reassigns subIds based on output order (body first, then definitions)
20+
21+
This ensures consistent HTML output regardless of definition position.
22+
"""
23+
if "footnotes" not in state.env:
24+
return
25+
26+
footnote_data = state.env["footnotes"]
27+
refs = footnote_data.get("refs", {})
28+
old_list = footnote_data.get("list", {})
29+
30+
if not refs:
31+
return
32+
33+
new_list: dict[int, dict] = {}
34+
old_to_new_id: dict[int, int] = {}
35+
36+
for new_id, label_key in enumerate(refs.keys()):
37+
label = label_key[1:]
38+
old_id = refs[label_key]
39+
40+
if old_id >= 0 and old_id in old_list:
41+
new_list[new_id] = old_list[old_id].copy()
42+
else:
43+
new_list[new_id] = {"label": label, "count": 0}
44+
45+
if old_id >= 0:
46+
old_to_new_id[old_id] = new_id
47+
refs[label_key] = new_id
48+
49+
footnote_data["list"] = new_list
50+
51+
_update_token_ids(state.tokens, old_to_new_id)
52+
_reassign_subids(state.tokens, refs, new_list)
53+
54+
55+
def _update_token_ids(tokens: list, old_to_new_id: dict[int, int]) -> None:
56+
"""Recursively update footnote IDs in tokens."""
57+
for token in tokens:
58+
if token.type in ("footnote_ref", "footnote_anchor"):
59+
if token.meta and "id" in token.meta:
60+
old_id = token.meta["id"]
61+
if old_id in old_to_new_id:
62+
token.meta["id"] = old_to_new_id[old_id]
63+
if token.children:
64+
_update_token_ids(token.children, old_to_new_id)
65+
66+
67+
def _partition_refs_by_context(
68+
tokens: list,
69+
) -> tuple[list, dict[str, list]]:
70+
"""Partition footnote refs into body refs and definition refs."""
71+
body_refs: list = []
72+
def_refs: dict[str, list] = {}
73+
current_def_label: str | None = None
74+
75+
for token in tokens:
76+
if token.type == "footnote_reference_open":
77+
current_def_label = token.meta.get("label")
78+
if current_def_label:
79+
def_refs.setdefault(current_def_label, [])
80+
elif token.type == "footnote_reference_close":
81+
current_def_label = None
82+
elif current_def_label is None:
83+
_collect_refs(token, body_refs)
84+
else:
85+
_collect_refs(token, def_refs.setdefault(current_def_label, []))
86+
87+
return body_refs, def_refs
88+
89+
90+
def _assign_subids_to_refs(ref_tokens: list, counters: dict[int, int]) -> None:
91+
"""Assign sequential subIds to a list of ref tokens."""
92+
for ref_token in ref_tokens:
93+
fn_id = ref_token.meta["id"]
94+
ref_token.meta["subId"] = counters.get(fn_id, 0)
95+
counters[fn_id] = counters.get(fn_id, 0) + 1
96+
97+
98+
def _reassign_subids(tokens: list, refs: dict, footnote_list: dict) -> None:
99+
"""Reassign subIds based on output order: body refs first, then definition refs."""
100+
body_refs, def_refs = _partition_refs_by_context(tokens)
101+
subid_counters: dict[int, int] = {}
102+
103+
_assign_subids_to_refs(body_refs, subid_counters)
104+
105+
for label_key in refs.keys():
106+
label = label_key[1:]
107+
if label in def_refs:
108+
_assign_subids_to_refs(def_refs[label], subid_counters)
109+
110+
for fn_id, count in subid_counters.items():
111+
if fn_id in footnote_list:
112+
footnote_list[fn_id]["count"] = count
113+
114+
115+
def _collect_refs(token, ref_list: list) -> None:
116+
"""Collect footnote_ref tokens from a token and its children."""
117+
if token.type == "footnote_ref" and token.meta:
118+
ref_list.append(token)
119+
if hasattr(token, "children") and token.children:
120+
for child in token.children:
121+
_collect_refs(child, ref_list)

mdformat_footnote/plugin.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@
88
from mdformat.renderer.typing import Render
99
from mdit_py_plugins.footnote import footnote_plugin
1010

11+
from ._reorder import reorder_footnotes_by_definition
12+
1113

1214
def update_mdit(mdit: MarkdownIt) -> None:
1315
"""Update the parser, adding the footnote plugin."""
1416
mdit.use(footnote_plugin)
1517
# Disable inline footnotes for now, since we don't have rendering
1618
# support for them yet.
1719
mdit.disable("footnote_inline")
20+
# Reorder footnotes to match definition order and preserve orphans.
21+
# Must run before footnote_tail.
22+
mdit.core.ruler.before(
23+
"footnote_tail", "reorder_footnotes", reorder_footnotes_by_definition
24+
)
1825

1926

2027
def _footnote_ref_renderer(node: RenderTreeNode, context: RenderContext) -> str:
@@ -25,18 +32,47 @@ def _footnote_renderer(node: RenderTreeNode, context: RenderContext) -> str:
2532
first_line = f"[^{node.meta['label']}]:"
2633
indent = " " * 4
2734
elements = []
35+
36+
first_child_idx = 0
37+
while (
38+
first_child_idx < len(node.children)
39+
and node.children[first_child_idx].type == "footnote_anchor"
40+
):
41+
first_child_idx += 1
42+
43+
if (
44+
first_child_idx < len(node.children)
45+
and node.children[first_child_idx].type == "paragraph"
46+
):
47+
with context.indented(len(first_line) + 1):
48+
first_element = node.children[first_child_idx].render(context)
49+
50+
first_element_lines = first_element.split("\n")
51+
first_para_first_line = first_element_lines[0]
52+
first_para_rest_lines = first_element_lines[1:]
53+
54+
with context.indented(len(indent)):
55+
for child in node.children[first_child_idx + 1 :]:
56+
if child.type == "footnote_anchor":
57+
continue
58+
elements.append(child.render(context))
59+
60+
result = first_line + " " + first_para_first_line
61+
if first_para_rest_lines:
62+
indented_rest = textwrap.indent("\n".join(first_para_rest_lines), indent)
63+
result += "\n" + indented_rest
64+
if elements:
65+
result += "\n\n" + textwrap.indent("\n\n".join(elements), indent)
66+
return result
67+
2868
with context.indented(len(indent)):
2969
for child in node.children:
3070
if child.type == "footnote_anchor":
3171
continue
3272
elements.append(child.render(context))
3373
body = textwrap.indent("\n\n".join(elements), indent)
34-
# if the first body element is a paragraph, we can start on the first line,
35-
# otherwise we start on the second line
36-
if body and node.children and node.children[0].type != "paragraph":
74+
if body:
3775
body = "\n" + body
38-
else:
39-
body = " " + body.lstrip()
4076
return first_line + body
4177

4278

tests/fixtures.md

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ Empty footnote
5656
.
5757
Here is a footnote reference [^emptynote]
5858

59-
[^emptynote]:
59+
[^emptynote]:
6060
.
6161
Here is a footnote reference [^emptynote]
6262

63-
[^emptynote]:
63+
[^emptynote]:
6464
.
6565

6666

@@ -116,3 +116,90 @@ unindented next line
116116
content
117117
```
118118
.
119+
120+
121+
footnote-ref-inside-footnote (issue #7)
122+
.
123+
[^a]: lorem
124+
[^c]: ipsum [^a]
125+
.
126+
[^a]: lorem
127+
128+
[^c]: ipsum [^a]
129+
.
130+
131+
132+
nested-footnote-refs (issue #8)
133+
.
134+
[^a]: Lorem. [^b]
135+
136+
[^b]: Ipsum.
137+
138+
A [^b]
139+
.
140+
A [^b]
141+
142+
[^a]: Lorem. [^b]
143+
144+
[^b]: Ipsum.
145+
.
146+
147+
148+
Footnote in table nested in admonition (issue #22)
149+
.
150+
# Document
151+
152+
| Color |
153+
| ------ |
154+
| R [^1] |
155+
| G [^2] |
156+
| B [^3] |
157+
158+
```{tip}
159+
| Color |
160+
| ------ |
161+
| C [^4] |
162+
| M [^5] |
163+
| Y [^6] |
164+
```
165+
166+
[^1]: Red
167+
168+
[^2]: Green
169+
170+
[^3]: Blue
171+
172+
[^4]: Cyan
173+
174+
[^5]: Magenta
175+
176+
[^6]: Yellow
177+
.
178+
# Document
179+
180+
| Color |
181+
| ------ |
182+
| R [^1] |
183+
| G [^2] |
184+
| B [^3] |
185+
186+
```{tip}
187+
| Color |
188+
| ------ |
189+
| C [^4] |
190+
| M [^5] |
191+
| Y [^6] |
192+
```
193+
194+
[^1]: Red
195+
196+
[^2]: Green
197+
198+
[^3]: Blue
199+
200+
[^4]: Cyan
201+
202+
[^5]: Magenta
203+
204+
[^6]: Yellow
205+
.

tests/test_word_wrap.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ def test_word_wrap():
1313
expected_output = """\
1414
[^a]
1515
16-
[^a]: Ooh no, the first line of this first
17-
paragraph is still wrapped too wide
18-
unfortunately. Should fix this.
16+
[^a]: Ooh no, the first line of this
17+
first paragraph is still wrapped
18+
too wide unfortunately. Should fix
19+
this.
1920
2021
But this second paragraph is wrapped
2122
exactly as expected. Woohooo,

0 commit comments

Comments
 (0)