Metadata-Version: 2.4
Name: starlette-templates
Version: 0.0.1a9
Summary: Serve a directory of Jinja2 templates and markdown as static files, with named SQL queries, Hugo-style shortcodes, and HTML-in-Python helpers, built on Starlette and Jinja2.
Author-email: Tycho Engineering <tychoengr@gmail.com>
Keywords: web,starlette,jinja2
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.10
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Environment :: Web Environment
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: starlette>=0.48.0
Requires-Dist: jinja2>=3.1.6
Requires-Dist: pydantic>=2.11.9
Requires-Dist: aiofiles>=25.1.0
Requires-Dist: markdown-it-py>=3.0.0
Requires-Dist: mdit-py-plugins>=0.4.2
Requires-Dist: linkify-it-py>=2.0.3
Provides-Extra: dev
Requires-Dist: ruff; extra == "dev"
Requires-Dist: ipykernel; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: nbformat; extra == "dev"
Requires-Dist: setuptools; extra == "dev"
Requires-Dist: wheel; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: uvicorn>=0.37.0; extra == "dev"
Requires-Dist: httpx>=0.28.1; extra == "dev"
Requires-Dist: mypy>=1.17.1; extra == "dev"
Requires-Dist: pytest-asyncio>=1.1.0; extra == "dev"
Requires-Dist: mkdocs; extra == "dev"
Requires-Dist: mkdocs-material; extra == "dev"
Requires-Dist: mkdocstrings-python; extra == "dev"
Requires-Dist: mkdocs-redirects; extra == "dev"
Requires-Dist: mkdocs-awesome-pages-plugin; extra == "dev"
Requires-Dist: mkdocs-autorefs; extra == "dev"
Requires-Dist: mkdocs-llmstxt; extra == "dev"
Dynamic: license-file

# Starlette-Templates

