1import re
2from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Match, Optional, Tuple
3
4from ..util import strip_end
5
6if TYPE_CHECKING:
7 from ..block_parser import BlockParser
8 from ..core import BaseRenderer, BlockState
9 from ..markdown import Markdown
10
11__all__ = ["def_list"]
12
13# https://michelf.ca/projects/php-markdown/extra/#def-list
14
15DEF_PATTERN = r"^:[ \t]+.*(?:\n|$)"
16DD_START_RE = re.compile(r"^:[ \t]+", re.M)
17DD_MARKER_RE = re.compile(r"^:[ \t]")
18TRIM_RE = re.compile(r"^ {0,4}", re.M)
19HAS_BLANK_LINE_RE = re.compile(r"\n[ \t]*\n$")
20
21
22def parse_def_list(block: "BlockParser", m: Match[str], state: "BlockState") -> Optional[int]:
23 head = _get_previous_paragraph(state)
24 if head is None:
25 return None
26
27 definitions, end_pos = _collect_definitions(state.src, state.cursor, state.cursor_max, head[1])
28 if not definitions:
29 return None
30
31 children = list(_parse_def_item(block, head[0], definitions))
32 _replace_previous_paragraph(state, children)
33 return end_pos
34
35
36def _parse_def_item(
37 block: "BlockParser",
38 head: str,
39 definitions: List[Tuple[str, bool]],
40) -> Iterable[Dict[str, Any]]:
41 for line in head.splitlines():
42 yield {
43 "type": "def_list_head",
44 "text": line,
45 }
46
47 for text, loose in definitions:
48 children = _process_text(block, text, loose)
49 yield {
50 "type": "def_list_item",
51 "children": children,
52 }
53
54
55def _process_text(block: "BlockParser", text: str, loose: bool) -> List[Any]:
56 text = TRIM_RE.sub("", text)
57 state = block.state_cls()
58 state.process(strip_end(text))
59 # use default list rules
60 block.parse(state, block.list_rules)
61 tokens = state.tokens
62 if not loose and len(tokens) == 1 and tokens[0]["type"] == "paragraph":
63 tokens[0]["type"] = "block_text"
64 return tokens
65
66
67def _get_previous_paragraph(state: "BlockState") -> Optional[Tuple[str, bool]]:
68 if not state.tokens:
69 return None
70
71 last_token = state.tokens[-1]
72 if last_token["type"] == "paragraph":
73 return last_token["text"], False
74
75 if last_token["type"] == "blank_line" and len(state.tokens) > 1:
76 prev_token = state.tokens[-2]
77 if prev_token["type"] == "paragraph":
78 return prev_token["text"], True
79
80 return None
81
82
83def _replace_previous_paragraph(state: "BlockState", children: List[Dict[str, Any]]) -> None:
84 if state.tokens[-1]["type"] == "blank_line":
85 state.tokens.pop()
86 state.tokens.pop()
87
88 if state.tokens and state.tokens[-1]["type"] == "def_list":
89 state.tokens[-1]["children"].extend(children)
90 else:
91 state.append_token(
92 {
93 "type": "def_list",
94 "children": children,
95 }
96 )
97
98
99def _collect_definitions(src: str, pos: int, max_pos: int, loose: bool) -> Tuple[List[Tuple[str, bool]], int]:
100 definitions = []
101 while pos < max_pos:
102 line = _get_line(src, pos, max_pos)
103 if not DD_START_RE.match(line):
104 break
105
106 start = pos
107 pos += len(line)
108 pos = _scan_definition_tail(src, pos, max_pos)
109 # drop the ':' marker plus its trailing space or tab; a tab here would
110 # otherwise survive TRIM_RE (spaces only) and make the line a code block
111 text = DD_MARKER_RE.sub(" ", src[start:pos], count=1)
112 definitions.append((text, loose))
113 loose = bool(HAS_BLANK_LINE_RE.search(text))
114
115 return definitions, pos
116
117
118def _scan_definition_tail(src: str, pos: int, max_pos: int) -> int:
119 while pos < max_pos:
120 line = _get_line(src, pos, max_pos)
121 if DD_START_RE.match(line):
122 break
123
124 if line.strip():
125 pos += len(line)
126 continue
127
128 while pos < max_pos:
129 line = _get_line(src, pos, max_pos)
130 if line.strip():
131 break
132 pos += len(line)
133
134 if pos >= max_pos:
135 break
136
137 line = _get_line(src, pos, max_pos)
138 if DD_START_RE.match(line):
139 break
140 if line.startswith((" ", "\t")):
141 continue
142 return pos
143
144 return pos
145
146
147def _get_line(src: str, pos: int, max_pos: int) -> str:
148 end = src.find("\n", pos, max_pos)
149 if end == -1:
150 return src[pos:max_pos]
151 return src[pos : end + 1]
152
153
154def render_def_list(renderer: "BaseRenderer", text: str) -> str:
155 return "<dl>\n" + text + "</dl>\n"
156
157
158def render_def_list_head(renderer: "BaseRenderer", text: str) -> str:
159 return "<dt>" + text + "</dt>\n"
160
161
162def render_def_list_item(renderer: "BaseRenderer", text: str) -> str:
163 return "<dd>" + text + "</dd>\n"
164
165
166def def_list(md: "Markdown") -> None:
167 """A mistune plugin to support def list, spec defined at
168 https://michelf.ca/projects/php-markdown/extra/#def-list
169
170 Here is an example:
171
172 .. code-block:: text
173
174 Apple
175 : Pomaceous fruit of plants of the genus Malus in
176 the family Rosaceae.
177
178 Orange
179 : The fruit of an evergreen tree of the genus Citrus.
180
181 It will be converted into HTML:
182
183 .. code-block:: html
184
185 <dl>
186 <dt>Apple</dt>
187 <dd>Pomaceous fruit of plants of the genus Malus in
188 the family Rosaceae.</dd>
189
190 <dt>Orange</dt>
191 <dd>The fruit of an evergreen tree of the genus Citrus.</dd>
192 </dl>
193
194 :param md: Markdown instance
195 """
196 md.block.register("def_list", DEF_PATTERN, parse_def_list, before="paragraph")
197 if md.renderer and md.renderer.NAME == "html":
198 md.renderer.register("def_list", render_def_list)
199 md.renderer.register("def_list_head", render_def_list_head)
200 md.renderer.register("def_list_item", render_def_list_item)