feat(bricks+wpml): full programmatic page generation + native WPML CTE integration

Sessions 3-4 deliverables. Establishes the Asterion content pipeline :
EN pages generated programmatically from content-source/*-data.php, FR
translations rebuilt natively by Bricks's WPML integration from EN structure
+ WPML strings (no direct FR _bricks_page_content_2 writes).

## tools/_av-bricks-shared.php (the library)
Shared helpers + element atoms (eyebrow/heading/text/button/text-link) +
section builders (hero/prose/list/cards/cta/upsell/closer) + page write
pipeline that handles : write EN content via $wpdb (bypass Bricks's filter),
force Bricks::Elements::load_elements() so controls are populated, fire
wpml_page_builder_register_strings, push dict FR into icl_string_translations
(skip user CTE edits, auto-prefix /fr on internal URLs), trigger Bricks
native FR rebuild via wpml_page_builder_string_translated, and ensure a CTE
job exists (icl_translate_job.editor='wpml' + revision=NULL + populate
icl_translate rows with base64+gzip-encoded EN/FR strings).

## tools/av-generate-{industries,solutions}.php
Thin wrappers : URL trigger (template_redirect, not init — Bricks elements
load on 'wp' hook), add_filter('av_fr_translation_dict', ...) to register
FR strings, content-type-specific hub builder. ~150 FR strings per type.

## tools/av-translate-{industries,solutions}-fr.php
One-shot seeds for the initial FR linked posts via WPML trid mapping.

## tools/av-wpml-* operational mu-plugins
- set-cte : switch doc_translation_method to ICL_TM_TMETHOD_EDITOR (=1)
- create-jobs : reset CTE jobs (status=10 + needs_update=1 + editor=wpml +
  revision=NULL). Integrated into pipeline via av_ensure_wpml_cte_job().
- fix-status / repair : align FR posts to correct trid + cleanup orphans.
- push-translations : push dict into icl_string_translations.
- plugins-check / debug / dump-strings / icl-dump / rescan : diagnostics.

## tools/av-fix-link-types.php
One-shot DB migration : convert legacy link.type='internal'+url to 'external'
across all _bricks_page_*_2 meta values (JSON and PHP-serialized). Bricks's
set_link_attributes() only renders href= for type='external' — older session-3
templates lost their href silently.

## Other tools/ mu-plugins
- av-bricks-rest-bridge : pivot — exposes Bricks CPTs over REST, dual-format
  read filter (JSON for MCP / array for renderer), auto-mirror content→header/
  footer for templates.
- av-apply-bricks-styles, av-cleanup-orphans, av-create-wp-menu, av-debug-meta,
  av-dump-bricks, av-fix-template-meta : misc operational scripts.

## Bricks Config/
JSON snapshots of bricks_theme_styles, bricks_color_palette,
bricks_global_settings for diffable inspection.

## .gitignore
Add .mcp.json — holds per-developer WP App Password for the Bricks MCP server.

## wp-content/themes/asterion-bricks/assets/css/utilities.css
Note that all responsive overrides are now emitted natively by the page
generator via Bricks per-breakpoint settings, no custom CSS needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 19:40:28 +02:00
parent af0ed66122
commit d2badf49d4
28 changed files with 4305 additions and 1 deletions

View File

@@ -0,0 +1,304 @@
<?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';
$args['supports'] = array_unique(array_merge(
$args['supports'] ?? [],
['title', 'editor', 'author', 'custom-fields'] // custom-fields needed so /wp/v2/<type> exposes the `meta` field
));
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');
});