1"""Process footnotes"""
2
3from __future__ import annotations
4
5from collections.abc import Sequence
6from functools import partial
7from typing import TYPE_CHECKING, TypedDict
8
9from markdown_it import MarkdownIt
10from markdown_it.helpers import parseLinkLabel
11from markdown_it.rules_block import StateBlock
12from markdown_it.rules_core import StateCore
13from markdown_it.rules_inline import StateInline
14from markdown_it.token import Token
15
16from mdit_py_plugins.utils import is_code_block
17
18if TYPE_CHECKING:
19 from markdown_it.renderer import RendererProtocol
20 from markdown_it.utils import EnvType, OptionsDict
21
22
23def footnote_plugin(
24 md: MarkdownIt,
25 *,
26 inline: bool = True,
27 move_to_end: bool = True,
28 always_match_refs: bool = False,
29) -> None:
30 """Plugin ported from
31 `markdown-it-footnote <https://github.com/markdown-it/markdown-it-footnote>`__.
32
33 It is based on the
34 `pandoc definition <http://johnmacfarlane.net/pandoc/README.html#footnotes>`__:
35
36 .. code-block:: md
37
38 Normal footnote:
39
40 Here is a footnote reference,[^1] and another.[^longnote]
41
42 [^1]: Here is the footnote.
43
44 [^longnote]: Here's one with multiple blocks.
45
46 Subsequent paragraphs are indented to show that they
47 belong to the previous footnote.
48
49 :param inline: If True, also parse inline footnotes (^[...]).
50 :param move_to_end: If True, move footnote definitions to the end of the token stream.
51 :param always_match_refs: If True, match references, even if the footnote is not defined.
52
53 """
54 md.block.ruler.before(
55 "reference", "footnote_def", footnote_def, {"alt": ["paragraph", "reference"]}
56 )
57 _footnote_ref = partial(footnote_ref, always_match=always_match_refs)
58 if inline:
59 md.inline.ruler.after("image", "footnote_inline", footnote_inline)
60 md.inline.ruler.after("footnote_inline", "footnote_ref", _footnote_ref)
61 else:
62 md.inline.ruler.after("image", "footnote_ref", _footnote_ref)
63 if move_to_end:
64 md.core.ruler.after("inline", "footnote_tail", footnote_tail)
65
66 md.add_render_rule("footnote_ref", render_footnote_ref)
67 md.add_render_rule("footnote_block_open", render_footnote_block_open)
68 md.add_render_rule("footnote_block_close", render_footnote_block_close)
69 md.add_render_rule("footnote_open", render_footnote_open)
70 md.add_render_rule("footnote_close", render_footnote_close)
71 md.add_render_rule("footnote_anchor", render_footnote_anchor)
72
73 # helpers (only used in other rules, no tokens are attached to those)
74 md.add_render_rule("footnote_caption", render_footnote_caption)
75 md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name)
76
77
78class _RefData(TypedDict, total=False):
79 # standard
80 label: str
81 count: int
82 # inline
83 content: str
84 tokens: list[Token]
85
86
87class _FootnoteData(TypedDict):
88 refs: dict[str, int]
89 """A mapping of all footnote labels (prefixed with ``:``) to their ID (-1 if not yet set)."""
90 list: dict[int, _RefData]
91 """A mapping of all footnote IDs to their data."""
92
93
94def _data_from_env(env: EnvType) -> _FootnoteData:
95 footnotes = env.setdefault("footnotes", {})
96 footnotes.setdefault("refs", {})
97 footnotes.setdefault("list", {})
98 return footnotes # type: ignore[no-any-return]
99
100
101# ## RULES ##
102
103
104def footnote_def(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool:
105 """Process footnote block definition"""
106
107 if is_code_block(state, startLine):
108 return False
109
110 start = state.bMarks[startLine] + state.tShift[startLine]
111 maximum = state.eMarks[startLine]
112
113 # line should be at least 5 chars - "[^x]:"
114 if start + 4 > maximum:
115 return False
116
117 if state.src[start] != "[":
118 return False
119 if state.src[start + 1] != "^":
120 return False
121
122 pos = start + 2
123 while pos < maximum:
124 if state.src[pos] == " ":
125 return False
126 if state.src[pos] == "]":
127 break
128 pos += 1
129
130 if pos == start + 2: # no empty footnote labels
131 return False
132 pos += 1
133 if pos >= maximum or state.src[pos] != ":":
134 return False
135 if silent:
136 return True
137 pos += 1
138
139 label = state.src[start + 2 : pos - 2]
140 footnote_data = _data_from_env(state.env)
141 footnote_data["refs"][":" + label] = -1
142
143 open_token = Token("footnote_reference_open", "", 1)
144 open_token.meta = {"label": label}
145 open_token.level = state.level
146 state.level += 1
147 state.tokens.append(open_token)
148
149 oldBMark = state.bMarks[startLine]
150 oldTShift = state.tShift[startLine]
151 oldSCount = state.sCount[startLine]
152 oldParentType = state.parentType
153
154 posAfterColon = pos
155 initial = offset = (
156 state.sCount[startLine]
157 + pos
158 - (state.bMarks[startLine] + state.tShift[startLine])
159 )
160
161 while pos < maximum:
162 ch = state.src[pos]
163
164 if ch == "\t":
165 offset += 4 - offset % 4
166 elif ch == " ":
167 offset += 1
168
169 else:
170 break
171
172 pos += 1
173
174 state.tShift[startLine] = pos - posAfterColon
175 state.sCount[startLine] = offset - initial
176
177 state.bMarks[startLine] = posAfterColon
178 state.blkIndent += 4
179 state.parentType = "footnote"
180
181 if state.sCount[startLine] < state.blkIndent:
182 state.sCount[startLine] += state.blkIndent
183
184 state.md.block.tokenize(state, startLine, endLine)
185
186 state.parentType = oldParentType
187 state.blkIndent -= 4
188 state.tShift[startLine] = oldTShift
189 state.sCount[startLine] = oldSCount
190 state.bMarks[startLine] = oldBMark
191
192 open_token.map = [startLine, state.line]
193
194 token = Token("footnote_reference_close", "", -1)
195 state.level -= 1
196 token.level = state.level
197 state.tokens.append(token)
198
199 return True
200
201
202def footnote_inline(state: StateInline, silent: bool) -> bool:
203 """Process inline footnotes (^[...])"""
204
205 maximum = state.posMax
206 start = state.pos
207
208 if start + 2 >= maximum:
209 return False
210 if state.src[start] != "^":
211 return False
212 if state.src[start + 1] != "[":
213 return False
214
215 labelStart = start + 2
216 labelEnd = parseLinkLabel(state, start + 1)
217
218 # parser failed to find ']', so it's not a valid note
219 if labelEnd < 0:
220 return False
221
222 # We found the end of the link, and know for a fact it's a valid link
223 # so all that's left to do is to call tokenizer.
224 #
225 if not silent:
226 refs = _data_from_env(state.env)["list"]
227 footnoteId = len(refs)
228
229 tokens: list[Token] = []
230 state.md.inline.parse(
231 state.src[labelStart:labelEnd], state.md, state.env, tokens
232 )
233
234 token = state.push("footnote_ref", "", 0)
235 token.meta = {"id": footnoteId}
236
237 refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens}
238
239 state.pos = labelEnd + 1
240 state.posMax = maximum
241 return True
242
243
244def footnote_ref(
245 state: StateInline, silent: bool, *, always_match: bool = False
246) -> bool:
247 """Process footnote references ([^...])"""
248
249 maximum = state.posMax
250 start = state.pos
251
252 # should be at least 4 chars - "[^x]"
253 if start + 3 > maximum:
254 return False
255
256 footnote_data = _data_from_env(state.env)
257
258 if not (always_match or footnote_data["refs"]):
259 return False
260 if state.src[start] != "[":
261 return False
262 if state.src[start + 1] != "^":
263 return False
264
265 pos = start + 2
266 while pos < maximum:
267 if state.src[pos] in (" ", "\n"):
268 return False
269 if state.src[pos] == "]":
270 break
271 pos += 1
272
273 if pos == start + 2: # no empty footnote labels
274 return False
275 if pos >= maximum:
276 return False
277 pos += 1
278
279 label = state.src[start + 2 : pos - 1]
280 if ((":" + label) not in footnote_data["refs"]) and not always_match:
281 return False
282
283 if not silent:
284 if footnote_data["refs"].get(":" + label, -1) < 0:
285 footnoteId = len(footnote_data["list"])
286 footnote_data["list"][footnoteId] = {"label": label, "count": 0}
287 footnote_data["refs"][":" + label] = footnoteId
288 else:
289 footnoteId = footnote_data["refs"][":" + label]
290
291 footnoteSubId = footnote_data["list"][footnoteId]["count"]
292 footnote_data["list"][footnoteId]["count"] += 1
293
294 token = state.push("footnote_ref", "", 0)
295 token.meta = {"id": footnoteId, "subId": footnoteSubId, "label": label}
296
297 state.pos = pos
298 state.posMax = maximum
299 return True
300
301
302def footnote_tail(state: StateCore) -> None:
303 """Post-processing step, to move footnote tokens to end of the token stream.
304
305 Also removes un-referenced tokens.
306 """
307
308 insideRef = False
309 refTokens = {}
310
311 if "footnotes" not in state.env:
312 return
313
314 current: list[Token] = []
315 tok_filter = []
316 for tok in state.tokens:
317 if tok.type == "footnote_reference_open":
318 insideRef = True
319 current = []
320 currentLabel = tok.meta["label"]
321 tok_filter.append(False)
322 continue
323
324 if tok.type == "footnote_reference_close":
325 insideRef = False
326 # prepend ':' to avoid conflict with Object.prototype members
327 refTokens[":" + currentLabel] = current
328 tok_filter.append(False)
329 continue
330
331 if insideRef:
332 current.append(tok)
333
334 tok_filter.append(not insideRef)
335
336 state.tokens = [t for t, f in zip(state.tokens, tok_filter, strict=False) if f]
337
338 footnote_data = _data_from_env(state.env)
339 if not footnote_data["list"]:
340 return
341
342 token = Token("footnote_block_open", "", 1)
343 state.tokens.append(token)
344
345 for i, foot_note in footnote_data["list"].items():
346 token = Token("footnote_open", "", 1)
347 token.meta = {"id": i, "label": foot_note.get("label", None)}
348 # TODO propagate line positions of original foot note
349 # (but don't store in token.map, because this is used for scroll syncing)
350 state.tokens.append(token)
351
352 if "tokens" in foot_note:
353 tokens = []
354
355 token = Token("paragraph_open", "p", 1)
356 token.block = True
357 tokens.append(token)
358
359 token = Token("inline", "", 0)
360 token.children = foot_note["tokens"]
361 token.content = foot_note["content"]
362 tokens.append(token)
363
364 token = Token("paragraph_close", "p", -1)
365 token.block = True
366 tokens.append(token)
367
368 elif "label" in foot_note:
369 tokens = refTokens.get(":" + foot_note["label"], [])
370
371 state.tokens.extend(tokens)
372 if state.tokens[len(state.tokens) - 1].type == "paragraph_close":
373 lastParagraph: Token | None = state.tokens.pop()
374 else:
375 lastParagraph = None
376
377 t = (
378 foot_note["count"]
379 if (("count" in foot_note) and (foot_note["count"] > 0))
380 else 1
381 )
382 j = 0
383 while j < t:
384 token = Token("footnote_anchor", "", 0)
385 token.meta = {"id": i, "subId": j, "label": foot_note.get("label", None)}
386 state.tokens.append(token)
387 j += 1
388
389 if lastParagraph:
390 state.tokens.append(lastParagraph)
391
392 token = Token("footnote_close", "", -1)
393 state.tokens.append(token)
394
395 token = Token("footnote_block_close", "", -1)
396 state.tokens.append(token)
397
398
399########################################
400# Renderer partials
401
402
403def render_footnote_anchor_name(
404 self: RendererProtocol,
405 tokens: Sequence[Token],
406 idx: int,
407 options: OptionsDict,
408 env: EnvType,
409) -> str:
410 n = str(tokens[idx].meta["id"] + 1)
411 prefix = ""
412
413 doc_id = env.get("docId", None)
414 if isinstance(doc_id, str):
415 prefix = f"-{doc_id}-"
416
417 return prefix + n
418
419
420def render_footnote_caption(
421 self: RendererProtocol,
422 tokens: Sequence[Token],
423 idx: int,
424 options: OptionsDict,
425 env: EnvType,
426) -> str:
427 n = str(tokens[idx].meta["id"] + 1)
428
429 if tokens[idx].meta.get("subId", -1) > 0:
430 n += ":" + str(tokens[idx].meta["subId"])
431
432 return "[" + n + "]"
433
434
435def render_footnote_ref(
436 self: RendererProtocol,
437 tokens: Sequence[Token],
438 idx: int,
439 options: OptionsDict,
440 env: EnvType,
441) -> str:
442 ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined]
443 caption: str = self.rules["footnote_caption"](tokens, idx, options, env) # type: ignore[attr-defined]
444 refid = ident
445
446 if tokens[idx].meta.get("subId", -1) > 0:
447 refid += ":" + str(tokens[idx].meta["subId"])
448
449 return (
450 '<sup class="footnote-ref"><a href="#fn'
451 + ident
452 + '" id="fnref'
453 + refid
454 + '">'
455 + caption
456 + "</a></sup>"
457 )
458
459
460def render_footnote_block_open(
461 self: RendererProtocol,
462 tokens: Sequence[Token],
463 idx: int,
464 options: OptionsDict,
465 env: EnvType,
466) -> str:
467 return (
468 (
469 '<hr class="footnotes-sep" />\n'
470 if options.xhtmlOut
471 else '<hr class="footnotes-sep">\n'
472 )
473 + '<section class="footnotes">\n'
474 + '<ol class="footnotes-list">\n'
475 )
476
477
478def render_footnote_block_close(
479 self: RendererProtocol,
480 tokens: Sequence[Token],
481 idx: int,
482 options: OptionsDict,
483 env: EnvType,
484) -> str:
485 return "</ol>\n</section>\n"
486
487
488def render_footnote_open(
489 self: RendererProtocol,
490 tokens: Sequence[Token],
491 idx: int,
492 options: OptionsDict,
493 env: EnvType,
494) -> str:
495 ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined]
496
497 if tokens[idx].meta.get("subId", -1) > 0:
498 ident += ":" + tokens[idx].meta["subId"]
499
500 return '<li id="fn' + ident + '" class="footnote-item">'
501
502
503def render_footnote_close(
504 self: RendererProtocol,
505 tokens: Sequence[Token],
506 idx: int,
507 options: OptionsDict,
508 env: EnvType,
509) -> str:
510 return "</li>\n"
511
512
513def render_footnote_anchor(
514 self: RendererProtocol,
515 tokens: Sequence[Token],
516 idx: int,
517 options: OptionsDict,
518 env: EnvType,
519) -> str:
520 ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined]
521
522 if tokens[idx].meta["subId"] > 0:
523 ident += ":" + str(tokens[idx].meta["subId"])
524
525 # ↩ with escape code to prevent display as Apple Emoji on iOS
526 return ' <a href="#fnref' + ident + '" class="footnote-backref">\u21a9\ufe0e</a>'