dhtml

Examples · read from examples/

Examples, read in full

A notes app and a shop, read line by line, then the two starters, LaunchBlog and LaunchSaaS (which has its own page). All are MIT-licensed and each is a zip you can upload to your site. The code on this page is read from those files when the site is built.

§ 01 · notes

Accounts, a database, an AI answer

A small notes app: pages and layouts, a table, protected paths, and an answer that streams in. It uses the fake AI provider, so it needs no key and no network.

notes/server/layouts/main.dhtmllayout
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title | default app.name }} · {{ app.name }}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<a class="brand" href="/">{{ app.name }}</a>
<nav>
<d:auth>
<a href="/account">{{ user.name | default user.email }}</a>
<d:auth admin><a href="/admin">Admin</a></d:auth>
<form method="post" action="/api/logout" class="inline"><button>Sign out</button></form>
<d:else>
<a href="/login">Sign in</a>
<a href="/register">Register</a>
</d:auth>
</nav>
</header>
<main>
<d:if test="flash"><p class="flash">{{ flash }}</p></d:if>
<d:slot />
</main>
<footer>dhtml {{ app.version }}</footer>
<script src="/_dhtml/dhtml.js"></script>
</body>
</html>

Try it: download the notes example and upload it to a site on dhtml.net (step by step). Write a note with <b>html</b> in it and watch it come back as text.

§ 02 · shop

A Stripe payment, in tags

Four routes, no code, no SDK. The checkout action works out the amount, creates a Stripe Checkout Session, stores the order and links to the payment page. When the customer has paid, Stripe calls a signed webhook, and the order becomes paid.

Tested end to end. The shop runs against a local Stripe, including a refused card and a forged webhook, and the signature check, the once-only event and the transaction all hold. Point it at your Stripe test keys to try it for real.

shop/dhtml.configsettings
# A tiny shop that takes a Stripe payment end to end:
# / a product and a form
# /api/checkout creates a Stripe Checkout Session with <d:http>
# /api/stripe-hook Stripe tells us the payment is done (a signed webhook)
# /order/<id> shows the order, pending or paid
[app]
name = shop
version = 0.1.0
environment = development
url = https://YOURSITE.dhtml.net
# The only host this app may call.
[http]
allow = api.stripe.com
# Stripe signs every call it makes to us; the secret is in Secrets.
[webhooks]
stripe = stripe env:STRIPE_WEBHOOK_SECRET
shop/server/api/checkout.dhtmlPOST /api/checkout
<d:action ratelimit="10/minute">
<d:validate field="email" required email />
<d:validate field="qty" required number min="1" max="10" />
<d:set as="cents" value="{{ form.qty | mul 1250 }}" />
<!-- Call Stripe first, then write: a call must not hold the database lock. -->
<d:http as="session" method="POST" url="https://api.stripe.com/v1/checkout/sessions" bearer="env:STRIPE_KEY" timeout="15">
<d:field name="mode" value="payment" />
<d:field name="customer_email" value="{{ form.email }}" />
<d:field name="line_items[0][quantity]" value="{{ form.qty }}" />
<d:field name="line_items[0][price_data][currency]" value="usd" />
<d:field name="line_items[0][price_data][unit_amount]" value="1250" />
<d:field name="line_items[0][price_data][product_data][name]" value="The dhtml mug" />
<d:field name="success_url" value="{{ config.app.url }}/" />
<d:field name="cancel_url" value="{{ config.app.url }}/" />
</d:http>
<d:if test="session.ok">
<d:exec as="order">INSERT INTO orders (email, qty, cents, session_id) VALUES ({{ form.email }}, {{ form.qty }}, {{ cents }}, {{ session.json.id }})</d:exec>
<d:respond>
<d:layout src="main" title="Pay">
<h1>Almost there</h1>
<p>Your order is ready. <a href="{{ session.json.url }}">Continue to payment</a>.</p>
<p class="muted">After paying, open <a href="/order/{{ session.json.id }}">your order</a>.</p>
</d:layout>
</d:respond>
<d:else>
<d:respond>
<d:layout src="main" title="Payment problem">
<h1>We could not start the payment</h1>
<p class="error">{{ session.json.error.message | default "The payment service did not answer." }}</p>
<p><a href="/">Try again</a></p>
</d:layout>
</d:respond>
</d:if>
<d:invalid>
<d:layout src="main" title="Buy a mug">
<h1>Check the form</h1>
<p class="error">{{ errors.email }} {{ errors.qty }}</p>
<p><a href="/">Back</a></p>
</d:layout>
</d:invalid>
</d:action>
shop/server/api/stripe-hook.dhtmlPOST /api/stripe-hook
<!-- Stripe calls this when something happens. The call is checked against the secret in Secrets
before anything below runs, and an event that was already handled is answered without running again. -->
<d:action webhook="stripe" dedupe="webhook.json.id" ratelimit="600/minute">
<d:if test='webhook.json.type == "checkout.session.completed" and webhook.json.data.object.payment_status == "paid"'>
<d:exec>UPDATE orders SET status = 'paid' WHERE session_id = {{ webhook.json.data.object.id }} AND status = 'pending'</d:exec>
</d:if>
<d:respond>ok</d:respond>
</d:action>

The check on every upload lists the one host the app calls and the one webhook it answers, so you can see what an app reaches before it runs.

§ 03 · Starters

Two whole apps, one zip each

StarterWhat you get
launchblogA news site in the style of a newspaper: a navy masthead with a date line and a ticker, a lead story, top stories and sections. Categories; several authors with author pages; contributors who sign in and write drafts that an editor publishes. Generated stock cover pictures (no photos linked from other sites). Search-engine data on every page: NewsArticle, breadcrumbs, profile, a sitemap with pictures and a feed. An AI writer on a schedule the editor sets, using Claude when [ai] is set to anthropic (a fake model by default), and always labelled as AI-written.
launchsaasAn AI product: credits, plans, Stripe, text and image studios, sign-in by link, a control centre, blog, docs and changelog.

Download LaunchBlog Download LaunchSaaS

Unzip, change what you like, zip the contents again and upload it to your site (how). LaunchBlog has been tested with the fake AI model only, has no independent review of this version, and has not been run in production.