array( 'required' => array( 'name' ), 'one_of' => array( array( 'offers', 'review', 'aggregateRating' ) ), 'recommended' => array( 'image', 'description', 'brand' ), ), 'Review' => array( 'required' => array( 'itemReviewed', 'reviewRating', 'author' ), ), 'AggregateRating' => array( 'required' => array( 'ratingValue' ), 'one_of' => array( array( 'reviewCount', 'ratingCount' ) ), ), 'Offer' => array( 'required' => array( 'price', 'priceCurrency' ), ), 'Article' => array( 'required' => array( 'headline' ), 'recommended' => array( 'image', 'datePublished', 'author' ), ), 'FAQPage' => array( 'required' => array( 'mainEntity' ), ), 'Question' => array( 'required' => array( 'name', 'acceptedAnswer' ), ), 'HowTo' => array( 'required' => array( 'name', 'step' ), ), 'ItemList' => array( 'required' => array( 'itemListElement' ), ), 'Organization' => array( 'required' => array( 'name' ), 'recommended' => array( 'logo', 'url' ), ), 'Person' => array( 'required' => array( 'name' ), ), ); } /** Validate the schema AICitare would emit for a post (fast, no HTTP). */ public static function validate_post( $post ) { $schema = new AICitare_Schema(); $nodes = $schema->build_graph( $post ); return self::validate_nodes( $nodes ); } /** * Fetch a URL and validate ALL JSON-LD on the rendered page (every source — * AICitare, the SEO plugin, the theme). This mirrors what Google actually sees. * * @param string $url Permalink. * @return array { nodes|error } */ public static function validate_url( $url ) { $resp = wp_remote_get( $url, array( 'timeout' => 15, 'redirection' => 3, 'headers' => array( 'Accept' => 'text/html' ), 'user-agent' => 'AICitare-SchemaValidator/1.0', ) ); if ( is_wp_error( $resp ) ) { return array( 'error' => $resp->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code >= 400 ) { /* translators: %d: HTTP status code */ return array( 'error' => sprintf( __( 'The page returned HTTP %d.', 'aicitare' ), $code ) ); } return array( 'nodes' => self::extract_from_html( wp_remote_retrieve_body( $resp ) ) ); } /** Pull every application/ld+json block out of an HTML document and decode it. */ public static function extract_from_html( $html ) { $out = array(); if ( ! $html || ! is_string( $html ) ) { return $out; } if ( preg_match_all( '#]*type=["\']application/ld\+json["\'][^>]*>(.*?)#is', $html, $m ) ) { foreach ( $m[1] as $block ) { $block = trim( $block ); $decoded = json_decode( $block, true ); if ( null === $decoded ) { // Some plugins HTML-encode entities (e.g. &) inside the script — decode and retry. $decoded = json_decode( html_entity_decode( $block, ENT_QUOTES ), true ); } if ( is_array( $decoded ) ) { $out[] = $decoded; } } } return $out; } /** * Validate a list of JSON-LD nodes. Returns one result per typed node found * (recursively), each with its issues and an overall valid flag. * * @param array $nodes Node graph. * @return array */ public static function validate_nodes( $nodes ) { $flat = array(); self::collect( $nodes, $flat ); $rules = self::rules(); $out = array(); foreach ( $flat as $node ) { $type = self::type_of( $node ); if ( ! $type || ! isset( $rules[ $type ] ) ) { continue; } $r = $rules[ $type ]; $issues = array(); foreach ( ( isset( $r['required'] ) ? $r['required'] : array() ) as $prop ) { if ( self::missing( $node, $prop ) ) { $issues[] = array( 'level' => 'critical', 'prop' => $prop, /* translators: 1: property, 2: schema type */ 'msg' => sprintf( __( '"%1$s" is required for %2$s.', 'aicitare' ), $prop, $type ), ); } } foreach ( ( isset( $r['one_of'] ) ? $r['one_of'] : array() ) as $group ) { $has = false; foreach ( $group as $prop ) { if ( ! self::missing( $node, $prop ) ) { $has = true; break; } } if ( ! $has ) { $issues[] = array( 'level' => 'critical', 'prop' => implode( '/', $group ), /* translators: 1: list of properties, 2: schema type */ 'msg' => sprintf( __( 'Either %1$s should be specified for %2$s.', 'aicitare' ), self::humanize_list( $group ), $type ), ); } } foreach ( ( isset( $r['recommended'] ) ? $r['recommended'] : array() ) as $prop ) { if ( self::missing( $node, $prop ) ) { $issues[] = array( 'level' => 'warning', 'prop' => $prop, /* translators: 1: property, 2: schema type */ 'msg' => sprintf( __( '"%1$s" is recommended for %2$s.', 'aicitare' ), $prop, $type ), ); } } $criticals = 0; foreach ( $issues as $i ) { if ( 'critical' === $i['level'] ) { $criticals++; } } $out[] = array( 'type' => $type, 'name' => self::name_of( $node ), 'issues' => $issues, 'valid' => ( 0 === $criticals ), ); } return $out; } /** Recursively collect every array that looks like a typed JSON-LD node. */ private static function collect( $data, &$acc ) { if ( ! is_array( $data ) ) { return; } if ( isset( $data['@type'] ) ) { $acc[] = $data; } foreach ( $data as $v ) { if ( is_array( $v ) ) { self::collect( $v, $acc ); } } } private static function type_of( $node ) { if ( ! isset( $node['@type'] ) ) { return ''; } $t = $node['@type']; return is_array( $t ) ? (string) reset( $t ) : (string) $t; } private static function name_of( $node ) { foreach ( array( 'name', 'headline' ) as $k ) { if ( ! empty( $node[ $k ] ) && is_string( $node[ $k ] ) ) { return $node[ $k ]; } } return ''; } /** A property is "missing" when unset, null, empty string, or empty array. */ private static function missing( $node, $prop ) { if ( ! array_key_exists( $prop, $node ) ) { return true; } $v = $node[ $prop ]; if ( null === $v ) { return true; } if ( is_string( $v ) && '' === trim( $v ) ) { return true; } if ( is_array( $v ) && empty( $v ) ) { return true; } return false; } /** "'a', 'b' or 'c'" */ private static function humanize_list( $props ) { $q = array_map( function ( $p ) { return '"' . $p . '"'; }, $props ); if ( count( $q ) <= 1 ) { return implode( '', $q ); } $last = array_pop( $q ); return implode( ', ', $q ) . ' ' . __( 'or', 'aicitare' ) . ' ' . $last; } }