Skip to content

Commit ef712ee

Browse files
Track placeholder callables into parser and onto component elements for end tag check.
1 parent 54a9e97 commit ef712ee

File tree

3 files changed

+54
-15
lines changed

3 files changed

+54
-15
lines changed

tdom/nodes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,17 @@ def __str__(self) -> str:
7777
return f"<!DOCTYPE {self.text}>"
7878

7979

80+
@dataclass
81+
class ComponentInfo:
82+
endtag: str | None
83+
84+
8085
@dataclass(slots=True)
8186
class Element(Node):
8287
tag: str
8388
attrs: dict[str, str | None] = field(default_factory=dict)
8489
children: list[Node] = field(default_factory=list)
90+
component_info: ComponentInfo | None = None
8591

8692
def __post_init__(self):
8793
"""Ensure all preconditions are met."""

tdom/parser.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Fragment,
1616
Node,
1717
Text,
18+
ComponentInfo,
1819
)
1920

2021
_FRAGMENT_TAG = f"t🐍f-{''.join(random.choices(string.ascii_lowercase, k=4))}-"
@@ -23,11 +24,16 @@
2324
class NodeParser(HTMLParser):
2425
root: Fragment
2526
stack: list[Element]
27+
placeholder_callables: dict[str, int]
2628

27-
def __init__(self):
29+
def __init__(self, placeholder_callables: dict[str, int] | None = None):
2830
super().__init__()
2931
self.root = Fragment(children=[])
3032
self.stack = []
33+
if placeholder_callables is None:
34+
self.placeholder_callables = {}
35+
else:
36+
self.placeholder_callables = placeholder_callables
3137

3238
def handle_starttag(
3339
self, tag: str, attrs: t.Sequence[tuple[str, str | None]]
@@ -42,16 +48,24 @@ def handle_startendtag(
4248
self, tag: str, attrs: t.Sequence[tuple[str, str | None]]
4349
) -> None:
4450
node = Element(tag, attrs=LastUpdatedOrderedDict(attrs), children=[])
51+
if node.tag in self.placeholder_callables:
52+
node.component_info = ComponentInfo(endtag=None)
4553
self.append_element_child(node)
4654

4755
def handle_endtag(self, tag: str) -> None:
4856
if not self.stack:
4957
raise ValueError(f"Unexpected closing tag </{tag}> with no open element.")
5058

5159
element = self.stack.pop()
52-
if element.tag != tag:
60+
if element.tag != tag and (
61+
element.tag not in self.placeholder_callables
62+
or self.placeholder_callables[element.tag]
63+
!= self.placeholder_callables[tag]
64+
):
5365
raise ValueError(f"Mismatched closing tag </{tag}> for <{element.tag}>.")
5466

67+
if element.tag in self.placeholder_callables:
68+
element.component_info = ComponentInfo(endtag=tag)
5569
self.append_element_child(element)
5670

5771
def handle_data(self, data: str) -> None:
@@ -130,7 +144,9 @@ def feed(self, data: str) -> None:
130144
super().feed(data)
131145

132146

133-
def parse_html(input: str | t.Iterable[str]) -> Node:
147+
def parse_html(
148+
input: str | t.Iterable[str], placeholder_callables: dict[str, int] | None = None
149+
) -> Node:
134150
"""
135151
Parse a string, or sequence of HTML string chunks, into a Node tree.
136152
@@ -139,7 +155,7 @@ def parse_html(input: str | t.Iterable[str]) -> Node:
139155
This is particularly useful if you want to keep specific text chunks
140156
separate in the resulting Node tree.
141157
"""
142-
parser = NodeParser()
158+
parser = NodeParser(placeholder_callables=placeholder_callables)
143159
iterable = [input] if isinstance(input, str) else input
144160
for chunk in iterable:
145161
parser.feed(chunk)

tdom/processor.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def _replace_placeholders(
138138

139139
def _instrument(
140140
strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...]
141-
) -> t.Iterable[str]:
141+
) -> tuple[list[str], dict[str, int]]:
142142
"""
143143
Join the strings with placeholders in between where interpolations go.
144144
@@ -150,24 +150,24 @@ def _instrument(
150150
"""
151151
count = len(strings)
152152

153-
callable_placeholders: dict[int, str] = {}
153+
placeholder_callables: dict[str, int] = {}
154154

155+
parts = []
155156
for i, s in enumerate(strings):
156-
yield s
157+
parts.append(s)
157158
# There are always count-1 placeholders between count strings.
158159
if i < count - 1:
159160
placeholder = _placeholder(i)
160161

161162
# Special case for component callables: if the interpolation
162163
# is a callable, we need to make sure that any matching closing
163-
# tag uses the same placeholder.
164+
# tag's placeholder is the same callable
164165
callable_info = callable_infos[i]
165166
if callable_info:
166-
placeholder = callable_placeholders.setdefault(
167-
callable_info.id, placeholder
168-
)
167+
placeholder_callables[placeholder] = callable_info.id
169168

170-
yield placeholder
169+
parts.append(placeholder)
170+
return parts, placeholder_callables
171171

172172

173173
@lru_cache(maxsize=0 if "pytest" in sys.modules else 512)
@@ -190,8 +190,8 @@ def _instrument_and_parse(cached_template: CachedTemplate) -> Node:
190190
callable_infos = tuple(
191191
_callable_info(interpolation.value) for interpolation in template.interpolations
192192
)
193-
instrumented = _instrument(template.strings, callable_infos)
194-
return parse_html(instrumented)
193+
instrumented, placeholder_callables = _instrument(template.strings, callable_infos)
194+
return parse_html(instrumented, placeholder_callables=placeholder_callables)
195195

196196

197197
def _callable_info(value: object) -> CallableInfo | None:
@@ -522,9 +522,26 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) ->
522522
interpolation = interpolations[index]
523523
value = format_interpolation(interpolation)
524524
return _node_from_value(value)
525-
case Element(tag=tag, attrs=attrs, children=children):
525+
case Element(
526+
tag=tag, attrs=attrs, children=children, component_info=component_info
527+
):
526528
new_children = _substitute_and_flatten_children(children, interpolations)
527529
if (index := _find_placeholder(tag)) is not None:
530+
if component_info is None:
531+
raise TypeError(
532+
"Only callables can be used for interpolations located in tags."
533+
)
534+
elif component_info.endtag is not None:
535+
end_index = _find_placeholder(component_info.endtag)
536+
if end_index is None:
537+
# Avoid typecheck errors.
538+
raise ValueError(
539+
"The endtag of a component must be an interpolation."
540+
)
541+
elif interpolations[index].value != interpolations[end_index].value:
542+
raise ValueError(
543+
"The endtag interpolation's value should match the starttag interpolation's value."
544+
)
528545
component_attrs = _substitute_interpolated_attrs(attrs, interpolations)
529546
return _invoke_component(
530547
component_attrs, new_children, interpolations[index]

0 commit comments

Comments
 (0)