` element per generated object. * * @since 1.3.0 */ final class SchemaEngine implements ModuleInterface { /** * Module slug, which also keys `enable_schema` in settings. * * @since 1.3.0 * @var string */ public const SLUG = 'schema'; /** * Priority at which the module prints inside `wp_head`. * * @since 1.3.0 * @var int */ public const PRINT_PRIORITY = 20; /** * Class attribute on every emitted script, for identification. * * @since 1.3.0 * @var string */ public const SCRIPT_CLASS = 'aive-schema'; /** * Ids of the posts this request has already printed for. * * Keyed by post id rather than held as a single boolean, because the subject * of the guard is the record: a callback registered twice, or a template that * reaches `wp_head` more than once, must not print the same graph twice. * * @since 1.3.0 * @var array */ private $emitted = array(); /** * Returns the module slug. * * @since 1.3.0 * * @return string Module slug. */ public function slug() { return self::SLUG; } /** * Attaches the module's WordPress hooks. * * The print callback is attached unconditionally and decides inside itself, * for the reason the other modules state: hooks are registered once per * request, so deciding here would freeze the answer for the whole request. * * The invalidation set covers every input the cache key carries. It is * belt-and-braces by design - a change to any of these already moves the key * - but deleting the entries keeps the options table from holding twelve * hours of unreachable rows. * * @since 1.3.0 * * @return void */ public function register_hooks() { add_action( 'wp_head', array( $this, 'print_schema' ), self::PRINT_PRIORITY ); // Post-scoped: these hooks all pass a post id first. foreach ( array( 'save_post', 'edit_post', 'before_delete_post', 'deleted_post', 'trashed_post', 'untrashed_post', 'clean_post_cache' ) as $hook ) { add_action( $hook, array( SchemaCache::class, 'flush_post' ) ); } add_action( 'transition_post_status', array( $this, 'flush_on_transition' ), 10, 3 ); foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) { add_action( $hook, array( $this, 'flush_on_thumbnail_meta' ), 10, 3 ); } /* * Site-wide: identity, presentation and ownership inputs are shared by * every post, so there is nothing narrower than a full flush to do. */ $site_wide = array( 'add_option_' . Helpers::OPTION_KEY, 'update_option_' . Helpers::OPTION_KEY, 'delete_option_' . Helpers::OPTION_KEY, 'update_option_blogname', 'update_option_home', 'update_option_siteurl', 'update_option_site_icon', 'update_option_WPLANG', 'update_option_active_plugins', 'update_site_option_active_sitewide_plugins', 'switch_theme', 'customize_save_after', 'activated_plugin', 'deactivated_plugin', 'profile_update', 'deleted_user', 'user_register', ); foreach ( $site_wide as $hook ) { add_action( $hook, array( SchemaCache::class, 'flush_all' ) ); } /* * The custom logo lives in the active theme's `theme_mods` option, whose * name carries the stylesheet, so the hook name has to be built. */ add_action( 'update_option_theme_mods_' . get_stylesheet(), array( SchemaCache::class, 'flush_all' ) ); } /** * Prints the generated JSON-LD for the current request, when it qualifies. * * @since 1.3.0 * * @return void */ public function print_schema() { if ( ! Helpers::is_module_enabled( self::SLUG ) ) { return; } if ( ! self::is_eligible_request() ) { return; } $eligibility = self::post_eligibility( get_queried_object() ); if ( null === $eligibility['post'] ) { return; } $post = $eligibility['post']; if ( isset( $this->emitted[ (int) $post->ID ] ) ) { return; } $ownership = self::ownership_result( (int) $post->ID, array( 'surface' => 'frontend', 'eligible' => true, ) ); if ( ! SchemaOwnership::allows_emission( $ownership ) ) { return; } $payload = self::payload( $post, $ownership ); /* * Closed before printing rather than after: were anything hooked into * output buffering to re-enter `wp_head`, the guard has to already hold. */ $this->emitted[ (int) $post->ID ] = true; foreach ( $payload['objects'] as $object ) { if ( ! is_array( $object ) ) { continue; } $json = SchemaBuilder::encode( $object ); if ( null === $json ) { Helpers::log( 'schema: an object could not be encoded as JSON and was not emitted', array( 'post_id' => (int) $post->ID ) ); continue; } printf( '' . "\n", esc_attr( self::SCRIPT_CLASS ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON from wp_json_encode() with JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT; every <, >, &, ' and " in a value is already a \u escape, and an HTML escaper here would corrupt the structural quotes. $json ); } } /** * Returns the schema ownership decision for one post. * * The stable service a future aggregate Conflict Detector consumes, named * here so that callers depend on the module rather than on the class that * happens to implement the adapters today. That aggregate is not built in * this increment. * * @since 1.3.0 * * @param int $post_id Post being evaluated. * @param array $context Caller context; see SchemaOwnership::result(). * @return array{state: string, reason_code: string, owner: string|null, evidence: array, override_applied: bool} Ownership result. */ public static function ownership_result( $post_id, array $context = array() ) { return SchemaOwnership::result( $post_id, $context ); } /** * Returns the generated payload for a post, from cache when possible. * * @since 1.3.0 * * @param WP_Post $post Post being evaluated. * @param array{state: string, reason_code: string, owner: string|null, evidence: array, override_applied: bool} $ownership Ownership decision the payload was generated under. * @return array{objects: array>, diagnostics: array, faq_source: string, howto_source: string} Generated payload. */ public static function payload( WP_Post $post, array $ownership ) { $key = SchemaCache::key( $post, $ownership ); $cached = SchemaCache::get( $key ); if ( null !== $cached ) { return $cached; } return SchemaCache::set( $key, SchemaBuilder::build( $post ) ); } /** * Reports whether the current request may carry generated schema at all. * * Every exclusion the contract names is tested explicitly, including the * ones `is_singular()` already implies. The redundancy is the point: this * list is the specification, and a reader checking it against the contract * should not have to know which conditional query tags overlap. * * @since 1.3.0 * * @return bool True when the request is a public, singular front-end view. */ public static function is_eligible_request() { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } if ( function_exists( 'wp_is_serving_rest_request' ) && wp_is_serving_rest_request() ) { return false; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return false; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return false; } if ( self::is_login_request() ) { return false; } if ( ! function_exists( 'is_singular' ) || ! did_action( 'wp' ) ) { /* * No main query has run, so there is no queried object to describe. * `wp_head` on a page assembled outside the normal query flow lands * here, and guessing a subject would be worse than printing nothing. */ return false; } if ( is_feed() || is_embed() || is_preview() ) { return false; } if ( is_404() || is_search() || is_home() || is_archive() || is_attachment() ) { return false; } return (bool) is_singular(); } /** * Reports whether this request is one of the authentication screens. * * `wp_head` does not run on wp-login.php - it prints `login_head` instead - * so this is defence against a theme or plugin that calls `wp_head()` there, * not a path WordPress takes on its own. * * @since 1.3.0 * * @return bool True on a login or registration request. */ private static function is_login_request() { if ( did_action( 'login_init' ) || did_action( 'login_head' ) ) { return true; } $pagenow = isset( $GLOBALS['pagenow'] ) && is_string( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : ''; return in_array( $pagenow, array( 'wp-login.php', 'wp-register.php', 'wp-signup.php' ), true ); } /** * Reports whether a post itself may carry generated schema, and why not. * * Post-level only: it says nothing about the request. The admin preview uses * it on its own, because a post that is eligible in this sense can still * never emit on a context the request gates exclude - which is exactly why * the preview is labelled a generated candidate rather than proof of * emission. * * @since 1.3.0 * * @param mixed $candidate Queried object, post id, or post. * @return array{post: WP_Post|null, reason: string} The post when eligible, and the machine-readable reason either way. */ public static function post_eligibility( $candidate ) { if ( is_numeric( $candidate ) ) { $candidate = get_post( (int) $candidate ); } if ( ! $candidate instanceof WP_Post ) { return self::ineligible( 'no-post' ); } $post = $candidate; if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return self::ineligible( 'revision-or-autosave' ); } if ( 'attachment' === $post->post_type ) { return self::ineligible( 'attachment' ); } if ( 'publish' !== $post->post_status ) { return self::ineligible( 'not-published' ); } if ( ! in_array( $post->post_type, self::eligible_post_types(), true ) ) { return self::ineligible( 'post-type-not-eligible' ); } if ( '' !== (string) $post->post_password ) { return self::ineligible( 'password-protected' ); } if ( '' === SchemaText::normalize( $post->post_title ) ) { return self::ineligible( 'title-empty' ); } if ( '' === SchemaBuilder::absolute_url( get_permalink( $post ) ) ) { return self::ineligible( 'permalink-invalid' ); } return array( 'post' => $post, 'reason' => 'eligible', ); } /** * Returns the post types that may carry generated schema. * * `post` and `page` are named because core does not mark `page` with * `publicly_queryable`, so a purely capability-driven test would drop it. * Everything else has to be a public custom post type, which is the same * test WordPress uses to decide whether a single view exists at all. * Attachments are excluded by `post_eligibility()` despite being public. * * @since 1.3.0 * * @return array Eligible post type names. */ public static function eligible_post_types() { $types = array( 'post', 'page' ); foreach ( get_post_types( array( 'public' => true ), 'names' ) as $type ) { if ( 'attachment' === $type || in_array( $type, $types, true ) ) { continue; } $types[] = $type; } return $types; } /** * Builds an ineligible result. * * @since 1.3.0 * * @param string $reason Machine-readable reason code. * @return array{post: null, reason: string} Ineligible result. */ private static function ineligible( $reason ) { return array( 'post' => null, 'reason' => (string) $reason, ); } /** * Clears a post's cache when its status changes. * * @since 1.3.0 * * @param string $new_status New post status. * @param string $old_status Previous post status. * @param WP_Post|null $post Post that changed. * @return void */ public function flush_on_transition( $new_status, $old_status, $post = null ) { if ( $post instanceof WP_Post ) { SchemaCache::flush_post( (int) $post->ID ); } } /** * Clears a post's cache when its featured image changes. * * @since 1.3.0 * * @param int|mixed $meta_id Meta row id, unused. * @param int|mixed $object_id Post the meta belongs to. * @param string|mixed $meta_key Meta key that changed. * @return void */ public function flush_on_thumbnail_meta( $meta_id, $object_id, $meta_key ) { if ( '_thumbnail_id' !== $meta_key ) { return; } SchemaCache::flush_post( $object_id ); } }