Filesystem Routing

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

10 min read 1961 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)
_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.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".

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: main #} — Tells Chirp which layout depth to render for HX-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.html files that use {% extends %} can conflict with render_with_blocks. The child template may wipe the shell. Prefer composing with shell_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-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.