Filesystem Routing

Route discovery from the pages/ directory with layouts and context cascade

13 min read 2598 words

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.pyGET /documents)
edit.py, create.py, etc. Route handlers that append to the path (e.g.edit.pyGET /documents/edit)
_meta.py Route metadata (title, section, breadcrumb_label, shell_mode). See Route Contract.
_layout.html Layout shell with{% block content %} and {# target: element_id #}
_context.py Context provider that cascades to child routes
_actions.py Mutation handlers with@actiondecorator
_viewmodel.py View assembly for complex context merging

Directories whose names are wrapped in{braces} become path parameters (e.g. {doc_id}//documents/{doc_id}).

See the Route Directory Contract for the full reserved file vocabulary and Route Directory Golden Path for recommended patterns.

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.py in documents/{doc_id}/GET /documents/{doc_id}/edit
  • create.py in documents/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 withHX-Target: #app-content: render from the layout that owns app-contentdown
  • Fragment request: render just the targeted block

If no target is declared, it defaults to"body".

Filesystem layouts can also declare an explicit navigation domain:

{# preset: site-shell #}
{# target: body #}
{# domain: site #}
{# shell: site #}
{# outlet: site-content #}

Use domainfor the author-facing answer to "which links should boost together?" Use shellfor persistent UI boundaries and nested layout composition. When any layout in a route chain declaresdomain, swap_attrs()resolves navigation from the shared domain ancestry first and only falls back to shell ancestry for older layouts that do not opt in.

This lets you model cases like:

  • one domain with multiple nested shells
  • sibling domains under a shared outer shell
  • incremental migration from shell-only layouts to explicit navigation intent

Layout presets

Apps and extensions can register named layout presets during setup:

app.register_layout_preset(
    "site-shell",
    target="body",
    swap_scope_name="site",
    outlet_target_id="site-content",
    outlet_mode="replace",
)

Then a filesystem layout can opt in with a single comment and keep only the route-specific intent inline:

{# preset: site-shell #}
{# domain: site #}
{# shell: site #}

Explicit comments always override preset defaults. use_chirp_ui(app)also registers a built-inchirpui-app-shell preset for the standard #main app-shell outlet.

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 extendboost.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.
  • Nohx-disinherit on the content wrapper — Boosted links must inherit hx-target, hx-swap, and hx-select from #main. Fragment requests with explicit hx-targetoverride the inherited value.
  • {# target: body #} — Which DOM id this layout level renders into in a nested layout chain (often bodyfor the root shell).
  • {# outlet: main #} — Declares the primary boosted-navigation outlet for this layout (the <main id="main"> region in chirp-ui’s app shell). LayoutChain.find_start_index_for_target matches both target and outlet so HX-Target: #main resolves to this layout and render_with_layouts wraps the page with the full shell—including the #page-content wrapper that hx-select expects. Without {# outlet: main #}, main does not match any layout and the server returns bare page HTML (no #page-content), which breaks boosted swaps that rely on hx-select.

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.

Composition, not inheritance

Chirp layouts use composition viarender_with_blocks. The layout defines {% block content %}{% end %}; Chirp replaces it with the rendered page HTML. This is not Kida/Jinja2 {% extends %}inheritance — the page template does not extend the layout, so it cannot override sibling blocks in the layout.

{# _layout.html — layout defines these blocks #}
<div id="page-content">
  {% block content %}{% end %}
</div>
{% block page_scripts %}{% end %}
{# page.html — this block is IGNORED, not an override #}
{% block page_content %}
  <p>Page content</p>
{% end %}

{% block page_scripts %}
  <script>/* never reaches the browser */</script>
{% end %}

In the example above, the page_scripts block in page.html defines a new local block — it does not fill the layout's page_scripts. The layout's version stays empty because render_with_blocks only injects into the contentblock.

The rule: page templates own the region inside{% block content %}. Everything outside — <head>, footers, script slots — belongs to the layout. If your page needs a <script>, put it inline within the content block:

{# page.html — script inside the content region, works correctly #}
{% block page_root %}
<div id="page-root">
{% block page_content %}
  <p>Page content</p>
{% end %}
</div>

<script>
Alpine.safeData("myComponent", function() { /* ... */ });
</script>
{% end %}

If you come from Jinja2/Django where child templates {% extends %}a parent and override blocks at will, this is the key difference. Chirp's filesystem pages are injected into layouts, not derived from them.

Common mistakes

  • {% extends %} in inner layouts — Inner _layout.html files that use {% extends %} can conflict with render_with_blocks. The child template may wipe the shell. Prefer composing with shell_sectioninstead.
  • Overriding layout blocks from page templates — Page templates cannot fill layout blocks likepage_scripts or head_extra. These blocks are only available to templates that {% extends %} the layout directly (e.g. inner _layout.html files). Put inline <script>tags inside the content region instead.
  • 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-disinherit in shell layouts — Prefer hx-select on the parent. Use hx-target="this" on event-driven elements (e.g. SSE) instead of hx-disinherit.
  • Duplicate targets in a chain — Two layouts with the same target causefind_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:

  1. Path parameters — From the URL match (e.g.doc_id from /documents/{doc_id})
  2. 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 byid
  • child routes can remove inherited actions byid
  • a zone canreplaceits 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 returns Page("path/to/page.html", "content", ...)
  • edit.py + edit.html → handler returns Page("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 target
  • page_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):

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.