PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 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 All 58 releases
← All changes | includes/seo/class-schema-builder.php +121 -44 2.0.2 → 2.14.3 View file →
@@ -91,8 +91,14 @@
91 91 case 'WebSite':
92 92 $schema = $this->populate_website_schema($schema, $data, $context);
93 93 break;
94 94 case 'WebPage':
95 + case 'AboutPage':
96 + case 'ContactPage':
97 + case 'ProfilePage':
98 + // All four carry the same WebPage properties; only @type
99 + // differs, and create_base_schema() has already set it from the
100 + // requested type (#624).
95 101 $schema = $this->populate_webpage_schema($schema, $data, $context);
96 102 break;
97 103 case 'FAQPage':
98 104 $schema = $this->populate_faq_schema($schema, $data, $context);
@@ -457,8 +463,54 @@
457 463 return '';
458 464 }
459 465
460 466 /**
467 + * The WebSite SearchAction for the sitelinks search box, or null when the
468 + * site switched it off.
469 + *
470 + * Reads the Website tab's two settings. website_enable_search absent (or
471 + * null) means not configured, which stays on; any other value is read as
472 + * a boolean, so the '' a switched-off toggle is stored as turns it off.
473 + *
474 + * website_search_url is used as the urlTemplate when it is an http(s) URL,
475 + * or a site path, and carries the {search_term_string} placeholder that
476 + * query-input names. Without the placeholder the template cannot take a
477 + * query, so the default WordPress search URL is used instead, as it is
478 + * when the field is empty.
479 + *
480 + * @since 2.15.0
481 + *
482 + * @param array $settings Schema settings or site_data.
483 + * @return array|null SearchAction node, or null when switched off.
484 + */
485 + public static function website_search_action(array $settings): ?array {
486 + $enabled = $settings['website_enable_search'] ?? null;
487 + if (null !== $enabled && empty($enabled)) {
488 + return null;
489 + }
490 +
491 + $url_template = home_url('/?s={search_term_string}');
492 +
493 + $custom = trim((string) ($settings['website_search_url'] ?? ''));
494 + if ('' !== $custom && false !== strpos($custom, '{search_term_string}')) {
495 + if (\ThinkRank\Core\Url_Validator::is_http_url($custom)) {
496 + $url_template = $custom;
497 + } elseif (\ThinkRank\Core\Url_Validator::is_http_url_or_path($custom)) {
498 + $url_template = home_url($custom);
499 + }
500 + }
501 +
502 + return [
503 + '@type' => 'SearchAction',
504 + 'target' => [
505 + '@type' => 'EntryPoint',
506 + 'urlTemplate' => $url_template
507 + ],
508 + 'query-input' => 'required name=search_term_string'
509 + ];
510 + }
511 +
512 + /**
461 513 * Populate Organization schema
462 514 * PRESERVED: Exact same method logic from original Schema_Generator
463 515 *
464 516 * @since 1.0.0
@@ -487,11 +539,18 @@
487 539 $data['url'] ?? null,
488 540 home_url()
489 541 );
490 542
491 - // Logo from user configuration or theme customizer
492 - if (!empty($data['site_data']['organization_logo'])) {
493 - $schema['logo'] = $this->format_image_schema($data['site_data']['organization_logo']);
543 + // Logo: the Schema Manager's Organization logo, then the Site Identity
544 + // logo (the one WebSite.publisher and LocalBusiness already use, so the
545 + // same entity carries the same logo everywhere), then the theme's
546 + // custom logo. With none of them, logo is left out (#1010).
547 + $logo_url = $this->first_non_empty(
548 + $data['site_data']['organization_logo'] ?? null,
549 + $data['site_data']['logo_url'] ?? null
550 + );
551 + if ('' !== $logo_url) {
552 + $schema['logo'] = $this->format_image_schema($logo_url);
494 553 } else {
495 554 // Fallback to WordPress custom logo
496 555 $custom_logo_id = get_theme_mod('custom_logo');
497 556 if ($custom_logo_id) {
@@ -722,12 +781,25 @@
722 781 return $schema;
723 782 }
724 783
725 784 /**
726 - * Truncate text to specified length
727 - * PRESERVED: Exact same method logic from original Schema_Generator
785 + * Normalise text for JSON-LD and truncate it to a length.
728 786 *
787 + * Every description, headline and reviewBody this class emits passes
788 + * through here, so this is the one place the deployed nodes get the same
789 + * treatment the automatic ones have had since #766. Before, it only
790 + * stripped tags: the block editor stores `&` as `&`, which was then
791 + * published literally (nothing decodes JSON-LD), and the cut landed
792 + * mid-word with "..." glued on. A deployed node outranks the automatic
793 + * one, so deploying a page type made its description worse than not
794 + * deploying anything.
795 + *
796 + * Seo_Text::trim_to_length() is multibyte-aware, which the #473 fix here
797 + * already required (a byte cut mojibaked every non-Latin description),
798 + * and falls back to a character cut for scripts without spaces.
799 + *
729 800 * @since 1.0.0
801 + * @since 2.10.0 Decodes entities and cuts on a word boundary.
730 802 *
731 803 * @param string $text Text to truncate
732 804 * @param int $length Maximum length
733 805 * @return string Truncated text
@@ -732,19 +804,12 @@
732 804 * @param int $length Maximum length
733 805 * @return string Truncated text
734 806 */
735 807 private function truncate_text(string $text, int $length): string {
736 - $text = wp_strip_all_tags($text);
737 -
738 - // Multibyte-aware. strlen()/substr() count bytes, so a cut landing
739 - // mid-character produced invalid UTF-8 — wp_json_encode()'s sanity
740 - // check then replaced the tail with "?", mojibaking every non-Latin
741 - // site's description and headline (#473).
742 - if (mb_strlen($text) <= $length) {
743 - return $text;
744 - }
745 -
746 - return mb_substr($text, 0, max(0, $length - 3)) . '...';
808 + return \ThinkRank\Core\Seo_Text::trim_to_length(
809 + \ThinkRank\Core\Seo_Text::normalize_schema_text($text),
810 + $length
811 + );
747 812 }
748 813
749 814 /**
750 815 * Normalise a date into ISO 8601 with a timezone offset.
@@ -827,17 +892,18 @@
827 892 '@type' => 'ImageObject',
828 893 'url' => $image_url
829 894 ];
830 895
831 - // Try to get image dimensions if it's a WordPress attachment
832 - $attachment_id = attachment_url_to_postid($image_url);
896 + // Try to get image dimensions if it's a WordPress attachment — those
897 + // of the file this URL names, which need not be the original upload.
898 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
833 899 if ($attachment_id) {
834 - $image_data = wp_get_attachment_image_src($attachment_id, 'full');
900 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
835 901 // SVGs report 0x0 — omit the dimensions rather than emitting
836 902 // zeroes, which invalidate the ImageObject.
837 - if ($image_data && (int) $image_data[1] > 0 && (int) $image_data[2] > 0) {
838 - $image_schema['width'] = (int) $image_data[1];
839 - $image_schema['height'] = (int) $image_data[2];
903 + if ($image_file['width'] > 0 && $image_file['height'] > 0) {
904 + $image_schema['width'] = $image_file['width'];
905 + $image_schema['height'] = $image_file['height'];
840 906 }
841 907 }
842 908
843 909 return $image_schema;
@@ -911,9 +977,9 @@
911 977 'organization_social_telegram'
912 978 ];
913 979
914 980 foreach ($social_fields as $field) {
915 - if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
981 + if (!empty($settings[$field]) && \ThinkRank\Core\Url_Validator::is_valid($settings[$field])) {
916 982 $social_profiles[] = $settings[$field];
917 983 }
918 984 }
919 985
@@ -1039,8 +1105,18 @@
1039 1105 $data['url'] ?? '',
1040 1106 home_url()
1041 1107 );
1042 1108
1109 + // Both WebSite producers have to carry this or the deployed node and the
1110 + // default one disagree about the same site — the shape of failure #688
1111 + // documents. The default node is generate_website_schema() (#692).
1112 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema(
1113 + $data['site_data']['alternate_name'] ?? null
1114 + );
1115 + if (null !== $alternate_name) {
1116 + $schema['alternateName'] = $alternate_name;
1117 + }
1118 +
1043 1119 // Recommended properties - prioritize user-configured Website schema description
1044 1120 if (!empty($data['site_data']['website_description'])) {
1045 1121 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
1046 1122 } elseif (!empty($data['content'])) {
@@ -1045,9 +1121,13 @@
1045 1121 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
1046 1122 } elseif (!empty($data['content'])) {
1047 1123 $schema['description'] = $this->truncate_text($data['content'], 160);
1048 1124 } else {
1049 - $schema['description'] = get_bloginfo('description');
1125 + // The tagline is stored esc_html()'d by sanitize_option(), so an
1126 + // ampersand in it arrives as `&amp;`.
1127 + $schema['description'] = \ThinkRank\Core\Seo_Text::normalize_schema_text(
1128 + (string) get_bloginfo('description')
1129 + );
1050 1130 }
1051 1131
1052 1132 // Author - use organization or person data
1053 1133 if (!empty($data['site_data']['organization_name'])) {
@@ -1103,22 +1183,14 @@
1103 1183 }
1104 1184
1105 1185 $schema['publisher'] = $publisher;
1106 1186
1107 - // Search action for sitelinks search box (optional but recommended)
1108 - if ($data['site_data']['website_enable_search'] ?? true) {
1109 - $search_url = $this->first_non_empty(
1110 - $data['site_data']['website_search_url'] ?? '',
1111 - home_url('/?s={search_term_string}')
1112 - );
1113 - $schema['potentialAction'] = [
1114 - '@type' => 'SearchAction',
1115 - 'target' => [
1116 - '@type' => 'EntryPoint',
1117 - 'urlTemplate' => $search_url
1118 - ],
1119 - 'query-input' => 'required name=search_term_string'
1120 - ];
1187 + // Search action for sitelinks search box (optional but recommended).
1188 + // Shared with the live fallback in SEO_Manager so the deployed and
1189 + // live WebSite nodes read the switch and the URL the same way (#1009).
1190 + $search_action = self::website_search_action($data['site_data'] ?? []);
1191 + if (null !== $search_action) {
1192 + $schema['potentialAction'] = $search_action;
1121 1193 }
1122 1194
1123 1195 // Social media profiles (sameAs)
1124 1196 $social_profiles = $this->get_social_profiles();
@@ -1280,8 +1352,18 @@
1280 1352 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1281 1353 // Get business data from Site Identity Business Info (single source of truth)
1282 1354 $business_data = $this->get_business_data_from_site_identity();
1283 1355
1356 + // The business type chosen in Local SEO is what this node IS. It was
1357 + // read into $business_data and then never used, so every site published
1358 + // "@type": "LocalBusiness" whatever it had picked, and the ~150-type
1359 + // selector (#623) changed nothing but a meta tag. create_base_schema()
1360 + // has already put @type in place, so reassigning keeps key order and a
1361 + // site on the default publishes exactly what it did before.
1362 + $schema['@type'] = \ThinkRank\Config\Local_Business_Types_Config::schema_type(
1363 + $business_data['business_type'] ?? ''
1364 + );
1365 +
1284 1366 // Required properties - use business name from Business Info.
1285 1367 // `name` is required for LocalBusiness, so an empty saved value must fall
1286 1368 // through to the next source rather than emit "".
1287 1369 $schema['name'] = $this->first_non_empty(
@@ -1561,14 +1643,9 @@
1561 1643 $social_profiles,
1562 1644 static function ($url) {
1563 1645 return is_string($url)
1564 1646 && '' !== trim($url)
1565 - && filter_var($url, FILTER_VALIDATE_URL)
1566 - && in_array(
1567 - strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)),
1568 - ['http', 'https'],
1569 - true
1570 - );
1647 + && \ThinkRank\Core\Url_Validator::is_http_url($url);
1571 1648 }
1572 1649 )));
1573 1650
1574 1651 if (!empty($social_profiles)) {