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:
- Dictionaries → Treated as attributes
- 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 ¶
TrueorNone→ 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><script>alert("XSS")</script></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><img src=x onerror="steal()"></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 →",
)
