# Templates
A dhtml page is HTML with `{{ }}` values and a few `` tags. Everything else passes through untouched. This page describes what each piece does. [syntax.md](syntax.md) is the short specification; this page adds the exact rules.
```html
{{ post.title | upper }}
Popular{{ tag }}No tags.
```
## How a page is built
1. The file is split into text, `{{ }}` values, `` tags and HTML comments.
2. Every tag and expression is checked. A mistake stops the page with an error that names the file and line.
3. The page, together with the layout and partials it uses, becomes one template. That is what keeps output safe: the template engine knows whether a value lands in text, an attribute, a URL, a script or a style, and escapes it for that place.
4. The template runs with the page's data and the result is sent.
Every upload does steps 1 to 3 for every page before the site is switched to the new version, and refuses the upload if anything fails.
## Values
```html
{{ post.title }}
{{ post.created | date }}
{{ post.title | truncate 60 }}
{{ user.name | default "Guest" }}
```
A value is a **path**, a **string** in double quotes, a **number**, or `true` or `false`, followed by any number of `| filter` steps. There are no function calls, no arithmetic, and no assignment.
- A path is a name and then `.name` steps: `post.author.name`. A whole number picks a list item: `posts.0.title`.
- A path that leads nowhere (a missing key, a bad index, `nothing.at.all`) is empty, not an error. The first name must exist: a name that is not a built-in or a loop variable is an error with a suggestion, such as `unknown variable "pots". Did you mean "post"?`.
- Text, numbers, booleans and dates print. A list or an object does not: use ``, pick one item, or use the `count` or `json` filter.
- Strings use `\"`, `\\`, `\n`, `\t` and `\r` as escapes.
### Where values may go
Anywhere. The engine escapes by place:
| Place | Example | What happens |
|---|---|---|
| Text | `
```
The tag stands alone: the words are in the file. It works in an action, in a job, and with `for` and `max` for many rows (`{{ b.title }}` is then a column of the row). Everything said about text mail holds: the same recipients, the same values (declared form fields, results of earlier steps, `config.app.url` and `config.app.name`), the same cleaning (one line, 100 characters, a stranger's link replaced), the same footer, the same limits. What is added:
- **Values go in the text.** Not in a tag, an attribute name, `style`, `alt` or an event. The one exception is the address of a link or an image, which must start with `{{ config.app.url }}` followed by `/` (or `?` or `#`) and may go on with values: `href="{{ config.app.url }}/orders/{{ order.code }}"`. So a mail cannot link to a place you did not write. Values are escaped like a page's: `` typed by a visitor is shown as text. Two values side by side are refused, and so is a value that finishes a link you began (`https://{{ x }}`).
- **A short list of tags and attributes.** Tags: `h1`-`h4`, `p`, `div`, `span`, `ul`, `ol`, `li`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `a`, `b`, `strong`, `i`, `em`, `img`, `hr`, `br`. Attributes: `href` (on `a`), `src` and `alt` (on `img`), `width`, `height`, `colspan`, `rowspan`, `align` and a short `style` (colours, font size and weight, alignment, padding, margin, border, width, line height). Values are in double quotes. No script, form, embed, class or id; the error names what it found. `` and `` work (a `for` goes over a list of an earlier query, one level deep, at most 50 rows); no includes, components or queries.
- **Links are literal.** A link you write is `https://...` or `mailto:...`, whole. An image is a file of your own site (`{{ config.app.url }}/logo.png`, from `public/`), never someone else's server, so opening the mail tells nobody anything.
- **Two parts, always.** The text part is made from the HTML by a fixed rule: tags removed, a link as `text (address)`, paragraphs separated by a blank line, list items and table rows on lines of their own. A client that blocks HTML shows the same words and links. The footer is added to both. The HTML part is at most 100 KB.
- **See it first.** The check on every upload compiles it with the action that uses it. A mistake names the template file and its line.
Not here: attachments, a template chosen by a value, CSS files, web fonts, tracking images.
### Saving a picture
`` keeps a picture that an action received as base64 text, the way image APIs answer, as a file of the same kind as an upload (see [Uploads](#uploads)). It is for an action that is not `GET`.
```html
INSERT INTO images (user_id, prompt, path) VALUES ({{ user.id }}, {{ form.prompt }}, {{ pic }})
```
- **What it does.** The value named by `from` (a name with dots, no filters, from an earlier step) must be standard base64, at most 5 MiB once decoded. Its first bytes must be what `type` says. `type` is fixed text: `png`, `jpg`, `jpeg`, `gif` or `webp`. Anything else, HTML labelled as a picture included, is refused.
- **Where it goes.** `server/data/uploads/.`, served at `/uploads/` by the same handler as uploads: fixed content type, no sniffing, a sandboxing policy. `as` holds that path as text; keep it in a table. The name cannot be guessed, but anyone who has the address can open it: do not put a private picture here.
- **It follows the action.** If the action does not commit (an error, failed validation, a `` of 400 or more) the file is removed, as for an upload. A crash between writing and committing can leave a file behind.
- **When it fails.** The action stops with an error that names the line (never the data). With `soft` it goes on with `as` empty, so a page can say that the picture could not be made.
- **A service's answer is limited to 1 MiB**, so ask for a compact format (for OpenAI, `output_format` webp or jpeg and `output_compression`). At most 8 pictures in one action; a job cannot save. Decision D29 has the reasons.
## Webhook actions
`` answers calls from another service (a payment provider, a code host). The name is declared in `[webhooks]` in `dhtml.config` with a scheme and a secret ([config](config.md)).
```html
UPDATE orders SET paid = 1 WHERE intent = {{ webhook.json.data.object.id }}ok
```
- **The signature decides.** The call is checked over its raw body, in constant time, before anything is read from it. A missing, wrong or old signature answers `401` with nothing else, and the action does not run. There are no cookies, no CSRF token, no `Origin` check and no signed-in visitor: those protect browsers, and the caller is not one. A cookie or a token does not take the place of the signature.
- **Rules.** POST only, a `ratelimit` is required, the body is at most 1 MiB. Only the body is signed, so only the body is read: `query` is empty in a webhook action, and a file with a `[parameter]` in its name answers `404`. A webhook action with `dedupe` cannot also use ``: the event is recorded in the database transaction before the steps run, and that lock would be held while the other service answers. No `auth`, `admin`, form fields (``), ``, account steps, `` or model calls (anyone who could reach the endpoint would make it cost money), and the answer cannot draw a form. Steps, ``, ``, ``, `` and `` all work. Answer `200` quickly: services retry a call that gets any other status.
- **Events arrive more than once.** Services retry, and a retry carries the same event. `dedupe="webhook.json.id"` makes the action run once per value: the value is recorded in the same transaction as the action's own writes (in the table `_dhtml_webhook_events` that the migrations make), a repeat is answered `200 duplicate` without running, and an action that fails leaves nothing recorded, so the service's retry runs it again. An event without the value is refused with `400`. Without `dedupe`, make the writes safe to repeat (an `UPDATE ... WHERE`, or an `INSERT` into a table with a unique column).
- **Replays.** Stripe's timestamp must be within `tolerance` seconds (default 300), which limits how long a captured call can be replayed. GitHub and generic `hmac` signatures carry no time, so a captured call stays valid for ever: `dedupe` is what stops a replay there (an event is remembered for 30 days), and the check warns about a webhook action of those schemes that has none.
- **Rate limits.** Every call, signed or not, is counted against a limit ten times the declared one, so a flood of forged calls is bounded. The declared `ratelimit` is counted only for calls whose signature passed, so forged calls cannot use up the allowance of the real sender. A webhook that is listed but whose secret is not set answers `401`, like a bad signature; the owner finds the reason in the log.
## Jobs
``, `` and ``. A job is work that runs later, away from any visitor: send a receipt, call a service that is slow, clean up old rows every night. It is a file in `server/jobs/` named after the job, and it starts with ``. The steps are the steps of an action (``, ``, ``, ``, ``, ``); there is no response, because nobody waits for one.
```html
SELECT id, email FROM orders WHERE id = {{ args.order }}UPDATE orders SET receipt_sent = 1 WHERE id = {{ o.id }}
```
An action puts the job in the queue with ``. Every attribute but `job` is an argument, given as text:
```html
INSERT INTO orders (email) VALUES ({{ form.email }})Thank you. The receipt is on its way.
```
A job can also run by the clock. A job with `every` starts itself and takes no arguments:
```html
DELETE FROM drafts WHERE created_at < date('now', '-30 days')
```
- **`` attributes**: `every` (`30m`, `6h`, `1d`; from `1m` to `30d`), `at` (`HH:MM` in UTC, only with `every="1d"`), `retries` (0 to 10, default 3: how many more times a failed run is tried) and `timeout` (seconds, 1 to 600, default 60).
- **``** declares an argument with the rules of `` (`required`, `number`, `email`, `min`, `max`, `message`; not `file`). A job reads only the arguments it declared, as `args.order`. They are checked before any step runs; a value that does not fit fails the run. A job can also read `app`, `config`, `job.name` and `job.attempt` (1 for the first run), and the results of its own steps. It cannot read `form`, `user`, `route`, `query` or the other values of a request: it has none.
- **Checked before anything runs.** The upload is refused if an `` names a job that does not exist, leaves out a required argument, gives one the job did not declare, or names a job that runs by the clock. The job name is written out, never taken from a value.
- **The queue is the database.** `` writes a row in the same transaction as the action's own writes, so the job exists exactly when they are committed, and an action that fails queues nothing. The table is `_dhtml_jobs`, made by the migrations; an app's own SQL cannot read or write it. At most 10,000 jobs may wait at once, and the arguments of one job may total 4 KiB.
- **Limits on the action.** An action that enqueues needs a `ratelimit`, is not `GET`, and may enqueue at most 5 jobs on a path. A job cannot enqueue another job. A job that calls a service or a model needs no `ratelimit` of its own: how often it runs is bounded by its schedule, or by the limit of the action that enqueues it, and each run is held to the same limits as an action (at most 5 calls, 3 model calls, none after a database step in a job that writes).
- **Retries.** A failed run (a step failed, the time ran out, the program stopped) is tried again after 30 seconds, then 1 minute, 2 minutes and so on, doubling up to an hour, until `retries` is used up; then it is kept as failed for 30 days so you can read why in the site's history. Delivery is at least once: a run that was cut short may run again, so make the steps safe to repeat. The database writes of a run are committed together with the mark that it is done, so they happen once; a call to another service may happen again.
- **A full queue.** When 10,000 jobs are waiting, a scheduled run is not planned until there is room (it stays due), `` fails its action with a message that says so, and the worker keeps draining. A run that takes longer than its claim (eleven minutes, more than any `timeout`) loses it: its writes are dropped, not committed, and the log says so. A run cut short by the program stopping is given back without counting as a try.
- **Scheduled jobs** start counting when the worker first sees them (a job added now does not run at once). If the worker was stopped, the runs that were missed collapse into one. A scheduled job that is still running or waiting is not started a second time.
- **Who runs the queue.** The site runs it, one job at a time.
## API keys
A program (a script, another service, a mobile app) calls an action with a key instead of a cookie. Each signed-in person makes their own keys, and a call with a key runs as that person.
```html
SELECT id, title FROM notes WHERE user_id = {{ user.id }} ORDER BY id DESC LIMIT {{ form.limit }}{{ notes }}
Copy it now: {{ k.key }}
{{ errors.name }}
```
- **`apikey`** makes the action answer calls that carry `Authorization: Bearer dhk_...`. It is a flag, like `auth`, and it replaces the cookie: the action reads no cookie, needs no CSRF token and no `Origin` (those protect browsers, and the caller is not one), and `user` is the owner of the key. `admin` still works (the owner must be an admin); `auth` and `webhook` cannot be combined with it. A `ratelimit` is required and is counted per key owner and per address. Any method works; a `GET` cannot write, as always.
- **A wrong, missing or revoked key** answers `401` with `WWW-Authenticate: Bearer` and nothing else. A key in the address is never read (addresses end up in logs). The failures are counted per address, so guessing is slow, though a key has 256 random bits and cannot be guessed.
- **Bodies.** A key action also accepts `application/json`: a JSON object whose strings, numbers and booleans fill the fields declared with ``. A field you did not declare is ignored, whatever it holds; a declared field that holds an object or a list is refused with `400`, as is a body that is not one JSON object. Form bodies work too. A validation failure answers `422` with `{"errors": {...}}` unless the action has its own ``.
- **What a key action cannot do**: redirect, draw a form, do account steps (`` and the rest), or make and revoke keys. A key cannot make more keys.
- **``** (needs `auth`, not `GET`, a `ratelimit`) makes a key for the signed-in visitor. The field it reads defaults to `name` and must be declared. `k.key` is the key itself, shown once: only its SHA-256 is stored. `k.id`, `k.name` and `k.prefix` (the first characters, to tell keys apart) stay available. A visitor may have at most 20 keys; a missing name or too many keys is shown like a failed validation. A password reset revokes every key of the account (with its sessions), since a reset is what a person does when someone else may have the account; a routine password change does not. Make new keys after signing in again.
- **``** revokes a key by id, only if it is the signed-in visitor's own; a stranger's id does nothing, and nothing says whether it exists.
- **Listing keys.** A page may read `SELECT id, name, prefix, created_at, last_used_at, revoked_at FROM _dhtml_api_keys WHERE user_id = {{ user.id }}`. The hash cannot be named, and `SELECT *` on the table is refused. The table is made by the migrations, which need the account tables first; writes by your own SQL are refused like for every `_dhtml_` table.
- **Cross-origin calls from a browser** (CORS) are declared in `[cors]` in `dhtml.config`, and only key actions can be listed ([config](config.md)).
## ``
```html
```
Inserts `server/partials/post-card.dhtml` right there. The partial sees the same variables as the place that includes it, so a partial can show "the `post` of the loop it is in". The name is fixed text. Partials can include partials; a cycle is an error, and nesting is limited to 20 levels. A partial cannot contain ``.
To render one partial alone, for a page that updates part of itself (htmx), the engine has `RenderPartial(name, data)`. The caller's data supplies the variables the partial uses. Partials are deliberately not served over HTTP on their own; an action can respond with the rendered fragment.
## ``
```html
```
Puts the steps of `server/steps/shop.dhtml` right there. Where `` shares *markup*, `` shares *logic*: the query that finds which shop a request is for, the check that a person belongs to it, the settings every page shows. Written once, used by any page, action or job.
```html
SELECT id, name FROM shops WHERE host = {{ request.host }}SELECT role FROM memberships WHERE user_id = {{ user.id }} AND shop_id = {{ shop.id }}INSERT INTO products (shop_id, title) VALUES ({{ shop.id }}, {{ form.title }})
```
- **It is put in place before anything is checked**, so the steps are held to the rules of the place they are used: a `` at the top level of a page, a write only among the steps of an action, `` before the steps, a name given once. Results the file makes (`shop`, `m`) are variables from there on, in the page, the action and the files used after it. A mistake is reported in the steps file, with its name and line.
- **A steps file holds steps**: tags such as ``, ``, ``, ``, `` and ``, and comments. Text or a `{{ }}` value standing directly in it is an error (markup belongs in a partial). It has to close every tag it opens and cannot close one of the file that uses it, nor give an `` to it.
- **No parameters.** A steps file reads the same variables as the place that uses it (`form`, `route`, `request`, `user`, and the results above it), and says in a comment which ones it needs. A name it needs that is missing is reported at the place of use, where the fix is.
- **Limits.** The name is fixed text (a letter, digit, `-`, `_`, with folders like `admin/shop`). Steps files may use steps files, up to 5 levels, and never in a circle. One file may use steps at most 50 times, counting those of the files it uses.
- **A steps file nobody uses is not compiled on its own**, since what it may hold depends on where it is used; the check finds its mistakes when a page or action uses it.
- **A guard is the usual use.** `` or `` in a steps file stops the page or action there, and an action that stops before its writes leaves nothing written.
## `` and ``
A component is a reusable piece with named inputs. It lives in `server/components/` and starts with the inputs it takes:
```html
```
- **Inputs.** `` is the first thing in the file. A name alone is required; `name="x"` gives a default (plain text). Each input is given on `` as `name="text"` or `name="{{ value }}"`, worked out where the component is used, and escaped like any other value. The component reads it as `{{ props.name }}`. `src` is the component's name and cannot be an input.
- **A component sees only its inputs** and the built-in names (`user`, `config`, `flash`, `csrf` and the others). It cannot read the variables of the place that uses it, or `page.*` of a layout. To give it a list or a row, pass it as an input and use ``.
- **``** marks where the content between the tags goes. That content is drawn in the caller's scope. A component has at most one slot. Content between the tags of a component without a slot is an error, since it would be lost; write the tag as ``.
- **Checked when the project is compiled**, with the file and line: an input the component does not declare (with a suggestion), a required input that is missing, `props.x` for an `x` that is not declared, content without a slot, two slots, a component that uses itself directly or through others, and nesting deeper than 20 levels.
- **Partials inside a component** work (``); the partial then sees what the component sees, which is `props`.
- The check lists the components with where each is used, and warns about one that nothing uses. `` cannot be used among the steps of an action; it can be used inside ``.
- `` and `server/partials/` are unchanged: a partial still sees the variables of the place that includes it.
## Islands: state in the page
Small interactions that belong in the browser need no script of your own. Load `/_dhtml/state.js` once and mark a piece of the page as an island:
```html
Shown when open.
Total:
Hello
```
- **Directives.** `data-d-text` (text), `data-d-show` (shows or hides), `data-d-class-active="n > 1"` (adds a class), `data-d-attr-href="link"` (sets an attribute from a fixed safe list), `data-d-on-click="..."` (click, input, change, submit, keydown, keyup, focus, blur, mouseenter, mouseleave), `data-d-model="name"` (an input and a state value kept together), and `` (a row for each item, at most 1000).
- **Expressions.** Numbers, strings, names, `a.b` and `a[i]`, `+ - * / %`, comparisons, `&& || !`, `a ? b : c`, lists like `[1, 2]`, assignment with `=`, `+=`, `-=` (several, separated by `;`), and the functions `len upper lower trim str num round min max contains concat without slice`, and for lists of rows `where(list, "field", value)`, `setby(list, "id", 2, "field", value)` (a copy with that row changed) and `removeby(list, "id", 2)`. There is no `eval`, no loops and no calling anything else; a name like `constructor` or `__proto__` is refused. Dividing by zero gives 0 and a missing name gives nothing.
- **Safety.** The state is written to the page only as text, never as markup. `onclick`, `style` and `srcdoc` cannot be set; `href` and `src` keep only relative, `http`, `https`, `mailto` and `tel` addresses. The state is the visitor''s to change, so the server never believes it: send what matters through a form or an action, which checks it again.
- **Starting values** come from the server: write the JSON with `{{ ... | json }}` inside the attribute. A broken expression is reported in the browser console and the rest of the island keeps working.
- **Live events.** `data-d-channel="board"` on the island opens `/_dhtml/events?channel=board` (see [Live updates](#live-updates)); `data-d-on-live-NAME="..."` runs when the event NAME arrives, with its JSON data as `event` (nothing, if the data is damaged). The connection is closed when the island leaves the page.
- **Fast navigation.** Islands in a page that was just swapped in are started by themselves.
For a bigger client app, write your own JavaScript in `public/` (a file, since the policy forbids inline scripts) and talk to actions that answer JSON (``); the server keeps the accounts, the database and the checks.
### Live updates
`` sends a message to the people who are listening to a channel, once the action has committed:
```html
INSERT INTO messages (room, user_id, body) VALUES ({{ form.room }}, {{ user.id }}, {{ form.text }})
```
- **Channels are listed first.** Every channel has a line in `[channels]` in `dhtml.config`: `board = auth`, `news = public`, `room-* = auth`, `inbox:* = user` (see [config](config.md)). A name that is not listed does not exist: an upload is refused if an action has a `` to it, and nobody can listen to it.
- **The message.** `event` is a short name (default `message`); `value` is exactly one `{{ }}` value without filters, sent as JSON (like ``), at most 4096 bytes. A channel name is lower-case letters, digits and `- _ : .`, up to 64 characters, and starts with fixed text. The name can continue with a `{{ }}` value; if it ends up invalid, the action fails with an error instead of publishing.
- **After the commit.** The message goes out only when the action commits. A failed action, a failed validation or a rolled back write sends nothing.
- **Rules.** It needs a `ratelimit`; it cannot be in a `GET` action or a job; at most 5 on one path. It is not allowed inside ``.
- **Listening.** A page connects to `/_dhtml/events?channel=NAME` with `EventSource`, or an island does it for you: put `data-d-channel="board"` on the island and `data-d-on-live-said="items = concat(items, event)"` on any element inside (the event name is lower case; the message arrives as `event`). The rule in `[channels]` decides who may connect: `public`, `auth` (signed in), `admin`, or `user` (the name is the prefix plus the visitor's own id: `inbox:42` for user 42). One process only: several copies of the app behind one address do not share channels.
- **Sending from the page** is an ordinary form or action, with CSRF, sign-in checks, `ratelimit` and `` as always.
## ``
```html
```
Stops the page and answers with that status (400 to 599). The visitor sees the plain status text. It works in pages, layouts and partials.
## ``
```html
SELECT id, title, slug FROM posts WHERE published = {{ true }} ORDER BY created DESC LIMIT 20
SELECT * FROM posts WHERE slug = {{ route.slug }}
{{ p.title }}
```
A query reads from the project's SQLite database (`server/data/app.db`, created and changed by migrations) and puts the result in the variable named by `as`. Without `one` the result is a list of rows; with `one` it is the first row, or empty when there is none. A row is an object: `{{ p.title }}`. Numbers, text and `NULL` (empty) come through as they are; BLOB columns as text. A value that is missing, such as `query.date` when the address has no `?date=`, is `NULL` in SQL, not `''`: `{{ query.date }} = ''` matches nothing. Write `COALESCE({{ query.date }}, '') = ''` or `{{ query.date }} IS NULL`. The check warns about a comparison with `''`.
- **Values are parameters.** An empty value (a missing `query.x`, an unset path) is sent as `NULL`, not as empty text, so `{{ query.date }} = ''` matches nothing when `?date=` is absent: write `COALESCE({{ query.date }}, '')` or `IS NULL`. Each `{{ }}` in the SQL is sent to the database as a separate value, never pasted into the SQL text, so `{{ route.slug }}` is safe whatever the visitor typed. Inside a query a `{{ }}` holds one plain value (a path, text, a number, `true` or `false`), without filters. A parameter written in SQL (`?`, `?1`, `:name`, `@name`, `$name`) is an error. To build a pattern use SQL: `LIKE '%' || {{ query.q }} || '%'`.
- **Table-valued functions work:** `FROM json_each({{ x }})` takes its argument as a value, since a function call is not a name.
- **A value cannot name things.** A `{{ }}` after `FROM`, `JOIN`, `ORDER BY`, `GROUP BY`, `AS`, inside quotes, or attached to other text (`post_{{ id }}`) is a compile error: parameters are for data, not table or column names. Write the name in the SQL; to choose between a few, put separate queries in ``.
- **Read only.** A query is one `SELECT` (or `WITH ... SELECT`). `INSERT`, `UPDATE`, `REPLACE INTO`, `DELETE`, `CREATE`, `PRAGMA`, comments and a second statement are errors, and the connection itself refuses writes. Writing is done with ``, described below.
- **Where it goes.** A query is written at the top level of a **page** (not inside ``, ``, ``, and not in a layout or partial). Queries run in order before the page is rendered, and a query may use the results above it: `WHERE user_id = {{ post.user_id }}`. Pass results to a layout with page values.
- **Limits.** A query returns at most 10000 rows (more is an error: add a `LIMIT`) and runs for at most 10 seconds (all the queries of a request share that time). Two columns with the same name (`SELECT a.id, b.id`) are an error: name one with `AS`.
- Errors in a query point at its line in the page: `server/pages/blog.dhtml line 4 query "posts" failed: no such table: posts`. A missing table means a migration is missing.
## Actions
A file in `server/api` is an action: the code that answers a form post or an API call. `server/api/posts.dhtml` is the URL `/api/posts`. One file answers one method; other methods get `405` with an `Allow` header.
```html
INSERT INTO posts (title, body) VALUES ({{ form.title }}, {{ form.body }})
```
**``** has `method` (`GET`, `POST`, `PUT` or `DELETE`, default `POST`), `ratelimit="10/minute"` (`second`, `minute`, `hour` or `day`, counted per action and visitor address; over the limit answers `429` with `Retry-After`), and the bare words `auth` and `admin`: an action with `auth` answers `401` to a visitor who is not signed in, and `admin` also answers `403` to one who is not an admin. See [Accounts](#accounts).
**``** declares a form field and its rules: `required`, `email`, `number`, `date` (`YYYY-MM-DD`, a day that exists: 2026-02-30 is refused), `time` (`HH:MM`, 00:00 to 23:59), `min` and `max` (for a number its value, for text its length in characters; not for `date` and `time`). Only one of `email`, `number`, `date`, `time` and `file` goes with a field. Nothing around the value is forgiven: `2026-2-3` and `9:05` are not a date and a time, `message="..."` to replace the error text, and `file` (below). **Only declared fields exist.** Whatever else a visitor sends is dropped, and reading `form.other` is a compile error; this is what stops a visitor from setting `is_admin` or `user_id` by adding a field. All `` tags come first.
**Steps** run top to bottom: `` (as in a page), ``, and `` / `` around them. `` is an `INSERT`, `UPDATE`, `DELETE` or `REPLACE` (a `WITH` before them is allowed) with the same rules as a query (every `{{ }}` is a bound parameter, no names from values, one statement, no comments); schema changes, `PRAGMA`, `ATTACH` and `RETURNING` are refused, and so is any name starting with `sqlite_` or `_dhtml_` (SQLite's own tables and the migration record). `as="saved"` gives `saved.id` (the last inserted row) and `saved.changes` (rows changed). A `GET` action cannot contain ``.
**A transaction** wraps an action with ``: it commits when a ``, `` or `` ends it, and rolls back on `` 400 or more, on invalid input, and on any error. Files saved by an upload are removed on a rollback. (A process that dies between saving a file and the commit leaves the file behind; nothing sweeps those yet.)
**Responses.** Every path through an action ends in exactly one:
- `` an HTML fragment (or a whole page with ``; `` works), status `200`. This is what htmx swaps in.
- `{{ posts }}` one value, written as JSON.
- `` answers `303`. `to` starts with a fixed path on this site; values after it are escaped for where they land (a path part or a query value), so a value can never add a host, a second parameter or a line break.
- ``.
A response ends the request, so `` followed by more steps is the usual guard. A step written after a response that ends every path can never run and is a compile error, as is a path with no response.
**``** (at most one, directly inside the action) is the answer when validation fails: status `422`, with `form` (the submitted values) and `errors` (`errors.title`) available, all escaped as everywhere. Without one, the answer is a plain `422` (JSON `{"errors": ...}` if the request asks for JSON).
**A response that is not HTML.** `` and `` answer with `application/xml` or `text/plain` (always UTF-8, always with `nosniff`). No other type is accepted. It is for a sitemap or a feed that reads the same tables as the site:
```html
SELECT slug, published_at FROM posts WHERE status = 'published' ORDER BY published_at DESC{{ config.app.url }}/blog/{{ p.slug }}{{ p.published_at }}
```
Give it its real address in `[routes]`: `/sitemap.xml = api/sitemap.dhtml`. Values are escaped like everywhere else, which is right for XML; in `text` an `&` shows as `&`, so use `text` for fixed words. The `` line is added for you; do not write it.
Actions are checked on every upload, before the site is switched to the new version.
### AI calls
```html
SELECT title, body FROM posts ORDER BY created DESC LIMIT 5Answer questions about this blog. Be brief.Latest post: {{ posts.0.title }}: {{ posts.0.body | truncate 2000 }}{{ form.question }}
{{ answer }}
```
`` asks a language model and puts the answer, as text, in the variable named by `as`. The provider, model and key come from [`[ai]` in `dhtml.config`](config.md#ai-optional); `model`, `max_tokens` (up to 32768) and `timeout` (seconds, up to 300) on the tag change them for one call.
- **Only in actions that cost something to abuse.** `` is allowed among the steps of an action that is not `GET`, so CSRF, validation and body limits apply, and the action must have a `ratelimit` of at most 120 requests a minute (a compile error otherwise, as is a limit written in another unit that comes to more). An action may call a model at most 3 times, so one request is never many calls. A page cannot call a model. At most 32 requests may wait for a model at once; the next one answers `503` with `Retry-After`, so slow models cannot use up every connection of the server.
- **The prompt** is `` (instructions, at most one), any number of `` (data the answer should use) and exactly one `` (the question). Each holds text and `{{ }}` values; a value goes in as the text it prints as, and nothing is escaped, because a prompt is not HTML (`<` and `&` are just characters). A list or object cannot be put in a prompt: pick fields, or build the text. The only filter is `| truncate N`, which keeps a long value from filling the prompt. The whole prompt is at most 256 KiB. dhtml sends the context in `` tags in the same message as the question, so a model can tell the data from the instruction. Text in the data can still try to instruct the model (prompt injection): never let an answer decide what the application does.
- **The answer is untrusted text.** It is a plain string, escaped wherever you show it (`{{ answer }}`), at most 1 MiB. Markdown can be rendered with `{{ answer | markdown }}`, which cannot produce a script.
- **Errors** are shown to you with the tag's line and what to do (`AI call failed: the AI provider did not accept the API key. Check the key in the Secrets section ...`). A visitor sees only the bare status and the log has the reason. Prompts and answers are never logged; a call logs the provider, model, token counts and outcome. The key is never put in an error: provider messages are cleaned of anything key-shaped first.
- **No database lock while a model thinks.** In an action that has a ``, the write transaction starts at its first database step after the call (a `` there counts, so what the action reads is what it writes against). A model call after a write, or after a query that is not before every call, is a compile error: call the model first, then query and write, or use ``, which commits before its first word. A `` that comes *before* a model call (or a ``) is the exception: it reads through the shared handle and does not start the transaction, so you can check something, call, and then write. It counts as before the call only if every path from it reaches a call before it reaches any write (``, an account step, ``, a key step); a path that ends in an answer with no write is fine. Otherwise it is an ordinary read in the transaction, and the call after it is refused as before. The read can be stale by the time the write happens, so a write that depends on it must say so: `UPDATE accounts SET balance = balance - {{ u.total }} WHERE user_id = {{ user.id }} AND balance >= {{ u.total }}`, then check `spent.changes`. The server's write timeout does not cut a slow answer short; the call's own `timeout` decides.
- **Streaming.** `` (no `as`) makes the action answer with server-sent events as the model writes: `data: ""` for each piece, then `event: done`, or `event: error` with a message that is safe to show. The stream is the response, so nothing can follow it in that path, and steps before it (an `` that saves the question) are committed before the first piece. To show it, give a form `data-stream-target`:
```html
```
`dhtml.js` sends the form with `fetch` (a `GET` form sends its fields in the address), and writes each piece into `#answer` with `textContent`, so the answer can never become markup. A newer submit for the same target stops the older stream. Plain text from the server (a refusal such as "question: This field is required.") is shown as it is; an HTML page, a redirect, or anything else is not shown, only a short message. The element gets `data-stream-state="streaming"`, then `done` or `error`, and a `dhtml:stream` event fires on it when the stream ends. Without JavaScript the form posts normally and the browser shows the raw event stream; use a normal `` for pages that must work without it.
**Usage, conversations and typed answers** (three attributes and a tag, all plain data):
```html
SELECT tokens FROM wallets WHERE user_id = {{ user.id }}SELECT role, body FROM (SELECT id, role, body FROM chat WHERE user_id = {{ user.id }} ORDER BY id DESC LIMIT 20) ORDER BY idYou are a helpful assistant for {{ app.name }}.{{ form.message }}UPDATE wallets SET tokens = tokens - {{ used.total }} WHERE user_id = {{ user.id }} AND tokens >= {{ used.total }}INSERT INTO chat (user_id, role, body) VALUES ({{ user.id }}, 'user', {{ form.message }})INSERT INTO chat (user_id, role, body) VALUES ({{ user.id }}, 'assistant', {{ answer }}){{ answer }}Out of tokens.Out of tokens.{{ form.message }}
```
- **`usage="used"`** keeps the tokens the provider reported in `used.input`, `used.output` and `used.total` (zero when it does not say). Not with `stream`, whose end comes after the steps have run. dhtml counts nothing and keeps no balance: credits, plans and prices are tables of your own, and the steps above are how an app spends them. The server log still holds the counts of every call.
- **Check for the most a call can cost, and check the spend.** The model is called before the spend, because a call cannot wait inside the write transaction. A balance of 5 against a call that costs 18 would still pass `balance.tokens > 0`, and the call, paid for by you, would be answered for nothing. So test the balance against more than one call can cost (the prompt you send plus `max_tokens`, here 2000), and show the answer only when the guarded `UPDATE` changed a row (`spent.changes == 1`), as above. The `AND tokens >= ...` in the `UPDATE` is what stops two requests at once from spending the same tokens.
- **A call that fails is still billed.** If the model answers in the wrong shape (`fields`), with nothing, or is cut off, the provider has already charged for it, but the action stops with an error and no step after the call runs, so nothing is spent. The `ratelimit` of the action bounds how often that can happen; an app that cannot afford it should keep its own count of attempts in a table, written before the call.
- **``** (inside ``, at most one) sends earlier turns before the question. `rows` is the result of a `` above it; `role` and `text` name the columns (defaults `role` and `text`). Each role must be exactly `user` or `assistant` (anything else, `system` included, fails the call, since a row in a table must not speak with the authority of your instructions); empty turns, more than 100 turns and a prompt over 256 KiB are refused. No rows is a new conversation. Select the latest turns, oldest first, as above.
- **`fields="mood:text, score:number, urgent:bool"`** asks for the answer as typed values (types `text`, `number`, `bool`, at most 20 fields; not with `stream`). dhtml adds the instruction to the system prompt, finds the JSON object in the reply, and checks every field: a missing field or a wrong type fails the step with a message that does not repeat what the model said. Extra keys are dropped. The variable named by `as` holds the values, so `{{ r.mood }}` is escaped like any text and `r.score` is a number that filters such as `add` accept. A model can still be wrong, or be led by the data in the prompt: the values are checked for shape, not for truth. If the reply holds more than one object (the model quoted some of the data it was given), the first one is used, so keep user text out of `` and say in `` that the answer is the only object.
With `provider = fake` in `[ai]` nothing leaves the machine: the answer is a fixed sentence built from the question. A question containing `[[fake:error]]`, `[[fake:rate-limit]]`, `[[fake:empty]]`, `[[fake:timeout]]` or `[[fake:drop]]` fails in that way, to try the error paths of a page.
### Forms and CSRF
Every `
```
### Uploads
```html
```
`types` can only name file types from this list: `png`, `jpg`, `jpeg`, `gif`, `webp`, `pdf`, `txt`, `csv`, `zip`. Files a browser would run (html, svg, js, xml) are never accepted. `max` is at most `20MB`. The form must be `enctype="multipart/form-data"`. A file field is only ever filled by an upload: text sent for it in a urlencoded body or a query string is ignored. An upload is refused when it is empty, too large, has a name with a path or a NUL in it, has an extension that is not in `types`, or its content does not look like that type. It is saved as `server/data/uploads/<32 random hex>.` and **never under its own name**, so `.htaccess`, `.user.ini`, `web.config` and `../../x` cannot happen. `form.photo` is that stored name; keep it in the database and show it with `/uploads/{{ form.photo }}`. Uploads are served with a fixed content type (the file's claim is ignored), `X-Content-Type-Options: nosniff` and a sandboxing CSP; images are shown inline and everything else downloads.
## Comments
`` comments are removed from the output, and nothing inside them is read: no `{{ }}`, no ``. Use them for notes you do not want visitors to see.
## Where tags may go
`` tags cannot appear inside an HTML tag or an attribute value, or inside the elements whose content is not markup: ``, as in HTML). Logic that builds markup or code from template tags is a common source of injection. Put the `` around the whole element instead, and use `{{ }}` for values (which is allowed in all those places).
An HTML tag or quote that is never closed is an error (`an HTML tag is never closed`), as is an HTML comment (``) or a `` tag that is never closed. A broken attribute is also an error, with its line: a stray `=` or quote where a name should be, a quote inside an unquoted value, or `name=` with no value. A `{` directly before a `{{ }}` is an error too (`{{{`): put a space between them.
## Scripts and the CSP
dhtml answers every page with a closed Content-Security-Policy (`default-src 'self'`). It does not allow inline `
```
```js
// public/js/like.js
const b = document.getElementById("like");
b.addEventListener("click", () => fetch("/api/like/" + b.dataset.post, { method: "POST" }));
```
For a whole object, use `
` and `JSON.parse(el.dataset.post)`. The `` form above still escapes correctly; it only runs if you loosen the CSP yourself.
## Accounts
dhtml keeps accounts, sessions and the one-time links for you (decision D19). Nothing here is written in SQL, and no password or token ever appears in your templates. The tables (`_dhtml_users`, `_dhtml_sessions`, `_dhtml_tokens`) are made by the migrations; your queries may read them but not the password hash or token columns.
**Showing things to the right visitor** is done with `` and ``, in pages, layouts, partials and responses. Both take an `` like ``, and `` is for admins only:
```html
Hello {{ user.name | default user.email }}Sign inAdmin
```
`user` is what these read. A page that never uses `user` does not look at the session at all.
**Account steps** go in an action, like ``. Each reads form fields by name; the names default to the ones shown, and an attribute names another declared field. All of them need `method="POST"` (the default), a `ratelimit`, and `` for each field they read.
```html
{{ errors.form }}
```
| Step | Does | Fields it reads |
|---|---|---|
| `` | Checks the address and password and starts a new session. | `email`, `password` |
| `` | Ends this session. | none |
| `` | Starts a sign-up (see below). It takes no password: anyone can type someone else's address. | `email`, and `name` (when a field of that name is declared, or `name="..."` names another) |
| `` | Makes the account from the emailed link, with the password its owner chooses now. | `token`, `password` |
| `` | Changes the password of the signed-in visitor (the action needs `auth`) and signs them out everywhere else. | `current`, `password` |
| `` | Mails a reset link, if the address has an account. | `email` |
| `` | Sets a new password from the emailed link and signs the account out everywhere. | `token`, `password` |
| `` | Mails a sign-in link, if the address has an account (see [Magic links](#magic-links)). Needs `[auth] magic_login = on`. | `email` |
| `` | Uses the emailed sign-in link and starts a session. | `token` |
| `` | Suspends an account (see [Banning](#banning-an-account)). The action must be `admin`. | `id`, and `reason` when a field of that name is declared |
| `` | Lifts a ban. The action must be `admin`. | `id` |
When a step fails in a way the visitor can fix (a wrong password, an expired link, a password that is too short), the action ends like failed validation: status `422`, nothing is saved, and `` is drawn with the message in `errors.form`. Too many attempts answer `429` with `Retry-After`. A password field is always empty in `form`, so a form can be drawn again without it, and a step of your own cannot read it (that is a compile error).
An action may do one account step on any path, and it counts as a write: it runs in the action's transaction, so a model call after it is refused like one after ``.
**Going back to where the visitor was heading.** `` goes to the `next` value the visitor came with, if it is a path on this site (a declared `next` field, or `?next=` on the address), and to `/home` otherwise. A protected page sends visitors to the login page with `?next=` already set; put it in a hidden input:
```html
```
**Signing up** is off unless `[auth] registration = on` (it needs `[mail]` and `url` in `[app]`, see [config](config.md)). `` always gives the same answer, so write a page that says "we sent a link" whatever happens; the mail says whether the address was new. The account exists only after the link is used, and its password is chosen then, by the owner of the address (a stranger who typed it cannot set it). The link opens `confirm_page` (default `/auth/confirm`) with `?token=`, a page of yours with a field for the password and a button that posts both to an action with ``; links are never used by simply opening them, since mail scanners open links. `` works the same way with `reset_page` and a form asking for the new password.
```html
We sent a link to {{ form.email }}. Open it to finish.
If that address has an account, we sent a link.
```
### Magic links
With `[auth] magic_login = on` (it needs `[mail]` and `url` in `[app]`), a visitor can sign in without a password: they type their address, get an email, and press a button. It follows the same rules as "forgot password", because whoever controls the mailbox can already do that.
```html
If that address has an account, we sent a sign-in link.
```
- **The same answer for every address.** `` writes the same rows and gives the same answer whether or not the address has an account, so write a page that says "we sent a link if there is an account". Mail goes out after the response.
- **A click, not a GET.** The link in the mail opens `magic_page` (default `/auth/magic`) with `?token=`. That page is yours: it shows a button that posts the token to the action with ``. Opening the link never signs anyone in, because mail scanners and link previews open links.
- **Short and single use.** The link works once, for 15 minutes. A new request for the same address replaces the old link. A sign-in with the link, a password change and a password reset end the account's other open links. A sign-in link cannot be used as a confirm or reset link, and the other way round.
- **Existing accounts only.** Nothing creates an account: use ``. The sign-in starts a new session and ends the visitor's old one, like ``. After it, an action can only respond (no model call, no form), as after a login.
- A wrong, used or expired link ends the action like failed validation: `422` and `errors.form` says to ask for a new one. Decision D28 has the reasons.
### Sign in with Google
With `[oauth]` set up (see [config](config.md#oauth-optional)), dhtml serves two paths itself and no tag is needed. A page links to `/_dhtml/oauth/google` (add `?next=/some/page` to land somewhere else) and reads `{{ config.oauth.google }}` to know whether to show the link:
```html
Continue with Google
```
- **What is checked.** The code flow with PKCE and a random state kept in a signed, short-lived cookie. A callback without the cookie, with another state, with a changed cookie or a second time does nothing. Google's own words are never shown or logged.
- **Who gets in.** Only an address Google has verified. A person already linked signs in as that account; an account with the same address is linked; with `[auth] registration = on` a new account is made (without a password anyone could know: "forgot password" lets them choose one). A suspended account is refused.
- **After it.** A new session starts and the old one ends, like ``. The runtime sends the person to `next` (a local path only) or to `after` in `[oauth]`. The credits, flash messages or welcome emails your password sign-in gives are not given by this path: point `after` at a page of yours that does it (LaunchSaaS has `/app/welcome`).
- The migrations make the table `_dhtml_oauth` that remembers the link; it goes with the account. The account log has `oauth_login`, `oauth_register`, `oauth_failed` (the `detail` says why in one word) and `oauth_banned`. Decision D41 has the reasons.
### Claiming the owner account
A site on dhtml.net has no command line, so an app with `registration = off` needs another way to get its first admin. The owner of the site proves they own the mailbox, the way a password reset does:
1. In the Secrets of the site, set `ADMIN_EMAIL` to the owner's address.
2. In `dhtml.config`, write `[auth]` `admin_email = env:ADMIN_EMAIL` (it needs `[mail]` and `url` in `[app]`; while the site is in preview the mail is written to the site's log, which the panel shows).
3. Upload, then open `/_dhtml/claim` on the site and type the address. A link is mailed to **that** address, whatever was typed: the page answers the same for every address, so it cannot be used to find out who the owner is.
4. The link works once and for one hour. It asks for a password (at least `min_password` characters) and makes the admin account, or makes the existing account of that address an admin with the new password, ends its other sessions and API keys, and signs the owner in.
The page stays after that: it is the owner's way back when the password is lost. It is limited to a few tries per visitor and three mails an hour for the whole site, and it writes `claim_requested`, `claim_done` and `claim_failed` to the account log. A banned account is not claimed back. `{{ config.admin_claim }}` is true when it is set up.
### Banning an account
`` and `` go in an action that is `admin`. A banned account cannot sign in (the right password says "suspended"; a wrong one still gets the usual answer), its open sessions and API keys stop working at once, and no sign-in link is sent to it. An admin cannot ban themselves or another admin.
```html
```
The table `_dhtml_users` has the columns `banned_at` (a time, empty when not banned) and `ban_reason`, which a page can list.
**The account log.** `_dhtml_auth_log` has one row for every account event: `at` (a Unix time), `kind` (`login`, `login_failed`, `login_banned`, `logout`, `register`, `confirm`, `confirm_failed`, `forgot`, `reset`, `reset_failed`, `password`, `password_failed`, `magic_asked`, `magic_login`, `magic_failed`, `oauth_login`, `oauth_register`, `oauth_failed`, `oauth_banned`, `ban`, `unban`), `user_id` (when the account is known), `email` (the address typed), `addr` (the client address) and `detail` (who banned, and why). A page can list it for an admin, as with the users table; an action cannot write it. It is written after the response, so failed sign-ins are in it, throttled attempts are not, and rows older than 90 days are removed. It holds personal data: say so in your privacy text.
**Protecting pages.** Name them in `[protect]` in `dhtml.config` instead of checking in every page (see [config](config.md)).
## Languages
An app in more than one language keeps the words in one file per language and the pages once (decision D58). A page is the same page in every language: the same markup, the same forms, the same behaviour.
```ini
[lang]
default = en
available = en, nl
detect = path, cookie, accept
```
`server/lang/en.lang` and `server/lang/nl.lang`: one text per line, `key = text`. A key is lower-case letters, digits, dots, hyphens and underscores. `#` starts a comment line. A long text is a key with nothing after `=`, then lines indented by two spaces. `{1}` to `{4}` are places for values, a literal opening brace is written `{{`, and the reserved key `@name` is the language's own name.
```text
@name = Nederlands
nav.home = Home
cart.items = Je hebt {1} artikelen
terms =
## Voorwaarden
Tekst met **opmaak**.
```
```html
{{ "cart.items" | t cart.count }}
{{ "terms" | t | markdown }}
{{ alt.name }}
```
- **Escaping**: the result of `t` is text, escaped like every value, and so is a value placed in `{1}`. A language file cannot put markup into a page; the one door is `| markdown`, which is sanitized (it cannot output a script).
- **Keys built by a page** work: `` then `{{ k | t }}`. The check cannot check such a key.
- **Falling back**: a key a language lacks shows the default language's text. A key that no language has is an error while you develop (the page says which key, in which file and line), and in production the page shows the key itself and the server logs a line, so a missing word never takes a page down.
- **Choosing the language**: `detect` lists the ways, tried in order, the first that decides wins. `path`: `/nl/about` serves `about` in Dutch (the default language has no prefix, so `/en/about` is a 404); `host`: `nl.example.com` or `example.nl`; `cookie`: a link with `?lang=nl` sets the cookie `lang` (HttpOnly, one year) and the choice is kept; `accept`: the browser's `Accept-Language`. A response that depends on a header is sent with the matching `Vary`.
- **Where `t` works**: in pages, layouts, partials and components, and in the subject, text and HTML template of a ``, where it is written out and takes the language of the recipient ([Mail in the recipient's language](#mail-in-the-recipients-language)). Not in the other steps of an action or a job.
- **The check on every upload** reads every language file and every `t` in the templates: a key a template names that the default language lacks is an error with the line and a "did you mean"; a key another language lacks is a warning (an error with `missing = error`); a key with different `{n}` places in two languages is an error; a key no template uses and a file for a language that is not in `available` are warnings; `t` without `[lang]` is an error.
## Errors
Every error names the file and line of what you wrote, never generated code:
```text
server/pages/blog.dhtml line 7
unknown variable "pots". Did you mean "post"?
```
Errors in a layout or partial name that file. At most 20 are reported per upload.
## Limits
| Limit | Value |
|---|---|
| Template file size | 1 MiB |
| Tag nesting depth | 100 |
| Layout and partial nesting | 20 |
| Output of one page | 16 MiB |
| Markdown input | 256 KiB |
## Fast navigation
Set `navigation = fast` under `[app]` in `dhtml.config` and links and forms stop reloading the page. Clicking a link fetches the next page and swaps only what changed, the way a single-page app does, but every page is still a plain server-rendered page with its own URL. Without JavaScript, or with the setting off (the default), the site works exactly as before.
It works for pages that use one ``. When the next page has the same layout, only the part that comes from the page itself (the slot) is swapped and the layout stays. When it has another layout, or the visitor has just signed in or out, the whole page is swapped in, still without a reload. Only when the new page needs a script the current one lacks, or the server cannot be sure, does the browser do an ordinary page load.
Parts of the layout that depend on the page, such as a menu that marks the current item or the flash message, are marked with `data-dhtml-region`. They are replaced with each navigation:
```html
{{ flash }}
```
A region is one element with a closing tag (not `p`, `li` or another element whose end tag can be left out), with a name of lower case letters, digits, `-` and `_`, used once per layout, and it never contains the slot. A layout that breaks these rules is simply sent whole.
What the router does:
- Takes over left clicks on links to this site. It leaves alone modified clicks (ctrl, shift, alt, meta), links with `target` or `download`, other sites, `/_dhtml/`, and links that only add a `#fragment` to the current page. Put `data-nav="off"` on a link, a form or any parent to opt out, and `data-nav="on"` to opt back in.
- Submits forms with `fetch`. A redirect after a successful submit shows the page it leads to. An answer that is a page (for example a form with errors, status 422) replaces the page and the address stays. A form is sent once: if the request fails, the form stays as it is and a `dhtml:nav-error` event fires on `document`. Forms with file uploads, `data-stream-target` or `hx-` attributes are not touched.
- Keeps the back button, scroll position and `#fragment` working, moves focus to the first `[autofocus]` or `
` of the new content, and announces the new title to screen readers.
- Starts loading a page when the pointer rests on a link for 65 ms, or on touch (not with the browser's data saver on; `data-prefetch="off"` on a link turns it off). This is safe because a dhtml page cannot change anything when it is read (`GET` actions cannot write), but a page's own `` still runs, so keep `data-prefetch="off"` on links to very heavy pages. A prefetched answer is used only for the same layout and the same signed-in state.
- Never shows a page from another site: a redirect that leaves the site is followed by the browser, as without the router. Stylesheets from other sites are not added when a page is swapped in.
- Cross-fades with the browser's View Transitions, unless the visitor asks for reduced motion. Shows a thin `#dhtml-progress` bar for requests slower than 150 ms; set `--dhtml-progress` to change its colour.
- Does not run scripts inside the swapped content. Page code that must run belongs in a file loaded by the layout, or listens for the `dhtml:navigated` event on `document`.
How it talks to the server, for anyone debugging: with the setting on, the full page gets `