Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit d458ca0

Browse files
committed
Handle many attribute and spread attribute substitutions.
1 parent f7e6e59 commit d458ca0

File tree

1 file changed

+120
-31
lines changed

1 file changed

+120
-31
lines changed

html_tstring/processor.py

Lines changed: 120 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,109 @@ def _instrument_and_parse(strings: tuple[str, ...]) -> Node:
8787
# --------------------------------------------------------------------------
8888

8989

90+
def _substitute_attr(
91+
key: str,
92+
value: object,
93+
) -> t.Iterable[tuple[str, str | None]]:
94+
"""
95+
Substitute a single attribute based on its key and the interpolated value.
96+
97+
A single parsed attribute with a placeholder may result in multiple
98+
attributes in the final output, for instance if the value is a dict or
99+
iterable of key-value pairs. Likewise, a value of False will result in
100+
the attribute being omitted entirely; nothing is yielded in that case.
101+
"""
102+
match value:
103+
case str():
104+
yield (key, str(value))
105+
case True:
106+
yield (key, None)
107+
case False | None:
108+
pass
109+
case dict() as d:
110+
for sub_k, sub_v in d.items():
111+
if sub_v is True:
112+
yield sub_k, None
113+
elif sub_v not in (False, None):
114+
yield sub_k, str(sub_v)
115+
case Iterable() as it:
116+
for item in it:
117+
match item:
118+
case tuple() if len(item) == 2:
119+
sub_k, sub_v = item
120+
if sub_v is True:
121+
yield sub_k, None
122+
elif sub_v not in (False, None):
123+
yield sub_k, str(sub_v)
124+
case str() | Markup():
125+
yield str(item), None
126+
case _:
127+
raise TypeError(
128+
f"Cannot use {type(item).__name__} as attribute "
129+
f"key-value pair in iterable for attribute '{key}'"
130+
)
131+
case _:
132+
raise TypeError(
133+
f"Cannot use {type(value).__name__} as attribute value for "
134+
f"attribute '{key}'"
135+
)
136+
137+
138+
def _substitute_spread_attrs(value: object) -> t.Iterable[tuple[str, str | None]]:
139+
"""
140+
Substitute a spread attribute based on the interpolated value.
141+
142+
A spread attribute is one where the key is a placeholder, indicating that
143+
the entire attribute set should be replaced by the interpolated value.
144+
The value must be a dict or iterable of key-value pairs.
145+
"""
146+
match value:
147+
case dict() as d:
148+
for sub_k, sub_v in d.items():
149+
if sub_v is True:
150+
yield sub_k, None
151+
elif sub_v not in (False, None):
152+
yield sub_k, str(sub_v)
153+
case Iterable() as it:
154+
for item in it:
155+
match item:
156+
case tuple() if len(item) == 2:
157+
sub_k, sub_v = item
158+
if sub_v is True:
159+
yield sub_k, None
160+
elif sub_v not in (False, None):
161+
yield sub_k, str(sub_v)
162+
case str() | Markup():
163+
yield str(item), None
164+
case _:
165+
raise TypeError(
166+
f"Cannot use {type(item).__name__} as attribute "
167+
f"key-value pair in iterable for spread attribute"
168+
)
169+
case _:
170+
raise TypeError(
171+
f"Cannot use {type(value).__name__} as value for spread attribute"
172+
)
173+
174+
90175
def _substitute_attrs(
91176
attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...]
92177
) -> dict[str, str | None]:
178+
"""Substitute placeholders in attributes based on the corresponding interpolations."""
93179
new_attrs: dict[str, str | None] = {}
94180
for key, value in attrs.items():
95-
if key.startswith(_PLACEHOLDER_PREFIX):
181+
if value and value.startswith(_PLACEHOLDER_PREFIX):
182+
index = _placholder_index(value)
183+
interpolation = interpolations[index]
184+
value = format_interpolation(interpolation)
185+
for sub_k, sub_v in _substitute_attr(key, value):
186+
new_attrs[sub_k] = sub_v
187+
elif key.startswith(_PLACEHOLDER_PREFIX):
96188
index = _placholder_index(key)
97189
interpolation = interpolations[index]
98190
value = format_interpolation(interpolation)
99-
if not isinstance(value, str):
100-
raise ValueError(
101-
f"Attribute interpolation must be a string, got: {value!r}"
102-
)
103-
new_attrs[key] = value
191+
for sub_k, sub_v in _substitute_spread_attrs(value):
192+
new_attrs[sub_k] = sub_v
104193
else:
105194
new_attrs[key] = value
106195
return new_attrs
@@ -123,38 +212,38 @@ def _substitute_and_flatten_children(
123212

124213

125214
def _node_from_value(value: object) -> Node:
126-
"""Convert a value to a Node, if possible."""
127-
# This is a bit of a hack, but it lets us handle Markup and
128-
# other objects that implement __html__ without special-casing them here.
129-
# We use a Text node to wrap the value, then parse it back out.
130-
# This is not the most efficient, but it is simple and works.
131-
node = Text(_placeholder(0))
132-
interpolations = (Interpolation(value, "", None, ""),)
133-
return _substitute_node(node, interpolations)
215+
"""
216+
Convert an arbitrary value to a Node.
217+
218+
This is the primary substitution performed when replacing interpolations
219+
in child content positions.
220+
"""
221+
match value:
222+
case str():
223+
return Text(value)
224+
case Node():
225+
return value
226+
case Template():
227+
return html(value)
228+
case HasHTMLDunder():
229+
return Text(value)
230+
case False:
231+
return Text("")
232+
case Iterable():
233+
children = [_node_from_value(v) for v in value]
234+
return Fragment(children=children)
235+
case _:
236+
return Text(str(value))
134237

135238

136239
def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node:
240+
"""Substitute placeholders in a node based on the corresponding interpolations."""
137241
match p_node:
138242
case Text(text) if str(text).startswith(_PLACEHOLDER_PREFIX):
139243
index = _placholder_index(str(text))
140244
interpolation = interpolations[index]
141245
value = format_interpolation(interpolation)
142-
match value:
143-
case str():
144-
return Text(value)
145-
case Node():
146-
return value
147-
case Template():
148-
return html(value)
149-
case HasHTMLDunder():
150-
return Text(value)
151-
case False:
152-
return Text("")
153-
case Iterable():
154-
children = [_node_from_value(v) for v in value]
155-
return Fragment(children=children)
156-
case _:
157-
return Text(str(value))
246+
return _node_from_value(value)
158247
case Element(tag=tag, attrs=attrs, children=children):
159248
new_attrs = _substitute_attrs(attrs, interpolations)
160249
new_children = _substitute_and_flatten_children(children, interpolations)
@@ -172,7 +261,7 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) ->
172261

173262

174263
def html(template: Template) -> Node:
175-
"""Create an HTML element from a string."""
264+
"""Parse a t-string and return a tree of Nodes."""
176265
# Parse the HTML, returning a tree of nodes with placeholders
177266
# where interpolations go.
178267
p_node = _instrument_and_parse(template.strings)

0 commit comments

Comments
 (0)