dhtml

Language · docs/templates.md

Templates #

A dhtml page is HTML with {{ }} values and a few <d:...> tags. Everything else passes through untouched. This page describes what each piece does. syntax.md is the short specification; this page adds the exact rules.

html
<d:layout src="main" title="{{ post.title }}">
  <h1>{{ post.title | upper }}</h1>
  <d:if test="post.likes > 10"><b>Popular</b></d:if>
  <d:for each="tag in post.tags"><span>{{ tag }}</span><d:empty>No tags.</d:for>
</d:layout>

How a page is built #

  1. The file is split into text, {{ }} values, <d:...> 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 <d:for>, 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:

PlaceExampleWhat happens
Text<p>{{ v }}</p>HTML-escaped
Attribute<a title="{{ v }}">Escaped for attributes. Quotes are optional; an unquoted value is quoted for you
URL<a href="{{ v }}">, <img src="{{ v }}">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<a href="/p/{{ v }}?q={{ w }}">Percent-encoded
Script<script>var x = {{ v }};</script>Becomes a JavaScript value: a string stays a quoted string. Use `{{ vjson }}` for an object. The default CSP blocks inline scripts, see Scripts and the CSP
Event handler<a onclick="f({{ v }})">The same, inside the attribute. Also blocked by the default CSP
Style<style>a { color: {{ v }} }</style>, style="..."Allowed CSS values pass; others become ZgotmplZ
Textarea, title<textarea>{{ v }}</textarea>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 <meta http-equiv> tag, and at the start of the address in <script src>, <iframe src>, <embed src>, <object data>, <base href>, <form action>, formaction, and <link href> for stylesheets, preloads and similar. Put a fixed path first and the value after it: <script src="/js/{{ name }}.js"> is fine, <script src="{{ url }}"> is not. Links (<a href>) and images are not affected.

There is no raw filter. The only values that are not escaped are the results of markdown (built by dhtml from a safe subset, see below) and json (JSON with <, > and & escaped, for use inside a script).

Filters #

FilterDoesExample
dateShows a date as 2006-01-02. Reads a date value, or text like 2024-03-09, 2024-03-09 18:30:00 or RFC 3339. Anything else is shown as it is. With a layout in quotes it shows the date that way: the layout is Go's reference time, Mon Jan 2 15:04:05 2006, written the way you want it ("02 Jan 2006", "15:04", "Monday, 2 January"), at most 64 characters{{ post.created | date }}, {{ post.created | date "02 Jan 2006" }}
upper, lowerChanges case{{ post.title | upper }}
default XUses X when the value is empty: nil, false, 0, empty text, an empty list or object{{ user.name | default "Guest" }}
truncate NCuts text to N characters, ending in … (so the total is N). N is a whole number from 1 to 1000000{{ post.body | truncate 80 }}
slugMakes text safe for a URL: lowercase letters and digits joined by single dashes. Latin letters with accents become plain ones (Čačak → cacak, čćšžđ → ccszd, Łódź → lodz, İstanbul → istanbul; ß → ss, æ → ae, þ → th); letters from other scripts are dropped{{ post.title | slug }}
markdownTurns Markdown into HTML (below){{ post.body | markdown }}
jsonWrites the value as JSON<script>var p = {{ post | json }};</script>
firstThe first item of a list, or the first character of text; empty if there is none{{ tags | first }}
countThe number of items in a list or object, or characters in text{{ posts | count }}
add NAdds 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 NSubtracts N (same rules as add){{ total | sub discount }}
mul NMultiplies by N (same rules as add){{ price | mul 100 }}
div NDivides by N (same rules as add). A whole result stays a whole number; division by zero is an error{{ cents | div 100 }}
roundRounds to a whole number, halves away from zero{{ price | mul 100 | round }}
fixedShows 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 }}
thousandsGroups 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 }}
sha256The SHA-256 of the text, as 64 hex characters{{ email | lower | sha256 }}
base64The text in Base64 (standard alphabet, with padding){{ token | base64 }}
urlencodeThe text escaped for a query string{{ q | urlencode }}
tThe 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 #

NameContents
appname, and version as 1.2.0.44
routeThe [slug] parts of the URL
queryThe URL's query string. For a repeated key, the first value
pageThe page values given to the layout (below). Empty in a page
configA 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
csrfThe CSRF token for the current visitor (see Forms and CSRF); you rarely write it yourself
requestrequest.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: <d:query as="shop" one>SELECT * FROM shops WHERE host = {{ request.host }}</d:query>. 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
langThe language of the request ([lang], 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 <link rel="alternate" hreflang>. Without [lang] they exist and are empty
webhookIn an action with webhook="...": webhook.json (the call's body, decoded) and webhook.text (the raw text). Empty elsewhere
flashA message set by <d:redirect flash="..."> on the previous request, shown once, then gone. Empty otherwise
form, errorsIn an action: the submitted fields that were declared with <d:validate>, and the error text per field. Empty in a page
userThe signed-in visitor: user.id, user.email, user.name, user.admin. Empty for a visitor who is not signed in. See Accounts

Loop variables (<d:for each="p in posts">) and the names given to queries (and, in an action, to writes) are added to these.

<d:if> #

html
<d:if test="post.likes > 10 and not post.hidden">...<d:else>...</d:if>

<d:else> 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 <d:if> 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: <d:if test='post.status == "draft"'>. 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.

<d:for> #

html
<d:for each="post in posts">
  <h2>{{ post.title }}</h2>
<d:empty>
  <p>Nothing yet.</p>
</d:for>

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. <d:empty> 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.

<d:layout> and <d:slot /> #

A layout is a page shell in server/layouts/:

html
<!-- server/layouts/main.dhtml -->
<!doctype html>
<title>{{ page.title | default app.name }}</title>
<body><d:slot /></body>

A page wraps its content in it:

html
<d:layout src="main" title="About us">
  <h1>About us</h1>
</d:layout>
  • 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 <d:slot /> is. It is evaluated in the page's scope. A layout has exactly one slot.
  • Layouts can use other layouts (a <d:layout> in a layout), and <d:include>. A layout that includes itself, directly or through others, is an error.
  • Content outside the <d:layout> tag is kept as written, so a page can start with <d:if ...><d:status ... /></d:if>.

<d:set /> #

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
<d:action ratelimit="10/minute">
  <d:validate field="price" required number min="0" />
  <d:set as="cents" value="{{ form.price | mul 100 | round }}" />
  <d:exec>INSERT INTO items (price_cents) VALUES ({{ cents }})</d:exec>
  <d:redirect to="/items" />
</d:action>

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
<d:action method="POST" ratelimit="10/minute">
  <d:validate field="order" required number />
  <d:query as="o" one>SELECT id, total FROM orders WHERE id = {{ form.order }}</d:query>
  <d:set as="sig" value="{{ o.id | hmac "env:SHOP_SECRET" }}" />
  <d:http as="r" method="POST" url="https://api.shop.example/v1/orders" bearer="env:SHOP_TOKEN">
    <d:header name="X-Signature" value="{{ sig }}" />
    <d:field name="order" value="{{ o.id }}" />
  </d:http>
  <d:redirect to="/orders" />
</d:action>
  • 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
<d:set as="hex" value="{{ o.id | hmac "env:SHOP_SECRET" }}" />
<d:set as="b64" value="{{ o.id | hmac_base64 "env:SHOP_SECRET" }}" />
<d:set as="token" value="{{ o.id | hmac_base64url "env:SHOP_SECRET" }}" />
  • 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 (<d:set>, and the values of <d:http>). 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
<d:set as="expected" value="{{ form.order | hmac "env:SHOP_SECRET" }}" />
<d:if test="expected != form.sig"><d:status code="403" /></d:if>
  • 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.

<d:http> #

A step of an action that calls another service (a payment provider, a search API, a customer's webhook). Like <d:ai> 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
<d:action ratelimit="10/minute">
  <d:validate field="price" required number min="1" />
  <d:set as="cents" value="{{ form.price | mul 100 | round }}" />
  <d:http as="intent" method="POST" url="https://api.stripe.com/v1/payment_intents" bearer="env:STRIPE_KEY" timeout="10">
    <d:header name="Idempotency-Key" value="order-{{ route.id }}" />
    <d:field name="amount" value="{{ cents }}" />
    <d:field name="currency" value="usd" />
    <d:field name="metadata[order]" value="{{ route.id }}" />
  </d:http>
  <d:if test="intent.ok">
    <d:respond>Pay with {{ intent.json.client_secret }}</d:respond>
  <d:else>
    <d:respond>The payment service said: {{ intent.json.error.message }}</d:respond>
  </d:if>
</d:action>
  • 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 <d:if> first). The host must be listed in allow in [http] in dhtml.config (config); 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 <value of NAME>, 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 <d:header> 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 <d:field name="..." value="..." /> 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 <d:ai>: 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): <d:http as="f" url="https://files.example.com/latest" follow="3">. 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 <d:header> 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 #

<d:notify /> mails the owner of the site when a form is sent, for example a contact form. The address is notify in [mail] (see config); a visitor can never choose it.

html
<d:action method="POST" ratelimit="5/hour">
  <d:validate field="name" required max="80" />
  <d:validate field="email" required email />
  <d:validate field="message" required max="5000" />
  <d:exec>INSERT INTO messages (name, email, body) VALUES ({{ form.name }}, {{ form.email }}, {{ form.message }})</d:exec>
  <d:notify subject="New message from {{ form.name }}" fields="name, email, message" reply="email" />
  <d:redirect to="/contact/thanks" />
</d:action>
  • 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 <d:validate> and cannot be a password. The action needs a ratelimit, is not GET, a key action or a webhook, and has one <d:notify /> 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 #

<d:mail /> 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). The mail is plain text, written by you, and it can only reach the address the visitor typed into the form.

