PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.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 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-schema-builder.php +188 -50 2.0.1 → 2.12.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
@@ -773,17 +839,18 @@
773 839 '@type' => 'ImageObject',
774 840 'url' => $image_url
775 841 ];
776 842
777 - // Try to get image dimensions if it's a WordPress attachment
778 - $attachment_id = attachment_url_to_postid($image_url);
843 + // Try to get image dimensions if it's a WordPress attachment — those
844 + // of the file this URL names, which need not be the original upload.
845 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
779 846 if ($attachment_id) {
780 - $image_data = wp_get_attachment_image_src($attachment_id, 'full');
847 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
781 848 // SVGs report 0x0 — omit the dimensions rather than emitting
782 849 // 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];
850 + if ($image_file['width'] > 0 && $image_file['height'] > 0) {
851 + $image_schema['width'] = $image_file['width'];
852 + $image_schema['height'] = $image_file['height'];
786 853 }
787 854 }
788 855
789 856 return $image_schema;
@@ -829,10 +896,19 @@
829 896 *
830 897 * @return array Social media profile URLs
831 898 */
832 899 private function get_social_profiles(): array {
833 - // Get Schema Manager settings for organization social profiles
834 - $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
900 + // Reuse one manager for the whole request. This method runs from inside
901 + // the foreign-settings listener, and constructing a fresh
902 + // Schema_Management_System on every Organization build was what let the
903 + // listener count double per save (#463). The constructor's static guard
904 + // stops the doubling; this stops the needless re-construction.
905 + static $schema_manager = null;
906 +
907 + if (null === $schema_manager) {
908 + $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
909 + }
910 +
835 911 $settings = $schema_manager->get_settings('site', null);
836 912
837 913 $social_profiles = [];
838 914
@@ -965,11 +1041,29 @@
965 1041 * @return array Populated schema
966 1042 */
967 1043 private function populate_website_schema(array $schema, array $data, string $context): array {
968 1044 // 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();
1045 + $schema['name'] = $this->first_non_empty(
1046 + $data['site_data']['website_name'] ?? '',
1047 + $data['title'] ?? '',
1048 + get_bloginfo('name')
1049 + );
1050 + $schema['url'] = $this->first_non_empty(
1051 + $data['site_data']['website_url'] ?? '',
1052 + $data['url'] ?? '',
1053 + home_url()
1054 + );
971 1055
1056 + // Both WebSite producers have to carry this or the deployed node and the
1057 + // default one disagree about the same site — the shape of failure #688
1058 + // documents. The default node is generate_website_schema() (#692).
1059 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema(
1060 + $data['site_data']['alternate_name'] ?? null
1061 + );
1062 + if (null !== $alternate_name) {
1063 + $schema['alternateName'] = $alternate_name;
1064 + }
1065 +
972 1066 // Recommended properties - prioritize user-configured Website schema description
973 1067 if (!empty($data['site_data']['website_description'])) {
974 1068 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1069 } elseif (!empty($data['content'])) {
@@ -974,9 +1068,13 @@
974 1068 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
975 1069 } elseif (!empty($data['content'])) {
976 1070 $schema['description'] = $this->truncate_text($data['content'], 160);
977 1071 } else {
978 - $schema['description'] = get_bloginfo('description');
1072 + // The tagline is stored esc_html()'d by sanitize_option(), so an
1073 + // ampersand in it arrives as `&amp;`.
1074 + $schema['description'] = \ThinkRank\Core\Seo_Text::normalize_schema_text(
1075 + (string) get_bloginfo('description')
1076 + );
979 1077 }
980 1078
981 1079 // Author - use organization or person data
982 1080 if (!empty($data['site_data']['organization_name'])) {
@@ -1034,9 +1132,12 @@
1034 1132 $schema['publisher'] = $publisher;
1035 1133
1036 1134 // Search action for sitelinks search box (optional but recommended)
1037 1135 if ($data['site_data']['website_enable_search'] ?? true) {
1038 - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}');
1136 + $search_url = $this->first_non_empty(
1137 + $data['site_data']['website_search_url'] ?? '',
1138 + home_url('/?s={search_term_string}')
1139 + );
1039 1140 $schema['potentialAction'] = [
1040 1141 '@type' => 'SearchAction',
1041 1142 'target' => [
1042 1143 '@type' => 'EntryPoint',
@@ -1052,9 +1153,10 @@
1052 1153 $schema['sameAs'] = $social_profiles;
1053 1154 }
1054 1155
1055 1156 // Language
1056 - $schema['inLanguage'] = get_locale();
1157 + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473).
1158 + $schema['inLanguage'] = get_bloginfo('language');
1057 1159
1058 1160 return $schema;
1059 1161 }
1060 1162
@@ -1081,13 +1183,13 @@
1081 1183 $schema['description'] = $this->truncate_text($data['content'], 160);
1082 1184 }
1083 1185
1084 1186 if (!empty($data['date'])) {
1085 - $schema['datePublished'] = $data['date'];
1187 + $schema['datePublished'] = $this->to_iso8601($data['date']);
1086 1188 }
1087 1189
1088 1190 if (!empty($data['modified'])) {
1089 - $schema['dateModified'] = $data['modified'];
1191 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
1090 1192 }
1091 1193
1092 1194 $schema['isPartOf'] = [
1093 1195 '@type' => 'WebSite',
@@ -1145,16 +1247,22 @@
1145 1247 }
1146 1248
1147 1249 $schema['mainEntity'] = $faq_data;
1148 1250
1149 - // Optional properties
1150 - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions';
1251 + // Optional properties. The FAQ form's own Page Title / Page URL fields
1252 + // win over the post's title and permalink — they were collected by the
1253 + // form and then never read, so typing in them changed nothing.
1254 + $schema['name'] = !empty($data['site_data']['faq_page_name'])
1255 + ? $data['site_data']['faq_page_name']
1256 + : ($data['title'] ?? 'Frequently Asked Questions');
1151 1257 if (!empty($data['excerpt'])) {
1152 1258 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1153 1259 }
1154 1260
1155 1261 // URL for the FAQ page
1156 - if (!empty($data['url'])) {
1262 + if (!empty($data['site_data']['faq_page_url'])) {
1263 + $schema['url'] = $data['site_data']['faq_page_url'];
1264 + } elseif (!empty($data['url'])) {
1157 1265 $schema['url'] = $data['url'];
1158 1266 }
1159 1267
1160 1268 // About - recommended property
@@ -1199,8 +1307,18 @@
1199 1307 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1200 1308 // Get business data from Site Identity Business Info (single source of truth)
1201 1309 $business_data = $this->get_business_data_from_site_identity();
1202 1310
1311 + // The business type chosen in Local SEO is what this node IS. It was
1312 + // read into $business_data and then never used, so every site published
1313 + // "@type": "LocalBusiness" whatever it had picked, and the ~150-type
1314 + // selector (#623) changed nothing but a meta tag. create_base_schema()
1315 + // has already put @type in place, so reassigning keeps key order and a
1316 + // site on the default publishes exactly what it did before.
1317 + $schema['@type'] = \ThinkRank\Config\Local_Business_Types_Config::schema_type(
1318 + $business_data['business_type'] ?? ''
1319 + );
1320 +
1203 1321 // Required properties - use business name from Business Info.
1204 1322 // `name` is required for LocalBusiness, so an empty saved value must fall
1205 1323 // through to the next source rather than emit "".
1206 1324 $schema['name'] = $this->first_non_empty(
@@ -1389,9 +1507,13 @@
1389 1507 * @return array Populated schema
1390 1508 */
1391 1509 private function populate_person_schema(array $schema, array $data, string $context): array {
1392 1510 // Required properties - prioritize user-configured fields
1393 - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? '';
1511 + $schema['name'] = $this->first_non_empty(
1512 + $data['site_data']['person_name'] ?? '',
1513 + $data['author']['name'] ?? '',
1514 + $data['title'] ?? ''
1515 + );
1394 1516
1395 1517 // Image from user configuration or fallback
1396 1518 if (!empty($data['site_data']['person_image'])) {
1397 1519 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
@@ -1466,10 +1588,26 @@
1466 1588 if (!empty($global_social_profiles)) {
1467 1589 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1468 1590 }
1469 1591
1470 - // Remove duplicates and empty values
1471 - $social_profiles = array_unique(array_filter($social_profiles));
1592 + // Remove duplicates, empties and anything that is not a URL. schema.org
1593 + // types sameAs as a URL, and the person social fields are free text, so
1594 + // without this a typed-in note shipped as a sameAs member and made the
1595 + // whole Person invalid (#480). get_social_profiles() above already
1596 + // filters its own values the same way.
1597 + $social_profiles = array_values(array_unique(array_filter(
1598 + $social_profiles,
1599 + static function ($url) {
1600 + return is_string($url)
1601 + && '' !== trim($url)
1602 + && filter_var($url, FILTER_VALIDATE_URL)
1603 + && in_array(
1604 + strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)),
1605 + ['http', 'https'],
1606 + true
1607 + );
1608 + }
1609 + )));
1472 1610
1473 1611 if (!empty($social_profiles)) {
1474 1612 $schema['sameAs'] = $social_profiles;
1475 1613 }