Per-request memo, keyed by value name. */ protected static $memo = []; public static function flush() { self::$memo = []; } /** * Built-in templates, used when the user has not configured one. * * The singular default deliberately reproduces what WordPress core already * outputs, so activating the plugin on a configured site changes nothing * until someone asks it to. */ public static function defaultTitleTemplate(Context $context) { switch ($context->type) { case Context::TYPE_HOME: return '%%sitename%% %%sep%% %%sitedesc%%'; case Context::TYPE_TAXONOMY: return '%%term_title%% %%sep%% %%sitename%%'; case Context::TYPE_AUTHOR: return '%%author%% %%sep%% %%sitename%%'; case Context::TYPE_DATE: return '%%date%% %%sep%% %%sitename%%'; case Context::TYPE_SEARCH: /* translators: %%searchphrase%% is a template variable, do not translate. */ return __('Search results for %%searchphrase%%', 'crownify') . ' %%sep%% %%sitename%%'; case Context::TYPE_NOT_FOUND: return __('Page not found', 'crownify') . ' %%sep%% %%sitename%%'; case Context::TYPE_POST_TYPE_ARCHIVE: return '%%pt_plural%% %%sep%% %%sitename%%'; default: return '%%title%% %%sep%% %%sitename%%'; } } public static function defaultDescriptionTemplate(Context $context) { switch ($context->type) { case Context::TYPE_HOME: return '%%sitedesc%%'; case Context::TYPE_TAXONOMY: return '%%term_description%%'; case Context::TYPE_AUTHOR: return '%%author_description%%'; case Context::TYPE_SINGULAR: return '%%excerpt%%'; default: return ''; } } /** * Read a configured template for this context, falling back to the built-in. * * Templates live under titles[][] so every post type and * taxonomy is independently configurable. The legacy flat keys are still * honoured because the scaffold shipped with them and early installs will * have them stored. */ public static function template(Context $context, $field) { $configured = Utils::getSetting('titles[' . $context->settingsKey . '][' . $field . ']'); if (is_string($configured) && '' !== trim($configured)) { return $configured; } // The single global template on the General tab is the default for // *singular* views only. Applying it to the front page too would render a // static home page's title as "Home - Site", which is what its %%title%% // resolves to and is a poor SEO title — the home page gets the site name // and tagline instead, as every competitor does. if (Context::TYPE_SINGULAR === $context->type) { $legacyKey = 'title' === $field ? 'metaTitleTemplate' : 'metaDescriptionTemplate'; $legacy = Utils::getSetting($legacyKey); if (is_string($legacy) && '' !== trim($legacy)) { return $legacy; } } return 'title' === $field ? self::defaultTitleTemplate($context) : self::defaultDescriptionTemplate($context); } public static function title(?Context $context = null) { $context = $context ?: Context::current(); if (!isset(self::$memo['title'])) { $override = self::override($context, 'title'); $title = '' !== $override ? Variables::resolve($override, $context) : Variables::resolve(self::template($context, 'title'), $context); // A template that resolves to nothing would emit an empty , which // is worse than anything else we could print, so fall back to the object // title and then to the site name. if ('' === $title) { $title = Variables::resolve('%%title%% %%sep%% %%sitename%%', $context); } if ('' === $title) { $title = get_bloginfo('name'); } // Paginated views get the page number appended unless the template // already places it, so page 2 is not a duplicate title of page 1. if ($context->isPaged() && false === strpos(self::template($context, 'title'), '%%page%%') && '' === $override) { $title .= ' ' . Variables::separator() . ' ' . Variables::varPage($context); } self::$memo['title'] = apply_filters('rankify_meta_title', $title, $context); } return self::$memo['title']; } public static function description(?Context $context = null) { $context = $context ?: Context::current(); if (!isset(self::$memo['description'])) { $override = self::override($context, 'description'); $description = '' !== $override ? Variables::resolve($override, $context) : Variables::resolve(self::template($context, 'description'), $context); self::$memo['description'] = apply_filters('rankify_meta_description', $description, $context); } return self::$memo['description']; } /** * The canonical URL for this view. * * Paginated archives canonicalise to themselves, not to page 1 — Google has * been explicit that pointing page 2 at page 1 hides its content, and the * rel=prev/next alternative was retired. */ public static function canonical(?Context $context = null) { $context = $context ?: Context::current(); if (!isset(self::$memo['canonical'])) { $override = self::override($context, 'canonical'); $canonical = '' !== $override ? $override : self::deriveCanonical($context); self::$memo['canonical'] = apply_filters('rankify_meta_canonical', $canonical, $context); } return self::$memo['canonical']; } protected static function deriveCanonical(Context $context) { $url = ''; switch ($context->type) { case Context::TYPE_HOME: $url = home_url('/'); break; case Context::TYPE_SINGULAR: $url = $context->objectId ? get_permalink($context->objectId) : ''; break; case Context::TYPE_TAXONOMY: $link = $context->objectId ? get_term_link($context->object) : ''; $url = is_wp_error($link) ? '' : $link; break; case Context::TYPE_AUTHOR: $url = $context->objectId ? get_author_posts_url($context->objectId) : ''; break; case Context::TYPE_POST_TYPE_ARCHIVE: $link = $context->postType ? get_post_type_archive_link($context->postType) : ''; $url = $link ?: ''; break; case Context::TYPE_DATE: $url = self::dateArchiveUrl(); break; // Search and 404 pages are noindex, and a canonical on an infinite URL // space would be meaningless. Emit nothing. case Context::TYPE_SEARCH: case Context::TYPE_NOT_FOUND: default: return ''; } if ('' === $url) { return ''; } if ($context->isPaged()) { $url = self::paginate($url, $context); } return $url; } protected static function dateArchiveUrl() { $year = (int) get_query_var('year'); $month = (int) get_query_var('monthnum'); $day = (int) get_query_var('day'); if ($year && $month && $day) { return get_day_link($year, $month, $day); } if ($year && $month) { return get_month_link($year, $month); } if ($year) { return get_year_link($year); } return ''; } /** * Append the page number the way permalinks are actually structured, which * differs between a multi-page post (/slug/2/) and an archive (/base/page/2/) * and falls back to a query arg when pretty permalinks are off. */ protected static function paginate($url, Context $context) { if (!get_option('permalink_structure')) { $arg = Context::TYPE_SINGULAR === $context->type ? 'page' : 'paged'; return add_query_arg($arg, $context->page, $url); } if (Context::TYPE_SINGULAR === $context->type) { return trailingslashit($url) . $context->page . '/'; } global $wp_rewrite; $base = $wp_rewrite instanceof \WP_Rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page'; return trailingslashit($url) . $base . '/' . $context->page . '/'; } /** * Robots directives as a name => value map, ready to be joined. * * Core's "Discourage search engines" option wins over everything. It is a * site-wide switch the user set deliberately, and quietly overriding it with * a plugin default would be the wrong kind of surprise. */ public static function robots(?Context $context = null) { $context = $context ?: Context::current(); if (!isset(self::$memo['robots'])) { $directives = [ 'index' => 'index', 'follow' => 'follow', 'max-snippet' => 'max-snippet:-1', 'max-image-preview' => 'max-image-preview:large', 'max-video-preview' => 'max-video-preview:-1', ]; if (!self::isPublicView($context)) { $directives['index'] = 'noindex'; } $configured = Utils::getSetting('titles[' . $context->settingsKey . '][noindex]'); if ('on' === $configured) { $directives['index'] = 'noindex'; } $configuredFollow = Utils::getSetting('titles[' . $context->settingsKey . '][nofollow]'); if ('on' === $configuredFollow) { $directives['follow'] = 'nofollow'; } foreach (['noindex' => 'index', 'nofollow' => 'follow'] as $flag => $slot) { if ('on' === self::override($context, $flag)) { $directives[$slot] = $flag; } } foreach (['noarchive', 'nosnippet', 'noimageindex'] as $flag) { if ('on' === self::override($context, $flag) || 'on' === Utils::getSetting('titles[' . $context->settingsKey . '][' . $flag . ']')) { $directives[$flag] = $flag; } } // Directives the site's previous SEO plugin set. Safe to apply after the // rules above because every one of them only ever *adds* a restriction — // there is no "force index" anywhere, so nothing here can override a // decision Crownify already made. Encodings differ per plugin and are // decoded in LegacyMeta, where the Yoast "2 means index" trap lives. if ($context->hasPost()) { foreach (LegacyMeta::robots((int) $context->objectId) as $flag) { if ('noindex' === $flag) { $directives['index'] = 'noindex'; } elseif ('nofollow' === $flag) { $directives['follow'] = 'nofollow'; } else { $directives[$flag] = $flag; } } } // noindex makes the preview-size directives meaningless, and emitting // both reads as a contradiction to anyone auditing the page. if ('noindex' === $directives['index']) { unset($directives['max-snippet'], $directives['max-image-preview'], $directives['max-video-preview']); } if (0 === (int) get_option('blog_public')) { $directives['index'] = 'noindex'; $directives['follow'] = 'nofollow'; unset($directives['max-snippet'], $directives['max-image-preview'], $directives['max-video-preview']); } self::$memo['robots'] = apply_filters('rankify_meta_robots', $directives, $context); } return self::$memo['robots']; } /** * Views that should never be indexed regardless of configuration: search * results are an infinite URL space, 404s are not content, and a paginated * or password-protected item has nothing unique to offer. */ protected static function isPublicView(Context $context) { if (Context::TYPE_SEARCH === $context->type || Context::TYPE_NOT_FOUND === $context->type) { return false; } if ($context->hasPost()) { if ('' !== $context->object->post_password) { return false; } if ('publish' !== $context->object->post_status && !is_preview()) { return false; } $postTypeObject = get_post_type_object($context->object->post_type); if ($postTypeObject && empty($postTypeObject->public)) { return false; } } return true; } /** * The image representing this view, as an attachment-backed URL where * possible so dimensions can be emitted alongside it. * * @return array{url:string,width:int,height:int,alt:string,type:string} */ public static function image(?Context $context = null) { $context = $context ?: Context::current(); if (!isset(self::$memo['image'])) { $image = self::deriveImage($context); self::$memo['image'] = apply_filters('rankify_meta_image', $image, $context); } return self::$memo['image']; } protected static function deriveImage(Context $context) { $empty = ['url' => '', 'width' => 0, 'height' => 0, 'alt' => '', 'type' => '']; $override = self::override($context, 'image'); if ('' !== $override) { $resolved = self::attachmentIdFromSetting($override); if (is_array($resolved)) { return array_merge($empty, ['url' => $resolved['url']]); } $attachmentId = $resolved; } else { $attachmentId = 0; } if (!$attachmentId && $context->hasPost() && has_post_thumbnail($context->objectId)) { $attachmentId = (int) get_post_thumbnail_id($context->objectId); } if (!$attachmentId) { $fallback = self::attachmentIdFromSetting(Utils::getSetting('socialFallbackImage')); if (is_array($fallback)) { return array_merge($empty, ['url' => $fallback['url']]); } $attachmentId = $fallback; } if (!$attachmentId) { return $empty; } return self::describeAttachment($attachmentId); } /** * Normalize an image setting to an attachment ID. * * The media picker component stores an attachment ID, which is what every * sibling plugin does and what survives a domain change. A URL is still * accepted because importers write those, and an external URL that resolves * to no attachment is returned as-is so it can still be emitted — just * without dimensions. * * @return int|array<string,string> An attachment ID, or a ['url' => …] array * for something not in the media library. */ public static function attachmentIdFromSetting($value) { if (is_numeric($value) && (int) $value > 0) { return (int) $value; } if (!is_string($value) || '' === trim($value)) { return 0; } $value = trim($value); $attachmentId = attachment_url_to_postid($value); return $attachmentId ? (int) $attachmentId : ['url' => $value]; } /** * Resolve an image setting straight to a describeAttachment() shape. */ public static function describeImageSetting($value) { $resolved = self::attachmentIdFromSetting($value); if (is_array($resolved)) { return ['url' => $resolved['url'], 'width' => 0, 'height' => 0, 'alt' => '', 'type' => '']; } return $resolved ? self::describeAttachment($resolved) : ['url' => '', 'width' => 0, 'height' => 0, 'alt' => '', 'type' => '']; } public static function describeAttachment($attachmentId) { $empty = ['url' => '', 'width' => 0, 'height' => 0, 'alt' => '', 'type' => '']; $attachmentId = (int) $attachmentId; // 'full' rather than a named crop: social networks re-crop anyway, and the // intermediate sizes a theme registers are frequently below the 200px // minimum that Facebook and X require. $source = wp_get_attachment_image_src($attachmentId, 'full'); if (!$source || empty($source[0])) { return $empty; } return [ 'url' => $source[0], 'width' => (int) $source[1], 'height' => (int) $source[2], 'alt' => (string) get_post_meta($attachmentId, '_wp_attachment_image_alt', true), 'type' => (string) get_post_mime_type($attachmentId), ]; } /** * Read a per-object override from post meta, term meta or user meta. * * Returns '' rather than false for a missing value so callers can compare * with '' consistently regardless of which storage backed it. */ public static function override(Context $context, $field) { $key = self::META_PREFIX . $field; $value = ''; if ($context->hasPost()) { $value = get_post_meta($context->objectId, $key, true); } elseif ($context->hasTerm()) { $value = get_term_meta($context->objectId, $key, true); } elseif ($context->hasUser()) { $value = get_user_meta($context->objectId, $key, true); } $value = is_string($value) ? trim($value) : ''; if ('' !== $value) { return $value; } // Nothing of Crownify's own, so fall back to whatever SEO plugin the site // used before. Crownify's value always wins when there is one, so editing a // field here permanently overrides the old plugin with no migration step — // and nothing is ever written back, so the old data stays untouched. return LegacyMeta::get($context, $field); } }