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.` (2026-09-27, template #24 Plainsum). * * A product points at the factory that makes it; the product page wants to print "Made by Malhas Cávado - * Barcelos, Portugal". The name came through (`title`), the place — a field on the factory — did not, so * the binding kept its bake-time placeholder. Same shape as the `product.*` gap above, one hop further. * The engine's own lazy bag, so nothing is read until a layout asks, and fields resolve through * `aumframe/meta` exactly as they do on the post itself (images, dates and references come out structured). */ if (class_exists('\\Aumframe\\MetaBag')) { $out['meta'] = new \Aumframe\MetaBag((int) $post->ID); } return $out; } private static function resolve_term(int $id): array { $empty = ['id' => 0, 'name' => '', 'url' => '', 'count' => '', 'description' => '', 'hasRef' => '']; $term = $id > 0 ? get_term($id) : null; if (!$term || is_wp_error($term)) { return $empty; } $link = get_term_link($term); return [ 'id' => $term->term_id, 'name' => $term->name, 'url' => is_wp_error($link) ? '' : (string) $link, 'count' => (string) $term->count, 'description' => (string) $term->description, 'hasRef' => '1', ]; } private static function attachment_alt(int $id, string $fallback): string { $alt = $id ? trim((string) get_post_meta($id, '_wp_attachment_image_alt', true)) : ''; return $alt !== '' ? $alt : $fallback; } /** * A repeater -> a plain list whose rows have each subfield resolved. * * The result must be a list with consecutive indices and nothing else mixed in — a loop foreaches it, * and a stray 'count' key would render one extra row. */ private static function resolve_group(array $field, string $stored): array { $rows = json_decode($stored, true); if (!is_array($rows)) { return []; } $subs = self::normalize_subfields($field); $out = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $line = []; foreach ($subs as $sub) { $line[$sub['key']] = self::resolve($sub, (string) ($row[$sub['key']] ?? '')); } $out[] = $line; } return $out; } // ------------------------------------------------------------------ The list the editor sees /** * Expand one field into the several paths it offers in the binding panel. * * An image field should be three entries there (url, alt, has-image), not one — offering only * `post.meta.cover` means the user binds it and receives an Array, and the front end is blank with no clue * why. * * @param string $prefix e.g. 'post.meta' */ public static function binding_paths(array $field, string $prefix): array { $key = $field['key']; $label = $field['label'] !== '' ? $field['label'] : $key; $type = $field['type'] ?? 'text'; $base = $prefix . '.' . $key; $def = self::type_def($type); switch (self::store_of($type)) { case self::STORE_ATTACHMENT: if ($type === 'image') { return [ ['path' => "$base.url", 'label' => $label, 'type' => 'image'], ['path' => "$base.alt", 'label' => "$label · " . __('alt text', 'aumframe'), 'type' => 'text'], ['path' => "$base.hasImage", 'label' => "$label · " . __('has image', 'aumframe'), 'type' => 'text'], ]; } return [ ['path' => "$base.url", 'label' => $label, 'type' => 'url'], ['path' => "$base.filename", 'label' => "$label · " . __('file name', 'aumframe'), 'type' => 'text'], ['path' => "$base.size", 'label' => "$label · " . __('size', 'aumframe'), 'type' => 'text'], ['path' => "$base.hasFile", 'label' => "$label · " . __('has file', 'aumframe'), 'type' => 'text'], ]; case self::STORE_REFERENCE: if (($def['ref'] ?? 'post') === 'term') { return [ ['path' => "$base.name", 'label' => $label, 'type' => 'text'], ['path' => "$base.url", 'label' => "$label · " . __('Link', 'aumframe'), 'type' => 'url'], ['path' => "$base.count", 'label' => "$label · " . __('count', 'aumframe'), 'type' => 'text'], ]; } $paths = [ ['path' => "$base.title", 'label' => $label, 'type' => 'text'], ['path' => "$base.url", 'label' => "$label · " . __('Link', 'aumframe'), 'type' => 'url'], ['path' => "$base.thumbnail.url", 'label' => "$label · " . __('thumbnail', 'aumframe'), 'type' => 'image'], ['path' => "$base.hasRef", 'label' => "$label · " . __('has relation', 'aumframe'), 'type' => 'text'], ]; // A reference into a catalogue type also offers the item's product fields (see resolve_post) $target = (string) ($field['postType'] ?? ''); if ($target !== '' && isset(\Aumframe\Catalog\Catalog::adapters()[$target])) { foreach (\Aumframe\Catalog\Catalog::FIELDS as $key => $meta) { $paths[] = ['path' => "$base.product.$key", 'label' => "$label · " . $meta['label'], 'type' => $meta['type']]; } } return $paths; case self::STORE_GROUP: return [ // A group cannot bind to a property; it is a loop's data source — listed separately in the // panel, see the list type ['path' => $base, 'label' => $label, 'type' => 'list', 'fields' => self::sub_binding_paths($field)], /* * The row count. Numbers like "view documents ③" or "5 models" need it. * * Before this they could only be typed by hand — and a hand-typed number **is wrong on the * next post**, silently. This was the gap of "a repeater can be stored and not counted". * Resolution lives in the plugin's `Renderer::resolve_raw` (`.count` on a list). */ ['path' => "$base.count", 'label' => "$label · " . __('row count', 'aumframe'), 'type' => 'text'], ]; } if ($type === 'date') { return [ ['path' => "$base.long", 'label' => $label, 'type' => 'text'], ['path' => "$base.short", 'label' => "$label · " . __('short', 'aumframe'), 'type' => 'text'], ['path' => "$base.day", 'label' => "$label · " . __('day', 'aumframe'), 'type' => 'text'], ['path' => "$base.month", 'label' => "$label · " . __('month', 'aumframe'), 'type' => 'text'], ['path' => "$base.monthLong", 'label' => "$label · " . __('month, full', 'aumframe'), 'type' => 'text'], ['path' => "$base.year", 'label' => "$label · " . __('year', 'aumframe'), 'type' => 'text'], ['path' => "$base.weekday", 'label' => "$label · " . __('weekday', 'aumframe'), 'type' => 'text'], ['path' => "$base.weekdayLong", 'label' => "$label · " . __('weekday, full', 'aumframe'), 'type' => 'text'], ['path' => "$base.iso", 'label' => "$label · " . __('YYYY-MM-DD', 'aumframe'), 'type' => 'text'], ['path' => "$base.hasDate", 'label' => "$label · " . __('has date', 'aumframe'), 'type' => 'text'], ['path' => "$base.isUpcoming", 'label' => "$label · " . __('is today or later', 'aumframe'), 'type' => 'text'], ['path' => "$base.isPast", 'label' => "$label · " . __('is in the past', 'aumframe'), 'type' => 'text'], ]; } return [['path' => $base, 'label' => $label, 'type' => $def['bindType'] ?? 'text']]; } /** Paths inside a group (used within a loop, where the prefix comes from the loop's `as`, so these are * relative). */ private static function sub_binding_paths(array $field): array { $out = []; foreach (self::normalize_subfields($field) as $sub) { foreach (self::binding_paths($sub, '') as $p) { // binding_paths yields '.key…', so the leading dot is removed $p['path'] = ltrim($p['path'], '.'); $out[] = $p; } } return $out; } /** * Which expressions may emit raw HTML. * * Rich-text fields only. Everything else is esc_html'd — a `