# 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 }})

{{ errors.title }}

``` - Forms: `
`. dhtml adds the CSRF token by itself. Never write one. - `ratelimit` is required for anything that logs in, mails, calls a model or another service, and enqueues. `auth` = signed in, `admin` = admin. A `GET` action cannot write. `ratelimit` counts tries per visitor address, so a shared office or school share one allowance: `30/minute` is plenty for a booking form. - Validation rules: `required email number date time min max file types message`. `date` is `YYYY-MM-DD` and a real day; `time` is `HH:MM`. **There is no pattern rule**: check other shapes in SQL (see the booking pattern). - Every path ends in exactly one response: `` (HTML), ``, ``, ``. A redirect `to` starts with `/`. There is no custom status for a redirect, so "slot taken" is a redirect with a flash. - `` is one `INSERT/UPDATE/DELETE/REPLACE` (no schema changes, no `RETURNING`, no names starting `sqlite_` or `_dhtml_`). `saved.id` and `saved.changes` are available after it. The whole action is one transaction: it rolls back on any error, `` 400+, or invalid input. - Filters do not work in SQL. Compute first: ``, then `{{ cents }}`. - Uploads: `` with `enctype="multipart/form-data"`; `form.photo` is a stored name, shown at `/uploads/{{ form.photo }}`. - Do not call a model (``) or `` after a database step in an action that writes; do the call first, the writes after. ## Patterns that cover most apps **Check and insert in one statement (no double booking, no oversell).** Do every check inside the `INSERT ... SELECT ... WHERE`, then read `changes`. SQL in `` and `` may not contain comments; explain it outside: ```html INSERT INTO bookings (service_id, date, start_min, name) SELECT s.id, {{ form.date }}, {{ slot }}, {{ form.name }} FROM services s WHERE s.id = {{ form.service }} AND s.active = 1 AND date({{ form.date }}) = {{ form.date }} AND {{ form.date }} >= date('now') AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.date = {{ form.date }} AND b.start_min = {{ slot }}) ``` `date(x) = x` makes sure the text is a real `YYYY-MM-DD` date. For a service that lasts longer than one slot, test overlap instead of equal start times: `b.start_min < {{ slot }} + s.duration_min AND {{ slot }} < b.start_min + b.duration_min`. Add a `UNIQUE` index in the migration as a second wall. A time kept as text can be checked with `GLOB '[0-2][0-9]:[0-5][0-9]'`. **Languages**: `[lang]` in `dhtml.config`, one file `server/lang/.lang` per language (`key = text`, long text as an indented block, `{1}` places), `{{ "nav.home" | t }}` in pages, `{{ lang.prefix }}` in links, the check on every upload lists missing keys; a mail uses `lang="b.lang"` and `{{ "key" | t }}`. Never write the words of a second language into the pages. **Signing**: `{{ value | hmac "env:NAME" }}` (also `hmac_base64`, `hmac_base64url`) in the steps of an action or a job only, with the key in `.env`; check a signature by signing again and comparing with `==`. Never sign what a visitor sent and hand it back. **An unguessable code** (a personal link): `code TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16))))` in the migration, then read it back with a `one` query after the insert and put it in a link or a mail: `https://site/b/{{ b.code }}`. SQLite functions work in SQL. Foreign keys are on, so `ON DELETE CASCADE` works. **Settings in one row.** `CREATE TABLE settings (id INTEGER PRIMARY KEY CHECK (id = 1), shop_name TEXT NOT NULL, ...)` with `INSERT INTO settings (id, shop_name) VALUES (1, 'My shop')`, one query `... one`, and an admin action that `UPDATE`s it. **Login, logout, protected pages.** ```html

{{ errors.form }}

