Coverage for tdom/nodes.py: 100%

53 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-17 19:54 +0000

1from dataclasses import dataclass, field 

2 

3from markupsafe import escape 

4 

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) 

24 

25 

26CDATA_CONTENT_ELEMENTS = frozenset(["script", "style"]) 

27RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"]) 

28CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS 

29 

30# FUTURE: add a pretty-printer to nodes for debugging 

31# FUTURE: make nodes frozen (and have the parser work with mutable builders) 

32 

33 

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) 

40 

41 

42@dataclass(slots=True) 

43class Text(Node): 

44 text: str 

45 

46 def __str__(self) -> str: 

47 # Use markupsafe's escape to handle HTML escaping 

48 return escape(self.text) 

49 

50 

51@dataclass(slots=True) 

52class Fragment(Node): 

53 children: list[Node] = field(default_factory=list) 

54 

55 def __str__(self) -> str: 

56 return "".join(str(child) for child in self.children) 

57 

58 

59@dataclass(slots=True) 

60class Comment(Node): 

61 text: str 

62 

63 def __str__(self) -> str: 

64 return f"<!--{self.text}-->" 

65 

66 

67@dataclass(slots=True) 

68class DocumentType(Node): 

69 text: str = "html" 

70 

71 def __str__(self) -> str: 

72 return f"<!DOCTYPE {self.text}>" 

73 

74 

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) 

80 

81 def __post_init__(self): 

82 """Ensure all preconditions are met.""" 

83 if not self.tag: 

84 raise ValueError("Element tag cannot be empty.") 

85 

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.") 

89 

90 @property 

91 def is_void(self) -> bool: 

92 return self.tag in VOID_ELEMENTS 

93 

94 @property 

95 def is_content(self) -> bool: 

96 return self.tag in CONTENT_ELEMENTS 

97 

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}>"