PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
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 +165 -19 1.26.0 → 2.14.0 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,13 @@
450 453
451 454 /**
452 455 * {@inheritDoc}
453 456 */
454 - protected function convert_template_variables(string $value, ?int $post_id = null): string {
457 + protected function convert_template_variables($value, ?int $post_id = null): string {
458 + // Foreign data first: booleans/arrays in the source plugin's options
459 + // must degrade to '' here, not fatal the migration (see abstract).
460 + $value = $this->stringify_template_value($value);
461 +
455 462 if (empty($value) || strpos($value, '%%') === false) {
456 463 return $value;
457 464 }
458 465
@@ -473,9 +480,12 @@
473 480 if ($post_id) {
474 481 $post = get_post($post_id);
475 482 if ($post) {
476 483 $replacements['%%title%%'] = $post->post_title;
477 - $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 + );
478 488 $replacements['%%date%%'] = get_the_date('', $post);
479 489 $replacements['%%modified%%'] = get_the_modified_date('', $post);
480 490 $replacements['%%id%%'] = (string) $post_id;
481 491 $replacements['%%name%%'] = get_the_author_meta('display_name', (int) $post->post_author);
@@ -504,8 +514,71 @@
504 514 return trim($value);
505 515 }
506 516
507 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 + /**
508 581 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
509 582 *
510 583 * Unlike convert_template_variables() — which resolves a per-post value to a
511 584 * literal string — post-type and taxonomy templates must stay templates so the
@@ -514,12 +587,16 @@
514 587 * (`%title%`) with a smaller vocabulary. Map the tokens ThinkRank can resolve
515 588 * and strip any Yoast token it has no equivalent for, so an imported template
516 589 * never renders stray `%` characters.
517 590 *
518 - * @param string $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
591 + * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
519 592 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
520 593 */
521 - private function convert_template_pattern(string $template): string {
594 + private function convert_template_pattern($template): string {
595 + // Foreign data first: booleans/arrays in the source plugin's options
596 + // must degrade to '' here, not fatal the migration (see abstract).
597 + $template = $this->stringify_template_value($template);
598 +
522 599 if ($template === '' || strpos($template, '%%') === false) {
523 600 return $template;
524 601 }
525 602
@@ -536,8 +613,15 @@
536 613 '%%modified%%' => '%modified%',
537 614 '%%name%%' => '%author%',
538 615 '%%category%%' => '%category%',
539 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%',
540 624 ];
541 625
542 626 $template = str_replace(array_keys($map), array_values($map), $template);
543 627
@@ -558,14 +642,17 @@
558 642 * Stored as a comma-separated list of advanced directives
559 643 * (e.g. "noimageindex,nosnippet"), or "-" / "none" when no
560 644 * extras are selected.
561 645 *
562 - * @param string $value Raw meta value from Yoast
646 + * @param mixed $value Raw meta value from Yoast
563 647 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
564 648 */
565 - private function parse_yoast_advanced_robots(string $value): array {
649 + private function parse_yoast_advanced_robots($value): array {
566 650 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
567 651
652 + // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
653 + $value = $this->stringify_template_value($value);
654 +
568 655 if ($value === '' || $value === '-' || $value === 'none') {
569 656 return $result;
570 657 }
571 658
@@ -772,8 +859,22 @@
772 859 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
773 860 // itself cannot resolve either. Map them rather than stripping them,
774 861 // or the format silently loses its search term / term name.
775 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().
776 877 ];
777 878 if ($context_token !== '') {
778 879 $map['%%title%%'] = $context_token;
779 880 $map['%%term_title%%'] = $context_token;
@@ -854,9 +955,13 @@
854 955 private function is_assoc_toggle_map(array $value): bool {
855 956 return array_keys($value) !== range(0, count($value) - 1);
856 957 }
857 958
858 - private function map_yoast_separator(string $separator): string {
959 + private function map_yoast_separator($separator): string {
960 + // Foreign data first: a non-string separator setting degrades to '' so
961 + // the default fallback applies instead of fatalling (see abstract).
962 + $separator = $this->stringify_template_value($separator);
963 +
859 964 $map = [
860 965 'sc-dash' => '-',
861 966 'sc-ndash' => '–',
862 967 'sc-mdash' => '—',
@@ -878,12 +983,15 @@
878 983
879 984 /**
880 985 * Parse Yoast's additional focus keywords JSON
881 986 *
882 - * @param string $json JSON string from _yoast_wpseo_focuskeywords
987 + * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
883 988 * @return array Array of additional keyword strings
884 989 */
885 - private function parse_yoast_additional_keywords(string $json): array {
990 + private function parse_yoast_additional_keywords($json): array {
991 + // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
992 + $json = $this->stringify_template_value($json);
993 +
886 994 if (empty($json)) {
887 995 return [];
888 996 }
889 997
@@ -969,6 +1077,44 @@
969 1077 }
970 1078 }
971 1079
972 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 '';
973 1119 }
974 1120 }