settings = $settings; } /** * Register hooks. * * The observers attach to filters that only exist when the respective SEO * plugin is active. Adding a listener to a hook that never fires costs * nothing, so there is no need to detect the plugin before subscribing. */ public function hooks() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'wp_head', array( $this, 'render' ), 99 ); add_filter( 'user_contactmethods', array( $this, 'contact_methods' ) ); add_filter( 'wpseo_schema_graph', array( $this, 'observe_yoast' ), 99 ); add_filter( 'rank_math/json_ld', array( $this, 'observe_rank_math' ), 99 ); add_filter( 'aioseo_schema_output', array( $this, 'observe_aioseo' ), 99 ); } /** * Register the JSON-LD validation route. */ public function register_routes() { register_rest_route( Oorank_Rest::NAMESPACE, '/schema/validate', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_validate' ), 'permission_callback' => static function () { return Oorank_Roles::can(); }, 'args' => array( 'json' => array( 'type' => 'string', 'required' => true, ), ), ) ); } /** * POST /schema/validate. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_validate( WP_REST_Request $request ) { return rest_ensure_response( self::validate_json( (string) $request->get_param( 'json' ) ) ); } // ---- Settings ---- /** * This module's settings, merged over defaults. * * @return array */ private function config() { $all = $this->settings->get(); return isset( $all['schema'] ) && is_array( $all['schema'] ) ? $all['schema'] : array(); } /** * The active output mode. * * @return string One of the MODE_* constants. */ public function mode() { $config = $this->config(); $mode = isset( $config['mode'] ) ? $config['mode'] : self::MODE_AUTO; return in_array( $mode, array( self::MODE_AUTO, self::MODE_ALWAYS, self::MODE_OFF ), true ) ? $mode : self::MODE_AUTO; } // ---- Dedupe ---- /** * SEO plugins that publish a full schema graph, and the types they cover. * * Used only as a fallback: when one of these is active but its graph filter * did not fire before `wp_head` ran — a caching layer, a non-standard * output point — Oorank still needs an answer, and assuming the documented * coverage is safer than emitting a duplicate Article. * * @return array */ public static function known_emitters() { $core_types = array( 'article', 'webpage', 'website', 'organization', 'person', 'breadcrumblist', 'imageobject' ); $emitters = array( 'yoast' => array( 'name' => 'Yoast SEO', 'active' => defined( 'WPSEO_VERSION' ), 'types' => $core_types, ), 'rank_math' => array( 'name' => 'Rank Math SEO', 'active' => defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ), 'types' => $core_types, ), 'aioseo' => array( 'name' => 'All in One SEO', 'active' => defined( 'AIOSEO_VERSION' ), 'types' => $core_types, ), 'seopress' => array( 'name' => 'SEOPress', 'active' => defined( 'SEOPRESS_VERSION' ), 'types' => $core_types, ), 'slim_seo' => array( 'name' => 'Slim SEO', 'active' => defined( 'SLIM_SEO_VER' ), 'types' => $core_types, ), // WooCommerce publishes its own Product markup on every product, // and it knows things Oorank does not — variations, tax display, // shipping. Only `product` is listed: Woo's BreadcrumbList is // product-page-only, and claiming it here would suppress Oorank's // breadcrumbs across the whole site to avoid a clash on one // template. 'woocommerce' => array( 'name' => 'WooCommerce', 'active' => class_exists( 'WooCommerce' ), 'types' => array( 'product' ), ), ); /** * Filter the structured-data emitters Oorank defers to. * * Extension seam: add a theme or plugin that publishes its own graph, * so `auto` mode stops duplicating its types. * * @param array $emitters Keyed by slug: name, active flag, covered types. */ return apply_filters( 'oorank_schema_emitters', $emitters ); } /** * The known emitters currently active, as slug => name. * * @return array */ public static function active_emitters() { $active = array(); foreach ( self::known_emitters() as $slug => $emitter ) { if ( ! empty( $emitter['active'] ) ) { $active[ $slug ] = $emitter['name']; } } return $active; } /** * Record the types Yoast SEO put in its graph. * * @param array $graph Yoast's graph pieces. * @return array Unmodified graph. */ public function observe_yoast( $graph ) { return $this->observe( $graph ); } /** * Record the types Rank Math put in its graph. * * @param array $data Rank Math's JSON-LD entities, keyed. * @return array Unmodified data. */ public function observe_rank_math( $data ) { return $this->observe( is_array( $data ) ? array_values( $data ) : $data ); } /** * Record the types All in One SEO put in its graph. * * @param array $graph AIOSEO's graph pieces. * @return array Unmodified graph. */ public function observe_aioseo( $graph ) { return $this->observe( $graph ); } /** * Collect `@type` values out of another plugin's graph and remember them. * * @param mixed $graph Graph as passed to the filter. * @return mixed The graph, untouched — this is an observer, not a filter. */ private function observe( $graph ) { if ( ! is_array( $graph ) ) { return $graph; } $seen = is_array( $this->foreign ) ? $this->foreign : array(); foreach ( $graph as $piece ) { if ( ! is_array( $piece ) || ! isset( $piece['@type'] ) ) { continue; } foreach ( (array) $piece['@type'] as $type ) { if ( is_string( $type ) ) { $seen[] = strtolower( $type ); } } } $this->foreign = array_values( array_unique( $seen ) ); return $graph; } /** * Reduce a schema.org type to the role it plays in a page's graph. * * Deduplication has to compare roles, not strings. Yoast describing a site * as a `Person` and Oorank describing it as an `Organization` are two * answers to one question — emitting both leaves an engine to guess which * entity gets the citation. Likewise `CollectionPage` and `WebPage`, or * `NewsArticle` and `Article`. * * FAQPage and HowTo are deliberately *not* folded into the page role: they * carry question and step content nothing else on the page carries, so they * are worth emitting alongside another plugin's WebPage. * * @param string $type schema.org type, any case. * @return string Role slug. */ public static function slot( $type ) { $type = strtolower( (string) $type ); $groups = array( 'publisher' => array( 'organization', 'person', 'corporation', 'localbusiness', 'newsmediaorganization', 'onlinebusiness' ), 'article' => array( 'article', 'blogposting', 'newsarticle', 'techarticle', 'scholarlyarticle', 'report', 'liveblogposting' ), 'webpage' => array( 'webpage', 'collectionpage', 'itempage', 'aboutpage', 'contactpage', 'profilepage', 'searchresultspage' ), // Breadcrumbs and products get roles of their own rather than // folding into webpage: an SEO plugin that publishes a WebPage // does not necessarily publish either, and suppressing them on // that assumption would lose markup nothing else carries. 'breadcrumb' => array( 'breadcrumblist' ), 'product' => array( 'product', 'productgroup' ), ); /** * Filter the type-to-role grouping used for deduplication. * * @param array $groups Role slug => list of lower-cased schema.org types. */ $groups = apply_filters( 'oorank_schema_type_slots', $groups ); foreach ( $groups as $slot => $types ) { if ( in_array( $type, (array) $types, true ) ) { return $slot; } } return $type; } /** * Map a list of types to their roles. * * @param string[] $types schema.org types. * @return string[] Role slugs. */ public static function slots( $types ) { return array_values( array_unique( array_map( array( __CLASS__, 'slot' ), (array) $types ) ) ); } /** * The roles Oorank assumes are taken when it has observed nothing — the * documented coverage of every active known emitter. * * Also what the admin screen reports, since it cannot observe a front-end * request from inside wp-admin. * * @return string[] Role slugs. */ public static function assumed_foreign_slots() { return self::slots( self::assumed_foreign_types() ); } /** * The coverage Oorank assumes when it has observed nothing — the union of * the documented types of every active known emitter. * * Also what the admin screen reports, since it cannot observe a front-end * request from inside wp-admin. * * @return string[] Lower-cased schema.org type names. */ public static function assumed_foreign_types() { $types = array(); foreach ( self::known_emitters() as $emitter ) { if ( ! empty( $emitter['active'] ) && ! empty( $emitter['types'] ) ) { $types = array_merge( $types, array_map( 'strtolower', (array) $emitter['types'] ) ); } } return array_values( array_unique( $types ) ); } /** * Roles Oorank must not duplicate on this request. * * The union of what was observed and what an active emitter documents — * not observation alone. An emitter that publishes a Person only on posts * that have an author would otherwise leave Oorank filling the publisher * role on some pages and not others, so one site would end up with two * different publisher entities. Assuming the documented coverage keeps the * decision the same on every URL; observation can only add to it. * * @return string[] Role slugs. */ private function foreign_slots() { $types = array_merge( is_array( $this->foreign ) ? $this->foreign : array(), self::assumed_foreign_types() ); /** * Filter the types Oorank treats as already published. * * @param string[] $types Lower-cased schema.org type names. */ $types = apply_filters( 'oorank_schema_foreign_types', array_values( array_unique( $types ) ) ); return self::slots( $types ); } // ---- Output ---- /** * Print the graph in the document head. */ public function render() { $graph = $this->graph(); if ( ! $graph ) { return; } $json = wp_json_encode( array( '@context' => 'https://schema.org', '@graph' => array_values( $graph ), ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP ); if ( ! $json ) { return; } /* * JSON_HEX_TAG and JSON_HEX_AMP escape every character that could close * the script element or open an entity, which is what makes this string * safe in this context. esc_html() would corrupt the JSON instead. */ echo "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-LD, escaped by the encoder flags above. } /** * Build the graph for the current request, after deduplication. * * @return array List of nodes; empty when nothing should be emitted. */ public function graph() { if ( self::MODE_OFF === $this->mode() || ! $this->is_eligible() ) { return array(); } $nodes = array_merge( $this->identity_nodes(), $this->content_nodes() ); // Hand-written blocks are appended after deduplication decisions are // made about our own nodes, and are never suppressed: the owner wrote // them for this page on purpose, and second-guessing that would make // the feature useless exactly when it is needed. $custom = $this->custom_nodes(); if ( self::MODE_AUTO === $this->mode() ) { $taken = $this->foreign_slots(); $nodes = array_filter( $nodes, static function ( $node ) use ( $taken ) { foreach ( self::slots( (array) $node['@type'] ) as $slot ) { if ( in_array( $slot, $taken, true ) ) { return false; } } return true; } ); $nodes = $this->prune_dangling_refs( $nodes ); } $nodes = array_merge( array_values( $nodes ), $custom ); /** * Filter the JSON-LD graph before output. * * Extension seam: add-ons append Product, Course, Event and similar * nodes here, or adjust what free core built. * * @param array $nodes Graph nodes, after deduplication. * @param Oorank_Schema $schema This module. */ $nodes = apply_filters( 'oorank_schema_graph', array_values( $nodes ), $this ); return is_array( $nodes ) ? $nodes : array(); } /** * Hand-written JSON-LD blocks whose display conditions match this request. * * @return array[] */ private function custom_nodes() { $config = $this->config(); $blocks = isset( $config['custom'] ) && is_array( $config['custom'] ) ? $config['custom'] : array(); $nodes = array(); foreach ( $blocks as $block ) { if ( empty( $block['json'] ) || ! self::matches_conditions( $block ) ) { continue; } $decoded = json_decode( (string) $block['json'], true ); if ( ! is_array( $decoded ) ) { continue; } // A block may be one node or several; both are accepted, because // a list is what anyone pasting from a generator will have. $list = isset( $decoded['@type'] ) ? array( $decoded ) : $decoded; foreach ( $list as $node ) { if ( is_array( $node ) && ! empty( $node['@type'] ) ) { // @context belongs to the graph, not to a node inside it. unset( $node['@context'] ); $nodes[] = $node; } } } return $nodes; } /** * Whether a custom block's include/exclude rules match this request. * * Rules are strings: `all`, a post type, or `post:123`. `hide` beats * `show`, since the only reason to write both is to carve something out. * * @param array $block Custom block. * @return bool */ public static function matches_conditions( $block ) { $show = isset( $block['show'] ) ? (array) $block['show'] : array( 'all' ); $hide = isset( $block['hide'] ) ? (array) $block['hide'] : array(); $context = array( 'all' ); if ( is_singular() ) { $post = get_queried_object(); if ( $post instanceof WP_Post ) { $context[] = $post->post_type; $context[] = 'post:' . $post->ID; } } elseif ( is_front_page() || is_home() ) { $context[] = 'front_page'; } if ( array_intersect( $hide, $context ) ) { return false; } return empty( $show ) || (bool) array_intersect( $show, $context ); } /** * Validate a hand-written JSON-LD block. * * Returns the problems a reviewer would find rather than a boolean: what * makes custom JSON-LD hard is that a typo produces markup which parses * and says nothing, and nobody finds out until the page is not cited. * * @param string $json Raw JSON. * @return array{valid:bool, errors:string[], types:string[]} */ public static function validate_json( $json ) { $json = trim( (string) $json ); $errors = array(); $types = array(); if ( '' === $json ) { return array( 'valid' => false, 'errors' => array( __( 'Nothing to validate.', 'oorank' ) ), 'types' => array(), ); } $decoded = json_decode( $json, true ); if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { return array( 'valid' => false, 'errors' => array( sprintf( /* translators: %s: JSON parser message. */ __( 'Not valid JSON: %s', 'oorank' ), json_last_error_msg() ), ), 'types' => array(), ); } $list = ( is_array( $decoded ) && isset( $decoded['@type'] ) ) ? array( $decoded ) : (array) $decoded; foreach ( $list as $node ) { if ( ! is_array( $node ) ) { $errors[] = __( 'Every entry must be an object.', 'oorank' ); continue; } if ( empty( $node['@type'] ) ) { $errors[] = __( 'An entry has no @type, so nothing can read it.', 'oorank' ); continue; } foreach ( (array) $node['@type'] as $type ) { $types[] = (string) $type; } } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'types' => array_values( array_unique( $types ) ), ); } /** * Whether this request should carry structured data at all. * * @return bool */ private function is_eligible() { if ( is_admin() || is_feed() || is_robots() || is_404() || is_search() ) { return false; } if ( is_singular() ) { $post = get_queried_object(); if ( ! $post instanceof WP_Post ) { return false; } // A page nobody can read should not advertise itself as quotable. if ( post_password_required( $post ) || 'publish' !== $post->post_status ) { return false; } return true; } // Elsewhere only the site identity, and who an author archive is about. return is_front_page() || is_home() || is_author(); } /** * WebSite plus the publishing entity (Organization or Person). * * @return array */ private function identity_nodes() { $config = $this->config(); $home = trailingslashit( home_url() ); $entity = array( '@type' => $this->entity_type(), '@id' => $this->entity_id(), 'name' => $this->entity_name(), 'url' => $home, ); $logo = $this->entity_logo(); if ( $logo ) { $entity['logo'] = $logo; $entity['image'] = array( '@id' => $logo['@id'] ); } $same_as = $this->entity_same_as( $config ); if ( $same_as ) { $entity['sameAs'] = $same_as; } // Address and phone are only meaningful on a business identity, and a // Person who filled them in years ago should not have their home // address published because they switched the type back. if ( 'LocalBusiness' === $this->entity_type() ) { $entity = array_merge( $entity, $this->entity_place( $config ) ); } $website = array( '@type' => 'WebSite', '@id' => $home . '#/schema/website', 'url' => $home, 'name' => $this->text( get_bloginfo( 'name' ) ), 'publisher' => array( '@id' => $this->entity_id() ), 'inLanguage' => $this->language(), ); $tagline = $this->text( get_bloginfo( 'description' ) ); if ( '' !== $tagline ) { $website['description'] = $tagline; } return array( $entity, $website ); } /** * Whether the site publishes as an organization or an individual. * * @return string 'Organization' or 'Person'. */ private function entity_type() { $config = $this->config(); $type = isset( $config['entity_type'] ) ? (string) $config['entity_type'] : ''; return in_array( $type, array( 'Person', 'LocalBusiness' ), true ) ? $type : 'Organization'; } /** * PostalAddress and contact details for a LocalBusiness identity. * * A LocalBusiness without an address is not a local business as far as an * answer engine is concerned — "where" is the whole point of the type — so * the fields are only emitted when they hold something, and the type is * only offered alongside them in the UI. * * @param array $config Schema settings. * @return array Properties to merge into the entity node. */ private function entity_place( $config ) { $out = array(); $address = array_filter( array( 'streetAddress' => $this->text( isset( $config['street'] ) ? $config['street'] : '' ), 'addressLocality' => $this->text( isset( $config['locality'] ) ? $config['locality'] : '' ), 'addressRegion' => $this->text( isset( $config['region'] ) ? $config['region'] : '' ), 'postalCode' => $this->text( isset( $config['postal_code'] ) ? $config['postal_code'] : '' ), 'addressCountry' => $this->text( isset( $config['country'] ) ? $config['country'] : '' ), ) ); if ( ! empty( $address ) ) { $out['address'] = array_merge( array( '@type' => 'PostalAddress' ), $address ); } $telephone = $this->text( isset( $config['telephone'] ) ? $config['telephone'] : '' ); if ( '' !== $telephone ) { $out['telephone'] = $telephone; } return $out; } /** * Stable identifier for the publishing entity, referenced from every node. * * @return string */ public function entity_id() { return trailingslashit( home_url() ) . '#/schema/publisher'; } /** * The entity's display name — the configured one, or the site title. * * @return string */ private function entity_name() { $config = $this->config(); $name = isset( $config['entity_name'] ) ? $this->text( $config['entity_name'] ) : ''; return '' !== $name ? $name : $this->text( get_bloginfo( 'name' ) ); } /** * Logo as an ImageObject, from the setting, the custom logo, or the site * icon — in that order of intent. * * @return array|null */ private function entity_logo() { $config = $this->config(); $attachment = isset( $config['logo'] ) ? absint( $config['logo'] ) : 0; if ( ! $attachment ) { $attachment = (int) get_theme_mod( 'custom_logo' ); } if ( ! $attachment ) { $attachment = (int) get_option( 'site_icon' ); } if ( ! $attachment ) { return null; } $image = wp_get_attachment_image_src( $attachment, 'full' ); if ( ! $image || empty( $image[0] ) ) { return null; } $node = array( '@type' => 'ImageObject', '@id' => trailingslashit( home_url() ) . '#/schema/logo', 'url' => $image[0], 'contentUrl' => $image[0], ); if ( ! empty( $image[1] ) ) { $node['width'] = (int) $image[1]; } if ( ! empty( $image[2] ) ) { $node['height'] = (int) $image[2]; } $alt = get_post_meta( $attachment, '_wp_attachment_image_alt', true ); if ( $alt ) { $node['caption'] = $this->text( $alt ); } return $node; } /** * Profile URLs that identify the entity elsewhere on the web. * * @param array $config This module's settings. * @return string[] */ private function entity_same_as( $config ) { $links = isset( $config['same_as'] ) && is_array( $config['same_as'] ) ? $config['same_as'] : array(); $clean = array(); foreach ( $links as $link ) { $url = esc_url_raw( trim( (string) $link ) ); if ( $url ) { $clean[] = $url; } } return array_values( array_unique( $clean ) ); } /** * Nodes describing what is actually on screen. * * @return array */ private function content_nodes() { if ( is_author() ) { return $this->author_archive_nodes(); } if ( ! is_singular() ) { return array(); } $post = get_queried_object(); if ( ! $post instanceof WP_Post ) { return array(); } $nodes = array( $this->webpage_node( $post ) ); if ( $this->article_type( $post ) ) { $nodes[] = $this->article_node( $post ); // The byline is only worth stating as an entity, not a string. if ( $this->author_exists( $post ) ) { $author = $this->person_node( (int) $post->post_author ); if ( $author ) { $nodes[] = $author; } } } $faq = $this->faq_node( $post ); if ( $faq ) { $nodes[] = $faq; } $howto = $this->howto_node( $post ); if ( $howto ) { $nodes[] = $howto; } $breadcrumb = $this->breadcrumb_node( $post ); if ( $breadcrumb ) { $nodes[] = $breadcrumb; } $product = $this->product_node( $post ); if ( $product ) { $nodes[] = $product; } return $nodes; } /** * BreadcrumbList for a singular view. * * Built from the page's own ancestry — parent pages, or the primary * category for a post — rather than from whatever a theme renders, so the * markup matches the site's structure even on a theme that shows no * breadcrumb trail at all. A single-item trail (home only) is not a trail * and is skipped. * * @param WP_Post $post Queried post. * @return array|null */ private function breadcrumb_node( WP_Post $post ) { $trail = array( array( 'name' => $this->text( get_bloginfo( 'name' ) ), 'url' => trailingslashit( home_url() ), ), ); foreach ( array_reverse( (array) get_post_ancestors( $post ) ) as $ancestor_id ) { $trail[] = array( 'name' => $this->text( get_the_title( $ancestor_id ) ), 'url' => (string) get_permalink( $ancestor_id ), ); } if ( empty( get_post_ancestors( $post ) ) && 'post' === $post->post_type ) { $terms = get_the_terms( $post, 'category' ); if ( is_array( $terms ) && ! empty( $terms ) ) { $term = $terms[0]; $trail[] = array( 'name' => $this->text( $term->name ), 'url' => (string) get_term_link( $term ), ); } } $trail[] = array( 'name' => $this->text( get_the_title( $post ) ), 'url' => (string) get_permalink( $post ), ); /** * Filter the breadcrumb trail before it becomes a BreadcrumbList. * * @param array $trail List of `array( name, url )`, home first. * @param WP_Post $post The post being described. */ $trail = apply_filters( 'oorank_schema_breadcrumb', $trail, $post ); if ( count( $trail ) < 3 ) { return null; } $items = array(); foreach ( array_values( $trail ) as $index => $crumb ) { $items[] = array( '@type' => 'ListItem', 'position' => $index + 1, 'name' => $crumb['name'], 'item' => $crumb['url'], ); } $url = get_permalink( $post ); return array( '@type' => 'BreadcrumbList', '@id' => $url . '#breadcrumb', 'itemListElement' => $items, ); } /** * Product for a WooCommerce product. * * Only what the store already knows and an answer engine can use: what it * is, what it costs, whether it can be bought and what people rated it. * Nothing is invented, and a store with no Woo installed emits nothing. * * In `auto` mode this is suppressed while WooCommerce's own markup is * active, because Woo is a known emitter of `product` — the node exists * for stores that have switched Woo's structured data off, and for * `always` mode. * * @param WP_Post $post Queried post. * @return array|null */ private function product_node( WP_Post $post ) { if ( 'product' !== $post->post_type || ! function_exists( 'wc_get_product' ) ) { return null; } $product = wc_get_product( $post ); if ( ! $product ) { return null; } $url = get_permalink( $post ); $node = array( '@type' => 'Product', '@id' => $url . '#product', 'name' => $this->text( $product->get_name() ), 'description' => $this->description( $post ), 'url' => $url, ); $sku = $product->get_sku(); if ( $sku ) { $node['sku'] = $this->text( $sku ); } $image = $this->primary_image( $post ); if ( $image ) { $node['image'] = $image; } $price = $product->get_price(); if ( '' !== $price && is_numeric( $price ) ) { $node['offers'] = array( '@type' => 'Offer', 'url' => $url, 'price' => (string) wc_format_decimal( $price, wc_get_price_decimals() ), 'priceCurrency' => get_woocommerce_currency(), 'availability' => $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', ); } $count = (int) $product->get_review_count(); if ( $count > 0 ) { $node['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => (string) $product->get_average_rating(), 'reviewCount' => $count, ); } return $node; } /** * The WebPage node for a singular view. * * @param WP_Post $post Queried post. * @return array */ private function webpage_node( WP_Post $post ) { $url = get_permalink( $post ); $node = array( '@type' => 'WebPage', '@id' => $url . '#webpage', 'url' => $url, 'name' => $this->text( get_the_title( $post ) ), 'isPartOf' => array( '@id' => trailingslashit( home_url() ) . '#/schema/website' ), 'datePublished' => $this->date( $post->post_date_gmt ), 'dateModified' => $this->date( Oorank_Freshness::modified_gmt( $post ) ), 'inLanguage' => $this->language(), ); $description = $this->description( $post ); if ( '' !== $description ) { $node['description'] = $description; } $image = $this->primary_image( $post ); if ( $image ) { $node['primaryImageOfPage'] = $image; } return $node; } /** * The schema.org type to use for a post type's main entity, or an empty * string when it should not get an Article node. * * Pages describe a place on the site rather than a piece of writing, so by * convention they carry WebPage only. * * @param WP_Post $post Queried post. * @return string */ private function article_type( WP_Post $post ) { $type = ( 'page' === $post->post_type ) ? '' : 'Article'; /** * Filter the Article type for a post type. * * Return an empty string to suppress the Article node, or a more * specific type such as BlogPosting, NewsArticle or TechArticle. * * @param string $type schema.org type, or '' for none. * @param WP_Post $post The post being described. */ return (string) apply_filters( 'oorank_schema_article_type', $type, $post ); } /** * The Article node — the piece an answer engine actually quotes. * * @param WP_Post $post Queried post. * @return array */ private function article_node( WP_Post $post ) { $url = get_permalink( $post ); $body = $this->plain_body( $post ); $node = array( '@type' => $this->article_type( $post ), '@id' => $url . '#article', 'headline' => $this->text( get_the_title( $post ) ), 'mainEntityOfPage' => array( '@id' => $url . '#webpage' ), 'isPartOf' => array( '@id' => $url . '#webpage' ), 'datePublished' => $this->date( $post->post_date_gmt ), 'dateModified' => $this->date( Oorank_Freshness::modified_gmt( $post ) ), 'publisher' => array( '@id' => $this->entity_id() ), 'inLanguage' => $this->language(), ); /* * Article requires an author. Imported content and content whose author * account was deleted has none, and an Article missing the property is * discarded rather than degraded — so the site itself stands in. */ $node['author'] = $this->author_exists( $post ) ? array( '@id' => $this->person_id( (int) $post->post_author ) ) : array( '@id' => $this->entity_id() ); $description = $this->description( $post ); if ( '' !== $description ) { $node['description'] = $description; } $image = $this->primary_image( $post ); if ( $image ) { $node['image'] = $image; } $words = str_word_count( $body ); if ( $words ) { $node['wordCount'] = $words; } $sections = $this->term_names( $post, 'category' ); if ( $sections ) { $node['articleSection'] = $sections; } $keywords = $this->term_names( $post, 'post_tag' ); if ( $keywords ) { $node['keywords'] = $keywords; } return $node; } // ---- Author and entity authority (F5) ---- /** * Profile URL fields Oorank adds to the user editor. * * These become the author's `sameAs` — the list of places the same person * is already identified. That list is what lets an engine treat a byline * as an entity it knows rather than a string it has seen, which is the * whole mechanism behind being cited by name. * * @return array Meta key => label. */ public static function profile_fields() { $fields = array( 'oorank_linkedin' => __( 'LinkedIn URL', 'oorank' ), 'oorank_x' => __( 'X (Twitter) URL', 'oorank' ), 'oorank_mastodon' => __( 'Mastodon URL', 'oorank' ), 'oorank_github' => __( 'GitHub URL', 'oorank' ), 'oorank_youtube' => __( 'YouTube URL', 'oorank' ), 'oorank_orcid' => __( 'ORCID URL', 'oorank' ), ); /** * Filter the author profile URL fields. * * @param array $fields Meta key => label. Keys must stay prefixed. */ return apply_filters( 'oorank_author_profile_fields', $fields ); } /** * Add the profile URL fields to the user editor. * * @param array $methods Existing contact methods. * @return array */ public function contact_methods( $methods ) { return array_merge( is_array( $methods ) ? $methods : array(), self::profile_fields() ); } /** * Whether a post's author is a real, resolvable user. * * @param WP_Post $post Post. * @return bool */ private function author_exists( WP_Post $post ) { return $post->post_author && false !== get_userdata( (int) $post->post_author ); } /** * Stable identifier for a user, referenced from every node they author. * * @param int $user_id User ID. * @return string */ private function person_id( $user_id ) { return get_author_posts_url( $user_id ) . '#/schema/person'; } /** * A Person node for an author. * * @param int $user_id User ID. * @return array|null */ private function person_node( $user_id ) { $user = get_userdata( $user_id ); if ( ! $user ) { return null; } $node = array( '@type' => 'Person', '@id' => $this->person_id( $user_id ), 'name' => $this->text( $user->display_name ), 'url' => get_author_posts_url( $user_id ), ); $bio = $this->text( get_the_author_meta( 'description', $user_id ) ); if ( '' !== $bio ) { $node['description'] = $bio; } $same_as = $this->author_same_as( $user_id ); if ( $same_as ) { $node['sameAs'] = $same_as; } /** * Filter an author's Person node. * * @param array $node The node. * @param int $user_id User ID. */ return apply_filters( 'oorank_schema_person', $node, $user_id ); } /** * An author's profile URLs, from their website field and the profile * fields Oorank adds. * * @param int $user_id User ID. * @return string[] */ private function author_same_as( $user_id ) { $urls = array( get_the_author_meta( 'user_url', $user_id ) ); foreach ( array_keys( self::profile_fields() ) as $key ) { $urls[] = get_the_author_meta( $key, $user_id ); } $clean = array(); foreach ( $urls as $url ) { $url = esc_url_raw( trim( (string) $url ) ); if ( '' !== $url ) { $clean[] = $url; } } return array_values( array_unique( $clean ) ); } /** * ProfilePage and Person for an author archive. * * An author archive is the one URL on a site that is unambiguously *about* * a person, so it is worth describing as such. * * @return array */ private function author_archive_nodes() { $user = get_queried_object(); if ( ! $user instanceof WP_User ) { return array(); } $person = $this->person_node( $user->ID ); if ( ! $person ) { return array(); } $url = get_author_posts_url( $user->ID ); $page = array( '@type' => 'ProfilePage', '@id' => $url . '#webpage', 'url' => $url, 'name' => $person['name'], 'isPartOf' => array( '@id' => trailingslashit( home_url() ) . '#/schema/website' ), 'inLanguage' => $this->language(), 'mainEntity' => array( '@id' => $person['@id'] ), ); return array( $page, $person ); } // ---- FAQ and HowTo ---- /** * FAQPage built from question/answer content in the post. * * Recognises the core Details block — the closest thing WordPress ships to * an FAQ primitive — plus the dedicated FAQ blocks from Yoast and Rank * Math, so the markup survives even if those plugins are switched off. * * @param WP_Post $post Queried post. * @return array|null */ private function faq_node( WP_Post $post ) { $pairs = $this->collect_faq( parse_blocks( $post->post_content ) ); // Page builders keep the body in meta, so a builder-authored page has // no blocks to walk and would silently lose its FAQ. Only consulted // when the blocks found nothing — a hybrid page must not report its // accordions twice. if ( empty( $pairs ) ) { $pairs = $this->collect_builder_faq( $post ); } /** * Filter the question/answer pairs found in a post. * * @param array $pairs List of `array( question, answer )`. * @param WP_Post $post The post being described. */ $pairs = apply_filters( 'oorank_schema_faq_items', $pairs, $post ); // One collapsible paragraph is not an FAQ; two or more is a pattern. if ( count( $pairs ) < 2 ) { return null; } $entities = array(); foreach ( $pairs as $pair ) { $entities[] = array( '@type' => 'Question', 'name' => $pair['question'], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $pair['answer'], ), ); } $url = get_permalink( $post ); return array( '@type' => 'FAQPage', '@id' => $url . '#faq', 'isPartOf' => array( '@id' => $url . '#webpage' ), 'inLanguage' => $this->language(), 'mainEntity' => $entities, ); } /** * Question/answer pairs from a page builder's stored content. * * Elementor's accordion, toggle and nested-accordion widgets all store an * items array of `tab_title` / `tab_content` in `_elementor_data`, and so * do several third-party widget packs, so the walk keys off the field * names rather than off any widget type. Anything that does not use those * names is simply not found, which is the correct failure: emitting an * FAQPage for content that is not questions is worse than emitting none. * * @param WP_Post $post Queried post. * @return array List of `array( question, answer )`. */ private function collect_builder_faq( WP_Post $post ) { $pairs = array(); foreach ( Oorank_Freshness::builder_meta_keys() as $key ) { $raw = get_post_meta( $post->ID, $key, true ); if ( ! is_string( $raw ) || '' === $raw || '[' !== substr( ltrim( $raw ), 0, 1 ) ) { continue; } $data = json_decode( $raw, true ); if ( is_array( $data ) ) { $pairs = array_merge( $pairs, $this->walk_builder_faq( $data ) ); } } return $pairs; } /** * Recursively pull title/content pairs out of a builder's data tree. * * @param array $data Decoded builder data. * @return array List of `array( question, answer )`. */ private function walk_builder_faq( $data ) { $pairs = array(); // Every level is treated the same way: is this array itself a // question, and what is inside it? Builders nest sections in columns // in widgets in settings in item lists, and each level names its // container differently, so walking by container name finds nothing. $pair = $this->builder_faq_pair( $data ); if ( $pair ) { $pairs[] = $pair; } foreach ( $data as $child ) { if ( is_array( $child ) ) { $pairs = array_merge( $pairs, $this->walk_builder_faq( $child ) ); } } return $pairs; } /** * Read one accordion item, if this array is one. * * @param array $node Builder node. * @return array|null `array( question, answer )`, or null. */ private function builder_faq_pair( $node ) { $question = ''; $answer = ''; foreach ( array( 'tab_title', 'accordion_title', 'item_title', 'title' ) as $field ) { if ( ! empty( $node[ $field ] ) && is_string( $node[ $field ] ) ) { $question = $node[ $field ]; break; } } foreach ( array( 'tab_content', 'accordion_content', 'item_content', 'content' ) as $field ) { if ( ! empty( $node[ $field ] ) && is_string( $node[ $field ] ) ) { $answer = $node[ $field ]; break; } } if ( '' === $question || '' === $answer ) { return null; } return array( 'question' => $this->text( wp_strip_all_tags( $question ) ), 'answer' => $this->text( wp_strip_all_tags( $answer ) ), ); } /** * Walk blocks collecting question/answer pairs. * * @param array $blocks Parsed blocks. * @return array List of `array( question, answer )`. */ private function collect_faq( $blocks ) { $pairs = array(); foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); if ( 'core/details' === $name ) { $pair = $this->details_pair( $block ); if ( $pair ) { $pairs[] = $pair; } // The answer is the whole block; do not also walk into it. continue; } if ( in_array( $name, array( 'yoast/faq-block', 'rank-math/faq-block' ), true ) ) { $pairs = array_merge( $pairs, $this->block_questions( $attrs ) ); continue; } if ( ! empty( $block['innerBlocks'] ) ) { $pairs = array_merge( $pairs, $this->collect_faq( $block['innerBlocks'] ) ); } } return $pairs; } /** * Split a rendered Details block into its summary and its body. * * @param array $block Parsed block. * @return array|null `array( question, answer )`. */ private function details_pair( $block ) { $html = render_block( $block ); if ( ! $html || ! preg_match( '#]*>(.*?)#is', $html, $match ) ) { return null; } $question = $this->text( wp_strip_all_tags( $match[1] ) ); $answer = $this->text( wp_strip_all_tags( str_replace( $match[0], '', $html ) ) ); if ( '' === $question || '' === $answer ) { return null; } return array( 'question' => $question, 'answer' => $this->clamp( $answer ), ); } /** * Normalise the question list an FAQ block stores in its attributes. * * Yoast keeps `jsonQuestion`/`jsonAnswer`; Rank Math keeps `title`/ * `content`. Both are plain strings by the time they reach here. * * @param array $attrs Block attributes. * @return array List of `array( question, answer )`. */ private function block_questions( $attrs ) { $questions = isset( $attrs['questions'] ) && is_array( $attrs['questions'] ) ? $attrs['questions'] : array(); $pairs = array(); foreach ( $questions as $question ) { if ( ! is_array( $question ) ) { continue; } $name = $this->first_string( $question, array( 'jsonQuestion', 'title', 'question' ) ); $text = $this->first_string( $question, array( 'jsonAnswer', 'content', 'answer' ) ); if ( '' !== $name && '' !== $text ) { $pairs[] = array( 'question' => $name, 'answer' => $this->clamp( $text ), ); } } return $pairs; } /** * HowTo built from a how-to block, when the post carries one. * * @param WP_Post $post Queried post. * @return array|null */ private function howto_node( WP_Post $post ) { $steps = $this->collect_howto( parse_blocks( $post->post_content ) ); /** * Filter the how-to steps found in a post. * * @param array $steps List of `array( name, text )`. * @param WP_Post $post The post being described. */ $steps = apply_filters( 'oorank_schema_howto_steps', $steps, $post ); if ( count( $steps ) < 2 ) { return null; } $list = array(); $rank = 1; foreach ( $steps as $step ) { $node = array( '@type' => 'HowToStep', 'position' => $rank, 'text' => $step['text'], ); if ( '' !== $step['name'] ) { $node['name'] = $step['name']; } $list[] = $node; ++$rank; } $url = get_permalink( $post ); $node = array( '@type' => 'HowTo', '@id' => $url . '#howto', 'name' => $this->text( get_the_title( $post ) ), 'isPartOf' => array( '@id' => $url . '#webpage' ), 'inLanguage' => $this->language(), 'step' => $list, ); $description = $this->description( $post ); if ( '' !== $description ) { $node['description'] = $description; } return $node; } /** * Walk blocks collecting how-to steps. * * @param array $blocks Parsed blocks. * @return array List of `array( name, text )`. */ private function collect_howto( $blocks ) { $steps = array(); foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); if ( in_array( $name, array( 'yoast/how-to-block', 'rank-math/howto-block' ), true ) ) { $raw = isset( $attrs['steps'] ) && is_array( $attrs['steps'] ) ? $attrs['steps'] : array(); foreach ( $raw as $step ) { if ( ! is_array( $step ) ) { continue; } $text = $this->first_string( $step, array( 'jsonText', 'content', 'text' ) ); if ( '' === $text ) { continue; } $steps[] = array( 'name' => $this->first_string( $step, array( 'jsonName', 'title', 'name' ) ), 'text' => $this->clamp( $text ), ); } continue; } if ( ! empty( $block['innerBlocks'] ) ) { $steps = array_merge( $steps, $this->collect_howto( $block['innerBlocks'] ) ); } } return $steps; } // ---- Helpers ---- /** * First non-empty string among a set of candidate attribute keys. * * @param array $source Attribute array. * @param string[] $keys Keys to try, in order of preference. * @return string */ private function first_string( $source, $keys ) { foreach ( $keys as $key ) { if ( isset( $source[ $key ] ) && is_string( $source[ $key ] ) ) { $value = $this->text( wp_strip_all_tags( $source[ $key ] ) ); if ( '' !== $value ) { return $value; } } } return ''; } /** * Drop `@id` references pointing at nodes deduplication removed, so the * graph never links to something that is not in it. * * @param array $nodes Surviving nodes. * @return array */ private function prune_dangling_refs( $nodes ) { $ids = array(); foreach ( $nodes as $node ) { if ( isset( $node['@id'] ) ) { $ids[] = $node['@id']; } } $refs = array( 'isPartOf', 'publisher', 'mainEntityOfPage', 'mainEntity', 'author' ); foreach ( $nodes as $key => $node ) { foreach ( $refs as $ref ) { if ( isset( $node[ $ref ]['@id'] ) && ! in_array( $node[ $ref ]['@id'], $ids, true ) ) { unset( $nodes[ $key ][ $ref ] ); } } } return $nodes; } /** * A post's description — its excerpt, or the opening of its body. * * @param WP_Post $post Post. * @return string */ private function description( WP_Post $post ) { $excerpt = $this->text( $post->post_excerpt ); if ( '' !== $excerpt ) { return $excerpt; } $body = $this->plain_body( $post ); if ( '' === $body ) { return ''; } return $this->text( wp_trim_words( $body, 40, '…' ) ); } /** * The post body as plain text, blocks rendered but the_content filters * skipped — third-party injections are not part of what this page is about. * * @param WP_Post $post Post. * @return string */ private function plain_body( $post ) { $content = do_blocks( $post->post_content ); // Without this, `

` collapses two sentences into one word. $content = str_replace( '<', ' <', $content ); $content = wp_strip_all_tags( strip_shortcodes( $content ), true ); return $this->text( $content ); } /** * Featured image as an ImageObject. * * @param WP_Post $post Post. * @return array|null */ private function primary_image( WP_Post $post ) { $attachment = (int) get_post_thumbnail_id( $post ); if ( ! $attachment ) { return null; } $image = wp_get_attachment_image_src( $attachment, 'full' ); if ( ! $image || empty( $image[0] ) ) { return null; } $node = array( '@type' => 'ImageObject', '@id' => get_permalink( $post ) . '#primaryimage', 'url' => $image[0], 'contentUrl' => $image[0], ); if ( ! empty( $image[1] ) ) { $node['width'] = (int) $image[1]; } if ( ! empty( $image[2] ) ) { $node['height'] = (int) $image[2]; } return $node; } /** * Term names for a taxonomy, as a plain list. * * @param WP_Post $post Post. * @param string $taxonomy Taxonomy slug. * @return string[] */ private function term_names( WP_Post $post, $taxonomy ) { if ( ! is_object_in_taxonomy( $post->post_type, $taxonomy ) ) { return array(); } $terms = get_the_terms( $post, $taxonomy ); if ( ! $terms || is_wp_error( $terms ) ) { return array(); } return array_values( wp_list_pluck( $terms, 'name' ) ); } /** * An ISO-8601 date in the site's timezone, from a GMT timestamp string. * * @param string $gmt Date in `Y-m-d H:i:s` GMT. * @return string */ private function date( $gmt ) { if ( empty( $gmt ) || '0000-00-00 00:00:00' === $gmt ) { return ''; } return get_date_from_gmt( $gmt, DATE_W3C ); } /** * The document language, in the form schema.org expects. * * @return string */ private function language() { return str_replace( '_', '-', get_bloginfo( 'language' ) ); } /** * Collapse whitespace and decode entities in a string destined for JSON. * * @param string $value Raw value. * @return string */ private function text( $value ) { $value = wp_strip_all_tags( (string) $value ); $value = html_entity_decode( $value, ENT_QUOTES, get_bloginfo( 'charset' ) ); return trim( preg_replace( '/\s+/u', ' ', $value ) ); } /** * Trim a body of text to the serialisation limit on a word boundary. * * @param string $value Text. * @return string */ private function clamp( $value ) { if ( mb_strlen( $value ) <= self::TEXT_LIMIT ) { return $value; } return rtrim( mb_substr( $value, 0, self::TEXT_LIMIT ), " \t\n\r,;:" ) . '…'; } }