shouldOutput()) { return; } $graph = $this->buildGraph(Context::current()); if (empty($graph)) { return; } $document = [ '@context' => 'https://schema.org', '@graph' => array_values($graph), ]; // JSON_UNESCAPED_SLASHES keeps URLs readable; JSON_UNESCAPED_UNICODE stops // non-Latin titles turning into \u escapes, which are valid but make the // output impossible for a site owner to eyeball. JSON_HEX_TAG is the one that // matters for safety: it escapes < and >, so a literal closing script tag // inside any string value cannot end the block early. $json = wp_json_encode($document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG); if (false === $json) { return; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() hex-escapes <, >, & and quotes, so the JSON-LD cannot close the script tag. esc_html() would turn it into invalid JSON. echo '' . "\n"; } /** * @return array> Nodes keyed for readability; the * keys are dropped before output. */ public function buildGraph(Context $context) { $graph = []; $publisher = $this->publisherNode(); if ($publisher) { $graph['publisher'] = $publisher; } $graph['website'] = $this->websiteNode($publisher); $webPage = $this->webPageNode($context, $publisher); if ($webPage) { $graph['webpage'] = $webPage; } $image = $this->primaryImageNode($context); if ($image) { $graph['primaryimage'] = $image; } $breadcrumb = $this->breadcrumbNode($context); if ($breadcrumb) { $graph['breadcrumb'] = $breadcrumb; } if (Context::TYPE_SINGULAR === $context->type && $context->hasPost() && 'page' !== $context->postType) { $author = $this->authorNode((int) $context->object->post_author); if ($author) { $graph['author'] = $author; } // 'none' is a deliberate "this is not an article" — a landing page or a // gallery. Emitting an article node for it is the sort of thing that // earns a structured-data manual action. if ('none' !== SchemaTypes::articleType($context->objectId)) { $article = $this->articleNode($context, $publisher, $author); if ($article) { $graph['article'] = $article; } } } if (Context::TYPE_AUTHOR === $context->type && $context->hasUser()) { $author = $this->authorNode($context->objectId); if ($author) { $graph['author'] = $author; } } /** * Filter the assembled graph. Feature classes that own their own types * (Product, Recipe, FAQPage) append here rather than printing a second * script block, so everything stays in one connected graph. */ return apply_filters('rankify_schema_graph', $graph, $context); } // ──────────────────────────────────────────────────────────── // Stable identifiers // ──────────────────────────────────────────────────────────── // // Every @id is derived from a URL rather than a database ID so that the same // entity keeps its identity across pages, which is the whole point of a graph. public static function publisherId() { $type = Utils::getSetting('schemaSiteType'); return home_url('/') . ('person' === $type ? '#person' : '#organization'); } public static function websiteId() { return home_url('/') . '#website'; } public static function webPageId(Context $context) { return self::currentUrl($context) . '#webpage'; } public static function authorId($userId) { // Hashed rather than the raw user ID: author @ids appear in public output, // and exposing sequential user IDs helps nobody but an enumeration script. return home_url('/') . '#/schema/person/' . wp_hash('rankify-author-' . (int) $userId); } protected static function currentUrl(Context $context) { $canonical = Resolver::canonical($context); return $canonical ?: Utils::getCurrentUrl(true); } // ──────────────────────────────────────────────────────────── // Nodes // ──────────────────────────────────────────────────────────── protected function publisherNode() { $name = Utils::getSetting('schemaSiteName'); $name = is_string($name) && '' !== trim($name) ? trim($name) : get_bloginfo('name'); if ('' === $name) { return null; } $isPerson = 'person' === Utils::getSetting('schemaSiteType'); $node = [ '@type' => $isPerson ? 'Person' : 'Organization', '@id' => self::publisherId(), 'name' => $name, 'url' => home_url('/'), ]; $logo = Resolver::describeImageSetting(Utils::getSetting('schemaSiteLogo')); if ('' !== $logo['url']) { $logoNode = [ '@type' => 'ImageObject', '@id' => home_url('/') . '#logo', 'url' => $logo['url'], 'contentUrl' => $logo['url'], ]; if ($logo['width'] && $logo['height']) { $logoNode['width'] = $logo['width']; $logoNode['height'] = $logo['height']; } if ('' !== $logo['alt']) { $logoNode['caption'] = $logo['alt']; } $node['logo'] = $logoNode; // Organization.image pointing at the logo is what Google's own examples // do, and some rich results read image rather than logo. $node['image'] = ['@id' => home_url('/') . '#logo']; } $profiles = $this->socialProfiles(); if (!empty($profiles)) { $node['sameAs'] = $profiles; } return $node; } /** * sameAs is how an answer engine confirms that the Organization on this site * is the same entity as the one it already knows from elsewhere, so it is * worth more than its size suggests. */ protected function socialProfiles() { $profiles = []; foreach (['socialFacebook', 'socialTwitterSite', 'socialInstagram', 'socialLinkedin', 'socialYoutube', 'socialPinterest'] as $key) { $value = Utils::getSetting($key); if (!is_string($value) || '' === trim($value)) { continue; } $value = trim($value); // The X handle field accepts a bare handle; everything else is a URL. if ('socialTwitterSite' === $key && false === strpos($value, '://')) { $value = 'https://x.com/' . ltrim($value, '@'); } if (false !== strpos($value, '://')) { $profiles[] = esc_url_raw($value); } } return array_values(array_unique(array_filter($profiles))); } protected function websiteNode($publisher) { $node = [ '@type' => 'WebSite', '@id' => self::websiteId(), 'url' => home_url('/'), 'name' => get_bloginfo('name'), 'inLanguage' => get_bloginfo('language'), ]; $description = get_bloginfo('description'); if ('' !== $description) { $node['description'] = $description; } if ($publisher) { $node['publisher'] = ['@id' => self::publisherId()]; } // The sitelinks search box. Only advertised when the site actually has // search enabled, since claiming a SearchAction that 404s is worse than // omitting it. if ('off' !== Utils::getSetting('schemaSitelinksSearch')) { $node['potentialAction'] = [ [ '@type' => 'SearchAction', 'target' => [ '@type' => 'EntryPoint', 'urlTemplate' => home_url('/') . '?s={search_term_string}', ], 'query-input' => 'required name=search_term_string', ], ]; } return $node; } protected function webPageNode(Context $context, $publisher) { $url = self::currentUrl($context); if ('' === $url) { return null; } $node = [ '@type' => $this->webPageType($context), '@id' => self::webPageId($context), 'url' => $url, 'name' => Resolver::title($context), 'isPartOf' => ['@id' => self::websiteId()], 'inLanguage' => get_bloginfo('language'), ]; $description = Resolver::description($context); if ('' !== $description) { $node['description'] = $description; } if ($context->hasPost()) { $node['datePublished'] = get_post_time('c', true, $context->object); $node['dateModified'] = get_post_modified_time('c', true, $context->object); } $image = Resolver::image($context); if ('' !== $image['url']) { $node['primaryImageOfPage'] = ['@id' => self::currentUrl($context) . '#primaryimage']; $node['image'] = ['@id' => self::currentUrl($context) . '#primaryimage']; } if (Context::TYPE_HOME === $context->type && $publisher) { $node['about'] = ['@id' => self::publisherId()]; } $trail = BreadcrumbTrail::build($context); if (count($trail) > 1) { $node['breadcrumb'] = ['@id' => self::currentUrl($context) . '#breadcrumb']; } return $node; } /** * The more specific WebPage subtype, where one applies. Search engines treat * CollectionPage and SearchResultsPage as meaningful signals about what the * URL is for. */ protected function webPageType(Context $context) { // A per-page override beats anything derived from the post type: no post // type default can know that one particular page is the contact page. if ($context->hasPost()) { $override = SchemaTypes::pageType($context->objectId); if ('' !== $override) { return $override; } } switch ($context->type) { case Context::TYPE_SEARCH: return 'SearchResultsPage'; case Context::TYPE_TAXONOMY: case Context::TYPE_POST_TYPE_ARCHIVE: case Context::TYPE_DATE: return 'CollectionPage'; case Context::TYPE_AUTHOR: return 'ProfilePage'; case Context::TYPE_SINGULAR: return 'page' === $context->postType ? 'WebPage' : 'ItemPage'; default: return 'WebPage'; } } protected function primaryImageNode(Context $context) { $image = Resolver::image($context); if ('' === $image['url']) { return null; } $node = [ '@type' => 'ImageObject', '@id' => self::currentUrl($context) . '#primaryimage', 'url' => $image['url'], 'contentUrl' => $image['url'], ]; if ($image['width'] && $image['height']) { $node['width'] = $image['width']; $node['height'] = $image['height']; } if ('' !== $image['alt']) { $node['caption'] = $image['alt']; } return $node; } protected function breadcrumbNode(Context $context) { $trail = BreadcrumbTrail::build($context); // A single crumb is just the home page pointing at itself, which adds // nothing and trips Google's "single-item breadcrumb" warning. if (count($trail) < 2) { return null; } $items = []; $position = 1; foreach ($trail as $crumb) { $item = [ '@type' => 'ListItem', 'position' => $position, 'name' => $crumb['name'], ]; // The final crumb omits item: it is the current page, and Google's // documentation explicitly says the last element should not carry one. if ('' !== $crumb['url']) { $item['item'] = $crumb['url']; } $items[] = $item; $position++; } return [ '@type' => 'BreadcrumbList', '@id' => self::currentUrl($context) . '#breadcrumb', 'itemListElement' => $items, ]; } protected function authorNode($userId) { $userId = (int) $userId; if (!$userId) { return null; } $name = get_the_author_meta('display_name', $userId); if ('' === $name) { return null; } $node = [ '@type' => 'Person', '@id' => self::authorId($userId), 'name' => $name, ]; $description = get_the_author_meta('description', $userId); if ('' !== $description) { $node['description'] = $description; } $url = get_author_posts_url($userId); if ($url) { $node['url'] = $url; } // Gravatar is a real, resolvable image and is what most sites have. Emitted // as a nested ImageObject so it can carry dimensions. $avatar = get_avatar_url($userId, ['size' => 512]); if ($avatar) { $node['image'] = [ '@type' => 'ImageObject', '@id' => self::authorId($userId) . '/image', 'url' => $avatar, 'contentUrl' => $avatar, ]; } return $node; } protected function articleNode(Context $context, $publisher, $author) { $post = $context->object; if (!($post instanceof \WP_Post)) { return null; } $node = [ '@type' => $this->articleType($context), '@id' => self::currentUrl($context) . '#article', 'isPartOf' => ['@id' => self::webPageId($context)], 'mainEntityOfPage' => ['@id' => self::webPageId($context)], 'headline' => $this->headline($context), 'datePublished' => get_post_time('c', true, $post), 'dateModified' => get_post_modified_time('c', true, $post), 'inLanguage' => get_bloginfo('language'), ]; $description = Resolver::description($context); if ('' !== $description) { $node['description'] = $description; } if ($author) { $node['author'] = ['@id' => self::authorId((int) $post->post_author)]; } if ($publisher) { $node['publisher'] = ['@id' => self::publisherId()]; } $image = Resolver::image($context); if ('' !== $image['url']) { $node['image'] = ['@id' => self::currentUrl($context) . '#primaryimage']; $node['thumbnailUrl'] = $image['url']; } $sections = get_the_terms($post, 'category'); if (!empty($sections) && !is_wp_error($sections)) { $node['articleSection'] = array_values(wp_list_pluck($sections, 'name')); } $keywords = get_the_terms($post, 'post_tag'); if (!empty($keywords) && !is_wp_error($keywords)) { $node['keywords'] = array_values(wp_list_pluck($keywords, 'name')); } $wordCount = $this->wordCount($post); if ($wordCount) { $node['wordCount'] = $wordCount; } if (post_type_supports($post->post_type, 'comments') && (int) $post->comment_count > 0) { $node['commentCount'] = (int) $post->comment_count; } return $node; } protected function articleType(Context $context) { if ($context->hasPost()) { $override = SchemaTypes::articleType($context->objectId); if ('' !== $override) { return $override; } } $configured = Utils::getSetting('titles[' . $context->settingsKey . '][articleType]'); $allowed = ['Article', 'BlogPosting', 'NewsArticle', 'TechArticle', 'Report', 'ScholarlyArticle']; if (is_string($configured) && in_array($configured, $allowed, true)) { return $configured; } return 'BlogPosting'; } /** * Google truncates headline at 110 characters and treats longer ones as * invalid for rich results, so it is capped here rather than at display time. */ protected function headline(Context $context) { $headline = $context->hasPost() ? html_entity_decode(get_the_title($context->object), ENT_QUOTES, 'UTF-8') : Resolver::title($context); $headline = wp_strip_all_tags($headline); if (mb_strlen($headline) > 110) { $headline = rtrim(mb_substr($headline, 0, 109)) . '…'; } return $headline; } protected function wordCount(\WP_Post $post) { $content = strip_shortcodes($post->post_content); $content = excerpt_remove_blocks($content); $content = wp_strip_all_tags($content); if ('' === trim($content)) { return 0; } // str_word_count is Latin-only and undercounts CJK badly; splitting on // whitespace is cruder but wrong in fewer languages. return count(preg_split('/\s+/u', trim($content), -1, PREG_SPLIT_NO_EMPTY)); } protected function shouldOutput() { if ('on' !== Utils::getSetting('schemaMarkup')) { return false; } if (is_feed() || is_admin() || is_embed()) { return false; } if (defined('REST_REQUEST') && REST_REQUEST) { return false; } // Noindexed pages get no structured data — it would describe a page we are // asking not to be listed. $robots = Resolver::robots(Context::current()); if (isset($robots['index']) && 'noindex' === $robots['index']) { return false; } return (bool) apply_filters('rankify_output_schema', true); } }