html
<d:action method="POST" ratelimit="5/hour">
  <d:validate field="name" required max="80" />
  <d:validate field="email" required email />
  <d:validate field="date" required />
  <d:exec>INSERT INTO bookings (name, email, day) VALUES ({{ form.name }}, {{ form.email }}, {{ form.date }})</d:exec>
  <d:mail to="email" subject="Your booking on {{ form.date }}" fields="date">
    Thank you, {{ form.name }}. Your booking is confirmed.
  </d:mail>
  <d:redirect to="/booked" />
</d:action>
  • 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 <d:query as="step" one> 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: <d:query as="next" one>SELECT email FROM waitlist ORDER BY id LIMIT 1</d:query> 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 <d:exec>, a column of a one query ({{ workshop.title }}), a <d:set> or <d:save> 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 <d:set> 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 <d:mail /> 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) 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
<d:job>
  <d:query as="due">SELECT email, title, lang FROM bookings WHERE reminded = 0 LIMIT 50</d:query>
  <d:mail for="b in due" max="50" to="b.email" lang="b.lang"
          subject="{{ "reminder.subject" | t b.title }}">{{ "reminder.body" | t b.title }}</d:mail>
</d:job>
  • lang is a form field (lang="language"), or a column of the row of the mail or of an earlier <d:query ... one> (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
<d:job every="1h">
  <d:query as="due">SELECT id, email, title, day FROM bookings WHERE reminded = 0 AND day = date('now', '+1 day') LIMIT 50</d:query>
  <d:exec>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)</d:exec>
  <d:mail for="b in due" max="50" to="b.email" subject="Tomorrow: {{ b.title }}">
    This is a reminder of {{ b.title }} on {{ b.day }}.
  </d:mail>
</d:job>
  • for="b in due" names a list from a <d:query> 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 <d:exec> 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
<d:mail to="email" subject="Your order {{ order.code }}" template="receipt" />
html
<!-- server/mails/receipt.dhtml -->
<h1>Thank you, {{ form.name }}</h1>
<table>
  <d:for each="it in items"><tr><td>{{ it.title }}</td><td>{{ it.qty }}</td></tr></d:for>
</table>
<d:if test="order.total > 100"><p>Delivery is free.</p></d:if>
<p><a href="{{ config.app.url }}/orders/{{ order.code }}">See your order</a></p>
<img src="{{ config.app.url }}/logo.png" alt="Shop" width="80">

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: <b> 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. <d:if> and <d:for> 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 #

<d:save /> 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). It is for an action that is not GET.

html
<d:action auth ratelimit="6/minute">
  <d:validate field="prompt" required max="400" />
  <d:http as="r" method="POST" url="https://api.openai.com/v1/images/generations" bearer="env:OPENAI_API_KEY" send="json" timeout="60">
    <d:field name="model" value="gpt-image-1" />
    <d:field name="prompt" value="{{ form.prompt }}" />
    <d:field name="output_format" value="webp" />
    <d:field name="output_compression" value="70" type="number" />
  </d:http>
  <d:save soft as="pic" from="r.json.data.0.b64_json" type="webp" />
  <d:if test="pic">
    <d:exec>INSERT INTO images (user_id, prompt, path) VALUES ({{ user.id }}, {{ form.prompt }}, {{ pic }})</d:exec>
    <d:redirect to="/studio" flash="Your picture is ready." />
  <d:else>
    <d:redirect to="/studio" flash="The picture could not be made. Try again." />
  </d:if>
</d:action>
  • 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/<random name>.<type>, served at /uploads/<name> 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 <d:status> 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 #

<d:action webhook="stripe"> 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).

html
<d:action webhook="stripe" dedupe="webhook.json.id" ratelimit="600/minute">
  <d:if test='webhook.json.type == "payment_intent.succeeded"'>
    <d:exec>UPDATE orders SET paid = 1 WHERE intent = {{ webhook.json.data.object.id }}</d:exec>
  </d:if>
  <d:respond>ok</d:respond>
</d:action>
  • 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 <d:http>: 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 (<d:validate>), <d:invalid>, account steps, <d:redirect> or model calls (anyone who could reach the endpoint would make it cost money), and the answer cannot draw a form. Steps, <d:http>, <d:set>, <d:respond>, <d:json> and <d:status> 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 #

<d:job>, <d:arg /> and <d:enqueue />. 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 <d:job>. The steps are the steps of an action (<d:query>, <d:exec>, <d:set>, <d:http>, <d:ai>, <d:if>); there is no response, because nobody waits for one.

html
<!-- server/jobs/send-receipt.dhtml -->
<d:job retries="3" timeout="30">
  <d:arg name="order" required number min="1" />
  <d:query as="o" one>SELECT id, email FROM orders WHERE id = {{ args.order }}</d:query>
  <d:if test="o">
    <d:http as="sent" method="POST" url="https://api.mail.example/v1/send" bearer="env:MAIL_KEY" send="json">
      <d:field name="to" value="{{ o.email }}" />
      <d:field name="subject" value="Your receipt for order {{ o.id }}" />
    </d:http>
    <d:exec>UPDATE orders SET receipt_sent = 1 WHERE id = {{ o.id }}</d:exec>
  </d:if>
</d:job>

An action puts the job in the queue with <d:enqueue>. Every attribute but job is an argument, given as text:

html
<d:action method="POST" ratelimit="10/minute">
  <d:validate field="email" required email />
  <d:exec as="order">INSERT INTO orders (email) VALUES ({{ form.email }})</d:exec>
  <d:enqueue job="send-receipt" order="{{ order.id }}" />
  <d:respond>Thank you. The receipt is on its way.</d:respond>
</d:action>

