# dhtml: guide for AI agents and for people in a hurry Read this first. It is short on purpose and covers what most apps need. The full references are [syntax.md](syntax.md), [templates.md](templates.md) and [config.md](config.md). **Never invent a tag, attribute or filter that is not listed there**: the check that runs on every upload will refuse it, and the error names the file and line. ## The loop dhtml sites run on dhtml.net. There is nothing to install and no local server: you write the files of the project, zip them and upload them to your site. ```text # write dhtml.config, server/migrations/001_*.sql, pages, actions, layouts, public/ # zip the CONTENTS of the project folder (dhtml.config at the top of the zip; no .env, no server/data) # upload the zip: the Deploy page of your site on dhtml.net, or the deploy API # read the result; fix every error, read every hint, upload again ``` Every upload is checked before it goes live: templates, routes, config and migrations are compiled, the database is backed up, and migrations are applied. A check that fails says the file, the line and a hint, and **the live version of the site is left as it was**. Upload after every few files, not at the end. Secrets (API keys, passwords) are not in the zip: they are set in the Secrets section of the site's page on dhtml.net and are named `env:NAME` in `dhtml.config`. To deploy from a script or an AI assistant, use a deploy key: see [dhtml.net/ai/deploy-api.txt](https://dhtml.net/ai/deploy-api.txt). ## Files | Where | What | |---|---| | `server/pages/**/*.dhtml` | GET pages. The path is the URL: `blog/[slug].dhtml` is `/blog/:slug`, read as `{{ route.slug }}` | | `server/api/*.dhtml` | Actions (forms, JSON). `server/api/save.dhtml` is `POST /api/save` | | `server/layouts/` `partials/` `components/` `steps/` | Shell with ``; fragments (``); pieces with inputs (``, `...`, read as `props.a`); **steps** (``: queries, checks and guards written once, used in pages, actions and jobs) | | `server/jobs/*.dhtml` | Work later or by the clock | | `server/migrations/NNN_name.sql` | Plain SQL, applied in order. Put sample/seed rows here | | `public/` | Static files (CSS, images, JS). Served as they are | | `dhtml.config` | Settings (INI). Secrets are set in the Secrets section of the site on dhtml.net and are named `env:NAME` | ## Values and logic ```html {{ post.title }} {{ post.created | date }} {{ body | truncate 80 }} {{ user.name | default "Guest" }} ... ... {{ p.title }} Nothing yet. signed in not admins visitors ``` - Every `{{ }}` is escaped for where it lands. There is no raw output. `markdown` and `json` are the only filters that make HTML or script, and they must be last. - Filters: `date upper lower default truncate slug markdown json first count add sub mul div round fixed thousands sha256 base64 urlencode`. Prices: `{{ cents | div 100 | fixed 2 }}` shows `12.50`; `{{ total | fixed 2 | thousands }}` shows `1,234,567.50`; `{{ d | date "02 Jan 2006" }}` takes a layout in quotes. **A `test` cannot use a filter** (`test="rows | count"` is an error): test the value itself, because an empty list, empty text, `0` and nothing are all false (`test="rows"`), or compare with `== != < > <= >=`. Also `not`, `and`, `or`; no parentheses. **Strings in a test use double quotes**, so write the attribute with single quotes: `test='b.status == "booked"'`. - There is no number or date formatting filter. Format in SQL: `printf('%.2f', price_cents / 100.0)`, `strftime('%d.%m.%Y', date)`. - Built-in names: `app route query form errors user page config csrf flash`. - **`` tags can never sit inside an HTML tag or attribute.** So there is no "selected" helper: write two whole elements, one in each branch: ```html ``` ## Pages read data ```html SELECT id, title, slug FROM posts WHERE published = 1 ORDER BY id DESC LIMIT 20 SELECT * FROM posts WHERE slug = {{ route.slug }}

{{ post.title }}

``` - **Do not copy a rule between files.** Put the query that finds the shop, the settings every page shows, or a check like "this person belongs to this shop" in `server/steps/name.dhtml` and write `` where it applies (page top level, or among the steps of an action after ``). A steps file holds only steps, reads the same variables as its user, and can end the request with `` or ``. - Queries go at the **top level of a page** (not inside ``, ``, a layout or a partial). In an **action**, queries, writes and mails may sit inside ``. Pass results to a layout as attributes (`page.title`). - Each `{{ }}` in SQL is a bound value: one plain value, **no filters**, never a table or column name, never after `FROM`/`JOIN`/`ORDER BY`. **An empty value is NULL in SQL, not `''`**: a missing `?date=` makes `{{ query.date }} = ''` match nothing. Write `COALESCE({{ query.date }}, '') = ''` or `{{ query.date }} IS NULL`; The check warns about the comparison with `''`. Build patterns in SQL: `LIKE '%' || {{ query.q }} || '%'`. Use `AS` when two columns share a name. Max 10000 rows. - A layout sees only built-ins and `page.*`, not the page's variables, and cannot run a query: something every page shows (the site name) is queried at the top of each page and passed with ``. ## Actions write data ```html INSERT INTO posts (title, body, user_id) VALUES ({{ form.title }}, {{ form.body }}, {{ user.id }})

