# dhtml syntax (draft spec) This is the source of truth for the dhtml language. Implementations and agents must not invent tags, attributes, or filters that aren't listed here. A change needs a written proposal first (see CONTRIBUTING.md in the repository). Status: **draft**, frozen at 1.0. ## Files | Location | Purpose | Routed | |---|---|---| | `server/pages/**/*.dhtml` | GET pages, return full HTML | Yes, path = URL | | `server/api/**/*.dhtml` | Actions, start with `` | Yes, `/api/...` | | `server/jobs/*.dhtml` | Jobs, start with `` | No, run by the queue | | `server/layouts/*.dhtml` | Page shells with `` | No | | `server/partials/*.dhtml` | Reusable fragments | No | | `server/components/*.dhtml` | Reusable pieces with named inputs (``), used with `` | No | | `server/steps/*.dhtml` | Reusable steps (queries, checks, guards), used with `` | No | | `server/mails/*.dhtml` | HTML mail templates, used with `` | No | | `server/migrations/NNN_name.sql` | Plain SQL migrations | No | Routing rules: - `pages/index.dhtml` → `/` - `pages/about.dhtml` → `/about` - `pages/blog/index.dhtml` → `/blog` - `pages/blog/[slug].dhtml` → `/blog/:slug`, available as `{{ route.slug }}` - Static segments win over parameters: `/blog/new` matches `blog/new.dhtml` before `blog/[slug].dhtml`. - `[routes]` in `dhtml.config` overrides file-based routes. - With `routing = explicit`, only routes listed in `[routes]` are served. ## Values ```html {{ post.title }} {{ post.created | date }} {{ post.title | truncate 60 }} {{ user.name | default "Guest" }} ``` - In HTML: always escaped for the context (text, attribute, URL, script, style). - Inside `` and ``: always a bound SQL parameter. Values can never be used as table or column names. - No function calls, no arithmetic, no assignment. ### Data available in templates | Name | Contents | |---|---| | `app` | `name`, `version` (e.g. `1.2.0.44`) | | `route` | URL parameters | | `query` | URL query string values | | `form` | Submitted form values (actions only) | | `user` | Signed-in visitor (`id`, `email`, `name`, `admin`), or empty | | `page` | Values passed to the layout (e.g. `title`) | | `errors` | Validation errors (in ``) | | `config` | Safe subset of `dhtml.config`, never secrets | | `lang` | The language of the request: `lang.code`, `lang.default`, `lang.name`, `lang.prefix`, `lang.alternates` (D58). Empty without `[lang]` | | `request` | `request.host`: the name the visitor used, lower case, no port, empty if not a valid host name. For choosing a tenant by name; pages and actions that read it are sent `Vary: Host` | ### Filters `date` (optionally with a layout in quotes: `date "02 Jan 2006"`), `upper`, `lower`, `default`, `truncate`, `slug`, `markdown` (sanitized HTML), `json`, `first`, `count`, and for numbers `add`, `sub`, `mul`, `div`, `round`, `fixed N` (exactly N decimals) and `thousands` (groups by threes). In a page, with a [`[lang]`](config.md#lang-optional) section: `t` (`{{ "nav.home" | t }}`, values fill `{1}` to `{4}`: `{{ "cart.items" | t cart.n }}`), the text of a key in the language of the request. To sign in the steps of an action or a job: `hmac "env:NAME"` (hex), `hmac_base64 "env:NAME"` and `hmac_base64url "env:NAME"`: the HMAC-SHA256 of the text with the key in the variable NAME. Never in a page. There is no `raw` filter at 1.0. ## Page tags ```html ... ... ... ... ... ... shown to signed-in visitors shown to the others shown to admins shown to visitors who are not signed in ``` `test` supports: a value, `not`, `and`, `or`, and comparisons `== != > < >= <=`. ## Data tags ```html SELECT id, title FROM posts ORDER BY created DESC LIMIT 20 SELECT * FROM posts WHERE slug = {{ route.slug }} ``` `one` returns a single row or empty. Each `{{ }}` in the SQL is a bound parameter. The rules (read only, no names from values, where a query may go) are in [templates.md](templates.md#dquery). ## Actions Files in `server/api/` start with ``. Steps run top to bottom. ```html INSERT INTO posts (title, user_id) VALUES ({{ form.title }}, {{ user.id }}) HTML fragment for htmx shown when validation fails ``` ### `` attributes | Attribute | Meaning | |---|---| | `method` | `GET`, `POST`, `PUT`, `DELETE` | | `auth` | Requires a logged-in user | | `admin` | Requires an admin user | | `ratelimit` | e.g. `10/minute`, `100/hour`, per IP | ### Validation ```html ``` ### Responses (exactly one per path) ```html ... {{ posts }} ``` ### Computing and calling other services ```html ``` Filters `add`, `sub`, `mul`, `div`, `round`, `sha256`, `base64`, `urlencode` do the arithmetic and encoding. `` calls only hosts listed in `[http]` in `dhtml.config`, with keys from the Secrets section of your site, and builds its body from fields so a value can never become syntax. Details in [templates.md](templates.md#dhttp). ### Accounts ```html ``` Each is a step that reads declared form fields by name (never `{{ }}`), is allowed only in a non-GET action with a `ratelimit`, and ends the action like failed validation when the visitor has to fix something (message in `errors.form`). The rules, the mail flow and the pages you write are in [templates.md](templates.md#accounts); the settings are `[auth]`, `[mail]` and `[protect]` in [config.md](config.md). ### Jobs Work that runs later, or by the clock, lives in `server/jobs/NAME.dhtml` and starts with ``. An action queues it with ``. ```html SELECT email FROM orders WHERE id = {{ args.order }} DELETE FROM drafts WHERE created_at < date('now', '-30 days') ``` | Tag or attribute | Meaning | |---|---| | `every` | `30m`, `6h`, `1d` (1 minute to 30 days): the job starts itself and takes no arguments | | `at` | `HH:MM` in UTC, with `every="1d"` | | `retries` | 0 to 10, default 3: more tries after a failure, with growing waits | | `timeout` | seconds, 1 to 600, default 60 | | `` | A declared argument, with the rules of ``; read as `args.x`. Also `job.name`, `job.attempt` | | `` | A step of an action: queue the job in the action's transaction, with arguments as text | A job has the steps of an action and no response. The site runs it by itself, one job at a time. Details in [templates.md](templates.md#jobs). CSRF protection is automatic for every non-GET action. The full rules (what each tag does, the order things happen in, uploads, flash messages) are in [templates.md](templates.md#actions). ## AI ```html Answer questions about this post. Be brief. {{ post.title }}: {{ post.body }} {{ form.question }}
{{ answer }}
``` - Provider, model, and key come from `dhtml.config`. `model`, `max_tokens` and `timeout` attributes override them for one call. - `` is only allowed in an action that is not `GET` and has a `ratelimit`. - `` (without `as`) is itself the response: the action answers with server-sent events as the model writes, and `dhtml.js` shows them in the element named by `data-stream-target` on the form. It replaces the `as` plus `` pair, since the answer cannot be rendered before it exists. - Model output is untrusted and always escaped. Details, limits and the error rules are in [templates.md](templates.md#ai-calls) and decision D18. ## Styling dhtml has no styling syntax of its own (decision D25). Link any stylesheet from `public/` in your layout; the starters use Tailwind's standalone CLI and commit the generated file. ## Example `dhtml.config` ```ini [app] name = myblog version = 1.0.0 [server] port = 3000 routing = files # or: explicit [ai] provider = anthropic model = claude-sonnet-5-5 key = env:ANTHROPIC_API_KEY [backups] keep_daily = 7 keep_weekly = 4 [routes] /login = pages/auth/login.dhtml /old-blog = redirect /blog /health = text OK [features] shell = off # never on a hosted site ``` Every key is documented in [config.md](config.md).