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