A job can also run by the clock. A job with every starts itself and takes no arguments:

html
<!-- server/jobs/clean-up.dhtml: every night at 03:30 UTC -->
<d:job every="1d" at="03:30">
  <d:exec>DELETE FROM drafts WHERE created_at < date('now', '-30 days')</d:exec>
</d:job>
  • <d:job> 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).
  • <d:arg name="order" ... /> declares an argument with the rules of <d:validate> (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 <d:enqueue> 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. <d:enqueue> 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), <d:enqueue> 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
<!-- a call from a program: GET /api/notes with  Authorization: Bearer dhk_... -->
<d:action apikey method="GET" ratelimit="120/minute">
  <d:validate field="limit" number min="1" max="50" />
  <d:query as="notes">SELECT id, title FROM notes WHERE user_id = {{ user.id }} ORDER BY id DESC LIMIT {{ form.limit }}</d:query>
  <d:json>{{ notes }}</d:json>
</d:action>

<!-- a signed-in visitor makes a key. It is shown once, here, and kept nowhere -->
<d:action method="POST" auth ratelimit="10/hour">
  <d:validate field="name" required max="60" />
  <d:newkey as="k" />
  <d:respond><p>Copy it now: <code>{{ k.key }}</code></p></d:respond>
  <d:invalid><p>{{ errors.name }}</p></d:invalid>
</d:action>

<!-- and revokes one -->
<d:action method="POST" auth ratelimit="30/hour">
  <d:validate field="id" required number />
  <d:revokekey />
  <d:redirect to="/account/keys" flash="Key revoked" />
</d:action>
  • 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 <d:validate>. 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 <d:invalid>.
  • What a key action cannot do: redirect, draw a form, do account steps (<d:login /> and the rest), or make and revoke keys. A key cannot make more keys.
  • <d:newkey as="k" name="name" /> (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.
  • <d:revokekey id="id" /> 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).

<d:include /> #

html
<d:include src="post-card" />

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 <d:slot />.

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.

<d:use /> #

html
<d:use src="shop" />

Puts the steps of server/steps/shop.dhtml right there. Where <d:include> shares markup, <d:use> 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
<!-- server/steps/shop.dhtml -->
<d:query as="shop" one>SELECT id, name FROM shops WHERE host = {{ request.host }}</d:query>
<d:if test="not shop"><d:status code="404" /></d:if>

<!-- server/steps/member.dhtml: needs a signed-in visitor and the shop above -->
<d:query as="m" one>SELECT role FROM memberships WHERE user_id = {{ user.id }} AND shop_id = {{ shop.id }}</d:query>
<d:if test="not m"><d:status code="403" /></d:if>

<!-- server/api/product-save.dhtml -->
<d:action method="POST" auth ratelimit="30/minute">
  <d:validate field="title" required max="120" />
  <d:use src="shop" />
  <d:use src="member" />
  <d:exec>INSERT INTO products (shop_id, title) VALUES ({{ shop.id }}, {{ form.title }})</d:exec>
  <d:redirect to="/admin/products" />
</d:action>
  • It is put in place before anything is checked, so the steps are held to the rules of the place they are used: a <d:query> at the top level of a page, a write only among the steps of an action, <d:validate> 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 <d:query>, <d:exec>, <d:set>, <d:if>, <d:status> and <d:redirect>, 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 <d:else> 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. <d:status code="403" /> or <d:redirect> in a steps file stops the page or action there, and an action that stops before its writes leaves nothing written.

<d:component> and <d:props> #

A component is a reusable piece with named inputs. It lives in server/components/ and starts with the inputs it takes:

html
<!-- server/components/card.dhtml -->
<d:props title href tone="info" />
<div class="card {{ props.tone }}">
  <a href="{{ props.href }}">{{ props.title }}</a>
  <d:slot />
</div>
html
<!-- in a page, a layout, a partial or a response -->
<d:component src="card" title="{{ post.title }}" href="/blog/{{ post.slug }}">
  <p>{{ post.summary }}</p>
</d:component>
<d:component src="card" title="Plain" href="/x" tone="warn" />
  • Inputs. <d:props> is the first thing in the file. A name alone is required; name="x" gives a default (plain text). Each input is given on <d:component> 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 <d:for each="x in props.items">.
  • <d:slot /> 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 <d:component ... />.
  • 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 (<d:include>); 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. <d:component> cannot be used among the steps of an action; it can be used inside <d:respond>.
  • <d:include> 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
<script src="/_dhtml/state.js" defer></script>

<div data-d-state='{"open": false, "n": 1, "price": 12, "tags": ["a", "b"]}'>
  <button data-d-on-click="open = !open">Details</button>
  <p data-d-show="open">Shown when open.</p>

  <button data-d-on-click="n = max(1, n - 1)">-</button>
  <span data-d-text="n"></span>
  <button data-d-on-click="n += 1">+</button>
  <p>Total: <b data-d-text="n * price"></b></p>

  <input data-d-model="name" placeholder="Your name">
  <p data-d-show="len(name) > 0">Hello <span data-d-text="name"></span></p>

  <ul><template data-d-for="tag, i in tags"><li data-d-text='(i + 1) + ". " + upper(tag)'></li></template></ul>
</div>
  • 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 <template data-d-for="item, i in list"> (a row for each item, at most 1000).
  • Expressions. Numbers, strings, names, a.b and a[i], + - * / %, comparisons, && || !, a ? b : c, lists like [1, 2], assignment with =, +=, -= (several, separated by ;), and the functions len upper lower trim str num round min max contains concat without slice, and for lists of rows where(list, "field", value), setby(list, "id", 2, "field", value) (a copy with that row changed) and removeby(list, "id", 2). There is no eval, no loops and no calling anything else; a name like constructor or __proto__ is refused. Dividing by zero gives 0 and a missing name gives nothing.
  • Safety. The state is written to the page only as text, never as markup. onclick, style and srcdoc cannot be set; href and src keep only relative, http, https, mailto and tel addresses. The state is the visitor''s to change, so the server never believes it: send what matters through a form or an action, which checks it again.
  • Starting values come from the server: write the JSON with {{ ... | json }} inside the attribute. A broken expression is reported in the browser console and the rest of the island keeps working.
  • Live events. data-d-channel="board" on the island opens /_dhtml/events?channel=board (see Live updates); data-d-on-live-NAME="..." runs when the event NAME arrives, with its JSON data as event (nothing, if the data is damaged). The connection is closed when the island leaves the page.
  • Fast navigation. Islands in a page that was just swapped in are started by themselves.

For a bigger client app, write your own JavaScript in public/ (a file, since the policy forbids inline scripts) and talk to actions that answer JSON (<d:json>); the server keeps the accounts, the database and the checks.

Live updates #

<d:publish /> sends a message to the people who are listening to a channel, once the action has committed:

html
<d:action auth method="POST" ratelimit="60/minute">
  <d:validate field="room" required max="20" />
  <d:validate field="text" required max="200" />
  <d:exec>INSERT INTO messages (room, user_id, body) VALUES ({{ form.room }}, {{ user.id }}, {{ form.text }})</d:exec>
  <d:publish channel="room-{{ form.room }}" event="said" value="{{ form.text }}" />
  <d:redirect to="/rooms/{{ form.room }}" />
