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:
- 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)