HTML

Stario includes a Python-based HTML generation system that emphasizes type safety, automatic escaping, and seamless integration with Datastar attributes.

Philosophy

  • Type-safe: Leverage Python's type system to catch errors early
  • Secure by default: All strings are escaped unless explicitly marked safe
  • Composable: Build HTML from small, reusable components
  • Flexible: Plug in alternative HTML libraries if needed

Basic Usage

from stario.html import div, h1, p, a, button

def greeting(name: str):
    return div(
        h1(f"Hello, {name}!"),
        p("Welcome to Stario."),
        a({"href": "/docs"}, "Read the docs"),
    )

Tag System

HTML tags are callable objects that create HTML elements. When you call a tag with arguments, it processes them in order:

  1. Dictionaries → Treated as attributes
  2. Everything else → Treated as children

Tags return either:

  • SafeString - For elements without children (leaf nodes)
  • Tuple[str, list, str] - For elements with children (opening tag, children list, closing tag)

Creating Tags

from stario.html.core import Tag

# Regular tag
div_tag = Tag("div")
# Self-closing tag
br_tag = Tag("br", self_closing=True)
img_tag = Tag("img", self_closing=True)

Stario provides pre-defined tags in stario.html for all standard HTML elements.

Attributes

Attributes are provided as dictionaries. You can pass multiple attribute dictionaries and they'll be merged:

from stario.html import button

# Single dict
button({"class": "btn", "type": "button"}, "Click me")

# Multiple dicts - they're merged
base_attrs = {"class": "btn"}
type_attrs = {"type": "submit"}
button(base_attrs, type_attrs, "Submit")
# Same as: button({"class": "btn", "type": "submit"}, "Submit")

# Mix attributes and children
button(
    {"class": "btn"},
    {"type": "button"},
    {"disabled": False},
    "Click me"
)

Supported Attribute Value Types

div({
    "class": "container",              # str
    "tabindex": 0,                     # int
    "opacity": 0.5,                    # float
    "data-value": Decimal("10.50"),    # Decimal
    "disabled": True,                  # bool → renders as "disabled"
    "hidden": False,                   # bool → omitted
    "class": ["btn", "primary"],       # list → joined with spaces
    "data": {"user-id": "123"},        # dict → nested attributes
    "style": {"color": "red"},         # dict → CSS styles
})

Boolean Attributes

  • True or None → attribute is present without value: <div disabled>
  • False → attribute is omitted entirely
button({"disabled": True}, "Disabled button")
# <button disabled>Disabled button</button>

button({"disabled": False}, "Enabled button")
# <button>Enabled button</button>

Nested Attributes

Dictionaries create nested attributes (data-, aria-, hx-*, etc.):

div({
    "data": {
        "user-id": "123",
        "role": "admin",
    },
    "aria": {
        "label": "Close dialog",
        "hidden": True,
    },
})
# <div data-user-id="123" data-role="admin" aria-label="Close dialog" aria-hidden>

Style Attributes

Style dictionaries are automatically converted to CSS strings:

div({
    "style": {
        "color": "red",
        "font-size": "16px",
        "background-color": "#fff",
    },
}, "Styled content")
# <div style="color:red;font-size:16px;background-color:#fff;">Styled content</div>

Escaping and SafeString

By default, all strings are escaped to prevent XSS attacks. This is the secure-by-default behavior.

user_input = '<script>alert("XSS")</script>'
div(user_input)
# <div>&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</div>

SafeString for Trusted Content

When you have trusted HTML that should not be escaped (like SVGs, pre-rendered HTML, or content from a WYSIWYG editor you trust), wrap it in SafeString:

from stario.html.safestring import SafeString

# Trusted SVG content
svg_content = SafeString('''
<svg width="100" height="100">
    <circle cx="50" cy="50" r="40" fill="red"/>
</svg>
''')

div(svg_content)
# <div><svg width="100" height="100">...</svg></div>
# ↑ Not escaped

Use SafeString sparingly and only for content you absolutely trust. Never use it with user input.

Security Warning - XSS Risk:

# ❌ WRONG - XSS vulnerability!
user_comment = request.form.get("comment")  # User input
div(SafeString(user_comment))  # Attacker can inject: <img src=x onerror="steal()">

