} */ public static function build_for_current_request( ?array $singular ): array { $graph = ABDS_SEO_Schema_Graph::build( self::gather( $singular ) ); /** * Filters the finished JSON-LD graph before it is printed. Add or adjust * nodes here; keep every @id unique and every reference pointing at a node * that exists (the Site Audit checks both). * * @since 0.14.0 * * @param array $graph Array with '@context' and '@graph'. */ $filtered = apply_filters( 'abds_seo_schema_graph', $graph ); return is_array( $filtered ) && isset( $filtered['@graph'] ) && is_array( $filtered['@graph'] ) ? $filtered : $graph; } /** * The builder input for the current request. * * @param array|null $singular Singular context, see build_for_current_request(). * @return array */ public static function gather( ?array $singular ): array { $home = home_url( '/' ); $site = self::plain( (string) get_bloginfo( 'name' ) ); $settings = (array) ( ( (array) get_option( 'abds_seo_settings', array() ) )['global'] ?? array() ); $policy = ABDS_SEO_Policy_Engine::resolve_current_request(); $is_page = is_singular(); $paged = is_paged(); $request = array( 'is_search' => is_search(), 'is_404' => is_404(), 'is_paged' => $paged, 'is_singular' => $is_page, 'is_front_page' => is_front_page(), 'index' => (bool) $policy['index'], 'schema_type' => (string) $policy['schema_type'], 'canonical' => '', 'title' => '', 'headline' => '', 'description' => '', 'breadcrumbs' => array(), ); if ( ! $request['is_search'] && ! $request['is_404'] ) { if ( $is_page && null !== $singular && ! $paged ) { $post = get_queried_object(); $request['canonical'] = '' !== (string) $singular['url'] ? (string) $singular['url'] : (string) get_permalink( $post ); $request['title'] = (string) $singular['title']; $request['headline'] = self::plain( (string) get_the_title( $post ) ); $request['description'] = (string) $singular['description']; $request['published'] = (string) $singular['published']; $request['modified'] = (string) $singular['modified']; $request['image'] = self::post_image( $post, (string) $singular['image'], (int) ( $settings['default_og_image'] ?? 0 ) ); $request['author'] = self::author( $post, $home ); } else { $request['canonical'] = self::collection_canonical( $paged ); $request['title'] = self::collection_title( $site ); $request['description'] = self::collection_description(); } $request['breadcrumbs'] = self::breadcrumbs( $home, $request ); } // The licensed local profile reaches the graph twice: in full on the // first page of the front page (local fields + extra @type), and as // identity (name, profile links, logo fallback) on every other view, // so the single #organization node carries the same name everywhere. $licensed = ABDS_SEO_License_Accessor::gate()->allows( ABDS_SEO_Feature_Map::FEATURE_LOCAL_SEO ); $local = null; $identity = null; if ( $licensed && class_exists( 'ABDS_SEO_Local_SEO_Scanner' ) ) { $nap = ( new ABDS_SEO_Local_SEO_Scanner() )->get_reference_nap(); $identity = ABDS_SEO_Meta_Builder::build_local_business_schema( $nap, $home ); $local = ABDS_SEO_Meta_Builder::local_business_head_node( $nap, $home, $licensed, is_front_page(), $paged ); } return array( 'home_url' => $home, 'site_name' => $site, 'language' => (string) get_bloginfo( 'language' ), /** * Filters whether the WebSite node describes the site search * (a SearchAction). WordPress has no switch for search being turned * off, so a theme or plugin that removes it should return false. * * @since 0.14.0 * * @param bool $enabled Default true. */ 'search_enabled' => (bool) apply_filters( 'abds_seo_schema_search_action', true ), 'entity' => self::entity( $settings ), 'local_node' => $local, 'local_identity' => $identity, 'request' => $request, ); } /** * The one organization or person the site represents, from the Global * tab of Search Appearance. A missing logo falls back to the theme's custom * logo; a missing name to the site name (the builder does that). * * @param array $settings Stored global settings. * @return array{kind:string,name:string,logo:?array,same_as:array} */ public static function entity( array $settings ): array { $logo_id = (int) ( $settings['org_logo'] ?? 0 ); if ( $logo_id <= 0 ) { $logo_id = (int) get_theme_mod( 'custom_logo' ); } return array( 'kind' => 'person' === ( $settings['schema_entity'] ?? '' ) ? 'person' : 'organization', 'name' => self::plain( (string) ( $settings['org_name'] ?? '' ) ), 'logo' => $logo_id > 0 ? self::attachment_image( $logo_id ) : null, 'same_as' => (array) ( $settings['org_profiles'] ?? array() ), ); } /** * URL and size of an attachment image, or null. * * @param int $id Attachment ID. * @return array{url:string,width:int,height:int}|null */ protected static function attachment_image( int $id ): ?array { $src = wp_get_attachment_image_src( $id, 'full' ); if ( ! is_array( $src ) || '' === (string) $src[0] ) { return null; } return array( 'url' => (string) $src[0], 'width' => (int) $src[1], 'height' => (int) $src[2], ); } /** * The page's primary image: featured image, else the Global default * social image, else the bare URL the head already resolved. * * @param WP_Post|mixed $post Queried post. * @param string $resolved_url Image URL the head tags use. * @param int $default_id Default social image attachment ID. * @return array|null */ protected static function post_image( $post, string $resolved_url, int $default_id ): ?array { $id = $post instanceof WP_Post ? (int) get_post_thumbnail_id( $post ) : 0; if ( $id <= 0 ) { $id = $default_id; } $image = $id > 0 ? self::attachment_image( $id ) : null; if ( null === $image && '' !== $resolved_url ) { return array( 'url' => $resolved_url ); } return $image; } /** * The author as a stable pseudo-id and display name. The @id is derived, * not the numeric user ID, so the markup does not hand out account IDs. * * @param WP_Post|mixed $post Queried post. * @param string $home Home URL. * @return array{name:string,id:string}|null */ protected static function author( $post, string $home ): ?array { if ( ! $post instanceof WP_Post ) { return null; } $name = self::plain( (string) get_the_author_meta( 'display_name', (int) $post->post_author ) ); return '' === $name ? null : array( 'name' => $name, 'id' => substr( md5( 'abds-author|' . $home . '|' . (int) $post->post_author ), 0, 16 ), ); } /** * Clean canonical address of a non-singular view (never the request URL). * * @param bool $paged Whether this is a paginated page. * @return string */ protected static function collection_canonical( bool $paged ): string { $url = ''; $queried = get_queried_object(); if ( $queried instanceof WP_Term ) { $link = get_term_link( $queried ); $url = is_wp_error( $link ) ? '' : (string) $link; } elseif ( is_post_type_archive() ) { $post_type_var = get_query_var( 'post_type' ); if ( is_array( $post_type_var ) ) { $post_type_var = reset( $post_type_var ); } $post_type = $queried instanceof WP_Post_Type ? $queried->name : (string) $post_type_var; $url = (string) get_post_type_archive_link( $post_type ); } elseif ( is_author() && $queried instanceof WP_User ) { $url = (string) get_author_posts_url( $queried->ID ); } elseif ( is_day() ) { $url = (string) get_day_link( (int) get_query_var( 'year' ), (int) get_query_var( 'monthnum' ), (int) get_query_var( 'day' ) ); } elseif ( is_month() ) { $url = (string) get_month_link( (int) get_query_var( 'year' ), (int) get_query_var( 'monthnum' ) ); } elseif ( is_year() ) { $url = (string) get_year_link( (int) get_query_var( 'year' ) ); } elseif ( is_home() ) { $posts_page = (int) get_option( 'page_for_posts' ); $url = $posts_page > 0 ? (string) get_permalink( $posts_page ) : home_url( '/' ); } if ( '' === $url ) { $url = home_url( '/' ); } if ( $paged && function_exists( 'get_pagenum_link' ) ) { $url = (string) get_pagenum_link( max( 2, (int) get_query_var( 'paged' ) ), false ); } return (string) preg_replace( '/[?#].*$/', '', $url ); } /** * Heading of a non-singular view, without the "Category:" style prefix. * * @param string $site Site name. * @return string */ protected static function collection_title( string $site ): string { if ( is_front_page() && is_home() ) { return $site; } if ( is_home() ) { $posts_page = (int) get_option( 'page_for_posts' ); return $posts_page > 0 ? self::plain( (string) get_the_title( $posts_page ) ) : $site; } add_filter( 'get_the_archive_title_prefix', '__return_empty_string' ); $title = self::plain( (string) get_the_archive_title() ); remove_filter( 'get_the_archive_title_prefix', '__return_empty_string' ); return '' !== $title ? $title : $site; } /** * Description of a term archive, plain text. * * @return string */ protected static function collection_description(): string { if ( ! ( get_queried_object() instanceof WP_Term ) ) { return ''; } return self::plain( (string) term_description() ); } /** * Breadcrumb trail for the current view (see ABDS_SEO_Schema_Graph for * when it is printed). The `abds_seo_breadcrumb_items` filter lets a * theme with its own breadcrumbs replace the trail. * * @param string $home Home URL. * @param array $request Request facts gathered so far. * @return array */ protected static function breadcrumbs( string $home, array $request ): array { $items = array( array( 'name' => __( 'Home', 'abds-seo' ), 'url' => $home, ), ); $queried = get_queried_object(); if ( $request['is_singular'] && $queried instanceof WP_Post && ! $request['is_paged'] ) { $items = array_merge( $items, self::singular_trail( $queried ) ); $items[] = array( 'name' => '' !== $request['headline'] ? $request['headline'] : $request['title'], 'url' => $request['canonical'], ); } elseif ( $queried instanceof WP_Term ) { $items = array_merge( $items, self::term_ancestors( $queried ) ); $items[] = array( 'name' => self::plain( (string) $queried->name ), 'url' => $request['canonical'], ); } elseif ( is_home() && ! is_front_page() ) { $items[] = array( 'name' => $request['title'], 'url' => $request['canonical'], ); } elseif ( ! is_home() ) { $items[] = array( 'name' => $request['title'], 'url' => $request['canonical'], ); } /** * Filters the breadcrumb trail used for the BreadcrumbList node. * Each item is `array( 'name' => string, 'url' => string )`; the last * item is the current page and is printed without a link. * * @since 0.14.0 * * @param array $items Trail, first item is the home page. * @param array $request Facts about the current request. */ $filtered = apply_filters( 'abds_seo_breadcrumb_items', $items, $request ); return self::normalize_items( $filtered, $items ); } /** * Parents of a hierarchical post, or the primary category (with its own * parents) of a post. * * @param WP_Post $post Queried post. * @return array */ protected static function singular_trail( WP_Post $post ): array { $trail = array(); if ( is_post_type_hierarchical( $post->post_type ) ) { foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor_id ) { $trail[] = array( 'name' => self::plain( (string) get_the_title( $ancestor_id ) ), 'url' => (string) get_permalink( $ancestor_id ), ); } return $trail; } if ( 'post' === $post->post_type ) { $terms = get_the_terms( $post, 'category' ); $term = is_array( $terms ) ? self::pick_primary_term( $terms ) : null; if ( $term instanceof WP_Term ) { $trail = self::term_ancestors( $term ); $link = get_term_link( $term ); if ( ! is_wp_error( $link ) ) { $trail[] = array( 'name' => self::plain( (string) $term->name ), 'url' => (string) $link, ); } } } return $trail; } /** * Ancestor terms of a hierarchical term, outermost first. * * @param WP_Term $term Term. * @return array */ protected static function term_ancestors( WP_Term $term ): array { if ( ! is_taxonomy_hierarchical( $term->taxonomy ) ) { return array(); } $trail = array(); foreach ( array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) ) as $ancestor_id ) { $ancestor = get_term( $ancestor_id, $term->taxonomy ); $link = $ancestor instanceof WP_Term ? get_term_link( $ancestor ) : ''; if ( $ancestor instanceof WP_Term && ! is_wp_error( $link ) ) { $trail[] = array( 'name' => self::plain( (string) $ancestor->name ), 'url' => (string) $link, ); } } return $trail; } /** * The category that represents a post in its breadcrumb: the only one, * or, when there are several, the first by name (then by id) so the * choice is deterministic and does not change between requests or after a * term is re-saved. * * @param array $terms Terms with `name` and `term_id`. * @return object|null */ public static function pick_primary_term( array $terms ) { $terms = array_values( array_filter( $terms, 'is_object' ) ); if ( array() === $terms ) { return null; } usort( $terms, static function ( $a, $b ): int { $by_name = strcasecmp( (string) $a->name, (string) $b->name ); return 0 !== $by_name ? $by_name : (int) $a->term_id <=> (int) $b->term_id; } ); return $terms[0]; } /** * Sanitises a (possibly filtered) breadcrumb list: only arrays with a * non-empty string name survive; if nothing usable is left the original * trail is kept (a misbehaving filter must not silently drop breadcrumbs). * * @param mixed $items Filter result. * @param array $fallback Trail used when $items is unusable. * @return array */ public static function normalize_items( $items, array $fallback ): array { if ( ! is_array( $items ) ) { return $fallback; } $out = array(); foreach ( $items as $item ) { if ( ! is_array( $item ) || ! is_string( $item['name'] ?? null ) || '' === trim( $item['name'] ) ) { continue; } $out[] = array( 'name' => trim( $item['name'] ), 'url' => is_string( $item['url'] ?? null ) ? trim( $item['url'] ) : '', ); } return array() === $out ? $fallback : $out; } /** * Plain text: tags stripped, entities decoded, whitespace collapsed. * * @param string $value Raw text. * @return string */ protected static function plain( string $value ): string { return trim( (string) preg_replace( '/\s+/u', ' ', wp_specialchars_decode( wp_strip_all_tags( $value ), ENT_QUOTES ) ) ); } }