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);
}
}