build_graph( $post ); if ( empty( $graph ) ) { return; } $doc = array( '@context' => 'https://schema.org', '@graph' => array_values( $graph ), ); $json = wp_json_encode( $doc, JSON_UNESCAPED_UNICODE ); $json = str_replace( array( '<', '>', '&' ), array( '\\u003c', '\\u003e', '\\u0026' ), (string) $json ); echo "\n\n"; echo '\n"; // phpcs:ignore WordPress.Security.EscapeOutput } /** * Build the JSON-LD node graph AICitare would emit for a post (without the * @context/@graph wrapper). Exposed so the schema validator can inspect it. * * @param WP_Post $post Post. * @return array List of schema nodes. */ public function build_graph( $post ) { if ( ! $post instanceof WP_Post ) { return array(); } $page = $this->find_page( $post->ID ); $fixed = AICitare_Data::get_fixed(); $types = $this->applied_types( $page, $fixed ); if ( empty( $types ) ) { return; } $settings = AICitare_Data::get_settings(); $complementary = ( 'full' !== $settings['schema'] ) && $this->seo_plugin_active(); $graph = array(); if ( in_array( 'Article', $types, true ) && ! $complementary ) { $graph[] = $this->article( $post ); } if ( in_array( 'ItemList', $types, true ) ) { $g = $this->itemlist( $post ); if ( $g ) { $graph[] = $g; } } if ( in_array( 'FAQPage', $types, true ) ) { $g = $this->faq( $post ); if ( $g ) { $graph[] = $g; } } if ( in_array( 'HowTo', $types, true ) ) { $g = $this->howto( $post ); if ( $g ) { $graph[] = $g; } } if ( in_array( 'Product', $types, true ) ) { $graph[] = $this->product( $post ); } if ( in_array( 'Person', $types, true ) ) { $g = $this->person( $post ); if ( $g ) { $graph[] = $g; } } // Additive enrichment emitted alongside any applied fix. $graph[] = $this->breadcrumb( $post ); $sp = $this->speakable( $post ); if ( $sp ) { $graph[] = $sp; } if ( ! $complementary ) { $graph[] = $this->organization(); } $graph = apply_filters( 'aicitare_schema_graph', $graph, $post ); return array_values( $graph ); } /** Map fixed issues to the schema types that should be emitted. */ private function applied_types( $page, $fixed ) { $types = array(); if ( ! $page || empty( $page['issues'] ) ) { return $types; } foreach ( $page['issues'] as $is ) { if ( empty( $fixed[ $is['id'] ] ) ) { continue; } $types = array_merge( $types, $this->types_for_issue( $is['cat'], $is['title'] ) ); } return array_values( array_unique( $types ) ); } /** Schema types a single issue maps to (shared by emit and preview). */ public function types_for_issue( $cat, $title ) { $title = strtolower( $title ); $types = array(); if ( 'schema' === $cat ) { if ( false !== strpos( $title, 'itemlist' ) || false !== strpos( $title, 'review' ) ) { $types[] = 'ItemList'; $types[] = 'Product'; } if ( false !== strpos( $title, 'faq' ) ) { $types[] = 'FAQPage'; } if ( false !== strpos( $title, 'howto' ) || false !== strpos( $title, 'how-to' ) || false !== strpos( $title, 'how to' ) ) { $types[] = 'HowTo'; } if ( false !== strpos( $title, 'product' ) ) { $types[] = 'Product'; } $types[] = 'Article'; } elseif ( 'structure' === $cat && false !== strpos( $title, 'faq' ) ) { $types[] = 'FAQPage'; } elseif ( 'authority' === $cat && ( false !== strpos( $title, 'author' ) || false !== strpos( $title, 'person' ) || false !== strpos( $title, 'reviewer' ) ) ) { $types[] = 'Person'; } return $types; } /** Build the JSON-LD that WOULD be injected for a single issue (preview, not persisted). */ public function preview_json( $post, $cat, $title ) { $types = array_values( array_unique( $this->types_for_issue( $cat, $title ) ) ); if ( empty( $types ) ) { return ''; } $graph = array(); foreach ( $types as $t ) { switch ( $t ) { case 'Article': $graph[] = $this->article( $post ); break; case 'ItemList': $g = $this->itemlist( $post ); if ( $g ) { $graph[] = $g; } break; case 'FAQPage': $g = $this->faq( $post ); if ( $g ) { $graph[] = $g; } break; case 'HowTo': $g = $this->howto( $post ); if ( $g ) { $graph[] = $g; } break; case 'Product': $graph[] = $this->product( $post ); break; case 'Person': $g = $this->person( $post ); if ( $g ) { $graph[] = $g; } break; } } if ( empty( $graph ) ) { return ''; } $doc = array( '@context' => 'https://schema.org', '@graph' => array_values( $graph ) ); return wp_json_encode( $doc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); } /* ---------------- builders ---------------- */ private function article( $post ) { $a = array( '@type' => 'Article', 'headline' => get_the_title( $post ), 'datePublished' => get_post_time( 'c', true, $post ), 'dateModified' => get_post_modified_time( 'c', true, $post ), 'author' => array( '@type' => 'Person', 'name' => get_the_author_meta( 'display_name', $post->post_author ) ), 'mainEntityOfPage' => get_permalink( $post ), ); $img = get_the_post_thumbnail_url( $post, 'full' ); if ( $img ) { $a['image'] = $img; } return $a; } private function itemlist( $post ) { $items = $this->headings( $post ); if ( count( $items ) < 2 ) { $items = array( get_the_title( $post ) ); } $els = array(); $pos = 1; foreach ( $items as $it ) { $els[] = array( '@type' => 'ListItem', 'position' => $pos++, 'name' => $it ); if ( $pos > 20 ) { break; } } return array( '@type' => 'ItemList', 'name' => get_the_title( $post ), 'itemListElement' => $els ); } private function faq( $post ) { // Prefer AI-generated / editor-provided Q&A stored on the post. $stored = get_post_meta( $post->ID, '_aicitare_faq', true ); if ( $stored ) { $arr = json_decode( $stored, true ); if ( is_array( $arr ) ) { $qas = array(); foreach ( $arr as $qa ) { if ( ! empty( $qa['q'] ) && ! empty( $qa['a'] ) ) { $qas[] = array( '@type' => 'Question', 'name' => wp_strip_all_tags( $qa['q'] ), 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => wp_strip_all_tags( $qa['a'] ) ), ); } if ( count( $qas ) >= 12 ) { break; } } if ( $qas ) { return array( '@type' => 'FAQPage', 'mainEntity' => $qas ); } } } $content = do_shortcode( $post->post_content ); if ( ! preg_match_all( '/]*>(.*?)<\/h[23]>/is', $content, $m, PREG_OFFSET_CAPTURE ) ) { return null; } $heads = $m[0]; $inner = $m[1]; $count = count( $heads ); // Prefer headings phrased as questions; otherwise use all headings. $question_idx = array(); for ( $i = 0; $i < $count; $i++ ) { if ( false !== strpos( $inner[ $i ][0], '?' ) ) { $question_idx[] = $i; } } $use = count( $question_idx ) >= 2 ? $question_idx : range( 0, $count - 1 ); $qas = array(); foreach ( $use as $i ) { $q = trim( wp_strip_all_tags( $inner[ $i ][0] ) ); if ( '' === $q ) { continue; } $start = $heads[ $i ][1] + strlen( $heads[ $i ][0] ); $end = isset( $heads[ $i + 1 ] ) ? $heads[ $i + 1 ][1] : strlen( $content ); $ans = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( substr( $content, $start, $end - $start ) ) ) ); if ( '' === $ans ) { continue; } $qas[] = array( '@type' => 'Question', 'name' => $q, 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => wp_trim_words( $ans, 60, '' ) ), ); if ( count( $qas ) >= 8 ) { break; } } return $qas ? array( '@type' => 'FAQPage', 'mainEntity' => $qas ) : null; } private function howto( $post ) { $steps = array(); $pos = 1; foreach ( $this->headings( $post ) as $h ) { $steps[] = array( '@type' => 'HowToStep', 'position' => $pos++, 'name' => $h ); if ( $pos > 12 ) { break; } } return count( $steps ) >= 2 ? array( '@type' => 'HowTo', 'name' => get_the_title( $post ), 'step' => $steps ) : null; } private function product( $post ) { $p = array( '@type' => 'Product', 'name' => get_the_title( $post ), 'description' => wp_trim_words( wp_strip_all_tags( get_the_excerpt( $post ) ), 40, '' ), ); $img = get_the_post_thumbnail_url( $post, 'full' ); if ( $img ) { $p['image'] = $img; } // Only emit a rating if the editor provided real values — never fabricate. $rating = get_post_meta( $post->ID, '_aicitare_rating', true ); if ( $rating ) { $count = get_post_meta( $post->ID, '_aicitare_rating_count', true ); $p['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => (string) $rating, 'reviewCount' => (string) ( $count ? $count : 1 ), ); } // Only emit an offer if the editor provided a real price. $price = get_post_meta( $post->ID, '_aicitare_price', true ); if ( '' !== (string) $price ) { $p['offers'] = array( '@type' => 'Offer', 'price' => (string) $price, 'priceCurrency' => (string) ( get_post_meta( $post->ID, '_aicitare_currency', true ) ?: 'USD' ), 'availability' => 'https://schema.org/InStock', 'url' => get_permalink( $post ), ); } return $p; } private function person( $post ) { $aid = $post->post_author; $name = get_the_author_meta( 'display_name', $aid ); if ( ! $name ) { return null; } $person = array( '@type' => 'Person', 'name' => $name ); $bio = get_the_author_meta( 'description', $aid ); if ( $bio ) { $person['description'] = $bio; } $url = get_author_posts_url( $aid ); if ( $url ) { $person['url'] = $url; } return $person; } /** Site publisher/Organization node. */ private function organization() { $org = array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), ); $logo = get_site_icon_url(); if ( $logo ) { $org['logo'] = $logo; } return $org; } /** BreadcrumbList: Home › Post title. */ private function breadcrumb( $post ) { return array( '@type' => 'BreadcrumbList', 'itemListElement' => array( array( '@type' => 'ListItem', 'position' => 1, 'name' => get_bloginfo( 'name' ), 'item' => home_url( '/' ) ), array( '@type' => 'ListItem', 'position' => 2, 'name' => get_the_title( $post ), 'item' => get_permalink( $post ) ), ), ); } /** Speakable: mark the quick-answer / first heading region for voice assistants. */ private function speakable( $post ) { return array( '@type' => 'WebPage', '@id' => get_permalink( $post ), 'speakable' => array( '@type' => 'SpeakableSpecification', 'cssSelector' => array( '.geo-quick-answer', 'h1', 'h2' ), ), ); } /* ---------------- helpers ---------------- */ /** Extract H2/H3 heading texts from rendered content. */ private function headings( $post ) { $content = do_shortcode( $post->post_content ); preg_match_all( '/]*>(.*?)<\/h[23]>/is', $content, $m ); $out = array(); foreach ( $m[1] as $h ) { $t = trim( wp_strip_all_tags( $h ) ); if ( '' !== $t ) { $out[] = $t; } } return $out; } private function seo_plugin_active() { return defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' ); } private function find_page( $post_id ) { $rel = wp_make_link_relative( get_permalink( $post_id ) ); foreach ( AICitare_Data::get_pages() as $p ) { if ( $p['id'] === 'wp' . $post_id || untrailingslashit( $p['url'] ) === untrailingslashit( $rel ) ) { return $p; } } return null; } }