diff --git a/docs/source/markup.rst b/docs/source/markup.rst index fc0fa57..37f9663 100644 --- a/docs/source/markup.rst +++ b/docs/source/markup.rst @@ -190,7 +190,7 @@ that normally would not be transformed. .. doctest:: transforms1 >>> print(html.tag('squiznart', auto_tabindex=True)) - + The Python APIs and the Generator tags use "_"-separated transform names (valid Python identifiers) as shown below, however please note that Genshi diff --git a/src/flatland/out/markup.py b/src/flatland/out/markup.py index ac10dd4..a217b52 100644 --- a/src/flatland/out/markup.py +++ b/src/flatland/out/markup.py @@ -6,6 +6,31 @@ _default_settings = {"ordered_attributes": True} _static_attribute_order = ["type", "name", "value"] +# HTML5 void elements that should never have separate closing tags. +# These are elements that cannot have content and should be: +# - self-closing in X(HT)ML, like
, or +# - dangling (no closing tag) in HTML5, like
+# If an element is not in this set, it should have a separate closing +# tag, like:
content
+VOID_ELEMENTS = frozenset( + [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ] +) + class Generator(Context): """General XML/HTML tag generator""" @@ -84,7 +109,7 @@ def form(self): If provided with a bind, form tags can generate the *name* attribute. """ - return self._tag("form", False, True) + return self._tag("form") @property def input(self): @@ -98,7 +123,7 @@ def input(self): and *id* attributes. Input tags support *tabindex* attributes. """ - return self._tag("input", True) + return self._tag("input") @property def textarea(self): @@ -115,7 +140,7 @@ def textarea(self): :meth:`~Tag.open` and :meth:`~Tag.close` method of the returned tag. """ - return self._tag("textarea", False, True) + return self._tag("textarea") @property def button(self): @@ -143,7 +168,7 @@ def select(self): attributes. Select tags support *tabindex* attributes. """ - return self._tag("select", False, True) + return self._tag("select") @property def option(self): @@ -162,7 +187,7 @@ def option(self): print(generator.option.close()) """ - return self._tag("option", False, True) + return self._tag("option") @property def label(self): @@ -199,10 +224,10 @@ def tag(self, tagname, bind=None, **attributes): else: return self._tag(tagname)(bind, **attributes) - def _tag(self, tagname, empty_in_html=False, always_paired=False): + def _tag(self, tagname): if self._tags[tagname]: return self._tags[tagname][-1] - return Tag(tagname, self, empty_in_html, always_paired) + return Tag(tagname, self) class Tag: @@ -222,13 +247,11 @@ class Tag: """ - __slots__ = ("tagname", "contents", "_context", "_html_dangle", "_always_paired") + __slots__ = ("tagname", "contents", "_context") - def __init__(self, tagname, context, dangle, paired): + def __init__(self, tagname, context): self.tagname = tagname self._context = context - self._html_dangle = dangle - self._always_paired = paired self.contents = None def open(self, bind=None, **attributes): @@ -238,12 +261,24 @@ def open(self, bind=None, **attributes): :param \*\*attributes: any desired tag attributes. """ + if self.tagname in VOID_ELEMENTS: + raise ValueError( + f"Cannot call open() on void element '<{self.tagname}>'. " + f"Void elements must be generated as complete tags. " + f"Use: gen.{self.tagname}(...) instead of gen.{self.tagname}.open(...)" + ) if self not in self._context._tags[self.tagname]: self._context._tags[self.tagname].append(self) return self._markup(self._open(bind, attributes) + ">") def close(self): """Return the closing half of the tag, e.g. ``

``.""" + if self.tagname in VOID_ELEMENTS: + raise ValueError( + f"Cannot call close() on void element '<{self.tagname}>'. " + f"Void elements cannot have closing tags. " + f"Use: gen.{self.tagname}(...) instead of gen.{self.tagname}.open(...) + gen.{self.tagname}.close()" + ) try: self._context._tags[self.tagname].remove(self) except ValueError: @@ -282,13 +317,10 @@ def _markup(self, string): def __call__(self, bind=None, **attributes): """Return a complete, closed markup string.""" header = self._open(bind, attributes) + if self.tagname in VOID_ELEMENTS: + # we ignore self.contents here, there must not be any. + return self._markup(header + (" />" if self._context.xml else ">")) contents = self.contents - if not contents: - if not self._always_paired: - if self._context.xml: - return self._markup(header + " />") - elif self._html_dangle: - return self._markup(header + ">") if hasattr(contents, "__html__"): contents = _unpack(contents) return self._markup(header + ">" + contents + self._close()) diff --git a/tests/markup/test_generator.py b/tests/markup/test_generator.py index eac00d7..97d7bcd 100644 --- a/tests/markup/test_generator.py +++ b/tests/markup/test_generator.py @@ -63,10 +63,19 @@ def test_detached_reuse(el): tag.close() +def test_input_open(el, xmlgen): + """Test that open() raises ValueError for void element input.""" + with pytest.raises(ValueError) as exc_info: + xmlgen.input.open(type="text", bind=el) + assert "Cannot call open() on void element ''" in str(exc_info.value) + + def test_input_close(el, xmlgen): - """""" + """Test that close() raises ValueError for void element input.""" xmlgen.input(type="text", bind=el) - assert xmlgen.input.close() == """""" + with pytest.raises(ValueError) as exc_info: + xmlgen.input.close() + assert "Cannot call close() on void element ''" in str(exc_info.value) def test_textarea_escaped(xmlgen, el):