HTML

Build HTML as Python tag-call trees.

stario.markup builds HTML as tag-call trees, and handlers send them with w.html.

from stario import h
from stario.markup import HtmlElement
 
 
def home_view(title: str) -> HtmlElement:
    return h.HtmlDocument(
        h.Head(h.Title(title)),
        h.Body(h.H1(title), h.P("Hello from Stario.")),
    )

Import from stario import h; there is no stario.html module. classes exports from the root too. h.HtmlDocument includes <!doctype html>; use h.Html or a fragment for patches. w.html accepts str, bytes, or a tree. Datastar attributes come from stario.datastar, not stario.markup.data.

Package layout#

  • stario.markup.html (h) — the HTML tag catalog: h.Div, h.P, h.HtmlDocument, …

  • stario.markup.svg — SVG tags: svg.Circle, svg.Path, …

  • stario.markup.render — render(tree) → str, for tests, emails, and debugging.

  • stario.markup.classes, styles, data, aria — Attrs fragments for class, style, static data-*, aria-*.

  • stario.markup.baked — @baked layouts (optional performance).

  • stario.markup.Tag — custom element names not in the catalog.

  • stario.markup.types — TagAttributes, AttributeValue for typing.

Type aliases HtmlElement, SafeString, Attrs, Comment export from stario.markup. For Datastar reactive attributes use from stario.datastar import data; stario.markup.data is only for static data-* HTML attributes on ordinary elements.

Tags and SafeString#

A Tag is a callable factory for one element name. Built-in tags use PascalCase so call sites read like h.Div(...). Define custom elements the same way:

from stario.markup import Tag, render
from stario import h
 
UserCard = Tag("user-card")
render(UserCard({"class": "rounded border p-4"}, h.P("Hello")))

For void elements, pass empty="void" to Tag. Built-ins such as h.Br, h.Img, and h.Hr already do this. Custom tag names must match ^[A-Za-z][A-Za-z0-9-]*$, e.g. user-card.

h.HtmlDocument prepends <!doctype html> before the root <html> element. h.Html renders a plain <html> tag for fragments and tests.

SafeString wraps markup inserted verbatim, with no escaping. Ordinary str children are escaped for text context; attribute values go through attribute escaping.

Comments#

Comment renders <!-- … -->. Text is escaped so > cannot break out of the comment:

from stario.markup import Comment, SafeString, render
 
render(Comment("note"))           # <!--note-->
render(Comment("a --> b"))        # <!--a --&gt; b--> (escaped)
render(Comment(SafeString("x")))  # trusted body

Accepted content: str, SafeString, int, float, None (empty comment). bool is rejected — convert explicitly if you need it as text.

Children and attributes#

When you call a tag, positional arguments are processed in order:

  • A mapping, usually a dict, merges into the opening tag as attributes. Several dicts in a row are allowed; they are not deduplicated.

  • An Attrs fragment from helpers such as classes(), styles(), data(), aria(), or data.bind(...) from stario.datastar.

  • Anything else — strings, numbers, lists, SafeString, nested tag calls — is child content. A list is more children; do not unpack it. None is skipped; int and float render as text.

  • Raw bool is not valid as a child. Use booleans in attribute dicts for boolean attributes, or branch so you omit the node.

h.Ul([h.Li(name) for name in names])

render(a, b) accepts multiple root nodes. Attributes must come before the first non-mapping, non-Attrs child.

from stario import classes, h
from stario.datastar import data
from stario.markup import render
 
view = h.Div(
    {"class": "card", "id": "main"},
    data.signals({"open": False}),
    h.H2("Title"),
    h.P("Body copy."),
)
render(view)
 
render(h.Div(classes("btn", "primary"), "Save"))

Attrs#

Attrs holds trusted, pre-rendered opening-tag attribute text, including leading spaces. classes(), styles(), data(), aria() from stario.markup return Attrs, and data.on, data.signals, and friends from stario.datastar do too.

Pass Attrs as tag positional arguments alongside attribute dicts, not inside dict values.

from stario import classes, h
from stario.datastar import data
 
h.Button(
    {"type": "submit"},
    data.on("click", "@post('/save')"),
    classes("btn", "primary"),
    "Save",
)

Attribute values#

Attribute dicts use string keys. Keys are validated: no whitespace, quotes, =, or other delimiter characters. Framework-style names such as @click and :class are allowed in plain dicts.

