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 +57 -20 2.4.0 → 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);
@@ -722,12 +728,25 @@
722 728 return $schema;
723 729 }
724 730
725 731 /**
726 - * Truncate text to specified length
727 - * PRESERVED: Exact same method logic from original Schema_Generator
732 + * Normalise text for JSON-LD and truncate it to a length.
728 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 + *
729 747 * @since 1.0.0
748 + * @since 2.10.0 Decodes entities and cuts on a word boundary.
730 749 *
731 750 * @param string $text Text to truncate
732 751 * @param int $length Maximum length
733 752 * @return string Truncated text
@@ -732,19 +751,12 @@
732 751 * @param int $length Maximum length
733 752 * @return string Truncated text
734 753 */
735 754 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)) . '...';
755 + return \ThinkRank\Core\Seo_Text::trim_to_length(
756 + \ThinkRank\Core\Seo_Text::normalize_schema_text($text),
757 + $length
758 + );
747 759 }
748 760
749 761 /**
750 762 * Normalise a date into ISO 8601 with a timezone offset.
@@ -827,17 +839,18 @@
827 839 '@type' => 'ImageObject',
828 840 'url' => $image_url
829 841 ];
830 842
831 - // Try to get image dimensions if it's a WordPress attachment
832 - $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);
833 846 if ($attachment_id) {
834 - $image_data = wp_get_attachment_image_src($attachment_id, 'full');
847 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
835 848 // SVGs report 0x0 — omit the dimensions rather than emitting
836 849 // 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];
850 + if ($image_file['width'] > 0 && $image_file['height'] > 0) {
851 + $image_schema['width'] = $image_file['width'];
852 + $image_schema['height'] = $image_file['height'];
840 853 }
841 854 }
842 855
843 856 return $image_schema;
@@ -1039,8 +1052,18 @@
1039 1052 $data['url'] ?? '',
1040 1053 home_url()
1041 1054 );
1042 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 +
1043 1066 // Recommended properties - prioritize user-configured Website schema description
1044 1067 if (!empty($data['site_data']['website_description'])) {
1045 1068 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
1046 1069 } elseif (!empty($data['content'])) {
@@ -1045,9 +1068,13 @@
1045 1068 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
1046 1069 } elseif (!empty($data['content'])) {
1047 1070 $schema['description'] = $this->truncate_text($data['content'], 160);
1048 1071 } else {
1049 - $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 + );
1050 1077 }
1051 1078
1052 1079 // Author - use organization or person data
1053 1080 if (!empty($data['site_data']['organization_name'])) {
@@ -1279,8 +1306,18 @@
1279 1306 */
1280 1307 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1281 1308 // Get business data from Site Identity Business Info (single source of truth)
1282 1309 $business_data = $this->get_business_data_from_site_identity();
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 + );
1283 1320
1284 1321 // Required properties - use business name from Business Info.
1285 1322 // `name` is required for LocalBusiness, so an empty saved value must fall
1286 1323 // through to the next source rather than emit "".