Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions tdom/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -371,12 +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:
open_tag = self.make_open_tag(tag, attrs)
# 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 explicitly dissallow this usage without preceding ascii whitespace.
# This applies to all tags (components or not).
if self.always_get_starttag_text().endswith("/>"):
if isinstance(open_tag, OpenTComponent):
error_tag = self.get_source().format_starttag(open_tag.start_i_index)
else:
error_tag = tag
raise ValueError(
f'Ambiguous self-closing tag, "<{error_tag}.../>", please precede "/>" with whitespace or apply quotes around the last attribute value.'
)

if isinstance(open_tag, OpenTElement) and open_tag.tag in VOID_ELEMENTS:
final_tag = self.finalize_tag(open_tag)
self.append_child(final_tag)
Expand Down
74 changes: 74 additions & 0 deletions tdom/parser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,77 @@ def test_extract_with_templated_attr_gt_char(self, Component):
strings=("<div>Hello, World!</div>",), 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"<div data={dataset} title={title}>{children}</div>"

return component

def test_component_ok(self, comp):
dynamic = "dynamic"
attrs = {"active": True}
for template in [
t"<{comp}/>",
t"<{comp} active/>", # Still ok because attr name cannot contain /
t"<{comp} {attrs}/>", # Still ok because attr name cannot contain /
t"<{comp} />",
t"<{comp} title=literal />",
t"<{comp} title=literal/ ></{comp}>", # This is really gross but shouldn't be common.
t'<{comp} title="literal"/>',
t"<{comp} title={dynamic} />",
t'<{comp} title="{dynamic}"/>',
t"<{comp} title={dynamic}literal />",
t'<{comp} title="{dynamic}literal"/>',
]:
tnode = TemplateParser.parse(template)
assert isinstance(tnode, TComponent) and tnode.start_i_index == 0

def test_component_ambiguous_error(self, comp):
dynamic = "dynamic"
for template in (
t"<{comp} title=literal/>",
t"<{comp} title={dynamic}/>",
t"<{comp} title={dynamic}literal/>",
t"<{comp} title=/>",
t"<{comp} title= />", # WS between = and value is ignored, so title=/
):
with pytest.raises(ValueError, match="Ambiguous self-closing tag"):
_ = TemplateParser.parse(template)

def test_element_ok(self):
dynamic = "dynamic"
attrs = {"active": True}
for template in (
t"<div/>",
t"<div active/>", # Still ok because attr name cannot contain /
t"<div {attrs}/>", # Still ok because attr name cannot contain /
t"<div />",
t"<div title=literal />",
t"<div title=literal/ ></div>", # This is really gross but shouldn't be common.
t'<div title="literal"/>',
t"<div title={dynamic} />",
t'<div title="{dynamic}"/>',
t"<div title={dynamic}literal />",
t'<div title="{dynamic}literal"/>',
):
tnode = TemplateParser.parse(template)
assert isinstance(tnode, TElement) and tnode.tag == "div"

def test_element_ambiguous_error(self):
dynamic = "dynamic"
for template in (
t"<div title=literal/>",
t"<div title={dynamic}/>",
t"<div title={dynamic}literal/>",
t"<div title=/>",
t"<div title= />", # WS between = and value is ignored, so title=/
):
with pytest.raises(ValueError, match="Ambiguous self-closing tag"):
_ = TemplateParser.parse(template)
Loading