HTML

Stario builds HTML as Python call trees, not template files. You compose tags with normal function calls; handlers pass the tree to responses.html, which renders it and sends the response. Escaping and attribute rules live in stario.markup so views stay plain Python.

How to build a page

1. Import the tag catalog — convention is h for HTML, svg for vector tags:

python
from stario.markup import html as h

Add helpers as you need them (render, classes, styles, baked, …). Full package surface:

python
from stario.markup import (
    Comment,
    HtmlElement,
    SafeString,
    aria,
    baked,
    classes,
    data,
    html as h,
    render,
    styles,
    svg,
)

Lighter paths when you only need part of that:

python
import stario.markup.html as h          # tags only
from stario.markup.render import render   # serialize only

Import markup from stario.markup (for example from stario.markup import html as h) — not a separate stario.html module.

2. Build a tree — each tag is a callable. Dicts before the first child become attributes; nested tags and strings are children:

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

Use h.HtmlDocument for full pages (<!doctype html> included). Use h.Html or a fragment (h.Div(...)) for partial trees and SSE patches.

3. Send it from a handler — pass the tree to responses.html; you rarely call render() yourself in route code:

python
import stario.responses as responses
from stario import Context, Writer
 
 
async def home(c: Context, w: Writer) -> None:
    responses.html(w, home_view("Hello"))

responses.html accepts a str, bytes, or HtmlElement tree. For long-lived SSE, use SSE(w).patch_elements(fragment) instead (Datastar).

4. Split views from handlers — typical layout:

ModuleRole
views.pyPure functions returning HtmlElement trees (home_view, page, …)
handlers.pyasync def routes: read c.req, call views, responses.html(w, …)
urls.pyUrlPath constants for registration and .href() in markup

Datastar attributes (data.on, data.signals, …) come from from stario.datastar import data, not stario.markup.data (static data-* only). See Hello world for a full example.

Annotate view parameters with HtmlElement from stario.markup when a function takes nested markup (def page(body: HtmlElement)).

Package layout

ImportUse for
stario.markup.html (h)HTML tag catalog — h.Div, h.P, h.HtmlDocument, …
stario.markup.svgSVG tags — svg.Circle, svg.Path, …
stario.markup.renderrender(tree)str (tests, emails, debugging)
stario.markup.classes, styles, data, ariaAttrs fragments for class, style, static data-*, aria-*
stario.markup.baked@baked layouts (optional performance)
stario.markup.TagCustom element names not in the catalog
stario.markup.typesTagAttributes, 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:

python
from stario.markup import Tag, render
from stario.markup import html as 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 (no escaping). Ordinary str children are escaped for text context; attribute values go through attribute escaping.

Comments

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

python
from stario.markup import Comment, SafeString, render
 
render(Comment("note"))           # <!--note-->
render(Comment("a -- b"))         # <!--a -- 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: attribute dicts, Attrs, and content

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

  • A mapping (usually a dict): merged 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): child content. Use list for fragments. 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.

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

python
from stario.datastar import data
from stario.markup import classes, html as h, 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 bytes (including leading spaces).

  • Markup helpersclasses(), styles(), data(), aria() from stario.markup return Attrs.

  • Datastardata.on, data.signals, … from stario.datastar also return Attrs.

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

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

Attributes

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 / aria), use from stario.markup.attributes import prefixed.

python
from stario.markup import aria, classes, data, html as h, 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 (e.g. render(h.Div) instead of render(h.Div())).

Baking (@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. They cannot appear inside styles(), classes(), or data(), or as attribute names. Build Attrs or style strings outside the builder and pass them in:

python
from stario.markup import SafeString, baked, classes, render
from stario.markup import html as h
 
 
@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, not on first call):

  • 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).

  • Parameter names starting with __stario.

Other rules: 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 (is, is not) are not guarded either — param is not None is always true at bake time, so branch outside the builder.

python
from stario.markup import baked, render
from stario.markup import html as 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
python
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