PluginProbe
AI Builder – Generate pages, blocks, images & translate with AI / trunk
AI Builder – Generate pages, blocks, images & translate with AI vtrunk
2.7.9 2.7.8 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.3.10 2.3.11 All 121 releases
ai-builder / includes / class-template-assets.php

class-template-assets.php in AI Builder – Generate pages, blocks, images & translate with AI trunk, at includes/class-template-assets.php

579 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Service de gestion du CSS / JS personnalisés pour les templates et
4 * template parts (Site Editor / Full Site Editing).
5 *
6 * Pour les pages et articles "classiques" on continue d'utiliser les post meta
7 * (ai_builder_css_content, ai_builder_js_content) via AIBUI_CSS_Handler /
8 * AIBUI_JS_Handler. Ce service gère uniquement les identifiants composites
9 * "theme//slug" (ex : "twentytwentyfive//404", "twentytwentyfive//header").
10 *
11 * Stockage : un unique wp_option "aibui_template_assets" (autoload = no)
12 * contenant une map :
13 *
14 * [
15 * 'twentytwentyfive//404' => [
16 * 'page_css' => '...',
17 * 'block_css' => '...',
18 * 'css' => '...combined...',
19 * 'page_js' => '...',
20 * 'block_js' => '...',
21 * 'js' => '...combined...',
22 * ],
23 * ...
24 * ]
25 *
26 * Toutes les opérations sont enrobées dans des try/catch silencieux afin
27 * d'éviter tout fatal susceptible de casser un site en production.
28 */
29
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 // NOTE : ne jamais utiliser un `return` top-level ici. Le fichier peut être
35 // inclus plusieurs fois dans la même requête via deux chemins canoniques
36 // différents (symlinks Local, chargeurs multiples, etc.), auquel cas le
37 // `return` sauterait l'enregistrement des hooks alors que la classe est
38 // déjà définie. On protège donc uniquement la redéclaration de classe et
39 // on s'assure que l'instanciation du singleton reste toujours exécutée.
40
41 if (!class_exists('AIBUI_Template_Assets', false)) {
42
43 class AIBUI_Template_Assets
44 {
45 const OPTION_KEY = 'aibui_template_assets';
46 const FEATURE_FLAG = 'aibui_template_assets_enabled';
47 const MAX_BYTES_PER_TEMPLATE = 262144; // 256 Ko par template pour éviter de gonfler wp_options
48
49 /**
50 * Templates/template parts repérés pendant le rendu d'une requête front.
51 * Clés = identifiants "theme//slug", valeur = true.
52 *
53 * @var array
54 */
55 private $used_templates = array();
56
57 /**
58 * Instance unique pour permettre aux hooks statiques de référencer
59 * la même collection used_templates.
60 *
61 * @var AIBUI_Template_Assets|null
62 */
63 private static $instance = null;
64
65 public static function instance()
66 {
67 if (self::$instance === null) {
68 self::$instance = new self();
69 }
70 return self::$instance;
71 }
72
73 public function __construct()
74 {
75 if (self::$instance === null) {
76 self::$instance = $this;
77 }
78
79 // Kill switch : si l'option est explicitement désactivée, on ne branche
80 // aucun hook (utile pour débrancher rapidement depuis la base).
81 if (!$this->is_enabled()) {
82 return;
83 }
84
85 // Capture des templates / template parts utilisés pour la requête.
86 // Ces hooks servent de "filet" supplémentaire. La vraie résolution des
87 // templates pour la page courante est faite au début de wp_head via
88 // resolve_current_templates() (voir inject_css).
89 add_filter('get_block_template', array($this, 'track_block_template'), 10, 3);
90 add_filter('get_block_file_template', array($this, 'track_block_template'), 10, 3);
91 add_filter('pre_render_block', array($this, 'track_template_part_block'), 10, 2);
92
93 // Injection sur le front (priorité tardive pour passer après les thèmes)
94 add_action('wp_head', array($this, 'inject_css'), 100);
95 add_action('wp_footer', array($this, 'inject_js'), 100);
96 }
97
98 /**
99 * Résout les templates à utiliser pour la requête courante en lisant les
100 * globals WordPress remplis par locate_block_template() AVANT wp_head :
101 * - $_wp_current_template_id : ex. "twentytwentyfive//404"
102 * - $_wp_current_template_content : contenu HTML sérialisé des blocs
103 *
104 * Puis parse récursivement les blocs core/template-part pour pousser
105 * aussi leur identifiant (header, footer, etc.) dans $used_templates.
106 *
107 * Idempotent (peut être appelée plusieurs fois sans effet de bord).
108 */
109 private function resolve_current_templates()
110 {
111 try {
112 // Template principal résolu par le cœur WP pour la requête.
113 if (!empty($GLOBALS['_wp_current_template_id']) && is_string($GLOBALS['_wp_current_template_id'])) {
114 $main_id = self::normalize_template_id($GLOBALS['_wp_current_template_id']);
115 if ($main_id !== '') {
116 $this->used_templates[$main_id] = true;
117 }
118 }
119
120 // Parser le contenu du template principal pour chercher les template parts.
121 if (function_exists('parse_blocks')
122 && !empty($GLOBALS['_wp_current_template_content'])
123 && is_string($GLOBALS['_wp_current_template_content'])
124 ) {
125 $blocks = parse_blocks($GLOBALS['_wp_current_template_content']);
126 $this->collect_template_parts_from_blocks($blocks, 0);
127 }
128 } catch (\Throwable $e) {
129 $this->log_error('resolve_current_templates', $e);
130 }
131 }
132
133 /**
134 * Parcours récursif (profondeur limitée) des blocs pour collecter les
135 * identifiants de template parts et leurs sous-template-parts.
136 *
137 * @param array $blocks
138 * @param int $depth pour éviter une récursion infinie en cas de
139 * référence circulaire.
140 */
141 private function collect_template_parts_from_blocks($blocks, $depth = 0)
142 {
143 if (!is_array($blocks) || $depth > 5) {
144 return;
145 }
146
147 $theme_default = function_exists('get_stylesheet') ? get_stylesheet() : '';
148
149 foreach ($blocks as $block) {
150 if (!is_array($block)) {
151 continue;
152 }
153
154 if (isset($block['blockName']) && $block['blockName'] === 'core/template-part') {
155 $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : array();
156 $slug = isset($attrs['slug']) ? (string) $attrs['slug'] : '';
157 $theme = isset($attrs['theme']) ? (string) $attrs['theme'] : $theme_default;
158
159 if ($slug !== '' && $theme !== '') {
160 $part_id = self::normalize_template_id($theme . '//' . $slug);
161 if ($part_id !== '' && !isset($this->used_templates[$part_id])) {
162 $this->used_templates[$part_id] = true;
163
164 // Descente récursive : on lit le contenu du template
165 // part pour repérer d'éventuels sous-template-parts
166 // (ex. un header qui inclut un autre template part).
167 if (function_exists('get_block_template')) {
168 $tpl = get_block_template($theme . '//' . $slug, 'wp_template_part');
169 if ($tpl && !empty($tpl->content) && function_exists('parse_blocks')) {
170 $inner = parse_blocks($tpl->content);
171 $this->collect_template_parts_from_blocks($inner, $depth + 1);
172 }
173 }
174 }
175 }
176 }
177
178 if (!empty($block['innerBlocks'])) {
179 $this->collect_template_parts_from_blocks($block['innerBlocks'], $depth + 1);
180 }
181 }
182 }
183
184 /**
185 * Active ou non le mécanisme. Option en base, true par défaut. Peut être
186 * désactivé via :
187 * update_option('aibui_template_assets_enabled', 0);
188 */
189 public function is_enabled()
190 {
191 $value = get_option(self::FEATURE_FLAG, 1);
192 return !empty($value);
193 }
194
195 /**
196 * Détermine si une valeur ressemble à un identifiant de template
197 * composite "theme//slug" (vs un ID numérique de post).
198 *
199 * @param mixed $id
200 * @return bool
201 */
202 public static function is_template_id($id)
203 {
204 if (!is_string($id)) {
205 return false;
206 }
207 // "theme//slug" : au moins un caractère avant et après "//"
208 return (bool) preg_match('#^[A-Za-z0-9_\-]+//[A-Za-z0-9_\-]+$#', $id);
209 }
210
211 /**
212 * Normalise un identifiant de template pour usage en clé.
213 *
214 * @param string $id
215 * @return string
216 */
217 public static function normalize_template_id($id)
218 {
219 $id = is_string($id) ? trim($id) : '';
220 // Certaines APIs encodent le slash ; on décode une fois.
221 if (strpos($id, '%2F') !== false || strpos($id, '%2f') !== false) {
222 $id = rawurldecode($id);
223 }
224 return $id;
225 }
226
227 /**
228 * Récupère la map complète depuis wp_options.
229 *
230 * @return array
231 */
232 public function get_map()
233 {
234 $map = get_option(self::OPTION_KEY, array());
235 return is_array($map) ? $map : array();
236 }
237
238 /**
239 * Sauvegarde la map complète (autoload = false pour ne pas charger
240 * potentiellement des centaines de Ko à chaque requête).
241 *
242 * @param array $map
243 * @return bool
244 */
245 private function save_map(array $map)
246 {
247 return update_option(self::OPTION_KEY, $map, false);
248 }
249
250 /**
251 * Récupère l'entrée d'un template.
252 *
253 * @param string $template_id
254 * @return array
255 */
256 public function get_entry($template_id)
257 {
258 $template_id = self::normalize_template_id($template_id);
259 if (!self::is_template_id($template_id)) {
260 return array();
261 }
262 $map = $this->get_map();
263 return isset($map[$template_id]) && is_array($map[$template_id])
264 ? $map[$template_id]
265 : array();
266 }
267
268 /**
269 * Sauvegarde CSS pour un template donné.
270 *
271 * Règles :
272 * - $type = 'page' : remplace intégralement le CSS "page"
273 * - $type = 'block' + $replace = true : remplace intégralement le CSS "block"
274 * - $type = 'block' + $replace = false : concatène à la suite du CSS "block"
275 * Puis recalcule la clé 'css' combinée = page_css . block_css.
276 *
277 * @param string $template_id
278 * @param string $css_content
279 * @param string $type 'page'|'block'
280 * @param bool $replace
281 * @return bool
282 */
283 public function save_css($template_id, $css_content, $type = 'page', $replace = false)
284 {
285 return $this->save_asset($template_id, 'css', $css_content, $type, $replace);
286 }
287
288 /**
289 * Sauvegarde JS pour un template donné (mêmes règles que save_css).
290 *
291 * @param string $template_id
292 * @param string $js_content
293 * @param string $type 'page'|'block'
294 * @param bool $replace
295 * @return bool
296 */
297 public function save_js($template_id, $js_content, $type = 'page', $replace = false)
298 {
299 return $this->save_asset($template_id, 'js', $js_content, $type, $replace);
300 }
301
302 /**
303 * Factorisation save_css / save_js.
304 *
305 * @param string $template_id
306 * @param string $asset 'css'|'js'
307 * @param string $content
308 * @param string $type 'page'|'block'
309 * @param bool $replace
310 * @return bool
311 */
312 private function save_asset($template_id, $asset, $content, $type, $replace)
313 {
314 try {
315 $template_id = self::normalize_template_id($template_id);
316 if (!self::is_template_id($template_id)) {
317 return false;
318 }
319 if (!in_array($asset, array('css', 'js'), true)) {
320 return false;
321 }
322 if (!in_array($type, array('page', 'block'), true)) {
323 $type = 'page';
324 }
325
326 $content = is_string($content) ? $content : '';
327 // Clip dur pour éviter qu'une entrée corrompue ne fasse exploser wp_options.
328 if (strlen($content) > self::MAX_BYTES_PER_TEMPLATE) {
329 $content = substr($content, 0, self::MAX_BYTES_PER_TEMPLATE);
330 }
331
332 $map = $this->get_map();
333 $entry = isset($map[$template_id]) && is_array($map[$template_id])
334 ? $map[$template_id]
335 : array();
336
337 $page_key = 'page_' . $asset;
338 $block_key = 'block_' . $asset;
339
340 $page = isset($entry[$page_key]) ? (string) $entry[$page_key] : '';
341 $block = isset($entry[$block_key]) ? (string) $entry[$block_key] : '';
342
343 if ($type === 'page') {
344 $page = $content;
345 } else {
346 if ($replace) {
347 $block = $content;
348 } else {
349 $separator = "\n/* Block " . strtoupper($asset) . ' - ' . date('Y-m-d H:i:s') . " */\n";
350 $block = ($block === '' ? '' : $block . "\n") . $separator . $content . "\n";
351 }
352 }
353
354 $combined = $page;
355 if ($block !== '') {
356 $combined = ($combined === '' ? '' : $combined . "\n") . $block;
357 }
358
359 $entry[$page_key] = $page;
360 $entry[$block_key] = $block;
361 $entry[$asset] = $combined;
362
363 $map[$template_id] = $entry;
364 $this->save_map($map);
365 return true;
366 } catch (\Throwable $e) {
367 $this->log_error('save_asset', $e);
368 return false;
369 }
370 }
371
372 /**
373 * Hook get_block_template / get_block_file_template.
374 * Enregistre l'id du template chargé (ex: "twentytwentyfive//404").
375 *
376 * @param WP_Block_Template|null $template
377 * @param string $id
378 * @param string $template_type
379 * @return WP_Block_Template|null
380 */
381 public function track_block_template($template, $id, $template_type = '')
382 {
383 try {
384 if ($template && isset($template->id) && is_string($template->id) && $template->id !== '') {
385 $this->used_templates[self::normalize_template_id($template->id)] = true;
386 } elseif (is_string($id) && $id !== '') {
387 $this->used_templates[self::normalize_template_id($id)] = true;
388 }
389 } catch (\Throwable $e) {
390 $this->log_error('track_block_template', $e);
391 }
392 return $template;
393 }
394
395 /**
396 * Hook pre_render_block : intercepte les blocs core/template-part pour
397 * mémoriser "theme//slug" de chaque template part rendu.
398 *
399 * @param string|null $pre_render
400 * @param array $block
401 * @return string|null
402 */
403 public function track_template_part_block($pre_render, $block)
404 {
405 try {
406 if (is_array($block)
407 && isset($block['blockName'])
408 && $block['blockName'] === 'core/template-part'
409 ) {
410 $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : array();
411 $slug = isset($attrs['slug']) ? (string) $attrs['slug'] : '';
412 $theme = isset($attrs['theme']) ? (string) $attrs['theme'] : '';
413 if ($slug !== '') {
414 if ($theme === '' && function_exists('get_stylesheet')) {
415 $theme = get_stylesheet();
416 }
417 if ($theme !== '') {
418 $this->used_templates[self::normalize_template_id($theme . '//' . $slug)] = true;
419 }
420 }
421 }
422 } catch (\Throwable $e) {
423 $this->log_error('track_template_part_block', $e);
424 }
425 return $pre_render;
426 }
427
428 /**
429 * Injection CSS dans <head>.
430 */
431 public function inject_css()
432 {
433 try {
434 // Résolution des templates de la requête courante (template principal
435 // + template parts référencés, récursivement).
436 $this->resolve_current_templates();
437
438 // --- DEBUG : commentaire HTML toujours émis pour diagnostiquer
439 // facilement la détection des templates et la présence des entrées
440 // en base sans activer WP_DEBUG. À retirer en production une fois
441 // le mécanisme stabilisé.
442 $this->print_debug_comment();
443
444 if (empty($this->used_templates)) {
445 return;
446 }
447 $map = $this->get_map();
448 if (empty($map)) {
449 return;
450 }
451 $css = '';
452 foreach (array_keys($this->used_templates) as $tid) {
453 if (!isset($map[$tid]) || !is_array($map[$tid])) {
454 continue;
455 }
456 $value = isset($map[$tid]['css']) ? (string) $map[$tid]['css'] : '';
457 if ($value !== '') {
458 $css .= "\n/* ai-builder template CSS: " . $tid . " */\n" . $value;
459 }
460 }
461 if ($css !== '') {
462 echo '<style id="ai-builder-template-css" type="text/css">' . $css . '</style>';
463 }
464 } catch (\Throwable $e) {
465 $this->log_error('inject_css', $e);
466 }
467 }
468
469 /**
470 * Injection JS dans le <footer>.
471 */
472 public function inject_js()
473 {
474 try {
475 // Résolution / complément (les hooks pre_render_block ont déjà
476 // alimenté $used_templates à ce stade, mais on reste idempotent).
477 $this->resolve_current_templates();
478
479 if (empty($this->used_templates)) {
480 return;
481 }
482 $map = $this->get_map();
483 if (empty($map)) {
484 return;
485 }
486 $js = '';
487 foreach (array_keys($this->used_templates) as $tid) {
488 if (!isset($map[$tid]) || !is_array($map[$tid])) {
489 continue;
490 }
491 $value = isset($map[$tid]['js']) ? (string) $map[$tid]['js'] : '';
492 if ($value !== '') {
493 // Isolation via IIFE pour limiter les conflits de scope.
494 $js .= "\n;/* ai-builder template JS: " . $tid . " */\n"
495 . "(function(){ try { " . $value . " } catch(e){ if (window.console) console.error('ai-builder template JS error', e); } })();\n";
496 }
497 }
498 if ($js !== '') {
499 echo '<script id="ai-builder-template-js" type="text/javascript">' . $js . '</script>';
500 }
501 } catch (\Throwable $e) {
502 $this->log_error('inject_js', $e);
503 }
504 }
505
506 /**
507 * Émet un commentaire HTML de diagnostic dans la sortie pour aider
508 * au débogage du mécanisme d'injection sur le front.
509 *
510 * À retirer une fois le mécanisme stabilisé en production.
511 */
512 private function print_debug_comment()
513 {
514 try {
515 $main = isset($GLOBALS['_wp_current_template_id']) ? (string) $GLOBALS['_wp_current_template_id'] : '';
516 $has_cont = !empty($GLOBALS['_wp_current_template_content']);
517 $is_block = function_exists('wp_is_block_theme') ? (int) wp_is_block_theme() : -1;
518 $stylesh = function_exists('get_stylesheet') ? get_stylesheet() : '';
519 $detected = array_keys($this->used_templates);
520 $map_keys = array_keys($this->get_map());
521 $enabled = (int) $this->is_enabled();
522
523 $line = sprintf(
524 'ai-builder template-assets :: enabled=%d is_block_theme=%d stylesheet=%s main_template_id=%s has_template_content=%d detected=[%s] stored_keys=[%s]',
525 $enabled,
526 $is_block,
527 $stylesh,
528 $main !== '' ? $main : '(empty)',
529 $has_cont ? 1 : 0,
530 implode(',', $detected),
531 implode(',', $map_keys)
532 );
533
534 // Un commentaire HTML sûr : on interdit les "--" qui fermeraient
535 // prématurément le commentaire.
536 $line = str_replace('--', '- -', $line);
537 echo "\n<!-- " . $line . " -->\n";
538 } catch (\Throwable $e) {
539 // Jamais de fatal sur un bloc de debug.
540 }
541 }
542
543 /**
544 * Vérifie qu'un utilisateur a le droit d'éditer les templates / parts.
545 * Les capabilities "edit_theme_options" couvre le Site Editor en pratique.
546 *
547 * @return bool
548 */
549 public static function current_user_can_edit()
550 {
551 return current_user_can('edit_theme_options');
552 }
553
554 /**
555 * Log défensif (ne déclenche jamais de fatal).
556 */
557 private function log_error($context, $e)
558 {
559 if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
560 error_log('[AIBUI_Template_Assets][' . $context . '] ' . $e->getMessage());
561 }
562 }
563 }
564
565 } // fin du guard if (!class_exists('AIBUI_Template_Assets', false))
566
567 // Initialisation unique (protégée pour ne jamais faire planter le site).
568 // Grâce au singleton, même si ce fichier est inclus plusieurs fois dans la
569 // même requête, les hooks ne sont enregistrés qu'à la première exécution.
570 if (class_exists('AIBUI_Template_Assets')) {
571 try {
572 AIBUI_Template_Assets::instance();
573 } catch (\Throwable $e) {
574 if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
575 error_log('[AIBUI_Template_Assets] bootstrap error: ' . $e->getMessage());
576 }
577 }
578 }
579