Coverage for tdom/nodes.py: 100%
53 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 19:54 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 19:54 +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
46 def __str__(self) -> str:
47 # Use markupsafe's escape to handle HTML escaping
48 return escape(self.text)
51@dataclass(slots=True)
52class Fragment(Node):
53 children: list[Node] = field(default_factory=list)
55 def __str__(self) -> str:
56 return "".join(str(child) for child in self.children)
59@dataclass(slots=True)
60class Comment(Node):
61 text: str
63 def __str__(self) -> str:
64 return f"<!--{self.text}-->"
67@dataclass(slots=True)
68class DocumentType(Node):
69 text: str = "html"
71 def __str__(self) -> str:
72 return f"<!DOCTYPE {self.text}>"
75@dataclass(slots=True)
76class Element(Node):
77 tag: str
78 attrs: dict[str, str | None] = field(default_factory=dict)
79 children: list[Node] = field(default_factory=list)
81 def __post_init__(self):
82 """Ensure all preconditions are met."""
83 if not self.tag:
84 raise ValueError("Element tag cannot be empty.")
86 # Void elements cannot have children
87 if self.is_void and self.children:
88 raise ValueError(f"Void element <{self.tag}> cannot have children.")
90 @property
91 def is_void(self) -> bool:
92 return self.tag in VOID_ELEMENTS
94 @property
95 def is_content(self) -> bool:
96 return self.tag in CONTENT_ELEMENTS
98 def __str__(self) -> str:
99 # We use markupsafe's escape to handle HTML escaping of attribute values
100 # which means it's possible to mark them as safe if needed.
101 attrs_str = "".join(
102 f" {key}" if value is None else f' {key}="{escape(value)}"'
103 for key, value in self.attrs.items()
104 )
105 if self.is_void:
106 return f"<{self.tag}{attrs_str} />"
107 if not self.children:
108 return f"<{self.tag}{attrs_str}></{self.tag}>"
109 if self.is_content:
110 # Content elements should *not* escape their content when
111 # rendering to HTML. Sheesh, HTML is weird.
112 children_str = "".join(
113 child.text if isinstance(child, Text) else str(child)
114 for child in self.children
115 )
116 else:
117 children_str = "".join(str(child) for child in self.children)
118 return f"<{self.tag}{attrs_str}>{children_str}</{self.tag}>"