` — * filled in perfectly in the admin, a broken image on the front end, looking like the editor is at fault when in * fact this layer was never written. * * Four channels, all of them "the theme declares and the editor consumes", the same approach as the palette and * forms, with no field-by-field hand wiring: * * aumframe/meta post.meta.xxx on a single, resolved per access (a lazy query, never prefetched) * aumframe/term_meta term.meta.xxx on an archive * aumframe/site_data tells the editor which fields a type has and what each can bind to * aumframe/raw_expressions which expressions may emit raw HTML (rich-text fields only) * * @package Aumframe */ defined('ABSPATH') || exit; class Bridge { public static function boot(): void { add_filter('aumframe/meta', [self::class, 'post_meta'], 10, 3); add_filter('aumframe/term_meta', [self::class, 'term_meta'], 10, 3); add_filter('aumframe/site_data', [self::class, 'site_data']); add_filter('aumframe/raw_expressions', [self::class, 'raw_expressions']); } /** * Reading post.meta.. * * Returning null means "this key is not mine" and lets Aumframe fall back to `get_post_meta()` — fields * hand-written in a site mu-plugin and ACF's fields all travel that path, unaffected. * **The manager does not monopolise the runtime, which is the precondition for it not becoming a * limitation.** */ public static function post_meta($value, $post_id, $key) { if ($value !== null) { return $value; // Somebody else has already taken it over } $type = get_post_type((int) $post_id); $field = $type ? Store::field(Store::fields_for_post_type($type), (string) $key) : null; if (!$field) { return null; } return Fields::resolve($field, self::stored(get_post_meta((int) $post_id, (string) $key, true))); } /** * What the meta table holds, as the string resolve() expects. A group field is normally a JSON * string (the metabox writes it that way), but anything else writing the same key — wp-cli * `--format=json`, a migration plugin, the REST API — lands a PHP array, and `(string)` on that * printed "Array to string conversion" into the page (2026-09-13, buyer simulation: two * warnings above the header on every person edited through wp-cli). Arrays are re-encoded. */ private static function stored($raw): string { if (is_array($raw) || is_object($raw)) { return (string) wp_json_encode($raw); } return (string) $raw; } public static function term_meta($value, $term_id, $key) { if ($value !== null) { return $value; } $term = get_term((int) $term_id); if (!$term || is_wp_error($term)) { return null; } $field = Store::field(Store::fields_for_taxonomy($term->taxonomy), (string) $key); if (!$field) { return null; } return Fields::resolve($field, self::stored(get_term_meta((int) $term_id, (string) $key, true))); } /** * The field list for the editor. * * Report paths, not just field names: an image field is three entries in the binding panel (url, alt, * has-image), because binding a bare `post.meta.cover` yields an array and leaves the front end blank with * no clue why. * * The source is `get_registered_meta_keys()` rather than this configuration — so **fields hand-written in a * site mu-plugin and fields registered by ACF appear in the dropdown too**. Anyone taking a different route * is not penalised, and that matters more than the manager itself. */ public static function site_data($data) { if (!is_array($data)) { return $data; } foreach (['postTypes' => 'post', 'taxonomies' => 'term'] as $bucket => $meta_kind) { foreach ((array) ($data[$bucket] ?? []) as $i => $entry) { $name = $entry['name'] ?? ''; if ($name === '') { continue; } $data[$bucket][$i]['fields'] = self::fields_of($meta_kind, $name); } } return $data; } /** * Every bindable field path on one content type or taxonomy. * * Fields built in the manager get the full type translation; fields registered elsewhere (hand-written, ACF) * have no known type and get one path as text — listed is always better than not listed: the user at * least knows the field exists, and binding it shows the raw stored value. */ private static function fields_of(string $kind, string $object): array { $prefix = $kind === 'term' ? 'term.meta' : 'post.meta'; $defined = $kind === 'term' ? Store::fields_for_taxonomy($object) : Store::fields_for_post_type($object); $out = []; $seen = []; foreach ($defined as $f) { /* * The bare path goes into $seen too. * * An image field expands into three entries (url / alt / hasImage) and the bare `post.meta.cover` is * not among them — while it genuinely is a registered meta key, so the "registered elsewhere" round * below would add it back as an unknown field, putting an entirely plausible-looking "cover image" * entry in the dropdown. Binding it yields an array, the front end is blank, and nothing points at * the cause. */ $seen[$prefix . '.' . $f['key']] = true; foreach (Fields::binding_paths($f, $prefix) as $p) { $out[] = $p; $seen[$p['path']] = true; } } /* * Meta registered elsewhere (hand-written in a site mu-plugin, ACF's, another plugin's). * * A few are internal implementation details whose registration did not follow the underscore * convention: `footnotes` is where the block editor stores footnotes, and binding it to a canvas yields * a block of JSON. Listing it only makes people think they have a field called "footnotes". */ $internal = apply_filters('aumframe/ct_hidden_meta', ['footnotes']); $registered = get_registered_meta_keys($kind, $object); foreach ($registered as $key => $args) { // A leading underscore is WordPress's convention for internal use, not shown to people if (strpos($key, '_') === 0 || in_array($key, (array) $internal, true)) { continue; } $path = $prefix . '.' . $key; if (isset($seen[$path])) { continue; } $out[] = [ 'path' => $path, 'label' => (string) (($args['description'] ?? '') !== '' ? $args['description'] : $key), 'type' => 'text', ]; $seen[$path] = true; } return $out; } /** * Rich-text fields may emit raw HTML. * * Rich text only. Every other type is esc_html'd — a `