``` `` is the same shape. Protect whole areas in `dhtml.config`, not page by page: `[protect]` then `/admin = admin` and `/admin/* = admin` (`= auth` for signed-in). A login page must exist at `/login`. The first admin of an app without sign-up claims the account: set `ADMIN_EMAIL` in the site's Secrets, write `admin_email = env:ADMIN_EMAIL` under `[auth]`, open `/_dhtml/claim` (a one-time link comes by mail). Accounts are made by `` (needs `[auth] registration = on`, `[mail]`, `url`), or by magic links (`magic_login = on`). Sign-in with Google is `[oauth]` (see config.md). **Admin list with per-row buttons.** One small `` per row, each with its own action; declare `id` with ``. **Tell the owner.** `` mails the one address in `[mail] notify`. Every field must be declared; values print as raw `field: value` lines. To confirm to the customer, use `Thank you, {{ form.name }}.`: `to` is a declared `email` form field (or `user.email`), text and `{{ form.x }}` values only, plain text, and the action needs a `ratelimit`. There is no way to mail any other address; links typed by a visitor are removed. At most 3 mails to one mailbox a day (a `+tag` and Gmail dots count as the same) and `[mail] limit_per_hour` (60) for the site: a mail over a limit is dropped and the visitor is told nothing. The text can use declared form fields and plain results of earlier steps (`{{ saved.id }}`, `{{ w.title }}` of a `one` query, a `` code), so `{{ config.app.url }}/b/{{ code }}` works (`url` is set in `[app]`; never hard-code the address). `to` is an `email` field, `user.email`, or a column of an earlier `` (`to="next.email"`, e.g. the next person on a waitlist). **Mail to many rows and HTML mail.** A job or an admin action can mail every row of an earlier list: `text with {{ b.title }}` (`max` 1 to 100 is required; mark the rows done in a `` of the same job so a retry mails nobody twice; the mails go out after the commit). For a mail that must look like something, write HTML in `server/mails/name.dhtml` and use ``: a short list of tags, values only in the text (and in a link path after `{{ config.app.url }}/`), no script, form or remote image, and a text part made for you. Details: templates.md. **Many shops in one app, by name.** `request.host` is the name the visitor used. At the top of each page: `SELECT * FROM shops WHERE host = {{ request.host }}` and ``; then put `shop.id` in the `WHERE` of every other query and write. A name is not a permission: still use `auth` and check that the person belongs to that shop. Cookies belong to one name, so a login on one shop is not a login on another. **Work later.** `server/jobs/x.dhtml` starts with ``; steps are the same as an action's. An action queues it with ``; a job with `every="1d" at="03:30"` runs by the clock. **Another service.** ``; the host must be in `[http] allow`, the key in `.env`. Read `r.ok`, `r.status`, `r.json.x`. Add `soft` to get `r.ok = false` instead of an error. **A model.** `..{{ form.q }}` in a non-GET action with `ratelimit`. `[ai] provider = fake` needs no key (fixed answer) for development; `anthropic` or `openai` need `key = env:NAME`. Model output is untrusted and escaped. ## Config you will write ```ini [app] name = myapp version = 0.1.0 url = https://myapp.dhtml.net # the address of the site; links in mail are built from it [auth] registration = off # on: sign-up by email link (needs [mail]) [mail] transport = log # mail is written to the log; use smtp for real mail (host, port, username, password = env:X, from) notify = owner@example.com # where goes [protect] /admin = admin /admin/* = admin [http] allow = api.stripe.com # the only hosts may call [routes] /sitemap.xml = api/sitemap.dhtml # map a path to a file; also: redirect /x, text ok, json {"a":1} ``` ## Errors you will meet | Message (shortened) | Fix | |---|---| | `unknown variable "pots". Did you mean "post"?` | Spelling, or the name is not in scope (layouts and components see only built-ins and their inputs) | | ` must come before the steps` | Move all `` to the top of the action | | `the form field "x" is not declared` | Add `` before using `form.x` | | `a GET action cannot write` | Use `method="POST"` for anything with `` | | `{{ }} after FROM/ORDER BY/... is a compile error` | A value cannot be a name. Write the name in SQL; choose between a few with separate queries in `` | | `tag inside an HTML tag or attribute` | Put the `` around the whole element | | `a model call / http call after a database step` | Make the call first, then the writes | | `needs a ratelimit` | Add `ratelimit="10/minute"` | | `[protect] sends visitors to /login, but no page answers there` | Create `server/pages/login.dhtml` | | `step name used twice` / two columns with one name | Names (`as=`) must be unique in a file; use `AS` in SQL | | `account tables do not exist` | Upload again: migrations are applied on every upload, and the account tables are made by them | ## Rules of the house - Link the stylesheet as `/style.css?v={{ app.version }}`: the browser then keeps it for a year, and a new build is a new address. - Plain HTML and CSS in `public/style.css` are enough; the page policy forbids inline `` | Becomes a JavaScript value: a string stays a quoted string. Use `{{ v | json }}` for an object. The default CSP blocks inline scripts, see [Scripts and the CSP](#scripts-and-the-csp) | | Event handler | `` | The same, inside the attribute. Also blocked by the default CSP | | Style | ``, `style="..."` | Allowed CSS values pass; others become `ZgotmplZ` | | Textarea, title | `` | Escaped as text | Some attributes decide what the browser loads or runs, so data may not choose them. A `{{ }}` is a compile error in `srcdoc`, in a `` tag, and at the start of the address in `` | | `first` | The first item of a list, or the first character of text; empty if there is none | `{{ tags \| first }}` | | `count` | The number of items in a list or object, or characters in text | `{{ posts \| count }}` | | `add N` | Adds `N`. The value and `N` are numbers, or text that is only a number (what a form field holds), and `N` can be a value too. Whole numbers stay exact; a result that does not fit in 64 bits is an error, never a wrong number. A value that is not a number is an error, never a silent zero | `{{ likes \| add 1 }}` | | `sub N` | Subtracts `N` (same rules as `add`) | `{{ total \| sub discount }}` | | `mul N` | Multiplies by `N` (same rules as `add`) | `{{ price \| mul 100 }}` | | `div N` | Divides by `N` (same rules as `add`). A whole result stays a whole number; division by zero is an error | `{{ cents \| div 100 }}` | | `round` | Rounds to a whole number, halves away from zero | `{{ price \| mul 100 \| round }}` | | `fixed` | Shows a number with exactly that many decimals (0 to 12), halves away from zero, on the number as it is written: 1.005 is `1.01`. Works on a number and on text that is only a number. Never `-0.00`. Shows numbers below 1,000,000,000,000,000 | `{{ cents \| div 100 \| fixed 2 }}` | | `thousands` | Groups the whole part by threes with a comma, and leaves the decimals as they are. Works on a number and on text that is only a number, so it goes well after `fixed`. A different separator or a currency symbol is text in your page | `{{ total \| fixed 2 \| thousands }}` | | `sha256` | The SHA-256 of the text, as 64 hex characters | `{{ email \| lower \| sha256 }}` | | `base64` | The text in Base64 (standard alphabet, with padding) | `{{ token \| base64 }}` | | `urlencode` | The text escaped for a query string | `{{ q \| urlencode }}` | | `t` | The text of a key in the language of the request (below). Optional values fill `{1}` to `{4}`. Plain text, escaped like every value; for formatted text chain `markdown` | `{{ "nav.home" \| t }}`, `{{ "cart.items" \| t cart.n }}` | Filters chain left to right: `{{ post.title | lower | slug | truncate 30 }}`. A filter name that does not exist is an error with a suggestion. `markdown` and `json` should be last: any later filter turns their result back into plain text. ### Markdown `markdown` supports headings (`#`), paragraphs, bullet and numbered lists (one level), block quotes, fenced code, rules, `code`, `*emphasis*`, `**strong**`, links and images. It cannot be made to output a script: raw HTML in the source is shown as text, and a link or image is kept only when its address is relative or uses `http`, `https`, `mailto` or `tel`. Links get `rel="noopener noreferrer nofollow"`. Very long input is cut at 256 KiB. ## Built-in variables | Name | Contents | |---|---| | `app` | `name`, and `version` as `1.2.0.44` | | `route` | The `[slug]` parts of the URL | | `query` | The URL's query string. For a repeated key, the first value | | `page` | The page values given to the layout (below). Empty in a page | | `config` | A safe part of `dhtml.config`: `app.name`, `app.version`, `app.environment`, `app.url` (the public address of the site, for building absolute addresses such as a payment provider's return page; empty if not set), `server.routing`, `backups.keep_daily`, `backups.keep_weekly`, `demo` (true while `[demo]` is on, D42), `oauth.google` (true when sign-in with Google is set up, D41). Never secrets, ports, addresses, `[ai]` or routes | | `csrf` | The CSRF token for the current visitor (see [Forms and CSRF](#forms-and-csrf)); you rarely write it yourself | | `request` | `request.host`: the name the visitor used to reach the site, in lower case, without the port, and only if it is a valid host name (letters, digits and hyphens in labels of at most 63 characters, at most 253 in all, no dot at the end), otherwise empty. It is the `Host` header, not `X-Forwarded-Host`, so a proxy must pass `Host` on. Use it to choose a tenant: `SELECT * FROM shops WHERE host = {{ request.host }}`. A page or an action that reads it is sent `Vary: Host`, so a cache keeps one copy for each name. It cannot go in a mail (it comes from the request), and a name is not a permission: still check who may do what. Cookies belong to the exact name that set them | | `lang` | The language of the request ([`[lang]`](config.md#lang-optional), below): `lang.code` (`nl`), `lang.default`, `lang.name` (its own name, `Nederlands`), `lang.prefix` (`""` for the default language, `/nl` for another: write links as `href="{{ lang.prefix }}/shop"`) and `lang.alternates`, a list of `{code, name, url}` for a switcher and for ``. Without `[lang]` they exist and are empty | | `webhook` | In an action with `webhook="..."`: `webhook.json` (the call's body, decoded) and `webhook.text` (the raw text). Empty elsewhere | | `flash` | A message set by `` on the previous request, shown once, then gone. Empty otherwise | | `form`, `errors` | In an action: the submitted fields that were declared with ``, and the error text per field. Empty in a page | | `user` | The signed-in visitor: `user.id`, `user.email`, `user.name`, `user.admin`. Empty for a visitor who is not signed in. See [Accounts](#accounts) | Loop variables (``) and the names given to queries (and, in an action, to writes) are added to these. ## `` ```html ...... ``` `` is optional and is not closed. A `test` is made of: - a value on its own: true unless it is empty (nil, `false`, `0`, `""`, an empty list or object, the zero time). The text `"0"` and `"false"` are true. - `not`, `and`, `or`, with `not` first, then `and`, then `or`: `a or b and c` means `a or (b and c)`. Parentheses are not supported; nest `` tags instead. - comparisons `==`, `!=`, `<`, `>`, `<=`, `>=` between two values. One comparison per condition: write `a < b and b < c`, not `a < b < c`. How comparisons work: numbers compare as numbers (`12 > 3`), and text that reads as a number counts as a number when the other side is one (`"10" == 10`). Two texts compare as text (`"007" != "7"`). Dates compare as dates. `nil` acts as empty text. `true` and `false` can be compared with `==` and `!=` only. Lists and objects support `==` and `!=` only. An attribute value may use single quotes, so a test with text in it needs no escaping: ``. Inside a quoted value, use the other kind of quote for text. dhtml has no escape for the quote that closes the attribute; if you need both kinds, put the text in a variable. ## `` ```html

{{ post.title }}

Nothing yet.

``` `each="name in list"`: the list is a path to a list. `name` exists only inside the loop. It cannot be a built-in name, or the name of an enclosing loop's variable. `` is optional, is not closed, and runs when the list is empty or missing. Looping over text, a number or an object is an error. ## `` and `` A layout is a page shell in `server/layouts/`: ```html {{ page.title | default app.name }} ``` A page wraps its content in it: ```html

About us

``` - `src` names the file without folder or extension. `admin/wide` is `server/layouts/admin/wide.dhtml`. It is fixed text, not a value. - Every other attribute is a **page value**, read in the layout as `page.name`. A value can be text, one `{{ }}`, or both: `title="Post: {{ post.title }}"`. A single `{{ }}` keeps its type, so a list can be passed on. - The layout sees the built-in variables and `page`, but **not** the page's own variables (such as a loop variable). Pass what it needs as page values. - The content of the tag goes where `` is. It is evaluated in the page's scope. A layout has exactly one slot. - Layouts can use other layouts (a `` in a layout), and ``. A layout that includes itself, directly or through others, is an error. - Content outside the `` tag is kept as written, so a page can start with ``. ## `` A step of an action that works out a value and keeps it in a variable, so the steps below it can use it in SQL, a redirect, a prompt or an HTTP call. SQL values cannot have filters, so this is how a computed value reaches a query: ```html INSERT INTO items (price_cents) VALUES ({{ cents }}) ``` A value that is a single `{{ }}` keeps its type (a number stays a number); text around it makes the result text (`value="item-{{ id }}"`). `markdown` and `json` cannot be used, since they make HTML and script that only a page can show. The name follows the rules of query names (not a built-in name, used once). A value cannot use a result that is made further down. ### Signing with `hmac` A signature proves that a text came from someone who knows a secret. Use it to sign a request to another service, a link that must not be changed, or to check a signature that came with a request. `hmac` works out the HMAC-SHA256 of a text with a key: ```html SELECT id, total FROM orders WHERE id = {{ form.order }} ``` - **Three forms**: `hmac` (lower-case hex), `hmac_base64` and `hmac_base64url` (no padding). The text is the value before the `|`; a number is signed as its text. ```html ``` - **The key is a name, never a secret in a file**: always `"env:NAME"`, read from the Secrets section of your site like the key of `[ai]`. A key written in the file (`hmac "s3cret"`), an empty name or a variable in place of the name is refused with the line. A name that is not set stops the upload, not the first request. The filter only gives out the MAC; the key cannot be printed. - **Where**: in the steps of an action and of a job (``, and the values of ``). **Not in a page, a layout, a partial, a component, a response or a mail**: they are drawn for whoever asks, and signing what a visitor sends and showing it would let anyone ask for valid signatures. Work the value out in a step first and use the variable. - **Checking a signature** that came with a request: sign the same text again and compare with `==`. The comparison of two texts is constant-time, so it does not tell an attacker how many characters were right: ```html ``` - **Links that expire**: put the expiry in the signed text (`{{ o.id }}.{{ expires }}`) and check it in the action the link points to. There is no hidden clock. - **Sign only what you meant to sign.** A signature of something a visitor chose, handed back to that visitor, is a signature they can use. Sign the values you read from your own database and your own steps, and do not answer a request with a MAC of its own input. ## `` A step of an action that calls another service (a payment provider, a search API, a customer's webhook). Like `` it costs time and can cost money, so it follows the same rules, and like everything else in dhtml it is built so that a value can never become syntax. ```html Pay with {{ intent.json.client_secret }} The payment service said: {{ intent.json.error.message }} ``` - **The address** starts with `https://` and a host that is written out. Values may follow it and are escaped for where they land (a path part, or a query value), so a value can never add a host, a path segment or a second parameter. A value in the path that is empty, `.` or `..` is an error (an unset value would call `/customers/` instead of `/customers/ID`; check it with `` first). The host must be listed in `allow` in `[http]` in `dhtml.config` ([config](config.md)); the check on every upload finds a call to a host that is not, and one whose key is not set. - **Keys** are never written in a page. `bearer="env:NAME"` sends `Authorization: Bearer `, `basic="env:NAME"` sends it as the user name of Basic sign-in, with the value read from the Secrets section of your site. A header with `` cannot be `Host`, `Cookie`, `Content-Length` and the like, cannot be `Authorization` when `bearer` or `basic` gives the key, and is set once whatever its capitalisation. - **The body** is built from `` tags, never from text. `method` is `GET` (the default), `POST`, `PUT`, `PATCH` or `DELETE`. For `GET` and `DELETE` the fields go in the address; otherwise they are sent form-encoded (names are used as written, so `metadata[order]` works), or as a JSON object with `send="json"`, where `customer.email` builds a nested object and `type="number"` or `type="bool"` sends a number or a boolean. - **The answer** is a variable with `status`, `ok` (a status from 200 to 299), `json` (the decoded body when it is JSON, whole numbers kept exact), `text`, `header` (names in lower case, with `_` for `-`: `x-request-id` is read as `intent.header.x_request_id`) and `error`. A status of 400 or more is data, not an error: the service answered. A network failure, a timeout or a refused address stops the action with an error, unless the tag says `soft`: then `status` is `0`, `ok` is false and `error` says what happened. - **Limits**: 10 seconds by default (`timeout`, at most 60), a response of at most 1 MiB, a request of at most 256 KiB, no redirects followed unless `follow` says so (below), no proxy from the environment, the standard https port. Like ``: only in an action that is not `GET`, with a `ratelimit` of at most 120 a minute, at most 5 calls on a path, and none after a database step in an action that writes (the database lock would be held while the service answers). At most 32 calls wait for answers at once. - **Redirects**: by default a redirect is the answer (`status` 301 to 308 and `header.location`). `follow="3"` follows at most that many (1 to 5): ``. **Every hop is held to the same rules as the first call**: `https` only, a host in `allow`, the standard port, an address that is not private; a redirect that breaks one stops the step with a message that names the hosts (`a.example.com -> b.example.com was refused: ...`), never a path or a query. The whole chain shares the one `timeout`. **What you set goes only to the host you wrote**: `bearer`, `basic` and every `` are dropped on a redirect to another host. A 301, 302 or 303 turns a `POST` into a `GET` without a body; a 307 or 308 repeats the call with its body. The answer has `redirects` (how many were followed) and `final_url` (the address that answered). - **Addresses**: the connection is made to an address that was checked. A host name that points at a private, loopback, link-local (cloud metadata) or reserved address is refused, however DNS answers; `private = on` in `[http]` turns that off for a service that really runs next to the app (and allows plain `http` there). - Nothing about a call (the query of the address, headers, body, key) is written to a log or shown in an error. ### Telling the owner `` mails the owner of the site when a form is sent, for example a contact form. The address is `notify` in `[mail]` (see [config](config.md)); a visitor can never choose it. ```html INSERT INTO messages (name, email, body) VALUES ({{ form.name }}, {{ form.email }}, {{ form.message }}) ``` - **The mail.** One line `field: value` for each field in `fields`; a long or multi-line value goes on its own indented lines. Control characters are removed and each value is cut at 5000 characters. There is no HTML and no free text. `subject` may use `{{ }}` values; it is one line, cut at 120 characters. - **Reply.** `reply` names the field with the visitor's address. It becomes the Reply-To header, so answering the mail answers the visitor. An address that is not valid is left out. - **Rules.** Every field must be declared with `` and cannot be a password. The action needs a `ratelimit`, is not `GET`, a key action or a webhook, and has one `` at most. A job cannot use it. - **It follows the action.** The mail is sent after the action commits. If it fails to send, the visitor still sees the normal answer; the failure is logged without the content. ### Mailing the visitor `` sends a confirmation to the person who filled in the form: a booking, an order, a sign-up. It needs `[mail]` with `from` (see [config](config.md)). The mail is plain text, written by you, and it can only reach the address the visitor typed into the form. ```html INSERT INTO bookings (name, email, day) VALUES ({{ form.name }}, {{ form.email }}, {{ form.date }}) Thank you, {{ form.name }}. Your booking is confirmed. ``` - **Who it can reach.** `to` names a form field declared with `email`, or is `user.email` (the signed-in visitor's own address), or is `step.column`: a column of a `` written above the mail in the same action. Never a fixed address and never a `{{ }}` value. There is no Cc, no Bcc and no Reply-To from the visitor. An address that is not one plain address sends nothing, and neither does a row that is missing. - **A stored address.** With `to="next.email"` you can tell someone other than the visitor, such as the next person on a waitlist: `SELECT email FROM waitlist ORDER BY id LIMIT 1` above the mail. An address that the request itself carried (a form field, the address path, the query string) is never taken from a row, so a query that just hands back what was typed cannot be used to mail any address. The address must be a plain one (letters, digits and `. _ + '-` before the `@`, a dotted host after it); anything else sends nothing. The same limits below apply to each address. dhtml cannot know that the person in the database agreed to be mailed: a stranger can type someone else's address into a form, so write mails that are safe to receive by mistake (the footer says why the mail came). - **What it says.** The text between the tags is yours. `{{ }}` values in the text and in `subject` can be declared form fields, or **a plain result of an earlier step of the action**: `{{ saved.id }}` of a ``, a column of a `one` query (`{{ workshop.title }}`), a `` or `` result. Also the address and name of the site you set in `[app]`: `{{ config.app.url }}` and `{{ config.app.name }}`, so a link is written once (`Manage it: {{ config.app.url }}/b/{{ code }}`) and follows the site when it moves. Not a whole row or a list, not the request (`route`, `query`), the visitor (`user`), the rest of the config or another service; `| truncate` is the one filter. After the text come the lines `field: value` for each field in `fields`. Plain text only: no HTML, no attachments. A value that came from a visitor or from the database is one line, cut at 100 characters, and **a value that a mail client would make a link is replaced by `[link removed]`**: a scheme (`http:`, `mailto:`, `tel:`, `javascript:`), a `www.` address, or a bare address of a site with a common ending (`secure-bank.com/login`, `bit.ly/x`). Values placed side by side are checked together, and **a value cannot finish a link you began** (`https://{{ form.site }}` removes the value), so a mail cannot carry someone else's link. An email address inside a value is left alone. A `` may only be made of form fields, results of earlier query and write steps and the site's address and name, or a mail refuses it. A link you write yourself is kept: `Manage it: https://example.com/b/{{ code }}` works. `subject` is one line, cut at 120 characters. - **A footer you cannot remove.** One line says which site sent the mail and that it was sent because someone entered the address there. - **Brakes you cannot turn off.** The action needs a `ratelimit`, is not `GET`, a key action or a webhook, and has two `` at most (one to many rows, one single). A job may use it only with a stored address (`to="step.column"`) or the loop below. On top of that: at most 3 mails to one mailbox in 24 hours (capital letters, a `+tag` and the dots in a Gmail name count as the same mailbox), and at most `limit_per_hour` (default 60) for the whole site. The counts are kept with the limits of actions, as a hash that holds no address, so they hold across a restart; they are fixed windows, so a burst of twice the limit can cross a boundary. A mail over a limit is dropped and logged (never the content, never the whole address), and the visitor still gets the normal answer. - **It follows the action.** The mail is sent after the action commits, and a failure to send is logged, never shown. With `transport = log` and in a demo it is only written to the log. ### Mail in the recipient's language With `[lang]` ([Languages](#languages)) a mail can be written in the language of the person it goes to, not of the visitor who triggered it. Write the words as keys, and say where the language is read with `lang`: ```html SELECT email, title, lang FROM bookings WHERE reminded = 0 LIMIT 50 {{ "reminder.body" | t b.title }} ``` - `lang` is a form field (`lang="language"`), or a column of the row of the mail or of an earlier `` (`lang="b.lang"`). Its value is a code like `nl`; anything that is not an available language (an empty value, `de` when the app has none, a stored value that is not a code) gives the default language, never an error and never a file name. - `t` in a mail is written out: `{{ "key" | t }}` with the key in quotes, in the subject, the text and an HTML mail template. A key built by a value is refused. The values after `t` (up to four, for `{1}` to `{4}`) are form fields or results of earlier steps and are cleaned like any other value of a mail: one short line, no link. A value can never finish a link the language file began (`https://{1}` gives `[link removed]`). - The words of the language file are yours, so they go into the mail as written, with the footer and the limits of every mail unchanged. - Without `lang` the mail is in the default language. A mail the system sends itself (the confirmation, the reset and the sign-in links) stays in English. ### Mailing many rows A reminder the day before needs a mail for every booking of tomorrow. Put the query above the mail, mark the rows in the same transaction, and loop with `for` and `max`: ```html SELECT id, email, title, day FROM bookings WHERE reminded = 0 AND day = date('now', '+1 day') LIMIT 50 UPDATE bookings SET reminded = 1 WHERE reminded = 0 AND day = date('now', '+1 day') AND id IN (SELECT id FROM bookings WHERE reminded = 0 AND day = date('now', '+1 day') LIMIT 50) This is a reminder of {{ b.title }} on {{ b.day }}. ``` - **`for="b in due"`** names a list from a `` above in the same file. One mail goes to each row, in order, at most **`max`** rows (`max` is required, 1 to 100). Later rows are left out and the log says how many. - **`to="b.email"`** is a column of the row, checked as for a stored address. Values like `{{ b.title }}` are columns of the row and are cleaned like any stored value. `fields` is not used with `for`. - **Once only is yours to arrange.** The query picks rows not yet done and the `` marks them in the same transaction. The mails go out after the commit, so a run that fails and is retried marks and mails nobody twice. A mail lost between the commit and the send is logged and not made up: use it for reminders, and send a receipt from the action that took the payment. - **The brakes stay.** The limit of 3 mails to one mailbox a day and `[mail] limit_per_hour` count every mail of the loop. A mail over a limit is dropped and logged. In an action the loop needs `admin`. ### HTML mail A receipt or a confirmation is expected to look like something. Put the HTML in `server/mails/receipt.dhtml` and use it with `template`: ```html ``` ```html

Thank you, {{ form.name }}

{{ it.title }}{{ it.qty }}

Delivery is free.

See your order

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

{{ post.summary }}

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