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:

  1. parseLink runs with /u/<id> intact — what is validated is exactly what would be published.
  2. Only then are exact /u/<id> strings replaced from an assets map submitted alongside the draft.
  3. Only blob: values are accepted, and only under /u/ keys. Without that, a POST body could put javascript: into an attribute rendered on our own origin. This is the security-relevant line in the feature; five tests in tests/app_test.ts cover it.
  4. 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:

  1. lib/builder/, components/builder/, islands/builder/ — which import nothing from this app, by test.
  2. The two endpoints, against any host that implements them. Vocabulary in vocabulary.ts is the full description of one; POST /builder/preview takes draft and assets form fields and answers JSON or HTML.
  3. A Draft type, which is Record<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 passes parseLink, 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 (/builder redirect, a page per type, 404 on an unknown type, no logo enumeration) and the preview contract (both representations, noindex/no-store, a refused mikro draft, and the asset-substitution rules).