Markdown.py

Summary

Lines:

329 of 345 covered (95.4%)

Excluded lines:

3

Branches:

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

Coverage:

93.9%

Directory:

Sphinx

Missed lines:

326-329, 345-346, 401, 448, 470, 555-557, 559-560, 563, 619

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