Route Directory Contract

Reserved files, inheritance rules, route kinds, and shell contract for filesystem routes

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Overview

When you build pages with filesystem routing, Chirp discovers routes from files on disk —page.py handlers, page.html templates, and reserved underscore files like _meta.py and _context.py. The route directory contract is the set of rules for those files: which ones Chirp looks for, whether each is scoped to one route or inherited down the tree, and how their metadata, context, and layouts combine.

Reach for this page when you are laying out a route directory and need to know what each reserved file does, or whenapp.check()flags a route-contract issue you want to fix.

Reserved Files

Every file Chirp recognizes in a route directory, what it does, and whether it is scoped to one route or inherited by every route below it:

File Scope Purpose
page.py route-local Primary route handler. Exportsget, post, etc. (or a handler fallback). page.py maps to the directory URL; any other .pyfile appends its stem.
page.html route-local Primary page template, sibling ofpage.py. Defines the fragment blocks rendered into the layout.
_meta.py route-local Route metadata — title, section, breadcrumb label, shell mode. Exports aMETA constant or a meta()callable.
_context.py inherited Subtree-scoped context provider. Exportscontext(), which receives path params, parent context, and services.
_layout.html inherited Subtree layout wrapper. Declares{# target: element_id #} and a {% block content %}the page composes into.
_actions.py route-local Mutation handlers. Exports@action-decorated functions dispatched by an _actionform field.
_viewmodel.py route-local View assembly. Exportsviewmodel()to merge data for the template.

Minimal working example

A route directory needs only two files to serve a titled page: apage.py handler and a sibling _meta.py.

Source: examples/chirpui/pages_shell/pages/projects.

For dynamic metadata, export ameta() callable instead of the METAconstant — it receives path params:

# pages/projects/{slug}/_meta.py
from chirp.pages.types import RouteMeta


def meta(slug: str) -> RouteMeta:
    return RouteMeta(title=f"Project: {slug}", breadcrumb_label=slug)

RouteMetais a frozen dataclass; every field is optional:

Field Type What it sets
title str Page title (page_titlein shell context)
section str Binds the route to a registeredSectionfor tabs and breadcrumbs
breadcrumb_label str This route's breadcrumb segment
shell_mode str App-shell mode (e.g."tabbed")
auth str Auth tag for the route
cache str Cache policy tag
tags tuple[str, ...] Free-form tags

Sections

ASection groups routes under shared navigation — tabs and a breadcrumb prefix. Register sections before mount_pages():

from chirp.pages.types import Section, TabItem

app.register_section(Section(
    id="discover",
    label="Discover",
    tab_items=(TabItem(label="Skills", href="/skills"),),
    breadcrumb_prefix=({"label": "App", "href": "/"},),
))

A route joins a section by setting RouteMeta.section to the section id. Chirp then resolves the section's tab_items and breadcrumb_prefix into the shell context for that route. The same tab list is exposed under route_tabs for chirp-ui's render_route_tabs macro. Each TabItem carries a label, href, optional icon and badge, and a match mode ("exact" or "prefix") that controls active state for nested URLs.

Route Kinds

Chirp classifies each discovered route by the files it finds:

Kind Files Description
page page.py + page.html Standard page with a template.
detail page.py + page.html inside {param}/ Parametrized page.
action page.py(no template) Mutation-only route.
redirect page.py returning a Redirect Redirect route.

Actions

_actions.py exports @action-decorated handlers. The decorator takes the name a form uses to target it:

from chirp.pages.actions import action
from chirp import OOB, Fragment


@action("rename")
def rename(name: str) -> OOB:
    # ... update data ...
    return OOB(Fragment("page.html", "title", title=name))

A form selects the action with an _actionfield whose value matches the decorator name:

<form hx-post="" hx-target="#title">
  <input type="hidden" name="_action" value="rename">
  <input name="name">
  <button>Save</button>
</form>

Chirp discovers actions at route registration and dispatches by the _actionvalue.

Contract validation

app.check()validates the route directory contract at startup. Read each issue as a pointer to one concrete fix target: a useful diagnostic names the route, template, block, selector, middleware, config flag, import string, or registration that must change. The route-specific categories:

Category Severity What it catches
route_contract ERROR / WARNING Umbrella for section bindings, shell-mode/block alignment, route-file consistency, duplicate routes, section tab hrefs, and_context.pyprovider signatures.
page_handlers ERROR / WARNING Apage.py defines no recognized HTTP method handler. A handler-shaped typo (def handle, def GET) is a WARNING; an entirely missing handler is an ERROR, because the file would register no routes.
route_names ERROR Two routes at different paths claim the same name, soapp.url_for(name)would resolve ambiguously. Method variants of the same URL are not flagged.
mount_app_merge INFO app.mount_app()dropped a sub-app global, filter, provider, handler, or severity override because the parent had already registered one. Parent-wins is intentional.
hx-target ERROR / WARNING htmx attributes (hx-target, hx-indicator, hx-boost) reference a missing selector or an unsafe target. Fix the selector or element named in the issue.
csrf_form WARNING CSRFMiddleware is active and a static mutating <form> has no {{ csrf_field() }}, csrf_token(), or _csrf_tokenfield.
dead WARNING A template exists but no route, include, import, or layout references it — usually cleanup, not a deploy blocker.

Each category is a stable handle for CI policy; the message names the concrete fix target. Tune any category before running checks:

from chirp.contracts.types import Severity

# Demote the missing-handler ERROR during a migration:
app.override_contract_severity("page_handlers", Severity.WARNING)

Advanced and introspection