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