Overview
Chirp can discover routes from a pages directory instead of registering them with@app.route(). The filesystem structure defines URL paths, layout nesting, and shared context. This is ideal for content-heavy apps where routes map naturally to a directory tree.
from chirp import App, AppConfig
app = App(AppConfig(template_dir="pages"))
app.mount_pages("pages")
app.run()
Directory Conventions
The discovery system walks thepages/directory and treats specific files as route definitions:
| File | Purpose |
|---|---|
page.py |
Route handler for the directory URL (e.g.documents/page.py → GET /documents) |
edit.py, create.py, etc. |
Route handlers that append to the path (e.g.edit.py → GET /documents/edit) |
_layout.html |
Layout shell with{% block content %} and {# target: element_id #} |
_context.py |
Context provider that cascades to child routes |
Directories whose names are wrapped in{braces} become path parameters (e.g. {doc_id}/ → /documents/{doc_id}).
Example Structure
pages/
_layout.html # Root layout (target: body)
_context.py # Root context (e.g. site config)
documents/
page.py # GET /documents
create.py # GET /documents/create
{doc_id}/
_layout.html # Nested layout (target: app-content)
_context.py # Loads doc, provides to children
page.py # GET /documents/{doc_id}
page.html # Template for page.py
edit.py # GET /documents/{doc_id}/edit
edit.html # Template for edit.py
Route Files
page.py
page.py maps to the directory URL. A get function handles GET, a post function handles POST, etc.
# pages/documents/page.py
from chirp import Page
def get():
return Page("documents/page.html", "content", items=load_items())
You can also return Suspensefor instant first paint with deferred blocks. The layout chain is applied automatically — the shell gets the full layout (head, CSS, sidebar), and OOB swaps target block IDs inside the page.
Other .py Files
Any other.py file (except those starting with _) appends its stem to the path:
edit.pyindocuments/{doc_id}/→GET /documents/{doc_id}/editcreate.pyindocuments/→GET /documents/create
Handler functions are named after HTTP methods:get, post, put, delete, patch, head, options. If no method-named function exists, a handlerfunction defaults to GET.
# pages/documents/{doc_id}/edit.py
from chirp import Page, NotFound
def get(doc_id: str, doc): # doc from _context.py
return Page("documents/{doc_id}/edit.html", "content", doc=doc)
async def post(doc_id: str, doc, request):
data = await request.form()
update_doc(doc_id, data)
return Redirect(f"/documents/{doc_id}")
Path Parameters
Directory names wrapped in{param}become URL path parameters:
documents/{doc_id}/page.py → /documents/{doc_id}
users/{user_id}/posts/{slug}/page.py → /users/{user_id}/posts/{slug}
Handlers receive path parameters as keyword arguments. Type annotations are respected (e.g. doc_id: int for {doc_id:int}).
Layouts
Each_layout.html defines a shell with a {% block content %}slot. The layout declares which DOM element it owns via a target comment:
{# target: app-content #}
<div id="app-content">
{% block content %}{% endblock %}
</div>
Layouts nest from root to leaf. The negotiation layer uses HX-Targetto decide how deep to render:
- Full page load: all layouts nested
- Boosted navigation with
HX-Target: #app-content: render from the layout that ownsapp-contentdown - Fragment request: render just the targeted block
If no target is declared, it defaults to"body".
Howrender_with_blocksworks
Chirp composes layouts usingrender_with_blocks({"content": page_html}). This replaces {% block content %} with the pre-rendered page HTML. Any markup you put inside {% block content %}in your layout is overridden — it never renders.
This means persistent UI (navbars, sidebars, topbars) must live outside{% block content %}:
{# target: main #}
{# ❌ Shell is INSIDE content — gets replaced, never renders #}
{% extends "chirpui/app_layout.html" %}
{% block content %}
<nav>...</nav>
{% block page_content %}{% end %}
{% end %}
{# target: main #}
{# ✅ Shell is OUTSIDE content — always renders #}
<nav>...</nav>
<main id="main">
<div id="page-content">
{% block content %}{% end %}
</div>
</main>
Layout ramp: boost → shell → nested shells
Chirp offers three layout patterns, from simplest to most structured:
| Layout | Use case |
|---|---|
chirp/layouts/boost.html |
Simple pages, no persistent shell. Useshx-select="#page-content"for fragment swaps. |
chirp/layouts/shell.html |
Persistent shell (topbar, sidebar). Override{% block shell %}to wrap main. |
chirpui/app_shell_layout.html |
ChirpUI apps — extends shell.html with sidebar, toast, CSS. |
| Nested shells | Forum > subforum > thread. Useshell_sectionmacro for inner levels. |
hx-select vs hx-disinherit: Preferhx-select="#page-content" on the boosted container. When the server returns a full HTML page, htmx extracts only #page-content for the swap. hx-disinherit breaks inheritance for fragment swaps; use hx-target="this" on event-driven elements instead (the safe_targetmiddleware auto-injects this).
Persistent app shell pattern
For dashboard-style apps with a topbar, sidebar, and content area, extendchirpui/app_shell_layout.html (if using ChirpUI) or chirp/layouts/shell.html:
{# target: body #}
{% extends "chirpui/app_shell_layout.html" %}
{% block brand %}My App{% end %}
{% block sidebar %}
{% from "chirpui/sidebar.html" import sidebar, sidebar_link, sidebar_section %}
{% call sidebar() %}
{% call sidebar_section("Main") %}
{{ sidebar_link("/", "Home") }}
{{ sidebar_link("/items", "Items") }}
{% end %}
{% end %}
{% end %}
Or without ChirpUI, extend chirp/layouts/shell.html and override {% block shell %}.
Key elements:
- Extend shell.html or app_shell_layout.html — Don't extend
boost.htmlfor app shell layouts. hx-boost="true"on<main id="main">— Boosted links inside the content area use AJAX navigation.hx-select="#page-content"— When the server returns a full HTML page, htmx parses it and extracts only#page-contentfor the swap. The shell persists client-side.- No
hx-disinheriton the content wrapper — Boosted links must inherithx-target,hx-swap, andhx-selectfrom#main. Fragment requests with explicithx-targetoverride the inherited value. {# target: main #}— Tells Chirp which layout depth to render forHX-Target: mainrequests.
Nested shells with shell_section
For multi-level layouts (e.g. forum > subforum > thread), use theshell_sectionmacro:
{# target: items-content #}
{% from "chirp/macros/shell.html" import shell_section %}
<div class="chirpui-shell-section">
<nav class="chirpui-shell-section__nav">Items</nav>
{% call shell_section("items-content") %}
{% block content %}{% end %}
{% end %}
</div>
Inner layouts don't need hx-select — the renderer produces fragments for them. Use chirp new myapp --shellto scaffold a project with this pattern.
Common mistakes
{% extends %}in inner layouts — Inner_layout.htmlfiles that use{% extends %}can conflict withrender_with_blocks. The child template may wipe the shell. Prefer composing withshell_sectioninstead.- Missing
{# target: X #}on inner layouts — Non-root layouts default to"body"if no target is declared. Add{# target: element_id #}so the layout chain resolves correctly. hx-disinheritin shell layouts — Preferhx-selecton the parent. Usehx-target="this"on event-driven elements (e.g. SSE) instead ofhx-disinherit.- Duplicate targets in a chain — Two layouts with the same target cause
find_start_index_for_targetto return the first match. Use unique targets per layout.
Context Cascade
_context.py files export a contextfunction that provides shared data to handlers. Context cascades from root to leaf; child context overrides parent.
Provider Signatures
Context providers receive arguments from two sources:
- Path parameters — From the URL match (e.g.
doc_idfrom/documents/{doc_id}) - Parent context — Values from providers higher in the filesystem tree
# pages/_context.py — root provider, no params
def context() -> dict:
return {"store": get_store(), "data_dir": "..."}
# pages/documents/{doc_id}/_context.py — child receives doc_id from path, store from parent
def context(doc_id: str, store) -> dict:
doc = store.get(doc_id)
if doc is None:
raise NotFound(f"Document '{doc_id}' not found")
return {"doc": doc}
For /documents/abc-123, the root provider runs first and adds store and data_dir. The child provider then receives doc_id="abc-123" from the path and storefrom the accumulated context.
Service providers: Context providers can also request types registered viaapp.provide(). Parameters with matching type annotations are resolved from the service provider factories:
# pages/documents/{doc_id}/_context.py
def context(doc_id: str, store: DocumentStore) -> dict:
doc = store.get(doc_id)
return {"doc": doc}
With app.provide(DocumentStore, get_store), the storeparam is injected from the factory.
Early Abort with HTTPError
Providers may raiseNotFound (or other HTTPErrorsubclasses) to abort the cascade. Chirp renders the appropriate error page automatically.
# pages/documents/{doc_id}/_context.py
from chirp import NotFound
def context(doc_id: str) -> dict:
doc = store.get(doc_id)
if doc is None:
raise NotFound(f"Document {doc_id} not found")
return {"doc": doc}
Handlers receive context as keyword arguments. Providers can be sync or async.
Route-Scoped Shell Actions
_context.py can also return a reserved shell_actionsvalue to drive persistent
shell chrome such as a global top bar. Shell actions cascade root-to-leaf just
like other context, but they merge by stable actionidinstead of plain dict
overwrite:
- child routes inherit parent actions by default
- child routes can override an inherited action by
id - child routes can remove inherited actions by
id - a zone can
replaceits inherited actions entirely
from chirp import ShellAction, ShellActions, ShellActionZone
# pages/forum/_context.py
def context() -> dict:
return {
"shell_actions": ShellActions(
primary=ShellActionZone(
items=(
ShellAction(id="new-thread", label="New thread", href="/forum/new"),
)
)
)
}
# pages/forum/{thread_id}/_context.py
def context(thread_id: str) -> dict:
return {
"shell_actions": ShellActions(
primary=ShellActionZone(
items=(
ShellAction(id="reply", label="Reply", href=f"/forum/{thread_id}/reply"),
),
remove=("new-thread",),
)
)
}
The resolved shell_actionsobject is available in page and layout templates.
For boosted shell navigations, Chirp also emits an out-of-band refresh for the
default target#chirp-shell-actions, so persistent top bars stay in sync as
the active route changes.
Template Convention
When a route file has a sibling.htmlfile with the same stem, that template is used implicitly:
page.py+page.html→ handler returnsPage("path/to/page.html", "content", ...)edit.py+edit.html→ handler returnsPage("path/to/edit.html", "content", ...)
Template paths are relative to the pages root. The handler must pass the correct path toPage().
For layout-heavy pages, prefer a self-contained page root plus narrower inner fragments:
{# pages/_page_layout.html #}
{% block content %}
{% block page_root %}
<div class="page-shell">
{% block page_header %}{% end %}
{% block page_content %}{% end %}
</div>
{% endblock %}
{% endblock %}
return Page(
"documents/page.html",
"page_content",
page_block_name="page_root",
items=load_items(),
)
This gives Chirp two safe render scopes:
page_contentfor explicit fragment swaps into a narrow targetpage_rootfor boosted navigation, where the response must carry page-level wrappers such as stacks, toolbars, and spacing
Handler Argument Resolution
Page handlers receive arguments from multiple sources, in priority order (first match wins):
Request
request: Request by parameter name or type annotation. Injected when the handler has a parameter named request or annotated with Request.
Path parameters
From the URL match, with type coercion. Parameters like{doc_id} in the route path are extracted and passed by name. Add :int or :floatfor automatic conversion.
Cascade context
From_context.pyproviders. Each provider's output is merged into the accumulated context; deeper providers override parent values.
Service providers
Registered viaapp.provide(). When a parameter's type matches a registered annotation, Chirp calls the factory and injects the result.
Extractable dataclasses
From query string (GET) or form/JSON body (POST). Dataclasses with appropriate annotations are populated from the request data.
def get(doc_id: str, doc, store: DocumentStore):
# doc_id from path, doc from _context.py, store from app.provide()
return Page("doc.html", "content", doc=doc)
When to Use Filesystem vs Decorator Routes
| Use filesystem routing when… | Use@app.route()when… |
|---|---|
| Routes map to a content hierarchy | Routes are API-like or action-oriented |
| Layouts and context cascade naturally | Each route is independent |
| You want co-located handlers and templates | You prefer explicit route registration |
You can mix both:app.mount_pages("pages") for the main app shell, and @app.route("/api/...")for API endpoints.
Related
- Routes — Decorator-based route registration
- Return Values —
PageandLayoutPage - Fragments — Block-level rendering for htmx
- View Transitions — Boosted navigation with layouts