a meta box on the edit screen * terms -> form rows on the term edit screen (the same field definitions and the same controls, not a * second implementation — two implementations always drift) * * The interface is vanilla JS, consistent with the theme's other admin screens, rather than introducing a build * step for one panel. * * @package Aumframe */ defined('ABSPATH') || exit; class Metabox { const NONCE = 'aumframe_ct_fields_nonce'; const INPUT = 'aumframe_ct'; /** * Are we rendering a repeater row right now? * * Rich text inside a group has to fall back to a plain textarea: TinyMCE cannot be cloned — a new row is * produced by JS copying a template row, the copied editor is a dead shell, and it silently submits nothing. * Better one fewer WYSIWYG inside a group than a box that looks usable and stores nothing. */ private static $in_group = false; public static function boot(): void { add_action('add_meta_boxes', [self::class, 'add_boxes']); add_action('save_post', [self::class, 'save_post'], 10, 2); add_action('admin_enqueue_scripts', [self::class, 'assets']); foreach (Store::taxonomies() as $tx) { if (empty($tx['fields'])) { continue; } add_action($tx['name'] . '_add_form_fields', [self::class, 'term_add_form']); add_action($tx['name'] . '_edit_form_fields', [self::class, 'term_edit_form'], 10, 2); add_action('created_' . $tx['name'], [self::class, 'save_term']); add_action('edited_' . $tx['name'], [self::class, 'save_term']); } } // ------------------------------------------------------------------ Assets public static function assets(string $hook): void { $screen = get_current_screen(); if (!$screen) { return; } $is_post = in_array($hook, ['post.php', 'post-new.php'], true) && Store::fields_for_post_type((string) $screen->post_type); $is_term = in_array($hook, ['term.php', 'edit-tags.php'], true) && Store::fields_for_taxonomy((string) $screen->taxonomy); if (!$is_post && !$is_term) { return; } // The media modal. Image, video and file fields all depend on it, and without enqueuing it "Select" // does nothing at all wp_enqueue_media(); $base = AUMFRAME_URL . 'assets/model/'; $dir = AUMFRAME_DIR . 'assets/model/'; wp_enqueue_style('aumframe-ct-fill', $base . 'ct-fill.css', [], @filemtime($dir . 'ct-fill.css') ?: null); wp_enqueue_script('aumframe-ct-fill', $base . 'ct-fill.js', [], @filemtime($dir . 'ct-fill.js') ?: null, true); wp_add_inline_script('aumframe-ct-fill', 'window.AUMFRAME_CT_FILL = ' . wp_json_encode([ 'i18n' => [ 'select' => __('Select', 'aumframe'), 'replace' => __('Replace', 'aumframe'), 'remove' => __('Remove', 'aumframe'), 'addRow' => __('Add row', 'aumframe'), 'delRow' => __('Delete this row?', 'aumframe'), 'mediaBtn' => __('Use this file', 'aumframe'), ], ], JSON_UNESCAPED_UNICODE) . ';', 'before'); } // ------------------------------------------------------------------ The post side public static function add_boxes(): void { foreach (Store::post_types() as $pt) { if (empty($pt['fields'])) { continue; } /* * An extension entry has no labels — see `Store::label_for()`, which is where that reasoning and * the fallback to the registering side now live. This was the first place to need it; it was * written out by hand here while twelve other readers subscripted `['labels']` directly. */ $label = Store::label_for($pt, 'singular'); add_meta_box( 'aum-ct-fields', /* translators: %s: Singular name of the content type, e.g. "Project". */ sprintf(__('%s details', 'aumframe'), $label), [self::class, 'render_box'], $pt['name'], 'normal', 'high' ); } } public static function render_box(\WP_Post $post): void { $fields = Store::fields_for_post_type($post->post_type); wp_nonce_field('aumframe_ct_save_' . $post->ID, self::NONCE); echo '
'; foreach ($fields as $f) { self::render_field($f, (string) get_post_meta($post->ID, $f['key'], true), self::INPUT . '[' . $f['key'] . ']'); } echo '
'; } public static function save_post(int $post_id, \WP_Post $post): void { if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; } $fields = Store::fields_for_post_type($post->post_type); if (!$fields) { return; } if (!isset($_POST[self::NONCE]) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST[self::NONCE])), 'aumframe_ct_save_' . $post_id)) { return; } if (!current_user_can('edit_post', $post_id)) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Fields::sanitize() in the loop below sanitises each value by its declared field type. $raw = isset($_POST[self::INPUT]) && is_array($_POST[self::INPUT]) ? wp_unslash($_POST[self::INPUT]) : []; foreach ($fields as $f) { $value = Fields::sanitize($f, $raw[$f['key']] ?? ''); if ($value === '') { // An empty value deletes the meta rather than storing an empty string — otherwise "never filled // in" and "filled in as empty" look identical in the database and conditional rendering cannot tell // them apart delete_post_meta($post_id, $f['key']); } else { // JSON going into meta must be wp_slash'd first: update_post_meta unslashes once internally, and // without compensating, an escape like \u79f0 becomes u79f0 and \n becomes n — non-ASCII content // is mangled wholesale, with nothing reported update_post_meta($post_id, $f['key'], wp_slash($value)); } } } // ------------------------------------------------------------------ The term side public static function term_add_form($taxonomy): void { $fields = Store::fields_for_taxonomy((string) $taxonomy); if (!$fields) { return; } wp_nonce_field('aumframe_ct_save_term', self::NONCE); echo '
'; foreach ($fields as $f) { self::render_field($f, '', self::INPUT . '[' . $f['key'] . ']'); } echo '
'; } public static function term_edit_form($term, $taxonomy): void { $fields = Store::fields_for_taxonomy((string) $taxonomy); if (!$fields) { return; } wp_nonce_field('aumframe_ct_save_term', self::NONCE); foreach ($fields as $f) { // The term edit screen is a , so fields go inside elements echo ''; } } public static function save_term($term_id): void { $term = get_term((int) $term_id); if (!$term || is_wp_error($term)) { return; } $fields = Store::fields_for_taxonomy($term->taxonomy); if (!$fields) { return; } if (!isset($_POST[self::NONCE]) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST[self::NONCE])), 'aumframe_ct_save_term')) { return; } if (!current_user_can('manage_categories')) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Fields::sanitize() in the loop below sanitises each value by its declared field type. $raw = isset($_POST[self::INPUT]) && is_array($_POST[self::INPUT]) ? wp_unslash($_POST[self::INPUT]) : []; foreach ($fields as $f) { $value = Fields::sanitize($f, $raw[$f['key']] ?? ''); if ($value === '') { delete_term_meta((int) $term_id, $f['key']); } else { update_term_meta((int) $term_id, $f['key'], wp_slash($value)); } } } // ------------------------------------------------------------------ Controls /** * One field's control. * * @param bool $with_label the term edit screen emits its label from the
'; echo '
'; self::render_field($f, (string) get_term_meta($term->term_id, $f['key'], true), self::INPUT . '[' . $f['key'] . ']', false); echo '
, so it must not be emitted * again here */ public static function render_field(array $field, string $stored, string $name, bool $with_label = true): void { $type = $field['type'] ?? 'text'; $id = 'aum-ct-' . sanitize_html_class($field['key']) . '-' . wp_rand(1000, 9999); echo '
'; if ($with_label) { echo ''; } self::control($field, $stored, $name, $id); if (($field['help'] ?? '') !== '') { echo '

' . esc_html($field['help']) . '

'; } echo '
'; } private static function control(array $field, string $stored, string $name, string $id): void { $type = $field['type'] ?? 'text'; $def = Fields::type_def($type); switch ($def['control']) { case 'textarea': printf( '', esc_attr($id), esc_attr($name), esc_textarea($stored) ); return; case 'richtext': if (self::$in_group) { printf( '', esc_attr($id), esc_attr($name), esc_textarea($stored) ); return; } /* * wp_editor rather than a textarea: the users of a rich-text field are clients, not us. Making * them type

tags into a plain text box means the field was never built. */ wp_editor($stored, str_replace(['[', ']'], '_', $name), [ 'textarea_name' => $name, 'textarea_rows' => 8, 'media_buttons' => false, 'teeny' => true, ]); return; case 'number': printf( '', esc_attr($id), esc_attr($name), esc_attr($stored) ); return; case 'toggle': printf( '', esc_attr($id), esc_attr($name), checked($stored, '1', false), esc_html__('On', 'aumframe') ); return; case 'select': printf(''; return; case 'color': /* * The picker and a hex input, both. * * A native `` opens the system colour panel on macOS — RGB sliders only, * with nowhere to type `#RRGGBB`. And a buyer's or designer's brand colour is always a hex * value; forcing them to convert `#9C5B3C` into three decimal numbers is awkward and easy to * get wrong. (Raised by the user on 2026-08-31.) * * `name` lives on the text input and the picker only picks — what is submitted is the * text field's value. With `name` on both, the later would overwrite the earlier and a colour * chosen in the picker would not be stored. * * Invalid input needs no guard here: the save side runs `sanitize_hex_color()` and stores empty * for anything invalid. */ $val = $stored !== '' ? $stored : '#000000'; printf( '' . '' . '' . '', esc_attr($id), esc_attr($val), esc_attr($name) ); return; case 'date': printf( '', esc_attr($id), esc_attr($name), esc_attr($stored) ); return; case 'url': printf( '', esc_attr($id), esc_attr($name), esc_attr($stored) ); return; case 'email': printf( '', esc_attr($id), esc_attr($name), esc_attr($stored) ); return; case 'media': self::media_control($field, $stored, $name, $id); return; case 'post_ref': self::post_ref_control($field, $stored, $name, $id); return; case 'term_ref': self::term_ref_control($field, $stored, $name, $id); return; case 'group': self::group_control($field, $stored, $name); return; default: printf( '', esc_attr($id), esc_attr($name), esc_attr($stored) ); } } /** * The media control. * * Stores an ID rather than a URL: a URL breaks after a domain change, a move to a CDN or regenerated * thumbnails, while an ID does not. The read side turns the ID back into url, alt and dimensions (see * CT_Fields::resolve). */ private static function media_control(array $field, string $stored, string $name, string $id): void { $att_id = (int) $stored; $mime = (string) ($field['mime'] ?? ''); $is_img = $att_id && strpos((string) get_post_mime_type($att_id), 'image/') === 0; $preview = ''; if ($att_id && $is_img) { $preview = (string) wp_get_attachment_image($att_id, 'thumbnail', false, ['class' => 'aum-ct-media-img']); } elseif ($att_id) { $preview = '' . esc_html(basename((string) get_attached_file($att_id))) . ''; } printf( '

%s
', esc_attr($mime), esc_attr($id), esc_attr($name), esc_attr((string) $att_id), /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $preview holds one of three things: an empty string, core markup from wp_get_attachment_image(), or a span whose only variable part is esc_html(basename(...)). */ $preview, esc_html($att_id ? __('Replace', 'aumframe') : __('Select', 'aumframe')), $att_id ? '' : ' hidden', esc_html__('Remove', 'aumframe') ); } /** * A content reference. * * There is a cap; beyond it, entry falls back to typing an ID **and says explicitly that the list was * truncated** — without saying so, the user concludes "my post is gone", a trap already hit once in * SiteData. */ private static function post_ref_control(array $field, string $stored, string $name, string $id): void { $pt = (string) ($field['postType'] ?? ''); if ($pt === '' || !post_type_exists($pt)) { printf( ' %s', esc_attr($id), esc_attr($name), esc_attr($stored), esc_html__('This field has no content type set, so the ID must be typed in.', 'aumframe') ); return; } $limit = 200; $posts = get_posts([ 'post_type' => $pt, 'numberposts' => $limit + 1, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', ]); $truncated = count($posts) > $limit; $posts = array_slice($posts, 0, $limit); printf(''; if ($truncated) { printf( '

%s

', /* translators: %d: Maximum number of items the picker lists at once. */ esc_html(sprintf(__('Too many items — only the first %d are listed.', 'aumframe'), $limit)) ); } } private static function term_ref_control(array $field, string $stored, string $name, string $id): void { $tx = (string) ($field['taxonomy'] ?? ''); if ($tx === '' || !taxonomy_exists($tx)) { printf( ' %s', esc_attr($id), esc_attr($name), esc_attr($stored), esc_html__('This field has no taxonomy set, so the ID must be typed in.', 'aumframe') ); return; } $terms = get_terms(['taxonomy' => $tx, 'hide_empty' => false, 'number' => 200]); $terms = is_wp_error($terms) ? [] : $terms; printf(''; } /** * A repeater. * * A row's inputs are named as nested arrays like `aumframe_ct[specs][0][label]` rather than a lump of JSON in a * hidden field — the browser natively collects those into a multidimensional array on the PHP side, which * removes a hand-written serialise/deserialise step and with it one more place that can drift. * * A new row is cloned by JS from the template row below, with `__i__` replaced by the next index. */ private static function group_control(array $field, string $stored, string $name): void { $subs = Fields::normalize_subfields($field); if (!$subs) { printf('

%s

', esc_html__('This repeater has no sub-fields yet — add them in Content Model.', 'aumframe')); return; } $rows = json_decode($stored, true); $rows = is_array($rows) ? $rows : []; echo '
'; echo '
'; foreach ($rows as $i => $row) { self::group_row($subs, is_array($row) ? $row : [], $name, (string) $i); } echo '
'; echo ''; printf( '

', esc_html__('Add row', 'aumframe') ); echo '
'; } private static function group_row(array $subs, array $row, string $name, string $index): void { $prev = self::$in_group; self::$in_group = true; echo '
'; echo '
'; foreach ($subs as $sub) { self::render_field( $sub, (string) ($row[$sub['key']] ?? ''), $name . '[' . $index . '][' . $sub['key'] . ']' ); } echo '
'; printf( '', esc_attr__('Delete this row', 'aumframe') ); echo '
'; self::$in_group = $prev; } }