*/ public static function required() { return array( 'WebSite' => array( 'url', 'name' ), 'Organization' => array( 'name', 'url' ), 'Person' => array( 'name' ), 'WebPage' => array( 'url', 'name', 'isPartOf' ), 'CollectionPage' => array( 'url', 'name', 'isPartOf' ), 'SearchResultsPage' => array( 'url', 'isPartOf' ), 'ProfilePage' => array( 'url', 'isPartOf' ), 'ImageObject' => array( 'url' ), 'Article' => array( 'headline', 'datePublished', 'author', 'publisher' ), 'BlogPosting' => array( 'headline', 'datePublished', 'author', 'publisher' ), 'NewsArticle' => array( 'headline', 'datePublished', 'author', 'publisher' ), 'BreadcrumbList' => array( 'itemListElement' ), 'FAQPage' => array( 'mainEntity' ), 'HowTo' => array( 'name', 'step' ), 'Product' => array( 'name' ), 'Recipe' => array( 'name', 'recipeIngredient', 'recipeInstructions' ), 'Event' => array( 'name', 'startDate', 'location' ), 'Course' => array( 'name', 'description', 'provider' ), 'JobPosting' => array( 'title', 'description', 'datePosted', 'hiringOrganization', 'jobLocation' ), 'Review' => array( 'itemReviewed', 'reviewRating', 'author' ), 'VideoObject' => array( 'name', 'thumbnailUrl', 'uploadDate' ), ); } /** * Validates a decoded document. * * @param mixed $document Decoded JSON-LD. * @return string[] Problems; empty when valid. */ public static function validate( $document ) { $errors = array(); if ( ! is_array( $document ) || ! isset( $document['@context'], $document['@graph'] ) || ! is_array( $document['@graph'] ) ) { return array( 'Document needs @context and an @graph array.' ); } if ( 'https://schema.org' !== $document['@context'] ) { $errors[] = '@context must be https://schema.org.'; } $ids = array(); foreach ( $document['@graph'] as $i => $node ) { if ( empty( $node['@type'] ) ) { $errors[] = "Node {$i} has no @type."; continue; } if ( isset( $node['@id'] ) && isset( $ids[ $node['@id'] ] ) ) { $errors[] = "Duplicate @id {$node['@id']}."; } self::collect_ids( $node, $ids ); } $required = self::required(); foreach ( $document['@graph'] as $node ) { if ( empty( $node['@type'] ) ) { continue; } $type = $node['@type']; foreach ( isset( $required[ $type ] ) ? $required[ $type ] : array() as $prop ) { if ( ! isset( $node[ $prop ] ) || '' === $node[ $prop ] || array() === $node[ $prop ] ) { $errors[] = "{$type} is missing {$prop}."; } } foreach ( array( 'url', 'contentUrl' ) as $prop ) { if ( isset( $node[ $prop ] ) && ! preg_match( '#^https?://#', $node[ $prop ] ) ) { $errors[] = "{$type}.{$prop} must be an absolute URL."; } } if ( in_array( $type, Graph::ARTICLE_TYPES, true ) ) { if ( isset( $node['headline'] ) && mb_strlen( $node['headline'] ) > 110 ) { $errors[] = "{$type}.headline is longer than 110 characters."; } if ( isset( $node['author'] ) && empty( $node['author']['name'] ) ) { $errors[] = "{$type}.author has no name."; } foreach ( array( 'datePublished', 'dateModified' ) as $prop ) { if ( isset( $node[ $prop ] ) && false === strtotime( $node[ $prop ] ) ) { $errors[] = "{$type}.{$prop} is not a date."; } } } if ( 'BreadcrumbList' === $type && isset( $node['itemListElement'] ) ) { $errors = array_merge( $errors, self::validate_breadcrumbs( $node['itemListElement'] ) ); } $errors = array_merge( $errors, self::unresolved_refs( $node, $ids, $type ) ); } return array_values( array_unique( $errors ) ); } /** * Breadcrumb positions run 1..n and every item but the last links somewhere. * * @param array $items ListItems. * @return string[] */ private static function validate_breadcrumbs( array $items ) { $errors = array(); $last = count( $items ) - 1; foreach ( array_values( $items ) as $i => $item ) { if ( ! isset( $item['position'] ) || $item['position'] !== $i + 1 ) { $errors[] = 'BreadcrumbList positions must run 1, 2, 3...'; } if ( empty( $item['name'] ) ) { $errors[] = 'BreadcrumbList item ' . ( $i + 1 ) . ' has no name.'; } if ( $i !== $last && empty( $item['item'] ) ) { $errors[] = 'BreadcrumbList item ' . ( $i + 1 ) . ' has no URL.'; } } return $errors; } /** * Records @ids of a node and of nested definitions (objects with @type and @id). * * @param array $node Node. * @param array $ids Known @ids, by reference. */ private static function collect_ids( array $node, array &$ids ) { if ( isset( $node['@id'], $node['@type'] ) ) { $ids[ $node['@id'] ] = true; } foreach ( $node as $value ) { if ( is_array( $value ) ) { self::collect_ids( $value, $ids ); } } } /** * References like {"@id": "..."} that point at no node in the graph. * * @param array $node Node. * @param array $ids Known @ids. * @param string $type Node type for messages. * @return string[] */ private static function unresolved_refs( array $node, array $ids, $type ) { $errors = array(); foreach ( $node as $key => $value ) { if ( '@id' === $key || ! is_array( $value ) ) { continue; } if ( 1 === count( $value ) && isset( $value['@id'] ) && ! isset( $ids[ $value['@id'] ] ) ) { $errors[] = "{$type}.{$key} points at missing node {$value['@id']}."; } } return $errors; } }