dhtml

Start · docs/agent-guide.md

dhtml: guide for AI agents and for people in a hurry #

Read this first. It is short on purpose and covers what most apps need. The full references are syntax.md, templates.md and 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.

Files #

WhereWhat
server/pages/**/*.dhtmlGET pages. The path is the URL: blog/[slug].dhtml is /blog/:slug, read as {{ route.slug }}
server/api/*.dhtmlActions (forms, JSON). server/api/save.dhtml is POST /api/save
server/layouts/ partials/ components/ steps/Shell with <d:slot />; fragments (<d:include src="x" />); pieces with inputs (<d:props a b="default" />, <d:component src="x" a="..">...</d:component>, read as props.a); steps (<d:use src="x" />: queries, checks and guards written once, used in pages, actions and jobs)
server/jobs/*.dhtmlWork later or by the clock
server/migrations/NNN_name.sqlPlain SQL, applied in order. Put sample/seed rows here
public/Static files (CSS, images, JS). Served as they are
dhtml.configSettings (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" }}
<d:if test="post.likes > 10 and not post.hidden"> ... <d:else> ... </d:if>
<d:for each="p in posts"> {{ p.title }} <d:empty> Nothing yet. </d:for>
<d:auth> signed in <d:else> not </d:auth>   <d:auth admin> admins </d:auth>   <d:guest> visitors </d:guest>
<d:status code="404" />
  • 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.
  • <d:...> 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
<d:if test="s.id == form.service"><option value="{{ s.id }}" selected>{{ s.name }}</option><d:else><option value="{{ s.id }}">{{ s.name }}</option></d:if>

Pages read data #

html
<d:query as="posts"> SELECT id, title, slug FROM posts WHERE published = 1 ORDER BY id 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:layout src="main" title="{{ post.title }}"> <h1>{{ post.title }}</h1> </d:layout>
  • 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 <d:use src="name" /> where it applies (page top level, or among the steps of an action after <d:validate>). A steps file holds only steps, reads the same variables as its user, and can end the request with <d:status code="403" /> or <d:redirect>.
  • Queries go at the top level of a page (not inside <d:if>, <d:for>, a layout or a partial). In an action, queries, writes and mails may sit inside <d:if>. 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 <d:layout src="main" space="{{ settings.name }}">.

Actions write data #

html
<!-- server/api/post-save.dhtml -->
<d:action method="POST" auth ratelimit="20/minute">
  <d:validate field="title" required max="120" />        <!-- ALL validates first; only declared fields exist -->
  <d:validate field="body" max="20000" />
  <d:exec as="saved"> INSERT INTO posts (title, body, user_id) VALUES ({{ form.title }}, {{ form.body }}, {{ user.id }}) </d:exec>
  <d:redirect to="/post/{{ saved.id }}" flash="Saved" />
  <d:invalid> <p>{{ errors.title }}</p> </d:invalid>      <!-- status 422, shown when validation fails; HTML only, no <d:redirect> -->
</d:action>
  • Forms: <form method="post" action="/api/post-save">. 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: <d:respond> (HTML), <d:json>, <d:redirect to="/path" flash=".." />, <d:status code="403" />. A redirect to starts with /. There is no custom status for a redirect, so "slot taken" is a redirect with a flash.
  • <d:exec> 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, <d:status> 400+, or invalid input.
  • Filters do not work in SQL. Compute first: <d:set as="cents" value="{{ form.price | mul 100 | round }}" />, then {{ cents }}.
  • Uploads: <d:validate field="photo" file types="png,jpg" max="2MB" /> with enctype="multipart/form-data"; form.photo is a stored name, shown at /uploads/{{ form.photo }}.
  • Do not call a model (<d:ai>) or <d:http> 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 <d:exec> and <d:query> may not contain comments; explain it outside:

html
<d:set as="slot" value="{{ form.slot | add 0 }}" />          <!-- a number, from the text a form sends -->
<d:exec as="saved">
  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 }})
</d:exec>
<d:if test="saved.changes == 1"> <d:redirect to="/thanks" /> <d:else> <d:redirect to="/book" flash="That time is taken." /> </d:if>

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/<code>.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 UPDATEs it.

Login, logout, protected pages.

html
<d:action ratelimit="10/minute">  <!-- server/api/login.dhtml -->
  <d:validate field="email" required email /> <d:validate field="password" required /> <d:validate field="next" />
  <d:login /> <d:redirect next="/admin" flash="Welcome back" />
  <d:invalid> <p class="error">{{ errors.form }}</p> </d:invalid>
</d:action>

<d:logout /> 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. Accounts are made by <d:register /> (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 <form method="post" action="/api/item-toggle"><input type="hidden" name="id" value="{{ i.id }}"><button>Hide</button></form> per row, each with its own action; declare id with <d:validate field="id" required number />.

Tell the owner. <d:notify subject="New order from {{ form.name }}" fields="name, email, message" reply="email" /> 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 <d:mail to="email" subject="Your booking on {{ form.date }}" fields="date">Thank you, {{ form.name }}.</d:mail>: 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 <d:set> 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 <d:query as="next" one> (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: <d:mail for="b in due" max="50" to="b.email" subject="...">text with {{ b.title }}</d:mail> (max 1 to 100 is required; mark the rows done in a <d:exec> 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 <d:mail to="email" subject="..." template="name" />: 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: <d:query as="shop" one>SELECT * FROM shops WHERE host = {{ request.host }}</d:query> and <d:if test="not shop"><d:status code="404" /></d:if>; 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 <d:job retries="3" timeout="30">; steps are the same as an action's. An action queues it with <d:enqueue job="x" arg="{{ id }}" />; a job with every="1d" at="03:30" runs by the clock.

Another service. <d:http as="r" method="POST" url="https://api.example.com/v1/x" bearer="env:KEY"><d:field name="a" value="{{ v }}" /></d:http>; 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. <d:ai as="answer"><d:system>..</d:system><d:user>{{ form.q }}</d:user></d:ai> 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 <d:notify /> goes
[protect]
/admin = admin
/admin/* = admin
[http]
allow = api.stripe.com            # the only hosts <d:http> 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)
<d:validate> must come before the stepsMove all <d:validate> to the top of the action
the form field "x" is not declaredAdd <d:validate field="x" /> before using form.x
a GET action cannot writeUse method="POST" for anything with <d:exec>
{{ }} after FROM/ORDER BY/... is a compile errorA value cannot be a name. Write the name in SQL; choose between a few with separate queries in <d:if>
tag inside an HTML tag or attributePut the <d:if> around the whole element
a model call / http call after a database stepMake the call first, then the writes
needs a ratelimitAdd ratelimit="10/minute"
[protect] sends visitors to /login, but no page answers thereCreate server/pages/login.dhtml
step name used twice / two columns with one nameNames (as=) must be unique in a file; use AS in SQL
account tables do not existUpload 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 <script> and onclick. Put JavaScript in public/ and pass data with data-* attributes.
  • Show every value through {{ }}; to show user text with paragraphs use {{ text | markdown }}.
  • Keep secrets in the Secrets section of your site on dhtml.net, never in a page or the config. Keep names (tables, columns, routes) fixed text.
  • Prefer a starter over starting from nothing when the app is close to a blog (launchblog) or an AI product with accounts, credits and Stripe (launchsaas): download its zip from the examples page on dhtml.org, change the files and upload it to your site.
  • When something is not in these docs, it does not exist. Say what you could not build instead of working around the language.