Files
AsterionWP2026/tools/_av-bricks-shared.php
j.foucher 094fdbea51 style(lists): replace '— ' dash prefix with vertical gold bar accent
The em-dash prefix on list items felt typographic but unbranded. Now each
list item gets a 3px-wide gold vertical bar on the left, evoking a tactical
breadcrumb. Implementation :
- Builders (av_build_list_section in _av-bricks-shared.php +
  av_build_form_two_col promise list in av-generate-forms.php) emit clean
  text without the '— ' prefix, and set _cssClasses = 'av-list-item'
- New mu-plugin av-list-bullet-style.php injects CSS via wp_head() :
  .av-list-item { position: relative; padding-left: 0.9rem; }
  .av-list-item::before { gold bar, 3px × 1.1em, aligned to first line }

Regen all sections (industries, solutions, technology, editorial, forms,
home) to switch existing pages to the new style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:05:45 +02:00

1574 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, // no "— " prefix anymore
'_cssClasses' => 'av-list-item', // gold-bar accent via wp_head CSS
'_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 || $card_count === 6) {
// 5 cards : 3 + 2 — 6 cards : 3 + 3. Both wrap cleanly at 3-per-row.
$flex_desktop = 'calc((100% - 3rem) / 3)';
} else {
// default 4 cards/row (and 7+ → 4 + N 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) {
// $parent_path empty/null/'' → top-level page (no parent)
$page_path = $parent_path ? "$parent_path/$slug" : $slug;
$existing = get_page_by_path($page_path);
$parent_id = 0;
if ($parent_path) {
$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);
}