Skip to content

Commit 33c27e0

Browse files
authored
👌 Add option for footnotes references to always be matched (#108)
Usually footnote references are only matched when a footnote definition of the same label has already been found. If `always_match_refs=True`, any `[^...]` syntax will be treated as a footnote.
1 parent 7762458 commit 33c27e0

File tree

3 files changed

+90
-29
lines changed

3 files changed

+90
-29
lines changed

‎mdit_py_plugins/footnote/index.py

+66-27
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Sequence
5+
from functools import partial
6+
from typing import TYPE_CHECKING, Sequence, TypedDict
67

78
from markdown_it import MarkdownIt
89
from markdown_it.helpers import parseLinkLabel
@@ -18,7 +19,13 @@
1819
from markdown_it.utils import EnvType, OptionsDict
1920

2021

21-
def footnote_plugin(md: MarkdownIt) -> None:
22+
def footnote_plugin(
23+
md: MarkdownIt,
24+
*,
25+
inline: bool = True,
26+
move_to_end: bool = True,
27+
always_match_refs: bool = False,
28+
) -> None:
2229
"""Plugin ported from
2330
`markdown-it-footnote <https://github.com/markdown-it/markdown-it-footnote>`__.
2431
@@ -38,13 +45,22 @@ def footnote_plugin(md: MarkdownIt) -> None:
3845
Subsequent paragraphs are indented to show that they
3946
belong to the previous footnote.
4047
48+
:param inline: If True, also parse inline footnotes (^[...]).
49+
:param move_to_end: If True, move footnote definitions to the end of the token stream.
50+
:param always_match_refs: If True, match references, even if the footnote is not defined.
51+
4152
"""
4253
md.block.ruler.before(
4354
"reference", "footnote_def", footnote_def, {"alt": ["paragraph", "reference"]}
4455
)
45-
md.inline.ruler.after("image", "footnote_inline", footnote_inline)
46-
md.inline.ruler.after("footnote_inline", "footnote_ref", footnote_ref)
47-
md.core.ruler.after("inline", "footnote_tail", footnote_tail)
56+
_footnote_ref = partial(footnote_ref, always_match=always_match_refs)
57+
if inline:
58+
md.inline.ruler.after("image", "footnote_inline", footnote_inline)
59+
md.inline.ruler.after("footnote_inline", "footnote_ref", _footnote_ref)
60+
else:
61+
md.inline.ruler.after("image", "footnote_ref", _footnote_ref)
62+
if move_to_end:
63+
md.core.ruler.after("inline", "footnote_tail", footnote_tail)
4864

4965
md.add_render_rule("footnote_ref", render_footnote_ref)
5066
md.add_render_rule("footnote_block_open", render_footnote_block_open)
@@ -58,6 +74,29 @@ def footnote_plugin(md: MarkdownIt) -> None:
5874
md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name)
5975

6076

77+
class _RefData(TypedDict, total=False):
78+
# standard
79+
label: str
80+
count: int
81+
# inline
82+
content: str
83+
tokens: list[Token]
84+
85+
86+
class _FootnoteData(TypedDict):
87+
refs: dict[str, int]
88+
"""A mapping of all footnote labels (prefixed with ``:``) to their ID (-1 if not yet set)."""
89+
list: dict[int, _RefData]
90+
"""A mapping of all footnote IDs to their data."""
91+
92+
93+
def _data_from_env(env: EnvType) -> _FootnoteData:
94+
footnotes = env.setdefault("footnotes", {})
95+
footnotes.setdefault("refs", {})
96+
footnotes.setdefault("list", {})
97+
return footnotes # type: ignore[no-any-return]
98+
99+
61100
# ## RULES ##
62101

63102

@@ -97,7 +136,8 @@ def footnote_def(state: StateBlock, startLine: int, endLine: int, silent: bool)
97136
pos += 1
98137

99138
label = state.src[start + 2 : pos - 2]
100-
state.env.setdefault("footnotes", {}).setdefault("refs", {})[":" + label] = -1
139+
footnote_data = _data_from_env(state.env)
140+
footnote_data["refs"][":" + label] = -1
101141

102142
open_token = Token("footnote_reference_open", "", 1)
103143
open_token.meta = {"label": label}
@@ -182,7 +222,7 @@ def footnote_inline(state: StateInline, silent: bool) -> bool:
182222
# so all that's left to do is to call tokenizer.
183223
#
184224
if not silent:
185-
refs = state.env.setdefault("footnotes", {}).setdefault("list", {})
225+
refs = _data_from_env(state.env)["list"]
186226
footnoteId = len(refs)
187227

188228
tokens: list[Token] = []
@@ -200,7 +240,9 @@ def footnote_inline(state: StateInline, silent: bool) -> bool:
200240
return True
201241

202242

203-
def footnote_ref(state: StateInline, silent: bool) -> bool:
243+
def footnote_ref(
244+
state: StateInline, silent: bool, *, always_match: bool = False
245+
) -> bool:
204246
"""Process footnote references ([^...])"""
205247

206248
maximum = state.posMax
@@ -210,7 +252,9 @@ def footnote_ref(state: StateInline, silent: bool) -> bool:
210252
if start + 3 > maximum:
211253
return False
212254

