Field types
A field type is a FieldKind: a type name and an Astro component. Register it
through a plugin’s fields array and it becomes usable in any field list, in
any setting, section, page tab or plugin config.
Example
Section titled “Example”Three files. A component, a factory, a line in the config.
---import type { FieldProps } from '@ouncepage/core';import { help, label } from '@ouncepage/core/fields/ui';
const { field, path, value } = Astro.props as FieldProps;const current = typeof value === 'string' ? value : '';---
<div> <label class={label} for={path}> {field.label} </label> <div class="mt-1 flex items-center gap-2"> <input class="h-9 w-12 rounded-md border border-gray-300 p-1" type="color" id={path} name={path} value={current || '#000000'} /> <output class="text-[13px] text-gray-500" data-swatch-value>{current}</output> </div> {field.help && <p class={help}>{field.help}</p>}</div>
<script> function bind(root: ParentNode) { root.querySelectorAll<HTMLInputElement>('input[type="color"]').forEach((input) => { const output = input.parentElement?.querySelector('[data-swatch-value]'); if (!output) return; input.addEventListener('input', () => { output.textContent = input.value; }); }); }
document.addEventListener('ounce:mount', (event) => bind((event as CustomEvent).detail.root)); bind(document);</script>import { defineField, type Plugin } from '@ouncepage/core';import Swatch from './Swatch.astro';
export function colour(): Plugin { return { name: 'colour', title: 'Colour field', fields: [defineField({ type: 'colour', component: Swatch, editable: false })], };}plugins: [colour()],Then use it anywhere a Field is accepted:
{ name: 'accent', label: 'Accent colour', type: 'colour' }FieldKind
Section titled “FieldKind”| Key | Type | Default | Meaning |
|---|---|---|---|
type |
string |
required | The name used in field.type. Unique; registering twice replaces |
component |
(props: FieldProps) => unknown |
required | The Astro component |
overlay |
component | none | Rendered once per admin page, not once per field. For a shared modal |
isArray |
boolean |
false |
The value is an array. Affects how form data is collected |
sanitize |
boolean |
false |
The value is HTML and goes through the sanitiser on every save |
editable |
boolean |
false |
Whether the AI assistant may write it. Built-in types holding copy, an image, a choice or a switch default to true; url, email, tel and avatar default to false |
children |
(field) => Field[] |
none | For container types. Returns the nested fields |
schema |
(fallback?) => ZodType |
none | A Zod schema the type supplies for itself |
interface FieldProps { field: Field; // your own field definition, including label, help, ai path: string; // the dotted path: 'site.accent', 'sections.banner.cta.link' value: unknown; // whatever is stored, unvalidated siblings?: unknown; // the object this field sits in, for fields that read a neighbour history?: boolean; // whether the clock icon is being shown beside this field}Three rules the engine depends on:
Use path as both name and id. The form reader rebuilds the nested
object from dotted input names. An input named anything else is not read, and
the field silently saves as empty.
Render exactly one top-level element. The history clock is placed in a grid column beside your component’s first child. Two root elements put the clock beside the first one.
Treat value as unknown. It is whatever is in D1. Narrow it yourself; do not
assume your schema already ran.
Client scripts
Section titled “Client scripts”Put the <script> in the component. The admin adds and removes fields from the
DOM at runtime, inside repeaters and when a tab first opens, so binding once on
load is not enough.
document.addEventListener('ounce:mount', (event) => bind(event.detail.root));bind(document);ounce:mount fires on document with detail.root set to the subtree that was
just inserted. Make bind idempotent, with a data-ready flag, because a
subtree can be mounted more than once.
Read-only mode is a disabled <fieldset> wrapped around the form. Your inputs
are disabled automatically. Your buttons and labels are not, so hide them
yourself:
if (input.matches(':disabled')) { wrapper.querySelector('[data-actions]')?.setAttribute('hidden', '');}Schemas
Section titled “Schemas”A field type whose form encoding does not round-trip through Zod can supply a schema builder, which the site then uses instead of writing one by hand.
import { z } from 'zod';
defineField({ type: 'toggle', component: Toggle, schema: (fallback = false) => z.preprocess((value) => { if (value === undefined) return fallback; if (value === 'true') return true; if (value === 'false') return false; return value; }, z.boolean()),});This is why toggle exists as a helper. An unchecked checkbox submits the
string 'false', and a plain z.boolean() rejects it.
Container types
Section titled “Container types”A type that holds other fields declares children. The engine uses it to walk
into nested values for sanitising, array collection, diffing and AI descriptors.
defineField({ type: 'columns', component: Columns, editable: true, children: (field) => (field.type === 'columns' ? field.fields : []),});Inside the component, render each child with the engine’s Field.astro and a
path built from your own:
---import Field from '@ouncepage/core/fields/Field.astro';import { getPath } from '@ouncepage/core/paths';---
{field.fields.map((child) => ( <Field field={child} path={`${path}.${child.name}`} value={getPath(value, child.name)} siblings={value} />))}Setting editable: false on a container excludes the whole subtree from the AI
assistant, not just the container.
A field may override its type’s default either way with ai.editable. Types
that point a visitor at a real destination, or at a real person, are the ones
that stay closed by default: a wrong phone number or a wrong face is a mistake
no diff makes obvious.
File layout
Section titled “File layout”Directorysrc/plugins/colour
- index.ts the factory, exporting the FieldKind
- Swatch.astro the component, with its own script and styles
The field owns its component, its client script and its styles. If the engine needs a flag describing your field’s needs, the boundary is wrong.
Limits
Section titled “Limits”- Add a column to
pagesorpage_sections. The value lives inside the JSON blob its parent already owns. - Save on its own. Every value goes through the form post of whatever contains it.
- Import
tailwindcssclasses outside the admin palette. Onlygray,red,green,amber,white,black,transparentandcurrentexist there.