[Documentation](https://starlette-templates.tycho.engineering) | [PyPI](https://pypi.org/project/starlette-templates/)

Starlette-Templates serves Jinja templates, Markdown, and static files. Templates in `shortcodes/` become Jinja tags and templates in `fragments/` become HTMX fragments that re-render themselves when a handler reports a change.

```bash
pip install starlette-templates
```

## Quick start

This site holds one page, one shortcode, and one fragment:

```
app.py
templates/
    index.jinja
    shortcodes/
        note.jinja
    fragments/
        clock.jinja
```

`app.py` mounts the site and registers two functions: one that supplies the data of the fragment, and one that
handles the button:

```python
from datetime import datetime
from importlib.resources import files

from jinja2 import FileSystemLoader
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.requests import Request

from starlette_templates import HTMXResponse, StaticFiles

PKG_DIR = files(__package__)

async def clock(request: Request) -> dict:
    """the context function of the clock fragment"""
    return {"now": datetime.now().strftime("%H:%M:%S")}

async def refresh(request: Request) -> HTMXResponse:
    """the handler of the refresh button"""
    return HTMXResponse("Refreshed.", trigger="clock.tick")

app = Starlette(
    routes=[
        Route("/refresh", refresh, methods=["POST"]),
        # The catch-all mount goes last.
        Mount(
            "/",
            StaticFiles(
                loader=FileSystemLoader(PKG_DIR / "templates"),
                html=True,
                fragments=[clock],
            ),
            name="site",
        ),
    ]
)
```

`fragments` takes context functions: async functions that take the request and return the variables for the Jinja template of the same name. They run each time the fragment renders, so the fragment always shows current data.

`templates/fragments/clock.jinja` renders the fragment. Its first line lists the triggers that re-render it:

```jinja
{% set triggers = ["clock.tick"] -%}
<span id="clock">{{ now }}</span>
```

Triggers are named events. A template lists the triggers that re-render it, and a handler sends one with
`HTMXResponse(..., trigger=...)`. The name is the only coupling between the two: the handler names no fragment and no
DOM id.

**One name, three places.** The fragment is named `clock` because the function is named `clock`. The template must be
`fragments/clock.jinja`, and the root element it renders must carry `id="clock"`:

```
async def clock(request)          →  the name of the fragment
templates/fragments/clock.jinja   →  the template of that name
<span id="clock">                 →  the element htmx swaps
```

The id is what htmx replaces on the page. A mismatched id raises when the fragment renders, rather than swapping into
nothing.

`templates/shortcodes/note.jinja` is a snippet. It uses `inner`, so the tag takes a body:

```jinja
<div class="note note-{{ kind }}">{{ inner }}</div>
```

`templates/index.jinja` calls both of them as tags:

```jinja
<!DOCTYPE html>
<html>
<head><script src="https://unpkg.com/htmx.org@2"></script></head>
<body>
  <p>The time is {% clock %}.</p>

  {% note kind="tip" %}
    The button below never names the clock.
  {% endnote %}

  <button hx-post="/refresh" hx-target="#flash">Refresh</button>
  <div id="flash"></div>
</body>
</html>
```

Run it with `uvicorn app:app --reload` and open `http://localhost:8000/`. A click posts to `refresh`, which answers with
`Refreshed.` and sends `clock.tick`. The clock lists that trigger, so it renders and rides back in the same response:

```
$ curl -X POST localhost:8000/refresh
Refreshed.<span id="clock" hx-swap-oob="true">14:32:07</span>
```

htmx swaps that span into the element with the same id, so the clock updates although the handler never named it. To add
a second fragment, you write another template and another function.

## Static files

`StaticFiles` is a raw ASGI app that serves the directories behind a Jinja2 loader. A `.jinja` or `.j2` file renders as a
template, and every other file is served unchanged with ETag and Last-Modified support.

Only `loader` is required. The call below passes everything it takes:

```python
from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader
from starlette.routing import Route

from starlette_templates import StaticFiles

import weather  # a module of fragment context functions

static = StaticFiles(
    # A ChoiceLoader checks each loader in turn, so a file in your app can
    # override a file that a framework or a theme ships.
    loader=ChoiceLoader([
        FileSystemLoader("site"),  # checked first
        PackageLoader("mytheme", "site"),  # fallback
    ]),
    html=True,  # serve the index page for a directory URL, and a 404 page on a miss
    check_dir=True,  # raise on the first request when a served directory is missing
    follow_symlink=False,  # keep a symlinked path inside the served directory
    max_age=3600,  # Cache-Control for plain files; None omits the header
    template_cache_control=None,  # Cache-Control for rendered templates; the default is none
    global_vars={"site_name": "Weather"},  # more template globals
    filters={"money": money},  # more Jinja filters
    extensions=["jinja2.ext.i18n"],  # your own Jinja extensions
    fragments=weather,  # context functions for the htmx fragments
    page_context=[Route("/weather/{code}", station)],  # variables for one page
    query_runner=SqliteRunner("weather.db"),  # runs the named SQL queries
)
```

With `html=True`, a directory URL serves the index page from that directory, and a directory URL without a trailing
slash redirects to add one. A request that matches no file falls back to a `404` page when one exists.

Extensionless URLs work as well. A page may be spelled `.jinja`, `.j2`, `.html`, `.html.jinja`, or `.html.j2`, and a
request for `/about` finds any of those spellings.

### Template globals

Every rendered template has these, and so does Markdown rendered through `include_markdown()`:

```jinja
{# request — the Starlette Request. Read auth state from request.user. #}
<p>Current path: {{ request.url.path }}</p>

{# url_for — a full URL from a path relative to the site root #}
<a href="{{ url_for('/search', query_params={'q': 'llamas'}) }}">Search</a>

{# jsonify — JSON safe to embed in HTML. It handles datetime, Decimal, and Pydantic models. #}
<script type="application/json">{{ jsonify(dict(request.query_params)) }}</script>

{# include_markdown — a Markdown file rendered to safe HTML. The file may contain Jinja. #}
<article>{{ include_markdown('intro.md') }}</article>

{# queries, fetch(), fetch_one(), fetch_value() — named SQL queries, below #}
```

Add your own with the `global_vars` and `filters` arguments.

### Markdown in a template

A `{% markdown %}` block writes Markdown inside the HTML, with no separate file:

```jinja
<article class="prose">
  {% markdown %}
  # Welcome

  Text with **bold** and a [link]({{ url_for('/about') }}).

  {% note kind="tip" %}Shortcodes work here too.{% endnote %}

  - one
  - two
  {% endmarkdown %}
</article>
```

The body is Jinja first and Markdown second, so variables, filters, shortcodes, and fragment tags all work in it. The
block strips its own indentation before the conversion, so a block laid out to match the HTML around it stays out of a
code block. Autoescape runs on the body, so a value cannot inject markup of its own.

### Page context

A page often needs data of its own. Each route in `page_context` names a URL and an async function, and the function
returns the variables that the page at that URL renders with:

```python
from jinja2 import FileSystemLoader
from starlette.routing import Route

from starlette_templates import StaticFiles

async def site_wide(request):
    return {"site_name": "Weather", "year": 2026}

async def one_station(request):
    code = request.path_params["code"]  # the route's convertors fill path_params
    return {"station": await load_station(code)}

static = StaticFiles(
    loader=FileSystemLoader("site"),
    page_context=[
        Route("/{path:path}", site_wide),  # every page
        Route("/weather/{code}", one_station),  # and this one as well
    ],
)
```

`site/weather/{code}.jinja` then writes those variables like any others:

```jinja
<h1>{{ station.name }} — {{ site_name }}</h1>
```

Every route that matches contributes, in declaration order, so a later route wins a repeated name. Nothing calls the
function as an endpoint: `StaticFiles` uses the route for its path alone. Every shortcode the page calls reads these
variables too, and so does every `{% markdown %}` block and every Markdown file the page includes.

### Named SQL queries

A template declares a named query with the `{% sql %}` tag. Declaring it produces no output. The query runs when the
template calls `fetch()` on it:

```jinja
{% sql stations from weather %}
SELECT name, elevation FROM stations WHERE country = :country
{% endsql %}

{% sql totals from weather %}
SELECT count(*) FROM stations WHERE country = :country
{% endsql %}

<p>{{ fetch_value(queries.totals, 0) }} stations.</p>

<ul>
{% for s in fetch(queries.stations) %}
  <li>{{ s.name }} at {{ s.elevation }}m</li>
{% endfor %}
</ul>
```

The three helpers differ in what they return:

```jinja
{{ fetch(queries.stations) }}          {# every row, as a list of dicts #}
{{ fetch_one(queries.stations) }}      {# the first row, or the default #}
{{ fetch_value(queries.totals, 0) }}   {# the first column of the first row, such as a COUNT #}
```

Markdown declares a query as a fenced code block instead, and the `{% sql %}` tag works there too:

````markdown
```sql totals from weather
SELECT count(*) FROM stations
```

There are {{ fetch_value(queries.totals) }} stations.
````

The query string of the request supplies the `:name` placeholders, so `?country=US` supplies `:country`. The runner
binds them out of band and never interpolates them.

Supply a runner that implements the `QueryRunner` protocol. This one uses [aiosqlite](https://aiosqlite.omnilib.dev/)
and runs every query against a single SQLite file:

```python
import aiosqlite
from jinja2 import FileSystemLoader

from starlette_templates import StaticFiles
from starlette_templates.staticfiles import Query, Row


class SqliteRunner:
    def __init__(self, path: str) -> None:
        self.path = path

    async def run(self, query: Query, params: dict) -> list[Row]:
        async with aiosqlite.connect(self.path) as conn:
            conn.row_factory = aiosqlite.Row
            async with conn.execute(query.sql, params) as cursor:
                return [dict(row) for row in await cursor.fetchall()]


static = StaticFiles(loader=FileSystemLoader("site"), query_runner=SqliteRunner("weather.db"))
```

A runner that serves several databases reads `query.database` to pick the connection, which this one ignores. Without a
runner, `fetch()` returns an empty list, so a site with no database still serves.

## Shortcodes

A `shortcodes/` folder under a served directory needs no registration. Every template file in it becomes a Jinja tag
named after the stem of the file, slugified with underscores:

```
shortcodes/
    youtube.html                  {% youtube %}
    onboarding-wizard-modal.html  {% onboarding_wizard_modal %}
    note.html                     {% note %} ... {% endnote %}
```

The template decides the form of the tag. When the template uses the `inner` variable, the shortcode is **paired** and
the caller closes it with `{% end<name> %}`. Otherwise it is **void** and takes no end tag:

```jinja
{# shortcodes/youtube.html — no `inner`, so the tag is void #}
<iframe src="https://www.youtube.com/embed/{{ id }}" title="{{ title }}"></iframe>

{# shortcodes/note.html — uses `inner`, so the tag is paired #}
<div class="note note-{{ kind }}">{{ inner }}</div>
```

A page calls them like this, and so does a Markdown file included with `include_markdown()`:

```jinja
{% youtube id="dQw4w9WgXcQ" title="Never gonna give you up" %}

{% note kind="warning" %}
    Do not feed the llamas after midnight.
{% endnote %}
```

A shortcode template renders with the keyword arguments from the call site, which win over the calling context. It also
renders with the full context of the calling page. A paired shortcode gets `inner` as well, the rendered body.

Discovery happens once, when you build the app. A file whose name cannot become a usable tag raises a `ValueError`
instead of being skipped: a name that normalizes to nothing, a non-identifier such as `3d.html`, a reserved Jinja tag
such as `for.html`, a collision with another tag, or a shadowed end tag such as `endnote.html` next to `note.html`.

To use shortcodes outside `StaticFiles`, build the extension yourself:

```python
from jinja2 import Environment, FileSystemLoader

from starlette_templates.shortcodes import shortcode_extension

env = Environment(
    loader=FileSystemLoader("templates"),
    extensions=[shortcode_extension("shortcodes")],
)
```

## HTMX fragments

One click often makes several parts of a page wrong at once. When a shopper adds an item to a cart, the badge, the
total, and the mini cart all have to change.

A fragment is three things that share one name: an async context function, a template under `fragments/` of that name,
and the `id` of the root element that the template renders. Here that name is `cart_badge`:

```python
# store.py — one function per fragment, named after the fragment
async def cart_badge(request):
    return {"count": await request.app.state.cart.count()}

async def cart_total(request):
    return {"total": await request.app.state.cart.total()}
```

```jinja
{# templates/fragments/cart_badge.html.jinja — the name is the stem of the file #}
{% set triggers = ["cart.changed"] -%}
<span id="cart_badge" class="badge">{{ count }}</span>
```

Triggers are named events. `cart_badge` lists `cart.changed`, so it re-renders whenever a handler sends that trigger.
Several fragments can list the same trigger, and one fragment can list several.

A template file may be spelled `.html`, `.jinja`, `.j2`, `.html.jinja`, or `.html.j2`, and the whole suffix comes off to
leave the name. A template with no context function of its name, and a template that lists no triggers, both raise when
you build the app. An `id` that does not match the name raises when the fragment renders.

```python
statics = StaticFiles(loader=FileSystemLoader(TEMPLATES), html=True, fragments=store)
```

A page drops a fragment in wherever it wants one. Each is a Jinja tag of its own name:

```jinja
<header>
  {% cart_badge %}
  {% cart_total %}
</header>
```

The handler changes one thing and sends the trigger that says what happened. It names no fragments and no DOM ids:

```python
async def add_to_cart(request):
    await request.app.state.cart.add(request.path_params["sku"])
    return HTMXResponse("Added.", trigger="cart.changed")
```

`HTMXResponse` renders every fragment that lists `cart.changed`. They render concurrently, and each rides back in the
same response as an htmx out-of-band swap:

```
$ curl -X POST localhost:8000/cart/add/boot
Added.
<span id="cart_badge" class="badge" hx-swap-oob="true">1</span>
<span id="cart_total" class="total" hx-swap-oob="true">$129.00</span>
<ul id="mini_cart" class="mini-cart" hx-swap-oob="true">...</ul>
```

The response finds the fragments through the site mounted in the app, so you install no middleware and the site hands
nothing to the handler. A trigger that no fragment lists leaves the response untouched.

### A slow fragment fetches itself

A slow fragment delays the response to the click. Add `{% set pull = true %}` to its template:

```jinja
{# templates/fragments/recommendations.html.jinja #}
{% set triggers = ["cart.changed"] -%}
{% set pull = true -%}
<div id="recommendations" class="recs">
  {% for item in suggestions %}<li>{{ item.name }}</li>{% endfor %}
</div>
```

Nothing else changes. The page still writes `{% recommendations %}`, and the handler still names no fragments. The tag
now emits the markup that re-fetches the fragment from the `/fragment/<name>` URL that the site already answers:

```html
<div hx-get="/fragment/recommendations" hx-trigger="cart.changed from:body" hx-swap="outerHTML">
  <div id="recommendations" class="recs">...</div>
</div>
```

The fragment stays out of the response to the click, and the `HX-Trigger` header survives so the browser asks for it
separately. You pay an extra round trip and keep the slow fragment off the critical path.

### Where the context functions come from

A module is the common source. The `fragments` argument takes any of these, or a sequence that mixes them:

```python
fragments=store  # a module: each function by its own name
fragments={"cart_badge": badge_for_header}  # a mapping: name a fragment something else
fragments=cart_badge  # one function, by its __name__
fragments=[store, checkout, {"mini_cart": mini}]  # any mix of the three
```

To send a trigger from a response of another class, install `HTMXMiddleware`. It does the same work for any response
that carries an `HX-Trigger` header, at the cost of buffering it.

The [htmx cascade example](examples/htmx_cascade) is a runnable cart with five fragments and two routes, and no handler
in it names a fragment.

## HTML in Python

The `ht` factory builds `Element` trees in Python. Use it for fragments, email bodies, or HTML you want to return
without a template file:

```python
from starlette_templates import ht

card = ht.div(
    ht.h1("Hello World"),
    ht.p("Built in Python."),
    id="card",
    classes=["container", "content"],
    style={"color": "red"},
)
html = ht.render_element(card)
```

`Document` is an `Element` subclass that renders a full HTML document. It is also an ASGI app, so a handler can return
one directly:

```python
from starlette.applications import Starlette
from starlette.routing import Route

from starlette_templates import Document, ht


async def homepage(request):
    return Document(ht.h1("Welcome"), page_title="Home")


app = Starlette(routes=[Route("/", homepage)])
```
