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>
1568 lines
64 KiB
PHP
1568 lines
64 KiB
PHP
<?php
|
|
/**
|
|
* Asterion — shared Bricks builders + FR translation pipeline.
|
|
*
|
|
* Required by each content-type generator (industries, solutions, technology,
|
|
* editorial). Owns :
|
|
* - layout/typography helpers (av_color, av_sp, av_id, av_radius, padding helpers)
|
|
* - element atoms (av_eyebrow, av_heading, av_text, av_button, av_text_link)
|
|
* - section builders (hero, prose, list, cards, cta_section, closer_cta, upsell)
|
|
* - page write pipeline (av_write_bricks_page_elements) — direct $wpdb + WPML
|
|
* rescan + FR auto-sync
|
|
* - FR translation pipeline (av_sync_fr_translation, av_fr_translation_dictionary)
|
|
* - FR initial seed (av_translate_page_fr) for the 1st EN→FR clone of each page
|
|
*
|
|
* Filename starts with `_` so WordPress's mu-plugin auto-loader doesn't try to
|
|
* "activate" it directly — it's a library, included by generators with require_once.
|
|
*/
|
|
defined('ABSPATH') || exit;
|
|
|
|
/* =========================================================================
|
|
0. WPML : exclude CSS-value controls from string registration
|
|
========================================================================= */
|
|
|
|
/**
|
|
* Tell Bricks's WPML integration to NOT register these controls as translatable
|
|
* strings. They contain technical values (calc(), css vars, breakpoint widths,
|
|
* flex props) — never real text. Without this, they pollute the CTE UI with
|
|
* lines like "Bricks (Container) calc((100% - 4.5rem) / 4)".
|
|
*/
|
|
add_filter('bricks/wpml/exclude_controls_from_translation', function ($exclude, $element, $control, $key) {
|
|
static $extra = null;
|
|
if ($extra === null) {
|
|
$extra = [
|
|
// Flex / size
|
|
'_flexBasis', '_flexGrow', '_flexShrink',
|
|
'_width', '_widthMin', '_widthMax',
|
|
'_height', '_heightMin', '_heightMax',
|
|
'_padding', '_margin',
|
|
'_rowGap', '_columnGap',
|
|
'_alignItems', '_justifyContent', '_alignContent', '_alignSelf',
|
|
'_direction', '_flexWrap',
|
|
// Visual / typo / border / bg
|
|
'_typography', '_background', '_border', '_boxShadow', '_radius',
|
|
// Positioning
|
|
'_position', '_top', '_left', '_right', '_bottom',
|
|
// Display / overflow
|
|
'_display', '_overflow', '_zIndex',
|
|
// CSS / scripts / advanced
|
|
'_cssGlobalClasses', '_cssClasses', '_cssId', '_cssTransition',
|
|
'customTag', 'targetSelector',
|
|
// Per-breakpoint variants (Bricks suffixes with :breakpoint_key — handled by Bricks core
|
|
// but if any leak through, also exclude them by full key with the breakpoint suffix)
|
|
];
|
|
}
|
|
return array_merge($exclude, $extra);
|
|
}, 10, 4);
|
|
|
|
/* =========================================================================
|
|
1. PRIMITIVES — colors, spacing, radius, ids
|
|
========================================================================= */
|
|
|
|
function av_color($var) { return ['raw' => $var]; }
|
|
function av_sp($t, $r, $b, $l) {
|
|
return ['top' => (string)$t, 'right' => (string)$r, 'bottom' => (string)$b, 'left' => (string)$l];
|
|
}
|
|
function av_radius($r) { return ['radius' => av_sp($r, $r, $r, $r)]; }
|
|
|
|
/**
|
|
* Generate a short readable ID prefixed by $base.
|
|
* Bricks accepts any string as element ID — these are stable across runs
|
|
* because we never call this for IDs that need to match parent/children.
|
|
*/
|
|
function av_id($base, $i = 0) {
|
|
return substr($base . substr(md5($base . $i . microtime()), 0, 4), 0, 6);
|
|
}
|
|
|
|
/**
|
|
* Section vertical padding per breakpoint.
|
|
* Default desktop : 5rem top/bottom. Override via args.
|
|
*/
|
|
function av_section_padding_responsive($top_desktop = '5rem', $bottom_desktop = '5rem') {
|
|
return [
|
|
'_padding' => av_sp($top_desktop, '0', $bottom_desktop, '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
];
|
|
}
|
|
|
|
/** Outer container horizontal gutters — tighten on phones. */
|
|
function av_outer_padding_responsive() {
|
|
return [
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
];
|
|
}
|
|
|
|
/* =========================================================================
|
|
2. ELEMENT ATOMS
|
|
========================================================================= */
|
|
|
|
function av_eyebrow($id, $parent, $text, $color = 'var(--color-brand-gold)') {
|
|
return [
|
|
'id' => $id, 'name' => 'text-basic', 'parent' => $parent, 'children' => [],
|
|
'settings' => [
|
|
'text' => $text,
|
|
'_typography' => [
|
|
'color' => av_color($color),
|
|
'font-weight' => '600',
|
|
'letter-spacing' => '0.08em',
|
|
'font-size' => '0.75rem',
|
|
'text-transform' => 'uppercase',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
function av_heading($id, $parent, $tag, $text, $color = null) {
|
|
$settings = ['tag' => $tag, 'text' => $text];
|
|
if ($color) $settings['_typography'] = ['color' => av_color($color)];
|
|
return [
|
|
'id' => $id, 'name' => 'heading', 'parent' => $parent, 'children' => [],
|
|
'settings' => $settings,
|
|
];
|
|
}
|
|
|
|
function av_text($id, $parent, $text, $color = 'var(--color-text-secondary)', $size = '1rem', $line = '1.5') {
|
|
return [
|
|
'id' => $id, 'name' => 'text-basic', 'parent' => $parent, 'children' => [],
|
|
'settings' => [
|
|
'text' => $text,
|
|
'_typography' => [
|
|
'color' => av_color($color),
|
|
'font-size' => $size,
|
|
'line-height' => $line,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
function av_button($id, $parent, $text, $url, $variant = 'primary') {
|
|
$base = [
|
|
'text' => $text,
|
|
// type='external' is the correct Bricks shape for plain URLs (relative or absolute).
|
|
// 'internal' would require a numeric postId. See bricks/includes/elements/base.php
|
|
// → set_link_attributes() : only 'external' uses the 'url' string verbatim.
|
|
'link' => ['type' => 'external', 'url' => $url],
|
|
'_typography' => ['font-weight' => '600', 'text-decoration' => 'none'],
|
|
'_padding' => av_sp('0.75rem', '1.5rem', '0.75rem', '1.5rem'),
|
|
'_border' => av_radius('4px'),
|
|
];
|
|
|
|
if ($variant === 'primary') {
|
|
$base['_background'] = ['color' => av_color('var(--color-brand-navy)')];
|
|
$base['_typography']['color'] = av_color('var(--color-text-on-dark)');
|
|
} elseif ($variant === 'gold') {
|
|
$base['_background'] = ['color' => av_color('var(--color-brand-gold)')];
|
|
$base['_typography']['color'] = av_color('var(--color-brand-navy)');
|
|
} elseif ($variant === 'secondary') {
|
|
// outline navy
|
|
$base['_background'] = ['color' => 'transparent'];
|
|
$base['_typography']['color'] = av_color('var(--color-brand-navy)');
|
|
$base['_border'] = [
|
|
'width' => av_sp('1px', '1px', '1px', '1px'),
|
|
'color' => av_color('var(--color-brand-navy)'),
|
|
'style' => 'solid',
|
|
'radius' => av_sp('4px', '4px', '4px', '4px'),
|
|
];
|
|
} elseif ($variant === 'outline-on-dark') {
|
|
$base['_background'] = ['color' => 'transparent'];
|
|
$base['_typography']['color'] = av_color('var(--color-text-on-dark)');
|
|
$base['_border'] = [
|
|
'width' => av_sp('1px', '1px', '1px', '1px'),
|
|
'color' => av_color('var(--color-text-on-dark)'),
|
|
'style' => 'solid',
|
|
'radius' => av_sp('4px', '4px', '4px', '4px'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => $id, 'name' => 'button', 'parent' => $parent, 'children' => [],
|
|
'settings' => $base,
|
|
];
|
|
}
|
|
|
|
function av_text_link($id, $parent, $text, $url, $color = 'var(--color-brand-navy)') {
|
|
return [
|
|
'id' => $id, 'name' => 'text-link', 'parent' => $parent, 'children' => [],
|
|
'settings' => [
|
|
'text' => $text,
|
|
// type='external' = Bricks renders href verbatim. 'internal' needs postId.
|
|
'link' => ['type' => 'external', 'url' => $url],
|
|
'_typography' => [
|
|
'color' => av_color($color),
|
|
'font-weight' => '600',
|
|
'text-decoration' => 'none',
|
|
'font-size' => '0.9375rem',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/* =========================================================================
|
|
3. SECTION BUILDERS
|
|
Convention :
|
|
section (full bleed) → outer container (1280, w 100%, padding gutters,
|
|
align-items: flex-start) → narrative inner (800, w 100%, margin 0 auto
|
|
0 0) → headings / text / list / cta-row
|
|
Cards section : narrative inner for eyebrow+h2, grid takes outer width.
|
|
========================================================================= */
|
|
|
|
function av_build_hero($slug, $d) {
|
|
$sid = "h{$slug}sc"; // section
|
|
$oct = "h{$slug}oc"; // outer container
|
|
$nrr = "h{$slug}nr"; // narrative inner
|
|
$eybId = "h{$slug}eb";
|
|
$h1Id = "h{$slug}h1";
|
|
$ledId = "h{$slug}ld";
|
|
$rowId = "h{$slug}rw";
|
|
|
|
$narrative_children = [$eybId, $h1Id];
|
|
if (! empty($d['hero_lead'])) $narrative_children[] = $ledId;
|
|
if (! empty($d['ctas'])) $narrative_children[] = $rowId;
|
|
|
|
$els = [];
|
|
|
|
$els[] = [
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('6rem', '0', '5rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2.5rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color('var(--color-bg-default)')],
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
'_rowGap' => '1.25rem',
|
|
],
|
|
];
|
|
|
|
$els[] = av_eyebrow($eybId, $nrr, $d['eyebrow'] ?? 'Industries');
|
|
$els[] = av_heading($h1Id, $nrr, 'h1', $d['tagline'] ?? $d['title']);
|
|
if (! empty($d['hero_lead'])) {
|
|
$els[] = av_text($ledId, $nrr, $d['hero_lead'], 'var(--color-text-secondary)', '1.125rem', '1.55');
|
|
}
|
|
|
|
if (! empty($d['ctas'])) {
|
|
$btn_ids = [];
|
|
foreach ($d['ctas'] as $i => $cta) $btn_ids[] = "h{$slug}b{$i}";
|
|
$els[] = [
|
|
'id' => $rowId, 'name' => 'container', 'parent' => $nrr, 'children' => $btn_ids,
|
|
'settings' => [
|
|
'_direction' => 'row',
|
|
'_columnGap' => '1rem',
|
|
'_rowGap' => '1rem',
|
|
'_flexWrap' => 'wrap',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0.75rem', '0', '0', '0'),
|
|
],
|
|
];
|
|
foreach ($d['ctas'] as $i => $cta) {
|
|
$variant = $cta['variant'] === 'primary' ? 'primary' : 'secondary';
|
|
$els[] = av_button("h{$slug}b{$i}", $rowId, $cta['label'], $cta['href'], $variant);
|
|
}
|
|
}
|
|
|
|
return $els;
|
|
}
|
|
|
|
function av_build_prose_section($slug, $i, $section, $bg = 'var(--color-bg-subtle)') {
|
|
$sid = "s{$slug}p{$i}";
|
|
$oct = "o{$slug}p{$i}";
|
|
$nrr = "n{$slug}p{$i}";
|
|
$eyb = "e{$slug}p{$i}";
|
|
$hId = "h{$slug}p{$i}";
|
|
$bId = "b{$slug}p{$i}";
|
|
|
|
$narrative_children = [];
|
|
if (! empty($section['eyebrow'])) $narrative_children[] = $eyb;
|
|
if (! empty($section['title'])) $narrative_children[] = $hId;
|
|
if (! empty($section['body'])) $narrative_children[] = $bId;
|
|
|
|
$els = [];
|
|
$els[] = [
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('5rem', '0', '5rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color($bg)],
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
'_rowGap' => '1rem',
|
|
],
|
|
];
|
|
if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']);
|
|
if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']);
|
|
if (! empty($section['body'])) $els[] = av_text($bId, $nrr, $section['body'], 'var(--color-text-secondary)', '1.0625rem', '1.6');
|
|
|
|
return $els;
|
|
}
|
|
|
|
function av_build_list_section($slug, $i, $section, $bg = 'var(--color-bg-default)') {
|
|
$sid = "s{$slug}l{$i}";
|
|
$oct = "o{$slug}l{$i}";
|
|
$nrr = "n{$slug}l{$i}";
|
|
$eyb = "e{$slug}l{$i}";
|
|
$hId = "h{$slug}l{$i}";
|
|
$lead = "ld{$slug}l{$i}";
|
|
$list = "l{$slug}l{$i}";
|
|
|
|
$list_items = $section['items'] ?? [];
|
|
$item_ids = [];
|
|
foreach ($list_items as $j => $_) $item_ids[] = "i{$slug}l{$i}x{$j}";
|
|
|
|
$narrative_children = [];
|
|
if (! empty($section['eyebrow'])) $narrative_children[] = $eyb;
|
|
if (! empty($section['title'])) $narrative_children[] = $hId;
|
|
if (! empty($section['lead'])) $narrative_children[] = $lead; // optional intro paragraph
|
|
$narrative_children[] = $list;
|
|
$cta_id = null;
|
|
if (! empty($section['cta'])) {
|
|
$cta_id = "k{$slug}l{$i}";
|
|
$narrative_children[] = $cta_id;
|
|
}
|
|
|
|
$els = [];
|
|
$els[] = [
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('5rem', '0', '5rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color($bg)],
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
'_rowGap' => '1.25rem',
|
|
],
|
|
];
|
|
if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']);
|
|
if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']);
|
|
if (! empty($section['lead'])) $els[] = av_text($lead, $nrr, $section['lead'], 'var(--color-text-secondary)', '1.0625rem', '1.6');
|
|
|
|
$els[] = [
|
|
'id' => $list, 'name' => 'container', 'parent' => $nrr, 'children' => $item_ids,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_rowGap' => '0.75rem',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0.25rem', '0', '0', '0'),
|
|
],
|
|
];
|
|
|
|
foreach ($list_items as $j => $item) {
|
|
$els[] = [
|
|
'id' => "i{$slug}l{$i}x{$j}", 'name' => 'text-basic', 'parent' => $list, 'children' => [],
|
|
'settings' => [
|
|
'text' => '— ' . $item,
|
|
'_typography' => [
|
|
'color' => av_color('var(--color-text-secondary)'),
|
|
'font-size' => '1rem',
|
|
'line-height' => '1.55',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($cta_id) {
|
|
$els[] = av_text_link($cta_id, $nrr, $section['cta']['label'], $section['cta']['href'], 'var(--color-brand-navy)');
|
|
}
|
|
|
|
return $els;
|
|
}
|
|
|
|
function av_build_cards_section($slug, $i, $section, $bg = 'var(--color-bg-subtle)') {
|
|
$sid = "s{$slug}c{$i}";
|
|
$oct = "o{$slug}c{$i}";
|
|
$nrr = "n{$slug}c{$i}";
|
|
$eyb = "e{$slug}c{$i}";
|
|
$hId = "h{$slug}c{$i}";
|
|
$grid = "g{$slug}c{$i}";
|
|
|
|
$items = $section['items'] ?? [];
|
|
$card_count = count($items);
|
|
$card_ids = [];
|
|
foreach ($items as $j => $_) $card_ids[] = "k{$slug}c{$i}x{$j}";
|
|
|
|
$narrative_children = [];
|
|
if (! empty($section['eyebrow'])) $narrative_children[] = $eyb;
|
|
if (! empty($section['title'])) $narrative_children[] = $hId;
|
|
|
|
$oct_children = [];
|
|
if (! empty($narrative_children)) $oct_children[] = $nrr;
|
|
$oct_children[] = $grid;
|
|
$cta_id = null;
|
|
if (! empty($section['cta'])) {
|
|
$cta_id = "kc{$slug}c{$i}";
|
|
$oct_children[] = $cta_id;
|
|
}
|
|
|
|
// Card sizing per Bricks breakpoints. 2 cards/row at desktop if exactly 2 items.
|
|
// 3 cards/row if 3. Otherwise 4-up grid. Always 2-up at tablet_portrait, 1-up at mobile_portrait.
|
|
if ($card_count === 2) {
|
|
$flex_desktop = 'calc((100% - 1.5rem) / 2)';
|
|
} elseif ($card_count === 3) {
|
|
$flex_desktop = 'calc((100% - 3rem) / 3)';
|
|
} elseif ($card_count === 5) {
|
|
// 5 cards : we wrap them as 3 + 2 — set flex-basis for 3-up
|
|
$flex_desktop = 'calc((100% - 3rem) / 3)';
|
|
} else {
|
|
// default 4 cards/row (and 6 → 4 + 2 wrapped)
|
|
$flex_desktop = 'calc((100% - 4.5rem) / 4)';
|
|
}
|
|
|
|
$els = [];
|
|
$els[] = [
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('5rem', '0', '5rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color($bg)],
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => $oct_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
'_rowGap' => '2rem',
|
|
],
|
|
];
|
|
|
|
if (! empty($narrative_children)) {
|
|
$els[] = [
|
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
'_rowGap' => '0.75rem',
|
|
],
|
|
];
|
|
if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']);
|
|
if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']);
|
|
}
|
|
|
|
$els[] = [
|
|
'id' => $grid, 'name' => 'container', 'parent' => $oct, 'children' => $card_ids,
|
|
'settings' => [
|
|
'_direction' => 'row',
|
|
'_columnGap' => '1.5rem',
|
|
'_rowGap' => '1.5rem',
|
|
'_flexWrap' => 'wrap',
|
|
'_alignItems' => 'stretch',
|
|
'_widthMin' => '0',
|
|
'_widthMax' => '100%',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
],
|
|
];
|
|
|
|
foreach ($items as $j => $item) {
|
|
$kid = "k{$slug}c{$i}x{$j}";
|
|
$khh = "kh{$slug}c{$i}x{$j}";
|
|
$kbb = "kb{$slug}c{$i}x{$j}";
|
|
$els[] = [
|
|
'id' => $kid, 'name' => 'container', 'parent' => $grid, 'children' => [$khh, $kbb],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_rowGap' => '0.75rem',
|
|
'_padding' => av_sp('1.75rem', '1.75rem', '1.75rem', '1.75rem'),
|
|
'_background' => ['color' => av_color('var(--color-bg-default)')],
|
|
'_border' => [
|
|
'width' => av_sp('1px', '1px', '1px', '1px'),
|
|
'color' => av_color('var(--color-border-subtle)'),
|
|
'style' => 'solid',
|
|
'radius' => av_sp('4px', '4px', '4px', '4px'),
|
|
],
|
|
'_flexBasis' => $flex_desktop,
|
|
'_flexBasis:tablet_portrait' => 'calc((100% - 1.5rem) / 2)',
|
|
'_flexBasis:mobile_portrait' => '100%',
|
|
'_flexGrow' => '0',
|
|
'_flexShrink' => '0',
|
|
'_widthMin' => '0',
|
|
'_widthMax' => '100%',
|
|
],
|
|
];
|
|
$els[] = av_heading($khh, $kid, 'h3', $item['title']);
|
|
$els[] = av_text($kbb, $kid, $item['body'], 'var(--color-text-secondary)', '1rem', '1.55');
|
|
}
|
|
|
|
if ($cta_id) {
|
|
$els[] = av_text_link($cta_id, $oct, $section['cta']['label'], $section['cta']['href'], 'var(--color-brand-navy)');
|
|
}
|
|
|
|
return $els;
|
|
}
|
|
|
|
/**
|
|
* 'cta' section type : prose-style heading + body + button row, on a tinted bg.
|
|
* Visually a mid-page conversion accent (vs. closer CTA which is full navy).
|
|
*/
|
|
function av_build_cta_section($slug, $i, $section, $bg = 'var(--color-bg-subtle)') {
|
|
$sid = "s{$slug}cta{$i}";
|
|
$oct = "o{$slug}cta{$i}";
|
|
$nrr = "n{$slug}cta{$i}";
|
|
$eyb = "e{$slug}cta{$i}";
|
|
$hId = "h{$slug}cta{$i}";
|
|
$bId = "b{$slug}cta{$i}";
|
|
$row = "r{$slug}cta{$i}";
|
|
|
|
$narrative_children = [];
|
|
if (! empty($section['eyebrow'])) $narrative_children[] = $eyb;
|
|
if (! empty($section['title'])) $narrative_children[] = $hId;
|
|
if (! empty($section['body'])) $narrative_children[] = $bId;
|
|
$narrative_children[] = $row;
|
|
|
|
$els = [];
|
|
$els[] = [
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('5rem', '0', '5rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color($bg)],
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => $narrative_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
'_rowGap' => '1.25rem',
|
|
],
|
|
];
|
|
if (! empty($section['eyebrow'])) $els[] = av_eyebrow($eyb, $nrr, $section['eyebrow']);
|
|
if (! empty($section['title'])) $els[] = av_heading($hId, $nrr, 'h2', $section['title']);
|
|
if (! empty($section['body'])) $els[] = av_text($bId, $nrr, $section['body'], 'var(--color-text-secondary)', '1.0625rem', '1.6');
|
|
|
|
// Button row
|
|
$ctas = $section['ctas'] ?? [];
|
|
$btn_ids = [];
|
|
foreach ($ctas as $j => $cta) $btn_ids[] = "b{$slug}cta{$i}x{$j}";
|
|
$els[] = [
|
|
'id' => $row, 'name' => 'container', 'parent' => $nrr, 'children' => $btn_ids,
|
|
'settings' => [
|
|
'_direction' => 'row',
|
|
'_columnGap' => '1rem',
|
|
'_rowGap' => '1rem',
|
|
'_flexWrap' => 'wrap',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0.5rem', '0', '0', '0'),
|
|
],
|
|
];
|
|
foreach ($ctas as $j => $cta) {
|
|
$variant = ($cta['variant'] ?? 'primary') === 'primary' ? 'primary' : 'secondary';
|
|
$els[] = av_button("b{$slug}cta{$i}x{$j}", $row, $cta['label'], $cta['href'], $variant);
|
|
}
|
|
|
|
return $els;
|
|
}
|
|
|
|
/**
|
|
* Upsell : cross-sell callout. Title + body + 1-2 inline links to sibling pages.
|
|
* Renders as a subtle bordered box on default bg.
|
|
*/
|
|
function av_build_upsell_section($slug, $upsell) {
|
|
$sid = "s{$slug}up";
|
|
$oct = "o{$slug}up";
|
|
$box = "x{$slug}up";
|
|
$hId = "h{$slug}up";
|
|
$bId = "b{$slug}up";
|
|
$lks = "l{$slug}up";
|
|
|
|
$box_children = [];
|
|
if (! empty($upsell['title'])) $box_children[] = $hId;
|
|
if (! empty($upsell['body'])) $box_children[] = $bId;
|
|
if (! empty($upsell['links'])) $box_children[] = $lks;
|
|
|
|
$els = [];
|
|
$els[] = [
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('2.5rem', '0', '2.5rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color('var(--color-bg-default)')],
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$box],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
],
|
|
];
|
|
$els[] = [
|
|
'id' => $box, 'name' => 'container', 'parent' => $oct, 'children' => $box_children,
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('1.75rem', '1.75rem', '1.75rem', '1.75rem'),
|
|
'_background' => ['color' => av_color('var(--color-bg-subtle)')],
|
|
'_border' => [
|
|
'width' => av_sp('1px', '1px', '1px', '1px'),
|
|
'color' => av_color('var(--color-border-subtle)'),
|
|
'style' => 'solid',
|
|
'radius' => av_sp('4px', '4px', '4px', '4px'),
|
|
],
|
|
'_rowGap' => '0.75rem',
|
|
],
|
|
];
|
|
if (! empty($upsell['title'])) $els[] = av_heading($hId, $box, 'h3', $upsell['title']);
|
|
if (! empty($upsell['body'])) $els[] = av_text($bId, $box, $upsell['body'], 'var(--color-text-secondary)', '1rem', '1.55');
|
|
|
|
if (! empty($upsell['links'])) {
|
|
$lk_ids = [];
|
|
foreach ($upsell['links'] as $j => $lk) $lk_ids[] = "lk{$slug}upx{$j}";
|
|
$els[] = [
|
|
'id' => $lks, 'name' => 'container', 'parent' => $box, 'children' => $lk_ids,
|
|
'settings' => [
|
|
'_direction' => 'row',
|
|
'_columnGap' => '1.25rem',
|
|
'_rowGap' => '0.5rem',
|
|
'_flexWrap' => 'wrap',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0.5rem', '0', '0', '0'),
|
|
],
|
|
];
|
|
foreach ($upsell['links'] as $j => $lk) {
|
|
$els[] = av_text_link("lk{$slug}upx{$j}", $lks, $lk['label'], $lk['href'], 'var(--color-brand-navy)');
|
|
}
|
|
}
|
|
|
|
return $els;
|
|
}
|
|
|
|
function av_build_closer_cta($slug) {
|
|
$sid = "scl{$slug}sc";
|
|
$oct = "ocl{$slug}oc";
|
|
$nrr = "ncl{$slug}nr";
|
|
$eyb = "ecl{$slug}eb";
|
|
$hId = "hcl{$slug}h2";
|
|
$led = "lcl{$slug}ld";
|
|
$row = "rcl{$slug}rw";
|
|
$b1 = "bcl{$slug}b1";
|
|
$b2 = "bcl{$slug}b2";
|
|
|
|
return [
|
|
[
|
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
|
'settings' => [
|
|
'_padding' => av_sp('5rem', '0', '5rem', '0'),
|
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '4rem', '0'),
|
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '3rem', '0'),
|
|
'_padding:mobile_portrait' => av_sp('2rem', '0', '2rem', '0'),
|
|
'_background' => ['color' => av_color('var(--color-brand-navy)')],
|
|
],
|
|
],
|
|
[
|
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '1280px',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
|
'_alignItems' => 'flex-start',
|
|
],
|
|
],
|
|
[
|
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => [$eyb, $hId, $led, $row],
|
|
'settings' => [
|
|
'_direction' => 'column',
|
|
'_widthMax' => '800px',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
|
'_padding' => av_sp('0', '0', '0', '0'),
|
|
'_rowGap' => '1.25rem',
|
|
],
|
|
],
|
|
av_eyebrow($eyb, $nrr, 'See it. Touch it. Then decide.'),
|
|
av_heading($hId, $nrr, 'h2', "Bring PROSERVE to your team.", 'var(--color-text-on-dark)'),
|
|
av_text($led, $nrr, "Book 30 minutes with our team. We'll bring the kit. You bring the doctrine.", 'var(--color-text-on-dark)', '1.125rem', '1.55'),
|
|
[
|
|
'id' => $row, 'name' => 'container', 'parent' => $nrr, 'children' => [$b1, $b2],
|
|
'settings' => [
|
|
'_direction' => 'row',
|
|
'_columnGap' => '1rem',
|
|
'_rowGap' => '1rem',
|
|
'_flexWrap' => 'wrap',
|
|
'_widthMin' => '0',
|
|
'_width' => '100%',
|
|
'_padding' => av_sp('0.5rem', '0', '0', '0'),
|
|
],
|
|
],
|
|
av_button($b1, $row, 'Book a Demo', '/request-demo/', 'gold'),
|
|
av_button($b2, $row, 'Request a Quote', '/request-quote/', 'outline-on-dark'),
|
|
];
|
|
}
|
|
|
|
/* =========================================================================
|
|
4. PAGE WRITE PIPELINE — direct $wpdb, WPML rescan, FR auto-sync
|
|
========================================================================= */
|
|
|
|
/**
|
|
* Build a section list from a data array (hero + sections + closer).
|
|
* Each $sections[i] has a 'type' field : prose / list / cards / cta.
|
|
* Alternates bg between bg-default and bg-subtle.
|
|
*
|
|
* Returns an array of Bricks elements (hero first, closer last).
|
|
*/
|
|
function av_build_detail_page_elements($slug, $d) {
|
|
$bg_alternates = ['var(--color-bg-default)', 'var(--color-bg-subtle)'];
|
|
$elements = [];
|
|
$elements = array_merge($elements, av_build_hero($slug, $d));
|
|
|
|
foreach ($d['sections'] ?? [] as $i => $section) {
|
|
$bg = $bg_alternates[$i % 2];
|
|
if ($section['type'] === 'prose') $elements = array_merge($elements, av_build_prose_section($slug, $i, $section, $bg));
|
|
elseif ($section['type'] === 'list') $elements = array_merge($elements, av_build_list_section($slug, $i, $section, $bg));
|
|
elseif ($section['type'] === 'cards') $elements = array_merge($elements, av_build_cards_section($slug, $i, $section, $bg));
|
|
elseif ($section['type'] === 'cta') $elements = array_merge($elements, av_build_cta_section($slug, $i, $section, $bg));
|
|
}
|
|
|
|
// Optional upsell callout (cross-sell to sibling pages)
|
|
if (! empty($d['upsell']) && is_array($d['upsell'])) {
|
|
$elements = array_merge($elements, av_build_upsell_section($slug, $d['upsell']));
|
|
}
|
|
|
|
$elements = array_merge($elements, av_build_closer_cta($slug));
|
|
|
|
return $elements;
|
|
}
|
|
|
|
/**
|
|
* Write Bricks elements to a page's _bricks_page_content_2 :
|
|
* - Bypass Bricks's update_post_metadata filter via $wpdb direct write
|
|
* - Cache flush
|
|
* - WPML rescan : do_action('wpml_page_builder_register_strings', ...)
|
|
* - FR auto-sync : if linked FR exists, push translated structure
|
|
*
|
|
* Returns ['stored_len' => int, 'json_len' => int].
|
|
*/
|
|
function av_write_bricks_page_elements($page_id, $elements) {
|
|
global $wpdb;
|
|
|
|
$json = wp_json_encode($elements);
|
|
if ($json === false) {
|
|
return ['stored_len' => 0, 'json_len' => 0, 'error' => 'JSON encode failed: ' . json_last_error_msg()];
|
|
}
|
|
|
|
$existing = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1",
|
|
$page_id, '_bricks_page_content_2'
|
|
));
|
|
if ($existing) {
|
|
$wpdb->update($wpdb->postmeta, ['meta_value' => $json], ['meta_id' => $existing], ['%s'], ['%d']);
|
|
} else {
|
|
$wpdb->insert($wpdb->postmeta, ['post_id' => $page_id, 'meta_key' => '_bricks_page_content_2', 'meta_value' => $json], ['%d', '%s', '%s']);
|
|
}
|
|
wp_cache_delete($page_id, 'post_meta');
|
|
|
|
$stored = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT LENGTH(meta_value) FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s LIMIT 1",
|
|
$page_id, '_bricks_page_content_2'
|
|
));
|
|
|
|
// WPML rescan : Bricks's wpml_page_builder_register_strings handler needs
|
|
// each element's controls to be loaded (defines which fields are translatable).
|
|
// Bricks loads controls on the 'wp' hook (not 'init'). Our URL triggers run on
|
|
// 'init' → controls would be empty → no strings registered. Force-load first.
|
|
if (class_exists('\Bricks\Elements') && method_exists('\Bricks\Elements', 'load_elements')) {
|
|
\Bricks\Elements::load_elements();
|
|
}
|
|
if (has_action('wpml_page_builder_register_strings')) {
|
|
do_action('wpml_page_builder_register_strings', get_post($page_id), [
|
|
'kind' => 'Bricks',
|
|
'name' => (string) $page_id,
|
|
'post_id' => $page_id,
|
|
'title' => "Bricks (ID $page_id)",
|
|
]);
|
|
|
|
// Push our FR dict translations into icl_string_translations so CTE shows
|
|
// them pre-filled. Only fills strings without an existing FR (so user
|
|
// edits done in CTE are not overwritten on re-regen).
|
|
av_push_fr_strings_to_wpml($page_id);
|
|
|
|
// Fire Bricks's native FR rebuild : Bricks reads EN _bricks_page_content_2,
|
|
// substitutes string translations from icl_string_translations, writes to FR.
|
|
av_trigger_bricks_fr_rebuild($page_id);
|
|
|
|
// Ensure a CTE job exists for this page (gear icon ⚙️ + click → CTE).
|
|
// The job lives in icl_translate_job with editor='wpml' and revision=NULL.
|
|
// Without it, the FR column shows pencil → WP editor fallback.
|
|
av_ensure_wpml_cte_job($page_id);
|
|
}
|
|
|
|
// Note : we used to mark status=10 on shutdown here. Now av_ensure_wpml_cte_job()
|
|
// sets status=2 (IN_PROGRESS) which is required for the gear ⚙️ icon + CTE link.
|
|
// Setting status=10 here would silently override that → pencil icon → broken UX.
|
|
|
|
return ['stored_len' => (int) $stored, 'json_len' => strlen($json)];
|
|
}
|
|
|
|
/**
|
|
* Upsert (create-or-update) a detail page under a given parent.
|
|
* Returns ['id' => int, 'action' => 'created'|'updated', 'element_count' => int, 'stored_len' => int].
|
|
*/
|
|
function av_upsert_detail_page($slug, $parent_path, $d) {
|
|
$existing = get_page_by_path("$parent_path/$slug");
|
|
$parent = get_page_by_path($parent_path);
|
|
$parent_id = $parent ? $parent->ID : 0;
|
|
|
|
if ($existing) {
|
|
wp_update_post([
|
|
'ID' => $existing->ID,
|
|
'post_title' => $d['title'],
|
|
'post_status' => 'publish',
|
|
'post_parent' => $parent_id,
|
|
]);
|
|
$page_id = $existing->ID;
|
|
$action = 'updated';
|
|
} else {
|
|
$page_id = wp_insert_post([
|
|
'post_type' => 'page',
|
|
'post_title' => $d['title'],
|
|
'post_name' => $slug,
|
|
'post_parent' => $parent_id,
|
|
'post_status' => 'publish',
|
|
'post_content' => '',
|
|
]);
|
|
$action = 'created';
|
|
}
|
|
|
|
$elements = av_build_detail_page_elements($slug, $d);
|
|
$write = av_write_bricks_page_elements($page_id, $elements);
|
|
|
|
return [
|
|
'id' => $page_id,
|
|
'action' => $action,
|
|
'element_count' => count($elements),
|
|
'json_len' => $write['json_len'],
|
|
'stored_len' => $write['stored_len'] ?? 'null',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ensure a hub page exists at a top-level slug. Creates if not.
|
|
* Returns the post ID.
|
|
*/
|
|
function av_ensure_hub_page($slug, $title) {
|
|
$existing = get_page_by_path($slug);
|
|
if ($existing) return $existing->ID;
|
|
return wp_insert_post([
|
|
'post_type' => 'page',
|
|
'post_title' => $title,
|
|
'post_name' => $slug,
|
|
'post_status' => 'publish',
|
|
'post_content' => '',
|
|
]);
|
|
}
|
|
|
|
/* =========================================================================
|
|
5. FR TRANSLATION PIPELINE
|
|
========================================================================= */
|
|
|
|
/**
|
|
* Find the FR post linked to an EN post via WPML trid. Returns 0 if none.
|
|
*/
|
|
function av_get_fr_post_for_en($en_post_id) {
|
|
global $wpdb, $sitepress;
|
|
if (! $sitepress) return 0;
|
|
$trid = $sitepress->get_element_trid($en_post_id, 'post_page');
|
|
if (! $trid) return 0;
|
|
return (int) $wpdb->get_var($wpdb->prepare(
|
|
"SELECT element_id FROM {$wpdb->prefix}icl_translations
|
|
WHERE trid=%d AND language_code='fr' AND element_type='post_page' AND element_id > 0
|
|
LIMIT 1",
|
|
$trid
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Trigger Bricks's native FR rebuild via the wpml_page_builder_string_translated
|
|
* hook. Bricks reads EN _bricks_page_content_2, substitutes WPML string translations,
|
|
* writes the result to FR _bricks_page_content_2.
|
|
*
|
|
* This is the WPML-native flow — replaces our previous av_sync_fr_translation()
|
|
* which wrote FR directly via $wpdb (causing divergence WPML couldn't track).
|
|
*/
|
|
function av_trigger_bricks_fr_rebuild($en_post_id) {
|
|
global $wpdb;
|
|
$fr_post_id = av_get_fr_post_for_en($en_post_id);
|
|
if (! $fr_post_id) return;
|
|
|
|
// Read all FR string translations registered for this EN page's package
|
|
$context = 'bricks-' . $en_post_id;
|
|
$rows = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT s.name AS string_id, st.value
|
|
FROM {$wpdb->prefix}icl_string_translations st
|
|
JOIN {$wpdb->prefix}icl_strings s ON s.id = st.string_id
|
|
WHERE s.context = %s AND st.language = 'fr' AND st.status = 10",
|
|
$context
|
|
));
|
|
if (! $rows) return;
|
|
|
|
// Build the structure Bricks's handler expects :
|
|
// $string_translations[string_id][lang]['value'] = 'translated text'
|
|
$string_translations = [];
|
|
foreach ($rows as $r) {
|
|
$string_translations[$r->string_id] = [
|
|
'fr' => ['value' => $r->value],
|
|
];
|
|
}
|
|
|
|
// Fire Bricks's rebuild handler. The closure-attached action signature is :
|
|
// ($package_kind, $translated_post_id, $original_post, $string_translations, $lang)
|
|
do_action(
|
|
'wpml_page_builder_string_translated',
|
|
'Bricks',
|
|
$fr_post_id,
|
|
get_post($en_post_id),
|
|
$string_translations,
|
|
'fr'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Write/refresh icl_translation_status row for a FR translation : status=10 (complete),
|
|
* needs_update=0, md5=hash of EN _bricks_page_content_2 (what WPML normally tracks).
|
|
*
|
|
* Idempotent. Called from av_sync_fr_translation() and from av_translate_page_fr().
|
|
*/
|
|
function av_wpml_mark_translation_complete($en_post_id, $fr_post_id, $trid) {
|
|
global $wpdb, $sitepress;
|
|
if (! $sitepress) return;
|
|
|
|
$fr_translation_id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT translation_id FROM {$wpdb->prefix}icl_translations
|
|
WHERE element_id=%d AND element_type='post_page' AND trid=%d AND language_code='fr'
|
|
LIMIT 1",
|
|
$fr_post_id, $trid
|
|
));
|
|
if (! $fr_translation_id) return;
|
|
|
|
$en_content = get_post_meta($en_post_id, '_bricks_page_content_2', true);
|
|
if (is_array($en_content)) $en_content = wp_json_encode($en_content);
|
|
$md5 = md5((string) $en_content);
|
|
|
|
$data = [
|
|
'status' => 10, // ICL_TM_COMPLETE
|
|
'needs_update' => 0,
|
|
'md5' => $md5,
|
|
'translation_service' => 'local',
|
|
'translator_id' => 0,
|
|
];
|
|
|
|
$existing = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT rid FROM {$wpdb->prefix}icl_translation_status WHERE translation_id=%d",
|
|
$fr_translation_id
|
|
));
|
|
if ($existing) {
|
|
$wpdb->update($wpdb->prefix . 'icl_translation_status', $data, ['rid' => $existing]);
|
|
} else {
|
|
$data['translation_id'] = $fr_translation_id;
|
|
$wpdb->insert($wpdb->prefix . 'icl_translation_status', $data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively prefix relative URLs inside link blocks ({type, url}) with $prefix.
|
|
* Idempotent : skips URLs that already start with the prefix. Skips absolute URLs
|
|
* (http://, https://, mailto:, tel:, #, etc.).
|
|
*
|
|
* Used by av_sync_fr_translation() so FR pages link to /fr/... siblings.
|
|
*/
|
|
function av_prefix_link_urls_fr(&$elements, $prefix = '/fr') {
|
|
$walk = function (&$node) use (&$walk, $prefix) {
|
|
if (! is_array($node)) return;
|
|
// Link-block shape : has 'type' and 'url'. Prefix relative root URLs only.
|
|
if (isset($node['type']) && isset($node['url']) && is_string($node['url'])) {
|
|
$u = $node['url'];
|
|
if ($u !== '' && $u[0] === '/' && strpos($u, $prefix . '/') !== 0) {
|
|
$node['url'] = $prefix . $u;
|
|
}
|
|
}
|
|
foreach ($node as &$value) {
|
|
if (is_array($value)) $walk($value);
|
|
}
|
|
};
|
|
foreach ($elements as &$el) $walk($el);
|
|
}
|
|
|
|
/**
|
|
* For each EN string registered for a page (context 'bricks-{ID}'), if our dict
|
|
* has a FR translation AND no FR translation exists yet in WPML, write it via
|
|
* icl_add_string_translation() with status=10 (complete).
|
|
*
|
|
* Strings already translated (by user in CTE) are NEVER overwritten. Strings
|
|
* without dict entry stay EN (linguist will fill them in CTE).
|
|
*/
|
|
function av_push_fr_strings_to_wpml($page_id) {
|
|
global $wpdb;
|
|
if (! function_exists('icl_add_string_translation')) return;
|
|
|
|
$dict = av_fr_translation_dictionary();
|
|
if (! $dict) return;
|
|
|
|
// Fetch EN strings + their existing FR (if any) in one query
|
|
$strings = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT s.id, s.value AS en_value, st.value AS fr_existing
|
|
FROM {$wpdb->prefix}icl_strings s
|
|
LEFT JOIN {$wpdb->prefix}icl_string_translations st
|
|
ON st.string_id = s.id AND st.language = 'fr'
|
|
WHERE s.context = %s",
|
|
'bricks-' . $page_id
|
|
));
|
|
|
|
foreach ($strings as $s) {
|
|
// Skip if user already has a FR translation (don't overwrite manual edits)
|
|
if (! empty($s->fr_existing)) continue;
|
|
|
|
$fr_value = null;
|
|
if (isset($dict[$s->en_value])) {
|
|
// 1. Dict-translated string (text label, heading, body)
|
|
$fr_value = $dict[$s->en_value];
|
|
} elseif (av_is_internal_root_url($s->en_value)) {
|
|
// 2. Internal URL : auto-prefix /fr so FR pages link to FR siblings
|
|
// Skip URLs that already are /fr-prefixed or external (http*, mailto*, #*).
|
|
$fr_value = '/fr' . $s->en_value;
|
|
}
|
|
|
|
if ($fr_value !== null) {
|
|
icl_add_string_translation((int) $s->id, 'fr', $fr_value, 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect "internal root-relative URL" : starts with /, single slash (not //),
|
|
* not yet /fr-prefixed. Used for auto-translating link URLs to FR siblings.
|
|
*/
|
|
function av_is_internal_root_url($value) {
|
|
if (! is_string($value) || strlen($value) < 2) return false;
|
|
if ($value[0] !== '/') return false;
|
|
if (substr($value, 0, 2) === '//') return false; // protocol-relative
|
|
if (strpos($value, '/fr/') === 0 || $value === '/fr') return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create or refresh a CTE (Classic Translation Editor) job for the FR translation
|
|
* of $en_post_id. Populates icl_translate_job + icl_translate with the right
|
|
* structure so WPML shows a gear ⚙️ icon in the FR column and routes the click
|
|
* to CTE.
|
|
*
|
|
* Key requirements (learned from comparing with a working WPML-created page) :
|
|
* - icl_translate_job.revision MUST be NULL ← WPML's icon JOIN filters on this
|
|
* - icl_translate_job.editor = 'wpml' ← CTE (matches doc_translation_method=1)
|
|
* - icl_translate has 4 standard WP rows (original_id/title/body/excerpt) + N
|
|
* package-string-{package_id}-{string_id} rows for each Bricks string
|
|
* - icl_translation_status.status = 2 (IN_PROGRESS), translator_id ≠ 0, batch_id
|
|
*
|
|
* Idempotent. Existing FR values in icl_translate.field_data_translated are
|
|
* preserved (so user CTE edits aren't overwritten).
|
|
*/
|
|
function av_ensure_wpml_cte_job($en_post_id) {
|
|
global $wpdb, $sitepress;
|
|
if (! $sitepress) return;
|
|
|
|
// CRITICAL : ensure WPML doesn't force the native WP editor for this page.
|
|
// The meta '_wpml_post_translation_editor_native' = 'yes' gets set when the
|
|
// user dismisses the "Translation in progress" warning by clicking "I
|
|
// understand - continue editing". That meta then permanently routes the
|
|
// orange-refresh icon to the WP editor instead of CTE. Delete it.
|
|
delete_post_meta($en_post_id, '_wpml_post_translation_editor_native');
|
|
delete_post_meta($en_post_id, '_wpml_post_translation_editor_wpml');
|
|
|
|
$fr_post_id = av_get_fr_post_for_en($en_post_id);
|
|
if (! $fr_post_id) return;
|
|
|
|
$trid = $sitepress->get_element_trid($en_post_id, 'post_page');
|
|
$fr_translation_id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT translation_id FROM {$wpdb->prefix}icl_translations
|
|
WHERE element_id=%d AND element_type='post_page' AND trid=%d AND language_code='fr' LIMIT 1",
|
|
$fr_post_id, $trid
|
|
));
|
|
if (! $fr_translation_id) return;
|
|
|
|
// 1. icl_translation_status : IN_PROGRESS + assigned + batched (matches the
|
|
// state of a real CTE job in flight). status=2 is what makes the icon "gear".
|
|
$existing_rid = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT rid FROM {$wpdb->prefix}icl_translation_status WHERE translation_id=%d",
|
|
$fr_translation_id
|
|
));
|
|
$uuid = wp_generate_uuid4();
|
|
$batch_id = av_get_or_create_batch_id();
|
|
|
|
$en_content = get_post_meta($en_post_id, '_bricks_page_content_2', true);
|
|
if (is_array($en_content)) $en_content = wp_json_encode($en_content);
|
|
$md5 = md5((string) $en_content);
|
|
|
|
// Status strategy : keep status=10 (COMPLETE) + needs_update=1 (orange refresh
|
|
// icon 🔄). This is WPML's canonical "translation needs updating after source
|
|
// changed" state. Click on the orange icon → routes natively to CTE (no
|
|
// capability errors). After CTE save, WPML sets needs_update=0 → pencil. On
|
|
// next regen we set needs_update=1 again → cycle re-opens CTE access.
|
|
//
|
|
// We avoid status=2 (IN_PROGRESS) because it triggers the "Translation in
|
|
// progress, wait before editing" warning when editing EN — and the gear icon
|
|
// URL was hitting "Sorry, not allowed" due to user_can_translate check.
|
|
$status_data = [
|
|
'status' => 10, // ICL_TM_COMPLETE
|
|
'translator_id' => 1,
|
|
'needs_update' => 1, // → orange refresh icon, click opens CTE
|
|
'md5' => $md5,
|
|
'translation_service' => 'local',
|
|
'batch_id' => $batch_id,
|
|
'uuid' => $uuid,
|
|
'tp_revision' => 1,
|
|
'links_fixed' => 1,
|
|
'timestamp' => current_time('mysql'),
|
|
];
|
|
if ($existing_rid) {
|
|
$wpdb->update($wpdb->prefix . 'icl_translation_status', $status_data, ['rid' => $existing_rid]);
|
|
$rid = (int) $existing_rid;
|
|
} else {
|
|
$status_data['translation_id'] = $fr_translation_id;
|
|
$wpdb->insert($wpdb->prefix . 'icl_translation_status', $status_data);
|
|
$rid = (int) $wpdb->insert_id;
|
|
}
|
|
|
|
// 2. icl_translate_job : ensure one job with revision=NULL exists
|
|
$existing_job = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT job_id FROM {$wpdb->prefix}icl_translate_job WHERE rid=%d AND revision IS NULL ORDER BY job_id DESC LIMIT 1",
|
|
$rid
|
|
));
|
|
$en_post = get_post($en_post_id);
|
|
$job_data = [
|
|
'rid' => $rid,
|
|
'translator_id' => 1,
|
|
'translated' => 1,
|
|
'manager_id' => 1,
|
|
// 'revision' deliberately omitted → DB default NULL
|
|
'title' => (string) $en_post->post_title,
|
|
'deadline_date' => null,
|
|
'completed_date' => current_time('mysql'),
|
|
'editor' => 'wpml', // CTE
|
|
'editor_job_id' => null,
|
|
'automatic' => 0,
|
|
];
|
|
|
|
if ($existing_job) {
|
|
$wpdb->update($wpdb->prefix . 'icl_translate_job', $job_data, ['job_id' => $existing_job]);
|
|
// $wpdb->update coerces NULL → '' ; force NULL via raw SQL.
|
|
$wpdb->query($wpdb->prepare(
|
|
"UPDATE {$wpdb->prefix}icl_translate_job SET revision = NULL WHERE job_id = %d",
|
|
$existing_job
|
|
));
|
|
$job_id = (int) $existing_job;
|
|
} else {
|
|
$wpdb->insert($wpdb->prefix . 'icl_translate_job', $job_data);
|
|
$job_id = (int) $wpdb->insert_id;
|
|
$wpdb->query($wpdb->prepare(
|
|
"UPDATE {$wpdb->prefix}icl_translate_job SET revision = NULL WHERE job_id = %d",
|
|
$job_id
|
|
));
|
|
}
|
|
|
|
// 3. icl_translate : populate field rows so CTE has data to display
|
|
av_populate_translate_rows_for_job($job_id, $en_post_id, $fr_post_id);
|
|
}
|
|
|
|
/**
|
|
* Build batch row for our programmatic translations (one shared batch).
|
|
*/
|
|
function av_get_or_create_batch_id() {
|
|
global $wpdb;
|
|
$name = 'Asterion programmatic translations';
|
|
$id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT id FROM {$wpdb->prefix}icl_translation_batches WHERE batch_name=%s LIMIT 1",
|
|
$name
|
|
));
|
|
if ($id) return (int) $id;
|
|
$wpdb->insert($wpdb->prefix . 'icl_translation_batches', [
|
|
'batch_name' => $name,
|
|
'last_update' => current_time('mysql'),
|
|
]);
|
|
return (int) $wpdb->insert_id;
|
|
}
|
|
|
|
/**
|
|
* Populate icl_translate for a job : 4 standard WP fields + N package-string
|
|
* rows (one per registered Bricks string). field_data is base64(gzcompress(string)).
|
|
*
|
|
* Existing translated values in field_data_translated are PRESERVED (so user
|
|
* edits done in CTE aren't overwritten on regen). Only fills empty cells.
|
|
*/
|
|
function av_populate_translate_rows_for_job($job_id, $en_post_id, $fr_post_id) {
|
|
global $wpdb;
|
|
|
|
// Build a map of existing rows : field_type → tid + current field_data_translated
|
|
$existing = [];
|
|
$rows = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT tid, field_type, field_data_translated, field_finished
|
|
FROM {$wpdb->prefix}icl_translate WHERE job_id = %d",
|
|
$job_id
|
|
));
|
|
foreach ($rows as $r) {
|
|
$existing[$r->field_type] = $r;
|
|
}
|
|
|
|
$en_post = get_post($en_post_id);
|
|
$fr_post = get_post($fr_post_id);
|
|
|
|
// 1. Standard WP fields
|
|
$std_fields = [
|
|
['original_id', '', (string) $en_post_id, '', 0, 0],
|
|
['title', 'base64', av_pack($en_post->post_title), av_pack($fr_post->post_title), 1, 1],
|
|
['body', 'base64', av_pack((string) $en_post->post_content), av_pack((string) $fr_post->post_content), 1, 0],
|
|
['excerpt', 'base64', av_pack((string) $en_post->post_excerpt), av_pack((string) $fr_post->post_excerpt), 1, 0],
|
|
];
|
|
foreach ($std_fields as $f) {
|
|
av_upsert_translate_row($job_id, $fr_post_id, $f[0], $f[1], $f[2], $f[3], $f[4], $f[5], $existing);
|
|
}
|
|
|
|
// 2. Package strings (one per Bricks translatable string for this page)
|
|
$strings = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT s.id AS string_id, s.string_package_id AS package_id, s.value AS en_value, st.value AS fr_value
|
|
FROM {$wpdb->prefix}icl_strings s
|
|
LEFT JOIN {$wpdb->prefix}icl_string_translations st ON st.string_id = s.id AND st.language = 'fr'
|
|
WHERE s.context = %s",
|
|
'bricks-' . $en_post_id
|
|
));
|
|
foreach ($strings as $s) {
|
|
$field_type = "package-string-{$s->package_id}-{$s->string_id}";
|
|
$is_done = ! empty($s->fr_value) ? 1 : 0;
|
|
av_upsert_translate_row(
|
|
$job_id, $fr_post_id, $field_type, 'base64',
|
|
av_pack($s->en_value), av_pack((string) $s->fr_value),
|
|
1, $is_done, $existing
|
|
);
|
|
}
|
|
|
|
// Cleanup : delete icl_translate rows for strings that no longer exist in
|
|
// icl_strings (e.g. user removed an element from EN page)
|
|
$valid_types = array_merge(
|
|
['original_id', 'title', 'body', 'excerpt'],
|
|
array_map(fn($s) => "package-string-{$s->package_id}-{$s->string_id}", $strings)
|
|
);
|
|
if ($valid_types) {
|
|
$placeholders = implode(',', array_fill(0, count($valid_types), '%s'));
|
|
$wpdb->query($wpdb->prepare(
|
|
"DELETE FROM {$wpdb->prefix}icl_translate WHERE job_id = %d AND field_type NOT IN ($placeholders)",
|
|
$job_id, ...$valid_types
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert or update a single icl_translate row, preserving existing
|
|
* field_data_translated if non-empty (don't overwrite user CTE edits).
|
|
*/
|
|
function av_upsert_translate_row($job_id, $content_id, $field_type, $field_format,
|
|
$field_data, $field_data_translated, $field_translate,
|
|
$field_finished, &$existing) {
|
|
global $wpdb;
|
|
|
|
if (isset($existing[$field_type])) {
|
|
$row = $existing[$field_type];
|
|
// Preserve user's CTE edits : keep the existing translated value if it's set
|
|
$translated = ! empty($row->field_data_translated) ? $row->field_data_translated : $field_data_translated;
|
|
$finished_final = ! empty($row->field_data_translated) ? (int) $row->field_finished : $field_finished;
|
|
$wpdb->update(
|
|
$wpdb->prefix . 'icl_translate',
|
|
[
|
|
'field_data' => $field_data, // EN source can change → always refresh
|
|
'field_data_translated' => $translated,
|
|
'field_finished' => $finished_final,
|
|
'timestamp' => current_time('mysql'),
|
|
],
|
|
['tid' => $row->tid]
|
|
);
|
|
} else {
|
|
$wpdb->insert($wpdb->prefix . 'icl_translate', [
|
|
'job_id' => $job_id,
|
|
'content_id' => $content_id,
|
|
'timestamp' => current_time('mysql'),
|
|
'field_type' => $field_type,
|
|
'field_wrap_tag' => '',
|
|
'field_format' => $field_format,
|
|
'field_translate' => $field_translate,
|
|
'field_data' => $field_data,
|
|
'field_data_translated' => $field_data_translated,
|
|
'field_finished' => $field_finished,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/** Pack a string for icl_translate.field_data : base64(gzcompress($s)). */
|
|
function av_pack($s) {
|
|
if ($s === '' || $s === null) return '';
|
|
return base64_encode(gzcompress((string) $s));
|
|
}
|
|
|
|
/** Walk Bricks element tree, translate 'text' and 'label' string values via dict. */
|
|
function av_walk_translate_for_sync($elements, $dict) {
|
|
// Special prefix-aware handler : list items are prefixed by "— " in the
|
|
// builder. We translate the part AFTER the prefix.
|
|
$walk = function (&$node) use (&$walk, $dict) {
|
|
if (! is_array($node)) return;
|
|
foreach ($node as $key => &$value) {
|
|
if (is_array($value)) {
|
|
$walk($value);
|
|
} elseif (is_string($value) && in_array($key, ['text', 'label'], true)) {
|
|
if (isset($dict[$value])) {
|
|
$value = $dict[$value];
|
|
} elseif (strpos($value, '— ') === 0) {
|
|
// List-item prefix : try translating the content
|
|
$inner = substr($value, strlen('— '));
|
|
if (isset($dict[$inner])) {
|
|
$value = '— ' . $dict[$inner];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
foreach ($elements as &$el) $walk($el);
|
|
return $elements;
|
|
}
|
|
|
|
/**
|
|
* Shared EN→FR dictionary. Each content-type generator can extend it via the
|
|
* `av_fr_translation_dict` filter (no code change to shared).
|
|
*/
|
|
function av_fr_translation_dictionary() {
|
|
static $dict = null;
|
|
if ($dict !== null) return $dict;
|
|
|
|
$base = [
|
|
/* -----------------------------------------------------------------
|
|
Common buttons / CTAs (shared across all pages)
|
|
----------------------------------------------------------------- */
|
|
'Book a Demo' => 'Réserver une démo',
|
|
'Book a demo' => 'Réserver une démo',
|
|
'Request a Demo' => 'Demander une démo',
|
|
'Request a T&E Kit' => 'Demander un kit T&E',
|
|
'Request a Quote' => 'Demander un devis',
|
|
"Schedule a discovery call" => 'Planifier un appel de découverte',
|
|
"Discuss your custom request" => 'Discuter de votre besoin spécifique',
|
|
"See the full scenario library" => 'Voir la bibliothèque complète de scénarios',
|
|
|
|
/* -----------------------------------------------------------------
|
|
Closer CTA (every detail page + hub)
|
|
----------------------------------------------------------------- */
|
|
"See it. Touch it. Then decide."
|
|
=> "Voyez-le. Manipulez-le. Puis décidez.",
|
|
"Bring PROSERVE to your team."
|
|
=> "Apportez PROSERVE à votre équipe.",
|
|
"Book 30 minutes with our team. We'll bring the kit. You bring the doctrine."
|
|
=> "Réservez 30 minutes avec notre équipe. Nous apportons le matériel. Vous apportez la doctrine.",
|
|
|
|
/* -----------------------------------------------------------------
|
|
Generic "Learn more" link arrow
|
|
----------------------------------------------------------------- */
|
|
"Learn more \u{2192}" => "En savoir plus \u{2192}",
|
|
];
|
|
|
|
/* Extension point : each generator registers its own page strings via
|
|
the filter (so we don't have one giant function to maintain). */
|
|
$dict = apply_filters('av_fr_translation_dict', $base);
|
|
return $dict;
|
|
}
|
|
|
|
/* =========================================================================
|
|
6. FR INITIAL SEED (one-shot, creates the FR post linked via WPML trid)
|
|
========================================================================= */
|
|
|
|
/**
|
|
* Create the linked FR translation of an EN page (slot + meta + slug).
|
|
* No-op if a FR translation already exists for the EN trid. Returns the FR ID.
|
|
*
|
|
* The FR _bricks_page_content_2 is set via av_write_bricks_page_elements()
|
|
* (the EN side) when the auto-sync fires after creation.
|
|
*
|
|
* @param int $en_post_id Source EN page ID
|
|
* @param string $en_slug Slug to keep on FR (WPML auto-prefixes with /fr/)
|
|
* @param string $fr_title FR title for the new page
|
|
* @return int|false FR post ID, or false on failure
|
|
*/
|
|
function av_translate_page_fr($en_post_id, $en_slug, $fr_title) {
|
|
global $wpdb, $sitepress;
|
|
if (! $sitepress) return false;
|
|
|
|
$trid = $sitepress->get_element_trid($en_post_id, 'post_page');
|
|
if (! $trid) return false;
|
|
|
|
$fr_post_id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT element_id FROM {$wpdb->prefix}icl_translations
|
|
WHERE trid=%d AND language_code='fr' AND element_type='post_page' AND element_id > 0
|
|
LIMIT 1",
|
|
$trid
|
|
));
|
|
|
|
if (! $fr_post_id) {
|
|
// Switch WPML context to FR BEFORE insert so :
|
|
// - the new post is created in the FR language slot
|
|
// - the slug uniqueness check is scoped to FR (allows same slug as EN)
|
|
$sitepress->switch_lang('fr');
|
|
$fr_post_id = wp_insert_post([
|
|
'post_type' => 'page',
|
|
'post_title' => $fr_title,
|
|
'post_name' => $en_slug,
|
|
'post_status' => 'publish',
|
|
'post_content' => '',
|
|
'post_author' => get_post($en_post_id)->post_author ?? 1,
|
|
'post_parent' => av_fr_parent_for_en(get_post($en_post_id)->post_parent),
|
|
]);
|
|
$sitepress->switch_lang('en');
|
|
|
|
if (is_wp_error($fr_post_id)) return false;
|
|
|
|
// Force-set the language details (override WPML defaults that may have
|
|
// assigned EN if its 'lang' context wasn't picked up by all filters)
|
|
$sitepress->set_element_language_details($fr_post_id, 'post_page', $trid, 'fr', 'en');
|
|
|
|
// Force slug to match EN (WPML allows duplicate slugs per language)
|
|
$wpdb->update($wpdb->posts, ['post_name' => $en_slug], ['ID' => $fr_post_id]);
|
|
clean_post_cache($fr_post_id);
|
|
} else {
|
|
// Already linked : just update title and slug.
|
|
$sitepress->switch_lang('fr');
|
|
wp_update_post([
|
|
'ID' => $fr_post_id,
|
|
'post_title' => $fr_title,
|
|
'post_status' => 'publish',
|
|
]);
|
|
$sitepress->switch_lang('en');
|
|
$wpdb->update($wpdb->posts, ['post_name' => $en_slug], ['ID' => $fr_post_id]);
|
|
clean_post_cache($fr_post_id);
|
|
}
|
|
|
|
// Mark icl_translation_status as completed (status=10) so WPML UI routes the
|
|
// pencil button to ATE and clears the orange "needs update" indicator.
|
|
av_wpml_mark_translation_complete($en_post_id, (int) $fr_post_id, $trid);
|
|
|
|
return (int) $fr_post_id;
|
|
}
|
|
|
|
/**
|
|
* Given an EN parent post ID, return the FR equivalent (or 0 if none).
|
|
* Used to keep the parent/child hierarchy intact when creating FR pages.
|
|
*/
|
|
function av_fr_parent_for_en($en_parent_id) {
|
|
if (! $en_parent_id) return 0;
|
|
global $wpdb, $sitepress;
|
|
if (! $sitepress) return 0;
|
|
|
|
$trid = $sitepress->get_element_trid($en_parent_id, 'post_page');
|
|
if (! $trid) return 0;
|
|
|
|
$fr_parent_id = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT element_id FROM {$wpdb->prefix}icl_translations
|
|
WHERE trid=%d AND language_code='fr' AND element_type='post_page' AND element_id > 0
|
|
LIMIT 1",
|
|
$trid
|
|
));
|
|
return (int) ($fr_parent_id ?: 0);
|
|
}
|