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.py only when the same route needs metadata, page(), @rpc() actions, auth checks, caching, redirects, or other server-side logic.
  • Use a standalone index.py only for non-visual routes such as redirects or action-only handlers.
  • Use layout.html for shared wrappers in route sections, and layout.py only 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,
    )
Dynamic path params arrive as a single 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.

src/app/users/[id]/index.html
/users/123

Catch-All Segments

Use an ellipsis inside brackets to match multiple path segments.

src/app/docs/[...slug]/index.py
/docs/getting-started/setup

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.