control (the admin control), see class-aum-ct-metabox.php
* 2. What does it look like when bound to a canvas -> resolve() (the read side)
*
* The second mirrors the lesson this project learned five times over. An image field stores an attachment ID,
* and without resolve that puts `142` into an `` — filled in perfectly in the admin, a broken image on
* the front end, and it looks like "the editor has a bug". Adding a field type means writing both halves.
*
* @package Aumframe
*/
defined('ABSPATH') || exit;
class Fields
{
/** The four storage primitives. */
const STORE_SCALAR = 'scalar';
const STORE_ATTACHMENT = 'attachment';
const STORE_REFERENCE = 'reference';
const STORE_GROUP = 'group';
/**
* Every field type.
*
* store — which storage primitive it lands on
* control — which admin control fills it
* bindType — what it can bind to in the binding panel (text / image / url), matching the values of
* prop.accepts in packages/editor/src/bindings.js
*/
public static function types(): array
{
return [
// Scalars
'text' => [
'label' => __('Text', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'text', 'bindType' => 'text',
],
'textarea' => [
'label' => __('Textarea', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'textarea', 'bindType' => 'text',
],
'richtext' => [
'label' => __('Rich text', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'richtext', 'bindType' => 'text',
// The only type allowed to emit raw HTML onto a page — see raw_expressions()
'raw' => true,
],
'number' => [
'label' => __('Number', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'number', 'bindType' => 'text',
],
'toggle' => [
'label' => __('Toggle', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'toggle', 'bindType' => 'text',
],
'select' => [
'label' => __('Select', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'select', 'bindType' => 'text',
'hasOptions' => true,
],
'color' => [
'label' => __('Colour', 'aumframe'),
// bindType is 'color' rather than 'text', so the binding panel's Fill row lists only genuine
// colour fields instead of treating every text field as a candidate (see bindings.js)
'store' => self::STORE_SCALAR, 'control' => 'color', 'bindType' => 'color',
],
'date' => [
'label' => __('Date', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'date', 'bindType' => 'text',
],
'url' => [
'label' => __('Link', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'url', 'bindType' => 'url',
],
'email' => [
'label' => __('Email', 'aumframe'),
'store' => self::STORE_SCALAR, 'control' => 'email', 'bindType' => 'text',
],
// Attachment references
// All store one attachment ID; they differ only in which mime the media modal filters for and
// which subfields resolve produces
'image' => [
'label' => __('Image', 'aumframe'),
'store' => self::STORE_ATTACHMENT, 'control' => 'media', 'mime' => 'image',
'bindType' => 'image',
],
'video' => [
'label' => __('Video', 'aumframe'),
'store' => self::STORE_ATTACHMENT, 'control' => 'media', 'mime' => 'video',
'bindType' => 'url',
],
'audio' => [
'label' => __('Audio', 'aumframe'),
'store' => self::STORE_ATTACHMENT, 'control' => 'media', 'mime' => 'audio',
'bindType' => 'url',
],
'file' => [
'label' => __('File', 'aumframe'),
'store' => self::STORE_ATTACHMENT, 'control' => 'media', 'mime' => '',
'bindType' => 'url',
],
// Content references
'post_ref' => [
'label' => __('Related content', 'aumframe'),
'store' => self::STORE_REFERENCE, 'control' => 'post_ref', 'ref' => 'post',
'bindType' => 'text',
],
'term_ref' => [
'label' => __('Related term', 'aumframe'),
'store' => self::STORE_REFERENCE, 'control' => 'term_ref', 'ref' => 'term',
'bindType' => 'text',
],
// Repeaters
/*
* The only type that touches the foundation, so it had to exist in v1 — without it, spec tables,
* galleries and timelines all fall back to being hard-coded into templates.
*/
'group' => [
'label' => __('Repeater (list)', 'aumframe'),
'store' => self::STORE_GROUP, 'control' => 'group', 'bindType' => 'list',
],
];
}
public static function type_def(string $type): array
{
$types = self::types();
return $types[$type] ?? $types['text'];
}
public static function store_of(string $type): string
{
return self::type_def($type)['store'];
}
/** A repeater cannot contain a repeater — nobody can fill in a nested-list admin interface, and no real need
* for one exists. */
public static function sub_types(): array
{
$out = self::types();
unset($out['group']);
return $out;
}
// ------------------------------------------------------------------ Writing
/**
* Reduce what the user typed into the string that goes into meta.
*
* Always a string. Not laziness — post meta is a string table, and storing an array serialises it into
* PHP's serialize format, which nothing else can read, locking the data inside this plugin.
* A repeater stores JSON (readable by people and machines alike); everything else stores its raw value.
*/
public static function sanitize(array $field, $raw): string
{
$type = $field['type'] ?? 'text';
switch (self::store_of($type)) {
case self::STORE_ATTACHMENT:
case self::STORE_REFERENCE:
$id = is_array($raw) ? 0 : absint($raw);
return $id > 0 ? (string) $id : '';
case self::STORE_GROUP:
return self::sanitize_group($field, $raw);
}
// Scalars
$value = is_array($raw) ? '' : (string) $raw;
switch ($type) {
case 'textarea':
return sanitize_textarea_field($value);
case 'richtext':
// kses runs on write, which is what makes emitting it verbatim on read safe — guarding only
// one end always leaves the other open
return wp_kses_post($value);
case 'number':
return is_numeric(trim($value)) ? trim($value) : '';
case 'toggle':
// The same convention as thumbnail.hasImage: true is '1' and false is '' (an empty string is
// naturally false in a condition)
return $value !== '' && $value !== '0' ? '1' : '';
case 'select':
$valid = wp_list_pluck(self::options_of($field), 'value');
return in_array($value, $valid, true) ? $value : '';
case 'color':
return (string) (sanitize_hex_color(trim($value)) ?: '');
case 'date':
return preg_match('/^\d{4}-\d{2}-\d{2}$/', trim($value)) ? trim($value) : '';
case 'url':
return esc_url_raw(trim($value));
case 'email':
return is_email(trim($value)) ? sanitize_email(trim($value)) : '';
default:
return sanitize_text_field($value);
}
}
/**
* A repeater: a JSON array whose rows are "subfield key => reduced value".
*
* Every row is sanitised subfield by subfield rather than being json_encode'd wholesale — otherwise a
* rich-text subfield inside a group would be an HTML injection point nobody guards.
*/
private static function sanitize_group(array $field, $raw): string
{
$rows = is_string($raw) ? json_decode($raw, true) : $raw;
if (!is_array($rows)) {
return '';
}
$subs = self::normalize_subfields($field);
$clean = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$out = [];
$empty = true;
foreach ($subs as $sub) {
$v = self::sanitize($sub, $row[$sub['key']] ?? '');
$out[$sub['key']] = $v;
if ($v !== '') {
$empty = false;
}
}
// An entirely empty row is dropped: pressing "add row" in the admin and filling nothing in should
// not render an empty entry on the front end
if (!$empty) {
$clean[] = $out;
}
}
return $clean ? (string) wp_json_encode($clean, JSON_UNESCAPED_UNICODE) : '';
}
/** The subfield definitions inside a group (never containing group itself). */
public static function normalize_subfields(array $field): array
{
$out = [];
foreach ((array) ($field['fields'] ?? []) as $sub) {
if (!is_array($sub) || empty($sub['key'])) {
continue;
}
if (($sub['type'] ?? 'text') === 'group') {
continue;
}
$out[] = $sub;
}
return $out;
}
/** Select options: one per line. */
public static function options_of(array $field): array
{
$out = [];
foreach (preg_split('/\r\n|\r|\n/', (string) ($field['options'] ?? '')) as $line) {
$line = trim($line);
if ($line !== '') {
$out[] = ['value' => $line, 'label' => $line];
}
}
return $out;
}
// ------------------------------------------------------------------ Reading
/**
* The stored string -> a value usable on the canvas. This is the read-side contract.
*
* Scalars come out as strings; everything else comes out as a structure:
*
* image -> ['id','url','alt','width','height','hasImage']
* video… -> ['id','url','filename','mime','size','hasFile']
* post_ref -> ['id','title','url','date','thumbnail'=>[…],'hasRef'] (+ lazy 'meta', and 'product' for catalogue items)
* term_ref -> ['id','name','url','count','hasRef']
* group -> [ ['subkey'=>value,…], … ] — a plain list, ready for a loop
*
* `hasXxx` is exposed separately for the same reason as `thumbnail.hasImage`: "hide the whole block when
* there is no value" is a legitimate need, and merging it into the value makes it inexpressible.
*/
public static function resolve(array $field, string $stored)
{
$type = $field['type'] ?? 'text';
switch (self::store_of($type)) {
case self::STORE_ATTACHMENT:
return self::resolve_attachment($type, (int) $stored);
case self::STORE_REFERENCE:
return self::type_def($type)['ref'] === 'term'
? self::resolve_term((int) $stored)
: self::resolve_post((int) $stored);
case self::STORE_GROUP:
return self::resolve_group($field, $stored);
}
/*
* Rich text goes through wpautop on the way out, like post content does through the_content.
* wp_editor (TinyMCE with wpautop on) strips the
tags when it saves and stores paragraphs * as blank-line-separated text — that is WordPress's convention, and it relies on the display side * putting the paragraphs back. Until 2026-09-13 nothing did: a practice area's two-paragraph overview * edited in the term screen came out on the front end as one run-on block (kit-imported values * carried explicit
tags, which is why it never showed before a buyer edited one).
* wpautop leaves already-wrapped HTML alone, so imported values are unchanged.
*/
if ($type === 'richtext') {
return $stored === '' ? '' : wpautop($stored);
}
/*
* A date goes out as a structure, not the stored `2026-11-14` (theme 2.0.73, template #13).
*
* Stored as `Y-m-d` so a query can compare and sort it (`meta: { compare: '>=', value: '@today' }`);
* but no layout wants to print that string. A diary row wants the day large, the month beside it and the
* weekday under; a card wants "14 November 2026" in the site's own date format; a condition wants "is it
* past". Until now the field resolved to the raw string and none of those had a path — twelve kits, and
* not one used a date field. Empty stays empty in every part, `hasDate` is '' — the same convention as
* `thumbnail.hasImage`.
*/
if ($type === 'date') {
return self::resolve_date($stored);
}
// Scalars go out unchanged (kses already ran on write)
return $stored;
}
/**
* `Y-m-d` -> the parts a layout prints. `isPast` / `isUpcoming` are judged against the site's current date
* (same calendar the date was entered in), so "Sold out" / "Register" / "Report" conditions need no script.
*/
public static function resolve_date(string $stored): array
{
$empty = [
'iso' => '', 'long' => '', 'short' => '', 'day' => '', 'month' => '', 'monthLong' => '', 'year' => '',
'weekday' => '', 'weekdayLong' => '', 'hasDate' => '', 'isPast' => '', 'isToday' => '', 'isUpcoming' => '',
];
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $stored)) {
return $empty;
}
$ts = strtotime($stored . ' 12:00:00');
if ($ts === false) {
return $empty;
}
$today = current_time('Y-m-d');
return [
'iso' => $stored,
'long' => date_i18n(get_option('date_format') ?: 'j F Y', $ts),
'short' => date_i18n('j M Y', $ts),
'day' => date_i18n('j', $ts),
'month' => date_i18n('M', $ts),
'monthLong' => date_i18n('F', $ts),
'year' => date_i18n('Y', $ts),
'weekday' => date_i18n('D', $ts),
'weekdayLong' => date_i18n('l', $ts),
'hasDate' => '1',
'isPast' => strcmp($stored, $today) < 0 ? '1' : '',
'isToday' => $stored === $today ? '1' : '',
'isUpcoming' => strcmp($stored, $today) >= 0 ? '1' : '',
];
}
private static function resolve_attachment(string $type, int $id): array
{
$empty = [
'id' => 0, 'url' => '', 'alt' => '', 'width' => '', 'height' => '',
'filename' => '', 'mime' => '', 'size' => '',
'hasImage' => '', 'hasFile' => '',
];
if ($id <= 0 || get_post_type($id) !== 'attachment') {
return $empty;
}
$url = (string) (wp_get_attachment_url($id) ?: '');
$mime = (string) get_post_mime_type($id);
$out = array_merge($empty, [
'id' => $id,
'url' => $url,
'mime' => $mime,
'filename' => basename((string) get_attached_file($id)),
'hasFile' => $url !== '' ? '1' : '',
]);
$path = get_attached_file($id);
if ($path && file_exists($path)) {
$out['size'] = size_format((int) filesize($path));
}
if ($type === 'image' || strpos($mime, 'image/') === 0) {
$src = wp_get_attachment_image_src($id, 'large');
if ($src) {
$out['url'] = $src[0];
$out['width'] = (string) $src[1];
$out['height'] = (string) $src[2];
}
// With no alt in the media library, fall back to the attachment's title — nine times out of ten
// a client leaves that box empty, and a content image with alt="" does not exist for a screen
// reader
$alt = trim((string) get_post_meta($id, '_wp_attachment_image_alt', true));
$out['alt'] = $alt !== '' ? $alt : (string) get_the_title($id);
$out['hasImage'] = $out['url'] !== '' ? '1' : '';
}
return $out;
}
private static function resolve_post(int $id): array
{
$empty = [
'id' => 0, 'title' => '', 'url' => '', 'date' => '', 'excerpt' => '',
'thumbnail' => ['url' => '', 'alt' => '', 'hasImage' => ''],
'hasRef' => '',
];
$post = $id > 0 ? get_post($id) : null;
if (!$post || $post->post_status !== 'publish') {
// The referenced post was deleted or returned to draft -> treat it as unreferenced rather than
// rendering a dead link
return $empty;
}
$thumb = get_post_thumbnail_id($post);
$url = $thumb ? (wp_get_attachment_image_url($thumb, 'large') ?: '') : '';
$out = [
'id' => $post->ID,
'title' => get_the_title($post),
'url' => (string) get_permalink($post),
'date' => get_the_date('', $post),
'excerpt' => (string) $post->post_excerpt,
'thumbnail' => [
'url' => $url,
'alt' => $url ? self::attachment_alt((int) $thumb, get_the_title($post)) : '',
'hasImage' => $url ? '1' : '',
],
'hasRef' => '1',
];
/*
* A reference to a catalogue item carries the item's `product.*` too (2026-09-15, template #12).
*
* An FF&E schedule is a group whose rows point at products (area - piece - quantity). Until now the row
* saw the piece's title, link and thumbnail and nothing else — no model number, no price, no add-to-enquiry
* link — while the same product one loop away had all of them through `post.product.*`. The only way to
* print the model on the schedule row was a second query per row, which the engine does not have.
* The bag is lazy, so a reference to a plain page costs nothing and resolves no adapter.
*/
if (\Aumframe\Catalog\Catalog::adapter_for((int) $post->ID)) {
$out['product'] = new \Aumframe\Catalog\Bag((int) $post->ID);
}
/*
* …and its own fields, `.meta.