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