PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/seo/class-schema-builder.php +150 -39 2.0.02.7.0 View file →
@@ -204,11 +204,11 @@
204 204 }
205 205
206 206 // Date published - prioritize user-configured date
207 207 if (!empty($data['site_data']['article_date_published'])) {
208 - $schema['datePublished'] = $data['site_data']['article_date_published'];
208 + $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']);
209 209 } elseif (!empty($data['date'])) {
210 - $schema['datePublished'] = $data['date'];
210 + $schema['datePublished'] = $this->to_iso8601($data['date']);
211 211 } else {
212 212 $schema['datePublished'] = current_time('c');
213 213 }
214 214
@@ -229,11 +229,11 @@
229 229 }
230 230
231 231 // Date modified - prioritize user-configured date
232 232 if (!empty($data['site_data']['article_date_modified'])) {
233 - $schema['dateModified'] = $data['site_data']['article_date_modified'];
233 + $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']);
234 234 } elseif (!empty($data['modified'])) {
235 - $schema['dateModified'] = $data['modified'];
235 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
236 236 } else {
237 237 $schema['dateModified'] = $schema['datePublished'];
238 238 }
239 239
@@ -506,31 +506,45 @@
506 506 }
507 507 }
508 508 }
509 509
510 - // Contact point from Business Info or contact configuration
511 - $contact_point = ['@type' => 'ContactPoint'];
512 - $has_contact_info = false;
510 + // Contact point: the Schema Manager's own fields win, then Business
511 + // Info. Reading telephone/email from Business Info alone and hard-coding
512 + // contactType left the Organization form's Contact Type, Phone and Email
513 + // inert — they saved but never reached the deployed markup, even though
514 + // Seo_Manager already applied this precedence for the same entity.
515 + $site_data = $data['site_data'] ?? [];
513 516
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 - }
517 + $contact_phone = $this->first_non_empty(
518 + $site_data['organization_contact_phone'] ?? null,
519 + $business_data['business_phone'] ?? null
520 + );
519 521
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 - }
522 + $contact_email = $this->first_non_empty(
523 + $site_data['organization_contact_email'] ?? null,
524 + $business_data['business_email'] ?? null
525 + );
525 526
526 - // Add contact type and hours if available
527 - if ($has_contact_info) {
528 - $contact_point['contactType'] = 'customer service';
527 + if ('' !== $contact_phone || '' !== $contact_email) {
528 + $contact_point = [
529 + '@type' => 'ContactPoint',
530 + 'contactType' => $this->first_non_empty(
531 + $site_data['organization_contact_type'] ?? null,
532 + 'customer service'
533 + ),
534 + ];
529 535
536 + if ('' !== $contact_phone) {
537 + $contact_point['telephone'] = $contact_phone;
538 + }
539 +
540 + if ('' !== $contact_email) {
541 + $contact_point['email'] = $contact_email;
542 + }
543 +
530 544 // 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'];
545 + if (!empty($site_data['organization_contact_hours'])) {
546 + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours'];
533 547 }
534 548
535 549 $schema['contactPoint'] = $contact_point;
536 550 }
@@ -628,9 +642,9 @@
628 642 }
629 643
630 644 // datePublished + url from content context.
631 645 if (!empty($data['date'])) {
632 - $schema['datePublished'] = $data['date'];
646 + $schema['datePublished'] = $this->to_iso8601($data['date']);
633 647 }
634 648 if (!empty($data['url'])) {
635 649 $schema['url'] = $data['url'];
636 650 }
@@ -719,15 +733,55 @@
719 733 * @return string Truncated text
720 734 */
721 735 private function truncate_text(string $text, int $length): string {
722 736 $text = wp_strip_all_tags($text);
723 - if (strlen($text) <= $length) {
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) {
724 743 return $text;
725 744 }
726 - return substr($text, 0, $length - 3) . '...';
745 +
746 + return mb_substr($text, 0, max(0, $length - 3)) . '...';
727 747 }
728 748
729 749 /**
750 + * Normalise a date into ISO 8601 with a timezone offset.
751 + *
752 + * Deployed schema is a stored snapshot, so rows written before #465 still
753 + * hold raw MySQL datetimes ("2026-08-23 10:19:10"). Google reports those as
754 + * an invalid date value and drops the Article rich result, so normalise on
755 + * the way out as well as on the way in.
756 + *
757 + * @since 1.16.0
758 + *
759 + * @param mixed $date Date in any parseable form.
760 + * @return string ISO 8601 date, or '' when the input cannot be parsed.
761 + */
762 + private function to_iso8601($date): string {
763 + if (empty($date) || !is_scalar($date)) {
764 + return '';
765 + }
766 +
767 + $date = (string) $date;
768 +
769 + // Already ISO 8601 (has the date/time separator) — leave it alone.
770 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $date)) {
771 + return $date;
772 + }
773 +
774 + $timestamp = strtotime($date);
775 +
776 + if (false === $timestamp) {
777 + return '';
778 + }
779 +
780 + return (string) wp_date('c', $timestamp);
781 + }
782 +
783 + /**
730 784 * Format author schema
731 785 * PRESERVED: Exact same method logic from original Schema_Generator
732 786 *
733 787 * @since 1.0.0
@@ -829,10 +883,19 @@
829 883 *
830 884 * @return array Social media profile URLs
831 885 */
832 886 private function get_social_profiles(): array {
833 - // Get Schema Manager settings for organization social profiles
834 - $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
887 + // Reuse one manager for the whole request. This method runs from inside
888 + // the foreign-settings listener, and constructing a fresh
889 + // Schema_Management_System on every Organization build was what let the
890 + // listener count double per save (#463). The constructor's static guard
891 + // stops the doubling; this stops the needless re-construction.
892 + static $schema_manager = null;
893 +
894 + if (null === $schema_manager) {
895 + $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
896 + }
897 +
835 898 $settings = $schema_manager->get_settings('site', null);
836 899
837 900 $social_profiles = [];
838 901
@@ -965,11 +1028,29 @@
965 1028 * @return array Populated schema
966 1029 */
967 1030 private function populate_website_schema(array $schema, array $data, string $context): array {
968 1031 // 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();
1032 + $schema['name'] = $this->first_non_empty(
1033 + $data['site_data']['website_name'] ?? '',
1034 + $data['title'] ?? '',
1035 + get_bloginfo('name')
1036 + );
1037 + $schema['url'] = $this->first_non_empty(
1038 + $data['site_data']['website_url'] ?? '',
1039 + $data['url'] ?? '',
1040 + home_url()
1041 + );
971 1042
1043 + // Both WebSite producers have to carry this or the deployed node and the
1044 + // default one disagree about the same site — the shape of failure #688
1045 + // documents. The default node is generate_website_schema() (#692).
1046 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema(
1047 + $data['site_data']['alternate_name'] ?? null
1048 + );
1049 + if (null !== $alternate_name) {
1050 + $schema['alternateName'] = $alternate_name;
1051 + }
1052 +
972 1053 // Recommended properties - prioritize user-configured Website schema description
973 1054 if (!empty($data['site_data']['website_description'])) {
974 1055 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1056 } elseif (!empty($data['content'])) {
@@ -1034,9 +1115,12 @@
1034 1115 $schema['publisher'] = $publisher;
1035 1116
1036 1117 // Search action for sitelinks search box (optional but recommended)
1037 1118 if ($data['site_data']['website_enable_search'] ?? true) {
1038 - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}');
1119 + $search_url = $this->first_non_empty(
1120 + $data['site_data']['website_search_url'] ?? '',
1121 + home_url('/?s={search_term_string}')
1122 + );
1039 1123 $schema['potentialAction'] = [
1040 1124 '@type' => 'SearchAction',
1041 1125 'target' => [
1042 1126 '@type' => 'EntryPoint',
@@ -1052,9 +1136,10 @@
1052 1136 $schema['sameAs'] = $social_profiles;
1053 1137 }
1054 1138
1055 1139 // Language
1056 - $schema['inLanguage'] = get_locale();
1140 + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473).
1141 + $schema['inLanguage'] = get_bloginfo('language');
1057 1142
1058 1143 return $schema;
1059 1144 }
1060 1145
@@ -1081,13 +1166,13 @@
1081 1166 $schema['description'] = $this->truncate_text($data['content'], 160);
1082 1167 }
1083 1168
1084 1169 if (!empty($data['date'])) {
1085 - $schema['datePublished'] = $data['date'];
1170 + $schema['datePublished'] = $this->to_iso8601($data['date']);
1086 1171 }
1087 1172
1088 1173 if (!empty($data['modified'])) {
1089 - $schema['dateModified'] = $data['modified'];
1174 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
1090 1175 }
1091 1176
1092 1177 $schema['isPartOf'] = [
1093 1178 '@type' => 'WebSite',
@@ -1145,16 +1230,22 @@
1145 1230 }
1146 1231
1147 1232 $schema['mainEntity'] = $faq_data;
1148 1233
1149 - // Optional properties
1150 - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions';
1234 + // Optional properties. The FAQ form's own Page Title / Page URL fields
1235 + // win over the post's title and permalink — they were collected by the
1236 + // form and then never read, so typing in them changed nothing.
1237 + $schema['name'] = !empty($data['site_data']['faq_page_name'])
1238 + ? $data['site_data']['faq_page_name']
1239 + : ($data['title'] ?? 'Frequently Asked Questions');
1151 1240 if (!empty($data['excerpt'])) {
1152 1241 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1153 1242 }
1154 1243
1155 1244 // URL for the FAQ page
1156 - if (!empty($data['url'])) {
1245 + if (!empty($data['site_data']['faq_page_url'])) {
1246 + $schema['url'] = $data['site_data']['faq_page_url'];
1247 + } elseif (!empty($data['url'])) {
1157 1248 $schema['url'] = $data['url'];
1158 1249 }
1159 1250
1160 1251 // About - recommended property
@@ -1389,9 +1480,13 @@
1389 1480 * @return array Populated schema
1390 1481 */
1391 1482 private function populate_person_schema(array $schema, array $data, string $context): array {
1392 1483 // Required properties - prioritize user-configured fields
1393 - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? '';
1484 + $schema['name'] = $this->first_non_empty(
1485 + $data['site_data']['person_name'] ?? '',
1486 + $data['author']['name'] ?? '',
1487 + $data['title'] ?? ''
1488 + );
1394 1489
1395 1490 // Image from user configuration or fallback
1396 1491 if (!empty($data['site_data']['person_image'])) {
1397 1492 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
@@ -1466,10 +1561,26 @@
1466 1561 if (!empty($global_social_profiles)) {
1467 1562 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1468 1563 }
1469 1564
1470 - // Remove duplicates and empty values
1471 - $social_profiles = array_unique(array_filter($social_profiles));
1565 + // Remove duplicates, empties and anything that is not a URL. schema.org
1566 + // types sameAs as a URL, and the person social fields are free text, so
1567 + // without this a typed-in note shipped as a sameAs member and made the
1568 + // whole Person invalid (#480). get_social_profiles() above already
1569 + // filters its own values the same way.
1570 + $social_profiles = array_values(array_unique(array_filter(
1571 + $social_profiles,
1572 + static function ($url) {
1573 + return is_string($url)
1574 + && '' !== trim($url)
1575 + && filter_var($url, FILTER_VALIDATE_URL)
1576 + && in_array(
1577 + strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)),
1578 + ['http', 'https'],
1579 + true
1580 + );
1581 + }
1582 + )));
1472 1583
1473 1584 if (!empty($social_profiles)) {
1474 1585 $schema['sameAs'] = $social_profiles;
1475 1586 }