Coverage for /pythoncovmergedfiles/medio/medio/src/turbohtml/tools/fuzz/markdown_structure_generators.py: 43%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Reader wrappers must consume budgets even when rendered HTML has fewer nodes."""
3from __future__ import annotations
5import argparse
6import random
7import re
8from dataclasses import dataclass, field
9from pathlib import Path
10from typing import TYPE_CHECKING, Final
12from fuzz.structure_generators import (
13 BudgetError,
14 Generated,
15 GenerationBudget,
16 Grammar,
17 Identifier,
18 Production,
19 ProductionFloorError,
20 Reference,
21 compile_grammar,
22 generate,
23 generation_sweep,
24 write_corpus,
25)
26from markdown_it import MarkdownIt
27from markdown_it.tree import SyntaxTreeNode
28from typing_extensions import override
30from turbohtml import Element, Namespace, Node, Text, parse_fragment
32if TYPE_CHECKING:
33 from collections.abc import Callable, Iterable, Iterator, Sequence
35MarkdownRecord = tuple[str, str, str, tuple[tuple[str, str], ...], int]
36_Meaning = tuple[str, tuple[tuple[str, str], ...], tuple["_Meaning", ...]]
39def main(argv: Sequence[str] | None = None) -> int:
40 """Write source bytes so corpus consumers need no mode-string interpretation."""
41 parser: Final = argparse.ArgumentParser()
42 parser.add_argument("--output", type=Path, required=True)
43 parser.add_argument("--budget", type=int, default=64)
44 parser.add_argument("--steps", type=int, default=256)
45 parser.add_argument("--count", type=int, default=0)
46 parser.add_argument("--seed", type=int, default=0)
47 parser.add_argument("--html", action="store_true")
48 args: Final = parser.parse_args(argv)
49 if args.count < 0:
50 parser.error("count must be nonnegative")
51 try:
52 cases: Final = (
53 tuple(
54 markdown_generate(random.Random(args.seed + index), args.budget, steps=args.steps, html=args.html)
55 for index in range(args.count)
56 )
57 if args.count
58 else generation_sweep(markdown_grammar(html=args.html), budget=GenerationBudget(args.budget, args.steps))
59 )
60 except (BudgetError, ProductionFloorError) as error:
61 parser.error(str(error))
62 write_corpus(cases, args.output)
63 return 0
66def markdown_generate(rng: random.Random, budget: int = 64, *, steps: int = 256, html: bool = False) -> Generated:
67 """Reader wrappers and HTML elements require different node costs."""
68 return generate(markdown_grammar(html=html), rng, GenerationBudget(budget, steps))
71def markdown_grammar(*, html: bool = False) -> Grammar:
72 """Provenance and finite floors let corpus consumers audit coverage."""
73 return _HTML_GRAMMAR if html else _RAW_GRAMMAR
76def markdown_snapshot(source: str) -> tuple[MarkdownRecord, ...]:
77 """Inline source spelling duplicates the semantic leaf text."""
78 nodes: Final = tuple(SyntaxTreeNode(_READER.parse(source)).walk())
79 return tuple(
80 (
81 node.type,
82 "" if node.is_root else node.tag,
83 "" if node.is_root or node.type == "inline" else node.content,
84 () if node.is_root else tuple(sorted((name, str(value)) for name, value in node.attrs.items())),
85 -1 if node.parent is None else nodes.index(node.parent),
86 )
87 for node in nodes
88 )
91def markdown_source_check(source: str, render: Callable[[Node], str] = Node.to_markdown) -> str | None:
92 """Check the first conversion because repeated passes can stabilize a loss."""
93 return markdown_html_check(_READER.render(source), render)
96def markdown_html_check(markup: str, render: Callable[[Node], str] = Node.to_markdown) -> str | None:
97 """Compare source meaning because a stable conversion can lose HTML semantics."""
98 expected: Final = markdown_html_meaning(markup)
99 converted: Final = _READER.render(render(parse_fragment(markup)))
100 if markdown_html_meaning(converted) == expected:
101 return None
102 if any(char in markup and char not in converted for char in "\x0b\x1c\x1d\x1e\x1f\x85"):
103 msg = "Markdown reader discards control whitespace at paragraph edges"
104 raise MarkdownProfileError(msg)
105 return "Markdown conversion changes meaning"
108def markdown_html_meaning(markup: str) -> tuple[_Meaning, ...]:
109 """Collapse reader HTML to the structure and text the Markdown oracle compares."""
110 # read the tree to_markdown converts, so a parser that lags the WHATWG tree builder cannot pose as a lost meaning
111 root: Final = parse_fragment(markup)
112 if any(sum(1 for _ in element.ancestors) > 128 for element in root.iter_elements()):
113 msg = "HTML nesting exceeds the meaning model"
114 raise MarkdownProfileError(msg)
115 return _flow(_children(_tree(root), preserve=False))
118def _tree(node: Element) -> _TreeNode:
119 element: Final = _TreeNode(
120 node.tag if node.namespace is Namespace.HTML else f"{{{node.namespace.value}}}{node.tag}",
121 {name: node.attr(name) or "" for name in node.attrs},
122 )
123 for child in node.children:
124 if isinstance(child, Text):
125 if element.children:
126 element.children[-1].tail += child.text
127 else:
128 element.text += child.text
129 elif isinstance(child, Element):
130 element.children.append(_tree(child))
131 return element
134def _flow(records: tuple[_Meaning, ...]) -> tuple[_Meaning, ...]:
135 output: Final[list[_Meaning]] = []
136 inline: Final[list[_Meaning]] = []
137 for record in (*_spread(records), _BOUNDARY):
138 if record[0] in _FLOW_INLINE:
139 inline.append(record)
140 continue
141 # a paragraph needs a non-blank line (CommonMark 4.8), so a run of breaks alone is none, as an empty <p> is
142 if content := _paragraph(inline):
143 output.append(("p", (), content))
144 inline.clear()
145 if record != _BOUNDARY:
146 output.append(record)
147 return tuple(output)
150def _paragraph(records: list[_Meaning]) -> tuple[_Meaning, ...]:
151 # spreading emphasis can leave a run that starts or ends at a block, where its edge spaces collapse away
152 return _block_end(_spacing(_merge(records), preserve=False, inline=False))
155def _spread(records: tuple[_Meaning, ...]) -> tuple[_Meaning, ...]:
156 # a delimiter run pairs only within one block's inline content (CommonMark 6.2), so in a flow of blocks emphasis
157 # around a block reaches each block's own inline runs
158 output: Final[list[_Meaning]] = []
159 for record in records:
160 if record[0] not in _EMPHASIS or not _holds_block(record):
161 output.append(record)
162 continue
163 run: list[_Meaning] = []
164 for child in _spread(record[2]):
165 if child[0] in _FLOW_INLINE:
166 run.append(child)
167 continue
168 output.extend(_emphasis(record[0], tuple(run)))
169 run = []
170 output.append(_emphasize_block(record[0], child))
171 output.extend(_emphasis(record[0], tuple(run)))
172 return tuple(output)
175def _holds_block(record: _Meaning) -> bool:
176 return any(child[0] not in _FLOW_INLINE or _holds_block(child) for child in record[2])
179def _emphasize_block(tag: str, record: _Meaning) -> _Meaning:
180 if record[0] in {"p", "td", *_HEADINGS}:
181 return (record[0], record[1], _emphasis(tag, record[2]))
182 if record[0] in {"blockquote", "li", "ul", "ol", "table", "tr"}:
183 return (record[0], record[1], tuple(_emphasize_block(tag, child) for child in record[2]))
184 return record # code holds literal text (CommonMark 4.5), and a rule or a boundary holds none
187def _block_end(records: tuple[_Meaning, ...]) -> tuple[_Meaning, ...]:
188 # a hard break that ends a block draws no line in a browser and does nothing in CommonMark (6.7), and a link with
189 # no content draws nothing, so a break before one ends the block too
190 tail = len(records)
191 while tail and (records[tail - 1][0] == "br" or _draws_nothing(records[tail - 1])):
192 tail -= 1
193 kept: Final = tuple(record for record in records[tail:] if record[0] != "br")
194 if len(kept) == len(records) - tail or not tail or records[tail - 1][0] != "#text":
195 return (*records[:tail], *kept)
196 text: Final = records[tail - 1][1][0][1].rstrip(" \t\n\f\r")
197 return (*records[: tail - 1], *((("#text", (("value", text),), ()),) if text else ()), *kept)
200def _draws_nothing(record: _Meaning) -> bool:
201 return record[0] in {"a", *_EMPHASIS} and all(map(_draws_nothing, record[2]))
204def _children(element: _TreeNode, *, preserve: bool) -> tuple[_Meaning, ...]:
205 records: Final[list[_Meaning]] = list(_text(element.text, preserve=preserve))
206 for child in element:
207 records.extend(_element(child, element.tag, preserve=preserve))
208 records.extend(_text(child.tail, preserve=preserve))
209 spaced: Final = _spacing(_merge(records), preserve=preserve, inline=element.tag in _INLINE_TEXT)
210 return spaced if preserve or element.tag in _INLINE_TEXT else _block_end(spaced)
213def _merge(records: Iterable[_Meaning]) -> tuple[_Meaning, ...]:
214 merged: Final[list[_Meaning]] = []
215 for record in records:
216 if record[0] == "#text" and merged and merged[-1][0] == "#text":
217 merged[-1] = ("#text", (("value", merged[-1][1][0][1] + record[1][0][1]),), ())
218 else:
219 merged.append(record)
220 return tuple(merged)
223def _spacing(records: tuple[_Meaning, ...], *, preserve: bool, inline: bool) -> tuple[_Meaning, ...]:
224 output: Final[list[_Meaning]] = []
225 for index, record in enumerate(records):
226 if record[0] != "#text" or preserve:
227 output.append(record)
228 continue
229 text: str = re.sub(r"[ \t\n\f\r]+", " ", record[1][0][1])
230 previous: Final = records[index - 1][0] if index else None
231 if (previous is None and not inline) or (
232 previous is not None and (previous not in _FLOW_INLINE or previous == "br")
233 ):
234 text = text.lstrip(" \t\n\f\r")
235 following: Final = records[index + 1][0] if index + 1 < len(records) else None
236 # a line drops the collapsible spaces at its end (CSS Text 3, 4.1.2), so a space before a break draws nothing
237 if (following is None and not inline) or (
238 following is not None and (following not in _FLOW_INLINE or following == "br")
239 ):
240 text = text.rstrip(" \t\n\f\r")
241 if text:
242 output.append(("#text", (("value", text),), ()))
243 return tuple(output)
246def _element(element: _TreeNode, parent: str, *, preserve: bool) -> tuple[_Meaning, ...]:
247 if element.tag in _HIDDEN or element.tag.endswith(("}script", "}style")):
248 return ()
249 _check_profile(element, parent)
250 children: Final = _children(element, preserve=preserve or element.tag == "pre")
251 if element.tag == "div":
252 return (_BOUNDARY, *children, _BOUNDARY) # a block ends the paragraph around it even when it holds no text
253 if (
254 element.tag in {"span", "thead", "tbody"}
255 # an <a> without href is a placeholder (WHATWG 4.5.1) and Markdown has no link without a destination
256 or (element.tag == "a" and "href" not in element.attrib)
257 ):
258 return children
259 return _record({"b": "strong", "i": "em", "del": "s", "th": "td"}.get(element.tag, element.tag), element, children)
262def _check_profile(element: _TreeNode, parent: str) -> None:
263 if element.tag not in _ALLOWED:
264 msg = f"HTML element has no declared Markdown meaning: {element.tag}"
265 raise MarkdownProfileError(msg)
266 if _breaks_content_model(element, parent):
267 msg = f"HTML content model violated at {element.tag}"
268 raise MarkdownProfileError(msg)
271def _breaks_content_model(element: _TreeNode, parent: str) -> bool:
272 if element.tag == "li":
273 return parent not in {"ul", "ol"}
274 if element.tag == "a":
275 # an a holds no a descendant (WHATWG 4.5.1), and link text holds no link (CommonMark 6.3)
276 return any(descendant.tag == "a" for descendant in _descendants(element))
277 return (allowed := _CONTENT_MODEL.get(element.tag)) is not None and (
278 any(child.tag not in allowed for child in element)
279 or any(text.strip(" \t\n\f\r") for text in (element.text, *(child.tail for child in element)))
280 )
283def _descendants(element: _TreeNode) -> Iterator[_TreeNode]:
284 for child in element:
285 yield child
286 yield from _descendants(child)
289def _record(tag: str, element: _TreeNode, children: tuple[_Meaning, ...]) -> tuple[_Meaning, ...]:
290 if tag in {"pre", "code"}:
291 # code holds literal text only (CommonMark 4.5, 6.1), so markup inside it means nothing and its text is read
292 # off the tree
293 return ((tag, (), text),) if (text := _code_text(element, block=tag == "pre")) or tag == "pre" else ()
294 if tag in _CONTENT_REQUIRED and not children:
295 return (_BOUNDARY,) if tag in _BLOCKS else ()
296 if tag in _EMPHASIS:
297 return _emphasis(tag, children)
298 if tag in {"blockquote", "li"}:
299 # quote and item content is a flow of blocks; a tight item's single paragraph renders bare (CommonMark 5.1-5.3),
300 # so items compare with every inline run wrapped
301 return ((tag, (), _flow(children)),)
302 if tag == "a":
303 # link text holds inline content only (CommonMark 6.3), so its blocks flatten onto its line
304 return _link(_attributes(tag, element.attrib), _line(children, inline=True, breaks=True))
305 if tag in _HEADINGS or (
306 tag == "td" and any(child[0] not in _FLOW_INLINE or _holds_block(child) for child in children)
307 ):
308 # a heading is one line of inline content (CommonMark 4.2), so its blocks and breaks flatten onto it as spaces;
309 # a table cell holds inline content too (GFM 4.10), but a raw <br> keeps its break there
310 children = _line(children, inline=False, breaks=tag == "td")
311 return ((tag, _attributes(tag, element.attrib), children),)
314def _line(records: tuple[_Meaning, ...], *, inline: bool, breaks: bool) -> tuple[_Meaning, ...]:
315 # blocks flatten onto one line as spaces, and so do hard breaks where the line cannot hold one; emphasis and links
316 # keep their place on the line with their own content flattened
317 space: Final[_Meaning] = ("#text", (("value", " "),), ())
318 flat: Final[list[_Meaning]] = []
319 for record in records:
320 if record == _BOUNDARY or (record[0] == "br" and not breaks):
321 flat.append(space)
322 elif record[0] in _EMPHASIS:
323 flat.extend(_emphasis(record[0], _line(record[2], inline=True, breaks=breaks)))
324 elif record[0] == "a":
325 flat.extend(_link(record[1], _line(record[2], inline=True, breaks=breaks)))
326 elif record[0] in _FLOW_INLINE:
327 flat.append(record)
328 elif record[0] == "pre":
329 # the inline form of a code block is a code span (CommonMark 6.1), which reads its line endings as spaces
330 text = re.sub(r"[ \t\n\f\r]+", " ", record[2][0][1][0][1]) if record[2] else ""
331 flat.extend((space, *((("code", (), (("#text", (("value", text),), ()),)),) if text else ()), space))
332 else:
333 flat.extend((space, *_line(record[2], inline=False, breaks=breaks), space))
334 return _spacing(_merge(flat), preserve=False, inline=inline)
337def _emphasis(tag: str, children: tuple[_Meaning, ...]) -> tuple[_Meaning, ...]:
338 if all(record[0] == "#text" and not record[1][0][1].strip(" \t\n\f\r") for record in children):
339 return children # a delimiter run cannot wrap whitespace alone (CommonMark 6.2)
340 before, inner, after = _edges(children)
341 return (*before, *(((tag, (), inner),) if inner else ()), *after)
344def _link(attributes: tuple[tuple[str, str], ...], children: tuple[_Meaning, ...]) -> tuple[_Meaning, ...]:
345 before, inner, after = _edges(children)
346 return (*before, ("a", attributes, inner), *after)
349def _edges(children: tuple[_Meaning, ...]) -> tuple[list[_Meaning], tuple[_Meaning, ...], list[_Meaning]]:
350 # emphasis and link text do not style a hard break, and spaces at their edge collapse with the ones outside (CSS
351 # Text 3, 4.1.1), so both render the same outside; a delimiter run touching whitespace cannot open or close
352 # (CommonMark 6.2), so they sit outside
353 inner: Final[list[_Meaning]] = list(children)
354 before: Final[list[_Meaning]] = []
355 after: Final[list[_Meaning]] = []
356 _peel(inner, before, 0, breaks=True)
357 _peel(inner, after, -1, breaks=True)
358 return before, tuple(inner), after
361def _peel(inner: list[_Meaning], outside: list[_Meaning], edge: int, *, breaks: bool) -> None:
362 while inner and (
363 (breaks and inner[edge][0] == "br")
364 or (inner[edge][0] == "#text" and inner[edge][1][0][1][edge:][:1] in _SPACES)
365 ):
366 text: str = inner[edge][1][0][1] if inner[edge][0] == "#text" else ""
367 kept: str = text.lstrip(" \t\n\f\r") if edge == 0 else text.rstrip(" \t\n\f\r")
368 if not kept:
369 outside.insert(0 if edge else len(outside), inner.pop(edge))
370 continue
371 space: _Meaning = ("#text", (("value", text[: len(text) - len(kept)] if edge == 0 else text[len(kept) :]),), ())
372 outside.insert(0 if edge else len(outside), space)
373 inner[edge] = ("#text", (("value", kept),), ())
376def _code_text(element: _TreeNode, *, block: bool) -> tuple[_Meaning, ...]:
377 # a block keeps its line breaks and drops the newline fenced content always ends in, which a browser does not
378 # draw; a span reads its line breaks as spaces (CommonMark 6.1) and collapses spaces as the normal flow does
379 # (CSS Text 3, 4.1.1)
380 text: Final = _literal(element).removesuffix("\n") if block else re.sub(r"[ \t\n\f\r]+", " ", _literal(element))
381 return (("#text", (("value", text),), ()),) if text else ()
384def _literal(element: _TreeNode) -> str:
385 # a block starts and ends a line even when it holds no text, so its edge breaks a line that already holds text
386 text = ""
387 boundary = False
388 for part in _literal_parts(element):
389 if part is None:
390 boundary = True
391 elif part:
392 text += "\n" if boundary and text and not text.endswith("\n") else ""
393 text += part
394 boundary = False
395 return text
398def _literal_parts(element: _TreeNode) -> Iterator[str | None]:
399 yield element.text
400 for child in element:
401 if child.tag == "br":
402 yield "\n"
403 elif child.tag not in _HIDDEN:
404 edge: tuple[None, ...] = () if child.tag in _LITERAL_INLINE else (None,)
405 yield from edge
406 yield from _literal_parts(child)
407 yield from edge
408 yield child.tail
411def _attributes(tag: str, attributes: dict[str, str]) -> tuple[tuple[str, str], ...]:
412 # an empty title gives no advisory information (WHATWG 3.2.6.1), the same as none
413 # URL navigation and Markdown destinations both discard tabs and line breaks.
414 semantic: Final = {
415 name: re.sub(r"[\t\n\r]", "", value) if name in {"href", "src"} else value
416 for name, value in attributes.items()
417 if name in _SEMANTIC_ATTRIBUTES.get(tag, ()) and (value or name != "title")
418 }
419 # image syntax always writes both attributes (CommonMark 6.4)
420 return tuple(sorted((({"src": "", "alt": ""} if tag == "img" else {}) | semantic).items()))
423def _text(text: str, *, preserve: bool) -> tuple[_Meaning, ...]:
424 if not text:
425 return ()
426 if not preserve:
427 text = re.sub(r"[ \t\n\f\r]+", " ", text)
428 return (("#text", (("value", text),), ()),)
431def markdown_source_seeds(*, html: bool = False) -> list[str]:
432 """Force named productions to make corpus coverage reproducible."""
433 return [
434 case.data.decode("utf-8")
435 for case in generation_sweep(markdown_grammar(html=html), budget=GenerationBudget(64, 256))
436 ]
439def markdown_controls(*, html: bool = False) -> dict[str, bool]:
440 """Stable losses distinguish first-conversion and repeated-pass checks."""
441 check: Final = markdown_html_check if html else markdown_source_check
442 sources: Final = (
443 ("<p><strong>x</strong></p>", '<p><a href="/target">x</a></p>', "<p>x</p>")
444 if html
445 else ("**x**", "[x](/target)", "x")
446 )
447 return {
448 "lost emphasis": check(sources[0], lambda node: node.text) is not None,
449 "changed destination": check(sources[1], lambda node: node.to_markdown().replace("/target", "/changed"))
450 is not None,
451 "empty conversion": check(sources[2], lambda _node: "") is not None,
452 }
455@dataclass(slots=True)
456class _TreeNode:
457 """Mirror the element/text/tail shape the meaning walk reads, built from turbohtml's own tree."""
459 tag: str
460 attrib: dict[str, str]
461 text: str = ""
462 tail: str = ""
463 children: list[_TreeNode] = field(default_factory=list)
465 def __iter__(self) -> Iterator[_TreeNode]:
466 return iter(self.children)
469class MarkdownProfileError(ValueError):
470 """Keep unsupported HTML semantics outside the Markdown conversion profile."""
473class _FaithfulMarkdown(MarkdownIt):
474 """Keep link spelling and policy independent of the reader's URL normalization."""
476 @override
477 def normalizeLink(self, url: str) -> str:
478 return url
480 @override
481 def validateLink(self, url: str) -> bool:
482 return True
485def _raw_grammar() -> Grammar:
486 productions: Final = [
487 Production("markdown:root", "root", (Reference("definition", 0), Reference("blocks", 1)), 1, _COMMONMARK),
488 Production(
489 "markdown:definition",
490 "definition",
491 (b"[", Identifier("reference", definition=True), b"]: /target\n\n"),
492 0,
493 _COMMONMARK,
494 ),
495 Production("markdown:single", "blocks", (Reference("block", 0),), 0, _COMMONMARK),
496 Production(
497 "markdown:siblings",
498 "blocks",
499 (Reference("block", 0), b"\n\n---\n\n", Reference("blocks", 0)),
500 1,
501 _COMMONMARK,
502 ),
503 Production(
504 "markdown:reference",
505 "block",
506 (b"[", Identifier("reference", definition=False), b"]\n"),
507 4,
508 _COMMONMARK,
509 3,
510 ),
511 ]
512 productions.extend(
513 Production(
514 f"markdown:{name}",
515 "block",
516 (source.encode("utf-8"),),
517 nodes,
518 _GFM if name in {"table", "strike"} else _COMMONMARK,
519 height,
520 )
521 for name, source, nodes, height in _RAW
522 )
523 return compile_grammar(productions, "root")
526def _html_grammar() -> Grammar:
527 productions: Final = [
528 Production(
529 "markdown-html:root",
530 "root",
531 (
532 b'<div id="',
533 Identifier("target", definition=True),
534 b'">',
535 Reference("blocks", 2),
536 b'<p><a href="#',
537 Identifier("target", definition=False),
538 b'">target</a></p></div>',
539 ),
540 5,
541 _COMMONMARK,
542 4,
543 ),
544 Production("markdown-html:single", "blocks", (Reference("block", 0),), 0, _COMMONMARK),
545 Production(
546 "markdown-html:siblings",
547 "blocks",
548 (Reference("block", 0), b"<hr>", Reference("blocks", 0)),
549 1,
550 _COMMONMARK,
551 ),
552 ]
553 productions.extend(
554 Production(
555 f"markdown-html:{name}",
556 "block",
557 (source.encode("utf-8"),),
558 nodes,
559 _GFM if name in {"table", "s", "del"} else _COMMONMARK,
560 height,
561 )
562 for name, source, nodes, height in _HTML
563 )
564 return compile_grammar(productions, "root")
567_COMMONMARK: Final = "https://spec.commonmark.org/0.31.2/"
568_GFM: Final = "https://github.github.com/gfm/"
569MARKDOWN_READER_SOURCE: Final = (
570 "https://github.com/executablebooks/markdown-it-py/tree/36c5f547144df2d01970a5792d68c71a3380b227"
571)
572MARKDOWN_RULES: Final = (
573 "table",
574 "code",
575 "fence",
576 "blockquote",
577 "hr",
578 "list",
579 "reference",
580 "html_block",
581 "heading",
582 "lheading",
583 "paragraph",
584 "text",
585 "linkify-disabled",
586 "newline",
587 "escape",
588 "backticks",
589 "strikethrough",
590 "emphasis",
591 "link",
592 "image",
593 "autolink",
594 "html_inline",
595 "entity",
596)
597_RAW: Final = (
598 ("heading1", "# x\n", 3, 2),
599 ("heading2", "## x\n", 3, 2),
600 ("heading3", "### x\n", 3, 2),
601 ("heading4", "#### x\n", 3, 2),
602 ("heading5", "##### x\n", 3, 2),
603 ("heading6", "###### x\n", 3, 2),
604 ("setext1", "x\n===\n", 3, 2),
605 ("setext2", "x\n---\n", 3, 2),
606 ("thematic", "---\n", 1, 0),
607 ("indented", " x\n", 1, 0),
608 ("fence-backtick", "```\nx\n```\n", 1, 0),
609 ("fence-tilde", "~~~\nx\n~~~\n", 1, 0),
610 ("html-comment", "<!-- x -->\n", 1, 0),
611 ("html-pi", "<?x?>\n", 1, 0),
612 ("html-declaration", "<!DOCTYPE html>\n", 1, 0),
613 ("html-cdata", "<![CDATA[x]]>\n", 1, 0),
614 ("html-script", "<script>x</script>\n", 1, 0),
615 ("html-block", "<p>x</p>\n", 1, 0),
616 ("html-span", "<span>x</span>\n", 5, 2),
617 ("html-tag", '<img src="/image" alt="x">\n', 1, 0),
618 ("quote", "> x\n", 4, 3),
619 ("bullet", "- x\n", 5, 4),
620 ("ordered", "1. x\n", 5, 4),
621 ("loose", "- x\n\n- y\n", 9, 4),
622 ("nested", "- x\n - y\n", 10, 6),
623 ("table", "| a |\n|---|\n| b |\n", 11, 5),
624 ("paragraph", "x\n", 3, 2),
625 ("softbreak", "x\ny\n", 5, 2),
626 ("hardbreak", "x \ny\n", 5, 2),
627 ("em-star", "*x*\n", 4, 3),
628 ("em-underscore", "_x_\n", 4, 3),
629 ("strong-star", "**x**\n", 6, 3),
630 ("strong-underscore", "__x__\n", 6, 3),
631 ("code", "`x`\n", 3, 2),
632 ("strike", "~~x~~\n", 4, 3),
633 ("link", "[x](/target)\n", 4, 3),
634 ("image", "\n", 4, 3),
635 ("autolink", "<https://e.test/>\n", 4, 3),
636 ("email", "<a@e.test>\n", 4, 3),
637 ("escape", "\\*x\\*\n", 3, 2),
638 ("entity", "&\n", 3, 2),
639 ("html-inline", "a <em>x</em> b\n", 7, 2),
640 ("linkify-disabled", "https://e.test/\n", 3, 2),
641)
642_HTML: Final = (
643 ("paragraph", "<p>x</p>", 2, 1),
644 ("heading1", "<h1>x</h1>", 2, 1),
645 ("heading2", "<h2>x</h2>", 2, 1),
646 ("heading3", "<h3>x</h3>", 2, 1),
647 ("heading4", "<h4>x</h4>", 2, 1),
648 ("heading5", "<h5>x</h5>", 2, 1),
649 ("heading6", "<h6>x</h6>", 2, 1),
650 ("thematic", "<hr>", 1, 0),
651 ("code-block", "<pre><code>x\n</code></pre>", 3, 2),
652 ("quote", "<blockquote><p>x</p></blockquote>", 3, 2),
653 ("bullet", "<ul><li>x</li></ul>", 3, 2),
654 ("ordered", "<ol><li>x</li></ol>", 3, 2),
655 ("loose", "<ul><li><p>x</p></li><li><p>y</p></li></ul>", 7, 3),
656 ("nested", "<ul><li>x<ul><li>y</li></ul></li></ul>", 6, 4),
657 ("table", "<table><thead><tr><th>a</th></tr></thead><tbody><tr><td>b</td></tr></tbody></table>", 9, 4),
658 ("em", "<p><em>x</em></p>", 3, 2),
659 ("strong", "<p><strong>x</strong></p>", 3, 2),
660 ("b", "<p><b>x</b></p>", 3, 2),
661 ("i", "<p><i>x</i></p>", 3, 2),
662 ("s", "<p><s>x</s></p>", 3, 2),
663 ("del", "<p><del>x</del></p>", 3, 2),
664 ("code", "<p><code>x</code></p>", 3, 2),
665 ("link", '<p><a href="/target" title="t">x</a></p>', 3, 2),
666 ("image", '<p><img src="/image" alt="x" title="t"></p>', 2, 1),
667 ("break", "<p>x<br>y</p>", 4, 1),
668 ("special", "<p>*x* [y] & é漢</p>", 2, 1),
669)
670_FLOW_INLINE: Final = frozenset({"#text", "a", "em", "strong", "s", "img", "br", "code"})
671_INLINE_HOSTS: Final = frozenset({
672 "p",
673 "li",
674 "td",
675 "th",
676 "a",
677 "em",
678 "strong",
679 "b",
680 "i",
681 "s",
682 "del",
683 "span",
684 "h1",
685 "h2",
686 "h3",
687 "h4",
688 "h5",
689 "h6",
690})
691_INLINE_TEXT: Final = frozenset({"a", "em", "strong", "b", "i", "s", "del", "span"})
692_LITERAL_INLINE: Final = _INLINE_TEXT | {"code", "img"}
693_ALLOWED: Final = _INLINE_HOSTS | {
694 "div",
695 "hr",
696 "pre",
697 "code",
698 "blockquote",
699 "ul",
700 "ol",
701 "table",
702 "thead",
703 "tbody",
704 "tr",
705 "img",
706 "br",
707}
708_SEMANTIC_ATTRIBUTES: Final[dict[str, frozenset[str]]] = {
709 "a": frozenset({"href", "title"}),
710 "img": frozenset({"src", "alt", "title"}),
711 "ol": frozenset({"start"}),
712 "td": frozenset({"align"}),
713}
714# CommonMark and GFM write none of these without content: emphasis and code spans need a character (6.1, 6.2), a
715# paragraph a non-blank line (4.8), a list an item (5.3) and a table a header cell (GFM 4.10)
716_CONTENT_REQUIRED: Final = frozenset({"p", "strong", "em", "s", "code", "ul", "ol", "table", "tr"})
717_EMPHASIS: Final = frozenset({"strong", "em", "s"})
718_BLOCKS: Final = frozenset({"p", "ul", "ol", "table"})
719_BOUNDARY: Final[_Meaning] = ("#block", (), ())
720_SPACES: Final = frozenset(" \t\n\f\r")
721_HEADINGS: Final = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
722# WHATWG Rendering 15.3.1 draws none of these; script and style hold code in every namespace
723_HIDDEN: Final = frozenset({
724 "area",
725 "base",
726 "basefont",
727 "datalist",
728 "head",
729 "link",
730 "meta",
731 "noembed",
732 "noframes",
733 "param",
734 "rp",
735 "script",
736 "style",
737 "template",
738 "title",
739})
740# the WHATWG content models of the containers whose Markdown syntax can hold nothing else (4.4.5-4.4.8, 4.9)
741_CONTENT_MODEL: Final[dict[str, frozenset[str]]] = {
742 "ul": frozenset({"li", "script"}),
743 "ol": frozenset({"li", "script"}),
744 "table": frozenset({"thead", "tbody", "tr", "script"}),
745 "thead": frozenset({"tr", "script"}),
746 "tbody": frozenset({"tr", "script"}),
747 "tr": frozenset({"td", "th", "script"}),
748}
749_READER: Final = _FaithfulMarkdown("gfm-like", {"linkify": False})
750_RAW_GRAMMAR: Final = _raw_grammar()
751_HTML_GRAMMAR: Final = _html_grammar()
753__all__ = [
754 "MARKDOWN_READER_SOURCE",
755 "MARKDOWN_RULES",
756 "MarkdownProfileError",
757 "MarkdownRecord",
758 "main",
759 "markdown_controls",
760 "markdown_generate",
761 "markdown_grammar",
762 "markdown_html_check",
763 "markdown_html_meaning",
764 "markdown_snapshot",
765 "markdown_source_check",
766 "markdown_source_seeds",
767]
769if __name__ == "__main__":
770 raise SystemExit(main())