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 +242 -64 1.30.0 → 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);
@@ -204,11 +210,11 @@
204 210 }
205 211
206 212 // Date published - prioritize user-configured date
207 213 if (!empty($data['site_data']['article_date_published'])) {
208 - $schema['datePublished'] = $data['site_data']['article_date_published'];
214 + $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']);
209 215 } elseif (!empty($data['date'])) {
210 - $schema['datePublished'] = $data['date'];
216 + $schema['datePublished'] = $this->to_iso8601($data['date']);
211 217 } else {
212 218 $schema['datePublished'] = current_time('c');
213 219 }
214 220
@@ -229,11 +235,11 @@
229 235 }
230 236
231 237 // Date modified - prioritize user-configured date
232 238 if (!empty($data['site_data']['article_date_modified'])) {
233 - $schema['dateModified'] = $data['site_data']['article_date_modified'];
239 + $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']);
234 240 } elseif (!empty($data['modified'])) {
235 - $schema['dateModified'] = $data['modified'];
241 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
236 242 } else {
237 243 $schema['dateModified'] = $schema['datePublished'];
238 244 }
239 245
@@ -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) {
@@ -506,31 +565,45 @@
506 565 }
507 566 }
508 567 }
509 568
510 - // Contact point from Business Info or contact configuration
511 - $contact_point = ['@type' => 'ContactPoint'];
512 - $has_contact_info = false;
569 + // Contact point: the Schema Manager's own fields win, then Business
570 + // Info. Reading telephone/email from Business Info alone and hard-coding
571 + // contactType left the Organization form's Contact Type, Phone and Email
572 + // inert — they saved but never reached the deployed markup, even though
573 + // Seo_Manager already applied this precedence for the same entity.
574 + $site_data = $data['site_data'] ?? [];
513 575
514 - // Use Business Info phone as primary contact
515 - if (!empty($business_data['business_phone'])) {
516 - $contact_point['telephone'] = $business_data['business_phone'];
517 - $has_contact_info = true;
518 - }
576 + $contact_phone = $this->first_non_empty(
577 + $site_data['organization_contact_phone'] ?? null,
578 + $business_data['business_phone'] ?? null
579 + );
519 580
520 - // Use Business Info email as primary contact
521 - if (!empty($business_data['business_email'])) {
522 - $contact_point['email'] = $business_data['business_email'];
523 - $has_contact_info = true;
524 - }
581 + $contact_email = $this->first_non_empty(
582 + $site_data['organization_contact_email'] ?? null,
583 + $business_data['business_email'] ?? null
584 + );
525 585
526 - // Add contact type and hours if available
527 - if ($has_contact_info) {
528 - $contact_point['contactType'] = 'customer service';
586 + if ('' !== $contact_phone || '' !== $contact_email) {
587 + $contact_point = [
588 + '@type' => 'ContactPoint',
589 + 'contactType' => $this->first_non_empty(
590 + $site_data['organization_contact_type'] ?? null,
591 + 'customer service'
592 + ),
593 + ];
529 594
595 + if ('' !== $contact_phone) {
596 + $contact_point['telephone'] = $contact_phone;
597 + }
598 +
599 + if ('' !== $contact_email) {
600 + $contact_point['email'] = $contact_email;
601 + }
602 +
530 603 // Add contact hours if available from organization settings
531 - if (!empty($data['site_data']['organization_contact_hours'])) {
532 - $contact_point['hoursAvailable'] = $data['site_data']['organization_contact_hours'];
604 + if (!empty($site_data['organization_contact_hours'])) {
605 + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours'];
533 606 }
534 607
535 608 $schema['contactPoint'] = $contact_point;
536 609 }
@@ -628,9 +701,9 @@
628 701 }
629 702
630 703 // datePublished + url from content context.
631 704 if (!empty($data['date'])) {
632 - $schema['datePublished'] = $data['date'];
705 + $schema['datePublished'] = $this->to_iso8601($data['date']);
633 706 }
634 707 if (!empty($data['url'])) {
635 708 $schema['url'] = $data['url'];
636 709 }
@@ -708,12 +781,25 @@
708 781 return $schema;
709 782 }
710 783
711 784 /**
712 - * Truncate text to specified length
713 - * PRESERVED: Exact same method logic from original Schema_Generator
785 + * Normalise text for JSON-LD and truncate it to a length.
714 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 + *
715 800 * @since 1.0.0
801 + * @since 2.10.0 Decodes entities and cuts on a word boundary.
716 802 *
717 803 * @param string $text Text to truncate
718 804 * @param int $length Maximum length
719 805 * @return string Truncated text
@@ -718,13 +804,46 @@
718 804 * @param int $length Maximum length
719 805 * @return string Truncated text
720 806 */
721 807 private function truncate_text(string $text, int $length): string {
722 - $text = wp_strip_all_tags($text);
723 - if (strlen($text) <= $length) {
724 - return $text;
808 + return \ThinkRank\Core\Seo_Text::trim_to_length(
809 + \ThinkRank\Core\Seo_Text::normalize_schema_text($text),
810 + $length
811 + );
812 + }
813 +
814 + /**
815 + * Normalise a date into ISO 8601 with a timezone offset.
816 + *
817 + * Deployed schema is a stored snapshot, so rows written before #465 still
818 + * hold raw MySQL datetimes ("2026-08-23 10:19:10"). Google reports those as
819 + * an invalid date value and drops the Article rich result, so normalise on
820 + * the way out as well as on the way in.
821 + *
822 + * @since 1.16.0
823 + *
824 + * @param mixed $date Date in any parseable form.
825 + * @return string ISO 8601 date, or '' when the input cannot be parsed.
826 + */
827 + private function to_iso8601($date): string {
828 + if (empty($date) || !is_scalar($date)) {
829 + return '';
725 830 }
726 - return substr($text, 0, $length - 3) . '...';
831 +
832 + $date = (string) $date;
833 +
834 + // Already ISO 8601 (has the date/time separator) — leave it alone.
835 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $date)) {
836 + return $date;
837 + }
838 +
839 + $timestamp = strtotime($date);
840 +
841 + if (false === $timestamp) {
842 + return '';
843 + }
844 +
845 + return (string) wp_date('c', $timestamp);
727 846 }
728 847
729 848 /**
730 849 * Format author schema
@@ -773,17 +892,18 @@
773 892 '@type' => 'ImageObject',
774 893 'url' => $image_url
775 894 ];
776 895
777 - // Try to get image dimensions if it's a WordPress attachment
778 - $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);
779 899 if ($attachment_id) {
780 - $image_data = wp_get_attachment_image_src($attachment_id, 'full');
900 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
781 901 // SVGs report 0x0 — omit the dimensions rather than emitting
782 902 // zeroes, which invalidate the ImageObject.
783 - if ($image_data && (int) $image_data[1] > 0 && (int) $image_data[2] > 0) {
784 - $image_schema['width'] = (int) $image_data[1];
785 - $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'];
786 906 }
787 907 }
788 908
789 909 return $image_schema;
@@ -829,10 +949,19 @@
829 949 *
830 950 * @return array Social media profile URLs
831 951 */
832 952 private function get_social_profiles(): array {
833 - // Get Schema Manager settings for organization social profiles
834 - $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
953 + // Reuse one manager for the whole request. This method runs from inside
954 + // the foreign-settings listener, and constructing a fresh
955 + // Schema_Management_System on every Organization build was what let the
956 + // listener count double per save (#463). The constructor's static guard
957 + // stops the doubling; this stops the needless re-construction.
958 + static $schema_manager = null;
959 +
960 + if (null === $schema_manager) {
961 + $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
962 + }
963 +
835 964 $settings = $schema_manager->get_settings('site', null);
836 965
837 966 $social_profiles = [];
838 967
@@ -848,9 +977,9 @@
848 977 'organization_social_telegram'
849 978 ];
850 979
851 980 foreach ($social_fields as $field) {
852 - if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
981 + if (!empty($settings[$field]) && \ThinkRank\Core\Url_Validator::is_valid($settings[$field])) {
853 982 $social_profiles[] = $settings[$field];
854 983 }
855 984 }
856 985
@@ -965,11 +1094,29 @@
965 1094 * @return array Populated schema
966 1095 */
967 1096 private function populate_website_schema(array $schema, array $data, string $context): array {
968 1097 // Required properties - prioritize user-configured Website schema fields
969 - $schema['name'] = $data['site_data']['website_name'] ?? $data['title'] ?? get_bloginfo('name');
970 - $schema['url'] = $data['site_data']['website_url'] ?? $data['url'] ?? home_url();
1098 + $schema['name'] = $this->first_non_empty(
1099 + $data['site_data']['website_name'] ?? '',
1100 + $data['title'] ?? '',
1101 + get_bloginfo('name')
1102 + );
1103 + $schema['url'] = $this->first_non_empty(
1104 + $data['site_data']['website_url'] ?? '',
1105 + $data['url'] ?? '',
1106 + home_url()
1107 + );
971 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 +
972 1119 // Recommended properties - prioritize user-configured Website schema description
973 1120 if (!empty($data['site_data']['website_description'])) {
974 1121 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1122 } elseif (!empty($data['content'])) {
@@ -974,9 +1121,13 @@
974 1121 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1122 } elseif (!empty($data['content'])) {
976 1123 $schema['description'] = $this->truncate_text($data['content'], 160);
977 1124 } else {
978 - $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 + );
979 1130 }
980 1131
981 1132 // Author - use organization or person data
982 1133 if (!empty($data['site_data']['organization_name'])) {
@@ -1032,19 +1183,14 @@
1032 1183 }
1033 1184
1034 1185 $schema['publisher'] = $publisher;
1035 1186
1036 - // Search action for sitelinks search box (optional but recommended)
1037 - if ($data['site_data']['website_enable_search'] ?? true) {
1038 - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}');
1039 - $schema['potentialAction'] = [
1040 - '@type' => 'SearchAction',
1041 - 'target' => [
1042 - '@type' => 'EntryPoint',
1043 - 'urlTemplate' => $search_url
1044 - ],
1045 - 'query-input' => 'required name=search_term_string'
1046 - ];
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;
1047 1193 }
1048 1194
1049 1195 // Social media profiles (sameAs)
1050 1196 $social_profiles = $this->get_social_profiles();
@@ -1052,9 +1198,10 @@
1052 1198 $schema['sameAs'] = $social_profiles;
1053 1199 }
1054 1200
1055 1201 // Language
1056 - $schema['inLanguage'] = get_locale();
1202 + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473).
1203 + $schema['inLanguage'] = get_bloginfo('language');
1057 1204
1058 1205 return $schema;
1059 1206 }
1060 1207
@@ -1081,13 +1228,13 @@
1081 1228 $schema['description'] = $this->truncate_text($data['content'], 160);
1082 1229 }
1083 1230
1084 1231 if (!empty($data['date'])) {
1085 - $schema['datePublished'] = $data['date'];
1232 + $schema['datePublished'] = $this->to_iso8601($data['date']);
1086 1233 }
1087 1234
1088 1235 if (!empty($data['modified'])) {
1089 - $schema['dateModified'] = $data['modified'];
1236 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
1090 1237 }
1091 1238
1092 1239 $schema['isPartOf'] = [
1093 1240 '@type' => 'WebSite',
@@ -1145,16 +1292,22 @@
1145 1292 }
1146 1293
1147 1294 $schema['mainEntity'] = $faq_data;
1148 1295
1149 - // Optional properties
1150 - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions';
1296 + // Optional properties. The FAQ form's own Page Title / Page URL fields
1297 + // win over the post's title and permalink — they were collected by the
1298 + // form and then never read, so typing in them changed nothing.
1299 + $schema['name'] = !empty($data['site_data']['faq_page_name'])
1300 + ? $data['site_data']['faq_page_name']
1301 + : ($data['title'] ?? 'Frequently Asked Questions');
1151 1302 if (!empty($data['excerpt'])) {
1152 1303 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1153 1304 }
1154 1305
1155 1306 // URL for the FAQ page
1156 - if (!empty($data['url'])) {
1307 + if (!empty($data['site_data']['faq_page_url'])) {
1308 + $schema['url'] = $data['site_data']['faq_page_url'];
1309 + } elseif (!empty($data['url'])) {
1157 1310 $schema['url'] = $data['url'];
1158 1311 }
1159 1312
1160 1313 // About - recommended property
@@ -1199,8 +1352,18 @@
1199 1352 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1200 1353 // Get business data from Site Identity Business Info (single source of truth)
1201 1354 $business_data = $this->get_business_data_from_site_identity();
1202 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 +
1203 1366 // Required properties - use business name from Business Info.
1204 1367 // `name` is required for LocalBusiness, so an empty saved value must fall
1205 1368 // through to the next source rather than emit "".
1206 1369 $schema['name'] = $this->first_non_empty(
@@ -1389,9 +1552,13 @@
1389 1552 * @return array Populated schema
1390 1553 */
1391 1554 private function populate_person_schema(array $schema, array $data, string $context): array {
1392 1555 // Required properties - prioritize user-configured fields
1393 - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? '';
1556 + $schema['name'] = $this->first_non_empty(
1557 + $data['site_data']['person_name'] ?? '',
1558 + $data['author']['name'] ?? '',
1559 + $data['title'] ?? ''
1560 + );
1394 1561
1395 1562 // Image from user configuration or fallback
1396 1563 if (!empty($data['site_data']['person_image'])) {
1397 1564 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
@@ -1466,10 +1633,21 @@
1466 1633 if (!empty($global_social_profiles)) {
1467 1634 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1468 1635 }
1469 1636
1470 - // Remove duplicates and empty values
1471 - $social_profiles = array_unique(array_filter($social_profiles));
1637 + // Remove duplicates, empties and anything that is not a URL. schema.org
1638 + // types sameAs as a URL, and the person social fields are free text, so
1639 + // without this a typed-in note shipped as a sameAs member and made the
1640 + // whole Person invalid (#480). get_social_profiles() above already
1641 + // filters its own values the same way.
1642 + $social_profiles = array_values(array_unique(array_filter(
1643 + $social_profiles,
1644 + static function ($url) {
1645 + return is_string($url)
1646 + && '' !== trim($url)
1647 + && \ThinkRank\Core\Url_Validator::is_http_url($url);
1648 + }
1649 + )));
1472 1650
1473 1651 if (!empty($social_profiles)) {
1474 1652 $schema['sameAs'] = $social_profiles;
1475 1653 }