diff --git a/tdom/parser.py b/tdom/parser.py
index 2eec36d..b72b5f2 100644
--- a/tdom/parser.py
+++ b/tdom/parser.py
@@ -189,11 +189,9 @@ def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag:
# @NOTE: This must be called when the tag is handled since it is
# populated based on the most recently finished start tag. Otherwise
# the value will be out of sync.
- starttag_text = self.get_starttag_text()
- if starttag_text is None:
- raise AssertionError(
- f"Expected startag_text to be set when parsing component at {i_index}."
- )
+ starttag_text = self.always_get_starttag_text(
+ f"Expected startag_text to be set when parsing component at {i_index}."
+ )
tattrs = self.make_tattrs(attrs)
@@ -371,11 +369,40 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None:
# any of this in the parser, instead relying on higher layers.
return tag_ref.i_indexes[0]
+ def always_get_starttag_text(
+ self, msg: str = "Expecting starttag text to be set."
+ ) -> str:
+ """
+ Wrap get_starttag_text and just raise if None is returned.
+
+ Do this so we don't guard for `None` everywhere.
+ """
+ starttag_text = self.get_starttag_text()
+ if starttag_text is None:
+ raise AssertionError(msg)
+ return starttag_text
+
# ------------------------------------------
# HTMLParser tag callbacks
# ------------------------------------------
def handle_starttag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None:
+ # An unquoted attribute value within a self-closing tag can consume
+ # the "/" as part of the attribute's value if not separated by whitespace.
+ # This is the CORRECT behavior but can can be especially confusing if
+ # preceded by an interpolation, such as `<{Comp} name={value}/>`.
+ # We correct this usage by removing the / from the last attr's value
+ # and treating the tag as self-closing.
+ # This applies to all tags (components or not).
+ if (
+ self.always_get_starttag_text().endswith("/>")
+ and len(attrs) > 0
+ and isinstance(attrs[-1][1], str) # attr is not boolean
+ and attrs[-1][1][-1] == "/" # attr value ends with /
+ ):
+ return self.handle_startendtag(
+ tag, (*attrs[:-1], (attrs[-1][0], attrs[-1][1][:-1]))
+ )
open_tag = self.make_open_tag(tag, attrs)
if isinstance(open_tag, OpenTElement) and open_tag.tag in VOID_ELEMENTS:
final_tag = self.finalize_tag(open_tag)
diff --git a/tdom/parser_test.py b/tdom/parser_test.py
index d1650ae..ed0f6c7 100644
--- a/tdom/parser_test.py
+++ b/tdom/parser_test.py
@@ -602,3 +602,120 @@ def test_extract_with_templated_attr_gt_char(self, Component):
strings=("
Hello, World!
",), i_indexes=()
),
)
+
+
+class TestAmbiguousSelfCloseCheck:
+ @pytest.fixture
+ def comp(self):
+ def component(
+ active: bool = False, title: str = "Title", children: Template = t""
+ ) -> Template:
+ dataset = {"active": active}
+ return t"{children}
"
+
+ return component
+
+ def test_component_ok(self, comp):
+ dynamic = "dynamic"
+ attrs = {"active": True}
+ for template in [
+ t"<{comp}/>abc",
+ t"<{comp} active/>abc", # Still ok because attr name cannot contain /
+ t"<{comp} {attrs}/>abc", # Still ok because attr name cannot contain /
+ t"<{comp} />abc",
+ t"<{comp} title=literal />abc",
+ t"<{comp} title=literal/ >{comp}>abc", # This is really gross but shouldn't be common.
+ t'<{comp} title="literal"/>abc',
+ t"<{comp} title={dynamic} />abc",
+ t'<{comp} title="{dynamic}"/>abc',
+ t"<{comp} title={dynamic}literal />abc",
+ t'<{comp} title="{dynamic}literal"/>abc',
+ ]:
+ tnode = TemplateParser.parse(template)
+ assert (
+ isinstance(tnode, TFragment)
+ and len(tnode.children) == 2
+ and isinstance(tnode.children[0], TComponent)
+ )
+
+ def test_component_ambiguous_corrected(self, comp):
+ dynamic = "dynamic"
+ for template in (
+ t"<{comp} title=literal/>abc",
+ t"<{comp} title={dynamic}/>abc",
+ t"<{comp} title={dynamic}literal/>abc",
+ t"<{comp} title=/>abc",
+ t"<{comp} title= />abc", # WS between = and value is ignored, so title=/
+ ):
+ tnode = TemplateParser.parse(template)
+ assert (
+ isinstance(tnode, TFragment)
+ and len(tnode.children) == 2
+ and isinstance(tnode.children[0], TComponent)
+ )
+
+ def test_component_ambiguous_corrected_attr_value_literal(self, comp):
+ tnode = TemplateParser.parse(t"<{comp} title=literal/>")
+ assert (
+ isinstance(tnode, TComponent)
+ and isinstance(tnode.attrs[-1], TLiteralAttribute)
+ and tnode.attrs[-1].value == "literal"
+ )
+
+ def test_component_ambiguous_corrected_attr_value_interpolated(self, comp):
+ dynamic = "dynamic"
+ tnode = TemplateParser.parse(t"<{comp} title={dynamic}/>")
+ assert (
+ isinstance(tnode, TComponent)
+ and isinstance(tnode.attrs[-1], TInterpolatedAttribute)
+ and tnode.attrs[-1].value_i_index == 1
+ )
+
+ def test_component_ambiguous_corrected_attr_value_templated(self, comp):
+ dynamic = "dynamic"
+ tnode = TemplateParser.parse(t"<{comp} title=prefix{dynamic}/>")
+ assert (
+ isinstance(tnode, TComponent)
+ and isinstance(tnode.attrs[-1], TTemplatedAttribute)
+ and tnode.attrs[-1].value_ref
+ == TemplateRef(strings=("prefix", ""), i_indexes=(1,))
+ )
+
+ def test_element_ok(self):
+ dynamic = "dynamic"
+ attrs = {"active": True}
+ for template in (
+ t"abc",
+ t"abc", # Still ok because attr name cannot contain /
+ t"abc", # Still ok because attr name cannot contain /
+ t"abc",
+ t"abc",
+ t"abc", # This is really gross but shouldn't be common.
+ t'abc',
+ t"abc",
+ t'abc',
+ t"abc",
+ t'abc',
+ ):
+ tnode = TemplateParser.parse(template)
+ assert (
+ isinstance(tnode, TFragment)
+ and len(tnode.children) == 2
+ and isinstance(tnode.children[0], TElement)
+ )
+
+ def test_element_ambiguous_corrected(self):
+ dynamic = "dynamic"
+ for template in (
+ t"abc",
+ t"
abc",
+ t"
abc",
+ t"
abc",
+ t"
abc", # WS between = and value is ignored, so title=/
+ ):
+ tnode = TemplateParser.parse(template)
+ assert (
+ isinstance(tnode, TFragment)
+ and len(tnode.children) == 2
+ and isinstance(tnode.children[0], TElement)
+ )