# ✅ CORRECT - Automatic escaping
user_comment = request.form.get("comment")
div(user_comment)  # <div>&lt;img src=x onerror=&quot;steal()&quot;&gt;</div>

# ✅ CORRECT - Only for trusted content
trusted_svg = read_from_config_file("logo.svg")  # From server, not user
div(SafeString(trusted_svg))

The default behavior (escaping all strings) protects you from XSS. Only bypass it when you have content from a trusted source (configuration files, your own database, pre-processed content).

Rendering

Convert HTML elements to strings using the render() function:

from stario.html import div, h1, p, render

html = render(
    div(
        h1("Welcome"),
        p("This is a paragraph."),
    )
)
# '<div><h1>Welcome</h1><p>This is a paragraph.</p></div>'

The render() function handles:

  • Nested elements
  • Lists of elements
  • Recursive rendering
  • Proper escaping

Datastar Integration

Datastar attributes (from the Datastar dependency) return dictionaries, so they merge seamlessly with other attributes:

from stario.datastar import Datastar
from stario.html import button, div

async def my_component(ds: Datastar):
    return div(
        {"class": "container"},
        ds.signals({"count": 0}),
        button(
            {"class": "btn"},
            ds.on("click", "$count++"),
            ds.text("$count"),
            "Increment",
        ),
    )

The dictionaries from ds.signals(), ds.on(), and ds.text() are merged with {"class": "btn"}, producing:

<button class="btn" data-on-click="$count++" data-text="$count">
  Increment
</button>

See Datastar for comprehensive documentation.

Custom Rendering Function

Stario's HTML rendering is pluggable. You can use any HTML library or custom renderer by providing a render function to the route's adapter.

Using a Custom Renderer

Pass your rendering function to the datastar_handler adapter:

from stario.routes import Query, datastar_handler
from stario.html import render as stario_render

# Example: Use htpy instead of Stario's HTML
from htpy import render as htpy_render

async def my_handler():
    # Return htpy elements instead of Stario elements
    ...

# Provide custom renderer via adapter
Query("/", my_handler, adapter=datastar_handler(renderer=htpy_render))

The renderer function is called with the handler's return value and should convert it to an HTML string.

Using Stario's Default Renderer

Stario's render() function is the default. If you don't specify an adapter, it's used automatically:

from stario.html import render
from stario.routes import Query

# Default behavior (no adapter needed)
Query("/", my_handler)

# Or explicitly:
Query("/", my_handler, adapter=datastar_handler(renderer=render))

Stario's render() handles:

  • Nested elements
  • Lists of elements
  • Automatic escaping
  • SafeString for trusted content

Using Raw Strings

For simple cases, you can use str() as the renderer—but be careful with escaping:

from stario.routes import Query, datastar_handler

async def simple_handler():
    return "<div>Hello</div>"  # Raw HTML string

# ⚠️ No automatic escaping!
Query("/", simple_handler, adapter=datastar_handler(renderer=str))

Common Patterns

Conditional Rendering

def user_badge(user: User, is_admin: bool):
    return div(
        h2(user.name),
        p(user.email),
        span({"class": "badge"}, "Admin") if is_admin else None,
        # None is ignored in rendering
    )

Lists of Elements

def user_list(users: list[User]):
    return ul(
        [li(user.name) for user in users]
    )

Fragments

def fragment(*children):
    """Render children without a wrapper element"""
    return list(children)

def layout():
    return div(
        fragment(
            h1("Title"),
            p("Paragraph 1"),
            p("Paragraph 2"),
        )
    )
# <div><h1>Title</h1><p>Paragraph 1</p><p>Paragraph 2</p></div>

Reusable Components

def card(title: str, content: str, *,  footer: str | None = None):
    return div(
        {"class": "card"},
        div({"class": "card-header"}, h3(title)),
        div({"class": "card-body"}, p(content)),
        div({"class": "card-footer"}, footer) if footer else None,
    )

# Use it
my_card = card(
    title="Welcome",
    content="This is a card",
    footer="Learn more →",
)

Next Steps

  • Explore Datastar for reactive attributes
  • Learn about Endpoints for how HTML is returned
  • See Toys for quick prototyping tools