settings = $settings; $this->meta = $meta; $this->local = $local; } /** * Builds the payload for resolved page data. * * @param array $data Resolver output. * @param array $trail Breadcrumb trail for the page. * @return array JSON-LD document with @context and @graph. */ public function build( array $data, array $trail ) { $home = home_url( '/' ); $site_id = $home . '#website'; $publisher = $this->publisher_node( $home ); $graph = array(); $website = array( '@type' => 'WebSite', '@id' => $site_id, 'url' => $home, 'name' => TemplateEngine::plain( get_bloginfo( 'name' ) ), 'inLanguage' => get_bloginfo( 'language' ), 'publisher' => array( '@id' => $publisher['@id'] ), 'potentialAction' => array( '@type' => 'SearchAction', 'target' => array( '@type' => 'EntryPoint', 'urlTemplate' => add_query_arg( 's', '{search_term_string}', $home ), ), 'query-input' => 'required name=search_term_string', ), ); $tagline = TemplateEngine::plain( get_bloginfo( 'description' ) ); if ( '' !== $tagline ) { $website['description'] = $tagline; } $graph[] = $website; $graph[] = $publisher; $url = $data['url']; if ( '404' === $data['object_type'] || '' === $url ) { return $this->document( $graph ); } $page_id = $url . '#webpage'; $page = array( '@type' => $this->page_type( $data ), '@id' => $page_id, 'url' => $url, 'name' => $data['title'], 'isPartOf' => array( '@id' => $site_id ), 'inLanguage' => get_bloginfo( 'language' ), ); if ( '' !== $data['description'] ) { $page['description'] = $data['description']; } if ( 'front' === $data['object_type'] ) { $page['about'] = array( '@id' => $publisher['@id'] ); } $post = 'post' === $data['object_type'] || ( 'front' === $data['object_type'] && $data['object_id'] ) ? get_post( $data['object_id'] ) : null; if ( $post ) { $page['datePublished'] = get_post_time( 'c', true, $post ); $page['dateModified'] = get_post_modified_time( 'c', true, $post ); } $image_id = ''; if ( '' !== $data['image'] ) { $image_id = $url . '#primaryimage'; $graph[] = $this->image_node( $image_id, $data['image'], $post ? (int) get_post_thumbnail_id( $post ) : 0 ); $page['primaryImageOfPage'] = array( '@id' => $image_id ); } if ( count( $trail ) > 1 ) { $page['breadcrumb'] = array( '@id' => $url . '#breadcrumb' ); } $graph[] = $page; if ( $post && 'post' === $data['object_type'] && in_array( $data['schema'], self::ARTICLE_TYPES, true ) ) { $graph[] = $this->article_node( $post, $data, $url, $page_id, $publisher['@id'], $image_id ); } // A static front page is a page too: its FAQ, video or product schema belongs on the homepage. // Password-protected content stays hidden: FAQ answers or video details would give it away. if ( $post && in_array( $data['object_type'], array( 'post', 'front' ), true ) && '' === $post->post_password ) { foreach ( $this->extra_nodes( $post, $data, $page_id, $publisher['@id'] ) as $node ) { $graph[] = $node; } } if ( 'front' === $data['object_type'] && $this->local ) { $business = $this->local->node( $publisher['@id'], (string) $this->settings->get( 'knowledge_logo' ) ); if ( $business ) { $graph[] = $business; } } if ( count( $trail ) > 1 ) { $graph[] = $this->breadcrumb_node( $url . '#breadcrumb', $trail ); } return $this->document( $graph ); } /** * Rich-result node chosen in the editor, WooCommerce product, and videos * found in the content. * * @param \WP_Post $post Post. * @param array $data Resolver output. * @param string $page_id WebPage @id. * @param string $publisher_id Publisher @id. * @return array */ private function extra_nodes( $post, array $data, $page_id, $publisher_id ) { $nodes = array(); $rich = $this->meta ? RichTypes::decode( $this->meta->get_post( $post->ID, 'rich_schema' ) ) : null; if ( $rich ) { $node = RichTypes::node( $rich['type'], $rich['data'], $post, $data, array( 'page' => $page_id, 'publisher' => $publisher_id, ) ); if ( $node ) { $nodes[] = $node; } } if ( ( ! $rich || 'Product' !== $rich['type'] ) && $this->settings->get( 'woo_schema' ) ) { $product = Woo::product_node( $post, $data, $page_id ); if ( $product ) { $nodes[] = $product; } } if ( ( ! $rich || 'VideoObject' !== $rich['type'] ) && $this->settings->get( 'video_schema' ) ) { foreach ( RichTypes::detected_videos( $post, $data ) as $video ) { $nodes[] = $video; } } return $nodes; } /** * JSON for a script tag. JSON_HEX_TAG turns "<" and ">" into escapes, so * values can never close the script element. * * @param array $document JSON-LD document. * @return string */ public static function to_json( array $document ) { return (string) wp_json_encode( $document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ); } /** * Wraps nodes in a document. * * @param array $graph Nodes. * @return array */ private function document( array $graph ) { $document = array( '@context' => 'https://schema.org', '@graph' => $graph, ); /** * Filters the JSON-LD document before output. * * @param array $document Document with @context and @graph. */ return apply_filters( 'orbitstem_seo_schema', $document ); } /** * The schema.org type for the page node. * * @param array $data Resolver output. * @return string */ private function page_type( array $data ) { switch ( $data['object_type'] ) { case 'search': return 'SearchResultsPage'; case 'author': return 'ProfilePage'; case 'term': case 'archive': case 'date': return 'CollectionPage'; case 'front': return 'CollectionPage' === $data['schema'] ? 'CollectionPage' : 'WebPage'; default: return 'WebPage'; } } /** * Organization or Person behind the site. * * @param string $home Home URL. * @return array */ private function publisher_node( $home ) { $name = (string) $this->settings->get( 'knowledge_name' ); $name = '' !== $name ? $name : TemplateEngine::plain( get_bloginfo( 'name' ) ); $logo = (string) $this->settings->get( 'knowledge_logo' ); $same = (array) $this->settings->get( 'knowledge_same_as' ); $person = 'person' === $this->settings->get( 'knowledge_type' ); $node = array( '@type' => $person ? 'Person' : 'Organization', '@id' => $home . ( $person ? '#person' : '#organization' ), 'name' => $name, 'url' => $home, ); if ( '' !== $logo ) { $image = array( '@type' => 'ImageObject', '@id' => $home . '#logo', 'url' => $logo, 'contentUrl' => $logo, ); if ( $person ) { $node['image'] = $image; } else { $node['logo'] = $image; $node['image'] = array( '@id' => $home . '#logo' ); } } if ( $same ) { $node['sameAs'] = array_values( $same ); } return $node; } /** * ImageObject node, with dimensions when the image is a local attachment. * * @param string $id Node @id. * @param string $url Image URL. * @param int $attachment_id Attachment ID or 0. * @return array */ private function image_node( $id, $url, $attachment_id ) { $node = array( '@type' => 'ImageObject', '@id' => $id, 'url' => $url, 'contentUrl' => $url, ); // Size and caption describe the featured image, so they are only added when that is the image used // (a separate social image has its own size). $size = ''; if ( $attachment_id ) { foreach ( array_merge( array( 'full', 'large' ), get_intermediate_image_sizes() ) as $candidate ) { $src = wp_get_attachment_image_src( $attachment_id, $candidate ); if ( $src && $src[0] === $url ) { $size = $candidate; break; } } } if ( '' !== $size ) { $node['width'] = (int) $src[1]; $node['height'] = (int) $src[2]; $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( is_string( $alt ) && '' !== $alt ) { $node['caption'] = TemplateEngine::plain( $alt ); } } return $node; } /** * Article-family node. * * @param \WP_Post $post Post. * @param array $data Resolver output. * @param string $url Page URL. * @param string $page_id WebPage @id. * @param string $publisher_id Publisher @id. * @param string $image_id Image @id or ''. * @return array */ private function article_node( $post, array $data, $url, $page_id, $publisher_id, $image_id ) { $headline = TemplateEngine::plain( $post->post_title ); if ( mb_strlen( $headline ) > 110 ) { $headline = mb_substr( $headline, 0, 109 ) . "\u{2026}"; } $node = array( '@type' => $data['schema'], '@id' => $url . '#article', 'isPartOf' => array( '@id' => $page_id ), 'mainEntityOfPage' => array( '@id' => $page_id ), 'headline' => $headline, 'datePublished' => get_post_time( 'c', true, $post ), 'dateModified' => get_post_modified_time( 'c', true, $post ), 'author' => array( '@type' => 'Person', 'name' => TemplateEngine::plain( get_the_author_meta( 'display_name', (int) $post->post_author ) ), 'url' => get_author_posts_url( (int) $post->post_author ), ), 'publisher' => array( '@id' => $publisher_id ), 'inLanguage' => get_bloginfo( 'language' ), ); if ( '' !== $image_id ) { $node['image'] = array( '@id' => $image_id ); } if ( '' !== $data['description'] ) { $node['description'] = $data['description']; } return $node; } /** * BreadcrumbList node. The last item omits "item", as it is the page itself. * * @param string $id Node @id. * @param array $trail Trail. * @return array */ private function breadcrumb_node( $id, array $trail ) { $items = array(); $last = count( $trail ) - 1; foreach ( array_values( $trail ) as $i => $crumb ) { $item = array( '@type' => 'ListItem', 'position' => $i + 1, 'name' => $crumb['name'], ); if ( $i !== $last && '' !== $crumb['url'] ) { $item['item'] = $crumb['url']; } $items[] = $item; } return array( '@type' => 'BreadcrumbList', '@id' => $id, 'itemListElement' => $items, ); } }