ADR-0161 — A website is a document: pages of sections, drafted, revised, published
- Status: Accepted; in progress (unreleased)
- Date: 2026-09-23
- Extends ADR-0154 (one console per site) and ADR-0159 (what a domain serves).
0. The report
An audit of the website builder, and the live install, agreed on why a
template site does not impress: the content model froze at its first version.
A business site is one flat record (bizsite.Content) rendered as a single page
with fixed sections. There are no pages, no section order, no draft, no history,
and saving is publishing. vayupress.johal.in published a template's sample
content as its real business because nothing distinguished the two.
1. Decision
A template website is a document: an ordered list of pages, each an ordered list of typed sections. It is edited as a draft and made public by publishing, and every publish is kept as a revision.
Document { v, pages: [ Page { slug, title, description, in_nav, sections: [ Section ] } ] }
Section { id, kind, ...fields for that kind }
kinds: hero · text · items · gallery · contact
1.1 Why typed sections, not free HTML blocks
Every field is plain text or a validated URL, and the renderer escapes all of
it. A site document therefore cannot carry script, style or markup, which is
what lets it stay under the install's strict Content-Security-Policy with no
per-site exception, and what lets a client, the operator and an assistant all
edit it through the same validator. Free-form HTML remains available where it
belongs: the uploaded bundle (custom mode) and the post block editor.
1.2 Why the database, not a compiled bundle
Published revisions are rows. The public page renders the domain's newest
published revision, as it rendered bizsite.Content before. Restoring is
publishing an older revision, so history is as deep as retention allows rather
than the uploaded bundle's single previous generation, and a draft preview is
the same renderer pointed at the draft.
1.3 Existing sites
A site with no document renders from its legacy bizsite.Content through
FromLegacy, which produces the equivalent document. Nothing changes the day
this ships. The first save from the new editor stores a real document; a
render-parity test holds FromLegacy to the same visible text, in the same
order, with the same links, as the old renderer.
1.4 Pages and the blog
The home page is the page with an empty slug. Other pages are served at
/<slug>. Where the blog shares the host (business_subpath, custom), posts
also live at /<slug>, so a page slug that names an existing post, or a
reserved route, is refused at save time. The editor says which.
1.5 Contact
The contact section posts to the existing /api/v1/contact endpoint (honeypot,
per-address limit, length checks). Messages gain the domain they were sent from
and go to that site's own contact address when it has one. Before this, a form
on a hosted client's site would have landed, unlabelled, in the operator's inbox.
2. What every published page carries
Title and description from the page, canonical URL, Open Graph and Twitter
cards, JSON-LD for a local business, the site's icon, and an alt on every
image — required by the validator for gallery images, so the audit's "forced
empty alt text" cannot recur.
3. Limits
A document is at most 256 KiB of JSON, 20 pages, 40 sections a page, 60 items
or images a section. Slugs match ^[a-z0-9][a-z0-9-]{0,62}$. Links are
https:, http:, mailto:, tel:, a site path (/…) or a fragment (#…);
images are the same origin or https:. Everything else is refused with the
field that failed.
4. Consequences
- One editor serves the primary site and every hosted site; the connector edits the same document through the same validator.
bizsite.Renderstays only as the parity reference for legacy content.- Templates keep their CSS; the section renderer emits the same
vb-*markup, so every existing template styles the new pages without change.