the other plugins' keys, in priority order. * * Ordering is by likelihood of being the one the user actually maintained, * not alphabetical: Yoast and Rank Math hold the overwhelming majority of * installs. * * All-in-One SEO v4 is deliberately absent from the post-meta list — it moved * everything into its own table, which is handled separately below. */ const POST_KEYS = [ 'title' => ['_yoast_wpseo_title', 'rank_math_title', '_seopress_titles_title', '_aioseop_title'], 'description' => ['_yoast_wpseo_metadesc', 'rank_math_description', '_seopress_titles_desc', '_aioseop_description'], 'canonical' => ['_yoast_wpseo_canonical', 'rank_math_canonical_url', '_seopress_robots_canonical', '_aioseop_custom_link'], 'focus_keywords' => ['_yoast_wpseo_focuskw', 'rank_math_focus_keyword', '_seopress_analysis_target_kw', '_aioseop_keywords'], 'og_title' => ['_yoast_wpseo_opengraph-title', 'rank_math_facebook_title', '_seopress_social_fb_title'], 'og_description' => ['_yoast_wpseo_opengraph-description', 'rank_math_facebook_description', '_seopress_social_fb_desc'], 'image' => ['_yoast_wpseo_opengraph-image-id', 'rank_math_facebook_image_id', '_seopress_social_fb_img'], ]; /** * Term meta. Yoast is missing on purpose — it keeps term data in one * serialized option rather than term meta, and that is read separately. */ const TERM_KEYS = [ 'title' => ['rank_math_title', '_seopress_titles_title'], 'description' => ['rank_math_description', '_seopress_titles_desc'], 'canonical' => ['rank_math_canonical_url', '_seopress_robots_canonical'], ]; /** * Whether to consult other plugins at all. */ public static function enabled() { return 'off' !== \DaftPlug\Rankify\Helpers\Utils::getSetting('reuseExistingSeo'); } /** * Which other SEO plugins left data on this site. * * Purely informational — nothing depends on the answer, because the values * are read per-object on demand. One grouped query per source rather than a * scan, and the whole thing is cached for a day since the answer only * changes when someone installs or purges another plugin. * * @return array */ public static function detect() { $cached = get_transient('rankify_legacy_seo_sources'); if (is_array($cached)) { return $cached; } global $wpdb; $sources = [ 'yoast' => ['Yoast SEO', ['_yoast_wpseo_title', '_yoast_wpseo_metadesc', '_yoast_wpseo_focuskw']], 'rankmath' => ['Rank Math', ['rank_math_title', 'rank_math_description', 'rank_math_focus_keyword']], 'seopress' => ['SEOPress', ['_seopress_titles_title', '_seopress_titles_desc']], 'aioseo3' => ['All in One SEO (legacy)', ['_aioseop_title', '_aioseop_description']], ]; $found = []; foreach ($sources as $slug => $source) { [$label, $keys] = $source; $marks = implode(',', array_fill(0, count($keys), '%s')); $sql = 'SELECT COUNT(DISTINCT post_id) FROM %i WHERE meta_key IN (' . $marks . ") AND meta_value != ''"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- IN list length follows a fixed key list. The table uses %i and every value is bound. The result is stored in a transient. $count = (int) $wpdb->get_var($wpdb->prepare($sql, array_merge([$wpdb->postmeta], $keys))); if ($count > 0) { $found[] = ['slug' => $slug, 'label' => $label, 'posts' => $count]; } } $aioseo = self::aioseoTableCount(); if ($aioseo > 0) { $found[] = ['slug' => 'aioseo', 'label' => 'All in One SEO', 'posts' => $aioseo]; } set_transient('rankify_legacy_seo_sources', $found, DAY_IN_SECONDS); return $found; } protected static function aioseoTableCount() { global $wpdb; $table = $wpdb->prefix . 'aioseo_posts'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema check. The result is stored in a transient. if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) { return 0; } // AIOSEO writes a row for every post it has ever rendered, most of them // empty, so a bare COUNT(*) would claim thousands of pages with data. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Another plugin's table, read once and stored in a transient. return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE title != '' OR description != '' OR canonical_url != ''", $table)); } /** * A value from another SEO plugin for this object, or ''. */ public static function get(Context $context, $field) { if (!self::enabled()) { return ''; } if ($context->hasPost()) { return self::forPost((int) $context->objectId, $field); } if ($context->hasTerm()) { return self::forTerm((int) $context->objectId, $context->taxonomy, $field); } return ''; } /** * Which other plugin, if any, is supplying this post's values. * * The editor needs to be able to say so out loud. A migrating user opens a * post, sees every field blank, and reasonably concludes nothing was picked * up — when in fact the blank field is the whole point and the old value is * already being served. Naming the source is the difference between that * looking broken and looking deliberate. * * @return string Display name, or '' when nothing of another plugin's is in use. */ public static function sourceForPost($postId) { if (!self::enabled()) { return ''; } $owners = [ '_yoast_wpseo_' => 'Yoast SEO', 'rank_math_' => 'Rank Math', '_seopress_' => 'SEOPress', '_aioseop_' => 'All in One SEO', ]; foreach (self::POST_KEYS as $keys) { foreach ($keys as $key) { $value = get_post_meta((int) $postId, $key, true); if (!is_string($value) || '' === trim($value)) { continue; } foreach ($owners as $prefix => $label) { if (0 === strpos($key, $prefix)) { return $label; } } } } return '' !== (string) self::fromAioseoTable((int) $postId, 'description') || '' !== (string) self::fromAioseoTable((int) $postId, 'title') ? 'All in One SEO' : ''; } public static function forPost($postId, $field) { if (isset(self::POST_KEYS[$field])) { foreach (self::POST_KEYS[$field] as $key) { $value = get_post_meta($postId, $key, true); if (is_string($value) && '' !== trim($value)) { return self::translate(trim($value), $key); } } } return self::fromAioseoTable($postId, $field); } public static function forTerm($termId, $taxonomy, $field) { if (isset(self::TERM_KEYS[$field])) { foreach (self::TERM_KEYS[$field] as $key) { $value = get_term_meta($termId, $key, true); if (is_string($value) && '' !== trim($value)) { return self::translate(trim($value), $key); } } } return self::fromYoastTaxonomyOption($termId, $taxonomy, $field); } /** * Robots directives another plugin set, as Crownify's flag names. * * Encoding differs sharply between plugins and getting it wrong is worse than * not reading it at all — a misread flag either hides a page the user * published or exposes one they hid. Each is decoded on its own terms. * * @return string[] */ public static function robots($postId) { if (!self::enabled()) { return []; } $flags = []; // Yoast: 1 = noindex, 2 = explicitly index, 0/absent = post type default. // Reading 2 as truthy would noindex every page marked indexable on purpose. if ('1' === (string) get_post_meta($postId, '_yoast_wpseo_meta-robots-noindex', true)) { $flags[] = 'noindex'; } if ('1' === (string) get_post_meta($postId, '_yoast_wpseo_meta-robots-nofollow', true)) { $flags[] = 'nofollow'; } $advanced = get_post_meta($postId, '_yoast_wpseo_meta-robots-adv', true); if (is_string($advanced) && '' !== $advanced) { foreach (explode(',', $advanced) as $entry) { $flags[] = trim($entry); } } // Rank Math: a serialized list of directive names. $rankMath = get_post_meta($postId, 'rank_math_robots', true); if (is_string($rankMath) && '' !== $rankMath) { $rankMath = maybe_unserialize($rankMath); } if (is_array($rankMath)) { foreach ($rankMath as $entry) { $flags[] = (string) $entry; } } // SEOPress inverts its own naming: `robots_index` set to 'yes' means // *no*index, and `robots_follow` set to 'yes' means *no*follow. if ('yes' === (string) get_post_meta($postId, '_seopress_robots_index', true)) { $flags[] = 'noindex'; } if ('yes' === (string) get_post_meta($postId, '_seopress_robots_follow', true)) { $flags[] = 'nofollow'; } $flags = array_merge($flags, self::aioseoRobots($postId)); return array_values(array_unique(array_intersect($flags, ['noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet']))); } // ──────────────────────────────────────────────────────────── // All in One SEO v4 — its own table, not post meta // ──────────────────────────────────────────────────────────── protected static function aioseoRow($postId) { static $cache = []; if (array_key_exists($postId, $cache)) { return $cache[$postId]; } global $wpdb; $table = $wpdb->prefix . 'aioseo_posts'; // A schema check per request, memoized: on the vast majority of sites the // table does not exist and this must not cost a query per post. static $exists = null; if (null === $exists) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema check, memoized for the request. $exists = (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); } if (!$exists) { $cache[$postId] = null; return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Another plugin's table. The row is memoized for the request. $cache[$postId] = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE post_id = %d LIMIT 1', $table, $postId), ARRAY_A); return $cache[$postId]; } protected static function fromAioseoTable($postId, $field) { $columns = [ 'title' => 'title', 'description' => 'description', 'canonical' => 'canonical_url', 'og_title' => 'og_title', 'og_description' => 'og_description', 'image' => 'og_image_custom_url', ]; if (!isset($columns[$field])) { return ''; } $row = self::aioseoRow($postId); if (!$row || empty($row[$columns[$field]])) { return ''; } return self::translate((string) $row[$columns[$field]], 'aioseo'); } protected static function aioseoRobots($postId) { $row = self::aioseoRow($postId); // `robots_default = 1` means "inherit the site setting" and leaves the // individual flags as stale leftovers, so they must not be read. if (!$row || !empty($row['robots_default'])) { return []; } $flags = []; foreach (['noindex', 'nofollow', 'noarchive'] as $directive) { if (!empty($row['robots_' . $directive])) { $flags[] = $directive; } } return $flags; } // ──────────────────────────────────────────────────────────── // Yoast term data — one serialized option // ──────────────────────────────────────────────────────────── protected static function fromYoastTaxonomyOption($termId, $taxonomy, $field) { if ('' === (string) $taxonomy) { return ''; } static $option = null; if (null === $option) { $stored = get_option('wpseo_taxonomy_meta'); $option = is_array($stored) ? $stored : []; } if (empty($option[$taxonomy][$termId])) { return ''; } $meta = $option[$taxonomy][$termId]; $map = ['title' => 'wpseo_title', 'description' => 'wpseo_desc', 'canonical' => 'wpseo_canonical']; if (!isset($map[$field]) || empty($meta[$map[$field]])) { return ''; } return self::translate((string) $meta[$map[$field]], '_yoast_wpseo_'); } // ──────────────────────────────────────────────────────────── // Template variables // ──────────────────────────────────────────────────────────── /** * Rewrite another plugin's template variables into Crownify's. * * This is the part that fails silently if skipped. A Yoast title stored as * `%%title%% %%sep%% %%sitename%%` happens to be Crownify's syntax already, but * a Rank Math one is `%title% %sep% %sitename%` and an AIOSEO one is * `#post_title #separator_sa #site_title` — both of which would be served to * Google as that literal string. */ protected static function translate($value, $sourceKey) { if (false === strpos($value, '%') && false === strpos($value, '#')) { return $value; } if (0 === strpos($sourceKey, 'rank_math')) { return self::rewrite($value, '/%([a-z0-9_-]+)(\([^)]*\))?%/i', self::RANK_MATH_VARS); } if ('aioseo' === $sourceKey || 0 === strpos($sourceKey, '_aioseop')) { return self::rewrite($value, '/#([a-z0-9_]+)/i', self::AIOSEO_VARS); } if (0 === strpos($sourceKey, '_seopress')) { return self::rewrite($value, '/%%([a-z0-9_]+)%%/i', self::SEOPRESS_VARS); } // Yoast shares Crownify's `%%name%%` syntax, so only the handful of names // that differ need rewriting; the rest pass through untouched. return self::rewrite($value, '/%%([a-z0-9_-]+)%%/i', self::YOAST_VARS, true); } const YOAST_VARS = [ 'primary_category' => 'category', 'name' => 'author', 'user_description' => 'author_description', 'category_description' => 'term_description', 'tag_description' => 'term_description', 'currenttime' => 'currentdate', 'currentday' => 'currentdate', 'caption' => 'excerpt', 'pagenumber' => 'page', 'pagetotal' => 'page', 'focuskw' => '', ]; const RANK_MATH_VARS = [ 'title' => 'title', 'sitename' => 'sitename', 'sitedesc' => 'sitedesc', 'sep' => 'sep', 'excerpt' => 'excerpt', 'excerpt_only' => 'excerpt_only', 'page' => 'page', 'pagenumber' => 'page', 'category' => 'category', 'categories' => 'category', 'primary_category' => 'category', 'tag' => 'tag', 'tags' => 'tag', 'date' => 'date', 'modified' => 'modified', 'currentyear' => 'currentyear', 'currentmonth' => 'currentmonth', 'currentdate' => 'currentdate', 'name' => 'author', 'post_author' => 'author', 'user_description' => 'author_description', 'id' => 'id', 'term' => 'term_title', 'term_description' => 'term_description', 'pt_single' => 'pt_single', 'pt_plural' => 'pt_plural', 'search_query' => 'searchphrase', 'parent_title' => 'parent_title', 'org_name' => 'sitename', ]; const AIOSEO_VARS = [ 'post_title' => 'title', 'page_title' => 'title', 'site_title' => 'sitename', 'blog_title' => 'sitename', 'site_description' => 'sitedesc', 'tagline' => 'sitedesc', 'separator_sa' => 'sep', 'post_excerpt' => 'excerpt', 'post_excerpt_only' => 'excerpt_only', 'post_content' => 'excerpt', 'post_date' => 'date', 'current_year' => 'currentyear', 'current_month' => 'currentmonth', 'current_date' => 'currentdate', 'author_name' => 'author', 'author_bio' => 'author_description', 'categories' => 'category', 'category_title' => 'term_title', 'tags' => 'tag', 'taxonomy_title' => 'term_title', 'taxonomy_description' => 'term_description', 'archive_title' => 'archive_title', 'search_term' => 'searchphrase', 'page_number' => 'page', 'post_type_plural' => 'pt_plural', 'post_type_singular' => 'pt_single', 'parent_title' => 'parent_title', 'permalink' => '', ]; const SEOPRESS_VARS = [ 'post_title' => 'title', 'sitetitle' => 'sitename', 'sitedesc' => 'sitedesc', 'tagline' => 'sitedesc', 'sep' => 'sep', 'post_excerpt' => 'excerpt', 'post_content' => 'excerpt', 'post_date' => 'date', 'post_modified_date' => 'modified', 'post_author' => 'author', 'currentyear' => 'currentyear', 'currentmonth' => 'currentmonth', 'currentdate' => 'currentdate', '_category_title' => 'term_title', 'tag_title' => 'tag', 'term_title' => 'term_title', 'term_description' => 'term_description', 'archive_title' => 'archive_title', 'search_keywords' => 'searchphrase', 'current_pagination' => 'page', 'post_type_plural' => 'pt_plural', 'post_type_singular' => 'pt_single', 'post_parent_title' => 'parent_title', ]; protected static function rewrite($value, $pattern, array $map, $passThrough = false) { $known = null; $result = preg_replace_callback( $pattern, static function ($matches) use ($map, $passThrough, &$known) { $name = strtolower($matches[1]); if (array_key_exists($name, $map)) { // An empty target means there is no Crownify equivalent. Dropping the // variable beats emitting a literal that would be served verbatim. return '' === $map[$name] ? '' : '%%' . $map[$name] . '%%'; } if ($passThrough) { if (null === $known) { $known = []; foreach (Variables::catalog() as $group) { if (!empty($group['variables'])) { $known = array_merge($known, array_keys($group['variables'])); } } } if (in_array($name, $known, true)) { return '%%' . $name . '%%'; } } return $matches[0]; }, $value, ); return is_string($result) ? $result : $value; } }