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

{{ v }}

` | HTML-escaped | | Attribute | `` | Escaped for attributes. Quotes are optional; an unquoted value is quoted for you | | URL | ``, `` | Only `http`, `https`, `mailto` and relative URLs pass; anything else (such as `javascript:`) becomes `#ZgotmplZ`. Write `href="tel:{{ phone }}"` for a phone link | | URL piece | `` | Percent-encoded | | Script | `` | 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 `