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;
}
}