{{ errors.title }}

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

{{ errors.form }}

``` `` is the same shape. Protect whole areas in `dhtml.config`, not page by page: `[protect]` then `/admin = admin` and `/admin/* = admin` (`= auth` for signed-in). A login page must exist at `/login`. The first admin of an app without sign-up claims the account: set `ADMIN_EMAIL` in the site's Secrets, write `admin_email = env:ADMIN_EMAIL` under `[auth]`, open `/_dhtml/claim` (a one-time link comes by mail). Accounts are made by `` (needs `[auth] registration = on`, `[mail]`, `url`), or by magic links (`magic_login = on`). Sign-in with Google is `[oauth]` (see config.md). **Admin list with per-row buttons.** One small `` per row, each with its own action; declare `id` with ``. **Tell the owner.** `` mails the one address in `[mail] notify`. Every field must be declared; values print as raw `field: value` lines. To confirm to the customer, use `Thank you, {{ form.name }}.`: `to` is a declared `email` form field (or `user.email`), text and `{{ form.x }}` values only, plain text, and the action needs a `ratelimit`. There is no way to mail any other address; links typed by a visitor are removed. At most 3 mails to one mailbox a day (a `+tag` and Gmail dots count as the same) and `[mail] limit_per_hour` (60) for the site: a mail over a limit is dropped and the visitor is told nothing. The text can use declared form fields and plain results of earlier steps (`{{ saved.id }}`, `{{ w.title }}` of a `one` query, a `` code), so `{{ config.app.url }}/b/{{ code }}` works (`url` is set in `[app]`; never hard-code the address). `to` is an `email` field, `user.email`, or a column of an earlier `` (`to="next.email"`, e.g. the next person on a waitlist). **Mail to many rows and HTML mail.** A job or an admin action can mail every row of an earlier list: `text with {{ b.title }}` (`max` 1 to 100 is required; mark the rows done in a `` of the same job so a retry mails nobody twice; the mails go out after the commit). For a mail that must look like something, write HTML in `server/mails/name.dhtml` and use ``: a short list of tags, values only in the text (and in a link path after `{{ config.app.url }}/`), no script, form or remote image, and a text part made for you. Details: templates.md. **Many shops in one app, by name.** `request.host` is the name the visitor used. At the top of each page: `SELECT * FROM shops WHERE host = {{ request.host }}` and ``; then put `shop.id` in the `WHERE` of every other query and write. A name is not a permission: still use `auth` and check that the person belongs to that shop. Cookies belong to one name, so a login on one shop is not a login on another. **Work later.** `server/jobs/x.dhtml` starts with ``; steps are the same as an action's. An action queues it with ``; a job with `every="1d" at="03:30"` runs by the clock. **Another service.** ``; the host must be in `[http] allow`, the key in `.env`. Read `r.ok`, `r.status`, `r.json.x`. Add `soft` to get `r.ok = false` instead of an error. **A model.** `..{{ form.q }}` in a non-GET action with `ratelimit`. `[ai] provider = fake` needs no key (fixed answer) for development; `anthropic` or `openai` need `key = env:NAME`. Model output is untrusted and escaped. ## Config you will write ```ini [app] name = myapp version = 0.1.0 url = https://myapp.dhtml.net # the address of the site; links in mail are built from it [auth] registration = off # on: sign-up by email link (needs [mail]) [mail] transport = log # mail is written to the log; use smtp for real mail (host, port, username, password = env:X, from) notify = owner@example.com # where goes [protect] /admin = admin /admin/* = admin [http] allow = api.stripe.com # the only hosts may call [routes] /sitemap.xml = api/sitemap.dhtml # map a path to a file; also: redirect /x, text ok, json {"a":1} ``` ## Errors you will meet | Message (shortened) | Fix | |---|---| | `unknown variable "pots". Did you mean "post"?` | Spelling, or the name is not in scope (layouts and components see only built-ins and their inputs) | | ` must come before the steps` | Move all `` to the top of the action | | `the form field "x" is not declared` | Add `` before using `form.x` | | `a GET action cannot write` | Use `method="POST"` for anything with `` | | `{{ }} after FROM/ORDER BY/... is a compile error` | A value cannot be a name. Write the name in SQL; choose between a few with separate queries in `` | | `tag inside an HTML tag or attribute` | Put the `` around the whole element | | `a model call / http call after a database step` | Make the call first, then the writes | | `needs a ratelimit` | Add `ratelimit="10/minute"` | | `[protect] sends visitors to /login, but no page answers there` | Create `server/pages/login.dhtml` | | `step name used twice` / two columns with one name | Names (`as=`) must be unique in a file; use `AS` in SQL | | `account tables do not exist` | Upload again: migrations are applied on every upload, and the account tables are made by them | ## Rules of the house - Link the stylesheet as `/style.css?v={{ app.version }}`: the browser then keeps it for a year, and a new build is a new address. - Plain HTML and CSS in `public/style.css` are enough; the page policy forbids inline `