diff --git a/README.md b/README.md
index 2254085..2040886 100644
--- a/README.md
+++ b/README.md
@@ -25,81 +25,113 @@ E.p("Powered by:").br.a(href="...")("html5tagger")
A complete example with template variables and other features:
```python
-from html5tagger import Document, E
+from html5tagger import Document, E, Template
+
+# Create reusable templates
+Item = Template(E.li.Name("Item"))
# Create a document
doc = Document(
- E.TitleText_, # The first argument is for
, adding variable TitleText
+ "Demo", # The first argument is for
lang="en", # Keyword arguments for attributes
# Just list the resources you need, no need to remember link/script tags
_urls=[ "style.css", "favicon.png", "manifest.json" ]
)
-# Upper case names are template variables. You can modify them later.
-doc.Head_
-doc.h1.TitleText_("Demo") # Goes inside and updates as well
-
# This has been a hard problem for DOM other such generators:
doc.p("A paragraph with ").a("a link", href="/files")(" and ").em("formatting")
+# Use templates to render dynamic content
+doc.h1("Demo")
+doc.ul._(Item(Name="Apple"), Item(Name="Banana"))
+
# Use with for complex nesting (not often needed)
with doc.table(id="data"):
doc.tr.th("First").th("Second").th("Third")
- doc.TableRows_
-
-# Let's add something to the template variables
-doc.Head._script("console.log(' escaping is weird')")
+ for row in range(3):
+ doc.tr
+ for col in range(3):
+ doc.td(row * col)
-table = doc.TableRows
-for row in range(10):
- table.tr
- for col in range(3):
- table.td(row * col)
-
-# Or remove the table data we just added
-doc.TableRows = None
+# Add inline scripts or styles with special escaping
+doc.script("console.log(' escaping is weird')")
```
-You can `str(doc)` to get the HTML code, and using `doc` directly usually has the desired effect as well (e.g. giving HTML responses). Jupyter Notebooks render it as HTML. For debugging, use `repr(doc)` where the templating variables are visible:
+You can `str(doc)` to get the HTML code, and using `doc` directly usually has the desired effect as well (e.g. giving HTML responses). Jupyter Notebooks render it as HTML. For debugging, use `repr(doc)`:
```html
>>> doc
《Document Builder》
-
-《TitleText:Demo》
+Demo
-《Head:》
-《TitleText:Demo》
A paragraph with a link and formatting
+
Demo
+
First Second Third
- 《TableRows》
+ 0 0 0
+ 0 1 2
+ 0 2 4
+
```
The actual HTML output is similar. No whitespace is added to the document, it is all on one line unless the content contains newlines. You may notice that `body` and other familiar tags are missing and that the escaping is very minimal. This is HTML5: the document is standards-compliant with a lot less cruft.
-## Templating (v1 deprecated)
+## Templating
-> ⚠️ **Deprecation notice:** The v1.3 templating API is deprecated as of html5tagger 1.4 and will be removed in 2.0. If you rely on it, pin `html5tagger<2` in your dependencies. Otherwise, upgrade to html5tagger 2.0 for the new templating API.
+A document builder can be turned into a template by `Template(doc)`. Templates prebuild all static content as long strings, leaving only capitalized placeholders to be filled in at render time. This provides extremely fast rendering and allows building a complex page out of clean components.
-The old API lets you mutate template tags inside a `Builder` and later render the document. html5tagger 2.0 replaces this with immutable `Template` objects that you render by calling them with the desired slot values. Placeholders no longer use an underscore suffix: `doc.TagName` adds the placeholder to the document (in v1 `doc.TagName_` did so), and `doc.TagName(value)` sets a default. To migrate, remove the underscore and use `Template(doc)` to compile your document into a static template that can be called with `TagName=` keyword arguments to render HTML output.
+The example below defines a page with `Title` reused for both the `` and ``, and an `Items` list populated from a product list. Parentheses directly after a placeholder set its default value (empty by default).
+
+```python
+from html5tagger import Document, E, Template
+
+# Define the reusable templates once
+Page = Template(Document(E.Title).h1.Title.ul.Items)
+Item = Template(E.li.span[".name"].Name._(": ").span[".price"].Price("N/A"))
+
+# Super fast rendering just fills in the dynamic data
+def render(products: list) -> str:
+ return Page(
+ Title="Product List",
+ Items=[Item(**product) for product in products],
+ )
+
+html = render([
+ {"Name": "Apple", "Price": "$1.20"},
+ {"Name": "Banana"},
+])
+```
+
+```html
+
+
+Product List
+Product List
+
+ Apple : $1.20
+ Banana : N/A
+
+```
+
+A builder can be finalized into a template by `Template(...)`. The resulting `Template` object is immutable and is called with keyword arguments to render the placeholders. Template values follow the same escaping rules as `doc(...)`, and a list of builders or strings is expanded in place.
## Nesting
In HTML5 elements such as ` ` do not need any closing tag, so we can keep adding content without worrying of when it should close. This module does not use closing tags for any elements where those are optional or forbidden.
-A tag is automatically closed when you add content to it or when another tag is added. Setting attributes alone does not close an element. Use `(None)` to close an empty element if any subsequent content is not meant to go inside it, e.g. `doc.script(None, src="...")`.
+A tag is automatically closed when you add content to it or when another tag is added. Setting attributes alone does not close an element, so we can do `doc.div[".foo"]("inside")` where the content still goes inside the div. `None` may be passed for content to close without content, e.g. `doc.div(None)("after")` produces `
after`.
For elements like `` and ``, you can use `with` blocks, pass sub-snippet arguments, or add a template variable.
```python
with doc.ul: # Nest using with
doc.li("Write HTML in Python")
- doc.li("Simple syntax").ul(id="inner").InnerList_ # Nest using template
+ doc.li("Simple syntax").ul(id="inner").InnerList # Nest using template
doc.li("No need for brackets or closing tags")
doc.ul(E.li("Easy").li("Peasy")) # Nest using (...)
```
@@ -126,9 +158,7 @@ Works perfectly in browsers.
## Name mangling and boolean attributes
-Underscore at the end of name is ignored so that `for_` and other attributes may be used despite being reserved words in Python. Other underscores convert into hyphens.
-
-⚠️ The above only is true for HTML elements and attributes, but template placeholders only use an ending underscore to denote that the it is to be placed on the document, rather than be fetched for use.
+Underscore at the end of a name is ignored so that `for_` and other attributes may be used despite being reserved words in Python. Other underscores convert into hyphens.
Boolean values convert into short attributes.
@@ -212,9 +242,9 @@ In the above benchmark html5tagger created the entire document from scratch, one
## Further development
-There have been no changes to the tagging API since 2018 when this module was brought to production use, and thus the interface is considered stable.
+There have been no changes to the tagging API since 2018 when this module was brought to production use, and thus the interface is considered stable with only small incremental changes like the `script` and `style` special methods being added.
-The legacy templating API added as a draft in version 1.3 is deprecated as of version 1.4 and will be removed in 2.0, where it is replaced by a redesigned templating system. Users who depend on the old templating behaviour should pin `html5tagger<2`; all others are encouraged to upgrade to 2.0.
+The templating API added as a draft in version 1.3 is deprecated as of version 1.4 and is removed in 2.0, where it is replaced by a redesigned templating system. Users who depend on the old templating behaviour should pin `html5tagger<2`; all others are encouraged to upgrade to 2.0 which is faster and more versatile.
## Development
diff --git a/html5tagger/__init__.py b/html5tagger/__init__.py
index 735f603..0923206 100755
--- a/html5tagger/__init__.py
+++ b/html5tagger/__init__.py
@@ -1,12 +1,13 @@
-"""Generate HTML5 documents directly from Python code."""
+"""Generate HTML5 documents directly in Python."""
from importlib.metadata import version
-__all__ = "Builder", "Document", "E", "HTML"
+__all__ = "Builder", "Document", "E", "HTML", "Template"
__version__ = version("html5tagger")
from . import builder, document, makebuilder, util
from .builder import Builder
from .document import Document
from .makebuilder import E
+from .template import Template
from .util import HTML
diff --git a/html5tagger/builder.py b/html5tagger/builder.py
index 4938f9d..f08c5e9 100644
--- a/html5tagger/builder.py
+++ b/html5tagger/builder.py
@@ -1,8 +1,24 @@
+from __future__ import annotations
+
import re
from .html5 import omit_endtag
from .nullbuilder import NullBuilder
-from .util import attributes, esc_script, esc_style, escape, escape_attr_value, escape_special, mangle
+from .util import (
+ _OMIT,
+ AttributeSlot,
+ ClassesAttributeSlot,
+ _is_placeholder_builder,
+ _placeholder_default,
+ attributes,
+ esc_script,
+ esc_style,
+ escape,
+ escape_attr_value,
+ escape_special,
+ mangle,
+ render_attributes,
+)
CSS_SELECTOR = re.compile(
r"(?:#(?P[\w-]+))|(?:\.(?P[\w-]+))|(?:\[(?P[\w-]+)(?:=(?P[^\]]*))?\])"
@@ -11,6 +27,26 @@
BACKSLASH_ESC = re.compile(r"\\(.)")
+class AttributedTag:
+ """An opening tag whose attributes may contain dynamic slots."""
+
+ __slots__ = ("prefix", "segments")
+
+ def __init__(self, prefix: str, segments: list[str | AttributeSlot]):
+ self.prefix = prefix
+ self.segments = segments
+
+ def __str__(self):
+ return f"{self.prefix}{''.join(str(s) for s in self.segments)}>"
+
+ @property
+ def brief(self):
+ """A shorter output for the repr() of the document."""
+ value = str(self)
+ value = f":{value[:20]} ···" if len(value) > 100 else f":{value}"
+ return value
+
+
class Builder:
"""Builder generates a document with .elemname(attr1="value", ...) syntax.
@@ -29,6 +65,23 @@ def _clear(self):
self._templates = {} # Template builders
self._endtag = ""
self._stack = []
+ self._pending_slot = None
+
+ def _set_default(self, *_content):
+ """Set a placeholder's default value.
+
+ ``None``/``False``/no argument produces a sentinel that omits the
+ attribute when used as an attribute slot, while keeping content slots
+ empty. ``True`` is preserved so attribute slots can render a short
+ attribute. Everything else is escaped as usual.
+ """
+ self._clear()
+ if not _content or _content[0] is None or _content[0] is False:
+ self._pieces.append(_OMIT)
+ elif len(_content) == 1 and isinstance(_content[0], bool):
+ self._pieces.append(_content[0])
+ else:
+ self._(*_content)
@property
def _allpieces(self):
@@ -50,7 +103,16 @@ def brief(self):
return f"《{self.name}{value}》"
def __repr__(self):
- ret = "".join([frag.brief if isinstance(frag, Builder) else frag for frag in self._allpieces])
+ def fmt(frag):
+ if isinstance(frag, Builder):
+ return frag.brief
+ if isinstance(frag, AttributedTag):
+ return frag.brief
+ if frag is _OMIT:
+ return ""
+ return frag
+
+ ret = "".join(fmt(frag) for frag in self._allpieces)
if len(ret) > 10000:
ret = f"{ret[:1000]} ··· {ret[-1000:]}"
return f"《{self.name}》\n{ret}" if len(ret) > 100 else self.brief
@@ -67,22 +129,27 @@ def __getattr__(self, name):
"""Names that don't begin with underscore are HTML tag names or template blocks."""
if name[0] == "_":
return object.__getattribute__(self, name)
- # If name is uppercase, it is a Template placeholder
+ # If name is uppercase, it is a Template placeholder.
if name[0].isupper():
add_to_doc = name.endswith("_")
if add_to_doc:
name = name[:-1]
builder = self._templates.get(name)
if not builder:
- if not add_to_doc:
- raise AttributeError(f"Template {name} not found. Use doc.{name}_ to add it to the document.")
builder = self._templates[name] = Builder(name=name)
if add_to_doc:
+ # Main style: doc.Head_ adds the placeholder and returns self.
+ self._pending_slot = None
self._pieces.append(builder)
return self
- else:
- return builder
+ # Templating-redux style: accessing a placeholder inserts it and
+ # closes any open tag (e.g. ``doc.span.Tag.br`` == ``Tag ``).
+ self._pending_slot = builder
+ self._pieces.append(builder)
+ self._endtag_close()
+ return self
# Otherwise it is a tag
+ self._pending_slot = None
tagname = mangle(name)
self._endtag_close()
self._pieces.append(f"<{tagname}>")
@@ -90,21 +157,27 @@ def __getattr__(self, name):
self._endtag = f"{tagname}>"
return self
- def __setattr__(self, name, value):
- if not name[0].isupper():
- return object.__setattr__(self, name, value)
- # Set the value of a Template placeholder
- template = self._templates[name]
- template._clear()
- template(value)
-
def __call__(self, *_content, **_attrs):
"""Add attributes and content to the current tag, or append to the document."""
+ # Immediate call after a template placeholder access sets default value.
+ if self._pending_slot is not None:
+ if _attrs:
+ raise TypeError("Cannot add attributes to a template placeholder")
+ slot = self._pending_slot
+ self._pending_slot = None
+ slot._set_default(*_content)
+ return self
+
# Template placeholder just added
if self._pieces and isinstance(self._pieces[-1], Builder):
assert not _attrs, "Cannot add attributes to a template placeholder"
- self._pieces[-1](*_content)
+ # Calling an uppercase placeholder sets/replaces its default value.
+ # Use ._(...) after the placeholder for content that should come after it.
+ slot = self._pieces[-1]
+ slot._set_default(*_content)
return self
+
+ self._pending_slot = None
# Add attributes and content to the current tag
if _attrs:
tag = self._pieces[-1]
@@ -112,24 +185,44 @@ def __call__(self, *_content, **_attrs):
f"Can only add attrs to opening tags, got {tag!r}"
)
if (classes := _attrs.get("classes")) is not None:
- assert "class_" not in _attrs, "Cannot specify both classes= and class_="
- if isinstance(classes, str):
- classes = classes.split()
- elif isinstance(classes, dict):
- classes = [k for k, v in classes.items() if v]
- else:
- classes = list(classes)
- if m := TAG_ATTR.search(tag):
- # Combine with existing class (from earlier [] or ())
- classes = (g if (g := m.group(1)) is not None else m.group(2)).split() + classes
- classes = " ".join(classes)
- tag = tag[: m.start()] + f" class={escape_attr_value(classes)}" + tag[m.end() :]
- del _attrs["classes"]
+ if not isinstance(classes, str) and _is_placeholder_builder(classes):
+ # Defer classes= to render time so templates can supply
+ # string/list/dict class specifications dynamically.
+ static_class = _attrs.pop("class_", "")
+ base = static_class.split() if static_class else []
+ if m := TAG_ATTR.search(tag):
+ existing = (g if (g := m.group(1)) is not None else m.group(2)).split()
+ base = existing + base
+ tag = tag[: m.start()] + tag[m.end() : -1] + ">"
+ placeholder = classes._pieces[0]
+ _attrs["classes"] = ClassesAttributeSlot(
+ placeholder.name,
+ base=" ".join(base) or None,
+ default=_placeholder_default(placeholder),
+ )
else:
- # New attribute, keeping ordering of kwargs
- _attrs["classes"] = " ".join(classes)
- _attrs = {"class" if k == "classes" else k: v for k, v in _attrs.items()}
- self._pieces[-1] = f"{tag[:-1]}{attributes(_attrs)}>"
+ assert "class_" not in _attrs, "Cannot specify both classes= and class_="
+ if isinstance(classes, str):
+ classes = classes.split()
+ elif isinstance(classes, dict):
+ classes = [k for k, v in classes.items() if v]
+ else:
+ classes = list(classes)
+ if m := TAG_ATTR.search(tag):
+ # Combine with existing class (from earlier [] or ())
+ classes = (g if (g := m.group(1)) is not None else m.group(2)).split() + classes
+ classes = " ".join(classes)
+ tag = tag[: m.start()] + f" class={escape_attr_value(classes)}" + tag[m.end() :]
+ del _attrs["classes"]
+ else:
+ # New attribute, keeping ordering of kwargs
+ _attrs["classes"] = " ".join(classes)
+ _attrs = {"class" if k == "classes" else k: v for k, v in _attrs.items()}
+ attr_result = attributes(_attrs)
+ if isinstance(attr_result, str):
+ self._pieces[-1] = f"{tag[:-1]}{attr_result}>"
+ else:
+ self._pieces[-1] = AttributedTag(tag[:-1], attr_result)
if _content:
self._(*_content)
self._endtag_close()
@@ -187,6 +280,7 @@ def __getitem__(self, item):
def _(self, *_content):
"""Append new content without closing the current tag."""
+ self._pending_slot = None
for c in _content:
if c is None:
continue
@@ -227,14 +321,14 @@ def script(self, code: str | None = None, **attrs):
"""Add inline JavaScript correctly escaped."""
self._endtag_close()
code = escape_special(esc_script, code) if code else ""
- self._pieces.append(f"")
+ self._pieces.append(f"")
return self
def style(self, code: str | None = None, **attrs):
"""Add inline CSS correctly escaped."""
self._endtag_close()
code = escape_special(esc_style, code) if code else ""
- self._pieces.append(f"")
+ self._pieces.append(f"")
return self
# compat: until version 1.3.0 underscores had to be used
diff --git a/html5tagger/template.py b/html5tagger/template.py
new file mode 100644
index 0000000..747cd4c
--- /dev/null
+++ b/html5tagger/template.py
@@ -0,0 +1,122 @@
+from .util import HTML, ClassesAttributeSlot, _render_attr, escape
+
+
+class Slot:
+ """A named hole inside a Template."""
+
+ __slots__ = ("name", "default", "render")
+
+ def __init__(self, name: str, default: str = "", render=None):
+ self.name = name
+ self.default = default
+ self.render = render
+
+ def __repr__(self):
+ return f"Slot({self.name!r})"
+
+
+class Template:
+ """A read-only, callable HTML template.
+
+ Define a template by wrapping a Builder. Uppercase placeholders inside the
+ builder become named slots. Calling the template with keyword arguments
+ fills the slots, escapes the values, and returns an HTML string.
+
+ The template object never stores the values passed to it; they are used
+ once during the call and then discarded.
+
+ A template is created by wrapping a builder::
+
+ Item = Template(E.li.Name(""))
+ """
+
+ __slots__ = ("_fragments",)
+
+ def __init__(self, builder):
+ from .builder import Builder
+
+ if not isinstance(builder, Builder):
+ raise TypeError("Template expects a Builder instance")
+ self._fragments = tuple(self._flatten(builder, builder._templates))
+
+ @staticmethod
+ def _flatten(builder, templates):
+ """Convert a Builder into a flat list of strings and Slots."""
+ from .builder import AttributedTag
+
+ fragments: list[str | Slot] = []
+ buffer: list[str] = []
+
+ def flush():
+ if buffer:
+ fragments.append("".join(buffer))
+ buffer.clear()
+
+ def make_attr_render(attr: str):
+ def render(value):
+ return _render_attr(attr, value)
+
+ return render
+
+ for piece in builder._allpieces:
+ if isinstance(piece, str):
+ buffer.append(piece)
+ elif isinstance(piece, AttributedTag):
+ buffer.append(piece.prefix)
+ for segment in piece.segments:
+ if isinstance(segment, str):
+ buffer.append(segment)
+ elif isinstance(segment, ClassesAttributeSlot):
+ flush()
+ fragments.append(
+ Slot(
+ segment.name,
+ default=segment.default,
+ render=segment,
+ )
+ )
+ else:
+ # segment is an AttributeSlot
+ flush()
+ fragments.append(
+ Slot(
+ segment.name,
+ default=segment.default,
+ render=make_attr_render(segment.attr),
+ )
+ )
+ buffer.append(">")
+ else:
+ # piece is a Builder
+ assert piece.name in templates, "Builder pieces must be template slots"
+ flush()
+ default = HTML(piece)
+ fragments.append(Slot(piece.name, default, render=Template._render_value))
+ flush()
+ return fragments
+
+ def __call__(self, **values):
+ """Render the template with the supplied slot values."""
+ parts = []
+ for fragment in self._fragments:
+ if isinstance(fragment, str):
+ parts.append(fragment)
+ else:
+ value = values.get(fragment.name, fragment.default)
+ parts.append(fragment.render(value))
+ return HTML("".join(parts))
+
+ @staticmethod
+ def _render_value(value):
+ if value is None:
+ return ""
+ if hasattr(value, "__html__"):
+ return str(value.__html__())
+ if isinstance(value, (str, bytes, bytearray)):
+ return str(escape(value))
+ if hasattr(value, "__iter__"):
+ return "".join(Template._render_value(v) for v in value)
+ return str(escape(value))
+
+ def __repr__(self):
+ return f"Template({self._fragments!r})"
diff --git a/html5tagger/util.py b/html5tagger/util.py
index a840862..8a1131a 100644
--- a/html5tagger/util.py
+++ b/html5tagger/util.py
@@ -1,6 +1,19 @@
import re
+class _OmitAttribute:
+ """Sentinel for an attribute-slot default that should omit the attribute."""
+
+ def __str__(self):
+ return ""
+
+ def __repr__(self):
+ return "_OMIT"
+
+
+_OMIT = _OmitAttribute()
+
+
class HTML(str):
"""A HTML string that will not be escaped."""
@@ -14,6 +27,94 @@ def escape(text):
return HTML(str(text).replace("&", "&").replace("<", "<"))
+def escape_attr_value(value: str):
+ """Return an attribute value, quoting it when necessary."""
+ return value if value.isalnum() else f'''"{value.replace("&", "&").replace('"', """)}"'''
+
+
+def _attr_value(value):
+ """Render an attribute value, including the leading '=' and optional quotes."""
+ return "=" + escape_attr_value(f"{value}")
+
+
+def _render_attr(attr: str, value) -> str:
+ """Render a single attribute (name + value) like attributes() would.
+
+ Supports True (short attribute), None/False/_OMIT (omit), and normal values.
+ The leading space is included so the attribute can be dropped entirely
+ when the value is None or False.
+ """
+ if value is None or value is False or value is _OMIT:
+ return ""
+ if value is True:
+ return " " + attr
+ if hasattr(value, "__html__"):
+ v = str(value.__html__())
+ if v.isalnum():
+ return " " + attr + "=" + v
+ return " " + attr + '="' + v.replace('"', """) + '"'
+ v = str(value)
+ if v.isalnum():
+ return " " + attr + "=" + v
+ return " " + attr + '="' + v.replace("&", "&").replace('"', """) + '"'
+
+
+class AttributeSlot:
+ """Marker for an attribute value placeholder inside a Builder."""
+
+ __slots__ = ("name", "attr", "default")
+
+ def __init__(self, name: str, attr: str, default=""):
+ self.name = name
+ self.attr = attr
+ self.default = default
+
+ def __str__(self):
+ return _render_attr(self.attr, self.default)
+
+ def __repr__(self):
+ return f"AttributeSlot({self.name!r}, {self.attr!r})"
+
+
+class ClassesAttributeSlot:
+ """Marker for a classes= placeholder inside a Builder.
+
+ Unlike a regular AttributeSlot, this slot accepts the same class
+ specification types as ``classes=`` (string, list, dict, iterable) and
+ renders them as a single ``class`` attribute.
+ """
+
+ __slots__ = ("name", "base", "default")
+
+ def __init__(self, name: str, base: str | None = None, default=None):
+ self.name = name
+ self.base = base
+ self.default = default
+
+ def _resolve(self, value):
+ if value is None or value is False or value is _OMIT or value is True:
+ return []
+ if isinstance(value, str):
+ return value.split()
+ if isinstance(value, dict):
+ return [k for k, v in value.items() if v]
+ return list(value)
+
+ def __call__(self, value):
+ classes = self._resolve(value)
+ if self.base:
+ classes = self.base.split() + classes
+ if not classes:
+ return ""
+ return " class=" + escape_attr_value(" ".join(classes))
+
+ def __str__(self):
+ return self(self.default)
+
+ def __repr__(self):
+ return f"ClassesAttributeSlot({self.name!r})"
+
+
# Inline styles and scripts only escape the specific end tag
esc_style = re.compile("(style>)", re.IGNORECASE)
esc_script = re.compile("(script>)", re.IGNORECASE)
@@ -23,24 +124,84 @@ def escape_special(tag: re.Pattern[str], text):
return HTML(tag.sub(r"<\\/\1", text))
-def escape_attr_value(value: str):
- return value if value.isalnum() else f'''"{value.replace("&", "&").replace('"', """)}"'''
+def _is_placeholder_builder(value):
+ """Detect a Builder that contains only a single uppercase placeholder."""
+ pieces = getattr(value, "_pieces", None)
+ if not pieces or len(pieces) != 1:
+ return False
+ placeholder = pieces[0]
+ name = getattr(placeholder, "name", "")
+ return isinstance(name, str) and name and name[0].isupper()
+
+
+def _placeholder_default(placeholder) -> object:
+ """Extract the default value from a placeholder builder for attribute slots."""
+ pieces = getattr(placeholder, "_pieces", None)
+ if not pieces:
+ return None
+ if len(pieces) == 1:
+ if pieces[0] is _OMIT or pieces[0] is None or pieces[0] is False:
+ return None
+ if isinstance(pieces[0], bool):
+ return pieces[0]
+ return HTML(str(placeholder))
def attributes(attrs):
ret = ""
for k, v in attrs.items():
- k = mangle(k)
if v is None or v is False:
continue
+ if isinstance(v, ClassesAttributeSlot):
+ segments: list[str | AttributeSlot | ClassesAttributeSlot] = [ret] if ret else []
+ segments.append(v)
+ return _attributes_with_slots(attrs, k, segments)
+ k = mangle(k)
+ if not isinstance(v, str) and _is_placeholder_builder(v):
+ # Switch to list mode so the Builder/Template can preserve the slot.
+ segments: list[str | AttributeSlot | ClassesAttributeSlot] = [ret] if ret else []
+ placeholder = v._pieces[0]
+ segments.append(AttributeSlot(placeholder.name, k, default=_placeholder_default(placeholder)))
+ return _attributes_with_slots(attrs, k, segments)
ret += " " + k
if v is True:
continue # Short attribute
- v = escape_attr_value(f"{v}")
- ret += "=" + v
+ ret += _attr_value(v)
return ret
+def _attributes_with_slots(attrs, current_key, segments):
+ """Continue processing attrs after the first slot was found at current_key."""
+ skip = True
+ for k, v in attrs.items():
+ if skip:
+ if k == current_key:
+ skip = False
+ continue
+ if isinstance(v, ClassesAttributeSlot):
+ segments.append(v)
+ continue
+ k = mangle(k)
+ if v is None or v is False:
+ continue
+ if not isinstance(v, str) and _is_placeholder_builder(v):
+ placeholder = v._pieces[0]
+ segments.append(AttributeSlot(placeholder.name, k, default=_placeholder_default(placeholder)))
+ continue
+ segments.append(" " + k)
+ if v is True:
+ continue
+ segments.append(_attr_value(v))
+ return segments
+
+
+def render_attributes(attr_result):
+ """Render the result of attributes() to a plain string for non-template contexts."""
+ if isinstance(attr_result, str):
+ return attr_result
+ return "".join(str(seg) if isinstance(seg, (AttributeSlot, ClassesAttributeSlot)) else seg for seg in attr_result)
+
+
def mangle(name):
"""Mangle Python identifiers into HTML tag/attribute names.
diff --git a/pyproject.toml b/pyproject.toml
index 1de2703..fa39f82 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -99,7 +99,7 @@ select = [
]
ignore = [
"E501", # line too long (handled by formatter)
- "B018", # useless expression (html5tagger uses attribute access for side effects)
+ "B018", # html5tagger's fluent API uses side-effecting attribute access
]
[tool.ruff.lint.per-file-ignores]
diff --git a/scripts/benchmark.py b/scripts/benchmark.py
index 5d199ed..c901bac 100644
--- a/scripts/benchmark.py
+++ b/scripts/benchmark.py
@@ -13,7 +13,71 @@
import timeit
-from html5tagger import Document, E
+from html5tagger import Document, E, Template
+
+# Pre-construct reusable templates once, in the style of the README example.
+# A realistic product card with several nested elements and attributes.
+Item = Template(
+ E.article(class_="product-card", data_sku=E.SKU)(
+ E.div(class_="product-image")(
+ E.span(class_="placeholder")(E.Initial),
+ ),
+ E.div(class_="product-body")(
+ E.h3(class_="product-name")(E.Name),
+ E.p(class_="product-desc")(E.Desc),
+ E.div(class_="product-meta")(
+ E.span(class_="product-price")(E.Price),
+ E.span(class_="product-stock")(E.Stock),
+ ),
+ E.a(class_="product-detail", href="/product/view")("Details"),
+ ),
+ )
+)
+
+
+# A realistic page shell: header, navigation, sidebar, main content, footer.
+# Only the Items slot is dynamic; everything else is prebuilt static HTML.
+Page = Template(
+ Document(
+ "Shop",
+ lang="en",
+ _urls=("style.css", "app.js"),
+ )
+ .header(class_="site-header")(
+ E.div(class_="container")(
+ E.a(class_="logo", href="/")("Shop"),
+ E.nav(class_="main-nav")(
+ E.a(href="/")("Home"),
+ E.a(href="/products")("Products"),
+ E.a(href="/about")("About"),
+ E.a(href="/contact")("Contact"),
+ ),
+ ),
+ )
+ .main(class_="main")(
+ E.div(class_="container")(
+ E.aside(class_="sidebar")(
+ E.h2("Categories"),
+ E.ul(
+ E.li("Electronics"),
+ E.li("Clothing"),
+ E.li("Home & Garden"),
+ E.li("Sports"),
+ E.li("Books"),
+ ),
+ ),
+ E.section(class_="content")(
+ E.h1("Products"),
+ E.div(class_="product-grid").Items,
+ ),
+ ),
+ )
+ .footer(class_="site-footer")(
+ E.div(class_="container")(
+ E.p("© 2026 Shop. All rights reserved."),
+ ),
+ )
+)
def make_products(count: int = 100) -> list[dict[str, str]]:
@@ -31,6 +95,11 @@ def make_products(count: int = 100) -> list[dict[str, str]]:
]
+def render_with_template(products: list[dict[str, str]]) -> str:
+ """Render using pre-built templates."""
+ return Page(Items=[Item(**p) for p in products])
+
+
def render_from_scratch(products: list[dict[str, str]]) -> str:
doc = Document(
"Shop",
@@ -124,19 +193,24 @@ def main() -> None:
products = make_products(100)
# Warm up and verify identical output.
+ html_template = render_with_template(products)
html_scratch = render_from_scratch(products)
html_selectors = render_with_selectors(products)
- assert html_scratch == html_selectors, "Outputs must be identical"
+ assert html_template == html_scratch == html_selectors, "Outputs differ!"
- print(f"Generated HTML length: {len(html_scratch)} bytes")
+ print(f"Generated HTML length: {len(html_template)} bytes")
print(f"Number of products: {len(products)}")
print()
number = 1000
+ t_template = timeit.timeit(lambda: render_with_template(products), number=number)
t_scratch = timeit.timeit(lambda: render_from_scratch(products), number=number)
t_selectors = timeit.timeit(lambda: render_with_selectors(products), number=number)
print(f"Single page render time (averaged over {number} renders):")
+ print(
+ f" Template callable: {t_template * 1000 / number:8.3f} ms ({t_template * 1_000_000 / number / len(products):.2f} µs/item)"
+ )
print(
f" Build from scratch: {t_scratch * 1000 / number:8.3f} ms ({t_scratch * 1_000_000 / number / len(products):.2f} µs/item)"
)
@@ -144,6 +218,7 @@ def main() -> None:
f" With CSS selectors: {t_selectors * 1000 / number:8.3f} ms ({t_selectors * 1_000_000 / number / len(products):.2f} µs/item)"
)
print()
+ print(f"Template is {t_scratch / t_template:.1f}x faster than building from scratch")
if __name__ == "__main__":
diff --git a/tests/test_html5tagger.py b/tests/test_html5tagger.py
index b481168..ce1df59 100644
--- a/tests/test_html5tagger.py
+++ b/tests/test_html5tagger.py
@@ -371,3 +371,21 @@ def test_classes_on_template_placeholder_raises():
assert doc.Head_ is doc
with pytest.raises(AssertionError):
doc(classes="foo")
+
+
+def test_builder_no_content_creates_empty():
+ snippet = Builder("Empty")
+ assert str(snippet) == ""
+
+
+def test_builder_underscore_appends_none_is_ignored():
+ doc = Document()
+ doc._(None, "hello", None)
+ assert str(doc) == "hello"
+
+
+def test_builder_underscore_appends_own_template_directly():
+ doc = Document(E.Title_)
+ title = doc._templates["Title"]
+ doc._(title)
+ assert doc._pieces[-1] is title
diff --git a/tests/test_templating.py b/tests/test_templating.py
index 6315327..95bec04 100644
--- a/tests/test_templating.py
+++ b/tests/test_templating.py
@@ -2,43 +2,438 @@
import pytest
-from html5tagger import Document, E
+from html5tagger import HTML, Document, E, Template
+## Placeholder creation
-def test_template_placeholder():
- doc = Document(E.TitleText_)
- doc.h1.TitleText_("Hello")
- assert "Hello " in str(doc)
- assert "Hello " in str(doc)
+def test_template_insert_placeholder():
+ doc = Document(E.TitleText)
+ doc.h1.TitleText
+ item = Template(doc)
+ assert str(item(TitleText="Hello")) == 'Hello Hello '
-def test_template_fetch_and_update():
- doc = Document(E.TitleText_)
- doc.h1.TitleText_("Hello")
- title = doc.TitleText
- title("World")
- assert "HelloWorld " in str(doc)
- assert "HelloWorld " in str(doc)
+def test_template_closes_open_tag():
+ """doc.span.Tag.br == Tag """
+ doc = Document()
+ doc.span.Tag.br
+ item = Template(doc)
+ assert str(item(Tag="content")) == "content "
-def test_template_not_found_raises():
- doc = Document("Demo")
- with pytest.raises(AttributeError):
- _ = doc.MissingTemplate
+def test_template_creates_on_access():
+ """Accessing an unknown uppercase name creates and inserts the placeholder."""
+ doc = Document()
+ doc.Missing
+ assert "Missing" in doc._templates
-def test_clear_template():
- doc = Document("Demo")
- assert doc.Head_ is doc
- doc.Head = None
- assert doc.Head is not None
+## Stateless Template callable
-def test_template_reuse_in_doc():
- doc = Document("Demo")
- assert doc.Head_ is doc
- head = doc.Head
- doc._(head)
- assert "Demo " in str(doc)
- # Adding the same template builder back appends it directly.
- assert doc._pieces[-1] is head
+
+def test_template_constructor():
+ item = Template(E.li.Name(""))
+ assert str(item(Name="Apple")) == "Apple"
+
+
+def test_template_default_value():
+ item = Template(E.li.Name("unknown"))
+ assert str(item()) == " unknown"
+ assert str(item(Name="Apple")) == " Apple"
+
+
+def test_template_slot_call_replaces_default():
+ item = Template(E.li.Name("first").Name("second"))
+ assert str(item()) == " secondsecond"
+
+
+def test_template_slot_default_stays_in_place_when_called_after_access():
+ item = Template(E.li.span.Name("NONAME").span.Price)
+ assert str(item(Price="$1.20")) == " NONAME $1.20 "
+ assert str(item(Name="Banana", Price="$0.80")) == "Banana $0.80 "
+
+
+def test_template_slot_followed_by_append_goes_after_parent_tag():
+ item = Template(E.li.span.Name._("X"))
+ assert str(item(Name="A")) == "A X"
+
+
+def test_template_escapes_values():
+ item = Template(E.li.Name(""))
+ assert str(item(Name="