# dhtml documentation
# dhtml syntax (draft spec)
This is the source of truth for the dhtml language. Implementations and agents must not invent tags, attributes, or filters that aren't listed here. A change needs a written proposal first (see CONTRIBUTING.md in the repository).
Status: **draft**, frozen at 1.0.
## Files
| Location | Purpose | Routed |
|---|---|---|
| `server/pages/**/*.dhtml` | GET pages, return full HTML | Yes, path = URL |
| `server/api/**/*.dhtml` | Actions, start with `` | Yes, `/api/...` |
| `server/jobs/*.dhtml` | Jobs, start with `` | No, run by the queue |
| `server/layouts/*.dhtml` | Page shells with `` | No |
| `server/partials/*.dhtml` | Reusable fragments | No |
| `server/components/*.dhtml` | Reusable pieces with named inputs (``), used with `` | No |
| `server/steps/*.dhtml` | Reusable steps (queries, checks, guards), used with `` | No |
| `server/mails/*.dhtml` | HTML mail templates, used with `` | No |
| `server/migrations/NNN_name.sql` | Plain SQL migrations | No |
Routing rules:
- `pages/index.dhtml` → `/`
- `pages/about.dhtml` → `/about`
- `pages/blog/index.dhtml` → `/blog`
- `pages/blog/[slug].dhtml` → `/blog/:slug`, available as `{{ route.slug }}`
- Static segments win over parameters: `/blog/new` matches `blog/new.dhtml` before `blog/[slug].dhtml`.
- `[routes]` in `dhtml.config` overrides file-based routes.
- With `routing = explicit`, only routes listed in `[routes]` are served.
## Values
```html
{{ post.title }}
{{ post.created | date }}
{{ post.title | truncate 60 }}
{{ user.name | default "Guest" }}
```
- In HTML: always escaped for the context (text, attribute, URL, script, style).
- Inside `` and ``: always a bound SQL parameter. Values can never be used as table or column names.
- No function calls, no arithmetic, no assignment.
### Data available in templates
| Name | Contents |
|---|---|
| `app` | `name`, `version` (e.g. `1.2.0.44`) |
| `route` | URL parameters |
| `query` | URL query string values |
| `form` | Submitted form values (actions only) |
| `user` | Signed-in visitor (`id`, `email`, `name`, `admin`), or empty |
| `page` | Values passed to the layout (e.g. `title`) |
| `errors` | Validation errors (in ``) |
| `config` | Safe subset of `dhtml.config`, never secrets |
| `lang` | The language of the request: `lang.code`, `lang.default`, `lang.name`, `lang.prefix`, `lang.alternates` (D58). Empty without `[lang]` |
| `request` | `request.host`: the name the visitor used, lower case, no port, empty if not a valid host name. For choosing a tenant by name; pages and actions that read it are sent `Vary: Host` |
### Filters
`date` (optionally with a layout in quotes: `date "02 Jan 2006"`), `upper`, `lower`, `default`, `truncate`, `slug`, `markdown` (sanitized HTML), `json`, `first`, `count`, and for numbers `add`, `sub`, `mul`, `div`, `round`, `fixed N` (exactly N decimals) and `thousands` (groups by threes). In a page, with a [`[lang]`](config.md#lang-optional) section: `t` (`{{ "nav.home" | t }}`, values fill `{1}` to `{4}`: `{{ "cart.items" | t cart.n }}`), the text of a key in the language of the request. To sign in the steps of an action or a job: `hmac "env:NAME"` (hex), `hmac_base64 "env:NAME"` and `hmac_base64url "env:NAME"`: the HMAC-SHA256 of the text with the key in the variable NAME. Never in a page.
There is no `raw` filter at 1.0.
## Page tags
```html
... ... ... ... ... ... shown to signed-in visitors shown to the others shown to admins shown to visitors who are not signed in
```
`test` supports: a value, `not`, `and`, `or`, and comparisons `== != > < >= <=`.
## Data tags
```html
SELECT id, title FROM posts ORDER BY created DESC LIMIT 20
SELECT * FROM posts WHERE slug = {{ route.slug }}
```
`one` returns a single row or empty. Each `{{ }}` in the SQL is a bound parameter. The rules (read only, no names from values, where a query may go) are in [templates.md](templates.md#dquery).
## Actions
Files in `server/api/` start with ``. Steps run top to bottom.
```html
INSERT INTO posts (title, user_id) VALUES ({{ form.title }}, {{ user.id }})
HTML fragment for htmx shown when validation fails
```
### `` attributes
| Attribute | Meaning |
|---|---|
| `method` | `GET`, `POST`, `PUT`, `DELETE` |
| `auth` | Requires a logged-in user |
| `admin` | Requires an admin user |
| `ratelimit` | e.g. `10/minute`, `100/hour`, per IP |
### Validation
```html
```
### Responses (exactly one per path)
```html
... {{ posts }}
```
### Computing and calling other services
```html
```
Filters `add`, `sub`, `mul`, `div`, `round`, `sha256`, `base64`, `urlencode` do the arithmetic and encoding. `` calls only hosts listed in `[http]` in `dhtml.config`, with keys from the Secrets section of your site, and builds its body from fields so a value can never become syntax. Details in [templates.md](templates.md#dhttp).
### Accounts
```html
```
Each is a step that reads declared form fields by name (never `{{ }}`), is allowed only in a non-GET action with a `ratelimit`, and ends the action like failed validation when the visitor has to fix something (message in `errors.form`). The rules, the mail flow and the pages you write are in [templates.md](templates.md#accounts); the settings are `[auth]`, `[mail]` and `[protect]` in [config.md](config.md).
### Jobs
Work that runs later, or by the clock, lives in `server/jobs/NAME.dhtml` and starts with ``. An action queues it with ``.
```html
SELECT email FROM orders WHERE id = {{ args.order }}DELETE FROM drafts WHERE created_at < date('now', '-30 days')
```
| Tag or attribute | Meaning |
|---|---|
| `every` | `30m`, `6h`, `1d` (1 minute to 30 days): the job starts itself and takes no arguments |
| `at` | `HH:MM` in UTC, with `every="1d"` |
| `retries` | 0 to 10, default 3: more tries after a failure, with growing waits |
| `timeout` | seconds, 1 to 600, default 60 |
| `` | A declared argument, with the rules of ``; read as `args.x`. Also `job.name`, `job.attempt` |
| `` | A step of an action: queue the job in the action's transaction, with arguments as text |
A job has the steps of an action and no response. The site runs it by itself, one job at a time. Details in [templates.md](templates.md#jobs).
CSRF protection is automatic for every non-GET action. The full rules (what each tag does, the order things happen in, uploads, flash messages) are in [templates.md](templates.md#actions).
## AI
```html
Answer questions about this post. Be brief.{{ post.title }}: {{ post.body }}{{ form.question }}
{{ answer }}
```
- Provider, model, and key come from `dhtml.config`. `model`, `max_tokens` and `timeout` attributes override them for one call.
- `` is only allowed in an action that is not `GET` and has a `ratelimit`.
- `` (without `as`) is itself the response: the action answers with server-sent events as the model writes, and `dhtml.js` shows them in the element named by `data-stream-target` on the form. It replaces the `as` plus `` pair, since the answer cannot be rendered before it exists.
- Model output is untrusted and always escaped.
Details, limits and the error rules are in [templates.md](templates.md#ai-calls) and decision D18.
## Styling
dhtml has no styling syntax of its own (decision D25). Link any stylesheet from `public/` in your layout; the starters use Tailwind's standalone CLI and commit the generated file.
## Example `dhtml.config`
```ini
[app]
name = myblog
version = 1.0.0
[server]
port = 3000
routing = files # or: explicit
[ai]
provider = anthropic
model = claude-sonnet-5-5
key = env:ANTHROPIC_API_KEY
[backups]
keep_daily = 7
keep_weekly = 4
[routes]
/login = pages/auth/login.dhtml
/old-blog = redirect /blog
/health = text OK
[features]
shell = off # never on a hosted site
```
Every key is documented in [config.md](config.md).
# dhtml: guide for AI agents and for people in a hurry
Read this first. It is short on purpose and covers what most apps need. The full references are [syntax.md](syntax.md), [templates.md](templates.md) and [config.md](config.md). **Never invent a tag, attribute or filter that is not listed there**: the check that runs on every upload will refuse it, and the error names the file and line.
## The loop
dhtml sites run on dhtml.net. There is nothing to install and no local server: you write the files of the project, zip them and upload them to your site.
```text
# write dhtml.config, server/migrations/001_*.sql, pages, actions, layouts, public/
# zip the CONTENTS of the project folder (dhtml.config at the top of the zip; no .env, no server/data)
# upload the zip: the Deploy page of your site on dhtml.net, or the deploy API
# read the result; fix every error, read every hint, upload again
```
Every upload is checked before it goes live: templates, routes, config and migrations are compiled, the database is backed up, and migrations are applied. A check that fails says the file, the line and a hint, and **the live version of the site is left as it was**. Upload after every few files, not at the end. Secrets (API keys, passwords) are not in the zip: they are set in the Secrets section of the site's page on dhtml.net and are named `env:NAME` in `dhtml.config`. To deploy from a script or an AI assistant, use a deploy key: see [dhtml.net/ai/deploy-api.txt](https://dhtml.net/ai/deploy-api.txt).
## Files
| Where | What |
|---|---|
| `server/pages/**/*.dhtml` | GET pages. The path is the URL: `blog/[slug].dhtml` is `/blog/:slug`, read as `{{ route.slug }}` |
| `server/api/*.dhtml` | Actions (forms, JSON). `server/api/save.dhtml` is `POST /api/save` |
| `server/layouts/` `partials/` `components/` `steps/` | Shell with ``; fragments (``); pieces with inputs (``, `...`, read as `props.a`); **steps** (``: queries, checks and guards written once, used in pages, actions and jobs) |
| `server/jobs/*.dhtml` | Work later or by the clock |
| `server/migrations/NNN_name.sql` | Plain SQL, applied in order. Put sample/seed rows here |
| `public/` | Static files (CSS, images, JS). Served as they are |
| `dhtml.config` | Settings (INI). Secrets are set in the Secrets section of the site on dhtml.net and are named `env:NAME` |
## Values and logic
```html
{{ post.title }} {{ post.created | date }} {{ body | truncate 80 }} {{ user.name | default "Guest" }}
... ... {{ p.title }} Nothing yet. signed in not admins visitors
```
- Every `{{ }}` is escaped for where it lands. There is no raw output. `markdown` and `json` are the only filters that make HTML or script, and they must be last.
- Filters: `date upper lower default truncate slug markdown json first count add sub mul div round fixed thousands sha256 base64 urlencode`. Prices: `{{ cents | div 100 | fixed 2 }}` shows `12.50`; `{{ total | fixed 2 | thousands }}` shows `1,234,567.50`; `{{ d | date "02 Jan 2006" }}` takes a layout in quotes. **A `test` cannot use a filter** (`test="rows | count"` is an error): test the value itself, because an empty list, empty text, `0` and nothing are all false (`test="rows"`), or compare with `== != < > <= >=`. Also `not`, `and`, `or`; no parentheses. **Strings in a test use double quotes**, so write the attribute with single quotes: `test='b.status == "booked"'`.
- There is no number or date formatting filter. Format in SQL: `printf('%.2f', price_cents / 100.0)`, `strftime('%d.%m.%Y', date)`.
- Built-in names: `app route query form errors user page config csrf flash`.
- **`` tags can never sit inside an HTML tag or attribute.** So there is no "selected" helper: write two whole elements, one in each branch:
```html
```
## Pages read data
```html
SELECT id, title, slug FROM posts WHERE published = 1 ORDER BY id DESC LIMIT 20 SELECT * FROM posts WHERE slug = {{ route.slug }}
{{ post.title }}
```
- **Do not copy a rule between files.** Put the query that finds the shop, the settings every page shows, or a check like "this person belongs to this shop" in `server/steps/name.dhtml` and write `` where it applies (page top level, or among the steps of an action after ``). A steps file holds only steps, reads the same variables as its user, and can end the request with `` or ``.
- Queries go at the **top level of a page** (not inside ``, ``, a layout or a partial). In an **action**, queries, writes and mails may sit inside ``. Pass results to a layout as attributes (`page.title`).
- Each `{{ }}` in SQL is a bound value: one plain value, **no filters**, never a table or column name, never after `FROM`/`JOIN`/`ORDER BY`. **An empty value is NULL in SQL, not `''`**: a missing `?date=` makes `{{ query.date }} = ''` match nothing. Write `COALESCE({{ query.date }}, '') = ''` or `{{ query.date }} IS NULL`; The check warns about the comparison with `''`. Build patterns in SQL: `LIKE '%' || {{ query.q }} || '%'`. Use `AS` when two columns share a name. Max 10000 rows.
- A layout sees only built-ins and `page.*`, not the page's variables, and cannot run a query: something every page shows (the site name) is queried at the top of each page and passed with ``.
## Actions write data
```html
INSERT INTO posts (title, body, user_id) VALUES ({{ form.title }}, {{ form.body }}, {{ user.id }})