The builder
A form-driven authoring tool at /builder for the four link types onnne.link
renders. It previews only: there is no save, because there is nowhere to
save to yet (ROADMAP.md). Publishing is
still committing files; what the builder removes is writing them by hand.
What it authors
| Tab | type |
Editor |
|---|---|---|
| Business Card | business-card |
seven fixed fields |
| Simple Link Page | links-page |
a repeater of tiles |
| Showcase Page | showcase-page |
a repeater of cards |
| Custom | alpha |
block tree + generated prop panels |
mikro is absent by construction — BuilderType is
Exclude<Link["type"], "mikro">, so leaving it out is a compile-time fact
rather than a check someone can forget. That type names an independently built
application (MIKRO.md); there is nothing for a form to author.
The seam
The builder is meant to be liftable — extractable into its own release without untangling it from onnne.link first. That is only true if it depends on the app through contracts rather than imports, so:
components/builder/** presentational, stateless ┐
islands/builder/** hooks, effects, browser APIs │ no core value imports
lib/builder/** no Deno APIs, no core values ┘
──────────────────────────── the seam ────────────────────────────
routes/builder/** the adapter — imports core freely, and is the
only place that may
Everything the builder needs from the app arrives over two endpoints:
| Endpoint | Returns |
|---|---|
POST /builder/preview |
validation errors (JSON) or the rendered page (HTML) |
GET /builder/schema |
the vocabulary: nodes, icons, platforms, limits |
routes/builder/schema.ts projects the node
registry into lib/builder/vocabulary.ts's
Vocabulary. The builder needs each node's label and prop schema to generate
property panels; it does not need the renderer that goes with them, and
importing NODE_REGISTRY for the labels would drag every render(), the token
maps and the whole design system into the builder's bundle.
FieldSpec is deliberately declared twice — once in
lib/alpha/types.ts as an internal type and once in
vocabulary.ts as the wire format. A contract that silently follows an internal
type is not a contract; tests/builder_test.ts asserts the two agree, which is
where a divergence has to surface.
Type-only imports from lib/data.ts are fine — they erase at compile time and
ship nothing.
Enforced, not documented.
tests/builder_boundary_test.ts walks the
runtime module graph from every file in the three isolated directories and fails
on any core import, using the same deno info technique as
tests/islands_test.ts. Its second test asserts the inverse — that
routes/builder/** does still reach lib/validate.ts,
components/LinkPageView.tsx and lib/alpha/registry.ts — so the rule reads as
a boundary rather than a ban.
The preview is the real page
components/LinkPageView.tsx holds the <Head>
and the type dispatch. routes/o/[uuid].tsx calls
it, and so does the preview. A preview that re-implements the dispatch is one
that will eventually disagree with the page it claims to show — and the
disagreement would surface only after someone published.
island state: draft (a plain object)
│ control → api.update(path, value)
▼ debounce 350 ms
├─ fetch POST /builder/preview Accept: application/json → { ok, errors[] }
└─ if ok: submit a hidden form → POST /builder/preview, target=<iframe>
▼
parseLink() → LinkPageView → a genuine Fresh response in the frame
Why a form submission and not injected HTML. The frame navigates to a real
response, so _app.tsx wraps it, the stylesheet and /logos/* resolve, and a
Custom document containing an interactive node mounts the real
islands/alpha/AlphaRuntime.tsx — its theme
and language toggles work inside the preview. Injecting a rendered string gets
none of that.
Only valid drafts reach the frame, so the preview holds its last good render while a field is half-typed instead of flashing an error page on every keystroke. The error bar shows the problems in the meantime.
Validation is the server's. parseLink is the same function that guards
data/links/*.json, so the errors the builder shows are the errors that would
take a published link down. It also cannot run in a browser: it reaches
lib/mikro/serve.ts, which uses Deno.* and @std/*.
The route is stateless — no draft is stored server-side, and there is no session.
State: three stores, one owner each
| Store | Holds | Lifetime |
|---|---|---|
URL /builder/<type>?d=… |
the record | shareable, back/forward, survives reload |
| IndexedDB | one draft per type + image blobs | survives closing the tab |
useState |
selection, active pane, width | ephemeral |
Load precedence is ?d= → IndexedDB → the blank template. A link someone sent
has to win over whatever was left in this browser, or following a shared draft
would silently show your own work instead.
The type is a path segment.
routes/builder/[type].tsx, with /builder a
307 to the first type. The tabs are therefore ordinary <a> elements — they
work before hydration, and because each type's draft persists separately,
switching type no longer destroys work. An unknown type is a 404:
/builder/web-page is a stale bookmark, and should say so.
Fresh scores static route segments above dynamic ones (getRoutePathScore: 2 vs
1), so preview.tsx and schema.ts keep their paths against [type].tsx.
?d= is the record, compressed.
lib/builder/state/codec.ts:
JSON.stringify → TextEncoder → fflate.deflateSync → base64url → "1" + payload
The "1" is a format version, so a future change is detectable rather than a
crash, and anything unreadable falls back to stored or blank state — a broken
share link should open an empty builder, not an error page.
Synchronous on purpose: this runs on the same debounce as the preview, and
history.replaceState must be called with the value for the render that
produced it. An async codec would let a slow encode land after a newer edit.
Size guard. Past MAX_URL_STATE (6 000 characters) the URL stops updating
and the UI says so. IndexedDB still has it — which is why persistence is not
optional. A 2 000-node document must not be capped by what a chat window will
carry.
The preview stays a POST for the same reason: it must carry a whole document plus an asset map, while the address bar must stay short. Conflating them would cap document size at the URL limit.
One reducer. Every control addresses its own field by path and calls one of
four operations (lib/builder/paths.ts):
<TextField
label="First name"
value={getAt(draft, "data.firstName")}
onChange={(v) => api.update("data.firstName", v)}
/>;
setAt / insertAt / removeAt / moveAt are immutable, clone only the nodes
along the path, and understand data.blocks[2].action.url. Two behaviours are
load-bearing: setAt(…, undefined) removes the key — storing
"website": "" passes validation and then renders a dead control — and missing
containers are created, so writing config.whatsapp.phone works before
whatsapp exists.
Draft is a loose record rather than a Link. A draft is invalid for most of
its life; parseLink is the only thing that decides otherwise.
Images, in the browser only
Nothing is uploaded anywhere. A picked file is downscaled to 1600 px on a canvas
and re-encoded as WebP (SVG is passed through), stored as a Blob in IndexedDB,
and exposed as a blob: URL. The record stores /u/<id> — root-relative,
so it passes isSafeUrl with no change to lib/errors.ts, and it reads as a
real path in the exported JSON. /u/ is reserved for the builder and is never
served.
Rendering an upload in a server-rendered preview is the one genuinely hard
part. In routes/builder/preview.tsx:
parseLinkruns with/u/<id>intact — what is validated is exactly what would be published.- Only then are exact
/u/<id>strings replaced from anassetsmap submitted alongside the draft. - Only
blob:values are accepted, and only under/u/keys. Without that, a POST body could putjavascript:into an attribute rendered on our own origin. This is the security-relevant line in the feature; five tests intests/app_test.tscover it. - Render.
Blob URLs resolve inside a same-origin iframe — verified in a real browser before this was built on — so the image appears with no flash, no DOM surgery and no script injected into the preview document. A reference with no blob behind it is left visible rather than blanked, because a draft opened from a shared link names images this browser never had.
Export is a zip (lib/builder/export.ts, using
the same fflate as the codec):
<uuid>.zip
├── <uuid>.json /u/<id> already rewritten to /logos/<name>
└── logos/
└── my-logo.webp
Unzip, copy both into the repository, commit. The exported record is publishable
as it stands — there is no manual path reconciliation step where a typo silently
404s an image. Filenames are slugified and de-duplicated, so two uploads called
logo.png do not become one file.
The Custom editor is generated
ALPHA.md says a node's schema is the specification an editor would
be generated from;
components/builder/custom/PropsForm.tsx
is that generator, dispatching on FieldSpec.kind:
kind |
Control |
|---|---|
text |
text input |
url |
text input |
media |
ImageField — upload or URL |
token |
<select> over options, seeded with default |
number |
number input |
boolean |
checkbox |
html |
textarea |
localizedText |
one input per entry in data.locales |
Adding a node type to lib/alpha/nodes/ gives it an editor and an insert-menu
entry with no change in the builder.
Four props are shapes a FieldSpec cannot describe, so they are named in
OVERRIDES (lib/builder/custom-fields.ts)
rather than guessed at: tabs.labels (a list of localized text),
gallery.images (a list of URLs), and responsiveVideo.desktop / .mobile
({ src, poster }). A fifth would be an argument for extending FieldSpec.
Inserted nodes are valid. seedProps fills required fields from the same
schema, so inserting a node never blanks the preview; tests/builder_test.ts
asserts this for every node the endpoint serves.
Selection is the path. A node's identity is
data.doc.children[0].children[2], which is also the address every edit writes
to, so the outline and the property panel cannot disagree. No separate tree
model, no ids.
An emptied locale input removes that key rather than storing "", so the
renderer's fallback takes over and a partly translated page still reads.
Layout
min-h-0 on every flex child containing a scroll area is load-bearing and
must not be removed. A flex or grid item defaults to min-height: auto, which
is its content's height, so an overflow-y-auto descendant never becomes a
scroll container — it just grows until an ancestor's overflow-hidden clips it.
The first version of the builder did exactly that: the editor was cut off at the
viewport with no scrollbar and no way to reach the controls below.
h-dvh rather than h-screen, so mobile browser chrome does not take the last
60 px. Below lg a segmented Edit / Preview control swaps which pane renders,
each full height, instead of stacking two clipped panes.
What it does not do
It does not save. No database, no revisions, no list of existing links. The export is a zip; publishing is a commit, as in OPERATIONS.md.
There is deliberately no disabled Save button. A dead control shipped here
once — ErrorDisplay's "Go Back" — and tests/render_test.ts guards against
its return. A greyed-out Save is the same mistake; a sentence saying what to do
instead is not.
It does not list assets/logos/. An earlier version offered every file
there as a dropdown, which put a roster of onnne.link's customers on a public
page. ImageField replaced it, listLogoPaths was deleted, and a test asserts
no brand name appears in a builder response.
Each draft carries a real uuid from creation
(lib/builder/uuid.ts), so the exported file is
immediately committable and the filename-must-equal-uuid rule holds without
anyone having to know it. The route generates the first one server-side so the
island's first render matches on both sides.
Extracting it later
The builder would need three things and nothing else:
lib/builder/,components/builder/,islands/builder/— which import nothing from this app, by test.- The two endpoints, against any host that implements them.
Vocabularyinvocabulary.tsis the full description of one;POST /builder/previewtakesdraftandassetsform fields and answers JSON or HTML. - A
Drafttype, which isRecord<string, unknown>.
What would stay behind is routes/builder/** — the adapter — plus
LinkPageView, the validator and the registry, which are onnne.link's, not the
builder's.
Test coverage
tests/builder_test.ts— the path helpers (including that they never mutate),newUuid, that every template passesparseLink, that the served vocabulary matches the registry field for field, and that a seeded node of every type validates.tests/builder_boundary_test.ts— the seam, in both directions.tests/app_test.ts— the routes (/builderredirect, a page per type, 404 on an unknown type, no logo enumeration) and the preview contract (both representations,noindex/no-store, a refusedmikrodraft, and the asset-substitution rules).
Related
- What each type renders → LINK-TYPES.md
- Field-level schema → DATA-MODEL.md
- The node vocabulary → ALPHA.md
- Publishing a link → OPERATIONS.md
- Why there is no save yet → ROADMAP.md