capability()) * 5. Register : appelle $tab->register() sur plugins_loaded prio 20 * 6. Assets : enqueue conditionnel des assets() du tab actif uniquement * 7. Hub : enregistre la page plugin dans AIFORYA_Hub * 8. Render : compose le tableau tabs et délègue à AIFORYA_FAQ_Shell * * Le moteur n'instancie JAMAIS d'objet métier. Il ne connaît que Aiforya_Faq_Tab. * * USAGE dans le main file plugin (template figé) : * * add_action( 'plugins_loaded', function() { * Aiforya_Faq_Engine::boot( __DIR__, [ * 'slug' => 'aiforya-{slug}', * 'title' => 'AIFORYA {Title}', * 'description' => '...', * 'icon' => 'dashicons-...', * 'category' => 'performance|seo|conversion|...', * ] ); * }, 5 ); * * @package Aiforya_Vendor * @since 1.0.0 (28/04/2026) */ if ( ! defined( 'ABSPATH' ) ) { exit; } if ( class_exists( 'Aiforya_Faq_Engine', false ) ) { return; // Idempotent. } class Aiforya_Faq_Engine { const VERSION = '1.0.0'; /** @var array Une instance par plugin. */ private static array $instances = []; private string $plugin_dir; private string $plugin_slug; /** @var array */ private array $config; /** @var array */ private array $tabs = []; private bool $booted = false; /** * Point d'entrée unique. Appelé depuis le main file plugin. * * @param string $plugin_dir __DIR__ du plugin (ex: WP_PLUGIN_DIR/aiforya-wooboost) * @param array $config [slug, title, description, icon?, category?] */ public static function boot( string $plugin_dir, array $config ): void { $slug = $config['slug'] ?? ''; if ( $slug === '' || isset( self::$instances[ $slug ] ) ) { return; } $engine = new self( $plugin_dir, $config ); self::$instances[ $slug ] = $engine; $engine->initialize(); } /** * Récupère une instance moteur par slug (utile pour cross-tab si vraiment nécessaire). */ public static function get( string $slug ): ?self { return self::$instances[ $slug ] ?? null; } /** * @param array $config */ private function __construct( string $plugin_dir, array $config ) { $this->plugin_dir = rtrim( $plugin_dir, '/\\' ); $this->plugin_slug = (string) ( $config['slug'] ?? '' ); $this->config = $config; } /** * Wiring complet : discovery → register tabs → admin menu → assets. */ private function initialize(): void { if ( $this->booted ) { return; } $this->booted = true; $this->discover_tabs(); $this->sort_tabs(); // WP 6.7+ : register() doit fire APRÈS init pour ne pas déclencher // `_load_textdomain_just_in_time was called incorrectly`. Tab register() // contient typiquement add_action(wp_ajax_*) — wp_ajax fire post-init donc OK. add_action( 'init', [ $this, 'on_plugins_loaded' ], 1 ); if ( is_admin() ) { add_action( 'admin_menu', [ $this, 'on_admin_menu' ], 20 ); add_action( 'admin_enqueue_scripts', [ $this, 'on_admin_enqueue_scripts' ] ); } } /** * Discovery : require_once tous les fichiers tabs/class-tab-*.php du plugin * + vendor-aiforya/class-aiforya-tab-*.php pour les tabs shared (Support, Avis). * Puis instancie toutes les classes qui étendent Aiforya_Faq_Tab. */ private function discover_tabs(): void { $patterns = [ $this->plugin_dir . '/tabs/class-tab-*.php', $this->plugin_dir . '/vendor-aiforya/class-aiforya-tab-*.php', ]; // skeleton — discovery par EXTRACTION du nom de classe // depuis le source du fichier (au lieu d'un diff de classes déclarées). // Raison : avec le diff, si Plugin A a déjà chargé vendor-aiforya/class-aiforya-tab-support.php, // Plugin B's discover voit la classe déjà déclarée → exclue du diff → jamais instanciée // pour Plugin B. Bug observé MMP 28/04 nuit (Support/Avis manquants). // Fix : on scanne chaque fichier pour trouver `class X extends Aiforya_Faq_Tab`, // require_once le fichier (idempotent), puis on instancie pour CE moteur, // indépendamment de l'état de la classe globale PHP. $candidate_classes = []; foreach ( $patterns as $pattern ) { foreach ( (array) glob( $pattern ) as $file ) { if ( ! is_file( $file ) ) { continue; } $contents = @file_get_contents( $file ); if ( is_string( $contents ) && preg_match_all( '/^\s*(?:final\s+|abstract\s+)?class\s+(\w+)\s+extends\s+Aiforya_Faq_Tab\b/m', $contents, $m ) ) { foreach ( (array) $m[1] as $cls ) { $candidate_classes[] = (string) $cls; } } require_once $file; } } // Dedup + instanciation. Chaque moteur garde son PROPRE tableau d'instances // donc instancier Aiforya_Faq_Tab_Support pour MMP n'affecte pas Cart Recovery. $candidate_classes = array_values( array_unique( $candidate_classes ) ); foreach ( $candidate_classes as $cls ) { if ( ! class_exists( $cls ) || ! is_subclass_of( $cls, 'Aiforya_Faq_Tab' ) ) { continue; } try { $this->tabs[] = new $cls(); } catch ( \Throwable $e ) { // Journal de diagnostic retiré de la variante wordpress.org. } } } private function sort_tabs(): void { usort( $this->tabs, static fn( Aiforya_Faq_Tab $a, Aiforya_Faq_Tab $b ): int => $a->order() <=> $b->order() ); } /** * Hook plugins_loaded prio 20 : appelle $tab->register() pour chaque onglet. * Permet aux tabs d'enregistrer leurs settings, AJAX, cron, etc. */ public function on_plugins_loaded(): void { foreach ( $this->tabs as $tab ) { try { $tab->register(); } catch ( \Throwable $e ) { // Journal de diagnostic retiré de la variante wordpress.org. } } } /** * Hook admin_menu prio 20 : enregistre la page plugin dans le Hub AIFORYA. * Le Hub crée le menu unique "AIFORYA" et y ajoute la sous-page. */ public function on_admin_menu(): void { if ( ! class_exists( 'AIFORYA_Hub' ) ) { return; } AIFORYA_Hub::register_plugin_page( [ 'slug' => $this->plugin_slug, 'page_title' => 'AIFORYA — ' . ( $this->config['title'] ?? $this->plugin_slug ), 'menu_label' => $this->config['title'] ?? $this->plugin_slug, 'plugin_name' => $this->config['title'] ?? $this->plugin_slug, 'plugin_image' => $this->config['plugin_image'] ?? '', 'category' => $this->config['category'] ?? '', 'description' => $this->config['description'] ?? '', 'capability' => $this->config['capability'] ?? 'manage_options', 'callback' => [ $this, 'render_admin_page' ], 'icon' => $this->config['icon'] ?? 'dashicons-admin-generic', 'position' => $this->config['position'] ?? 20, ] ); } /** * Hook admin_enqueue_scripts : enqueue les assets() du tab actif uniquement. * Évite de charger les CSS/JS de tous les tabs sur toutes les pages admin. */ public function on_admin_enqueue_scripts( string $hook ): void { // AIFORYA UI helpers (Toast + Confirm modal charte) — doctrine PATTERN JS INVIOLABLE. // Enqueued GLOBALEMENT sur toutes pages admin pour que activation.js // et autres scripts vendor partagés puissent utiliser AiforyaConfirm.ask sans // dépendre du contexte de page plugin. wp_enqueue_* dédup automatiquement par handle. $vendor_url = $this->plugin_url() . 'vendor-aiforya/assets/'; wp_enqueue_style( 'aiforya-ui-bbf8d166', $vendor_url . 'aiforya-ui.css', [], self::VERSION ); wp_enqueue_script( 'aiforya-ui-bbf8d166', $vendor_url . 'aiforya-ui.js', [], self::VERSION, true ); wp_localize_script( 'aiforya-ui-bbf8d166', 'aiforyaUiI18n', [ 'confirm' => __( 'OK', 'aiforya-faq-generator' ), 'cancel' => __( 'Cancel', 'aiforya-faq-generator' ), ] ); // Vérifie qu'on est bien sur une sous-page de notre plugin pour les assets tab actif. if ( strpos( $hook, $this->plugin_slug ) === false ) { return; } $active = $this->get_active_tab(); if ( $active === null ) { return; } $assets = $active->assets(); foreach ( $assets['css'] ?? [] as $css ) { wp_enqueue_style( (string) $css['handle'], (string) $css['src'], (array) ( $css['deps'] ?? [] ), (string) ( $css['ver'] ?? self::VERSION ) ); } foreach ( $assets['js'] ?? [] as $js ) { wp_enqueue_script( (string) $js['handle'], (string) $js['src'], (array) ( $js['deps'] ?? [] ), (string) ( $js['ver'] ?? self::VERSION ), (bool) ( $js['in_footer'] ?? true ) ); } } /** * Callback de rendu — appelé par AIFORYA_Hub via le menu admin WP. * Compose le tableau tabs et délègue le rendu à AIFORYA_FAQ_Shell. */ public function render_admin_page(): void { if ( ! class_exists( 'AIFORYA_FAQ_Shell' ) ) { echo '

AIFORYA_FAQ_Shell missing — check vendor-aiforya/.

'; return; } $visible_tabs = $this->visible_tabs(); $shell_tabs = []; foreach ( $visible_tabs as $tab ) { $shell_tabs[ $tab->slug() ] = [ 'label' => $tab->label(), 'callback' => function ( array $args ) use ( $tab ): void { try { // Le tampon vit ICI depuis V783-B, plus dans render() : l'onglet EMET. // Un echo de valeur empechait PHPCS de voir l'echappement, qui a // toujours ete fait DANS render(). ob_end_flush() n'est pas un echo // de variable : le sniff se tait sans qu'on ait rien masque. // Le tampon est conserve pour que le catch ci-dessous puisse tout // jeter : une emission nue laisserait du HTML partiel a l'ecran. ob_start(); $tab->render( $args ); ob_end_flush(); } catch ( \Throwable $e ) { if ( ob_get_level() > 0 ) { ob_end_clean(); } printf( '

%s

', esc_html__( 'Error while rendering this tab. See error_log.', 'aiforya-faq-generator' ) ); // Journal de diagnostic retiré de la variante wordpress.org. } }, ]; } AIFORYA_FAQ_Shell::render_admin_page( [ 'slug' => $this->plugin_slug, 'title' => $this->config['title'] ?? $this->plugin_slug, 'description' => $this->config['description'] ?? '', 'tabs' => $shell_tabs, 'support_module' => false, // Support et Avis sont des Aiforya_Faq_Tab shared, pas auto-injectés. 'plugin_basename' => $this->config['plugin_basename'] ?? '', ] ); } /** * Filtre les tabs selon la capability courante de l'utilisateur. * * @return array */ private function visible_tabs(): array { return array_values( array_filter( $this->tabs, static fn( Aiforya_Faq_Tab $tab ): bool => current_user_can( $tab->capability() ) ) ); } /** * Retourne le tab actif d'après ?tab=... */ private function get_active_tab(): ?Aiforya_Faq_Tab { $visible = $this->visible_tabs(); if ( empty( $visible ) ) { return null; } $requested = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification if ( $requested ) { foreach ( $visible as $tab ) { if ( $tab->slug() === $requested ) { return $tab; } } } return $visible[0]; // défaut : premier tab visible } /** * Helper : retourne le chemin physique du plugin (utile pour les Tab qui * ont besoin de référencer leurs propres fichiers). */ public function plugin_dir(): string { return $this->plugin_dir; } /** * Helper : retourne une clé de config passée à boot(). * Utilisé par les Tabs partagés (Support / Avis) pour lire faq, etc. * * @param mixed $default Valeur retournée si la clé n'existe pas. * @return mixed */ public function get_config( string $key, $default = null ) { return $this->config[ $key ] ?? $default; } /** * Helper : URL des assets du plugin. */ public function plugin_url(): string { return plugin_dir_url( $this->plugin_dir . '/dummy.php' ); } }