Values must be scalars: strings, numbers, SafeString, bool, or None.

  • Strings and numbers — quoted attributes with escaping (always double-quoted on the wire; single quotes in values stay literal).

  • SafeString — inserted verbatim when you control the bytes.

  • True — boolean attribute present with no value (<details open>).

  • False / None — attribute omitted.

For class, style, static data-*, and aria-*, use classes(), styles(), data(), and aria() from stario.markup. classes({"primary": cond}) includes tokens whose values are truthy, not strictly bool. styles() values must be str, int, or float — not SafeString. Property names cannot be @rules or contain :;{}. List or dict values inside a plain attribute dict are rejected.

For other prefix-* attributes beyond data and aria, use from stario.markup.attributes import prefixed.

from stario import classes, h
from stario.markup import aria, data, styles
from stario.markup.attributes import prefixed
 
h.Button({"type": "submit"}, classes("btn", "btn-primary"), "Save")
h.Details({"open": True}, h.Summary("More"), h.P("…"))
h.Button(data({"user-id": "42"}), "Go")
h.Div(prefixed("hx", {"post": "/save", "target": "#main"}))

Types#

HtmlElement is the union type for trees, children, and render() roots — use it on view signatures (def page(body: HtmlElement)). TagAttributes and AttributeValue in stario.markup.types annotate attribute dicts.

SafeString and escaping#

  • Text nodes: escape_text escapes &, <, >.

  • Attribute values: escape_attribute_value also escapes ". Values are always emitted in double quotes.

  • SafeString is emitted as-is in both contexts.

If markup comes from users, escape or sanitize first. Stario does not second-guess SafeString.

render() raises if you pass an uncalled Tag — render(h.Div) instead of render(h.Div()).

Baking with @baked#

Most pages never need @baked. Use it when a stable layout is invoked often and you have measured allocation or render cost.

Without @baked, each call allocates a fresh element tree and render walks it. With @baked, the decorator captures the layout once at import into a segment plan; each call splices dynamic parameters and returns a SafeString fragment.

Dynamic values must appear as element children or whole attribute values — not inside styles(), classes(), or data(), and not as attribute names. Build Attrs or style strings outside the builder and pass them in:

from stario import classes, h
from stario.markup import SafeString, baked, render
 
 
@baked
def row(label, class_attrs):
    return h.Li(class_attrs, label)
 
 
@baked
def badge(label, style_value):
    return h.Span({"style": style_value}, label)
 
 
render(row("Save", classes("btn", "primary")))
render(badge("New", SafeString("color:red;")))

Rejected at decoration time, when @baked runs:

  • Unused parameters — every parameter must appear as a child or whole attribute value.

  • Boolean literals and uncalled Tag objects in the builder tree.

  • Parameters used as attribute names — only values work.

  • Parameter names starting with __stario.

Signatures cannot use *args, **kwargs, or positional-only /. Keyword-only parameters and defaults are supported — inner=None omits the child when not passed. Nested calls to other baked functions return SafeString fragments that compose without double-escaping. A no-parameter builder is frozen once at import into a fixed SafeString.

Comparisons on parameters are not supported inside the builder (==, truthiness, if / and / or). Identity checks are not guarded either — param is not None is always true at bake time, so branch outside the builder.

from stario.markup import baked, render
from stario import h
 
 
@baked
def shell_fast(title, body):
    return h.Div(h.Title(title), body)
 
 
render(shell_fast("Docs", h.P("Hello.")))

SVG#

Vector tags live in stario.markup.svg. SVG uses camelCase attribute names (viewBox, stdDeviation) and literal colon keys ("xlink:href"). For standalone SVG documents, set xmlns on the root svg.Svg element.

Empty modes matter when mixing HTML and SVG:

ModeBehaviorExamples
voidSelf-close; children are an errorh.Br, h.Img, Tag("br", empty="void")
self_closing_when_emptySelf-close when empty; allow children when presentsvg.Circle, svg.Path
normal (default)<tag></tag> when emptyh.Div, svg.G
from stario.markup import Tag, svg
 
svg.Svg({"viewBox": "0 0 100 100"}, svg.Circle({"cx": "50", "cy": "50", "r": "40"}))
CustomDot = Tag("circle", empty="self_closing_when_empty")

API#

class Tag(name, *, empty='normal', prefix='')#

Callable factory for a single HTML or SVG element name.

