PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/admin/importers/class-yoast-exporter.php +142 -16 1.30.0 → 2.14.2 View file →
@@ -128,10 +128,10 @@
128 128 'object_id' => $post_id,
129 129 'object_type' => 'post',
130 130 'source_plugin' => $this->plugin_slug,
131 131 'data' => [
132 - 'seo_title' => $this->convert_template_variables($meta['_yoast_wpseo_title'] ?? '', $post_id),
133 - 'meta_description' => $this->convert_template_variables($meta['_yoast_wpseo_metadesc'] ?? '', $post_id),
132 + 'seo_title' => $this->convert_post_value($meta['_yoast_wpseo_title'] ?? '', $post_id),
133 + 'meta_description' => $this->convert_post_value($meta['_yoast_wpseo_metadesc'] ?? '', $post_id),
134 134 'focus_keyword' => $meta['_yoast_wpseo_focuskw'] ?? '',
135 135 // Full keyword list (primary + Yoast Premium's additional
136 136 // keyphrases) for ThinkRank's keyword array — without this
137 137 // the migrator falls back to the single focus_keyword and
@@ -148,15 +148,16 @@
148 148 'nofollow' => $robots['nofollow'],
149 149 'noarchive' => $advanced_robots['noarchive'],
150 150 'noimageindex' => $advanced_robots['noimageindex'],
151 151 'nosnippet' => $advanced_robots['nosnippet'],
152 - 'og_title' => $this->convert_template_variables($meta['_yoast_wpseo_opengraph-title'] ?? '', $post_id),
153 - 'og_description' => $this->convert_template_variables($meta['_yoast_wpseo_opengraph-description'] ?? '', $post_id),
152 + 'og_title' => $this->convert_post_value($meta['_yoast_wpseo_opengraph-title'] ?? '', $post_id),
153 + 'og_description' => $this->convert_post_value($meta['_yoast_wpseo_opengraph-description'] ?? '', $post_id),
154 154 'og_image' => $meta['_yoast_wpseo_opengraph-image'] ?? '',
155 - 'twitter_title' => $this->convert_template_variables($meta['_yoast_wpseo_twitter-title'] ?? '', $post_id),
156 - 'twitter_description' => $this->convert_template_variables($meta['_yoast_wpseo_twitter-description'] ?? '', $post_id),
155 + 'twitter_title' => $this->convert_post_value($meta['_yoast_wpseo_twitter-title'] ?? '', $post_id),
156 + 'twitter_description' => $this->convert_post_value($meta['_yoast_wpseo_twitter-description'] ?? '', $post_id),
157 157 'twitter_image' => $meta['_yoast_wpseo_twitter-image'] ?? '',
158 158 'primary_category' => (int) ($meta['_yoast_wpseo_primary_category'] ?? 0),
159 + 'product_identifier' => $this->extract_product_identifier($meta),
159 160 'schema_type' => $meta['_yoast_wpseo_schema_page_type'] ?? '',
160 161 // Yoast "cornerstone content" maps to ThinkRank pillar content.
161 162 'pillar_content' => (int) ($meta['_yoast_wpseo_is_cornerstone'] ?? 0),
162 163 ],
@@ -208,16 +209,16 @@
208 209 'object_id' => $term_id,
209 210 'object_type' => 'term',
210 211 'source_plugin' => $this->plugin_slug,
211 212 'data' => [
212 - 'seo_title' => $this->convert_template_variables($meta['wpseo_title'] ?? ''),
213 - 'meta_description' => $this->convert_template_variables($meta['wpseo_desc'] ?? ''),
213 + 'seo_title' => $this->convert_term_value($meta['wpseo_title'] ?? ''),
214 + 'meta_description' => $this->convert_term_value($meta['wpseo_desc'] ?? ''),
214 215 'focus_keyword' => $meta['wpseo_focuskw'] ?? '',
215 216 'canonical_url' => $meta['wpseo_canonical'] ?? '',
216 217 'noindex' => $noindex,
217 218 'nofollow' => 0,
218 - 'og_title' => $this->convert_template_variables($meta['wpseo_opengraph-title'] ?? ''),
219 - 'og_description' => $this->convert_template_variables($meta['wpseo_opengraph-description'] ?? ''),
219 + 'og_title' => $this->convert_term_value($meta['wpseo_opengraph-title'] ?? ''),
220 + 'og_description' => $this->convert_term_value($meta['wpseo_opengraph-description'] ?? ''),
220 221 ],
221 222 'extended' => [
222 223 'breadcrumb_title' => $meta['wpseo_bctitle'] ?? '',
223 224 'og_image' => $meta['wpseo_opengraph-image'] ?? '',
@@ -452,9 +453,9 @@
452 453
453 454 /**
454 455 * {@inheritDoc}
455 456 */
456 - protected function convert_template_variables(mixed $value, ?int $post_id = null): string {
457 + protected function convert_template_variables($value, ?int $post_id = null): string {
457 458 // Foreign data first: booleans/arrays in the source plugin's options
458 459 // must degrade to '' here, not fatal the migration (see abstract).
459 460 $value = $this->stringify_template_value($value);
460 461
@@ -479,9 +480,12 @@
479 480 if ($post_id) {
480 481 $post = get_post($post_id);
481 482 if ($post) {
482 483 $replacements['%%title%%'] = $post->post_title;
483 - $replacements['%%excerpt%%'] = wp_trim_words($post->post_excerpt ?: wp_trim_words(wp_strip_all_tags($post->post_content), 55), 55);
484 + $replacements['%%excerpt%%'] = \ThinkRank\Core\Seo_Text::trim_words(
485 + $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
486 + 55
487 + );
484 488 $replacements['%%date%%'] = get_the_date('', $post);
485 489 $replacements['%%modified%%'] = get_the_modified_date('', $post);
486 490 $replacements['%%id%%'] = (string) $post_id;
487 491 $replacements['%%name%%'] = get_the_author_meta('display_name', (int) $post->post_author);
@@ -510,8 +514,71 @@
510 514 return trim($value);
511 515 }
512 516
513 517 /**
518 + * Yoast per-post tokens ThinkRank resolves per request (#886): kept as
519 + * tags rather than frozen into text. Mirrors convert_template_pattern().
520 + */
521 + private const POST_TOKENS = [
522 + '%%title%%' => '%title%',
523 + '%%sitename%%' => '%sitename%',
524 + '%%sep%%' => '%sep%',
525 + '%%excerpt%%' => '%excerpt%',
526 + '%%date%%' => '%date%',
527 + '%%modified%%' => '%modified%',
528 + '%%name%%' => '%author%',
529 + '%%category%%' => '%category%',
530 + '%%primary_category%%' => '%category%',
531 + '%%wc_price%%' => '%price%',
532 + '%%wc_sku%%' => '%sku%',
533 + '%%wc_shortdesc%%' => '%short_description%',
534 + '%%wc_brand%%' => '%brand%',
535 + ];
536 +
537 + /**
538 + * Yoast per-term tokens ThinkRank resolves on a term archive. The term's
539 + * own name and description used to be stripped outright, since the
540 + * literal resolver only knows post context.
541 + */
542 + private const TERM_TOKENS = [
543 + '%%term_title%%' => '%term%',
544 + '%%term_description%%' => '%excerpt%',
545 + '%%category_description%%' => '%excerpt%',
546 + '%%tag_description%%' => '%excerpt%',
547 + '%%sitename%%' => '%sitename%',
548 + '%%sep%%' => '%sep%',
549 + ];
550 +
551 + /**
552 + * A per-post Yoast value: mapped tokens as ThinkRank tags, the rest literal.
553 + *
554 + * @param mixed $value Raw Yoast value
555 + * @param int $post_id Post ID
556 + * @return string
557 + */
558 + private function convert_post_value($value, int $post_id): string {
559 + return $this->tokenize_object_template(
560 + $this->stringify_template_value($value),
561 + self::literal_token_patterns(self::POST_TOKENS),
562 + fn(string $rest): string => $this->convert_template_variables($rest, $post_id)
563 + );
564 + }
565 +
566 + /**
567 + * A per-term Yoast value: mapped tokens as ThinkRank tags, the rest literal.
568 + *
569 + * @param mixed $value Raw Yoast value
570 + * @return string
571 + */
572 + private function convert_term_value($value): string {
573 + return $this->tokenize_object_template(
574 + $this->stringify_template_value($value),
575 + self::literal_token_patterns(self::TERM_TOKENS),
576 + fn(string $rest): string => $this->convert_template_variables($rest)
577 + );
578 + }
579 +
580 + /**
514 581 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
515 582 *
516 583 * Unlike convert_template_variables() — which resolves a per-post value to a
517 584 * literal string — post-type and taxonomy templates must stay templates so the
@@ -523,9 +590,9 @@
523 590 *
524 591 * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
525 592 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
526 593 */
527 - private function convert_template_pattern(mixed $template): string {
594 + private function convert_template_pattern($template): string {
528 595 // Foreign data first: booleans/arrays in the source plugin's options
529 596 // must degrade to '' here, not fatal the migration (see abstract).
530 597 $template = $this->stringify_template_value($template);
531 598
@@ -546,8 +613,15 @@
546 613 '%%modified%%' => '%modified%',
547 614 '%%name%%' => '%author%',
548 615 '%%category%%' => '%category%',
549 616 '%%primary_category%%' => '%category%',
617 + // WooCommerce SEO product variables. Stripped rather than mapped
618 + // until #715, so a product description template imported as "Buy
619 + // %title% for" with the price and stock gone.
620 + '%%wc_price%%' => '%price%',
621 + '%%wc_sku%%' => '%sku%',
622 + '%%wc_shortdesc%%' => '%short_description%',
623 + '%%wc_brand%%' => '%brand%',
550 624 ];
551 625
552 626 $template = str_replace(array_keys($map), array_values($map), $template);
553 627
@@ -571,9 +645,9 @@
571 645 *
572 646 * @param mixed $value Raw meta value from Yoast
573 647 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
574 648 */
575 - private function parse_yoast_advanced_robots(mixed $value): array {
649 + private function parse_yoast_advanced_robots($value): array {
576 650 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
577 651
578 652 // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
579 653 $value = $this->stringify_template_value($value);
@@ -785,8 +859,22 @@
785 859 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
786 860 // itself cannot resolve either. Map them rather than stripping them,
787 861 // or the format silently loses its search term / term name.
788 862 '%%search_query%%' => '%search_term%',
863 + // NOT the WooCommerce product variables, deliberately. This
864 + // converter writes Site Identity title formats, and those are
865 + // resolved by SEO_Manager::process_title_template() against its own
866 + // vocabulary — %site_title%, %separator%, %search_term% and the
867 + // rest — which has no %price%/%sku%/%brand% and, unlike
868 + // Pattern_Resolver, does not strip what it cannot resolve. Mapping
869 + // them here published "Search pennant - %price% - Media Wipe" in a
870 + // live <title>: a silent loss traded for the literal leak #715 is
871 + // about. The formats this feeds are the homepage, search, archive,
872 + // author, category and tag titles, where there is no one product
873 + // for a price to belong to either way, so the strip below is the
874 + // right answer. The per-post converter keeps them, because
875 + // Pattern_Resolver does resolve them: see
876 + // convert_template_pattern().
789 877 ];
790 878 if ($context_token !== '') {
791 879 $map['%%title%%'] = $context_token;
792 880 $map['%%term_title%%'] = $context_token;
@@ -867,9 +955,9 @@
867 955 private function is_assoc_toggle_map(array $value): bool {
868 956 return array_keys($value) !== range(0, count($value) - 1);
869 957 }
870 958
871 - private function map_yoast_separator(mixed $separator): string {
959 + private function map_yoast_separator($separator): string {
872 960 // Foreign data first: a non-string separator setting degrades to '' so
873 961 // the default fallback applies instead of fatalling (see abstract).
874 962 $separator = $this->stringify_template_value($separator);
875 963
@@ -898,9 +986,9 @@
898 986 *
899 987 * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
900 988 * @return array Array of additional keyword strings
901 989 */
902 - private function parse_yoast_additional_keywords(mixed $json): array {
990 + private function parse_yoast_additional_keywords($json): array {
903 991 // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
904 992 $json = $this->stringify_template_value($json);
905 993
906 994 if (empty($json)) {
@@ -989,6 +1077,44 @@
989 1077 }
990 1078 }
991 1079
992 1080 return $settings;
1081 + }
1082 +
1083 + /**
1084 + * A product's identifier from Yoast WooCommerce SEO.
1085 + *
1086 + * Yoast stores every identifier type in one serialised map keyed by type
1087 + * (`gtin8`, `gtin12`, `gtin13`, `gtin14`, `isbn`, `mpn`) rather than in a
1088 + * field per type. ThinkRank Pro holds a single identifier, so the first
1089 + * non-empty one wins, in the order Google prefers: a GTIN identifies the
1090 + * product itself, an MPN only identifies it within one manufacturer, and
1091 + * an ISBN applies to books alone.
1092 + *
1093 + * @since 2.10.1
1094 + *
1095 + * @param array $meta Post meta for the product.
1096 + * @return string Identifier, or '' when the product has none.
1097 + */
1098 + private function extract_product_identifier(array $meta): string {
1099 + $raw = $meta['wpseo_global_identifier_values'] ?? '';
1100 +
1101 + if (is_string($raw) && '' !== $raw) {
1102 + $decoded = maybe_unserialize($raw);
1103 + $raw = is_array($decoded) ? $decoded : [];
1104 + }
1105 +
1106 + if (!is_array($raw) || empty($raw)) {
1107 + return '';
1108 + }
1109 +
1110 + foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8', 'mpn', 'isbn'] as $type) {
1111 + $value = trim((string) ($raw[$type] ?? ''));
1112 +
1113 + if ('' !== $value) {
1114 + return $value;
1115 + }
1116 + }
1117 +
1118 + return '';
993 1119 }
994 1120 }