Routing
Caspian follows the same mental model as the Next.js App Router: routes
live under src/app,
folders define URL segments, and layouts nest automatically. The current
contract is HTML-first: visible page markup stays in
index.html
and Python companions add metadata, server logic, RPC actions, auth, or
redirects only when needed.
Current Rules
-
Put application routes in
src/app/. -
For routes that render UI, keep the markup in
index.html. -
Add
index.pyonly when the same route needs metadata,page(),@rpc()actions, auth checks, caching, redirects, or other server-side logic. -
Use a standalone
index.pyonly for non-visual routes such as redirects or action-only handlers. -
Use
layout.htmlfor shared wrappers in route sections, andlayout.pyonly for shared synchronous props or metadata. -
Every authored route and layout template must have exactly one top-level
root element or one imported
x-*root, with any owned plain<script>kept inside that same root.
Route Shapes
| Route Shape | Files | Typical Use |
|---|---|---|
| UI only | index.html | Simple static or PulsePoint-enhanced page |
| UI plus backend logic | index.html + index.py | Metadata, page context, auth checks, RPC actions |
| Backend only | index.py | Redirect-only or action-only route |
| Files | URL |
|---|---|
| src/app/index.html | / |
| src/app/about/index.html | /about |
| src/app/dashboard/index.html + index.py | /dashboard |
| src/app/(auth)/signout/index.py | /signout |
Route Templates
Keep visible route markup in index.html.
Import reusable Python components with top-of-file
@import
comments and render them with HTML-first kebab-cased
x-*
tags.
<!-- @import Button from "../../components/Button.py" --> <section class="space-y-4"> <h1>Dashboard</h1> <x-button>Create report</x-button> <script> const [filter, setFilter] = pp.state("all"); </script> </section>
Hard invariant
The authored template must have exactly one root. Keep any owned plain
<script>
inside that root. Do not handwrite
pp-component
or type="text/pp";
those are injected by the runtime.
The Python Companion
Use index.py
for metadata and server-side preparation. For UI routes, keep the page
markup in the sibling index.html
and let page()
call render_page(__file__, ...).
from casp.layout import Metadata, render_page metadata = Metadata( title="Dashboard | Caspian", description="Section overview for the dashboard.", ) async def page(params: dict, request=None): slug = params.get("slug") return render_page(__file__, "slug": slug, "is_authenticated": bool(getattr(request, "user", None)) if request else False, )
params
dict. Query params are injected by name, and
request
is injected by keyword when declared.
async def page(): page_html = render_page(__file__, ) return page_html, "dashboard_body_class": "dashboard-shell dashboard-shell--reports"
The 2-item tuple form lets a child route push layout props upward so
parent layouts can consume them as
{{ layout.* }}.
Dynamic Segments
Use square brackets to define dynamic URL segments.
Catch-All Segments
Use an ellipsis inside brackets to match multiple path segments.
Route Groups And Section Layouts
Use a normal folder like dashboard/
when the section name should appear in the URL. Use a parenthesized group
like (marketing)/
when the folder should organize code and own a layout without adding a
URL segment.
src/
app/
(marketing)/
layout.html
pricing/
index.html
about/
index.html
dashboard/
layout.html
layout.py
index.html
settings/
index.html
index.py
Layouts
Keep shared wrapper markup in layout.html.
Use layout.py
only for shared synchronous props or metadata, while the HTML shell stays native Jinja plus <slot />.
<html> <head> <title>{{ metadata.title }}</title> <meta name="description" content="{{ metadata.description }}" /> </head> <body class="{{ layout.theme_class }}"> <main pp-reset-scroll="true"> <slot /> </main> </body> </html>
from casp.layout import Metadata, render_layout metadata = Metadata( title="Docs Section | Caspian", description="Shared docs layout metadata.", ) def layout(): return render_layout(__file__), "theme_class": "docs-shell"
Current layout runtime
layout()
is synchronous in the installed runtime. Put async I/O in
page()
or in route-owned @rpc()
actions instead of awaiting inside layout.py.