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();
}
}