Alpha
The alpha link type: pages authored as data, so a client can publish one
without repository access and without a deploy.
Why a document, not HTML
The obvious design is to store the page's HTML. WordPress Gutenberg does exactly that, and documents the consequence: on load "the saved markup for each block is regenerated using the attributes… if the newly generated markup does not match what was already stored, the block is marked as invalid." Changing a block's output between versions breaks every post already saved, and the workaround is maintaining deprecations forever.
Alpha stores a node tree with semantic props instead. Two properties follow that are worth the extra machinery:
- Improving a node reaches every page that uses it. Change the markup in
render()and every stored document picks it up on the next request. Nothing to migrate, no deprecations. - There is no Tailwind safelist. Class names live in repository source, in
lib/alpha/tokens.ts, where Tailwind's ordinary file scanning finds them. Content storestone: "inverse", neverclass="bg-slate-900"— which also means restyling the design system does not touch content.
Sanity's Portable Text guidance puts the rule succinctly: model meaning, not appearance.
Record shape
{
"uuid": "<64 alphanumeric characters>",
"type": "alpha",
"config": {
"logo": "/logos/onnne-demo.svg",
"primaryColor": "#114ef6",
"title": "onnne.link — Alpha demo",
"phone": "12025550101", // powers contactButton channel "call"
"whatsapp": { "phone": "12025550101" }, // powers channel "whatsapp"
"locale": "en"
},
"data": {
"schemaVersion": 1,
"locales": ["en", "ar"],
"defaultLocale": "en",
"doc": { "type": "root", "children": [/* nodes */] }
}
}
config is the same metadata every other link type carries. Contact numbers
live there rather than in the document, so a client changing their number edits
one field — and Alpha needs no contact API and no secrets.
Localization is per field
{
"type": "heading",
"props": { "text": { "en": "Our Solutions", "ar": "حلولنا" } }
}
The structure exists once; only text carries locales. Adding a third language is a translation job, not a duplication job, and the trees cannot drift apart. A field missing the active locale falls back to another rather than rendering blank, so a partly translated page still reads.
Nodes
Every node is one entry in the registry (lib/alpha/registry.ts), and each
entry owns three things: how its props validate, how it renders, and — via
schema — what an editor should offer for it. There is one declaration, so the
editor and the renderer cannot drift apart.
A node is { "type": "…", "props": { … }, "children": [ … ] }. Unknown types
and invalid props fail validation, and the page returns the standard 500 "Link
Unavailable".
This table is generated from the registry — regenerate it when the vocabulary changes.
| Type | Label | Flags | Props |
|---|---|---|---|
badge |
Badge | — | label: localizedText requiredtone: token (default | muted | primary | inverse) — default primary |
contactButton |
Contact button | — | channel: token (call | whatsapp) — default whatsapplabel: localizedTextmessage: textfloating: boolean — default false |
container |
Container | children | width: token (sm | md | lg | xl) — default xlalign: token (start | center | end) — default start |
gallery |
Gallery | — | images: media requiredcols: token (2 | 3 | 4) — default 4gap: token (sm | md | lg | xl) — default md |
grid |
Grid | children | cols: token (1 | 2 | 3 | 4) — default 3gap: token (sm | md | lg | xl) — default lg |
heading |
Heading | — | text: localizedText requiredlevel: token (1 | 2 | 3 | 4) — default 2size: token (sm | md | lg | xl) — default lgtone: token (default | muted | primary | inverse) — default defaultalign: token (start | center | end) — default start |
image |
Image | — | src: media requiredalt: localizedTextheight: token (sm | md | lg | xl) — default lgrounded: boolean — default false |
link |
Link | — | label: localizedText requiredhref: url requiredvariant: token (primary | secondary | plain) — default primaryexternal: boolean — default true |
localeToggle |
Language switch | interactive | tone: token (default | muted | primary | inverse) — default default |
rawHtml |
Custom HTML | — | html: html required |
responsiveVideo |
Responsive video | interactive | desktop: media requiredmobile: mediabreakpoint: number — default 767full: boolean — default true |
root |
Document | children | — |
section |
Section | children | tone: token (default | muted | primary | inverse) — default defaultpad: token (sm | md | lg | xl) — default lgpattern: token (none | grid | dots | glow | rings) — default noneanchor: textfull: boolean — default false |
stack |
Stack | children | direction: token (vertical | horizontal) — default verticalgap: token (sm | md | lg | xl) — default mdalign: token (start | center | end) — default startmotion: token (none | orbit | orbit-reverse | pulse) — default none |
tabs |
Tabs | children, interactive | labels: localizedText requiredtone: token (default | muted | primary | inverse) — default primary |
text |
Text | — | text: localizedText requiredsize: token (sm | md | lg | xl) — default lgtone: token (default | muted | primary | inverse) — default mutedalign: token (start | center | end) — default start |
themeToggle |
Theme switch | interactive | tone: token (default | muted | primary | inverse) — default default |
interactive marks a node that needs client-side behaviour. A document
containing none is rendered server-side and ships no JavaScript at all, like
every other link type. One that does mounts a single shared island,
islands/alpha/AlphaRuntime.tsx, which re-renders the same validated tree
client-side through the same registry.
That is why the language toggle costs nothing: switching locale re-renders from data already in memory. There is no second copy of the markup in the HTML, no fetch, and no flash.
Styling
Props are tokens — tone, pad, size, align, cols, motion, pattern —
mapped to Tailwind classes in lib/alpha/tokens.ts. To add a visual option, add
a token there; do not put class names in content.
Colours resolve through CSS custom properties defined in
assets/alpha/design-system.css, so light and dark work without any token
needing a dark variant.
Surfaces re-point the tokens
A section with tone: "inverse" also carries an alpha-inverse marker class,
and the stylesheet re-points --alpha-foreground, --alpha-muted-foreground,
--alpha-border and --alpha-surface-muted for everything inside it. A node
asking for --alpha-foreground means "readable text on the surface I am on",
and without this it got the default surface's colour instead — the secondary
link variant rendered black on black in the light theme and white on white in
the dark one, so the Alpha demo's second hero button was an outlined pill with
no visible label in both.
--alpha-primary is deliberately left alone: the brand blue is legible on both
inverse surfaces, and remapping it turned the hero's primary button into a plain
white pill.
Section patterns
pattern adds a decorative background layer — grid, dots, glow or
rings. It is a ::before in the scoped stylesheet, so it costs no markup and
nothing to author, and its colours are mixed from currentColor so one
definition works on every tone. pattern: "none" contributes no class at all,
which is why adding this prop left every existing record's markup untouched.
Patterns are hidden under prefers-contrast: more.
Everything in that stylesheet is scoped to #alpha. The page this was
derived from styled body, html, ::selection and [dir="rtl"] globally;
merging those unscoped would have restyled all 21 business cards and links pages
served from the same deployment. Nothing here may target body.
The escape hatch
rawHtml is the only node whose output is not generated by a component, and so
the only XSS surface. It is sanitised with an allowlist on every load of a
record, not merely on save: records reach the database by routes we do not
control — a migration, a support script, a direct edit — and a sanitiser that
only guards the write path guards nothing.
Stripped: <script>, inline event handlers, style attributes, javascript:
and data: URLs, protocol-relative URLs, <iframe>/<object>/<embed>. Links
opening a new tab always get rel="noopener noreferrer".
Prefer a real node. Reach for rawHtml when the vocabulary genuinely cannot
express something, and consider whether that gap should become a node instead.
Why the sanitiser is injected, not imported
The registry is bundled for the browser, and sanitize-html is a Node library:
it depends on postcss, which imports node:fs, node:path and node:url.
Importing it from a node definition put those specifiers in both island entry
chunks, the browser refused them, and neither island hydrated — with a green
test suite, because server rendering was unaffected.
So the seam is inverted. rawHtml declares htmlProps: ["html"]
(lib/alpha/nodes/content.tsx) and parseDoc
applies whatever sanitiser its caller supplies:
parseDoc(raw, e, "data.doc", { sanitizeHtml: sanitizeRawHtml });
Two callers pass one: parseLink in lib/validate.ts, and
components/LinkPageView.tsx, which re-resolves
the stored document at render time. Both are server-only.
parseDoc fails closed: with no sanitiser the prop becomes "", so a
caller that forgets one renders an empty block rather than raw markup. Two tests
hold the line — tests/islands_test.ts fails if any island reaches an npm
package outside a browser-safe allowlist, and a CI step fails if a built client
chunk imports a node: specifier.
Assets
Referenced by URL, never imported — clients have no repository access. Values
must be https:, mailto:, tel: or root-relative; anything else fails
validation.
Per-document assets live under static/alpha/<document>/, so each record owns a
folder and none can collide. Root-relative paths keep the record portable:
moving those files to a CDN later is a find-and-replace in the document, with no
code change.
Adding a node type
- Write the definition in
lib/alpha/nodes/—type,label,schema,parseProps,render, andinteractiveif it needs behaviour. - Export it from the file's array so
registry.tspicks it up. - If it uses hooks, extract a named component and have
renderreturn it. The hooks work either way, but naming the component makes the ownership explicit and satisfies the linter. - Add validation and render tests in
tests/alpha-nodes_test.ts. - Regenerate the table above.
Limits
MAX_NODES 2000, MAX_DEPTH 32. A stored document is untrusted input and the
renderer must not become a denial-of-service vector. The proof-of-concept
document is 205 nodes.
The editor
/builder (BUILDER.md) authors these documents, and its property
panels are generated from the schema on each node definition — the use
that field was added for. components/builder/custom/PropsForm.tsx dispatches
on FieldSpec.kind; the insert menu is built from the same declarations.
The consequence worth knowing when adding a node type: writing the definition is the whole job. The node becomes insertable and editable with no change in the builder, which is the same property that keeps the renderer and the editor from drifting apart.
It reads those schemas over HTTP, from GET /builder/schema, rather than
importing NODE_REGISTRY. An editor needs each node's label and props; it does
not need render(), the token maps or the design system, and the builder is
meant to stay liftable into its own release. tests/builder_test.ts asserts the
served vocabulary matches the registry field for field, so the indirection
cannot become a divergence.
Three props are shapes a FieldSpec cannot describe — tabs.labels,
gallery.images and responsiveVideo.desktop/.mobile — and are named in an
OVERRIDES map rather than inferred. A fourth would be an argument for
extending FieldSpec instead.
ALPHA_SCHEMA_VERSION lives in registry.ts, beside MAX_NODES and
MAX_DEPTH, rather than in lib/validate.ts: those constants describe a
document, and the record validator reaches the filesystem through
lib/mikro/serve.ts.
What is deliberately not here yet
Draft and publish. One document per link; an edit is live immediately. There
is no rollback, and the builder's preview is of a draft in a browser, not of a
stored record. schemaVersion exists from day one so the record can be migrated
when that changes.
Saving from the editor. /builder can produce a document but not store one;
that waits on the same storage decision as everything else
(ROADMAP.md).