Tables, forms, and the files EDS blocks expect to find.
Sheets are content: authored in a grid, served as EDS sheet JSON at their own path, published alongside the page that reads them. Forms are rendered by the server from a typed definition and submitted back to it. query-index.json, sitemap.xml, the feed and the redirect map are all derived from the pages you already have.
The last step is additive on purpose: it runs after the durable write and the broadcast, so a missing tab, a formatting failure or a write error is logged and skipped rather than losing the submission. The honeypot answers with success and drops the submission silently, which is the only useful response to something that filled in an invisible field.
Sheets
Tabular content, not a database
A sheet is author-edited, untyped and multi-tab: every cell is a string, and every tab keeps its author order rather than being sorted. It is stored as its own source model under the store root and derived into the EDS wire shape on each request — a single-sheet document for one tab, a multi-sheet envelope with a :names list for two or more — so a block can fetch('/data/features.json') exactly as it would against a real EDS sheet.
The build-profile table on the front page is one of these, read a second way: server-side, by a block template, through the same store. Editing is a grid at /_/sheets with debounced whole-document autosave; the server validates and canonicalises what it receives and hands back the canonical document, which replaces what the browser holds. CSV and TSV import and export round-trip through one parser.
Sheets ride publishing with the page that references them, which is where the one piece of special-casing lives. A sheet marked as collecting form submissions is created on the published store if it is missing and never overwritten, because the two stores legitimately diverge: the preview copy holds your test rows and the published copy holds real ones. Regular sheets overwrite as you would expect.
Untyped means untyped: no column types, no per-cell widgets, no validation beyond the structural checks that reject duplicate columns and pad ragged rows. The whole sheet loads into the browser grid with no row ceiling and no virtualisation. And a save is a whole-document write rather than a cell operation, so two people editing one sheet at the same time are last-write-wins on the document. Cell-granular editing and large-sheet handling are both recorded as deferred, not planned-for-next-week.
Forms
Rendered by the server that receives it
A form is a typed definition rendered into an EDS-shaped <form>: one field wrapper per field, a visually hidden honeypot, a hidden marker naming the form, one submit button. Delivery and published render it byte-identically; the authoring port instruments only the form element itself, because the fields belong to the definition rather than to the page.
Where the definition lives decides whether it can be edited. A file in your forms/ directory is read-only and holds the same EDS forms sheet JSON aem.live publishes — edit the file. A definition in the content store is editable in the admin and rides preview and publish like any other content. The waitlist form at the foot of every page here is the read-only kind: a file in the repository, whose settings rows point at the sheet its submissions land in.
forms/waitlist.json{
"total": 3,
"offset": 0,
"limit": 3,
"data": [
{ "Name": ":sheet", "Value": "/data/waitlist" },
{ "Name": ":tab", "Value": "Signups" },
{
"Name": "email",
"Type": "email",
"Label": "Email address",
"Placeholder": "[email protected]",
"Mandatory": "true"
}
]
}
Submissions are visible as they arrive: the admin viewer seeds a page of rows over HTTP and then subscribes to a stream, and the publisher and the subscriber are on different ports of the same process. Export is CSV or JSON, with the columns taken from the definition rather than from whatever keys a given row happens to carry.
The submit rate limiter keys on the TCP peer address and does not resolve forwarded-for headers on the public ports, unlike the authoring port’s own middleware. Behind a reverse proxy every submission appears to come from the proxy, so the per-IP limit degrades to a single global bucket. It fails closed — it over-throttles rather than letting anything through — but it is a real limitation of this version, and it is named here rather than in a changelog. CAPTCHA, notification on submit, retention policy and file-upload fields are all out of scope in this version too.
Derived outputs
The files EDS blocks expect to fetch
query-index.json is generated from your pages, and the generation is honest about what it reads: each page is rendered to its delivery-shape DOM and the CSS selectors in helix-query.yaml are evaluated against that HTML — the same thing an EDS crawler would see, from the same render. An existing project’s config drops in unchanged. With no config at all you get one default index carrying title, description, image, robots and a last-modified timestamp. This site’s own is four properties long:
helix-query.yamlversion: 1
indices:
default:
include: ['/**']
exclude: ['/nav', '/footer']
target: /query-index.json
properties:
title: { select: 'head > title' }
description: { select: 'head > meta[name="description"]',
value: 'attribute(el, content)' }
image: { select: 'main img', value: 'attribute(el, src)' }
section: { select: 'head > meta[name="section"]',
value: 'attribute(el, content)' }
Two columns are intrinsic and not selector-driven: path is always first, and a last-modified timestamp is always last, taken from the page file rather than from a selector — which is also why the canonical Adobe config’s timestamp expression is ignored without complaint instead of warning about an expression ledge does not evaluate.
Sitemaps read helix-sitemap.yaml — the same filename and structure EDS uses, so an existing one drops in. Feeds read feed.yaml, which is ours, since EDS has no feed, and emit RSS 2.0. Both are projections of a query-index sheet: sorted, capped, and scoped by pointing them at a scoped index rather than by filtering. Redirects are a redirects.json sheet in the EDS shape, consulted at the very top of every request so a redirect wins over a real page at the same path, with an optional Status column as our single extension to the format — EDS is permanent-only.
Each port serves its own store: the delivery and authoring ports read the preview index, the published port reads the published one. So the index a developer sees updates as they edit, with no publish step, and the public one changes only when something is published.
Index and sitemap writes happen inside the same lock as the content write, and a failure is logged rather than propagated — the page write still succeeds. That makes a stale index a degradation rather than a data-integrity problem, and one endpoint rebuilds both domains. Two things are known not to be swept up on their own: a block-template or plugin change that alters the extracted HTML, and any block a plugin renders at all — extraction runs with plugins off, so a plugin block contributes its fallback markup to the index, not its real output.
Keep reading
Two more pieces of the same system
Where the SQLite file, the sheets and the published store actually live, and what the smaller build profile leaves out.
The two things that read a sheet server-side: a minijinja block template, and a sandboxed WebAssembly renderer.
Waitlist
One mail, when there is a binary.
No newsletter, no drip sequence, no passing your address to anyone. One message when ledge is downloadable, and that is the entire promise.