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 +111 -44 2.7.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);
@@ -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) {
@@ -722,12 +781,25 @@
722 781 return $schema;
723 782 }
724 783
725 784 /**
726 - * Truncate text to specified length
727 - * PRESERVED: Exact same method logic from original Schema_Generator
785 + * Normalise text for JSON-LD and truncate it to a length.
728 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 + *
729 800 * @since 1.0.0
801 + * @since 2.10.0 Decodes entities and cuts on a word boundary.
730 802 *
731 803 * @param string $text Text to truncate
732 804 * @param int $length Maximum length
733 805 * @return string Truncated text
@@ -732,19 +804,12 @@
732 804 * @param int $length Maximum length
733 805 * @return string Truncated text
734 806 */
735 807 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)) . '...';
808 + return \ThinkRank\Core\Seo_Text::trim_to_length(
809 + \ThinkRank\Core\Seo_Text::normalize_schema_text($text),
810 + $length
811 + );
747 812 }
748 813
749 814 /**
750 815 * Normalise a date into ISO 8601 with a timezone offset.
@@ -827,17 +892,18 @@
827 892 '@type' => 'ImageObject',
828 893 'url' => $image_url
829 894 ];
830 895
831 - // Try to get image dimensions if it's a WordPress attachment
832 - $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);
833 899 if ($attachment_id) {
834 - $image_data = wp_get_attachment_image_src($attachment_id, 'full');
900 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
835 901 // SVGs report 0x0 — omit the dimensions rather than emitting
836 902 // 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];
903 + if ($image_file['width'] > 0 && $image_file['height'] > 0) {
904 + $image_schema['width'] = $image_file['width'];
905 + $image_schema['height'] = $image_file['height'];
840 906 }
841 907 }
842 908
843 909 return $image_schema;
@@ -911,9 +977,9 @@
911 977 'organization_social_telegram'
912 978 ];
913 979
914 980 foreach ($social_fields as $field) {
915 - if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
981 + if (!empty($settings[$field]) && \ThinkRank\Core\Url_Validator::is_valid($settings[$field])) {
916 982 $social_profiles[] = $settings[$field];
917 983 }
918 984 }
919 985
@@ -1055,9 +1121,13 @@
1055 1121 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
1056 1122 } elseif (!empty($data['content'])) {
1057 1123 $schema['description'] = $this->truncate_text($data['content'], 160);
1058 1124 } else {
1059 - $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 + );
1060 1130 }
1061 1131
1062 1132 // Author - use organization or person data
1063 1133 if (!empty($data['site_data']['organization_name'])) {
@@ -1113,22 +1183,14 @@
1113 1183 }
1114 1184
1115 1185 $schema['publisher'] = $publisher;
1116 1186
1117 - // Search action for sitelinks search box (optional but recommended)
1118 - if ($data['site_data']['website_enable_search'] ?? true) {
1119 - $search_url = $this->first_non_empty(
1120 - $data['site_data']['website_search_url'] ?? '',
1121 - home_url('/?s={search_term_string}')
1122 - );
1123 - $schema['potentialAction'] = [
1124 - '@type' => 'SearchAction',
1125 - 'target' => [
1126 - '@type' => 'EntryPoint',
1127 - 'urlTemplate' => $search_url
1128 - ],
1129 - 'query-input' => 'required name=search_term_string'
1130 - ];
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;
1131 1193 }
1132 1194
1133 1195 // Social media profiles (sameAs)
1134 1196 $social_profiles = $this->get_social_profiles();
@@ -1290,8 +1352,18 @@
1290 1352 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1291 1353 // Get business data from Site Identity Business Info (single source of truth)
1292 1354 $business_data = $this->get_business_data_from_site_identity();
1293 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 +
1294 1366 // Required properties - use business name from Business Info.
1295 1367 // `name` is required for LocalBusiness, so an empty saved value must fall
1296 1368 // through to the next source rather than emit "".
1297 1369 $schema['name'] = $this->first_non_empty(
@@ -1571,14 +1643,9 @@
1571 1643 $social_profiles,
1572 1644 static function ($url) {
1573 1645 return is_string($url)
1574 1646 && '' !== 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 - );
1647 + && \ThinkRank\Core\Url_Validator::is_http_url($url);
1581 1648 }
1582 1649 )));
1583 1650
1584 1651 if (!empty($social_profiles)) {