dhtml

Start · docs/syntax.md

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 #

LocationPurposeRouted
server/pages/**/*.dhtmlGET pages, return full HTMLYes, path = URL
server/api/**/*.dhtmlActions, start with <d:action>Yes, /api/...
server/jobs/*.dhtmlJobs, start with <d:job>No, run by the queue
server/layouts/*.dhtmlPage shells with <d:slot />No
server/partials/*.dhtmlReusable fragmentsNo
server/components/*.dhtmlReusable pieces with named inputs (<d:props>), used with <d:component>No
server/steps/*.dhtmlReusable steps (queries, checks, guards), used with <d:use src="name" />No
server/mails/*.dhtmlHTML mail templates, used with <d:mail template="name" />No
server/migrations/NNN_name.sqlPlain SQL migrationsNo

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 <d:query> and <d:exec>: 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 #

NameContents
appname, version (e.g. 1.2.0.44)
routeURL parameters
queryURL query string values
formSubmitted form values (actions only)
userSigned-in visitor (id, email, name, admin), or empty
pageValues passed to the layout (e.g. title)
errorsValidation errors (in <d:invalid>)
configSafe subset of dhtml.config, never secrets
langThe language of the request: lang.code, lang.default, lang.name, lang.prefix, lang.alternates (D58). Empty without [lang]
requestrequest.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] 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
<d:layout src="main" title="Blog"> ... </d:layout>
<d:slot />                                        <!-- in layouts -->
<d:include src="post-card" />                     <!-- from partials/ -->
<d:use src="shop" />                              <!-- steps from steps/: queries and guards, written once -->
<d:component src="card" title="{{ post.title }}">...</d:component>   <!-- from components/ -->
<d:props title href tone="info" />                <!-- first line of a component -->

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

<d:for each="post in posts"> ... <d:empty> ... </d:for>

<d:auth> shown to signed-in visitors <d:else> shown to the others </d:auth>
<d:auth admin> shown to admins </d:auth>
<d:guest> shown to visitors who are not signed in </d:guest>

<d:status code="404" />                           <!-- stops rendering, shows error page -->

test supports: a value, not, and, or, and comparisons == != > < >= <=.

Data tags #

html
<d:query as="posts">
  SELECT id, title FROM posts ORDER BY created DESC LIMIT 20
</d:query>

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

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.

Actions #

Files in server/api/ start with <d:action>. Steps run top to bottom.

html
<d:action method="POST" auth ratelimit="10/minute">

  <d:validate field="title" required max="120" />

  <d:exec>
    INSERT INTO posts (title, user_id) VALUES ({{ form.title }}, {{ user.id }})
  </d:exec>

  <d:respond> HTML fragment for htmx </d:respond>
  <d:invalid> shown when validation fails </d:invalid>

</d:action>

<d:action> attributes #

AttributeMeaning
methodGET, POST, PUT, DELETE
authRequires a logged-in user
adminRequires an admin user
ratelimite.g. 10/minute, 100/hour, per IP

Validation #

html
<d:validate field="email" required email />
<d:validate field="age" number min="18" max="120" />
<d:validate field="day" required date />   <!-- YYYY-MM-DD, a day that exists -->
<d:validate field="at" time />            <!-- HH:MM -->
<d:validate field="body" required max="20000" />

Responses (exactly one per path) #

html
<d:respond> ... </d:respond>             <!-- HTML fragment -->
<d:json>{{ posts }}</d:json>             <!-- JSON -->
<d:redirect to="/blog" flash="Saved" />  <!-- redirect -->
<d:redirect next="/home" />              <!-- back to where the visitor was heading, else /home -->
<d:status code="403" />                  <!-- error -->

Computing and calling other services #

html
<d:set as="cents" value="{{ form.price | mul 100 | round }}" />   <!-- a value for the steps below -->

<d:http as="intent" method="POST" url="https://api.stripe.com/v1/payment_intents" bearer="env:STRIPE_KEY">
  <d:header name="Idempotency-Key" value="order-{{ route.id }}" />
  <d:field name="amount" value="{{ cents }}" />
</d:http>
<!-- intent.status, intent.ok, intent.json, intent.text, intent.header -->

Filters add, sub, mul, div, round, sha256, base64, urlencode do the arithmetic and encoding. <d:http> 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.

Accounts #

html
<d:login />       <!-- email="email" password="password" -->
<d:logout />
<d:register />    <!-- email, and name when a field called name is declared; no password -->
<d:confirm />     <!-- token="token" password="password", from the emailed link -->
<d:password current="old" password="new" />   <!-- needs an action with auth -->
<d:forgot />      <!-- email="email" -->
<d:reset />       <!-- token="token" password="password" -->

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; the settings are [auth], [mail] and [protect] in config.md.

Jobs #

Work that runs later, or by the clock, lives in server/jobs/NAME.dhtml and starts with <d:job>. An action queues it with <d:enqueue>.

html
<!-- server/jobs/send-receipt.dhtml -->
<d:job retries="3" timeout="30">
  <d:arg name="order" required number />
  <d:query as="o" one>SELECT email FROM orders WHERE id = {{ args.order }}</d:query>
  <d:http as="sent" method="POST" url="https://api.mail.example/v1/send" bearer="env:MAIL_KEY" />
</d:job>

<!-- server/jobs/clean-up.dhtml -->
<d:job every="1d" at="03:30"> <d:exec>DELETE FROM drafts WHERE created_at < date('now', '-30 days')</d:exec> </d:job>

<!-- in an action with a ratelimit -->
<d:enqueue job="send-receipt" order="{{ order.id }}" />
Tag or attributeMeaning
every30m, 6h, 1d (1 minute to 30 days): the job starts itself and takes no arguments
atHH:MM in UTC, with every="1d"
retries0 to 10, default 3: more tries after a failure, with growing waits
timeoutseconds, 1 to 600, default 60
<d:arg name="x" ... />A declared argument, with the rules of <d:validate>; read as args.x. Also job.name, job.attempt
<d:enqueue job="name" x="..." />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.

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.

AI #

html
<d:action method="POST" ratelimit="10/minute">
  <d:validate field="question" required max="500" />

  <d:ai as="answer">
    <d:system>Answer questions about this post. Be brief.</d:system>
    <d:context>{{ post.title }}: {{ post.body }}</d:context>
    <d:user>{{ form.question }}</d:user>
  </d:ai>

  <d:respond>
    <div>{{ answer }}</div>
  </d:respond>
</d:action>
  • Provider, model, and key come from dhtml.config. model, max_tokens and timeout attributes override them for one call.
  • <d:ai> is only allowed in an action that is not GET and has a ratelimit.
  • <d:ai stream> (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 <d:respond> 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 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.