213-
if "footnotes" not in state.env or "refs" not in state.env["footnotes"]:
255+
footnote_data = _data_from_env(state.env)
256+
257+
if not (always_match or footnote_data["refs"]):
214258
return False
215259
if state.src[start] != "[":
216260
return False
@@ -219,9 +263,7 @@ def footnote_ref(state: StateInline, silent: bool) -> bool:
219263

220264
pos = start + 2
221265
while pos < maximum:
222-
if state.src[pos] == " ":
223-
return False
224-
if state.src[pos] == "\n":
266+
if state.src[pos] in (" ", "\n"):
225267
return False
226268
if state.src[pos] == "]":
227269
break
@@ -234,22 +276,19 @@ def footnote_ref(state: StateInline, silent: bool) -> bool:
234276
pos += 1
235277

236278
label = state.src[start + 2 : pos - 1]
237-
if (":" + label) not in state.env["footnotes"]["refs"]:
279+
if ((":" + label) not in footnote_data["refs"]) and not always_match:
238280
return False
239281

240282
if not silent:
241-
if "list" not in state.env["footnotes"]:
242-
state.env["footnotes"]["list"] = {}
243-
244-
if state.env["footnotes"]["refs"][":" + label] < 0:
245-
footnoteId = len(state.env["footnotes"]["list"])
246-
state.env["footnotes"]["list"][footnoteId] = {"label": label, "count": 0}
247-
state.env["footnotes"]["refs"][":" + label] = footnoteId
283+
if footnote_data["refs"].get(":" + label, -1) < 0:
284+
footnoteId = len(footnote_data["list"])
285+
footnote_data["list"][footnoteId] = {"label": label, "count": 0}
286+
footnote_data["refs"][":" + label] = footnoteId
248287
else:
249-
footnoteId = state.env["footnotes"]["refs"][":" + label]
288+
footnoteId = footnote_data["refs"][":" + label]
250289

251-
footnoteSubId = state.env["footnotes"]["list"][footnoteId]["count"]
252-
state.env["footnotes"]["list"][footnoteId]["count"] += 1
290+
footnoteSubId = footnote_data["list"][footnoteId]["count"]
291+
footnote_data["list"][footnoteId]["count"] += 1
253292

254293
token = state.push("footnote_ref", "", 0)
255294
token.meta = {"id": footnoteId, "subId": footnoteSubId, "label": label}
@@ -295,14 +334,14 @@ def footnote_tail(state: StateCore) -> None:
295334

296335
state.tokens = [t for t, f in zip(state.tokens, tok_filter) if f]
297336

298-
if "list" not in state.env.get("footnotes", {}):
337+
footnote_data = _data_from_env(state.env)
338+
if not footnote_data["list"]:
299339
return
300-
foot_list = state.env["footnotes"]["list"]
301340

302341
token = Token("footnote_block_open", "", 1)
303342
state.tokens.append(token)
304343

305-
for i, foot_note in foot_list.items():
344+
for i, foot_note in footnote_data["list"].items():
306345
token = Token("footnote_open", "", 1)
307346
token.meta = {"id": i, "label": foot_note.get("label", None)}
308347
# TODO propagate line positions of original foot note
@@ -326,7 +365,7 @@ def footnote_tail(state: StateCore) -> None:
326365
tokens.append(token)
327366

328367
elif "label" in foot_note:
329-
tokens = refTokens[":" + foot_note["label"]]
368+
tokens = refTokens.get(":" + foot_note["label"], [])
330369

331370
state.tokens.extend(tokens)
332371
if state.tokens[len(state.tokens) - 1].type == "paragraph_close":

‎tests/fixtures/footnote.md

+20
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,23 @@ Indented by 4 spaces, DISABLE-CODEBLOCKS
372372
</ol>
373373
</section>
374374
.
375+
376+
refs with no definition standard
377+
.
378+
[^1] [^1]
379+
.
380+
<p>[^1] [^1]</p>
381+
.
382+
383+
refs with no definition, ALWAYS_MATCH-REFS
384+
.
385+
[^1] [^1]
386+
.
387+
<p><sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> <sup class="footnote-ref"><a href="#fn1" id="fnref1:1">[1:1]</a></sup></p>
388+
<hr class="footnotes-sep">
389+
<section class="footnotes">
390+
<ol class="footnotes-list">
391+
<li id="fn1" class="footnote-item"> <a href="#fnref1" class="footnote-backref">↩︎</a> <a href="#fnref1:1" class="footnote-backref">↩︎</a></li>
392+
</ol>
393+
</section>
394+
.

‎tests/test_footnote.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def test_footnote_def():
9696
"hidden": False,
9797
},
9898
]
99-
assert state.env == {"footnotes": {"refs": {":a": -1}}}
99+
assert state.env == {"footnotes": {"refs": {":a": -1}, "list": {}}}
100100

101101

102102
def test_footnote_ref():
@@ -440,7 +440,9 @@ def test_plugin_render():
440440

441441
@pytest.mark.parametrize("line,title,input,expected", read_fixture_file(FIXTURE_PATH))
442442
def test_all(line, title, input, expected):
443-
md = MarkdownIt("commonmark").use(footnote_plugin)
443+
md = MarkdownIt().use(
444+
footnote_plugin, always_match_refs="ALWAYS_MATCH-REFS" in title
445+
)
444446
if "DISABLE-CODEBLOCKS" in title:
445447
md.disable("code")
446448
md.options["xhtmlOut"] = False

0 commit comments

Comments
 (0)