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