: Organization and WebSite as the * site-wide anchors, a WebPage per page, the primary entity for the post, * its author, video and breadcrumb. * * @package AEOmatic */ if ( ! defined( 'ABSPATH' ) ) { exit; } class AEOMATIC_Schema { public function register(): void { add_action( 'wp_head', array( $this, 'output' ), 5 ); // Speakable: mark the opening paragraph, then describe it after the // content has rendered — only when the mark is really on the page. add_filter( 'the_content', array( $this, 'mark_speakable' ), 20 ); add_action( 'wp_footer', array( $this, 'speakable_output' ), 5 ); // Invalidate the schema cache whenever content changes. add_action( 'save_post', array( $this, 'on_post_changed' ), 30, 2 ); add_action( 'edited_post_meta', array( $this, 'on_meta_changed' ), 10, 4 ); add_action( 'updated_post_meta', array( $this, 'on_meta_changed' ), 10, 4 ); add_action( 'deleted_post_meta', array( $this, 'on_meta_changed' ), 10, 4 ); add_action( 'set_object_terms', array( $this, 'on_terms_changed' ), 10, 4 ); } public function on_post_changed( int $post_id, WP_Post $post ): void { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } AEOMATIC_Schema_Builder::invalidate( $post_id ); AEOMATIC_Schema_Analyzer::invalidate_cache( $post_id ); // Rebuild the ACF text cache in a safe context so the frontend never // has to call get_field_objects() during wp_head. AEOMATIC_Content_Extractor::build_acf_cache( $post ); } /** * Invalidate when ACF or other meta updates — but skip our own cache key * to avoid infinite loops, and skip the audit score meta. */ public function on_meta_changed( $meta_id, int $object_id, string $meta_key, $meta_value ): void { // Don't invalidate on our own bookkeeping writes. $skip_keys = array( AEOMATIC_Schema_Builder::CACHE_META, AEOMATIC_Content_Extractor::ACF_CACHE_META, // Our own analysis output. None of these feed schema generation, so // writing them must not drop the built schema. '_aeomatic_score', '_aeomatic_score_breakdown', '_aeomatic_ai_readiness', '_aeomatic_ai_readiness_fingerprint', '_aeomatic_ai_readiness_score', '_aeomatic_citation_gaps', '_aeomatic_citation_gaps_fingerprint', '_aeomatic_citation_gaps_score', '_aeomatic_entity_scan', '_aeomatic_entity_scan_fingerprint', '_aeomatic_entity_coverage', AEOMATIC_Page_Crawler::META, '_edit_lock', '_edit_last', '_encloseme', ); if ( in_array( $meta_key, $skip_keys, true ) ) { return; } if ( ! get_post( $object_id ) ) { return; } AEOMATIC_Schema_Builder::invalidate( $object_id ); } public function on_terms_changed( int $object_id, $terms, $tt_ids, string $taxonomy ): void { if ( ! get_post( $object_id ) ) { return; } AEOMATIC_Schema_Builder::invalidate( $object_id ); } /** * Render JSON-LD blocks for the current request. * * The blocks form one graph linked by @id: Organization and WebSite are * the site-wide anchors, WebPage is the per-page spine, and the primary * entity, author and breadcrumb all point back at them. */ public function output(): void { if ( is_admin() || is_feed() || is_robots() || is_trackback() ) { return; } $settings = AEOMATIC_Settings::get( 'schema' ); if ( empty( $settings ) ) { return; } $post = null; $schema_type = ''; if ( is_singular() ) { $queried = get_queried_object(); if ( $queried instanceof WP_Post && AEOMATIC_Post_Types::is_enabled( $queried->post_type ) ) { $post = $queried; // Use the analyzer to pick the most accurate schema type. $schema_type = AEOMATIC_Schema_Analyzer::analyze( $post )['type']; } } $blocks = array(); // 1. Organization — printed in full on the homepage and the About page // only. Every other node references it by @id. if ( ! empty( $settings['organization'] ) && ( is_front_page() || 'AboutPage' === $schema_type ) ) { $org = $this->organization_schema( $settings ); if ( $org ) { $blocks[] = $org; } } // 2. WebSite — homepage only. if ( is_front_page() ) { $blocks[] = $this->website_schema( $settings ); } if ( $post ) { // 3. Primary schema via Generator — merges auto-extracted values // with any user-edited field overrides from the visual editor. $primary = null; if ( ( 'Product' === $schema_type && class_exists( 'WooCommerce' ) ) || ! empty( $settings['article'] ) ) { $primary = AEOMATIC_Schema_Generator::generate( $post, $schema_type, $settings ) ?: null; } // 4. WebPage — the per-page spine. A WebPage-subtype primary // (AboutPage, ProfilePage…) already is that node. if ( $primary && in_array( $schema_type, AEOMATIC_Schema_Generator::PAGE_TYPES, true ) ) { $blocks[] = $this->page_extras( $primary, get_permalink( $post ), $settings ); } else { $blocks[] = $this->webpage_schema( $post, $settings ); if ( $primary ) { $blocks[] = $primary; } } // 5. Author schema. if ( ! empty( $settings['author'] ) ) { $author = $this->author_schema( (int) $post->post_author ); if ( $author ) { $blocks[] = $author; } } // 6. VideoObject — auto-detected YouTube / Vimeo embeds. $video = $this->video_schema( $post ); if ( $video ) { $blocks[] = $video; } } elseif ( is_front_page() || is_home() || is_category() || is_tag() || is_tax() ) { $page = $this->archive_page_schema( $settings ); if ( $page ) { $blocks[] = $page; } } if ( ! empty( $settings['breadcrumb'] ) && ! is_front_page() ) { $crumbs = $this->breadcrumb_schema(); if ( $crumbs ) { $blocks[] = $crumbs; } } foreach ( $blocks as $b ) { $this->print_block( $b ); } if ( ! empty( $blocks ) ) { AEOMATIC_Activity::bump( 'schema_renders', count( $blocks ) ); } } /** * Print one JSON-LD block as a