Skip to content

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.

Three files. A component, a factory, a line in the config.

src/plugins/colour/Swatch.astro
---
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>
src/plugins/colour/index.ts
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 })],
};
}
src/site/ounce.config.ts
plugins: [colour()],

Then use it anywhere a Field is accepted:

{ name: 'accent', label: 'Accent colour', type: 'colour' }
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.

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', '');
}

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.

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.

  • 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.

  • Add a column to pages or page_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 tailwindcss classes outside the admin palette. Only gray, red, green, amber, white, black, transparent and current exist there.