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 +144 -16 1.28.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'] ?? '',
@@ -416,8 +417,9 @@
416 417
417 418 $offset = ($page - 1) * $this->chunk_size;
418 419
419 420 $rows = $wpdb->get_results(
421 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
420 422 $wpdb->prepare(
421 423 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
422 424 $this->chunk_size,
423 425 $offset
@@ -423,8 +425,9 @@
423 425 $offset
424 426 ),
425 427 ARRAY_A
426 428 );
429 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
427 430
428 431 if (empty($rows)) {
429 432 return [];
430 433 }
@@ -450,9 +453,9 @@
450 453
451 454 /**
452 455 * {@inheritDoc}
453 456 */
454 - protected function convert_template_variables(mixed $value, ?int $post_id = null): string {
457 + protected function convert_template_variables($value, ?int $post_id = null): string {
455 458 // Foreign data first: booleans/arrays in the source plugin's options
456 459 // must degrade to '' here, not fatal the migration (see abstract).
457 460 $value = $this->stringify_template_value($value);
458 461
@@ -477,9 +480,12 @@
477 480 if ($post_id) {
478 481 $post = get_post($post_id);
479 482 if ($post) {
480 483 $replacements['%%title%%'] = $post->post_title;
481 - $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 + );
482 488 $replacements['%%date%%'] = get_the_date('', $post);
483 489 $replacements['%%modified%%'] = get_the_modified_date('', $post);
484 490 $replacements['%%id%%'] = (string) $post_id;
485 491 $replacements['%%name%%'] = get_the_author_meta('display_name', (int) $post->post_author);
@@ -508,8 +514,71 @@
508 514 return trim($value);
509 515 }
510 516
511 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 + /**
512 581 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
513 582 *
514 583 * Unlike convert_template_variables() — which resolves a per-post value to a
515 584 * literal string — post-type and taxonomy templates must stay templates so the
@@ -521,9 +590,9 @@
521 590 *
522 591 * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
523 592 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
524 593 */
525 - private function convert_template_pattern(mixed $template): string {
594 + private function convert_template_pattern($template): string {
526 595 // Foreign data first: booleans/arrays in the source plugin's options
527 596 // must degrade to '' here, not fatal the migration (see abstract).
528 597 $template = $this->stringify_template_value($template);
529 598
@@ -544,8 +613,15 @@
544 613 '%%modified%%' => '%modified%',
545 614 '%%name%%' => '%author%',
546 615 '%%category%%' => '%category%',
547 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%',
548 624 ];
549 625
550 626 $template = str_replace(array_keys($map), array_values($map), $template);
551 627
@@ -569,9 +645,9 @@
569 645 *
570 646 * @param mixed $value Raw meta value from Yoast
571 647 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
572 648 */
573 - private function parse_yoast_advanced_robots(mixed $value): array {
649 + private function parse_yoast_advanced_robots($value): array {
574 650 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
575 651
576 652 // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
577 653 $value = $this->stringify_template_value($value);
@@ -783,8 +859,22 @@
783 859 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
784 860 // itself cannot resolve either. Map them rather than stripping them,
785 861 // or the format silently loses its search term / term name.
786 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().
787 877 ];
788 878 if ($context_token !== '') {
789 879 $map['%%title%%'] = $context_token;
790 880 $map['%%term_title%%'] = $context_token;
@@ -865,9 +955,9 @@
865 955 private function is_assoc_toggle_map(array $value): bool {
866 956 return array_keys($value) !== range(0, count($value) - 1);
867 957 }
868 958
869 - private function map_yoast_separator(mixed $separator): string {
959 + private function map_yoast_separator($separator): string {
870 960 // Foreign data first: a non-string separator setting degrades to '' so
871 961 // the default fallback applies instead of fatalling (see abstract).
872 962 $separator = $this->stringify_template_value($separator);
873 963
@@ -896,9 +986,9 @@
896 986 *
897 987 * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
898 988 * @return array Array of additional keyword strings
899 989 */
900 - private function parse_yoast_additional_keywords(mixed $json): array {
990 + private function parse_yoast_additional_keywords($json): array {
901 991 // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
902 992 $json = $this->stringify_template_value($json);
903 993
904 994 if (empty($json)) {
@@ -987,6 +1077,44 @@
987 1077 }
988 1078 }
989 1079
990 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 '';
991 1119 }
992 1120 }