1# Process admonitions and pass to cb.
2
3from __future__ import annotations
4
5from collections.abc import Callable, Sequence
6from contextlib import suppress
7import re
8from typing import TYPE_CHECKING
9
10from markdown_it import MarkdownIt
11from markdown_it.rules_block import StateBlock
12
13from mdit_py_plugins.utils import is_code_block
14
15if TYPE_CHECKING:
16 from markdown_it.renderer import RendererProtocol
17 from markdown_it.token import Token
18 from markdown_it.utils import EnvType, OptionsDict
19
20
21def _get_multiple_tags(params: str) -> tuple[list[str], str]:
22 """Check for multiple tags when the title is double quoted."""
23 re_tags = re.compile(r'^\s*(?P<tokens>[^"]+)\s+"(?P<title>.*)"\S*$')
24 match = re_tags.match(params)
25 if match:
26 tags = match["tokens"].strip().split(" ")
27 return [tag.lower() for tag in tags], match["title"]
28 raise ValueError("No match found for parameters")
29
30
31def _get_tag(_params: str) -> tuple[list[str], str]:
32 """Separate the tag name from the admonition title."""
33 params = _params.strip()
34 if not params:
35 return [""], ""
36
37 with suppress(ValueError):
38 return _get_multiple_tags(params)
39
40 tag, *_title = params.split(" ")
41 joined = " ".join(_title)
42
43 title = ""
44 if not joined:
45 title = tag.title()
46 elif joined != '""': # Specifically check for no title
47 title = joined
48 return [tag.lower()], title
49
50
51def _validate(params: str) -> bool:
52 """Validate the presence of the tag name after the marker."""
53 tag = params.strip().split(" ", 1)[-1] or ""
54 return bool(tag)
55
56
57MARKER_LEN = 3 # Regardless of extra characters, block indent stays the same
58MARKERS = ("!!!", "???", "???+")
59MARKER_CHARS = {_m[0] for _m in MARKERS}
60MAX_MARKER_LEN = max(len(_m) for _m in MARKERS)
61
62
63def _extra_classes(markup: str) -> list[str]:
64 """Return the list of additional classes based on the markup."""
65 if markup.startswith("?"):
66 if markup.endswith("+"):
67 return ["is-collapsible collapsible-open"]
68 return ["is-collapsible collapsible-closed"]
69 return []
70
71
72def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool:
73 if is_code_block(state, startLine):
74 return False
75
76 start = state.bMarks[startLine] + state.tShift[startLine]
77 maximum = state.eMarks[startLine]
78
79 # Check out the first character quickly, which should filter out most of non-containers
80 if state.src[start] not in MARKER_CHARS:
81 return False
82
83 # Check out the rest of the marker string
84 marker = ""
85 marker_len = MAX_MARKER_LEN
86 while marker_len > 0:
87 marker_pos = start + marker_len
88 markup = state.src[start:marker_pos]
89 if markup in MARKERS:
90 marker = markup
91 break
92 marker_len -= 1
93 else:
94 return False
95
96 params = state.src[marker_pos:maximum]
97
98 if not _validate(params):
99 return False
100
101 # Since start is found, we can report success here in validation mode
102 if silent:
103 return True
104
105 old_parent = state.parentType
106 old_line_max = state.lineMax
107 old_indent = state.blkIndent
108
109 blk_start = marker_pos
110 while blk_start < maximum and state.src[blk_start] == " ":
111 blk_start += 1
112
113 state.parentType = "admonition"
114 # Correct block indentation when extra marker characters are present
115 marker_alignment_correction = MARKER_LEN - len(marker)
116 state.blkIndent += blk_start - start + marker_alignment_correction
117
118 was_empty = False
119
120 # Search for the end of the block
121 next_line = startLine
122 while True:
123 next_line += 1
124 if next_line >= endLine:
125 # unclosed block should be autoclosed by end of document.
126 # also block seems to be autoclosed by end of parent
127 break
128 pos = state.bMarks[next_line] + state.tShift[next_line]
129 maximum = state.eMarks[next_line]
130 is_empty = state.sCount[next_line] < state.blkIndent
131
132 # two consecutive empty lines autoclose the block
133 if is_empty and was_empty:
134 break
135 was_empty = is_empty
136
137 if pos < maximum and state.sCount[next_line] < state.blkIndent:
138 # non-empty line with negative indent should stop the block:
139 # - !!!
140 # test
141 break
142
143 # this will prevent lazy continuations from ever going past our end marker
144 state.lineMax = next_line
145
146 tags, title = _get_tag(params)
147 tag = tags[0]
148
149 token = state.push("admonition_open", "div", 1)
150 token.markup = markup
151 token.block = True
152 token.attrs = {"class": " ".join(["admonition", *tags, *_extra_classes(markup)])}
153 token.meta = {"tag": tag}
154 token.content = title
155 token.info = params
156 token.map = [startLine, next_line]
157
158 if title:
159 title_markup = f"{markup} {tag}"
160 token = state.push("admonition_title_open", "p", 1)
161 token.markup = title_markup
162 token.attrs = {"class": "admonition-title"}
163 token.map = [startLine, startLine + 1]
164
165 token = state.push("inline", "", 0)
166 token.content = title
167 token.map = [startLine, startLine + 1]
168 token.children = []
169
170 token = state.push("admonition_title_close", "p", -1)
171
172 state.md.block.tokenize(state, startLine + 1, next_line)
173
174 token = state.push("admonition_close", "div", -1)
175 token.markup = markup
176 token.block = True
177
178 state.parentType = old_parent
179 state.lineMax = old_line_max
180 state.blkIndent = old_indent
181 state.line = next_line
182
183 return True
184
185
186def admon_plugin(md: MarkdownIt, render: None | Callable[..., str] = None) -> None:
187 """Plugin to use
188 `python-markdown style admonitions
189 <https://python-markdown.github.io/extensions/admonition>`_.
190
191 .. code-block:: md
192
193 !!! note
194 *content*
195
196 `And mkdocs-style collapsible blocks
197 <https://squidfunk.github.io/mkdocs-material/reference/admonitions/#collapsible-blocks>`_.
198
199 .. code-block:: md
200
201 ???+ note
202 *content*
203
204 Note, this is ported from
205 `markdown-it-admon
206 <https://github.com/commenthol/markdown-it-admon>`_.
207 """
208
209 def renderDefault(
210 self: RendererProtocol,
211 tokens: Sequence[Token],
212 idx: int,
213 _options: OptionsDict,
214 env: EnvType,
215 ) -> str:
216 return self.renderToken(tokens, idx, _options, env) # type: ignore[attr-defined,no-any-return]
217
218 render = render or renderDefault
219
220 md.add_render_rule("admonition_open", render)
221 md.add_render_rule("admonition_close", render)
222 md.add_render_rule("admonition_title_open", render)
223 md.add_render_rule("admonition_title_close", render)
224
225 md.block.ruler.before(
226 "fence",
227 "admonition",
228 admonition,
229 {"alt": ["paragraph", "reference", "blockquote", "list"]},
230 )