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 '
| ';
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 ' ';
}
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 ' . esc_html($field['help']) . ' '; } echo '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
%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 ' ';
}
private static function group_row(array $subs, array $row, string $name, string $index): void
{
$prev = self::$in_group;
self::$in_group = true;
echo '';
foreach ($rows as $i => $row) {
self::group_row($subs, is_array($row) ? $row : [], $name, (string) $i);
}
echo ' ';
echo '';
self::group_row($subs, [], $name, '__i__');
echo '';
printf(
'',
esc_html__('Add row', 'aumframe')
);
echo '';
echo ' ';
self::$in_group = $prev;
}
}
';
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 ' |
|---|