Instantiate with the constructor (e.g. P = Tag("p")). Prefer the built-in catalogs in stario.markup.html and stario.markup.svg where they exist. The factory caches opening/closing strings and, for tags with no arguments, a ready SafeString (avoid mutating these attributes after init).

empty="normal" — no children renders <tag></tag>; children allowed. empty="void" — no children renders <tag/>; children are an error (HTML br, img, input, …). empty="self_closing_when_empty" — no children renders <tag/>; children allowed when present (SVG leaves like circle, path, filter primitives).

Calling the instance (h.Div(...), h.P("hi"), etc.) builds a tree node; see Tag.__call__.

Tag.__call__(*children)#

Build one element from positional arguments.

Children are processed in order:

  • Each initial mapping (dict or other Mapping) is rendered as flat key=value attributes in the order it appears. Several mappings in a row are allowed, but they are not merged or deduplicated; duplicate attributes render as duplicate attributes. None entries are skipped.

  • The first non-mapping starts child content. After that, mappings must not appear (attributes must come first).

Return value: a (open, attrs, children, tail) four-tuple, or a cached SafeString when there are no arguments. With no attributes, open is the tag start including > (tag_start_no_attrs); otherwise open is "<name" and attrs is a joined string or slot list. children is None for empty elements; tail is no_children_close or closing_tag.

Tag.__call__(*children)#

Build one element from positional arguments.

Children are processed in order:

  • Each initial mapping (dict or other Mapping) is rendered as flat key=value attributes in the order it appears. Several mappings in a row are allowed, but they are not merged or deduplicated; duplicate attributes render as duplicate attributes. None entries are skipped.

  • The first non-mapping starts child content. After that, mappings must not appear (attributes must come first).

Return value: a (open, attrs, children, tail) four-tuple, or a cached SafeString when there are no arguments. With no attributes, open is the tag start including > (tag_start_no_attrs); otherwise open is "<name" and attrs is a joined string or slot list. children is None for empty elements; tail is no_children_close or closing_tag.

render(*nodes)#

Walk HTML fragments depth-first and return one UTF-8 string.

Accepts any number of root nodes (variadic). Plain str in child position is HTML text-escaped; attribute escaping happens inside Tag when the opening tag is built. SafeString is emitted unchanged (trusted).

Fragments from baked are SafeString values. Pass them as one root, e.g. render(layout(a, b)). You can also nest layout(...) wherever an element child is accepted, same as h.Div(...).

baked(fn=None)#

Compile fn once into a segment plan, then return a fast callable.

Parameters may appear as element children or whole attribute values. Every call returns a SafeString with dynamic children and attributes already rendered.

Use this for repeated application views whose structure is stable across calls. Keep baked builders declarative: compute conditionals, loops, and derived strings outside the builder, then pass the finished child fragment or whole attribute value in as a parameter.

Invalid static markup (boolean literals, uncalled tags, unsupported types) and unused parameters are rejected when the decorator runs, not on the first render call.

Equality and truthiness on parameters (==, if / and / or) are not supported inside the builder — placeholders stand in at decoration time. Identity checks (is, is not) are not guarded: param is not None is always true while the builder runs.

Comment(content='')#

<!-- ... --> with textual content escaped so --> cannot break out.

Parsers do not decode entities inside comments, so escaped characters (e.g. &gt;) read back literally: escaping protects against breakout, not byte-for-byte fidelity. Pass SafeString when you control the bytes and need them verbatim.

classes(*tokens)#

Return a pre-rendered class attribute fragment.

Mapping values are truthy-tested (not strictly bool): any truthy value includes the class name.

styles(declarations)#

Return a pre-rendered style attribute fragment.

data(attrs)#

Return pre-rendered data-* attribute fragments from a flat mapping.

aria(attrs)#

Return pre-rendered aria-* attribute fragments from a flat mapping.

prefixed(prefix, attrs)#

Return pre-rendered prefix-name attribute fragments from a flat mapping.

Internal primitive for data() and aria(). For other prefixes import from this submodule, e.g. from stario.markup.attributes import prefixed.

class SafeString(rendered)#

Markup inserted verbatim into rendered HTML (no escaping).

Fields

  • rendered(str):—

class Attrs(rendered)#

Trusted/pre-rendered opening-tag attributes, including leading spaces.

Fields

  • rendered(str):—

type HtmlElement = str | int | float | stario.markup.types.SafeString | list[HtmlElement] | HtmlElementTuple | None#