diff --git a/tdom/parser.py b/tdom/parser.py
index aef287c..82de290 100644
--- a/tdom/parser.py
+++ b/tdom/parser.py
@@ -19,6 +19,8 @@
TTemplatedAttribute,
TText,
)
+from .template_utils import combine_template_refs
+
type HTMLAttribute = tuple[str, str | None]
type HTMLAttributesDict = dict[str, str | None]
@@ -38,7 +40,6 @@ class OpenTFragment:
@dataclass
class OpenTComponent:
- # TODO: hold on to start_s_index when we start to need it.
start_i_index: int
attrs: tuple[TAttribute, ...]
children: list[TNode] = field(default_factory=list)
@@ -61,11 +62,6 @@ class SourceTracker:
def interpolations(self) -> tuple[Interpolation, ...]:
return self.template.interpolations
- @property
- def s_index(self) -> int:
- """The current string index."""
- return self.i_index + 1
-
def advance_interpolation(self) -> int:
"""Call before processing an interpolation to move to the next one."""
self.i_index += 1
@@ -81,28 +77,10 @@ def get_expression(
ip = self.interpolations[i_index]
return ip.expression if ip.expression else f"{{{fallback_prefix}-{i_index}}}"
- def get_interpolation_value(self, i_index: int):
- """Get the runtime value at the given interpolation index."""
- return self.interpolations[i_index].value
-
def format_starttag(self, i_index: int) -> str:
"""Format a component start tag for error messages."""
return self.get_expression(i_index, fallback_prefix="component-starttag")
- def format_endtag(self, i_index: int) -> str:
- """Format a component end tag for error messages."""
- return self.get_expression(i_index, fallback_prefix="component-endtag")
-
- def format_open_tag(self, open_tag: OpenTag) -> str:
- """Format any open tag for error messages."""
- match open_tag:
- case OpenTElement(tag=tag):
- return tag
- case OpenTFragment():
- return ""
- case OpenTComponent(start_i_index=i_index):
- return self.format_starttag(i_index)
-
class TemplateParser(HTMLParser):
root: OpenTFragment
@@ -275,8 +253,13 @@ def handle_endtag(self, tag: str) -> None:
def handle_data(self, data: str) -> None:
ref = self.placeholders.remove_placeholders(data)
- text = TText(ref)
- self.append_child(text)
+ parent = self.get_parent()
+ if parent.children and isinstance(parent.children[-1], TText):
+ parent.children[-1] = TText(
+ ref=combine_template_refs(parent.children[-1].ref, ref)
+ )
+ else:
+ self.append_child(TText(ref=ref))
def handle_comment(self, data: str) -> None:
ref = self.placeholders.remove_placeholders(data)
@@ -287,13 +270,14 @@ def handle_decl(self, decl: str) -> None:
ref = self.placeholders.remove_placeholders(decl)
if not ref.is_literal:
raise ValueError("Interpolations are not allowed in declarations.")
- if not decl.upper().startswith("DOCTYPE"):
+ elif decl.upper().startswith("DOCTYPE "):
+ doctype_content = decl[7:].strip()
+ doctype = TDocumentType(doctype_content)
+ self.append_child(doctype)
+ else:
raise NotImplementedError(
- "Only DOCTYPE declarations are currently supported."
+ "Only well formed DOCTYPE declarations are currently supported."
)
- doctype_content = decl[7:].strip()
- doctype = TDocumentType(doctype_content)
- self.append_child(doctype)
def reset(self):
super().reset()
diff --git a/tdom/parser_test.py b/tdom/parser_test.py
index a5167b1..298adc2 100644
--- a/tdom/parser_test.py
+++ b/tdom/parser_test.py
@@ -16,6 +16,36 @@
)
+def test_parse_mixed_literal_content():
+ node = TemplateParser.parse(
+ t""
+ t""
+ t'
{Component}>")
+
+
+def test_adjacent_start_component_tag_error():
+ def Component():
+ pass
+
+ with pytest.raises(ValueError):
+ _ = TemplateParser.parse(t"<{Component}{Component}>{Component}>")
+
+
+def test_adjacent_end_component_tag_error():
+ def Component():
+ pass
+
+ with pytest.raises(ValueError):
+ _ = TemplateParser.parse(t"<{Component}>{Component}{Component}>")
diff --git a/tdom/processor.py b/tdom/processor.py
index e3773a9..1e2528d 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -84,42 +84,40 @@ def format_interpolation(interpolation: Interpolation) -> object:
# --------------------------------------------------------------------------
-def _force_dict(value: t.Any, *, kind: str) -> dict:
- """Try to convert a value to a dict, raising TypeError if not possible."""
- try:
- return dict(value)
- except (TypeError, ValueError):
- raise TypeError(
- f"Cannot use {type(value).__name__} as value for {kind} attributes"
- ) from None
-
-
def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
"""Produce aria-* attributes based on the interpolated value for "aria"."""
if value is None:
return
- d = _force_dict(value, kind="aria")
- for sub_k, sub_v in d.items():
- if sub_v is True:
- yield f"aria-{sub_k}", "true"
- elif sub_v is False:
- yield f"aria-{sub_k}", "false"
- elif sub_v is None:
- yield f"aria-{sub_k}", None
- else:
- yield f"aria-{sub_k}", str(sub_v)
+ elif isinstance(value, dict):
+ for sub_k, sub_v in value.items():
+ if sub_v is True:
+ yield f"aria-{sub_k}", "true"
+ elif sub_v is False:
+ yield f"aria-{sub_k}", "false"
+ elif sub_v is None:
+ yield f"aria-{sub_k}", None
+ else:
+ yield f"aria-{sub_k}", str(sub_v)
+ else:
+ raise TypeError(
+ f"Cannot use {type(value).__name__} as value for aria attribute"
+ )
def _expand_data_attr(value: object) -> t.Iterable[Attribute]:
"""Produce data-* attributes based on the interpolated value for "data"."""
if value is None:
return
- d = _force_dict(value, kind="data")
- for sub_k, sub_v in d.items():
- if sub_v is True or sub_v is False or sub_v is None:
- yield f"data-{sub_k}", sub_v
- else:
- yield f"data-{sub_k}", str(sub_v)
+ elif isinstance(value, dict):
+ for sub_k, sub_v in value.items():
+ if sub_v is True or sub_v is False or sub_v is None:
+ yield f"data-{sub_k}", sub_v
+ else:
+ yield f"data-{sub_k}", str(sub_v)
+ else:
+ raise TypeError(
+ f"Cannot use {type(value).__name__} as value for data attribute"
+ )
def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
@@ -130,8 +128,14 @@ def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
the entire attribute set should be replaced by the interpolated value.
The value must be a dict or iterable of key-value pairs.
"""
- d = _force_dict(value, kind="spread")
- yield from d.items()
+ if value is None:
+ return
+ elif isinstance(value, dict):
+ yield from value.items()
+ else:
+ raise TypeError(
+ f"Cannot use {type(value).__name__} as value for spread attributes"
+ )
ATTR_EXPANDERS = {
@@ -324,6 +328,8 @@ def _resolve_t_attrs(
new_attrs.get(name, True)
)
new_attrs[name] = attr_accs[name].merge_value(attr_value)
+ elif expander := ATTR_EXPANDERS.get(name):
+ raise TypeError(f"{name} attributes cannot be templated")
else:
new_attrs[name] = attr_value
case TSpreadAttribute(i_index=i_index):
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 30a4ed5..11b1c4d 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -2,6 +2,7 @@
import typing as t
from dataclasses import dataclass, field
from string.templatelib import Interpolation, Template
+from itertools import product
import pytest
from markupsafe import Markup
@@ -15,30 +16,77 @@
# --------------------------------------------------------------------------
-def test_parse_empty():
+#
+# Text
+#
+def test_empty():
node = html(t"")
assert node == Fragment(children=[])
assert str(node) == ""
-def test_parse_text():
+def test_text_literal():
node = html(t"Hello, world!")
assert node == Text("Hello, world!")
assert str(node) == "Hello, world!"
-def test_parse_comment():
+def test_text_singleton():
+ greeting = "Hello, Alice!"
+ node = html(t"{greeting}")
+ assert node == Text("Hello, Alice!")
+ assert str(node) == "Hello, Alice!"
+
+
+def test_text_template():
+ name = "Alice"
+ node = html(t"Hello, {name}!")
+ assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")])
+ assert str(node) == "Hello, Alice!"
+
+
+def test_text_template_escaping():
+ name = "Alice & Bob"
+ node = html(t"Hello, {name}!")
+ assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")])
+ assert str(node) == "Hello, Alice & Bob!"
+
+
+#
+# Comments.
+#
+def test_comment():
node = html(t"")
assert node == Comment("This is a comment")
assert str(node) == ""
+def test_comment_template():
+ text = "comment"
+ node = html(t"")
+ assert node == Comment("This is a comment")
+ assert str(node) == ""
+
+
+def test_comment_template_escaping():
+ text = "-->comment"
+ node = html(t"")
+ assert node == Comment("This is a -->comment")
+ assert str(node) == ""
+
+
+#
+# Document types.
+#
def test_parse_document_type():
node = html(t"")
assert node == DocumentType("html")
assert str(node) == ""
+#
+# Elements
+#
def test_parse_void_element():
node = html(t"
")
assert node == Element("br")
@@ -66,13 +114,6 @@ def test_parse_chain_of_void_elements():
assert str(node) == '

'
-def test_static_boolean_attr_retained():
- # Make sure a boolean attribute (bare attribute) is not omitted.
- node = html(t"
")
- assert node == Element("input", {"disabled": None})
- assert str(node) == "
"
-
-
def test_parse_element_with_text():
node = html(t"
Hello, world!
")
assert node == Element(
@@ -84,18 +125,6 @@ def test_parse_element_with_text():
assert str(node) == "
Hello, world!
"
-def test_parse_element_with_attributes():
- node = html(t'
Link')
- assert node == Element(
- "a",
- attrs={"href": "https://example.com", "target": "_blank"},
- children=[
- Text("Link"),
- ],
- )
- assert str(node) == '
Link'
-
-
def test_parse_nested_elements():
node = html(t"
")
assert node == Element(
@@ -188,6 +217,14 @@ def test_interpolated_trusted_in_content_node():
assert str(node) == ("")
+def test_script_elements_error():
+ nested_template = t"
"
+ # Putting non-text content inside a script is not allowed.
+ with pytest.raises(ValueError):
+ node = html(t"")
+ _ = str(node)
+
+
# --------------------------------------------------------------------------
# Interpolated non-text content
# --------------------------------------------------------------------------
@@ -414,216 +451,222 @@ def test_nested_list_items():
# --------------------------------------------------------------------------
-# Interpolated attribute content
+# Attributes
# --------------------------------------------------------------------------
-def test_interpolated_attribute_value():
- url = "https://example.com/"
- node = html(t'
Link')
+def test_literal_attrs():
+ node = html(
+ (
+ t"
"
+ )
+ )
assert node == Element(
- "a", attrs={"href": "https://example.com/"}, children=[Text("Link")]
+ "a",
+ attrs={
+ "id": "example_link",
+ "autofocus": None,
+ "title": "",
+ "href": "https://example.com",
+ "target": "_blank",
+ },
+ )
+ assert (
+ str(node)
+ == '
'
+ )
+
+
+def test_literal_attr_escaped():
+ node = html(t'
')
+ assert node == Element(
+ "a",
+ attrs={"title": "<"},
)
- assert str(node) == '
Link'
+ assert str(node) == '
'
-def test_escaping_of_interpolated_attribute_value():
+def test_interpolated_attr():
+ url = "https://example.com/"
+ node = html(t'
')
+ assert node == Element("a", attrs={"href": "https://example.com/"})
+ assert str(node) == '
'
+
+
+def test_interpolated_attr_escaped():
url = 'https://example.com/?q="test"&lang=en'
- node = html(t'
Link')
+ node = html(t'
')
assert node == Element(
"a",
attrs={"href": 'https://example.com/?q="test"&lang=en'},
- children=[Text("Link")],
)
assert (
- str(node)
- == '
Link'
+ str(node) == '
'
)
-def test_interpolated_unquoted_attribute_value():
+def test_interpolated_attr_unquoted():
id = "roquefort"
- node = html(t"
Cheese
")
- assert node == Element("div", attrs={"id": "roquefort"}, children=[Text("Cheese")])
- assert str(node) == '
Cheese
'
+ node = html(t"
")
+ assert node == Element("div", attrs={"id": "roquefort"})
+ assert str(node) == '
'
-def test_interpolated_attribute_value_true():
+def test_interpolated_attr_true():
disabled = True
- node = html(t"
")
- assert node == Element(
- "button", attrs={"disabled": None}, children=[Text("Click me")]
- )
- assert str(node) == "
"
+ node = html(t"
")
+ assert node == Element("button", attrs={"disabled": None})
+ assert str(node) == "
"
-def test_interpolated_attribute_value_falsy():
+def test_interpolated_attr_false():
disabled = False
- crumpled = None
- node = html(t"
")
- assert node == Element("button", attrs={}, children=[Text("Click me")])
- assert str(node) == "
"
+ node = html(t"
")
+ assert node == Element("button")
+ assert str(node) == "
"
+
+
+def test_interpolated_attr_none():
+ disabled = None
+ node = html(t"
")
+ assert node == Element("button")
+ assert str(node) == "
"
+
+
+def test_interpolate_attr_empty_string():
+ node = html(t'
')
+ assert node == Element(
+ "div",
+ attrs={"title": ""},
+ )
+ assert str(node) == '
'
-def test_interpolated_attribute_spread_dict():
+def test_spread_attr():
attrs = {"href": "https://example.com/", "target": "_blank"}
- node = html(t"
Link")
+ node = html(t"
")
assert node == Element(
"a",
attrs={"href": "https://example.com/", "target": "_blank"},
- children=[Text("Link")],
)
- assert str(node) == '
Link'
+ assert str(node) == '
'
+
+
+def test_spread_attr_none():
+ attrs = None
+ node = html(t"
")
+ assert node == Element("a")
+ assert str(node) == "
"
+
+
+def test_spread_attr_type_errors():
+ for attrs in (0, [], (), False, True):
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+
+
+def test_templated_attr_mixed_interpolations_start_end_and_nest():
+ left, middle, right = 1, 3, 5
+ prefix, suffix = t'
'
+ # Check interpolations at start, middle and/or end of templated attr
+ # or a combination of those to make sure text is not getting dropped.
+ for left_part, middle_part, right_part in product(
+ (t"{left}", Template(str(left))),
+ (t"{middle}", Template(str(middle))),
+ (t"{right}", Template(str(right))),
+ ):
+ test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix
+ node = html(test_t)
+ assert node == Element(
+ "div",
+ attrs={"data-range": "1-3-5"},
+ )
+ assert str(node) == '
'
-def test_interpolated_mixed_attribute_values_and_spread_dict():
+def test_templated_attr_no_quotes():
+ start = 1
+ end = 5
+ node = html(t"
")
+ assert node == Element(
+ "div",
+ attrs={"data-range": "1-5"},
+ )
+ assert str(node) == '
'
+
+
+def test_attr_merge_disjoint_interpolated_attr_spread_attr():
attrs = {"href": "https://example.com/", "id": "link1"}
target = "_blank"
- node = html(t'
Link')
+ node = html(t"
")
assert node == Element(
"a",
attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
- children=[Text("Link")],
- )
- assert (
- str(node)
- == '
Link'
)
+ assert str(node) == '
'
-def test_multiple_attribute_spread_dicts():
+def test_attr_merge_overlapping_spread_attrs():
attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
attrs2 = {"target": "_blank", "id": "link1"}
- node = html(t"
Link")
+ node = html(t"
")
assert node == Element(
"a",
attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"},
- children=[Text("Link")],
- )
- assert (
- str(node)
- == '
Link'
)
+ assert str(node) == '
'
-def test_interpolated_class_attribute():
- class_list = ["btn", "btn-primary", "one two", None]
- class_dict = {"active": True, "btn-secondary": False}
- class_str = "blue"
- class_space_sep_str = "green yellow"
- class_none = None
- class_empty_list = []
- class_empty_dict = {}
- button_t = (
- t"
"
- )
- node = html(button_t)
- assert node == Element(
- "button",
- attrs={"class": "red btn btn-primary one two active blue green yellow"},
- children=[Text("Click me")],
- )
- assert (
- str(node)
- == '
'
- )
-
-
-def test_interpolated_class_attribute_with_multiple_placeholders():
- classes1 = ["btn", "btn-primary"]
- classes2 = [False and "disabled", None, {"active": True}]
- node = html(t'
')
- # CONSIDER: Is this what we want? Currently, when we have multiple
- # placeholders in a single attribute, we treat it as a string attribute.
- assert node == Element(
- "button",
- attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
- children=[Text("Click me")],
- )
-
-
-def test_interpolated_attribute_spread_with_class_attribute():
- attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
- node = html(t"
")
- assert node == Element(
- "button",
- attrs={"id": "button1", "class": "btn btn-primary"},
- children=[Text("Click me")],
- )
- assert str(node) == '
'
+def test_attr_merge_replace_literal_attr_str_str():
+ node = html(t'
')
+ assert node == Element("div", {"title": "fresh"})
+ assert str(node) == '
'
-def test_interpolated_attribute_value_embedded_placeholder():
- slug = "item42"
- node = html(t"
")
- assert node == Element(
- "div",
- attrs={"data-id": "prefix-item42"},
- children=[],
- )
- assert str(node) == '
'
+def test_attr_merge_replace_literal_attr_str_true():
+ node = html(t'
')
+ assert node == Element("div", {"title": None})
+ assert str(node) == "
"
-def test_interpolated_attribute_value_with_static_prefix_and_suffix():
- counter = 3
- node = html(t'
')
- assert node == Element(
- "div",
- attrs={"data-id": "item-3-suffix"},
- children=[],
- )
- assert str(node) == '
'
+def test_attr_merge_replace_literal_attr_true_str():
+ node = html(t"
")
+ assert node == Element("div", {"title": "fresh"})
+ assert str(node) == '
'
-def test_attribute_value_empty_string():
- node = html(t'
')
- assert node == Element(
- "div",
- attrs={"data-id": ""},
- children=[],
- )
+def test_attr_merge_remove_literal_attr_str_none():
+ node = html(t'
')
+ assert node == Element("div")
+ assert str(node) == "
"
-def test_interpolated_attribute_value_multiple_placeholders():
- start = 1
- end = 5
- node = html(t'
')
- assert node == Element(
- "div",
- attrs={"data-range": "1-5"},
- children=[],
- )
- assert str(node) == '
'
+def test_attr_merge_remove_literal_attr_true_none():
+ node = html(t"
")
+ assert node == Element("div")
+ assert str(node) == "
"
-def test_interpolated_attribute_value_tricky_multiple_placeholders():
- start = "start"
- end = "end"
- node = html(t'
')
- assert node == Element(
- "div",
- attrs={"data-range": "start5-and-end12"},
- children=[],
- )
- assert str(node) == '
'
+def test_attr_merge_other_literal_attr_intact():
+ node = html(t'
![default]()
')
+ assert node == Element("img", {"title": "default", "alt": "fresh"})
+ assert str(node) == '
![default fresh]()
'
def test_placeholder_collision_avoidance():
config = make_placeholder_config()
# This test is to ensure that our placeholder detection avoids collisions
# even with content that might look like a placeholder.
- tricky = "123"
+ tricky = "0"
template = Template(
- '
',
+ f'
',
)
node = html(template)
assert node == Element(
@@ -636,18 +679,9 @@ def test_placeholder_collision_avoidance():
)
-def test_interpolated_attribute_value_multiple_placeholders_no_quotes():
- start = 1
- end = 5
- node = html(t"
")
- assert node == Element(
- "div",
- attrs={"data-range": "1-5"},
- children=[],
- )
- assert str(node) == '
'
-
-
+#
+# Special data attribute handling.
+#
def test_interpolated_data_attributes():
data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None}
node = html(t"
User Info
")
@@ -683,16 +717,48 @@ def test_data_attr_unrelated_unaffected():
assert str(node) == "
"
-@pytest.mark.skip(reason="Waiting on attribute resolution ... resolution.")
-def test_interpolated_data_attribute_multiple_placeholders():
- confusing = {"user-id": "user-123"}
- placeholders = {"role": "admin"}
+def test_data_attr_templated_error():
+ data1 = {"user-id": "user-123"}
+ data2 = {"role": "admin"}
with pytest.raises(TypeError):
- node = html(t'
User Info
')
+ node = html(t'
')
print(str(node))
-def test_interpolated_aria_attributes():
+def test_data_attr_none():
+ button_data = None
+ node = html(t"
")
+ assert node == Element("button", children=[Text("X")])
+ assert str(node) == "
"
+
+
+def test_data_attr_errors():
+ for v in [False, [], (), 0, "data?"]:
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+
+
+def test_data_literal_attr_bypass():
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ node = html(t'
')
+ assert node == Element(
+ "p",
+ attrs={"data": "passthru", "id": "resolved"},
+ ), "A single literal attribute should not trigger data expansion."
+
+
+#
+# Special aria attribute handling.
+#
+def test_aria_templated_attr_error():
+ aria1 = {"label": "close"}
+ aria2 = {"hidden": "true"}
+ with pytest.raises(TypeError):
+ node = html(t'
')
+ print(str(node))
+
+
+def test_aria_interpolated_attr_dict():
aria = {"label": "Close", "hidden": True, "another": False, "more": None}
node = html(t"
")
assert node == Element(
@@ -706,24 +772,124 @@ def test_interpolated_aria_attributes():
)
-def test_special_aria_none():
+def test_aria_interpolate_attr_none():
button_aria = None
node = html(t"
")
assert node == Element("button", children=[Text("X")])
assert str(node) == "
"
-def test_special_data_none():
- button_data = None
- node = html(t"
")
- assert node == Element("button", children=[Text("X")])
- assert str(node) == "
"
+def test_aria_attr_errors():
+ for v in [False, [], (), 0, "aria?"]:
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+
+
+def test_aria_literal_attr_bypass():
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ node = html(t'
')
+ assert node == Element(
+ "p",
+ attrs={"aria": "passthru", "id": "resolved"},
+ ), "A single literal attribute should not trigger aria expansion."
+
+
+#
+# Special class attribute handling.
+#
+def test_interpolated_class_attribute():
+ class_list = ["btn", "btn-primary", "one two", None]
+ class_dict = {"active": True, "btn-secondary": False}
+ class_str = "blue"
+ class_space_sep_str = "green yellow"
+ class_none = None
+ class_empty_list = []
+ class_empty_dict = {}
+ button_t = (
+ t"
"
+ )
+ node = html(button_t)
+ assert node == Element(
+ "button",
+ attrs={"class": "red btn btn-primary one two active blue green yellow"},
+ children=[Text("Click me")],
+ )
+ assert (
+ str(node)
+ == '
'
+ )
+
+
+def test_interpolated_class_attribute_with_multiple_placeholders():
+ classes1 = ["btn", "btn-primary"]
+ classes2 = [False and "disabled", None, {"active": True}]
+ node = html(t'
')
+ # CONSIDER: Is this what we want? Currently, when we have multiple
+ # placeholders in a single attribute, we treat it as a string attribute.
+ assert node == Element(
+ "button",
+ attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
+ children=[Text("Click me")],
+ )
+
+
+def test_interpolated_attribute_spread_with_class_attribute():
+ attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
+ node = html(t"
")
+ assert node == Element(
+ "button",
+ attrs={"id": "button1", "class": "btn btn-primary"},
+ children=[Text("Click me")],
+ )
+ assert str(node) == '
'
+
+
+def test_class_literal_attr_bypass():
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ node = html(t'
')
+ assert node == Element(
+ "p",
+ attrs={"class": "red red", "id": "veryred"},
+ ), "A single literal attribute should not trigger class accumulator."
+
+
+def test_class_none_ignored():
+ class_item = None
+ node = html(t"
")
+ assert node == Element("p")
+ # Also ignored inside a sequence.
+ node = html(t"
")
+ assert node == Element("p")
+
+
+def test_class_type_errors():
+ for class_item in (False, True, 0):
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+
+
+def test_class_merge_literals():
+ node = html(t'
')
+ assert node == Element("p", {"class": "red blue"})
+
+
+def test_class_merge_literal_then_interpolation():
+ class_item = "blue"
+ node = html(t'
')
+ assert node == Element("p", {"class": "red blue"})
#
# Special style attribute handling.
#
-def test_style_in_literal_attr():
+def test_style_literal_attr_passthru():
p_id = "para1" # non-literal attribute to cause attr resolution
node = html(t'
Warning!
')
assert node == Element(
@@ -854,12 +1020,19 @@ def test_style_attribute_non_str_non_dict():
_ = html(t"
Warning!
")
-def test_special_attrs_as_static():
- node = html(t'
')
+def test_style_literal_attr_bypass():
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ node = html(t'
')
assert node == Element(
"p",
- attrs={"aria": "aria?", "data": "data?", "class": "class?", "style": "style?"},
- )
+ attrs={"style": "invalid;invalid:", "id": "resolved"},
+ ), "A single literal attribute should bypass style accumulator."
+
+
+def test_style_none():
+ styles = None
+ node = html(t"
")
+ assert node == Element("p")
# --------------------------------------------------------------------------
@@ -1312,39 +1485,3 @@ def test_mismatched_component_closing_tag_fails():
_ = html(
t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello{ClassComponent}>"
)
-
-
-def test_replace_static_attr_str_str():
- node = html(t'
')
- assert node == Element("div", {"title": "fresh"})
- assert str(node) == '
'
-
-
-def test_replace_static_attr_str_true():
- node = html(t'
')
- assert node == Element("div", {"title": None})
- assert str(node) == "
"
-
-
-def test_replace_static_attr_true_str():
- node = html(t"
")
- assert node == Element("div", {"title": "fresh"})
- assert str(node) == '
'
-
-
-def test_remove_static_attr_str_none():
- node = html(t'
')
- assert node == Element("div")
- assert str(node) == "
"
-
-
-def test_remove_static_attr_true_none():
- node = html(t"
")
- assert node == Element("div")
- assert str(node) == "
"
-
-
-def test_other_static_attr_intact():
- node = html(t'
![default]()
')
- assert node == Element("img", {"title": "default", "alt": "fresh"})
- assert str(node) == '
![default fresh]()
'
diff --git a/tdom/template_utils.py b/tdom/template_utils.py
index 943c5ef..39dc8b3 100644
--- a/tdom/template_utils.py
+++ b/tdom/template_utils.py
@@ -14,6 +14,12 @@ def template_from_parts(
return Template(*flat)
+def combine_template_refs(*template_refs: TemplateRef) -> TemplateRef:
+ return TemplateRef.from_naive_template(
+ sum((tr.to_naive_template() for tr in template_refs), t"")
+ )
+
+
@dataclass(slots=True, frozen=True)
class TemplateRef:
"""Reference to a template with indexes for its original interpolations."""
@@ -39,6 +45,11 @@ def is_singleton(self) -> bool:
"""Return True if there is exactly one interpolation and no other content."""
return self.strings == ("", "")
+ def to_naive_template(self) -> Template:
+ return template_from_parts(
+ self.strings, [Interpolation(i, "", None, "") for i in self.i_indexes]
+ )
+
@classmethod
def literal(cls, s: str) -> t.Self:
return cls((s,), ())
@@ -51,6 +62,13 @@ def empty(cls) -> t.Self:
def singleton(cls, i_index: int) -> t.Self:
return cls(("", ""), (i_index,))
+ @classmethod
+ def from_naive_template(cls, t: Template) -> TemplateRef:
+ return cls(
+ strings=t.strings,
+ i_indexes=tuple([int(ip.value) for ip in t.interpolations]),
+ )
+
def __post_init__(self):
if len(self.strings) != len(self.i_indexes) + 1:
raise ValueError(
diff --git a/tdom/template_utils_test.py b/tdom/template_utils_test.py
index c2b393c..971b1ee 100644
--- a/tdom/template_utils_test.py
+++ b/tdom/template_utils_test.py
@@ -2,7 +2,7 @@
import pytest
-from .template_utils import TemplateRef, template_from_parts
+from .template_utils import TemplateRef, template_from_parts, combine_template_refs
def test_template_from_parts() -> None:
@@ -40,3 +40,18 @@ def test_template_ref_is_singleton() -> None:
def test_template_ref_post_init_validation() -> None:
with pytest.raises(ValueError):
_ = TemplateRef(("Hello",), (0, 1))
+
+
+def test_combine_template_refs():
+ template_refs = map(
+ TemplateRef.from_naive_template,
+ [
+ t"ab",
+ t"c{0}d",
+ t"ef{1}",
+ t"{2}ghi",
+ ],
+ )
+ assert combine_template_refs(*template_refs) == TemplateRef.from_naive_template(
+ t"abc{0}def{1}{2}ghi"
+ )