Coverage for tdom/nodes.py: 100%
53 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-31 17:14 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-31 17:14 +0000
1from dataclasses import dataclass, field
3from markupsafe import escape
5# See https://developer.mozilla.org/en-US/docs/Glossary/Void_element
6VOID_ELEMENTS = frozenset(
7 [
8 "area",
9 "base",
10 "br",
11 "col",
12 "embed",
13 "hr",
14 "img",
15 "input",
16 "link",
17 "meta",
18 "param",
19 "source",
20 "track",
21 "wbr",
22 ]
23)
26CDATA_CONTENT_ELEMENTS = frozenset(["script", "style"])
27RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"])
28CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS
30# FUTURE: add a pretty-printer to nodes for debugging
31# FUTURE: make nodes frozen (and have the parser work with mutable builders)
34@dataclass(slots=True)
35class Node:
36 def __html__(self) -> str:
37 """Return the HTML representation of the node."""
38 # By default, just return the string representation
39 return str(self)
42@dataclass(slots=True)
43class Text(Node):
44 text: str # which may be markupsafe.Markup in practice.
46 def __str__(self) -> str:
47 # Use markupsafe's escape to handle HTML escaping
48 return escape(self.text)
50 def __eq__(self, other: object) -> bool:
51 # This is primarily of use for testing purposes. We only consider
52 # two Text nodes equal if their string representations match.
53 return isinstance(other, Text) and str(self) == str(other)
56@dataclass(slots=True)
57class Fragment(Node):
58 children: list[Node] = field(default_factory=list)
60 def __str__(self) -> str:
61 return "".join(str(child) for child in self.children)
64@dataclass(slots=True)
65class Comment(Node):
66 text: str
68 def __str__(self) -> str:
69 return f"<!--{self.text}-->"
72@dataclass(slots=True)
73class DocumentType(Node):
74 text: str = "html"
76 def __str__(self) -> str:
77 return f"<!DOCTYPE {self.text}>"
80@dataclass(slots=True)
81class Element(Node):
82 tag: str
83 attrs: dict[str, str | None] = field(default_factory=dict)
84 children: list[Node] = field(default_factory=list)
86 def __post_init__(self):
87 """Ensure all preconditions are met."""
88 if not self.tag:
89 raise ValueError("Element tag cannot be empty.")
91 # Void elements cannot have children
92 if self.is_void and self.children:
93 raise ValueError(f"Void element <{self.tag}> cannot have children.")
95 @property
96 def is_void(self) -> bool:
97 return self.tag in VOID_ELEMENTS
99 @property
100 def is_content(self) -> bool:
101 return self.tag in CONTENT_ELEMENTS
103 def __str__(self) -> str:
104 # We use markupsafe's escape to handle HTML escaping of attribute values
105 # which means it's possible to mark them as safe if needed.
106 attrs_str = "".join(
107 f" {key}" if value is None else f' {key}="{escape(value)}"'
108 for key, value in self.attrs.items()
109 )
110 if self.is_void:
111 return f"<{self.tag}{attrs_str} />"
112 if not self.children:
113 return f"<{self.tag}{attrs_str}></{self.tag}>"
114 children_str = "".join(str(child) for child in self.children)
115 return f"<{self.tag}{attrs_str}>{children_str}</{self.tag}>"