Files
AsterionWP2026/tools/av-bricks-rest-bridge.php
j.foucher 86cd37ed80 fix(bricks): stop overriding bricks_template supports — only add custom-fields
The REST bridge was rewriting the `supports` array on bricks_template (and
case_study, scenario) by force-adding ['title','editor','author','custom-fields'].
For bricks_template specifically, Bricks's own registration sets supports =
['author','revisions','thumbnail','title'] — deliberately WITHOUT 'editor',
because the post type is meant to be edited only via the Bricks builder, not
the WP block editor.

Adding 'editor' confused Bricks's builder : it would refuse to open the
template with "Invalid post type" because the post type now claimed to support
two incompatible editors.

Fix : keep the original supports list untouched, only append 'custom-fields'
(which IS required for /wp/v2/<type>/<id> to expose the `meta` field). All
templates (Header #25, Footer #46, Single Post #154) should now open in the
Bricks builder again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:39:44 +02:00

311 lines
14 KiB
PHP

<?php
/**
* Plugin Name: Asterion — Bricks REST Bridge
* Description: Exposes Bricks Builder data (templates CPT + page/post/CPT meta keys) over the WP REST API so the bricks-mcp-server (sabiertas) can read/write Bricks content. Idempotent — safe to keep installed.
*
* Why this is needed :
* - Bricks registers the bricks_template CPT WITHOUT show_in_rest, so /wp/v2/bricks_template returns 404.
* - The Bricks element JSON lives in post_meta keys (_bricks_page_content_2 etc.) that aren't exposed by default.
* - register_post_meta() with show_in_rest=true is the canonical way to expose them; meta is then read/written via /wp/v2/<post_type>/<id> with the `meta` field.
*
* Auth :
* - Read : public (REST default for show_in_rest)
* - Write : edit_posts capability (=> the WP Application Password user must have at least Editor role)
*/
defined('ABSPATH') || exit;
/* ----------------------------------------------------------------
1) Force show_in_rest=true on bricks_template + our CPTs.
---------------------------------------------------------------- */
add_filter('register_post_type_args', function ($args, $post_type) {
$needs_rest = ['bricks_template', 'case_study', 'scenario'];
if (! in_array($post_type, $needs_rest, true)) {
return $args;
}
$args['show_in_rest'] = true;
$args['rest_base'] = $post_type === 'bricks_template' ? 'bricks_template' : ($post_type . 's');
$args['rest_controller_class'] = 'WP_REST_Posts_Controller';
// Only ADD 'custom-fields' (required so /wp/v2/<type> exposes the meta field).
// Do NOT add 'editor' or override the whole supports array — that breaks the
// Bricks builder for bricks_template (Bricks's CPT supports = author/revisions/
// thumbnail/title — no 'editor', deliberately).
$existing_supports = $args['supports'] ?? [];
if (! in_array('custom-fields', $existing_supports, true)) {
$existing_supports[] = 'custom-fields';
}
$args['supports'] = $existing_supports;
return $args;
}, 10, 2);
/* ----------------------------------------------------------------
2) Register Bricks meta keys for REST on every relevant post type.
---------------------------------------------------------------- */
add_action('init', function () {
// Post types that may carry Bricks content
$post_types = ['page', 'post', 'bricks_template', 'case_study', 'scenario'];
// Bricks meta keys we want exposed (read+write) over REST
// Keys come from wp-content/themes/bricks/functions.php
$bricks_meta = [
'_bricks_page_content_2' => 'string', // The element JSON (the main payload)
'_bricks_page_css_2' => 'string',
'_bricks_page_header_2' => 'string',
'_bricks_page_footer_2' => 'string',
'_bricks_page_settings' => 'string',
'_bricks_template_type' => 'string',
'_bricks_template_settings' => 'string',
];
$auth_cb = function () {
return current_user_can('edit_posts');
};
// Pass-through sanitize : keep the JSON string verbatim. Default sanitize for
// type=string strips slashes / escapes which corrupts our JSON payload.
$sanitize_passthrough = function ($value) { return $value; };
foreach ($post_types as $type) {
foreach ($bricks_meta as $meta_key => $meta_type) {
register_post_meta($type, $meta_key, [
'type' => $meta_type,
'single' => true,
'show_in_rest' => true,
'auth_callback' => $auth_cb,
'sanitize_callback' => $sanitize_passthrough,
]);
}
}
}, 30); // priority 30 — after both Bricks (10-25) and our CPTs registration (10)
/* ----------------------------------------------------------------
3) Permit also unknown _bricks_* meta keys (e.g. _bricks_snapshot_<ts>)
so MCP snapshots work without us pre-registering each key.
---------------------------------------------------------------- */
add_filter('is_protected_meta', function ($protected, $meta_key, $meta_type) {
if ($meta_type !== 'post') return $protected;
if (strpos($meta_key, '_bricks_snapshot_') === 0) {
return false; // expose snapshot meta in REST despite the leading underscore
}
return $protected;
}, 10, 3);
/* ----------------------------------------------------------------
4) AUTO-MIRROR : the bricks-mcp-server writes element JSON to
_bricks_page_content_2, but Bricks reads HEADER templates from
_bricks_page_header_2 and FOOTER templates from _bricks_page_footer_2.
Mirror the content into the correct key whenever a bricks_template
with type=header/footer is saved.
---------------------------------------------------------------- */
/* DUAL-FORMAT META READ : the MCP server writes Bricks element data as a JSON
string into post_meta. Bricks's renderer expects a PHP array. We keep the JSON
string format in DB (so REST reads it back fine for the MCP's Json.parse), and
decode on the fly when Bricks reads via get_post_meta (non-REST context).
Result : same value, two views — JSON for the MCP, array for the renderer. */
add_filter('get_post_metadata', 'av_decode_bricks_meta_for_renderer', 10, 5);
function av_decode_bricks_meta_for_renderer($value, $post_id, $meta_key, $single, $meta_type) {
// Only post-type meta concerns us
if ($meta_type !== 'post') return $value;
// Only Bricks element content keys
$content_keys = ['_bricks_page_content_2', '_bricks_page_header_2', '_bricks_page_footer_2'];
if (! in_array($meta_key, $content_keys, true)) return $value;
// In REST context, leave the raw string for the MCP server
if (defined('REST_REQUEST') && REST_REQUEST) return $value;
// Bricks renderer context : fetch the raw stored value (bypassing this filter
// via remove_filter, then re-add) and decode JSON → array
remove_filter('get_post_metadata', __FUNCTION__, 10);
$raw = get_post_meta($post_id, $meta_key, $single);
add_filter('get_post_metadata', __FUNCTION__, 10, 5);
if (! is_string($raw)) return $value;
$trim = trim($raw);
if ($trim === '' || $trim === '[]') return $value;
if ($trim[0] !== '[' && $trim[0] !== '{') return $value;
$decoded = json_decode($raw, true);
if (! is_array($decoded)) return $value;
// WP's get_metadata_raw() convention for filter return values :
// - When filter returns array AND $single=true → WP returns $check[0]
// - When filter returns array AND $single=false → WP returns $check as-is
// Both cases need a list wrapper around our decoded value so WP's
// single-mode auto-indexing yields the decoded array (not the section).
return [$decoded];
}
add_action('updated_post_meta', 'av_mirror_bricks_template_content', 10, 4);
add_action('added_post_meta', 'av_mirror_bricks_template_content', 10, 4);
function av_mirror_bricks_template_content($meta_id, $post_id, $meta_key, $meta_value) {
// Trigger 1 : content updated → mirror to header/footer if type already known
// Trigger 2 : type updated → re-mirror existing content (fixes the order-of-writes
// on REST create_template which writes content before type)
if (! in_array($meta_key, ['_bricks_page_content_2', '_bricks_template_type'], true)) return;
if (get_post_type($post_id) !== 'bricks_template') return;
// Reuse the av_decode filter logic by reading via get_post_meta (which now
// returns array) when the value is JSON, or by direct decoding here.
if ($meta_key === '_bricks_page_content_2') {
$type = get_post_meta($post_id, '_bricks_template_type', true);
$content = is_string($meta_value) ? $meta_value : '';
} else {
// type just updated → fetch raw content (bypass our filter to get string)
$type = is_string($meta_value) ? $meta_value : '';
remove_filter('get_post_metadata', 'av_decode_bricks_meta_for_renderer', 10);
$content = get_post_meta($post_id, '_bricks_page_content_2', true);
add_filter('get_post_metadata', 'av_decode_bricks_meta_for_renderer', 10, 5);
}
$mirror_key = match ($type) {
'header' => '_bricks_page_header_2',
'footer' => '_bricks_page_footer_2',
default => null,
};
if (! $mirror_key) return;
// Bricks stores element JSON as a PHP-serialized array. Decode JSON string to array first.
$value = is_string($content) ? json_decode($content, true) : $content;
if (! is_array($value)) return;
// Bypass register_post_meta string-coercion by writing a serialized blob directly via $wpdb.
// This is what Bricks's own builder save does — the value lands as a serialized PHP array,
// which `get_post_meta($id, $key, true)` auto-unserializes back to array on read.
global $wpdb;
$serialized = maybe_serialize($value);
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1",
$post_id, $mirror_key
));
if ($existing) {
$wpdb->update(
$wpdb->postmeta,
['meta_value' => $serialized],
['meta_id' => $existing],
['%s'], ['%d']
);
} else {
$wpdb->insert(
$wpdb->postmeta,
['post_id' => $post_id, 'meta_key' => $mirror_key, 'meta_value' => $serialized],
['%d', '%s', '%s']
);
}
wp_cache_delete($post_id, 'post_meta');
}
/* ----------------------------------------------------------------
5) BACKFILL : trigger a manual mirror via URL.
Useful one-shot for templates created before this plugin was installed.
Visit /?av_mirror_template=ID
---------------------------------------------------------------- */
/* One-shot : ensure all Bricks content meta is stored as JSON STRING in DB.
- PHP-serialized arrays (legacy from earlier hook) → re-encode to JSON
- JSON strings → leave alone
This is the canonical storage format. The av_decode filter handles
the array-view for Bricks's renderer at read time.
/?av_fix_all_bricks_content=1 */
add_action('init', function () {
if (empty($_GET['av_fix_all_bricks_content'])) return;
if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only');
global $wpdb;
$rows = $wpdb->get_results(
"SELECT meta_id, post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key IN ('_bricks_page_content_2','_bricks_page_header_2','_bricks_page_footer_2')
AND LENGTH(meta_value) > 4"
);
$report = ['Normalizing Bricks content meta to JSON strings...'];
foreach ($rows as $r) {
$val = $r->meta_value;
$first = $val[0] ?? '';
if ($first === '[' || $first === '{') {
// Already JSON string : skip
$report[] = " OK meta_id={$r->meta_id} post={$r->post_id} key={$r->meta_key} (JSON)";
continue;
}
// Serialized PHP array → unserialize, JSON-encode
if (is_serialized($val)) {
$array = unserialize($val);
if (is_array($array)) {
$json = wp_json_encode($array);
$wpdb->update(
$wpdb->postmeta,
['meta_value' => $json],
['meta_id' => $r->meta_id],
['%s'], ['%d']
);
wp_cache_delete($r->post_id, 'post_meta');
$report[] = " CONV meta_id={$r->meta_id} post={$r->post_id} key={$r->meta_key} (" . count($array) . " elements, " . strlen($json) . " bytes)";
continue;
}
}
$report[] = " SKIP meta_id={$r->meta_id} post={$r->post_id} key={$r->meta_key} (unknown format)";
}
header('Content-Type: text/plain; charset=utf-8');
echo implode("\n", $report);
exit;
});
add_action('init', function () {
if (empty($_GET['av_mirror_template'])) return;
// localhost-only debug — no auth required (we're on http://localhost:10004)
if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only');
$id = (int) $_GET['av_mirror_template'];
$content = get_post_meta($id, '_bricks_page_content_2', true);
$type = get_post_meta($id, '_bricks_template_type', true);
if (! is_string($content) && ! is_array($content)) wp_die('no content meta');
$value = is_string($content) ? json_decode($content, true) : $content;
if (! is_array($value)) wp_die('content is not an array');
$mirror_key = match ($type) {
'header' => '_bricks_page_header_2',
'footer' => '_bricks_page_footer_2',
default => null,
};
if (! $mirror_key) wp_die("no mirror needed for type=$type");
// Write directly to DB as a serialized PHP array (the format Bricks's renderer expects)
global $wpdb;
$serialized = maybe_serialize($value);
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1",
$id, $mirror_key
));
if ($existing) {
$wpdb->update($wpdb->postmeta, ['meta_value' => $serialized], ['meta_id' => $existing], ['%s'], ['%d']);
} else {
$wpdb->insert($wpdb->postmeta, ['post_id' => $id, 'meta_key' => $mirror_key, 'meta_value' => $serialized], ['%d', '%s', '%s']);
}
wp_cache_delete($id, 'post_meta');
// Verify
$after = get_post_meta($id, $mirror_key, true);
$is_array = is_array($after);
wp_die('<pre style="font:12px/1.5 monospace;padding:2rem;background:#0B1F3A;color:#E0C892;">'
. esc_html("Mirror done : template #$id ($type)\n _bricks_page_content_2 -> $mirror_key\n " . count($value) . " elements\n stored as " . ($is_array ? 'array (correct)' : 'string (WRONG)'))
. '</pre>', 'Mirror done');
});