errors = array(); $this->warnings = array(); if ( ! is_array( $schema ) || empty( $schema ) ) { $this->errors[] = __( 'Schema data is empty or invalid.', 'ai-schema-gen' ); return $this->build_result(); } // Generic checks. $this->validate_generic( $schema ); // Type-specific validation. if ( isset( $schema['@type'] ) ) { $this->validate_type_specific( $schema ); } return $this->build_result(); } /** * Perform generic schema validation. * * @param array $schema The schema array. */ private function validate_generic( $schema ) { // @type is always required. if ( ! isset( $schema['@type'] ) || empty( $schema['@type'] ) ) { $this->errors[] = __( 'Missing required field: @type', 'ai-schema-gen' ); } // @context is recommended at the top level. if ( ! isset( $schema['@context'] ) ) { $this->warnings[] = __( 'Missing @context field. Should be "https://schema.org".', 'ai-schema-gen' ); } elseif ( 'https://schema.org' !== $schema['@context'] && 'http://schema.org' !== $schema['@context'] ) { $this->warnings[] = __( '@context should be "https://schema.org".', 'ai-schema-gen' ); } // Check for empty string values in required-looking fields. $important_fields = array( 'name', 'headline', 'description', 'url' ); foreach ( $important_fields as $field ) { if ( isset( $schema[ $field ] ) && '' === $schema[ $field ] ) { $this->warnings[] = sprintf( /* translators: %s: field name */ __( 'Field "%s" is present but empty.', 'ai-schema-gen' ), $field ); } } } /** * Perform type-specific validation. * * @param array $schema The schema array. */ private function validate_type_specific( $schema ) { $type = $schema['@type']; switch ( $type ) { case 'Article': case 'BlogPosting': case 'NewsArticle': $this->validate_article( $schema ); break; case 'Product': $this->validate_product( $schema ); break; case 'LocalBusiness': $this->validate_local_business( $schema ); break; case 'FAQPage': $this->validate_faq( $schema ); break; case 'Event': $this->validate_event( $schema ); break; case 'Recipe': $this->validate_recipe( $schema ); break; case 'HowTo': $this->validate_howto( $schema ); break; case 'Review': $this->validate_review( $schema ); break; case 'VideoObject': $this->validate_video( $schema ); break; case 'Course': $this->validate_course( $schema ); break; case 'Person': $this->validate_person( $schema ); break; case 'Organization': $this->validate_organization( $schema ); break; } } /** * Validate Article / BlogPosting / NewsArticle. * * Google requires: headline, image, author, datePublished, publisher. * * @param array $schema The schema array. */ private function validate_article( $schema ) { if ( ! isset( $schema['headline'] ) || empty( $schema['headline'] ) ) { $this->errors[] = __( 'Missing required field: headline', 'ai-schema-gen' ); } elseif ( strlen( $schema['headline'] ) > 110 ) { $this->warnings[] = __( 'Headline exceeds 110 characters. Google may truncate it.', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) || empty( $schema['image'] ) ) { $this->warnings[] = __( 'Recommended field missing: image', 'ai-schema-gen' ); } if ( ! isset( $schema['author'] ) || empty( $schema['author'] ) ) { $this->warnings[] = __( 'Recommended field missing: author', 'ai-schema-gen' ); } else { $this->validate_nested_type( $schema['author'], 'Person', 'author' ); } if ( ! isset( $schema['datePublished'] ) ) { $this->warnings[] = __( 'Recommended field missing: datePublished', 'ai-schema-gen' ); } if ( ! isset( $schema['publisher'] ) ) { $this->warnings[] = __( 'Recommended field missing: publisher', 'ai-schema-gen' ); } } /** * Validate Product. * * Google requires: name, image. Recommends: offers. * * @param array $schema The schema array. */ private function validate_product( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) || empty( $schema['image'] ) ) { $this->errors[] = __( 'Missing required field: image', 'ai-schema-gen' ); } if ( ! isset( $schema['offers'] ) || empty( $schema['offers'] ) ) { $this->warnings[] = __( 'Recommended field missing: offers', 'ai-schema-gen' ); } else { // Validate offer has price and currency. $offers = $schema['offers']; if ( isset( $offers['@type'] ) && 'Offer' === $offers['@type'] ) { if ( ! isset( $offers['price'] ) ) { $this->warnings[] = __( 'Offer is missing price.', 'ai-schema-gen' ); } if ( ! isset( $offers['priceCurrency'] ) ) { $this->warnings[] = __( 'Offer is missing priceCurrency.', 'ai-schema-gen' ); } } } } /** * Validate LocalBusiness. * * Google requires: name, address. * * @param array $schema The schema array. */ private function validate_local_business( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['address'] ) || empty( $schema['address'] ) ) { $this->errors[] = __( 'Missing required field: address', 'ai-schema-gen' ); } if ( ! isset( $schema['telephone'] ) ) { $this->warnings[] = __( 'Recommended field missing: telephone', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) ) { $this->warnings[] = __( 'Recommended field missing: image', 'ai-schema-gen' ); } } /** * Validate FAQPage. * * Google requires: mainEntity with Question/Answer pairs. * * @param array $schema The schema array. */ private function validate_faq( $schema ) { if ( ! isset( $schema['mainEntity'] ) || empty( $schema['mainEntity'] ) ) { $this->errors[] = __( 'Missing required field: mainEntity (Questions)', 'ai-schema-gen' ); } elseif ( is_array( $schema['mainEntity'] ) ) { foreach ( $schema['mainEntity'] as $index => $question ) { if ( ! isset( $question['@type'] ) || 'Question' !== $question['@type'] ) { $this->errors[] = sprintf( /* translators: %d: question index number */ __( 'mainEntity item %d must have @type "Question".', 'ai-schema-gen' ), $index + 1 ); } if ( ! isset( $question['name'] ) || empty( $question['name'] ) ) { $this->errors[] = sprintf( /* translators: %d: question index number */ __( 'Question %d is missing the "name" (question text).', 'ai-schema-gen' ), $index + 1 ); } if ( ! isset( $question['acceptedAnswer'] ) || empty( $question['acceptedAnswer'] ) ) { $this->errors[] = sprintf( /* translators: %d: question index number */ __( 'Question %d is missing "acceptedAnswer".', 'ai-schema-gen' ), $index + 1 ); } } } } /** * Validate Event. * * Google requires: startDate, location. * * @param array $schema The schema array. */ private function validate_event( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['startDate'] ) || empty( $schema['startDate'] ) ) { $this->errors[] = __( 'Missing required field: startDate', 'ai-schema-gen' ); } if ( ! isset( $schema['location'] ) || empty( $schema['location'] ) ) { $this->errors[] = __( 'Missing required field: location', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) ) { $this->warnings[] = __( 'Recommended field missing: image', 'ai-schema-gen' ); } if ( ! isset( $schema['description'] ) ) { $this->warnings[] = __( 'Recommended field missing: description', 'ai-schema-gen' ); } if ( ! isset( $schema['endDate'] ) ) { $this->warnings[] = __( 'Recommended field missing: endDate', 'ai-schema-gen' ); } if ( ! isset( $schema['offers'] ) ) { $this->warnings[] = __( 'Recommended field missing: offers', 'ai-schema-gen' ); } } /** * Validate Recipe. * * Google requires: recipeIngredient, recipeInstructions. * * @param array $schema The schema array. */ private function validate_recipe( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['recipeIngredient'] ) || empty( $schema['recipeIngredient'] ) ) { $this->errors[] = __( 'Missing required field: recipeIngredient', 'ai-schema-gen' ); } if ( ! isset( $schema['recipeInstructions'] ) || empty( $schema['recipeInstructions'] ) ) { $this->errors[] = __( 'Missing required field: recipeInstructions', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) ) { $this->warnings[] = __( 'Recommended field missing: image', 'ai-schema-gen' ); } if ( ! isset( $schema['author'] ) ) { $this->warnings[] = __( 'Recommended field missing: author', 'ai-schema-gen' ); } if ( ! isset( $schema['totalTime'] ) && ! isset( $schema['cookTime'] ) ) { $this->warnings[] = __( 'Recommended field missing: totalTime or cookTime', 'ai-schema-gen' ); } } /** * Validate HowTo. * * Google requires: step. * * @param array $schema The schema array. */ private function validate_howto( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['step'] ) || empty( $schema['step'] ) ) { $this->errors[] = __( 'Missing required field: step', 'ai-schema-gen' ); } elseif ( is_array( $schema['step'] ) ) { foreach ( $schema['step'] as $index => $step ) { if ( is_array( $step ) && ! isset( $step['text'] ) && ! isset( $step['name'] ) ) { $this->warnings[] = sprintf( /* translators: %d: step index number */ __( 'Step %d should have a "text" or "name" field.', 'ai-schema-gen' ), $index + 1 ); } } } if ( ! isset( $schema['description'] ) ) { $this->warnings[] = __( 'Recommended field missing: description', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) ) { $this->warnings[] = __( 'Recommended field missing: image', 'ai-schema-gen' ); } } /** * Validate Review. * * Google requires: reviewRating, itemReviewed. * * @param array $schema The schema array. */ private function validate_review( $schema ) { if ( ! isset( $schema['reviewRating'] ) || empty( $schema['reviewRating'] ) ) { $this->errors[] = __( 'Missing required field: reviewRating', 'ai-schema-gen' ); } else { $rating = $schema['reviewRating']; if ( is_array( $rating ) && ! isset( $rating['ratingValue'] ) ) { $this->errors[] = __( 'reviewRating is missing ratingValue.', 'ai-schema-gen' ); } } if ( ! isset( $schema['itemReviewed'] ) || empty( $schema['itemReviewed'] ) ) { $this->errors[] = __( 'Missing required field: itemReviewed', 'ai-schema-gen' ); } if ( ! isset( $schema['author'] ) ) { $this->warnings[] = __( 'Recommended field missing: author', 'ai-schema-gen' ); } if ( ! isset( $schema['datePublished'] ) ) { $this->warnings[] = __( 'Recommended field missing: datePublished', 'ai-schema-gen' ); } if ( ! isset( $schema['reviewBody'] ) ) { $this->warnings[] = __( 'Recommended field missing: reviewBody', 'ai-schema-gen' ); } } /** * Validate VideoObject. * * Google requires: uploadDate, contentUrl or embedUrl. * * @param array $schema The schema array. */ private function validate_video( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['uploadDate'] ) || empty( $schema['uploadDate'] ) ) { $this->errors[] = __( 'Missing required field: uploadDate', 'ai-schema-gen' ); } if ( ( ! isset( $schema['contentUrl'] ) || empty( $schema['contentUrl'] ) ) && ( ! isset( $schema['embedUrl'] ) || empty( $schema['embedUrl'] ) ) ) { $this->errors[] = __( 'Missing required field: contentUrl or embedUrl', 'ai-schema-gen' ); } if ( ! isset( $schema['description'] ) ) { $this->warnings[] = __( 'Recommended field missing: description', 'ai-schema-gen' ); } if ( ! isset( $schema['thumbnailUrl'] ) ) { $this->warnings[] = __( 'Recommended field missing: thumbnailUrl', 'ai-schema-gen' ); } if ( ! isset( $schema['duration'] ) ) { $this->warnings[] = __( 'Recommended field missing: duration', 'ai-schema-gen' ); } } /** * Validate Course. * * Google requires: provider. * * @param array $schema The schema array. */ private function validate_course( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['provider'] ) || empty( $schema['provider'] ) ) { $this->errors[] = __( 'Missing required field: provider', 'ai-schema-gen' ); } if ( ! isset( $schema['description'] ) ) { $this->warnings[] = __( 'Recommended field missing: description', 'ai-schema-gen' ); } if ( ! isset( $schema['offers'] ) ) { $this->warnings[] = __( 'Recommended field missing: offers', 'ai-schema-gen' ); } } /** * Validate Person. * * Required: name. * * @param array $schema The schema array. */ private function validate_person( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['url'] ) ) { $this->warnings[] = __( 'Recommended field missing: url', 'ai-schema-gen' ); } if ( ! isset( $schema['image'] ) ) { $this->warnings[] = __( 'Recommended field missing: image', 'ai-schema-gen' ); } } /** * Validate Organization. * * Required: name, url. * * @param array $schema The schema array. */ private function validate_organization( $schema ) { if ( ! isset( $schema['name'] ) || empty( $schema['name'] ) ) { $this->errors[] = __( 'Missing required field: name', 'ai-schema-gen' ); } if ( ! isset( $schema['url'] ) || empty( $schema['url'] ) ) { $this->errors[] = __( 'Missing required field: url', 'ai-schema-gen' ); } if ( ! isset( $schema['logo'] ) ) { $this->warnings[] = __( 'Recommended field missing: logo', 'ai-schema-gen' ); } if ( ! isset( $schema['contactPoint'] ) ) { $this->warnings[] = __( 'Recommended field missing: contactPoint', 'ai-schema-gen' ); } } /** * Validate that a nested object has the expected @type. * * @param mixed $value The value to check. * @param string $expected_type The expected schema.org @type. * @param string $field_name The parent field name (for error messages). */ private function validate_nested_type( $value, $expected_type, $field_name ) { if ( is_array( $value ) && isset( $value['@type'] ) ) { // Valid nested type. if ( ! isset( $value['name'] ) ) { $this->warnings[] = sprintf( /* translators: %s: field name */ __( 'The "%s" object is missing a "name" field.', 'ai-schema-gen' ), $field_name ); } } } /** * Build the validation result array. * * @return array Validation result with valid, errors, warnings, and score. */ private function build_result() { return array( 'valid' => empty( $this->errors ), 'errors' => $this->errors, 'warnings' => $this->warnings, 'score' => $this->calculate_score(), ); } /** * Calculate a quality score from 0-100. * * Deducts 20 points per error and 5 points per warning from a base of 100. * * @return int The quality score clamped to 0-100. */ private function calculate_score() { $score = 100; $score -= count( $this->errors ) * 20; $score -= count( $this->warnings ) * 5; return max( 0, min( 100, $score ) ); } /** * Validate @id cross-references across a @graph entity set. * * Collects every defined @id in the entity set plus known site-wide * entities, then checks every {"@id":"..."} reference stub in the graph * resolves to a defined entity. * * @param array $entities Array of schema entity objects (the @graph contents). * @return array { valid: bool, errors: string[] } */ public function validate_graph( $entities ) { if ( ! is_array( $entities ) || empty( $entities ) ) { return array( 'valid' => true, 'errors' => array() ); } // Collect all @id values that belong to full entity definitions. $defined_ids = array(); foreach ( $entities as $entity ) { if ( is_array( $entity ) && ! empty( $entity['@id'] ) ) { $defined_ids[] = $entity['@id']; } } // Site-wide entities defined on their respective pages — always resolvable. // Call the ID builder directly rather than reimplementing the URL // format here: this used to build its own rtrim(home_url('/'), '/') // string, which drifted from Schema_AI_ID_Builder's actual // trailingslashit(home_url('/')) convention (no trailing slash before // '#' here vs. one there) — the two never matched, so this "always // resolvable" list never actually recognized a real generated ID and // every non-homepage page's WebPage entity was silently marked // 'warning' for a dangling reference that wasn't real. Confirmed live // via a local WordPress install while testing today's WebPage/WebSite // work: generating the homepage's Organization/WebSite first did not // clear the warning on another page, because this list was comparing // against the wrong string the whole time. $defined_ids[] = Schema_AI_ID_Builder::local_business(); $defined_ids[] = Schema_AI_ID_Builder::organization(); $defined_ids[] = Schema_AI_ID_Builder::website(); // Scan every entity for {"@id":"..."} stubs that don't resolve. $dangling = array(); foreach ( $entities as $entity ) { if ( is_array( $entity ) ) { $this->collect_dangling_refs( $entity, $defined_ids, $dangling ); } } $errors = array(); foreach ( array_unique( $dangling ) as $ref ) { $errors[] = sprintf( 'Dangling @id reference "%s" — entity not defined in @graph.', $ref ); } return array( 'valid' => empty( $errors ), 'errors' => $errors, ); } /** * Recursively collect @id stubs that have no matching definition. * * A reference stub is an array whose only key is "@id". * A full entity has "@id" alongside "@type" and other fields. * * @param array $node The node to scan. * @param array $defined_ids All known @id definitions. * @param array &$dangling Accumulates unresolved reference strings. */ private function collect_dangling_refs( $node, $defined_ids, &$dangling ) { if ( ! is_array( $node ) ) { return; } $keys = array_keys( $node ); // Pure stub: {"@id": "..."} with no other keys. if ( array( '@id' ) === $keys ) { $ref = $node['@id']; if ( ! empty( $ref ) && ! in_array( $ref, $defined_ids, true ) ) { $dangling[] = $ref; } return; } // Full entity or mixed value — recurse into children, skip own "@id". foreach ( $node as $key => $value ) { if ( '@id' === $key ) { continue; } if ( is_array( $value ) ) { $this->collect_dangling_refs( $value, $defined_ids, $dangling ); } } } }