tag high in the . * * @package SchemaMonsterFree */ namespace SchemaMonsterFree; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Frontend controller. */ final class Frontend { /** * The graph we intend to emit for the current request. Cached across * hooks so we don't rebuild it twice within one request. * * @var array|null */ private static $current_graph = null; /** * Post ID we already built the graph for. * * @var int */ private static $graph_post_id = 0; /** * Cache key combining post ID + harvest fingerprint. Invalidates the * graph when the same post is rebuilt with different harvested Qs. * * @var string */ private static $graph_cache_key = ''; /** * True once we've decided to buffer this request. * * @var bool */ private static $buffering = false; /** * Register hooks. */ public static function init(): void { // Open a full-page output buffer at the earliest safe frontend hook. // template_redirect fires once WordPress has decided which template to // load, after auth checks, before any header/HTML is sent. add_action( 'template_redirect', array( __CLASS__, 'maybe_start_buffer' ), 0 ); // Scan post content for FAQs before the buffer callback. Priority // 100 runs after plugins attach their the_content filters. // Elementor-built posts are read from meta, never rendered here. add_action( 'template_redirect', array( __CLASS__, 'prime_faq_detection' ), 100 ); // Close the buffer as late as possible so we've captured everything // third-party plugins wrote. wp_footer priority 999999 is the standard // "very late" hook. add_action( 'wp_footer', array( __CLASS__, 'close_buffer' ), 999999 ); // Fallback: some themes bypass wp_footer entirely. shutdown always // fires; close_buffer is idempotent. add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 ); } /** * Start buffering the whole response if this request qualifies for * schema emission. */ public static function maybe_start_buffer(): void { if ( self::$buffering ) { return; } if ( ! self::should_emit() ) { return; } self::$buffering = true; // Suppress mode filters Yoast/Rank Math output, so it must be // registered now, before wp_head runs. Not on pages where Schema // Monster is disabled, which would otherwise end up with no schema. if ( Coexistence::MODE_SUPPRESS === Coexistence::resolve_mode() && ! SMFree_Overrides::is_disabled( (int) get_queried_object_id() ) ) { Coexistence::register_suppression(); } ob_start( array( __CLASS__, 'process_buffer' ) ); } /** * Run FAQ detection for the queried post now, then lock the detector. * * For non-Elementor posts detection renders post content, and content * rendering (core blocks, shortcodes) uses ob_start(), which PHP forbids * inside an output-buffer callback. Priming here caches the result so * the call made from process_buffer() never renders. */ public static function prime_faq_detection(): void { if ( ! self::$buffering ) { return; } $post_id = (int) get_queried_object_id(); if ( $post_id > 0 && ! SMFree_Overrides::is_disabled( $post_id ) ) { SMFree_FAQ_Detector::detect( $post_id ); } SMFree_FAQ_Detector::lock(); } /** * Close the buffer (idempotent). Safe to call from multiple hooks. */ public static function close_buffer(): void { if ( ! self::$buffering ) { return; } // Only close if our buffer is still on the stack. if ( ob_get_level() > 0 ) { @ob_end_flush(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } self::$buffering = false; } /** * Output buffer callback. Receives the full response HTML, builds our * graph, applies the coexistence mode, and injects our ', $json ); } /** * Decide whether the current request should get schema. */ private static function should_emit(): bool { if ( ! (bool) get_option( SMFREE_OPTION_PREFIX . 'enabled', 1 ) ) { return false; } if ( is_admin() ) { return false; } // Skip non-HTML responses. if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } if ( is_feed() || is_search() || is_404() || is_robots() || is_trackback() ) { return false; } if ( ! is_singular() ) { // v1.0 emits for singular views only (posts, pages, static // front page, single WooCommerce products). Archives, taxonomies, // and blog-post index pages are skipped in v1.0. return false; } return true; } /** * Build the graph for the current queried object, merging in any * harvested FAQ questions. Cache is keyed on post ID + harvest * fingerprint so a page that gains new harvested content between * calls rebuilds correctly. * * @param array $harvested * @return array */ private static function build_graph_with_harvest( array $harvested ): array { $post_id = (int) get_queried_object_id(); if ( $post_id <= 0 ) { return array(); } $fingerprint = md5( wp_json_encode( $harvested ) ?: '' ); $cache_key = $post_id . ':' . $fingerprint; if ( self::$graph_cache_key === $cache_key && is_array( self::$current_graph ) ) { return self::$current_graph; } self::$graph_cache_key = $cache_key; self::$graph_post_id = $post_id; self::$current_graph = Engine::build_graph( $post_id, $harvested ); return is_array( self::$current_graph ) ? self::$current_graph : array(); } }