index.py 6.7 KB

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