</d:action>
  • Channels are listed first. Every channel has a line in [channels] in dhtml.config: board = auth, news = public, room-* = auth, inbox:* = user (see config). A name that is not listed does not exist: an upload is refused if an action has a <d:publish /> to it, and nobody can listen to it.
  • The message. event is a short name (default message); value is exactly one {{ }} value without filters, sent as JSON (like <d:json>), at most 4096 bytes. A channel name is lower-case letters, digits and - _ : ., up to 64 characters, and starts with fixed text. The name can continue with a {{ }} value; if it ends up invalid, the action fails with an error instead of publishing.
  • After the commit. The message goes out only when the action commits. A failed action, a failed validation or a rolled back write sends nothing.
  • Rules. It needs a ratelimit; it cannot be in a GET action or a job; at most 5 on one path. It is not allowed inside <d:respond>.
  • Listening. A page connects to /_dhtml/events?channel=NAME with EventSource, or an island does it for you: put data-d-channel="board" on the island and data-d-on-live-said="items = concat(items, event)" on any element inside (the event name is lower case; the message arrives as event). The rule in [channels] decides who may connect: public, auth (signed in), admin, or user (the name is the prefix plus the visitor's own id: inbox:42 for user 42). One process only: several copies of the app behind one address do not share channels.
  • Sending from the page is an ordinary form or action, with CSRF, sign-in checks, ratelimit and <d:validate> as always.

<d:status /> #

html
<d:if test="not post"><d:status code="404" /></d:if>

Stops the page and answers with that status (400 to 599). The visitor sees the plain status text. It works in pages, layouts and partials.

<d:query> #

html
<d:query as="posts">
  SELECT id, title, slug FROM posts WHERE published = {{ true }} ORDER BY created DESC LIMIT 20
</d:query>

<d:query as="post" one>
  SELECT * FROM posts WHERE slug = {{ route.slug }}
</d:query>

<d:if test="not post"><d:status code="404" /></d:if>
<d:for each="p in posts"><a href="/blog/{{ p.slug }}">{{ p.title }}</a></d:for>

A query reads from the project's SQLite database (server/data/app.db, created and changed by migrations) and puts the result in the variable named by as. Without one the result is a list of rows; with one it is the first row, or empty when there is none. A row is an object: {{ p.title }}. Numbers, text and NULL (empty) come through as they are; BLOB columns as text. A value that is missing, such as query.date when the address has no ?date=, is NULL in SQL, not '': {{ query.date }} = '' matches nothing. Write COALESCE({{ query.date }}, '') = '' or {{ query.date }} IS NULL. The check warns about a comparison with ''.

  • Values are parameters. An empty value (a missing query.x, an unset path) is sent as NULL, not as empty text, so {{ query.date }} = '' matches nothing when ?date= is absent: write COALESCE({{ query.date }}, '') or IS NULL. Each {{ }} in the SQL is sent to the database as a separate value, never pasted into the SQL text, so {{ route.slug }} is safe whatever the visitor typed. Inside a query a {{ }} holds one plain value (a path, text, a number, true or false), without filters. A parameter written in SQL (?, ?1, :name, @name, $name) is an error. To build a pattern use SQL: LIKE '%' || {{ query.q }} || '%'.
  • Table-valued functions work: FROM json_each({{ x }}) takes its argument as a value, since a function call is not a name.
  • A value cannot name things. A {{ }} after FROM, JOIN, ORDER BY, GROUP BY, AS, inside quotes, or attached to other text (post_{{ id }}) is a compile error: parameters are for data, not table or column names. Write the name in the SQL; to choose between a few, put separate queries in <d:if>.
  • Read only. A query is one SELECT (or WITH ... SELECT). INSERT, UPDATE, REPLACE INTO, DELETE, CREATE, PRAGMA, comments and a second statement are errors, and the connection itself refuses writes. Writing is done with <d:exec>, described below.
  • Where it goes. A query is written at the top level of a page (not inside <d:if>, <d:for>, <d:layout>, and not in a layout or partial). Queries run in order before the page is rendered, and a query may use the results above it: WHERE user_id = {{ post.user_id }}. Pass results to a layout with page values.
  • Limits. A query returns at most 10000 rows (more is an error: add a LIMIT) and runs for at most 10 seconds (all the queries of a request share that time). Two columns with the same name (SELECT a.id, b.id) are an error: name one with AS.
  • Errors in a query point at its line in the page: server/pages/blog.dhtml line 4 query "posts" failed: no such table: posts. A missing table means a migration is missing.

Actions #

A file in server/api is an action: the code that answers a form post or an API call. server/api/posts.dhtml is the URL /api/posts. One file answers one method; other methods get 405 with an Allow header.

html
<d:action method="POST" ratelimit="10/minute">
  <d:validate field="title" required max="120" />
  <d:validate field="body" max="20000" />

  <d:exec as="saved">
    INSERT INTO posts (title, body) VALUES ({{ form.title }}, {{ form.body }})
  </d:exec>

  <d:redirect to="/blog/{{ saved.id }}" flash="Saved" />

  <d:invalid>
    <form method="post" action="/api/posts">
      <input name="title" value="{{ form.title }}"> <b>{{ errors.title }}</b>
      <textarea name="body">{{ form.body }}</textarea>
    </form>
  </d:invalid>
</d:action>

<d:action> has method (GET, POST, PUT or DELETE, default POST), ratelimit="10/minute" (second, minute, hour or day, counted per action and visitor address; over the limit answers 429 with Retry-After), and the bare words auth and admin: an action with auth answers 401 to a visitor who is not signed in, and admin also answers 403 to one who is not an admin. See Accounts.

<d:validate field="x" ... /> declares a form field and its rules: required, email, number, date (YYYY-MM-DD, a day that exists: 2026-02-30 is refused), time (HH:MM, 00:00 to 23:59), min and max (for a number its value, for text its length in characters; not for date and time). Only one of email, number, date, time and file goes with a field. Nothing around the value is forgiven: 2026-2-3 and 9:05 are not a date and a time, message="..." to replace the error text, and file (below). Only declared fields exist. Whatever else a visitor sends is dropped, and reading form.other is a compile error; this is what stops a visitor from setting is_admin or user_id by adding a field. All <d:validate> tags come first.

Steps run top to bottom: <d:query as="x"> (as in a page), <d:exec>, and <d:if> / <d:else> around them. <d:exec> is an INSERT, UPDATE, DELETE or REPLACE (a WITH before them is allowed) with the same rules as a query (every {{ }} is a bound parameter, no names from values, one statement, no comments); schema changes, PRAGMA, ATTACH and RETURNING are refused, and so is any name starting with sqlite_ or _dhtml_ (SQLite's own tables and the migration record). as="saved" gives saved.id (the last inserted row) and saved.changes (rows changed). A GET action cannot contain <d:exec>.

A transaction wraps an action with <d:exec>: it commits when a <d:respond>, <d:json> or <d:redirect> ends it, and rolls back on <d:status> 400 or more, on invalid input, and on any error. Files saved by an upload are removed on a rollback. (A process that dies between saving a file and the commit leaves the file behind; nothing sweeps those yet.)

Responses. Every path through an action ends in exactly one:

  • <d:respond> an HTML fragment (or a whole page with <d:layout>; <d:include> works), status 200. This is what htmx swaps in.
  • <d:json>{{ posts }}</d:json> one value, written as JSON.
  • <d:redirect to="/blog/{{ saved.id }}" flash="Saved" /> answers 303. to starts with a fixed path on this site; values after it are escaped for where they land (a path part or a query value), so a value can never add a host, a second parameter or a line break.
  • <d:status code="403" />.

A response ends the request, so <d:if test="not post"><d:status code="404" /></d:if> followed by more steps is the usual guard. A step written after a response that ends every path can never run and is a compile error, as is a path with no response.

<d:invalid> (at most one, directly inside the action) is the answer when validation fails: status 422, with form (the submitted values) and errors (errors.title) available, all escaped as everywhere. Without one, the answer is a plain 422 (JSON {"errors": ...} if the request asks for JSON).

A response that is not HTML. <d:respond type="xml"> and <d:respond type="text"> answer with application/xml or text/plain (always UTF-8, always with nosniff). No other type is accepted. It is for a sitemap or a feed that reads the same tables as the site:

html
<d:action method="GET">
  <d:query as="posts">SELECT slug, published_at FROM posts WHERE status = 'published' ORDER BY published_at DESC</d:query>
  <d:respond type="xml">
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      <d:for each="p in posts"><url><loc>{{ config.app.url }}/blog/{{ p.slug }}</loc><lastmod>{{ p.published_at }}</lastmod></url></d:for>
    </urlset>
  </d:respond>
</d:action>

Give it its real address in [routes]: /sitemap.xml = api/sitemap.dhtml. Values are escaped like everywhere else, which is right for XML; in text an & shows as &amp;, so use text for fixed words. The <?xml ... ?> line is added for you; do not write it.

Actions are checked on every upload, before the site is switched to the new version.

AI calls #

html
<d:action method="POST" ratelimit="10/minute">
  <d:validate field="question" required max="500" />
  <d:query as="posts">SELECT title, body FROM posts ORDER BY created DESC LIMIT 5</d:query>

  <d:ai as="answer" max_tokens="400">
    <d:system>Answer questions about this blog. Be brief.</d:system>
    <d:context>Latest post: {{ posts.0.title }}: {{ posts.0.body | truncate 2000 }}</d:context>
    <d:user>{{ form.question }}</d:user>
  </d:ai>

  <d:respond><div class="answer">{{ answer }}</div></d:respond>
</d:action>

<d:ai> asks a language model and puts the answer, as text, in the variable named by as. The provider, model and key come from [ai] in dhtml.config; model, max_tokens (up to 32768) and timeout (seconds, up to 300) on the tag change them for one call.

  • Only in actions that cost something to abuse. <d:ai> is allowed among the steps of an action that is not GET, so CSRF, validation and body limits apply, and the action must have a ratelimit of at most 120 requests a minute (a compile error otherwise, as is a limit written in another unit that comes to more). An action may call a model at most 3 times, so one request is never many calls. A page cannot call a model. At most 32 requests may wait for a model at once; the next one answers 503 with Retry-After, so slow models cannot use up every connection of the server.
  • The prompt is <d:system> (instructions, at most one), any number of <d:context> (data the answer should use) and exactly one <d:user> (the question). Each holds text and {{ }} values; a value goes in as the text it prints as, and nothing is escaped, because a prompt is not HTML (< and & are just characters). A list or object cannot be put in a prompt: pick fields, or build the text. The only filter is | truncate N, which keeps a long value from filling the prompt. The whole prompt is at most 256 KiB. dhtml sends the context in <context> tags in the same message as the question, so a model can tell the data from the instruction. Text in the data can still try to instruct the model (prompt injection): never let an answer decide what the application does.
  • The answer is untrusted text. It is a plain string, escaped wherever you show it ({{ answer }}), at most 1 MiB. Markdown can be rendered with {{ answer | markdown }}, which cannot produce a script.
  • Errors are shown to you with the tag's line and what to do (AI call failed: the AI provider did not accept the API key. Check the key in the Secrets section ...). A visitor sees only the bare status and the log has the reason. Prompts and answers are never logged; a call logs the provider, model, token counts and outcome. The key is never put in an error: provider messages are cleaned of anything key-shaped first.
  • No database lock while a model thinks. In an action that has a <d:exec>, the write transaction starts at its first database step after the call (a <d:query> there counts, so what the action reads is what it writes against). A model call after a write, or after a query that is not before every call, is a compile error: call the model first, then query and write, or use <d:ai stream>, which commits before its first word. A <d:query> that comes before a model call (or a <d:http>) is the exception: it reads through the shared handle and does not start the transaction, so you can check something, call, and then write. It counts as before the call only if every path from it reaches a call before it reaches any write (<d:exec>, an account step, <d:enqueue>, a key step); a path that ends in an answer with no write is fine. Otherwise it is an ordinary read in the transaction, and the call after it is refused as before. The read can be stale by the time the write happens, so a write that depends on it must say so: UPDATE accounts SET balance = balance - {{ u.total }} WHERE user_id = {{ user.id }} AND balance >= {{ u.total }}, then check spent.changes. The server's write timeout does not cut a slow answer short; the call's own timeout decides.
  • Streaming. <d:ai stream> (no as) makes the action answer with server-sent events as the model writes: data: "<piece as a JSON string>" for each piece, then event: done, or event: error with a message that is safe to show. The stream is the response, so nothing can follow it in that path, and steps before it (an <d:exec> that saves the question) are committed before the first piece. To show it, give a form data-stream-target:
html
<form method="post" action="/api/ask" data-stream-target="#answer">
  <input name="question"> <button>Ask</button>
</form>
<p id="answer"></p>
<script src="/_dhtml/dhtml.js"></script>

dhtml.js sends the form with fetch (a GET form sends its fields in the address), and writes each piece into #answer with textContent, so the answer can never become markup. A newer submit for the same target stops the older stream. Plain text from the server (a refusal such as "question: This field is required.") is shown as it is; an HTML page, a redirect, or anything else is not shown, only a short message. The element gets data-stream-state="streaming", then done or error, and a dhtml:stream event fires on it when the stream ends. Without JavaScript the form posts normally and the browser shows the raw event stream; use a normal <d:ai as="answer"> for pages that must work without it.

Usage, conversations and typed answers (three attributes and a tag, all plain data):

html
<d:action method="POST" auth ratelimit="20/minute">
  <d:validate field="message" required max="2000" />
  <d:query as="balance" one>SELECT tokens FROM wallets WHERE user_id = {{ user.id }}</d:query>
  <d:query as="msgs">SELECT role, body FROM (SELECT id, role, body FROM chat WHERE user_id = {{ user.id }} ORDER BY id DESC LIMIT 20) ORDER BY id</d:query>
  <d:if test="balance.tokens >= 2000">
    <d:ai as="answer" usage="used" max_tokens="1000">
      <d:system>You are a helpful assistant for {{ app.name }}.</d:system>
      <d:history rows="msgs" text="body" />
      <d:user>{{ form.message }}</d:user>
    </d:ai>
    <d:exec as="spent">UPDATE wallets SET tokens = tokens - {{ used.total }} WHERE user_id = {{ user.id }} AND tokens >= {{ used.total }}</d:exec>
    <d:if test="spent.changes == 1">
      <d:exec>INSERT INTO chat (user_id, role, body) VALUES ({{ user.id }}, 'user', {{ form.message }})</d:exec>
      <d:exec>INSERT INTO chat (user_id, role, body) VALUES ({{ user.id }}, 'assistant', {{ answer }})</d:exec>
      <d:respond>{{ answer }}</d:respond>
    <d:else>
      <d:respond>Out of tokens.</d:respond>
    </d:if>
  <d:else>
    <d:respond>Out of tokens.</d:respond>
  </d:if>
</d:action>

<d:ai as="r" fields="mood:text, score:number, urgent:bool"> <d:user>{{ form.message }}</d:user> </d:ai>
<!-- {{ r.mood }}, {{ r.score }}, {{ r.urgent }} -->
  • usage="used" keeps the tokens the provider reported in used.input, used.output and used.total (zero when it does not say). Not with stream, whose end comes after the steps have run. dhtml counts nothing and keeps no balance: credits, plans and prices are tables of your own, and the steps above are how an app spends them. The server log still holds the counts of every call.
  • Check for the most a call can cost, and check the spend. The model is called before the spend, because a call cannot wait inside the write transaction. A balance of 5 against a call that costs 18 would still pass balance.tokens > 0, and the call, paid for by you, would be answered for nothing. So test the balance against more than one call can cost (the prompt you send plus max_tokens, here 2000), and show the answer only when the guarded UPDATE changed a row (spent.changes == 1), as above. The AND tokens >= ... in the UPDATE is what stops two requests at once from spending the same tokens.
  • A call that fails is still billed. If the model answers in the wrong shape (fields), with nothing, or is cut off, the provider has already charged for it, but the action stops with an error and no step after the call runs, so nothing is spent. The ratelimit of the action bounds how often that can happen; an app that cannot afford it should keep its own count of attempts in a table, written before the call.
  • <d:history rows="msgs" role="role" text="text" /> (inside <d:ai>, at most one) sends earlier turns before the question. rows is the result of a <d:query> above it; role and text name the columns (defaults role and text). Each role must be exactly user or assistant (anything else, system included, fails the call, since a row in a table must not speak with the authority of your instructions); empty turns, more than 100 turns and a prompt over 256 KiB are refused. No rows is a new conversation. Select the latest turns, oldest first, as above.
  • fields="mood:text, score:number, urgent:bool" asks for the answer as typed values (types text, number, bool, at most 20 fields; not with stream). dhtml adds the instruction to the system prompt, finds the JSON object in the reply, and checks every field: a missing field or a wrong type fails the step with a message that does not repeat what the model said. Extra keys are dropped. The variable named by as holds the values, so {{ r.mood }} is escaped like any text and r.score is a number that filters such as add accept. A model can still be wrong, or be led by the data in the prompt: the values are checked for shape, not for truth. If the reply holds more than one object (the model quoted some of the data it was given), the first one is used, so keep user text out of <d:system> and say in <d:user> that the answer is the only object.

With provider = fake in [ai] nothing leaves the machine: the answer is a fixed sentence built from the question. A question containing [[fake:error]], [[fake:rate-limit]], [[fake:empty]], [[fake:timeout]] or [[fake:drop]] fails in that way, to try the error paths of a page.

Forms and CSRF #

Every <form method="post"> (also put and delete, or a method given by a {{ }}) gets a hidden _csrf input, written by dhtml when the page is compiled. A form with no action, or an action that starts with a single /, qualifies; a form that posts to another site never gets a token. dhtml reads the form the way a browser does: the first of two repeated attributes wins, character references are decoded, and tabs and line breaks inside an address are dropped. A formaction that leaves the site inside a form that carries a token is a compile error. A page without such a form sets no cookie. The token is checked on every non-GET action, together with the Origin and Sec-Fetch-Site headers. A request without a valid token answers 403. A token in the X-CSRF-Token header is judged before the body is read. In a multipart form the _csrf field must be the first part (the hidden input dhtml writes is the first thing in the form, so browsers send it first; a script building a FormData should append it first or use the header), and it is judged before any other part is read, so a client without a token cannot make the server store files. A urlencoded body (at most the global limit, and it cannot hold files) is read whole before its token is checked. A form that arrives with the token in neither place is refused.

The token is an HMAC of a random cookie dhtml_csrf (HttpOnly, SameSite=Lax, Secure on HTTPS), under a key kept in server/data/secret.key (created on first use, never served). Nothing is stored per token. A token is tied to the visitor's cookie: a token from another visitor, or one from before the cookie changed, does not work (login and logout will change it once they exist). Every page view shows a different token string for the same cookie (the signature is masked with a random pad), so a compressed HTTPS page cannot leak it by its size (BREACH); all of them are accepted. A page that shows a token or a flash message is sent with Cache-Control: private, no-store and Vary: Cookie.

For htmx: include /_dhtml/htmx.js and then /_dhtml/dhtml.js (both served by dhtml itself, so the closed CSP allows them). dhtml.js fetches the visitor's token once from /_dhtml/csrf (a page on another site can ask for it but cannot read the answer) and sends it in an X-CSRF-Token header on every htmx request that changes data. The header is checked before the body is read; a token in the URL is never accepted.

html
<script src="/_dhtml/htmx.js"></script>
<script src="/_dhtml/dhtml.js"></script>
<form hx-post="/api/posts" hx-target="#list" hx-swap="beforeend">...</form>

Uploads #

html
<d:validate field="photo" file required types="png,jpg" max="2MB" />

types can only name file types from this list: png, jpg, jpeg, gif, webp, pdf, txt, csv, zip. Files a browser would run (html, svg, js, xml) are never accepted. max is at most 20MB. The form must be enctype="multipart/form-data". A file field is only ever filled by an upload: text sent for it in a urlencoded body or a query string is ignored. An upload is refused when it is empty, too large, has a name with a path or a NUL in it, has an extension that is not in types, or its content does not look like that type. It is saved as server/data/uploads/<32 random hex>.<ext> and never under its own name, so .htaccess, .user.ini, web.config and ../../x cannot happen. form.photo is that stored name; keep it in the database and show it with /uploads/{{ form.photo }}. Uploads are served with a fixed content type (the file's claim is ignored), X-Content-Type-Options: nosniff and a sandboxing CSP; images are shown inline and everything else downloads.

Comments #

<!-- ... --> comments are removed from the output, and nothing inside them is read: no {{ }}, no <d:...>. Use them for notes you do not want visitors to see.

Where tags may go #

<d:...> tags cannot appear inside an HTML tag or an attribute value, or inside the elements whose content is not markup: <script>, <style>, <textarea>, <title>, <iframe>, <noscript>, <xmp>, <noembed> and <noframes>. A trailing slash does not close them (<script /> still waits for </script>, as in HTML). Logic that builds markup or code from template tags is a common source of injection. Put the <d:if> around the whole element instead, and use {{ }} for values (which is allowed in all those places).

An HTML tag or quote that is never closed is an error (an HTML tag is never closed), as is an HTML comment (<!-- without -->) or a <d:...> tag that is never closed. A broken attribute is also an error, with its line: a stray = or quote where a name should be, a quote inside an unquoted value, or name= with no value. A { directly before a {{ }} is an error too ({{{): put a space between them.

Scripts and the CSP #

dhtml answers every page with a closed Content-Security-Policy (default-src 'self'). It does not allow inline <script> or onclick="...", and dhtml will not add 'unsafe-inline' for you. Put the code in a file under public/ and hand it data through data-* attributes, which are escaped like any attribute:

html
<button id="like" data-post="{{ post.id }}" data-likes="{{ post.likes }}">Like</button>
<script src="/js/like.js"></script>
js
// public/js/like.js
const b = document.getElementById("like");
b.addEventListener("click", () => fetch("/api/like/" + b.dataset.post, { method: "POST" }));

For a whole object, use <div id="data" data-post="{{ post | json }}"> and JSON.parse(el.dataset.post). The <script>var x = {{ v }};</script> form above still escapes correctly; it only runs if you loosen the CSP yourself.

Accounts #

dhtml keeps accounts, sessions and the one-time links for you (decision D19). Nothing here is written in SQL, and no password or token ever appears in your templates. The tables (_dhtml_users, _dhtml_sessions, _dhtml_tokens) are made by the migrations; your queries may read them but not the password hash or token columns.

Showing things to the right visitor is done with <d:auth> and <d:guest>, in pages, layouts, partials and responses. Both take an <d:else> like <d:if>, and <d:auth admin> is for admins only:

html
<d:auth>Hello {{ user.name | default user.email }}</d:auth>
<d:guest><a href="/login">Sign in</a></d:guest>
<d:auth admin><a href="/admin">Admin</a></d:auth>

user is what these read. A page that never uses user does not look at the session at all.

Account steps go in an action, like <d:exec>. Each reads form fields by name; the names default to the ones shown, and an attribute names another declared field. All of them need method="POST" (the default), a ratelimit, and <d:validate> for each field they read.

html
<d:action ratelimit="10/minute">
  <d:validate field="email" required email />
  <d:validate field="password" required />
  <d:validate field="next" />
  <d:login />                                  <!-- email="email" password="password" -->
  <d:redirect next="/home" flash="Welcome back" />
  <d:invalid><p>{{ errors.form }}</p></d:invalid>
</d:action>
StepDoesFields it reads
<d:login />Checks the address and password and starts a new session.email, password
<d:logout />Ends this session.none
<d:register />Starts a sign-up (see below). It takes no password: anyone can type someone else's address.email, and name (when a field of that name is declared, or name="..." names another)
<d:confirm />Makes the account from the emailed link, with the password its owner chooses now.token, password
<d:password current="old" password="new" />Changes the password of the signed-in visitor (the action needs auth) and signs them out everywhere else.current, password
<d:forgot />Mails a reset link, if the address has an account.email
<d:reset />Sets a new password from the emailed link and signs the account out everywhere.token, password
<d:magic />Mails a sign-in link, if the address has an account (see Magic links). Needs [auth] magic_login = on.email
<d:magiclogin />Uses the emailed sign-in link and starts a session.token
<d:ban />Suspends an account (see Banning). The action must be admin.id, and reason when a field of that name is declared
<d:unban />Lifts a ban. The action must be admin.id

When a step fails in a way the visitor can fix (a wrong password, an expired link, a password that is too short), the action ends like failed validation: status 422, nothing is saved, and <d:invalid> is drawn with the message in errors.form. Too many attempts answer 429 with Retry-After. A password field is always empty in form, so a form can be drawn again without it, and a step of your own cannot read it (that is a compile error).

An action may do one account step on any path, and it counts as a write: it runs in the action's transaction, so a model call after it is refused like one after <d:exec>.

Going back to where the visitor was heading. <d:redirect next="/home" /> goes to the next value the visitor came with, if it is a path on this site (a declared next field, or ?next= on the address), and to /home otherwise. A protected page sends visitors to the login page with ?next= already set; put it in a hidden input:

html
<form method="post" action="/api/login">
  <input type="hidden" name="next" value="{{ query.next }}">
  <input name="email" type="email"> <input name="password" type="password">
  <button>Sign in</button>
</form>

Signing up is off unless [auth] registration = on (it needs [mail] and url in [app], see config). <d:register /> always gives the same answer, so write a page that says "we sent a link" whatever happens; the mail says whether the address was new. The account exists only after the link is used, and its password is chosen then, by the owner of the address (a stranger who typed it cannot set it). The link opens confirm_page (default /auth/confirm) with ?token=, a page of yours with a field for the password and a button that posts both to an action with <d:confirm />; links are never used by simply opening them, since mail scanners open links. <d:reset /> works the same way with reset_page and a form asking for the new password.

html
<d:action ratelimit="5/hour">
  <d:validate field="email" required email />
  <d:validate field="name" max="100" />
  <d:register />
  <d:respond><p>We sent a link to {{ form.email }}. Open it to finish.</p></d:respond>
</d:action>

<d:action ratelimit="5/hour">
  <d:validate field="token" required />
  <d:validate field="password" required min="10" />
  <d:confirm />
  <d:redirect to="/login" flash="Address confirmed. You can sign in." />
</d:action>

<d:action ratelimit="5/hour">
  <d:validate field="email" required email />
  <d:forgot />
  <d:respond><p>If that address has an account, we sent a link.</p></d:respond>
</d:action>

<d:action ratelimit="5/hour">
  <d:validate field="token" required />
  <d:validate field="password" required min="10" />
  <d:reset />
  <d:redirect to="/login" flash="Password changed" />
</d:action>

<d:action auth ratelimit="5/hour">
  <d:validate field="old" required />
  <d:validate field="new" required min="10" />
  <d:password current="old" password="new" />
  <d:redirect to="/account" flash="Password changed" />
</d:action>

<d:action ratelimit="10/minute">
  <d:logout />
  <d:redirect to="/" />
</d:action>

With [auth] magic_login = on (it needs [mail] and url in [app]), a visitor can sign in without a password: they type their address, get an email, and press a button. It follows the same rules as "forgot password", because whoever controls the mailbox can already do that.

html
<d:action ratelimit="5/hour">
  <d:validate field="email" required email />
  <d:magic />
  <d:respond><p>If that address has an account, we sent a sign-in link.</p></d:respond>
</d:action>

<d:action ratelimit="10/minute">
  <d:validate field="token" required />
  <d:magiclogin />
  <d:redirect next="/app" flash="Welcome back" />
</d:action>
  • The same answer for every address. <d:magic /> writes the same rows and gives the same answer whether or not the address has an account, so write a page that says "we sent a link if there is an account". Mail goes out after the response.
  • A click, not a GET. The link in the mail opens magic_page (default /auth/magic) with ?token=. That page is yours: it shows a button that posts the token to the action with <d:magiclogin />. Opening the link never signs anyone in, because mail scanners and link previews open links.
  • Short and single use. The link works once, for 15 minutes. A new request for the same address replaces the old link. A sign-in with the link, a password change and a password reset end the account's other open links. A sign-in link cannot be used as a confirm or reset link, and the other way round.
  • Existing accounts only. Nothing creates an account: use <d:register />. The sign-in starts a new session and ends the visitor's old one, like <d:login />. After it, an action can only respond (no model call, no form), as after a login.
  • A wrong, used or expired link ends the action like failed validation: 422 and errors.form says to ask for a new one. Decision D28 has the reasons.

Sign in with Google #

With [oauth] set up (see config), dhtml serves two paths itself and no tag is needed. A page links to /_dhtml/oauth/google (add ?next=/some/page to land somewhere else) and reads {{ config.oauth.google }} to know whether to show the link:

html
<d:if test="config.oauth.google"><a href="/_dhtml/oauth/google">Continue with Google</a></d:if>
  • What is checked. The code flow with PKCE and a random state kept in a signed, short-lived cookie. A callback without the cookie, with another state, with a changed cookie or a second time does nothing. Google's own words are never shown or logged.
  • Who gets in. Only an address Google has verified. A person already linked signs in as that account; an account with the same address is linked; with [auth] registration = on a new account is made (without a password anyone could know: "forgot password" lets them choose one). A suspended account is refused.
  • After it. A new session starts and the old one ends, like <d:login />. The runtime sends the person to next (a local path only) or to after in [oauth]. The credits, flash messages or welcome emails your password sign-in gives are not given by this path: point after at a page of yours that does it (LaunchSaaS has /app/welcome).
  • The migrations make the table _dhtml_oauth that remembers the link; it goes with the account. The account log has oauth_login, oauth_register, oauth_failed (the detail says why in one word) and oauth_banned. Decision D41 has the reasons.

Claiming the owner account #

A site on dhtml.net has no command line, so an app with registration = off needs another way to get its first admin. The owner of the site proves they own the mailbox, the way a password reset does:

  1. In the Secrets of the site, set ADMIN_EMAIL to the owner's address.
  2. In dhtml.config, write [auth] admin_email = env:ADMIN_EMAIL (it needs [mail] and url in [app]; while the site is in preview the mail is written to the site's log, which the panel shows).
  3. Upload, then open /_dhtml/claim on the site and type the address. A link is mailed to that address, whatever was typed: the page answers the same for every address, so it cannot be used to find out who the owner is.
  4. The link works once and for one hour. It asks for a password (at least min_password characters) and makes the admin account, or makes the existing account of that address an admin with the new password, ends its other sessions and API keys, and signs the owner in.

The page stays after that: it is the owner's way back when the password is lost. It is limited to a few tries per visitor and three mails an hour for the whole site, and it writes claim_requested, claim_done and claim_failed to the account log. A banned account is not claimed back. {{ config.admin_claim }} is true when it is set up.

Banning an account #

<d:ban /> and <d:unban /> go in an action that is admin. A banned account cannot sign in (the right password says "suspended"; a wrong one still gets the usual answer), its open sessions and API keys stop working at once, and no sign-in link is sent to it. An admin cannot ban themselves or another admin.

html
<d:action admin ratelimit="20/minute">
  <d:validate field="id" required number />
  <d:validate field="reason" max="200" />
  <d:ban />
  <d:redirect to="/admin/users" flash="Suspended." />
</d:action>

<d:action admin ratelimit="20/minute">
  <d:validate field="id" required number />
  <d:unban />
  <d:redirect to="/admin/users" flash="Restored." />
</d:action>

The table _dhtml_users has the columns banned_at (a time, empty when not banned) and ban_reason, which a page can list.

The account log. _dhtml_auth_log has one row for every account event: at (a Unix time), kind (login, login_failed, login_banned, logout, register, confirm, confirm_failed, forgot, reset, reset_failed, password, password_failed, magic_asked, magic_login, magic_failed, oauth_login, oauth_register, oauth_failed, oauth_banned, ban, unban), user_id (when the account is known), email (the address typed), addr (the client address) and detail (who banned, and why). A page can list it for an admin, as with the users table; an action cannot write it. It is written after the response, so failed sign-ins are in it, throttled attempts are not, and rows older than 90 days are removed. It holds personal data: say so in your privacy text.

Protecting pages. Name them in [protect] in dhtml.config instead of checking in every page (see config).

Languages #

An app in more than one language keeps the words in one file per language and the pages once (decision D58). A page is the same page in every language: the same markup, the same forms, the same behaviour.

ini
[lang]
default   = en
available = en, nl
detect    = path, cookie, accept

server/lang/en.lang and server/lang/nl.lang: one text per line, key = text. A key is lower-case letters, digits, dots, hyphens and underscores. # starts a comment line. A long text is a key with nothing after =, then lines indented by two spaces. {1} to {4} are places for values, a literal opening brace is written {{, and the reserved key @name is the language's own name.

text
@name = Nederlands
nav.home = Home
cart.items = Je hebt {1} artikelen
terms =
  ## Voorwaarden

  Tekst met **opmaak**.
html
<nav><a href="{{ lang.prefix }}/">{{ "nav.home" | t }}</a></nav>
<p>{{ "cart.items" | t cart.count }}</p>
{{ "terms" | t | markdown }}
<d:for each="alt in lang.alternates">
  <a href="{{ alt.url }}" hreflang="{{ alt.code }}">{{ alt.name }}</a>
</d:for>
  • Escaping: the result of t is text, escaped like every value, and so is a value placed in {1}. A language file cannot put markup into a page; the one door is | markdown, which is sanitized (it cannot output a script).
  • Keys built by a page work: <d:set as="k" value="status.{{ order.state }}" /> then {{ k | t }}. The check cannot check such a key.
  • Falling back: a key a language lacks shows the default language's text. A key that no language has is an error while you develop (the page says which key, in which file and line), and in production the page shows the key itself and the server logs a line, so a missing word never takes a page down.
  • Choosing the language: detect lists the ways, tried in order, the first that decides wins. path: /nl/about serves about in Dutch (the default language has no prefix, so /en/about is a 404); host: nl.example.com or example.nl; cookie: a link with ?lang=nl sets the cookie lang (HttpOnly, one year) and the choice is kept; accept: the browser's Accept-Language. A response that depends on a header is sent with the matching Vary.
  • Where t works: in pages, layouts, partials and components, and in the subject, text and HTML template of a <d:mail>, where it is written out and takes the language of the recipient (Mail in the recipient's language). Not in the other steps of an action or a job.
  • The check on every upload reads every language file and every t in the templates: a key a template names that the default language lacks is an error with the line and a "did you mean"; a key another language lacks is a warning (an error with missing = error); a key with different {n} places in two languages is an error; a key no template uses and a file for a language that is not in available are warnings; t without [lang] is an error.

Errors #

Every error names the file and line of what you wrote, never generated code:

text
server/pages/blog.dhtml line 7
  unknown variable "pots". Did you mean "post"?

Errors in a layout or partial name that file. At most 20 are reported per upload.

Limits #

LimitValue
Template file size1 MiB
Tag nesting depth100
Layout and partial nesting20
Output of one page16 MiB
Markdown input256 KiB

Fast navigation #

Set navigation = fast under [app] in dhtml.config and links and forms stop reloading the page. Clicking a link fetches the next page and swaps only what changed, the way a single-page app does, but every page is still a plain server-rendered page with its own URL. Without JavaScript, or with the setting off (the default), the site works exactly as before.

It works for pages that use one <d:layout>. When the next page has the same layout, only the part that comes from the page itself (the slot) is swapped and the layout stays. When it has another layout, or the visitor has just signed in or out, the whole page is swapped in, still without a reload. Only when the new page needs a script the current one lacks, or the server cannot be sure, does the browser do an ordinary page load.

Parts of the layout that depend on the page, such as a menu that marks the current item or the flash message, are marked with data-dhtml-region. They are replaced with each navigation:

html
<nav data-dhtml-region="menu">…</nav>
<div data-dhtml-region="flash">{{ flash }}</div>
<main><d:slot /></main>

A region is one element with a closing tag (not p, li or another element whose end tag can be left out), with a name of lower case letters, digits, - and _, used once per layout, and it never contains the slot. A layout that breaks these rules is simply sent whole.

What the router does:

  • Takes over left clicks on links to this site. It leaves alone modified clicks (ctrl, shift, alt, meta), links with target or download, other sites, /_dhtml/, and links that only add a #fragment to the current page. Put data-nav="off" on a link, a form or any parent to opt out, and data-nav="on" to opt back in.
  • Submits forms with fetch. A redirect after a successful submit shows the page it leads to. An answer that is a page (for example a form with errors, status 422) replaces the page and the address stays. A form is sent once: if the request fails, the form stays as it is and a dhtml:nav-error event fires on document. Forms with file uploads, data-stream-target or hx- attributes are not touched.
  • Keeps the back button, scroll position and #fragment working, moves focus to the first [autofocus] or <h1> of the new content, and announces the new title to screen readers.
  • Starts loading a page when the pointer rests on a link for 65 ms, or on touch (not with the browser's data saver on; data-prefetch="off" on a link turns it off). This is safe because a dhtml page cannot change anything when it is read (GET actions cannot write), but a page's own <d:query> still runs, so keep data-prefetch="off" on links to very heavy pages. A prefetched answer is used only for the same layout and the same signed-in state.
  • Never shows a page from another site: a redirect that leaves the site is followed by the browser, as without the router. Stylesheets from other sites are not added when a page is swapped in.
  • Cross-fades with the browser's View Transitions, unless the visitor asks for reduced motion. Shows a thin #dhtml-progress bar for requests slower than 150 ms; set --dhtml-progress to change its colour.
  • Does not run scripts inside the swapped content. Page code that must run belongs in a file loaded by the layout, or listens for the dhtml:navigated event on document.

How it talks to the server, for anyone debugging: with the setting on, the full page gets <script src="/_dhtml/nav.js" data-layout="NAME" data-auth="TOKEN" defer> before </body>, and the slot is wrapped in the comments <!--dhtml-slot--> and <!--/dhtml-slot-->. The script asks with the headers X-Dhtml-Nav (the layout it shows) and X-Dhtml-Auth (a short hash of the session cookie, 0 for none). If both match, the answer has the header X-Dhtml-Nav: slot and holds the slot and the regions in <template> elements, with the title in X-Dhtml-Title (percent-encoded). The responses carry Vary: X-Dhtml-Nav, X-Dhtml-Auth, so a cache never mixes the two forms.

Compiling #

Every template is compiled when the site is uploaded, and a request only runs the compiled page. Actions are compiled each time they are needed.