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/ >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) + )