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'
' + t"Hello,
world !" + t"
" + ) + assert node == TFragment( + children=( + TDocumentType("html"), + TComment.literal(" Comment "), + TElement( + "div", + attrs=(TLiteralAttribute("class", "container"),), + children=( + TText.literal("Hello, "), + TElement("br", attrs=(TLiteralAttribute("class", "funky"),)), + TText.literal("world "), + TComment.literal(" neato "), + TText.literal("!"), + ), + ), + ) + ) + + +# +# Text +# def test_parse_empty(): node = TemplateParser.parse(t"") assert node == TFragment() @@ -26,11 +56,37 @@ def test_parse_text(): assert node == TText.literal("Hello, world!") +def test_parse_text_multiline(): + node = TemplateParser.parse(t"""Hello, world! + Hello, moon! +Hello, sun! +""") + assert node == TText.literal("""Hello, world! + Hello, moon! +Hello, sun! +""") + + def test_parse_text_with_entities(): - node = TemplateParser.parse(t"Panini's") - assert node == TText.literal("Panini's") + node = TemplateParser.parse(t"a < b") + assert node == TText.literal("a < b") + + +def test_parse_text_with_template_singleton(): + greeting = "Hello, World!" + node = TemplateParser.parse(t"{greeting}") + assert node == TText(ref=TemplateRef(strings=("", ""), i_indexes=(0,))) + + +def test_parse_text_with_template(): + who = "World" + node = TemplateParser.parse(t"Hello, {who}!") + assert node == TText(ref=TemplateRef(strings=("Hello, ", "!"), i_indexes=(0,))) +# +# Elements +# def test_parse_void_element(): node = TemplateParser.parse(t"
") assert node == TElement("br") @@ -62,40 +118,23 @@ def test_parse_nested_elements(): ) -def test_parse_element_with_attributes(): - node = TemplateParser.parse( - t'Link' - ) +def test_parse_element_with_template(): + who = "World" + node = TemplateParser.parse(t"
Hello, {who}!
") assert node == TElement( - "a", - attrs=( - TLiteralAttribute("href", "https://example.com"), - TLiteralAttribute("target", "_blank"), - ), - children=(TText.literal("Link"),), + "div", + children=(TText(ref=TemplateRef(strings=("Hello, ", "!"), i_indexes=(0,))),), ) -def test_parse_element_attribute_order(): - node = TemplateParser.parse(t'') - assert isinstance(node, TElement) - assert node.attrs == ( - TLiteralAttribute("title", "a"), - TLiteralAttribute("href", "b"), - TLiteralAttribute("title", "c"), +def test_parse_element_with_template_singleton(): + greeting = "Hello, World!" + node = TemplateParser.parse(t"
{greeting}
") + assert node == TElement( + "div", children=(TText(ref=TemplateRef(strings=("", ""), i_indexes=(0,))),) ) -def test_parse_comment(): - node = TemplateParser.parse(t"") - assert node == TComment.literal(" This is a comment ") - - -def test_parse_doctype(): - node = TemplateParser.parse(t"") - assert node == TDocumentType("html") - - def test_parse_multiple_voids(): node = TemplateParser.parse(t"






") assert node == TFragment( @@ -111,31 +150,7 @@ def test_parse_multiple_voids(): ) -def test_parse_mixed_content(): - node = TemplateParser.parse( - t'
' - t"Hello,
world !
" - ) - assert node == TFragment( - children=( - TDocumentType("html"), - TComment.literal(" Comment "), - TElement( - "div", - attrs=(TLiteralAttribute("class", "container"),), - children=( - TText.literal("Hello, "), - TElement("br", attrs=(TLiteralAttribute("class", "funky"),)), - TText.literal("world "), - TComment.literal(" neato "), - TText.literal("!"), - ), - ), - ) - ) - - -def test_parse_entities_are_escaped(): +def test_parse_text_entities(): node = TemplateParser.parse(t"

</p>

") assert node == TElement( "p", @@ -159,7 +174,7 @@ def test_parse_script_with_entities(): assert node == TElement( "script", children=(TText.literal("var x = 'a & b';"),), - ) + ), "Entities SHOULD NOT be evaluated in scripts." def test_parse_textarea_tag_content(): @@ -178,7 +193,7 @@ def test_parse_textarea_with_entities(): assert node == TElement( "textarea", children=(TText.literal("var x = 'a & b';"),), - ) + ), "Entities SHOULD be evaluated in textarea/title." def test_parse_title_unusual(): @@ -233,18 +248,53 @@ def test_self_closing_void_tags_unexpected_closing_tag(): _ = TemplateParser.parse(t"") -def test_literal_attributes(): - node = TemplateParser.parse(t'') +# +# Attributes +# +def test_literal_attrs(): + node = TemplateParser.parse( + ( + t"Link" + ) + ) assert node == TElement( - "input", + "a", attrs=( - TLiteralAttribute("type", "text"), - TLiteralAttribute("disabled", None), + TLiteralAttribute("id", "example_link"), + TLiteralAttribute("autofocus", None), + TLiteralAttribute("title", ""), + TLiteralAttribute("href", "https://example.com"), + TLiteralAttribute("target", "_blank"), ), + children=(TText.literal("Link"),), ) -def test_interpolated_attributes(): +def test_literal_attr_entities(): + node = TemplateParser.parse(t'Link') + assert node == TElement( + "a", + attrs=(TLiteralAttribute("title", "<"),), + children=(TText.literal("Link"),), + ) + + +def test_literal_attr_order(): + node = TemplateParser.parse(t'') + assert isinstance(node, TElement) + assert node.attrs == ( + TLiteralAttribute("title", "a"), + TLiteralAttribute("href", "b"), + TLiteralAttribute("title", "c"), # dupe IS allowed + ) + + +def test_interpolated_attr(): value1 = 42 value2 = 99 node = TemplateParser.parse(t'
') @@ -258,7 +308,7 @@ def test_interpolated_attributes(): ) -def test_templated_attributes(): +def test_templated_attr(): value1 = 42 value2 = 99 node = TemplateParser.parse( @@ -276,6 +326,16 @@ def test_templated_attributes(): ) +def test_spread_attr(): + spread_attrs = {} + node = TemplateParser.parse(t"
") + assert node == TElement( + "div", + attrs=(TSpreadAttribute(i_index=0),), + children=(), + ) + + def test_templated_attribute_name_error(): with pytest.raises(ValueError): attr_name = "some-attr" @@ -289,13 +349,62 @@ def test_templated_attribute_name_and_value_error(): _ = TemplateParser.parse(t'
') -def test_spread_attribute(): - props = "doesnt-matter-the-type" - node = TemplateParser.parse(t"
") - assert node == TElement( - "div", - attrs=(TSpreadAttribute(i_index=0),), - children=(), +def test_adjacent_spread_attrs_error(): + with pytest.raises(ValueError): + attrs1 = {} + attrs2 = {} + _ = TemplateParser.parse(t"
") + + +# +# Comments +# +def test_parse_comment(): + node = TemplateParser.parse(t"") + assert node == TComment.literal(" This is a comment ") + + +def test_parse_comment_interpolation(): + text = "comment" + node = TemplateParser.parse(t"") + assert node == TComment( + ref=TemplateRef(strings=(" This is a ", " "), i_indexes=(0,)) + ) + + +# +# Doctypes +# +def test_parse_doctype(): + node = TemplateParser.parse(t"") + assert node == TDocumentType("html") + + +def test_parse_doctype_interpolation_error(): + extra = "SYSTEM" + with pytest.raises(ValueError): + _ = TemplateParser.parse(t"") + + +def test_unsupported_decl_error(): + with pytest.raises(NotImplementedError): + _ = TemplateParser.parse(t"") # Unknown declaration + with pytest.raises(NotImplementedError): + _ = TemplateParser.parse(t"") # missing DTD + + +# +# Components. +# +def test_component_element_with_children(): + def Component(children): + return t"{children}" + + node = TemplateParser.parse(t"<{Component}>
Hello, World!
") + assert node == TComponent( + start_i_index=0, + end_i_index=1, + children=(TElement("div", children=(TText.literal("Hello, World!"),)),), ) @@ -340,3 +449,19 @@ def Component(): with pytest.raises(ValueError): _ = TemplateParser.parse(t"
") + + +def test_adjacent_start_component_tag_error(): + def Component(): + pass + + with pytest.raises(ValueError): + _ = TemplateParser.parse(t"<{Component}{Component}>") + + +def test_adjacent_end_component_tag_error(): + def Component(): + pass + + with pytest.raises(ValueError): + _ = TemplateParser.parse(t"<{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"

Hello

World

") 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'') + assert node == Element("img", {"title": "default", "alt": "fresh"}) + assert str(node) == '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" ) - - -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'') - assert node == Element("img", {"title": "default", "alt": "fresh"}) - assert str(node) == '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" + )