Markdown.py

Summary

Lines:

326 of 342 covered (95.3%)

Excluded lines:

3

Branches:

175 of 192 taken (91.1%), 13 lines partially

Coverage:

93.8%

Directory:

Sphinx

Missed lines:

322-325, 341-342, 397, 444, 466, 551-553, 555-556, 559, 615

Source

pyTooling/Sphinx/Markdown.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2#             _____           _ _               ____        _     _                                                    #
3#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
7# |_|    |___/                          |___/        |_|                                                               #
8# ==================================================================================================================== #
9# Authors:                                                                                                             #
10#   Patrick Lehmann                                                                                                    #
11#                                                                                                                      #
12# License:                                                                                                             #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany                                                             #
15#                                                                                                                      #
16# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
17# you may not use this file except in compliance with the License.                                                     #
18# You may obtain a copy of the License at                                                                              #
19#                                                                                                                      #
20#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
21#                                                                                                                      #
22# Unless required by applicable law or agreed to in writing, software                                                  #
23# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
25# See the License for the specific language governing permissions and                                                  #
26# limitations under the License.                                                                                       #
27#                                                                                                                      #
28# SPDX-License-Identifier: Apache-2.0                                                                                  #
29# ==================================================================================================================== #
30#
31"""
32A translator of Markdown tokens - and of the raw HTML in between - into docutils nodes.
33
34:class:`MarkdownTranslator` translates the tokens markdown-it-py parses into the nodes a directive returns, for a
35:class:`~pyTooling.Sphinx.GitHubMarkdown.GitHubMarkdown` converter, which links GitHub's references in the text.
36
37.. rubric:: Headings
38
39A heading must not change the sections of the page the Markdown is put into. A heading becomes a
40:class:`~docutils.nodes.topic`, titled by the heading and holding what follows, up to the next heading of the same or a
41higher level; a deeper heading inside the topic becomes a :class:`~docutils.nodes.rubric`, as does a heading inside a
42list or block quote.
43
44.. rubric:: Alerts
45
46A GitHub alert - ``> [!NOTE]`` - becomes the admonition of its kind (see :data:`ALERTS`), whose title the writer
47writes.
48
49.. rubric:: Raw HTML
50
51Markdown may hold HTML, which :class:`RawHTMLParser` reads. The elements with a docutils counterpart are translated
52(see :data:`HTML_ELEMENTS`), e.g. ``<ul>``, ``<strong>``, ``<a href>`` or ``<details>``, and ``<br>``, ``<img>`` and
53``<hr>``; an element without one is reported by a warning of type ``github`` and subtype ``markdown``, and its content
54is kept as if the element wasn't there. A comment is skipped, as GitHub doesn't show it either.
55
56.. rubric:: Nodes of their own
57
58Task list checkboxes and line breaks are nodes of their own (:class:`~pyTooling.Sphinx.Node.TaskListCheckbox`,
59:class:`~pyTooling.Sphinx.Node.LineBreak`), which every format writes in its own way. A horizontal rule is written as
60the ``|hr|`` substitution writes it, as docutils allows a transition only between sections' body elements.
61"""
62from __future__                        import annotations
63
64from html.parser                       import HTMLParser
65from typing                            import TYPE_CHECKING, Optional as Nullable
66
67from docutils                          import nodes
68from sphinx.util.logging               import getLogger
69
70from pyTooling.Decorators              import export
71from pyTooling.MetaClasses             import ExtendedType
72from pyTooling.Sphinx.Node             import LineBreak, TaskListCheckbox
73from pyTooling.Sphinx.Roles            import BREAK_ROLES
74
75if TYPE_CHECKING:  # pragma: no cover
76	from markdown_it.token               import Token
77
78	from pyTooling.Sphinx.GitHubMarkdown import GitHubMarkdown
79
80
81__all__ = ["IMAGE_CLASS", "ALERTS", "HTML_ELEMENTS"]
82
83#: The CSS class of every image the translator writes, by which
84#: :class:`~pyTooling.Sphinx.GitHubMarkdown.RasterizeShieldsBadges` finds them.
85IMAGE_CLASS = "github-image"
86
87#: The admonition each kind of GitHub alert becomes, by the kind as written: ``> [!NOTE]``.
88ALERTS: dict[str, type[nodes.Admonition]] = {
89	"NOTE":      nodes.note,
90	"TIP":       nodes.tip,
91	"IMPORTANT": nodes.important,
92	"WARNING":   nodes.warning,
93	"CAUTION":   nodes.caution,
94}
95
96#: The HTML elements with a docutils counterpart: the node class, and the CSS classes it gets. An element mapped to
97#: ``None`` is transparent: its content is kept, the element has no node of its own.
98HTML_ELEMENTS: dict[str, tuple[Nullable[type[nodes.Element]], tuple[str, ...]]] = {
99	"p":          (nodes.paragraph,       ()),
100	"ul":         (nodes.bullet_list,     ()),
101	"ol":         (nodes.enumerated_list, ()),
102	"li":         (nodes.list_item,       ()),
103	"blockquote": (nodes.block_quote,     ()),
104	"pre":        (nodes.literal_block,   ()),
105	"details":    (nodes.container,       ("github-details", )),
106	"summary":    (nodes.rubric,          ("github-summary", )),
107	"h1":         (nodes.rubric,          ("github-heading", )),
108	"h2":         (nodes.rubric,          ("github-heading", )),
109	"h3":         (nodes.rubric,          ("github-heading", )),
110	"h4":         (nodes.rubric,          ("github-heading", )),
111	"h5":         (nodes.rubric,          ("github-heading", )),
112	"h6":         (nodes.rubric,          ("github-heading", )),
113	"strong":     (nodes.strong,          ()),
114	"b":          (nodes.strong,          ()),
115	"em":         (nodes.emphasis,        ()),
116	"i":          (nodes.emphasis,        ()),
117	"code":       (nodes.literal,         ()),
118	"tt":         (nodes.literal,         ()),
119	"kbd":        (nodes.literal,         ("kbd", )),
120	"sub":        (nodes.subscript,       ()),
121	"sup":        (nodes.superscript,     ()),
122	"del":        (nodes.inline,          ("strike", )),
123	"s":          (nodes.inline,          ("strike", )),
124	"strike":     (nodes.inline,          ("strike", )),
125	"ins":        (nodes.inline,          ("underline", )),
126	"u":          (nodes.inline,          ("underline", )),
127	"a":          (nodes.reference,       ()),
128	"div":        (None,                  ()),
129	"span":       (None,                  ()),
130}
131
132
133@export
134class MarkdownTranslator(metaclass=ExtendedType, slots=True):
135	"""
136	A translator of Markdown tokens - and the raw HTML in between - into docutils nodes: a stack of the nodes still
137	open, which a token or an HTML element opens or closes.
138
139	Markdown's tokens nest; raw HTML may not: an element may stay open across tokens, e.g. ``<details>``, or never be
140	closed, e.g. ``<li>``. An HTML element closes only nodes opened after the innermost open token's node, and every
141	node a closing token opened later. Text directly in a list item, a table cell or a block quote - which HTML allows,
142	docutils not - is put into a paragraph.
143
144	:meth:`Translate` translates the tokens of one Markdown document; a translator is used once.
145	"""
146
147	_converter:  GitHubMarkdown                                   #: The converter, which links GitHub's references.
148	_document:   nodes.document                                   #: The document the nodes are put into.
149	_location:   Nullable[str]                                    #: Location the warnings are reported at.
150	_root:       nodes.container                                  #: The container collecting the nodes.
151	_stack:      list[nodes.Element]                              #: The open nodes, :attr:`_root` first.
152	_floors:     list[int]                                        #: Per node a token opened, the stack's length.
153	_closers:    list[int]                                        #: Per open token, the number of nodes it opened.
154	_implicit:   list[nodes.paragraph]                            #: The open paragraphs nobody wrote.
155	_html:       list[tuple[str, Nullable[nodes.Element], int]]   #: Per HTML element: tag, node, stack index.
156	_topic:      Nullable[nodes.topic]                            #: The open topic, if any.
157	_topicLevel: int                                              #: The level of the topic's heading.
158	_checkbox:   Nullable[TaskListCheckbox]                       #: A task's checkbox, until its paragraph.
159	_footnotes:  dict[int, nodes.footnote]                        #: The footnotes by the parser's number.
160	_line:       int                                              #: The Markdown line of the last block token.
161	_warned:     set[str]                                         #: The HTML elements warned about.
162
163	def __init__(self, converter: GitHubMarkdown, document: nodes.document, location: Nullable[str]) -> None:
164		"""
165		Initializes a translator for one Markdown document.
166
167		:param converter: The converter, which links GitHub's references.
168		:param document:  The document the nodes are put into.
169		:param location:  Location the warnings are reported at.
170		"""
171		self._converter =  converter
172		self._document =   document
173		self._location =   location
174		self._root =       nodes.container()
175		self._stack =      [self._root]
176		self._floors =     []
177		self._closers =    []
178		self._implicit =   []
179		self._html =       []
180		self._topic =      None
181		self._topicLevel = 0
182		self._checkbox =   None
183		self._footnotes =  {}
184		self._line =       0
185		self._warned =     set()
186
187	def Translate(self, tokens: list[Token]) -> list[nodes.Node]:
188		"""
189		Translate the tokens of a Markdown document.
190
191		An alert's title is skipped: it is the alert's kind, which the admonition's writer writes as title.
192
193		:param tokens: The tokens of the block level, as ``MarkdownIt.parse()`` returns them.
194		:returns:      The nodes.
195		"""
196		iterator = iter(tokens)
197		for token in iterator:
198			if token.map is not None:
199				self._line = token.map[0] + 1
200
201			if token.type == "alert_title_open":
202				for titleToken in iterator:
203					if titleToken.type == "alert_title_close":
204						break
205
206				continue
207
208			self._TranslateToken(token)
209
210		while len(self._stack) > 1:
211			self._Pop()
212
213		return list(self._root.children)
214
215	def _TranslateToken(self, token: Token) -> None:
216		"""
217		Translate a token: open a node for an opening token, close the nodes for a closing one, or write a node.
218
219		:param token: The token.
220		"""
221		if token.nesting == -1:
222			match token.type:
223				case "list_item_close" if self._checkbox is not None:
224					self._InlineTarget()
225				case "table_close":
226					tableGroup = self._stack[self._floors[-1] - 1]
227					columnCount = max(len(row) for row in tableGroup.findall(nodes.row))
228					tableGroup["cols"] = columnCount
229					for _ in range(columnCount):
230						tableGroup.insert(0, nodes.colspec(colwidth=1))
231
232			for _ in range(self._closers.pop()):
233				self._Close()
234
235			return
236
237		opened = 0
238		match token.type:
239			case "inline":
240				for child in token.children:
241					self._TranslateToken(child)
242			case "text":
243				self._AppendText(token.content, html=False)
244			case "text_special":
245				self._InlineTarget().append(nodes.Text(token.content))
246			case "softbreak" if not self._converter.LineBreaks:
247				self._InlineTarget().append(nodes.Text("\n"))
248			case "softbreak" | "hardbreak":
249				self._InlineTarget().append(LineBreak("\n", "\n"))
250			case "code_inline":
251				self._InlineTarget().append(nodes.literal(token.content, token.content))
252			case "em_open":
253				opened = self._Open(nodes.emphasis(), inline=True)
254			case "strong_open":
255				opened = self._Open(nodes.strong(), inline=True)
256			case "s_open":
257				opened = self._Open(nodes.inline(classes=["strike"]), inline=True)
258			case "link_open":
259				opened = self._Open(nodes.reference(refuri=token.attrs["href"]), inline=True)
260			case "image":
261				alternative = "".join(child.content for child in token.children)
262				self._InlineTarget().append(nodes.image(uri=token.attrs["src"], alt=alternative, classes=[IMAGE_CLASS]))
263			case "html_inline" | "html_block":
264				parser = RawHTMLParser(self)
265				parser.feed(token.content)
266				parser.close()
267			case "footnote_ref":
268				self._InlineTarget().append(self._FootnoteReference(token.meta["id"]))
269			case "footnote_block_open" if self._topic is not None:
270				self._CloseTopic()
271			case "footnote_block_open" | "footnote_block_close" | "footnote_anchor":
272				pass  # a footnote is a node of its own, which links back to its references
273			case "paragraph_open":
274				opened = self._Open(nodes.paragraph(), inline=False)
275			case "heading_open":
276				opened = self._OpenHeading(int(token.tag[1:]))
277			case "bullet_list_open":
278				bulletList = nodes.bullet_list(bullet=token.markup)
279				if "contains-task-list" in str(token.attrs.get("class", "")):
280					bulletList["classes"].append("github-task-list")
281				opened = self._Open(bulletList, inline=False)
282			case "ordered_list_open":
283				enumeratedList = nodes.enumerated_list(enumtype="arabic", prefix="", suffix=token.markup)
284				if (start := int(token.attrs.get("start", 1))) != 1:
285					enumeratedList["start"] = start
286				opened = self._Open(enumeratedList, inline=False)
287			case "list_item_open":
288				item = nodes.list_item()
289				if "checked" in token.meta:
290					item["classes"].append("github-task-list-item")
291					symbol = "\u2611" if token.meta["checked"] else "\u2610"
292					self._checkbox = TaskListCheckbox(symbol, symbol, checked=token.meta["checked"])
293				opened = self._Open(item, inline=False)
294			case "blockquote_open":
295				opened = self._Open(nodes.block_quote(), inline=False)
296			case "alert_open":
297				opened = self._Open(ALERTS[token.meta["kind"]](classes=["github-alert"]), inline=False)
298			case "hr":
299				if (target := self._BlockTarget()) is not None:
300					target.extend(nodes.raw("", written, format=writer) for writer, written in BREAK_ROLES["hr"].items())
301			case "fence" | "code_block":
302				language = token.info.split()[0] if token.info.strip() != "" else "text"
303				code = token.content.rstrip("\n")
304				if (target := self._BlockTarget()) is not None:
305					target.append(nodes.literal_block(code, code, language=language))
306			case "table_open":
307				opened = self._Open(nodes.table(classes=["github-table"]), inline=False)
308				opened += self._Open(nodes.tgroup(), inline=False)
309			case "thead_open":
310				opened = self._Open(nodes.thead(), inline=False)
311			case "tbody_open":
312				opened = self._Open(nodes.tbody(), inline=False)
313			case "tr_open":
314				opened = self._Open(nodes.row(), inline=False)
315			case "th_open" | "td_open":
316				entry = nodes.entry()
317				if (style := str(token.attrs.get("style", ""))).startswith("text-align:"):
318					entry["classes"].append(f"github-align-{style.removeprefix('text-align:')}")
319				opened = self._Open(entry, inline=False)
320			case "footnote_open":
321				opened = self._Open(self._Footnote(token.meta["id"]), inline=False)
322			case _:
323				self._Warn(f"Markdown token '{token.type}' not supported; content kept as text.")
324				if token.content != "":
325					self._AppendText(token.content, html=False)
326
327		if token.nesting == 1:
328			self._closers.append(opened)
329
330	def _Open(self, node: nodes.Element, inline: bool) -> int:
331		"""
332		Put a node a token opens into the innermost open node, and open it.
333
334		:param node:   The node.
335		:param inline: Whether the node is inline - put into a paragraph - or a body element.
336		:returns:      The number of nodes opened, ``1``; ``0`` if a body element can't be put into the innermost node.
337		"""
338		if inline:
339			target = self._InlineTarget()
340		elif (target := self._BlockTarget()) is None:
341			self._Warn(f"'{node.tagname}' not supported inside '{self._stack[-1].tagname}'; content kept.")
342			return 0
343
344		self._Push(target, node)
345		self._floors.append(len(self._stack))
346		return 1
347
348	def _Push(self, target: nodes.Element, node: nodes.Element) -> None:
349		"""
350		Put a node into a target node, and open it.
351
352		A task list item's checkbox is put into the item's first paragraph.
353
354		:param target: The node the node is put into.
355		:param node:   The node.
356		"""
357		target += node
358		self._stack.append(node)
359
360		if self._checkbox is not None and isinstance(node, nodes.paragraph):
361			node += self._checkbox
362			node += nodes.Text(" ")
363			self._checkbox = None
364
365	def _Close(self) -> None:
366		"""
367		Close the node opened last by a token, and every node opened after it.
368		"""
369		floor = self._floors.pop()
370		while len(self._stack) >= floor:
371			self._Pop()
372
373	def _Pop(self) -> None:
374		"""
375		Close the innermost open node, and forget the HTML elements opened inside it.
376
377		A paragraph loses the whitespace at both ends, which raw HTML leaves, and is removed if nothing remains.
378		"""
379		node = self._stack.pop()
380		if len(self._implicit) > 0 and self._implicit[-1] is node:
381			self._implicit.pop()
382
383		depth = len(self._stack)
384		self._html = [entry for entry in self._html if entry[2] < depth or (entry[1] is None and entry[2] == depth)]
385
386		if isinstance(node, nodes.paragraph):
387			if len(node) > 0 and isinstance(node[0], nodes.Text):
388				node[0] = nodes.Text(node[0].lstrip())
389
390			if len(node) > 0 and isinstance(node[-1], nodes.Text):
391				node[-1] = nodes.Text(node[-1].rstrip())
392
393			for child in [child for child in node.children if isinstance(child, nodes.Text) and child == ""]:
394				node.remove(child)
395
396			if len(node) == 0:
397				node.parent.remove(node)
398
399	def _InlineTarget(self) -> nodes.Element:
400		"""
401		Return the node inline content goes into: the innermost open node, or a new paragraph in it, if it holds body
402		elements.
403
404		:returns: The node.
405		"""
406		if isinstance(top := self._stack[-1], (nodes.TextElement, nodes.Inline)):
407			return top
408
409		paragraph = nodes.paragraph()
410		self._Push(top, paragraph)
411		self._implicit.append(paragraph)
412		return paragraph
413
414	def _BlockTarget(self) -> Nullable[nodes.Element]:
415		"""
416		Return the node a body element goes into: the innermost open node holding body elements.
417
418		The paragraphs nobody wrote and the inline HTML elements still open above the innermost node a token opened are
419		closed: a body element ends them, as in HTML.
420
421		:returns: The node, or ``None`` if the innermost node a token opened holds inline content.
422		"""
423		floor = self._floors[-1] if len(self._floors) > 0 else 1
424		while isinstance(self._stack[-1], (nodes.TextElement, nodes.Inline)) and len(self._stack) > floor:
425			self._Pop()
426
427		if isinstance(top := self._stack[-1], (nodes.TextElement, nodes.Inline)):
428			return None
429
430		return top
431
432	def _OpenHeading(self, level: int) -> int:
433		"""
434		Open the node of a heading: a topic's title, or a rubric.
435
436		A heading outside of a topic, or of the topic's level or a higher one, opens a new topic; a deeper heading in the
437		topic, or a heading inside e.g. a list, opens a rubric.
438
439		:param level:  The heading's level, ``1`` for ``#``.
440		:returns:      The number of nodes opened.
441		"""
442		target = self._BlockTarget()
443		if target is None:
444			return self._Open(nodes.rubric(classes=["github-heading"]), inline=False)
445		elif target is not self._root and target is not self._topic:
446			return self._Open(nodes.rubric(classes=["github-heading"]), inline=False)
447		elif self._topic is not None and level > self._topicLevel:
448			return self._Open(nodes.rubric(classes=["github-heading"]), inline=False)
449
450		if self._topic is not None:
451			self._CloseTopic()
452
453		self._topic =      nodes.topic(classes=["github-topic"])
454		self._topicLevel = level
455		self._Push(self._root, self._topic)
456		self._floors.append(len(self._stack))
457		self._Push(self._topic, nodes.title())
458		self._floors.append(len(self._stack))
459		return 1
460
461	def _CloseTopic(self) -> None:
462		"""
463		Close the open topic, and every node opened in it.
464		"""
465		while self._stack[-1] is not self._topic:
466			self._Close()
467
468		self._Close()
469		self._topic =      None
470		self._topicLevel = 0
471
472	def _AppendText(self, text: str, html: bool) -> None:
473		"""
474		Append text to the innermost open node: verbatim in code, else with GitHub's references linked - except in a
475		link - and emoji as nodes.
476
477		Raw HTML's whitespace is collapsed to a space; whitespace between HTML elements holding body elements is skipped.
478
479		:param text: The text.
480		:param html: Whether the text is raw HTML's.
481		"""
482		if any(isinstance(node, (nodes.literal, nodes.literal_block)) for node in self._stack):
483			self._stack[-1].append(nodes.Text(text))
484			return
485		elif html:
486			if text.strip() == "":
487				if not isinstance(self._stack[-1], (nodes.TextElement, nodes.Inline)):
488					return
489
490				text = " " if text != "" else ""
491			else:
492				text = (" " if text[0].isspace() else "") + " ".join(text.split()) + (" " if text[-1].isspace() else "")
493
494		references = not any(isinstance(node, nodes.reference) for node in self._stack)
495		self._InlineTarget().extend(self._converter.Parse(text, references))
496
497	def _Footnote(self, number: int) -> nodes.footnote:
498		"""
499		Return the footnote of a number, created by its first reference or by its definition.
500
501		A footnote is labeled by its number counted from 1, and gets an ID unique in the document.
502
503		:param number: The parser's number of the footnote, counted from 0.
504		:returns:      The footnote.
505		"""
506		if (footnote := self._footnotes.get(number)) is None:
507			footnote = nodes.footnote(backrefs=[])
508			footnote += nodes.label(f"{number + 1}", f"{number + 1}")
509			self._document.set_id(footnote, suggested_prefix="github-footnote")
510			self._footnotes[number] = footnote
511
512		return footnote
513
514	def _FootnoteReference(self, number: int) -> nodes.footnote_reference:
515		"""
516		Create a reference to a footnote.
517
518		:param number: The parser's number of the footnote, counted from 0.
519		:returns:      The reference.
520		"""
521		footnote = self._Footnote(number)
522		reference = nodes.footnote_reference(f"{number + 1}", f"{number + 1}", refid=footnote["ids"][0])
523		footnote["backrefs"].append(self._document.set_id(reference, suggested_prefix="github-footnote-reference"))
524		return reference
525
526	def StartElement(self, tag: str, attributes: dict[str, str]) -> None:
527		"""
528		Translate an HTML element's start tag: open the node of the element, or write the node of an empty element.
529
530		An element of :data:`HTML_ELEMENTS` opens its node, an element mapped to ``None`` none. ``<br>``, ``<img>`` and
531		``<hr>`` are written as a line break, an image and a horizontal rule. In ``<pre>``, every element is transparent.
532		Every other element is warned about once, and transparent.
533
534		A ``<li>`` or ``<p>`` closes an open one of its kind directly before, as HTML allows to omit its end tag.
535
536		:param tag:        The element's name, in lower case.
537		:param attributes: The element's attributes; an attribute without value has value ``""``.
538		"""
539		inCode = any(isinstance(node, nodes.literal_block) for node in self._stack)
540		if tag == "br":
541			self._InlineTarget().append(nodes.Text("\n") if inCode else LineBreak("\n", "\n"))
542			return
543		elif tag == "img":
544			image = nodes.image(uri=attributes.get("src", ""), alt=attributes.get("alt", ""), classes=[IMAGE_CLASS])
545			for dimension in ("width", "height"):
546				if (value := attributes.get(dimension, "")).isdigit():
547					image[dimension] = f"{value}px"
548			self._InlineTarget().append(image)
549			return
550		elif tag == "hr":
551			if (target := self._BlockTarget()) is not None:
552				target.extend(nodes.raw("", written, format=writer) for writer, written in BREAK_ROLES["hr"].items())
553			return
554		elif tag in ("area", "base", "col", "embed", "input", "link", "meta", "source", "track", "wbr"):
555			self._WarnElement(tag)
556			return
557
558		if tag in ("li", "p") and len(self._html) > 0 and self._html[-1][0] == tag:
559			self.EndElement(tag)
560
561		nodeClass, classes = HTML_ELEMENTS.get(tag, (None, ()))
562		if inCode or nodeClass is None or (tag == "a" and attributes.get("href", "") == ""):
563			if not inCode and tag not in HTML_ELEMENTS:
564				self._WarnElement(tag)
565			self._html.append((tag, None, len(self._stack)))
566			return
567
568		node = nodeClass(classes=list(classes))
569		if tag == "a":
570			node["refuri"] = attributes["href"]
571		elif tag == "ul":
572			node["bullet"] = "*"
573		elif tag == "ol":
574			node["enumtype"] = "arabic"
575			node["prefix"] =   ""
576			node["suffix"] =   "."
577			if (start := attributes.get("start", "")).isdigit() and start != "1":
578				node["start"] = int(start)
579		elif tag == "pre":
580			node["language"] = attributes.get("lang", "text")
581
582		if issubclass(nodeClass, nodes.Inline):
583			target = self._InlineTarget()
584		elif (target := self._BlockTarget()) is None:
585			self._Warn(f"HTML element '<{tag}>' not supported inside '{self._stack[-1].tagname}'; content kept.")
586			self._html.append((tag, None, len(self._stack)))
587			return
588
589		self._html.append((tag, node, len(self._stack)))
590		self._Push(target, node)
591
592	def EndElement(self, tag: str) -> None:
593		"""
594		Translate an HTML element's end tag: close the node of the innermost open element of that name, and every node
595		opened after it.
596
597		An end tag without an open element of that name, or whose element is outside the innermost node a token opened,
598		is ignored.
599
600		:param tag: The element's name, in lower case.
601		"""
602		for index in range(len(self._html) - 1, -1, -1):
603			if self._html[index][0] == tag:
604				break
605		else:
606			return
607
608		_, node, depth = self._html[index]
609		if node is None:
610			del self._html[index]
611			return
612
613		floor = self._floors[-1] if len(self._floors) > 0 else 1
614		if depth < floor:
615			return
616
617		while len(self._stack) > depth:
618			self._Pop()
619
620	def AppendHTMLText(self, text: str) -> None:
621		"""
622		Translate the text of raw HTML, whose character references are resolved.
623
624		:param text: The text.
625		"""
626		self._AppendText(text, html=True)
627
628	def _WarnElement(self, tag: str) -> None:
629		"""
630		Warn about an HTML element without docutils counterpart, once per element name.
631
632		:param tag: The element's name.
633		"""
634		if tag not in self._warned:
635			self._warned.add(tag)
636			self._Warn(f"HTML element '<{tag}>' not supported; content kept as text.")
637
638	def _Warn(self, message: str) -> None:
639		"""
640		Warn about something the Markdown holds, which has no docutils counterpart, with type ``github`` and subtype
641		``markdown``.
642
643		:param message: The message, which is prefixed by the Markdown line.
644		"""
645		getLogger(__name__).warning(
646			f"Markdown line {self._line}: {message}", type="github", subtype="markdown", location=self._location
647		)
648
649
650@export
651class RawHTMLParser(HTMLParser):
652	"""
653	A parser of the raw HTML in Markdown, which hands the elements and the text to a :class:`MarkdownTranslator`.
654
655	A comment is skipped, as GitHub doesn't show it either. A classic class, because :class:`~html.parser.HTMLParser`
656	has no slots.
657	"""
658
659	def __init__(self, translator: MarkdownTranslator) -> None:
660		"""
661		Initializes a parser for one piece of raw HTML.
662
663		:param translator: The translator, which gets the elements and the text.
664		"""
665		super().__init__(convert_charrefs=True)
666		self._translator = translator
667
668	def handle_starttag(self, tag: str, attrs: list[tuple[str, Nullable[str]]]) -> None:
669		"""
670		Hand an element's start tag to the translator.
671
672		:param tag:   The element's name, in lower case.
673		:param attrs: The element's attributes, as pairs of name and value; ``None`` for an attribute without value.
674		"""
675		self._translator.StartElement(tag, {name: "" if value is None else value for name, value in attrs})
676
677	def handle_endtag(self, tag: str) -> None:
678		"""
679		Hand an element's end tag to the translator.
680
681		:param tag: The element's name, in lower case.
682		"""
683		self._translator.EndElement(tag)
684
685	def handle_data(self, data: str) -> None:
686		"""
687		Hand text to the translator.
688
689		:param data: The text.
690		"""
691		self._translator.AppendHTMLText(data)