Metadata-Version: 2.4
Name: starlette-templates
Version: 0.0.1a11
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
Requires-Dist: pyyaml>=6.0
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: types-PyYAML; 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 a folder of Jinja templates, Markdown, and static files as a website. A `.jinja` or `.j2`
file renders on each request, a `.md` file renders and converts to HTML inside the layout its frontmatter names, and
every other file goes out unchanged with ETag and Last-Modified support.

## Installation

Starlette-Templates needs Python 3.10 or later.

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

## Quick start

```python
from jinja2 import PackageLoader
from starlette.applications import Starlette
from starlette.routing import Mount

from starlette_templates import StaticFiles

app = Starlette(
    routes=[
        # PackageLoader reads myapp/site/, next to myapp/__init__.py
        Mount("/", StaticFiles(loader=PackageLoader("myapp", "site"), html=True), name="site"),
    ]
)
```

`myapp/site/index.jinja` renders on each request:

```jinja
<!DOCTYPE html>
<link rel="stylesheet" href="{{ url_for('/style.css') }}">
<h1>Hello</h1>
<p>You asked for {{ request.url.path }}.</p>
```

Run `uvicorn app:app --reload` and open `http://localhost:8000/`. `html=True` resolves the directory URL to
`myapp/site/index.jinja`. `/style.css` goes out unchanged, with an ETag and `Cache-Control: public, max-age=3600`.

## Static files

`StaticFiles` is a raw ASGI app. It serves the directories that a Jinja2 loader reads.

Only `loader` is required. The call below names every argument with its default; `money`, `station`, and `SqliteRunner`
are your own code:

```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 request for `/docs/` serves `docs/index.jinja`, and a request for `/docs` redirects to `/docs/`. A
request that matches no file falls back to a `404` page when one exists.

URLs need no extension. A request for `/about` finds `about.jinja`, `about.j2`, `about.md`, `about.markdown`,
`about.html`, `about.html.jinja`, or `about.html.j2`.

### Markdown pages

A `.md` file is a page. Its YAML frontmatter gives it variables, and `layout` names the template that wraps it:

```markdown
---
layout: base.html.jinja
title: About us
---

# {{ page.title }}

Hello **{{ request.query_params.get('name', 'friend') }}**.
```

`GET /about` serves it as HTML. The frontmatter is `page` in the render context, so the file reads its own `page.title`,
and so does the layout:

```jinja
<!DOCTYPE html>
<title>{{ page.title }}</title>
<main>{{ content }}</main>
```

`content` is the converted HTML, marked safe. A page that names no layout is served as the HTML its Markdown converts
to. The body is Jinja first and Markdown second, so it writes everything a `.jinja` page writes: shortcodes, fragment
tags, `{% sql %}` blocks, and its `page_context` variables.

### Template globals

Every rendered template gets these variables, and so does the Markdown that `include_markdown()` renders:

```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>

{# page — the YAML frontmatter of the Markdown file being rendered #}
<h1>{{ page.title }}</h1>

{# 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') }}).

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

Jinja runs on the body first and the Markdown conversion runs second, so variables, filters, shortcodes, and fragment
tags all work inside the block. The block strips its own indentation before the conversion, so a block that you indent
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

Each route in `page_context` pairs a URL with an async function, and the page at that URL renders with the variables the
function returns:

```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 adds its variables, in the order that you declare them, so a later route wins when two routes
return the same name. `StaticFiles` uses the `Route` for its path and its convertors alone, and nothing calls the
function as an endpoint. Every shortcode the page calls reads these variables too, and so does every `{% markdown %}`
block and every Markdown file the page includes. A synchronous context function raises `TypeError` naming its route.

### 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 query string of the request fills the `:name` placeholders, so `?country=US` fills `:country`. The runner hands the
values to the database as parameters and never writes them into the SQL text, so a visitor cannot inject SQL through the
URL. A path parameter binds nothing, so a placeholder the URL leaves unfilled reaches the runner unbound and the runner
raises.

The three helpers return different shapes:

```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.
````

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 a connection. The runner above ignores it and uses
one file. Without a runner, `fetch()` returns an empty list, so a site with no database still serves.

## Shortcodes

Put a `shortcodes/` folder in a served directory. Every template file in it becomes a Jinja tag, named after the file
without its suffix, slugified with underscores:

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

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 renders with the full context of the page that calls it, plus the keyword arguments from the call. The
keyword arguments win when a name appears in both. A paired shortcode also gets `inner`, its rendered body.

`StaticFiles` reads the folder once, when you build the app. Five kinds of file name raise a `ValueError` there: 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, and 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")],
)
```

## HTML in Python

The `ht` factory builds `Element` trees in Python. Use it for email bodies, or for 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)])
```

## HTMX fragments

When a shopper adds an item to a cart, the badge, the total, and the mini cart all have to change.

A fragment is a template under `fragments/`. The name of the file is the name of the fragment, and it is also the `id`
of the root element that the template renders. The template lists the events that make it out of date. Here the name is
`cart_badge`:

```jinja
{# templates/fragments/cart_badge.html.jinja — the name is the stem of the file #}
{% set triggers = ["cart.changed"] -%}
{% sql count %}SELECT count(*) AS n FROM cart{% endsql -%}
<span id="cart_badge" class="badge">{{ fetch_value(queries.count) }}</span>
```

The fragment renders with `request` and a `QuerySet` of its own, so a fragment that displays one query needs no Python.

Those events are called **triggers**, and a trigger is only a name. `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.

When the template cannot produce the variables it needs, give the fragment an async **context function** of the same
name. It takes the request and returns a dictionary of variables, and it runs on every render:

```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 #}
{% set triggers = ["cart.changed"] -%}
<span id="cart_badge" class="badge">{{ count }}</span>
```

**One name, three places.** The fragment is named `cart_badge` because of the file, and the same name is the `id` htmx
swaps and the name of the context function:

```
templates/fragments/cart_badge.html.jinja  →  the name of the fragment
<span id="cart_badge">                     →  the element htmx swaps
async def cart_badge(request)              →  its context function, when it needs one
```

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 that lists no triggers raises an error when you build the app, because a trigger is the one
thing that renders a fragment again. An `id` that does not match the name raises an error while the fragment renders.

Every template in `fragments/` is a fragment, even when you pass no `fragments` argument. The argument names the context
functions of the fragments that need one:

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

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

```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 at the same time, and each one goes 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>
```

htmx swaps each one into the element carrying the same id. `HTMXResponse` finds the fragments through the site you
mounted, so you install no middleware and the handler needs no access to the site. A trigger that no fragment lists
leaves the response untouched.

### Keep a slow fragment off the critical path

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>
```

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 one extra round trip, and the slow fragment no longer delays the click.

### Where the context functions come from

Most sites keep them in one module. The `fragments` argument is optional, and 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. Install it innermost, before any middleware that
rewrites the body, so that layer sees the finished body with the swaps already in it.

## Next steps

- [Static files](https://starlette-templates.tycho.engineering/static-files/) — every `StaticFiles` argument, path
  resolution, and HTTP caching.
- [Shortcodes](https://starlette-templates.tycho.engineering/shortcodes/) — tag names, discovery, and validation.
- [HTMX fragments](https://starlette-templates.tycho.engineering/htmx/) — the full cascade, `pull`, and
  `HTMXMiddleware`.
- [HTML in Python](https://starlette-templates.tycho.engineering/hypertext/) — what `ht` accepts as a child, and
  `Document`.
- [Error handling](https://starlette-templates.tycho.engineering/errors/) — raise `AppException` to get an HTML page or
  a JSON:API document.
