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):