|null Built once per request. */ protected static $registry = null; /** * The variables the UI advertises, grouped for the picker. * * Kept next to the implementations so a variable can never appear in the * picker without something behind it — the editor builds its autocomplete * from this, so anything missing here is simply untypeable. * * `label` is what the chip reads in the editor; `description` is the hint in * the dropdown. Labels are deliberately short — a chip has to fit inline. */ public static function catalog() { return [ 'general' => [ 'label' => __('General', 'crownify'), 'variables' => [ 'title' => ['label' => __('Title', 'crownify'), 'description' => __('Title of the post, term or archive', 'crownify')], 'sitename' => ['label' => __('Site Name', 'crownify'), 'description' => __('Site title', 'crownify')], 'sitedesc' => ['label' => __('Tagline', 'crownify'), 'description' => __('Site tagline', 'crownify')], 'sep' => ['label' => __('Separator', 'crownify'), 'description' => __('Title separator', 'crownify')], 'currentyear' => ['label' => __('Year', 'crownify'), 'description' => __('Current year', 'crownify')], 'currentmonth' => ['label' => __('Month', 'crownify'), 'description' => __('Current month', 'crownify')], 'currentdate' => ['label' => __('Today', 'crownify'), 'description' => __('Current date', 'crownify')], 'page' => ['label' => __('Page', 'crownify'), 'description' => __('Page number, on paginated views', 'crownify')], 'sitelanguage' => ['label' => __('Language', 'crownify'), 'description' => __('Site language code', 'crownify')], ], ], 'post' => [ 'label' => __('Posts', 'crownify'), 'variables' => [ 'excerpt' => ['label' => __('Excerpt', 'crownify'), 'description' => __('Post excerpt, generated from content when empty', 'crownify')], 'excerpt_only' => ['label' => __('Excerpt Only', 'crownify'), 'description' => __('Post excerpt, blank when not set manually', 'crownify')], 'date' => ['label' => __('Date', 'crownify'), 'description' => __('Publish date', 'crownify')], 'modified' => ['label' => __('Modified', 'crownify'), 'description' => __('Last modified date', 'crownify')], 'author' => ['label' => __('Author', 'crownify'), 'description' => __('Author display name', 'crownify')], 'author_description' => ['label' => __('Author Bio', 'crownify'), 'description' => __('Author biography', 'crownify')], 'category' => ['label' => __('Category', 'crownify'), 'description' => __('First category, or all categories', 'crownify')], 'tag' => ['label' => __('Tag', 'crownify'), 'description' => __('First tag, or all tags', 'crownify')], 'id' => ['label' => __('ID', 'crownify'), 'description' => __('Post ID', 'crownify')], 'parent_title' => ['label' => __('Parent Title', 'crownify'), 'description' => __('Title of the parent page', 'crownify')], 'pt_single' => ['label' => __('Type', 'crownify'), 'description' => __('Post type singular name', 'crownify')], 'pt_plural' => ['label' => __('Type Plural', 'crownify'), 'description' => __('Post type plural name', 'crownify')], ], ], 'archive' => [ 'label' => __('Archives', 'crownify'), 'variables' => [ 'term_title' => ['label' => __('Term', 'crownify'), 'description' => __('Term name', 'crownify')], 'term_description' => ['label' => __('Term Description', 'crownify'), 'description' => __('Term description', 'crownify')], 'searchphrase' => ['label' => __('Search Phrase', 'crownify'), 'description' => __('What the visitor searched for', 'crownify')], 'archive_title' => ['label' => __('Archive Title', 'crownify'), 'description' => __('Archive title as WordPress renders it', 'crownify')], ], ], ]; } /** * Realistic stand-in values so the editor can render a live preview without a * server round-trip on every keystroke. * * Drawn from the newest published post where there is one, because a preview * built from the user's actual content tells them something a lorem-ipsum * preview cannot — most obviously whether their template overruns. */ public static function sampleValues() { $values = [ 'title' => __('Your Post Title Goes Here', 'crownify'), 'sitename' => self::decode(get_bloginfo('name')), 'sitedesc' => self::decode(get_bloginfo('description')), 'sep' => self::separator(), 'currentyear' => gmdate('Y'), 'currentmonth' => date_i18n('F'), 'currentdate' => date_i18n(get_option('date_format')), 'page' => '', 'sitelanguage' => get_bloginfo('language'), 'excerpt' => __('A short summary of the page, taken from the excerpt or the opening of the content.', 'crownify'), 'excerpt_only' => '', 'date' => date_i18n(get_option('date_format')), 'modified' => date_i18n(get_option('date_format')), 'author' => __('Author Name', 'crownify'), 'author_description' => __('A short biography of the author.', 'crownify'), 'category' => __('Category', 'crownify'), 'tag' => __('Tag', 'crownify'), 'id' => '1', 'parent_title' => __('Parent Page', 'crownify'), 'pt_single' => __('Post', 'crownify'), 'pt_plural' => __('Posts', 'crownify'), 'term_title' => __('Term Name', 'crownify'), 'term_description' => __('A short description of this term.', 'crownify'), 'searchphrase' => __('search term', 'crownify'), 'archive_title' => __('Archive', 'crownify'), ]; $recent = get_posts(['numberposts' => 1, 'post_status' => 'publish', 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => false]); if (!empty($recent)) { $post = $recent[0]; $context = new Context(); $context->type = Context::TYPE_SINGULAR; $context->object = $post; $context->objectId = (int) $post->ID; $context->postType = $post->post_type; $context->settingsKey = $post->post_type; $values['title'] = self::varTitle($context); $values['id'] = (string) $post->ID; $values['date'] = get_the_date('', $post); $values['modified'] = get_the_modified_date('', $post); $values['author'] = self::varAuthor($context); $values['pt_single'] = self::varPostTypeSingular($context) ?: $values['pt_single']; $values['pt_plural'] = self::varPostTypePlural($context) ?: $values['pt_plural']; $excerpt = self::varExcerpt($context); if ('' !== $excerpt) { $values['excerpt'] = $excerpt; } $category = self::varCategory($context); if ('' !== $category) { $values['category'] = $category; } $tag = self::varTag($context); if ('' !== $tag) { $values['tag'] = $tag; } } return $values; } /** * Replace every %%variable%% in $template using $context. * * Unknown variables are stripped rather than left visible — a template * carried over from another plugin's importer will contain variables we do * not implement, and "%%wc_brand%%" rendering literally into a page title is * worse than it rendering as nothing. */ public static function resolve($template, ?Context $context = null) { if (!is_string($template) || '' === $template || false === strpos($template, '%%')) { return is_string($template) ? trim($template) : ''; } $context = $context ?: Context::current(); $registry = self::registry(); $resolved = preg_replace_callback( '/%%([a-z0-9_]+)%%/i', function ($matches) use ($registry, $context) { $name = strtolower($matches[1]); if (!isset($registry[$name])) { /** * Filter an unknown variable so add-ons can supply their own without * touching the registry. Returning null keeps the strip behaviour. */ $custom = apply_filters('rankify_meta_variable', null, $name, $context); return null === $custom ? '' : (string) $custom; } return (string) call_user_func($registry[$name], $context); }, $template, ); return self::tidy($resolved); } /** * Collapse the whitespace and dangling separators that stripping an empty * variable leaves behind, so "%%title%% %%sep%% %%sitename%%" on a site with * no title does not render as " - Site". */ protected static function tidy($value) { $separator = preg_quote(self::separator(), '/'); $value = preg_replace('/\s+/u', ' ', (string) $value); $value = preg_replace('/^(?:\s*' . $separator . '\s*)+/u', '', $value); $value = preg_replace('/(?:\s*' . $separator . '\s*)+$/u', '', $value); // Two separators with nothing between them collapse to one. $value = preg_replace('/(\s*' . $separator . '\s*){2,}/u', ' ' . self::separator() . ' ', $value); return trim($value); } public static function separator() { $stored = Utils::getSetting('metaTitleSeparator'); $separator = is_string($stored) && '' !== $stored ? $stored : '-'; return apply_filters('rankify_meta_separator', $separator); } protected static function registry() { if (null !== self::$registry) { return self::$registry; } self::$registry = [ 'title' => [self::class, 'varTitle'], 'sitename' => [self::class, 'varSiteName'], 'sitedesc' => [self::class, 'varSiteDescription'], 'sep' => [self::class, 'varSeparator'], 'currentyear' => [self::class, 'varCurrentYear'], 'currentmonth' => [self::class, 'varCurrentMonth'], 'currentdate' => [self::class, 'varCurrentDate'], 'page' => [self::class, 'varPage'], 'sitelanguage' => [self::class, 'varSiteLanguage'], 'excerpt' => [self::class, 'varExcerpt'], 'excerpt_only' => [self::class, 'varExcerptOnly'], 'date' => [self::class, 'varDate'], 'modified' => [self::class, 'varModified'], 'author' => [self::class, 'varAuthor'], 'author_description' => [self::class, 'varAuthorDescription'], 'category' => [self::class, 'varCategory'], 'tag' => [self::class, 'varTag'], 'id' => [self::class, 'varId'], 'parent_title' => [self::class, 'varParentTitle'], 'pt_single' => [self::class, 'varPostTypeSingular'], 'pt_plural' => [self::class, 'varPostTypePlural'], 'term_title' => [self::class, 'varTermTitle'], 'term_description' => [self::class, 'varTermDescription'], 'searchphrase' => [self::class, 'varSearchPhrase'], 'archive_title' => [self::class, 'varArchiveTitle'], ]; return self::$registry; } // ──────────────────────────────────────────────────────────── // Variable implementations // ──────────────────────────────────────────────────────────── /** * The one variable that means something different in every context: the post * title on a single, the term name on an archive, the author's name on an * author archive. */ public static function varTitle(Context $context) { if ($context->hasPost()) { return self::decode(get_the_title($context->object)); } if ($context->hasTerm()) { return self::decode($context->object->name); } if ($context->hasUser()) { return self::decode($context->object->display_name); } switch ($context->type) { case Context::TYPE_HOME: return self::decode(get_bloginfo('name')); case Context::TYPE_SEARCH: return self::varSearchPhrase($context); case Context::TYPE_NOT_FOUND: return __('Page not found', 'crownify'); default: return self::varArchiveTitle($context); } } public static function varSiteName(Context $context) { return self::decode(get_bloginfo('name')); } public static function varSiteDescription(Context $context) { return self::decode(get_bloginfo('description')); } public static function varSeparator(Context $context) { return self::separator(); } public static function varCurrentYear(Context $context) { return gmdate('Y'); } public static function varCurrentMonth(Context $context) { return date_i18n('F'); } public static function varCurrentDate(Context $context) { return date_i18n(get_option('date_format')); } /** * Renders as "Page 2" only when there is a page 2 to be on, so the variable * can sit in a template unconditionally without adding noise to page 1. */ public static function varPage(Context $context) { if (!$context->isPaged()) { return ''; } /* translators: %d: page number. */ return sprintf(__('Page %d', 'crownify'), $context->page); } public static function varSiteLanguage(Context $context) { return get_bloginfo('language'); } /** * Falls back to content because most posts never get a manual excerpt, and a * meta description template of %%excerpt%% resolving to nothing on 90% of a * site is the single most common misconfiguration in this feature. */ public static function varExcerpt(Context $context) { if (!$context->hasPost()) { return ''; } $manual = self::varExcerptOnly($context); if ('' !== $manual) { return $manual; } return self::summarize($context->object->post_content); } public static function varExcerptOnly(Context $context) { if (!$context->hasPost()) { return ''; } return self::decode(trim((string) $context->object->post_excerpt)); } public static function varDate(Context $context) { if ($context->hasPost()) { return get_the_date('', $context->object); } if (Context::TYPE_DATE === $context->type) { return self::varArchiveTitle($context); } return ''; } public static function varModified(Context $context) { return $context->hasPost() ? get_the_modified_date('', $context->object) : ''; } public static function varAuthor(Context $context) { if ($context->hasUser()) { return self::decode($context->object->display_name); } if ($context->hasPost()) { return self::decode(get_the_author_meta('display_name', (int) $context->object->post_author)); } return ''; } public static function varAuthorDescription(Context $context) { if ($context->hasUser()) { return self::decode(get_the_author_meta('description', $context->objectId)); } if ($context->hasPost()) { return self::decode(get_the_author_meta('description', (int) $context->object->post_author)); } return ''; } public static function varCategory(Context $context) { return self::termList($context, 'category'); } public static function varTag(Context $context) { return self::termList($context, 'post_tag'); } public static function varId(Context $context) { return $context->objectId ? (string) $context->objectId : ''; } public static function varParentTitle(Context $context) { if (!$context->hasPost() || empty($context->object->post_parent)) { return ''; } return self::decode(get_the_title((int) $context->object->post_parent)); } public static function varPostTypeSingular(Context $context) { $object = $context->postType ? get_post_type_object($context->postType) : null; return $object ? self::decode($object->labels->singular_name) : ''; } public static function varPostTypePlural(Context $context) { $object = $context->postType ? get_post_type_object($context->postType) : null; return $object ? self::decode($object->labels->name) : ''; } public static function varTermTitle(Context $context) { return $context->hasTerm() ? self::decode($context->object->name) : ''; } public static function varTermDescription(Context $context) { if (!$context->hasTerm()) { return ''; } return self::summarize($context->object->description); } public static function varSearchPhrase(Context $context) { $search = get_search_query(); return $search ? self::decode($search) : ''; } /** * WordPress prefixes archive titles ("Category: News"). The prefix is * stripped because a meta title reading "Category: News - Site" looks like a * database field leaked into the SERP. */ public static function varArchiveTitle(Context $context) { $title = wp_strip_all_tags(get_the_archive_title()); return self::decode(preg_replace('/^[^:]{1,20}:\s*/u', '', $title)); } // ──────────────────────────────────────────────────────────── // Shared helpers // ──────────────────────────────────────────────────────────── protected static function termList(Context $context, $taxonomy) { if ($context->hasTerm() && $context->object->taxonomy === $taxonomy) { return self::decode($context->object->name); } if (!$context->hasPost()) { return ''; } $terms = get_the_terms($context->object, $taxonomy); if (empty($terms) || is_wp_error($terms)) { return ''; } $names = wp_list_pluck($terms, 'name'); return self::decode(implode(', ', $names)); } /** * Turn body content into a description-length sentence. * * Shortcodes are stripped rather than executed: running them here would fire * arbitrary plugin code inside wp_head on every request, and some of them * echo, which would corrupt the document. */ protected static function summarize($content, $words = 40) { $content = (string) $content; if ('' === trim($content)) { return ''; } $content = strip_shortcodes($content); $content = excerpt_remove_blocks($content); // Restore paragraph breaks as spaces so words do not run together once the // tags come off. $content = str_replace(['

', '
', '
', '
'], ' ', $content); $content = wp_strip_all_tags($content); $content = self::decode($content); $content = preg_replace('/\s+/u', ' ', $content); return trim(wp_trim_words($content, $words, '')); } /** * Meta tag values are escaped at output. Any entity still encoded at this * point would be double-escaped there and render as "&". */ protected static function decode($value) { return trim(html_entity_decode((string) $value, ENT_QUOTES, get_bloginfo('charset') ?: 'UTF-8')); } }