*/ public const ARTICLE_TYPES = [ 'Article', 'BlogPosting', 'NewsArticle', 'TechArticle' ]; /** * Page slugs that make a page an `AboutPage`. * * @var array */ private const ABOUT_SLUGS = [ 'about', 'despre', 'about-us' ]; /** * Page slugs that make a page a `ContactPage`. * * @var array */ private const CONTACT_SLUGS = [ 'contact' ]; /** * How many characters of the summary the page description keeps. * * @var int */ private const DESCRIPTION_LENGTH = 160; /** * What the current query says about the page being rendered. * * @return array Page facts, as described in the graph builder's docblock. */ public static function current(): array { $page = [ 'url' => SiteData::current_url(), 'title' => '', 'description' => '', 'kind' => 'other', 'webpage_type' => 'WebPage', 'article_type' => '', 'date_published' => '', 'date_modified' => '', 'language' => (string) ( SiteData::identity()['language'] ?? '' ), 'image' => null, 'author' => null, 'breadcrumbs' => [], 'faq' => [], 'summary' => '', 'word_count' => 0, 'keywords' => [], 'section' => '', 'has_summary_block' => false, ]; if ( is_front_page() ) { return array_replace( $page, self::front_page_context() ); } if ( is_singular() ) { return array_replace( $page, self::singular_context() ); } if ( is_search() ) { return array_replace( $page, self::search_context() ); } if ( is_author() || is_archive() || is_home() ) { return array_replace( $page, self::archive_context() ); } return $page; } /** * The front page, whether it is a static page or the blog index. * * A static front page keeps everything the page itself says, but is not an * article: a site's front door is the site, not one of its posts. * * @return array Page facts. */ private static function front_page_context(): array { $context = is_singular() ? self::singular_context() : [ 'title' => (string) get_bloginfo( 'name' ), 'description' => (string) get_bloginfo( 'description' ), ]; return array_replace( $context, [ 'kind' => 'home', 'url' => home_url( '/' ), 'webpage_type' => 'WebPage', 'breadcrumbs' => [], ] ); } /** * One post, page or custom post type entry. * * @return array Page facts. */ private static function singular_context(): array { $post = get_post(); if ( ! $post instanceof WP_Post ) { return []; } $post_id = (int) $post->ID; $summary = ContentData::summary_for( $post_id ); return [ 'url' => (string) get_permalink( $post ), 'title' => ContentData::plain_text( get_the_title( $post ) ), 'description' => ContentData::shorten( $summary, self::DESCRIPTION_LENGTH ), 'kind' => 'singular', 'webpage_type' => self::webpage_type( $post ), 'article_type' => self::article_type( $post ), 'date_published' => (string) get_the_date( 'c', $post ), 'date_modified' => (string) get_the_modified_date( 'c', $post ), 'image' => self::image_context( $post_id ), 'author' => self::author_context( (int) $post->post_author ), 'breadcrumbs' => self::breadcrumbs( $post ), 'faq' => ContentData::faq_for( $post_id ), 'summary' => $summary, 'word_count' => self::word_count( (string) wp_strip_all_tags( (string) $post->post_content ) ), 'keywords' => self::keywords( $post ), 'section' => self::section( $post ), 'has_summary_block' => has_block( ContentData::SUMMARY_BLOCK, $post ), ]; } /** * A term, author, date or post type archive. * * @return array Page facts. */ private static function archive_context(): array { $queried = get_queried_object(); $context = array_merge( [ 'kind' => 'archive', 'webpage_type' => 'CollectionPage', ], self::archive_facts( $queried ) ); if ( is_author() && $queried instanceof WP_User ) { $context['kind'] = 'author'; $context['webpage_type'] = 'ProfilePage'; $context['author'] = self::author_context( (int) $queried->ID ); } $context['breadcrumbs'] = self::archive_breadcrumbs( $context['title'] ); return $context; } /** * A search results page. * * @return array Page facts. */ private static function search_context(): array { return [ 'kind' => 'search', 'webpage_type' => 'SearchResultsPage', /* translators: %s: search query. */ 'title' => sprintf( __( 'Search results for ā€œ%sā€', 'acid-aeo' ), ContentData::plain_text( get_search_query() ) ), ]; } /** * What an archive is called and what it says about itself. * * `get_the_archive_title()` prefixes the name with its kind ("Category:") * and wraps it in markup, which is right for a heading and wrong for a * `name` property, so whatever the query resolved to is asked directly and * the core functions are only the fallback. * * @param mixed $queried Queried object. * @return array Keys `title` and `description`. */ private static function archive_facts( $queried ): array { if ( $queried instanceof WP_Term ) { return self::facts( $queried->name, ContentData::plain_text( $queried->description ) ); } if ( $queried instanceof WP_User ) { return self::facts( $queried->display_name, ContentData::plain_text( get_the_author_meta( 'description', (int) $queried->ID ) ) ); } if ( $queried instanceof WP_Post_Type ) { return self::facts( $queried->labels->name ?? $queried->name, ContentData::plain_text( $queried->description ) ); } if ( $queried instanceof WP_Post ) { return self::facts( get_the_title( $queried ), ContentData::summary_for( (int) $queried->ID ) ); } return self::facts( get_the_archive_title(), ContentData::plain_text( get_the_archive_description() ) ); } /** * One name and description pair, normalized. * * @param mixed $title Raw title. * @param string $description Plain text description. * @return array Keys `title` and `description`. */ private static function facts( $title, string $description ): array { return [ 'title' => ContentData::plain_text( $title ), 'description' => ContentData::shorten( $description, self::DESCRIPTION_LENGTH ), ]; } /** * Which flavour of `WebPage` a singular entry is. * * Only pages carry a flavour: an "about" post is a post about something, * while an "about" page is the site's own about page. * * @param WP_Post $post Current post. * @return string Schema.org type. */ private static function webpage_type( WP_Post $post ): string { if ( 'page' !== $post->post_type ) { return 'WebPage'; } $slug = (string) $post->post_name; if ( in_array( $slug, self::ABOUT_SLUGS, true ) ) { return 'AboutPage'; } return in_array( $slug, self::CONTACT_SLUGS, true ) ? 'ContactPage' : 'WebPage'; } /** * Which kind of article a singular entry is, if any. * * The per-post override wins over the post type mapping, and `none` is an * answer: this post is a page, not an article. * * @param WP_Post $post Current post. * @return string Article type, or an empty string for no article node. */ private static function article_type( WP_Post $post ): string { $override = self::meta_string( (int) $post->ID, self::TYPE_META ); if ( 'none' === $override ) { return ''; } if ( in_array( $override, self::ARTICLE_TYPES, true ) ) { return $override; } $map = Options::get( 'schema.post_type_map', [] ); $map = is_array( $map ) ? $map : []; return trim( (string) ( $map[ $post->post_type ] ?? '' ) ); } /** * The post's featured image, with whatever the media library licenced it under. * * @param int $post_id Post ID. * @return array|null Image facts, or null when there is no featured image. */ private static function image_context( int $post_id ): ?array { $attachment_id = (int) get_post_thumbnail_id( $post_id ); if ( $attachment_id <= 0 ) { return null; } $source = wp_get_attachment_image_src( $attachment_id, 'full' ); if ( ! is_array( $source ) || empty( $source[0] ) ) { return null; } return [ 'url' => (string) $source[0], 'width' => (int) ( $source[1] ?? 0 ), 'height' => (int) ( $source[2] ?? 0 ), 'license' => self::meta_string( $attachment_id, self::LICENSE_META ), 'credit' => self::meta_string( $attachment_id, self::CREDIT_META ), 'creator' => self::meta_string( $attachment_id, self::CREATOR_META ), 'caption' => ContentData::plain_text( wp_get_attachment_caption( $attachment_id ) ), ]; } /** * One author, with the E-E-A-T fields their profile carries. * * @param int $user_id User ID. * @return array|null Author facts, or null when the user is unknown. */ private static function author_context( int $user_id ): ?array { if ( $user_id <= 0 || ! get_userdata( $user_id ) ) { return null; } return [ 'id' => $user_id, 'name' => ContentData::plain_text( get_the_author_meta( 'display_name', $user_id ) ), 'url' => (string) get_author_posts_url( $user_id ), 'image' => (string) get_avatar_url( $user_id, [ 'size' => 256 ] ), 'description' => ContentData::plain_text( get_the_author_meta( 'description', $user_id ) ), 'job_title' => self::user_meta_string( $user_id, UserFields::JOB_TITLE ), 'same_as' => self::split( self::user_meta_string( $user_id, UserFields::SAME_AS ), "\n" ), 'knows_about' => self::split( self::user_meta_string( $user_id, UserFields::KNOWS_ABOUT ), ',' ), 'alumni_of' => self::user_meta_string( $user_id, UserFields::ALUMNI_OF ), ]; } /** * The trail from the home page down to a singular entry. * * The last crumb is the page itself and carries no URL: a breadcrumb that * links to the page you are already on is noise. * * @param WP_Post $post Current post. * @return array> Crumbs of `name` and `url`. */ private static function breadcrumbs( WP_Post $post ): array { $crumbs = [ [ 'name' => __( 'Home', 'acid-aeo' ), 'url' => home_url( '/' ), ], ]; if ( is_post_type_hierarchical( (string) $post->post_type ) ) { foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor ) { $crumbs[] = [ 'name' => ContentData::plain_text( get_the_title( (int) $ancestor ) ), 'url' => (string) get_permalink( (int) $ancestor ), ]; } } else { $term = self::primary_term( $post ); $url = null !== $term ? get_term_link( $term ) : ''; if ( null !== $term && is_string( $url ) ) { $crumbs[] = [ 'name' => ContentData::plain_text( $term->name ), 'url' => $url, ]; } } $crumbs[] = [ 'name' => ContentData::plain_text( get_the_title( $post ) ), 'url' => '', ]; return $crumbs; } /** * The trail down to an archive. * * @param string $title Archive name. * @return array> Crumbs of `name` and `url`. */ private static function archive_breadcrumbs( string $title ): array { if ( '' === $title ) { return []; } return [ [ 'name' => __( 'Home', 'acid-aeo' ), 'url' => home_url( '/' ), ], [ 'name' => $title, 'url' => '', ], ]; } /** * The section a post belongs to. * * @param WP_Post $post Current post. * @return string Term name, empty when the post is in no hierarchical taxonomy. */ private static function section( WP_Post $post ): string { $term = self::primary_term( $post ); return null !== $term ? ContentData::plain_text( $term->name ) : ''; } /** * The post's tags, as keywords. * * Read from every flat taxonomy the post type has, so a custom post type * with its own tags is described as well as a post is. * * @param WP_Post $post Current post. * @return array Term names. */ private static function keywords( WP_Post $post ): array { $keywords = []; foreach ( self::terms( $post, false ) as $term ) { $keywords[] = ContentData::plain_text( $term->name ); } return array_values( array_unique( array_filter( $keywords ) ) ); } /** * The first term of the post's first hierarchical taxonomy. * * @param WP_Post $post Current post. * @return WP_Term|null The term, or null when there is none. */ private static function primary_term( WP_Post $post ): ?WP_Term { $terms = self::terms( $post, true ); return $terms[0] ?? null; } /** * The post's terms in the taxonomies of one shape. * * @param WP_Post $post Current post. * @param bool $hierarchical True for category-like taxonomies, false for tag-like ones. * @return array Terms, in taxonomy registration order. */ private static function terms( WP_Post $post, bool $hierarchical ): array { $found = []; foreach ( get_object_taxonomies( (string) $post->post_type, 'objects' ) as $taxonomy ) { if ( empty( $taxonomy->public ) || (bool) $taxonomy->hierarchical !== $hierarchical ) { continue; } $terms = get_the_terms( $post, (string) $taxonomy->name ); if ( ! is_array( $terms ) ) { continue; } foreach ( $terms as $term ) { if ( $term instanceof WP_Term ) { $found[] = $term; } } } return $found; } /** * How many words a text carries. * * Counted with a Unicode aware pattern rather than `str_word_count()`, * which reads bytes: on Romanian or Greek content that function splits a * word at every multibyte character and reports a third more words than * the text has. Public and taking a string because it is pure, and unit * tested as such. * * @param string $text Plain text. * @return int Word count. */ public static function word_count( string $text ): int { return (int) preg_match_all( '/[\p{L}\p{N}\'-]+/u', $text ); } /** * A post meta value as a trimmed string. * * @param int $post_id Post or attachment ID. * @param string $key Meta key. * @return string Meta value, empty when unset or not scalar. */ private static function meta_string( int $post_id, string $key ): string { $value = get_post_meta( $post_id, $key, true ); return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * A user meta value as a trimmed string. * * @param int $user_id User ID. * @param string $key Meta key. * @return string Meta value, empty when unset or not scalar. */ private static function user_meta_string( int $user_id, string $key ): string { $value = get_user_meta( $user_id, $key, true ); return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Splits a stored multi value field into its items. * * @param string $value Stored value. * @param string $separator Item separator. * @return array Items, empty ones dropped. */ private static function split( string $value, string $separator ): array { if ( '' === $value ) { return []; } return array_values( array_filter( array_map( 'trim', explode( $separator, $value ) ), 'strlen' ) ); } }