PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/seo/class-schema-builder.php +181 -44 1.29.0 → 2.10.0 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
@@ -506,31 +512,45 @@
506 512 }
507 513 }
508 514 }
509 515
510 - // Contact point from Business Info or contact configuration
511 - $contact_point = ['@type' => 'ContactPoint'];
512 - $has_contact_info = false;
516 + // Contact point: the Schema Manager's own fields win, then Business
517 + // Info. Reading telephone/email from Business Info alone and hard-coding
518 + // contactType left the Organization form's Contact Type, Phone and Email
519 + // inert — they saved but never reached the deployed markup, even though
520 + // Seo_Manager already applied this precedence for the same entity.
521 + $site_data = $data['site_data'] ?? [];
513 522
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 - }
523 + $contact_phone = $this->first_non_empty(
524 + $site_data['organization_contact_phone'] ?? null,
525 + $business_data['business_phone'] ?? null
526 + );
519 527
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 - }
528 + $contact_email = $this->first_non_empty(
529 + $site_data['organization_contact_email'] ?? null,
530 + $business_data['business_email'] ?? null
531 + );
525 532
526 - // Add contact type and hours if available
527 - if ($has_contact_info) {
528 - $contact_point['contactType'] = 'customer service';
533 + if ('' !== $contact_phone || '' !== $contact_email) {
534 + $contact_point = [
535 + '@type' => 'ContactPoint',
536 + 'contactType' => $this->first_non_empty(
537 + $site_data['organization_contact_type'] ?? null,
538 + 'customer service'
539 + ),
540 + ];
529 541
542 + if ('' !== $contact_phone) {
543 + $contact_point['telephone'] = $contact_phone;
544 + }
545 +
546 + if ('' !== $contact_email) {
547 + $contact_point['email'] = $contact_email;
548 + }
549 +
530 550 // 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'];
551 + if (!empty($site_data['organization_contact_hours'])) {
552 + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours'];
533 553 }
534 554
535 555 $schema['contactPoint'] = $contact_point;
536 556 }
@@ -628,9 +648,9 @@
628 648 }
629 649
630 650 // datePublished + url from content context.
631 651 if (!empty($data['date'])) {
632 - $schema['datePublished'] = $data['date'];
652 + $schema['datePublished'] = $this->to_iso8601($data['date']);
633 653 }
634 654 if (!empty($data['url'])) {
635 655 $schema['url'] = $data['url'];
636 656 }
@@ -708,12 +728,25 @@
708 728 return $schema;
709 729 }
710 730
711 731 /**
712 - * Truncate text to specified length
713 - * PRESERVED: Exact same method logic from original Schema_Generator
732 + * Normalise text for JSON-LD and truncate it to a length.
714 733 *
734 + * Every description, headline and reviewBody this class emits passes
735 + * through here, so this is the one place the deployed nodes get the same
736 + * treatment the automatic ones have had since #766. Before, it only
737 + * stripped tags: the block editor stores `&` as `&`, which was then
738 + * published literally (nothing decodes JSON-LD), and the cut landed
739 + * mid-word with "..." glued on. A deployed node outranks the automatic
740 + * one, so deploying a page type made its description worse than not
741 + * deploying anything.
742 + *
743 + * Seo_Text::trim_to_length() is multibyte-aware, which the #473 fix here
744 + * already required (a byte cut mojibaked every non-Latin description),
745 + * and falls back to a character cut for scripts without spaces.
746 + *
715 747 * @since 1.0.0
748 + * @since 2.10.0 Decodes entities and cuts on a word boundary.
716 749 *
717 750 * @param string $text Text to truncate
718 751 * @param int $length Maximum length
719 752 * @return string Truncated text
@@ -718,13 +751,46 @@
718 751 * @param int $length Maximum length
719 752 * @return string Truncated text
720 753 */
721 754 private function truncate_text(string $text, int $length): string {
722 - $text = wp_strip_all_tags($text);
723 - if (strlen($text) <= $length) {
724 - return $text;
755 + return \ThinkRank\Core\Seo_Text::trim_to_length(
756 + \ThinkRank\Core\Seo_Text::normalize_schema_text($text),
757 + $length
758 + );
759 + }
760 +
761 + /**
762 + * Normalise a date into ISO 8601 with a timezone offset.
763 + *
764 + * Deployed schema is a stored snapshot, so rows written before #465 still
765 + * hold raw MySQL datetimes ("2026-08-23 10:19:10"). Google reports those as
766 + * an invalid date value and drops the Article rich result, so normalise on
767 + * the way out as well as on the way in.
768 + *
769 + * @since 1.16.0
770 + *
771 + * @param mixed $date Date in any parseable form.
772 + * @return string ISO 8601 date, or '' when the input cannot be parsed.
773 + */
774 + private function to_iso8601($date): string {
775 + if (empty($date) || !is_scalar($date)) {
776 + return '';
725 777 }
726 - return substr($text, 0, $length - 3) . '...';
778 +
779 + $date = (string) $date;
780 +
781 + // Already ISO 8601 (has the date/time separator) — leave it alone.
782 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $date)) {
783 + return $date;
784 + }
785 +
786 + $timestamp = strtotime($date);
787 +
788 + if (false === $timestamp) {
789 + return '';
790 + }
791 +
792 + return (string) wp_date('c', $timestamp);
727 793 }
728 794
729 795 /**
730 796 * Format author schema
@@ -829,10 +895,19 @@
829 895 *
830 896 * @return array Social media profile URLs
831 897 */
832 898 private function get_social_profiles(): array {
833 - // Get Schema Manager settings for organization social profiles
834 - $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
899 + // Reuse one manager for the whole request. This method runs from inside
900 + // the foreign-settings listener, and constructing a fresh
901 + // Schema_Management_System on every Organization build was what let the
902 + // listener count double per save (#463). The constructor's static guard
903 + // stops the doubling; this stops the needless re-construction.
904 + static $schema_manager = null;
905 +
906 + if (null === $schema_manager) {
907 + $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
908 + }
909 +
835 910 $settings = $schema_manager->get_settings('site', null);
836 911
837 912 $social_profiles = [];
838 913
@@ -965,11 +1040,29 @@
965 1040 * @return array Populated schema
966 1041 */
967 1042 private function populate_website_schema(array $schema, array $data, string $context): array {
968 1043 // 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();
1044 + $schema['name'] = $this->first_non_empty(
1045 + $data['site_data']['website_name'] ?? '',
1046 + $data['title'] ?? '',
1047 + get_bloginfo('name')
1048 + );
1049 + $schema['url'] = $this->first_non_empty(
1050 + $data['site_data']['website_url'] ?? '',
1051 + $data['url'] ?? '',
1052 + home_url()
1053 + );
971 1054
1055 + // Both WebSite producers have to carry this or the deployed node and the
1056 + // default one disagree about the same site — the shape of failure #688
1057 + // documents. The default node is generate_website_schema() (#692).
1058 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema(
1059 + $data['site_data']['alternate_name'] ?? null
1060 + );
1061 + if (null !== $alternate_name) {
1062 + $schema['alternateName'] = $alternate_name;
1063 + }
1064 +
972 1065 // Recommended properties - prioritize user-configured Website schema description
973 1066 if (!empty($data['site_data']['website_description'])) {
974 1067 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1068 } elseif (!empty($data['content'])) {
@@ -974,9 +1067,13 @@
974 1067 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1068 } elseif (!empty($data['content'])) {
976 1069 $schema['description'] = $this->truncate_text($data['content'], 160);
977 1070 } else {
978 - $schema['description'] = get_bloginfo('description');
1071 + // The tagline is stored esc_html()'d by sanitize_option(), so an
1072 + // ampersand in it arrives as `&amp;`.
1073 + $schema['description'] = \ThinkRank\Core\Seo_Text::normalize_schema_text(
1074 + (string) get_bloginfo('description')
1075 + );
979 1076 }
980 1077
981 1078 // Author - use organization or person data
982 1079 if (!empty($data['site_data']['organization_name'])) {
@@ -1034,9 +1131,12 @@
1034 1131 $schema['publisher'] = $publisher;
1035 1132
1036 1133 // Search action for sitelinks search box (optional but recommended)
1037 1134 if ($data['site_data']['website_enable_search'] ?? true) {
1038 - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}');
1135 + $search_url = $this->first_non_empty(
1136 + $data['site_data']['website_search_url'] ?? '',
1137 + home_url('/?s={search_term_string}')
1138 + );
1039 1139 $schema['potentialAction'] = [
1040 1140 '@type' => 'SearchAction',
1041 1141 'target' => [
1042 1142 '@type' => 'EntryPoint',
@@ -1052,9 +1152,10 @@
1052 1152 $schema['sameAs'] = $social_profiles;
1053 1153 }
1054 1154
1055 1155 // Language
1056 - $schema['inLanguage'] = get_locale();
1156 + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473).
1157 + $schema['inLanguage'] = get_bloginfo('language');
1057 1158
1058 1159 return $schema;
1059 1160 }
1060 1161
@@ -1081,13 +1182,13 @@
1081 1182 $schema['description'] = $this->truncate_text($data['content'], 160);
1082 1183 }
1083 1184
1084 1185 if (!empty($data['date'])) {
1085 - $schema['datePublished'] = $data['date'];
1186 + $schema['datePublished'] = $this->to_iso8601($data['date']);
1086 1187 }
1087 1188
1088 1189 if (!empty($data['modified'])) {
1089 - $schema['dateModified'] = $data['modified'];
1190 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
1090 1191 }
1091 1192
1092 1193 $schema['isPartOf'] = [
1093 1194 '@type' => 'WebSite',
@@ -1145,16 +1246,22 @@
1145 1246 }
1146 1247
1147 1248 $schema['mainEntity'] = $faq_data;
1148 1249
1149 - // Optional properties
1150 - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions';
1250 + // Optional properties. The FAQ form's own Page Title / Page URL fields
1251 + // win over the post's title and permalink — they were collected by the
1252 + // form and then never read, so typing in them changed nothing.
1253 + $schema['name'] = !empty($data['site_data']['faq_page_name'])
1254 + ? $data['site_data']['faq_page_name']
1255 + : ($data['title'] ?? 'Frequently Asked Questions');
1151 1256 if (!empty($data['excerpt'])) {
1152 1257 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1153 1258 }
1154 1259
1155 1260 // URL for the FAQ page
1156 - if (!empty($data['url'])) {
1261 + if (!empty($data['site_data']['faq_page_url'])) {
1262 + $schema['url'] = $data['site_data']['faq_page_url'];
1263 + } elseif (!empty($data['url'])) {
1157 1264 $schema['url'] = $data['url'];
1158 1265 }
1159 1266
1160 1267 // About - recommended property
@@ -1199,8 +1306,18 @@
1199 1306 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1200 1307 // Get business data from Site Identity Business Info (single source of truth)
1201 1308 $business_data = $this->get_business_data_from_site_identity();
1202 1309
1310 + // The business type chosen in Local SEO is what this node IS. It was
1311 + // read into $business_data and then never used, so every site published
1312 + // "@type": "LocalBusiness" whatever it had picked, and the ~150-type
1313 + // selector (#623) changed nothing but a meta tag. create_base_schema()
1314 + // has already put @type in place, so reassigning keeps key order and a
1315 + // site on the default publishes exactly what it did before.
1316 + $schema['@type'] = \ThinkRank\Config\Local_Business_Types_Config::schema_type(
1317 + $business_data['business_type'] ?? ''
1318 + );
1319 +
1203 1320 // Required properties - use business name from Business Info.
1204 1321 // `name` is required for LocalBusiness, so an empty saved value must fall
1205 1322 // through to the next source rather than emit "".
1206 1323 $schema['name'] = $this->first_non_empty(
@@ -1389,9 +1506,13 @@
1389 1506 * @return array Populated schema
1390 1507 */
1391 1508 private function populate_person_schema(array $schema, array $data, string $context): array {
1392 1509 // Required properties - prioritize user-configured fields
1393 - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? '';
1510 + $schema['name'] = $this->first_non_empty(
1511 + $data['site_data']['person_name'] ?? '',
1512 + $data['author']['name'] ?? '',
1513 + $data['title'] ?? ''
1514 + );
1394 1515
1395 1516 // Image from user configuration or fallback
1396 1517 if (!empty($data['site_data']['person_image'])) {
1397 1518 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
@@ -1466,10 +1587,26 @@
1466 1587 if (!empty($global_social_profiles)) {
1467 1588 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1468 1589 }
1469 1590
1470 - // Remove duplicates and empty values
1471 - $social_profiles = array_unique(array_filter($social_profiles));
1591 + // Remove duplicates, empties and anything that is not a URL. schema.org
1592 + // types sameAs as a URL, and the person social fields are free text, so
1593 + // without this a typed-in note shipped as a sameAs member and made the
1594 + // whole Person invalid (#480). get_social_profiles() above already
1595 + // filters its own values the same way.
1596 + $social_profiles = array_values(array_unique(array_filter(
1597 + $social_profiles,
1598 + static function ($url) {
1599 + return is_string($url)
1600 + && '' !== trim($url)
1601 + && filter_var($url, FILTER_VALIDATE_URL)
1602 + && in_array(
1603 + strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)),
1604 + ['http', 'https'],
1605 + true
1606 + );
1607 + }
1608 + )));
1472 1609
1473 1610 if (!empty($social_profiles)) {
1474 1611 $schema['sameAs'] = $social_profiles;
1475 1612 }