sequences. * * @package Schema_AI * @since 1.0.0 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Class Schema_AI_Schema_Renderer */ class Schema_AI_Schema_Renderer { /** * Schema cache instance. * * @var Schema_AI_Schema_Cache */ private $cache; /** * Constructor. * * Loads the cache class and registers the wp_head hook. */ public function __construct() { if ( ! class_exists( 'Schema_AI_Schema_Cache' ) ) { require_once dirname( __FILE__ ) . '/class-schema-cache.php'; } if ( ! class_exists( 'Schema_AI_ID_Builder' ) ) { require_once dirname( __FILE__ ) . '/class-id-builder.php'; } $this->cache = new Schema_AI_Schema_Cache(); } /** * Initialise hooks. * * Attaches the render method to wp_head so that JSON-LD is output * on every front-end page load. */ public function init() { add_action( 'wp_head', array( $this, 'render' ), 1 ); } /** * Render JSON-LD script tag(s) in the document head. * * Runs on the wp_head action. For singular posts/pages it loads the * schema from cache (or the database), validates it, and outputs a * (or , , etc.) breakout impossible // regardless of casing or spacing — a proper fix for the sink, replacing the // old case-sensitive str_replace that only caught one exact spelling. $json = wp_json_encode( $graph, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); if ( false === $json ) { return; } echo "\n\n"; echo '' . "\n"; } /** * Assemble a {"@context": "https://schema.org", "@graph": [...]} wrapper. * * Strips individual @context keys (invalid inside @graph), flattens any * nested @graph arrays from previously stored schemas, and returns the * complete structure ready for JSON encoding. * * @param array $schemas Array of schema entity arrays. * @return array */ private function build_graph( $schemas ) { if ( empty( $schemas ) ) { return array(); } // Normalise a single schema to an array. if ( isset( $schemas['@type'] ) ) { $schemas = array( $schemas ); } $entities = array(); foreach ( $schemas as $schema ) { if ( ! is_array( $schema ) || empty( $schema ) ) { continue; } // If a stored schema is already wrapped in @graph (legacy format), // unwrap it so we don't nest @graph inside @graph. if ( isset( $schema['@graph'] ) && is_array( $schema['@graph'] ) ) { foreach ( $schema['@graph'] as $entity ) { unset( $entity['@context'] ); $entities[] = $entity; } continue; } // Strip @context — it belongs only on the outer wrapper. unset( $schema['@context'] ); $entities[] = $schema; } if ( empty( $entities ) ) { return array(); } return array( '@context' => 'https://schema.org', '@graph' => $entities, ); } /** * Load schema data from the custom database table. * * @param int $post_id The WordPress post ID. * @return array|false Array of schema data arrays, or false on failure. */ private function load_schemas_from_db( $post_id ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // Check if the table exists first. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( ! $table_exists ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT schema_data FROM {$table} WHERE post_id = %d AND validation_status != 'invalid' ORDER BY created_at DESC", $post_id ) ); if ( empty( $rows ) ) { return false; } $schemas = array(); foreach ( $rows as $row ) { $data = json_decode( $row->schema_data, true ); if ( is_array( $data ) && ! empty( $data ) ) { $schemas[] = $data; } } return ! empty( $schemas ) ? $schemas : false; } /** * Manually render schema for a specific post. * * Can be called from templates or shortcodes to output schema markup * at a specific location instead of in wp_head. * * @param int $post_id The WordPress post ID. * @param array|null $schema Optional schema data. If null, loads from DB/cache. */ public function render_for_post( $post_id, $schema = null ) { if ( null === $schema ) { $schema = $this->cache->get_cached_schema( $post_id ); if ( false === $schema ) { $schema = $this->load_schemas_from_db( $post_id ); } } if ( empty( $schema ) ) { return; } $schemas = isset( $schema['@type'] ) ? array( $schema ) : $schema; $this->output_graph( $schemas ); } /** * Get the schema JSON string for a post without outputting it. * * Useful for the REST API, admin preview, or testing. * * @param int $post_id The WordPress post ID. * @return string|false JSON string or false if no schema exists. */ public function get_json_ld( $post_id ) { $schemas = $this->cache->get_cached_schema( $post_id ); if ( false === $schemas ) { $schemas = $this->load_schemas_from_db( $post_id ); } if ( empty( $schemas ) ) { return false; } $graph = $this->build_graph( $schemas ); if ( empty( $graph['@graph'] ) ) { return false; } // Match output_graph(): JSON_HEX_TAG|JSON_HEX_AMP so any consumer echoing // this string cannot be broken out of a