PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.28.0 1.27.0 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-site-identity-manager.php +154 -45 2.7.0 → 2.13.0 View file →
@@ -40,8 +40,36 @@
40 40 */
41 41 class Site_Identity_Manager extends Abstract_SEO_Manager {
42 42
43 43 /**
44 + * The per-context title formats as ThinkRank ships them.
45 + *
46 + * These are not in get_default_settings(): the admin screen seeds them on
47 + * first save, so on a real install they are stored values, indistinguishable
48 + * from a template the user typed. The migration needs to tell those two
49 + * apart — it may overwrite a shipped default with an imported template, and
50 + * must never overwrite a choice the user made — so this is the record of
51 + * what "untouched" looks like.
52 + *
53 + * Keep in step with getDefaultSettings() in
54 + * src/admin/components/essential-seo/SiteIdentityTab.js. SiteIdentityTitleFormatDefaultsTest
55 + * fails when the two drift.
56 + *
57 + * @since 2.8.0
58 + * @var array<string, string>
59 + */
60 + public const TITLE_FORMAT_DEFAULTS = [
61 + 'homepage_title' => '%site_title% %sep% %site_description%',
62 + 'post_title' => '%post_title% %sep% %site_title%',
63 + 'page_title' => '%page_title% %sep% %site_title%',
64 + 'category_title' => '%category_title% %sep% %site_title%',
65 + 'tag_title' => '%tag_title% %sep% %site_title%',
66 + 'author_title' => '%author_name% %sep% %site_title%',
67 + 'search_title' => 'Search Results for "%search_term%" %sep% %site_title%',
68 + 'archive_title' => '%archive_title% %sep% %site_title%',
69 + ];
70 +
71 + /**
44 72 * WordPress filesystem instance
45 73 *
46 74 * @since 1.0.0
47 75 * @var \WP_Filesystem_Base|null
@@ -480,11 +508,12 @@
480 508 * Attachment ID behind a configured icon URL, or 0 when it is not ours.
481 509 *
482 510 * attachment_url_to_postid() matches _wp_attached_file, which holds the
483 511 * ORIGINAL upload path, so the URL of a generated derivative
484 - * (`logo-512.png`) returns 0 — and that is exactly what the media picker
485 - * hands back when the user chooses a size. Strip the dimension suffix and
486 - * try the original once.
512 + * (`logo-512x512.png`) returns 0 — and that is exactly what the media
513 + * picker hands back when the user chooses a size. Attachment_Lookup falls
514 + * back to the original behind it; the fallback started here and moved
515 + * there when every other image lookup turned out to need it (#847).
487 516 *
488 517 * Shared with SEO_Manager's site-icon filter so both sides of the feature
489 518 * agree on which attachment a configured URL means.
490 519 *
@@ -493,21 +522,9 @@
493 522 * @param string $url Configured icon URL.
494 523 * @return int Attachment ID, or 0.
495 524 */
496 525 public static function icon_attachment_id(string $url): int {
497 - $attachment_id = (int) attachment_url_to_postid($url);
498 -
499 - if ($attachment_id) {
500 - return $attachment_id;
501 - }
502 -
503 - $original = preg_replace('/-\d+x\d+(?=\.[a-zA-Z0-9]+$)/', '', $url);
504 -
505 - if (is_string($original) && $original !== $url) {
506 - return (int) attachment_url_to_postid($original);
507 - }
508 -
509 - return 0;
526 + return Attachment_Lookup::id_from_url($url);
510 527 }
511 528
512 529 /**
513 530 * Which ICON_SIZES derivatives this attachment still needs.
@@ -1404,13 +1421,16 @@
1404 1421 }
1405 1422
1406 1423 // Additional logo analysis for local images
1407 1424 if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
1408 - $attachment_id = attachment_url_to_postid($logo_url);
1425 + $attachment_id = Attachment_Lookup::id_from_url($logo_url);
1409 1426 if ($attachment_id) {
1410 1427 $image_meta = wp_get_attachment_metadata($attachment_id);
1411 - $width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1412 - $height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1428 + // The configured file's own size — a logo picked at a generated
1429 + // size is not as large as the upload behind it.
1430 + $logo_file = Attachment_Lookup::describe($attachment_id, $logo_url);
1431 + $width = $logo_file['width'];
1432 + $height = $logo_file['height'];
1413 1433
1414 1434 // SVG logos store 0x0 metadata — no dimension/ratio analysis
1415 1435 // is possible (and dividing by 0 is fatal).
1416 1436 if ($image_meta && $width > 0 && $height > 0) {
@@ -1513,11 +1533,12 @@
1513 1533 $optimization['score'] -= 15;
1514 1534 }
1515 1535 }
1516 1536
1517 - // Business type validation
1518 - if (empty($settings['business_type'])) {
1519 - $optimization['suggestions'][] = 'Select a specific business type for better schema markup';
1537 + // Business type validation (shared rule, one message — #622).
1538 + $business_type = $this->business_type_status($settings);
1539 + if ('suggestion' === $business_type['status']) {
1540 + $optimization['suggestions'][] = $business_type['message'];
1520 1541 $optimization['score'] -= 5;
1521 1542 }
1522 1543
1523 1544 // Email validation
@@ -2047,24 +2068,17 @@
2047 2068 'icon' => '✗'
2048 2069 ];
2049 2070 }
2050 2071
2051 - // Business Type validation
2052 - if (!empty($settings['business_type']) && $settings['business_type'] !== 'LocalBusiness') {
2053 - $field_details[] = [
2054 - 'field' => 'business_type',
2055 - 'label' => 'Business type is selected for proper schema markup.',
2056 - 'status' => 'valid',
2057 - 'icon' => '✓'
2058 - ];
2059 - } else {
2060 - $field_details[] = [
2061 - 'field' => 'business_type',
2062 - 'label' => 'Specific business type selection recommended for better schema markup.',
2063 - 'status' => 'suggestion',
2064 - 'icon' => '⚠'
2065 - ];
2066 - }
2072 + // Business Type validation — see business_type_status() for why there
2073 + // is exactly one rule here now (#622).
2074 + $business_type = $this->business_type_status($settings);
2075 + $field_details[] = [
2076 + 'field' => 'business_type',
2077 + 'label' => $business_type['message'],
2078 + 'status' => $business_type['status'],
2079 + 'icon' => 'valid' === $business_type['status'] ? '✓' : '⚠',
2080 + ];
2067 2081
2068 2082 // Address validation (NAP consistency)
2069 2083 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
2070 2084 $address_complete = true;
@@ -2748,11 +2762,15 @@
2748 2762 } else {
2749 2763 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
2750 2764 }
2751 2765
2752 - // Validate business type
2753 - if (empty($settings['business_type'])) {
2754 - $validation['suggestions'][] = 'Select a specific business type for better schema markup';
2766 + // Business type, through the shared rule (#622). This is the only place
2767 + // it is reported on the generic path: validate_settings() with no tab
2768 + // context attaches basic-info field details, not business-info ones, so
2769 + // without this the setting would go unreported there entirely.
2770 + $business_type = $this->business_type_status($settings);
2771 + if ('suggestion' === $business_type['status']) {
2772 + $validation['suggestions'][] = $business_type['message'];
2755 2773 }
2756 2774
2757 2775 return $validation;
2758 2776 }
@@ -2888,8 +2906,10 @@
2888 2906 return [
2889 2907 // Title formats, one per context.
2890 2908 'homepage_title', 'post_title', 'page_title', 'category_title',
2891 2909 'tag_title', 'author_title', 'search_title', 'archive_title',
2910 + // The blog-index homepage's meta description (#897).
2911 + 'homepage_description',
2892 2912 // Breadcrumbs.
2893 2913 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
2894 2914 // Identity, as written by the setup wizard and the importers.
2895 2915 'alternate_name', 'identity_type', 'represents',
@@ -2945,12 +2965,86 @@
2945 2965 ? $sanitized['canonical_scheme']
2946 2966 : Url_Scheme::AUTOMATIC;
2947 2967 }
2948 2968
2969 + // Same reasoning again for the business type. It goes straight into
2970 + // LocalBusiness schema, so a type that is not in the schema.org
2971 + // vocabulary is invalid structured data — and storing it verbatim would
2972 + // have get-site-identity-settings report a type the site cannot
2973 + // actually publish. An empty value keeps meaning "not set"; anything
2974 + // else unrecognised falls back to the general-purpose root (#623).
2975 + if (array_key_exists('business_type', $sanitized)) {
2976 + $type = (string) $sanitized['business_type'];
2977 +
2978 + if ('' !== $type && !\ThinkRank\Config\Local_Business_Types_Config::is_valid($type)) {
2979 + $type = \ThinkRank\Config\Local_Business_Types_Config::ROOT;
2980 + }
2981 +
2982 + $sanitized['business_type'] = $type;
2983 + }
2984 +
2949 2985 return $sanitized;
2950 2986 }
2951 2987
2952 2988 /**
2989 + * schema.org's general-purpose LocalBusiness type.
2990 + *
2991 + * The default, the first option in the control, and a valid answer in its
2992 + * own right — which is the whole point of #622.
2993 + *
2994 + * @since 2.10.0
2995 + * @var string
2996 + */
2997 + private const GENERAL_BUSINESS_TYPE = 'LocalBusiness';
2998 +
2999 + /**
3000 + * The one rule for whether a business type needs the user's attention.
3001 + *
3002 + * There were three, with two wordings and two different conditions. Two
3003 + * fired when the value was empty; the third fired when it WAS
3004 + * `LocalBusiness` — which is the default, the first option in the control
3005 + * and a perfectly valid schema.org type. So the warning appeared out of the
3006 + * box for every site, could not be cleared without choosing a type that
3007 + * might be inaccurate, and on an empty value it appeared three times in two
3008 + * different phrasings, which is why it was reported as showing twice (#622).
3009 + *
3010 + * The rule now: a type is expected, and any type in the vocabulary is a
3011 + * correct answer. Only an unset value is worth prompting about.
3012 + * `LocalBusiness` is the general-purpose answer and is accepted as one —
3013 + * with a note that a more specific type sharpens the schema, phrased as the
3014 + * guidance it is rather than as a fault the user has to clear.
3015 + *
3016 + * @since 2.10.0
3017 + *
3018 + * @param array $settings Site identity settings.
3019 + * @return array{status:string,message:string} `valid` or `suggestion`.
3020 + */
3021 + private function business_type_status(array $settings): array {
3022 + $type = trim((string) ($settings['business_type'] ?? ''));
3023 +
3024 + if ('' === $type) {
3025 + return [
3026 + 'status' => 'suggestion',
3027 + 'message' => __('Select a business type so your local schema describes the right kind of business.', 'thinkrank'),
3028 + ];
3029 + }
3030 +
3031 + // The literal rather than a constant from the expanded type list (#623):
3032 + // that lands on its own branch, and this fix must not wait on it.
3033 + if (self::GENERAL_BUSINESS_TYPE === $type) {
3034 + return [
3035 + 'status' => 'valid',
3036 + 'message' => __('Business type is set to Local Business. A more specific type sharpens your schema, if one fits.', 'thinkrank'),
3037 + ];
3038 + }
3039 +
3040 + return [
3041 + 'status' => 'valid',
3042 + 'message' => __('Business type is selected for proper schema markup.', 'thinkrank'),
3043 + ];
3044 + }
3045 +
3046 + /**
2953 3047 * Get default settings for a context type (implements interface)
2954 3048 *
2955 3049 * @since 1.0.0
2956 3050 *
@@ -3675,9 +3769,22 @@
3675 3769 // business sitemap and the sitemaps other plugins register both land
3676 3770 // here for the same reason, so they go through one list (#104).
3677 3771 $extra = [];
3678 3772
3679 - if (file_exists(ABSPATH . 'local-sitemap.xml')) {
3773 + // Not a file test. Under dynamic delivery the local sitemap is
3774 + // served from PHP and no file is ever written, so file_exists()
3775 + // silently dropped a sitemap the site really does publish (#752).
3776 + // On static sites the file is still what proves it, so both count.
3777 + $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
3778 +
3779 + if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
3780 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3781 +
3782 + $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
3783 + && $generator->publishes_local_sitemap();
3784 + }
3785 +
3786 + if ($local_sitemap_published) {
3680 3787 $extra[] = '/local-sitemap.xml';
3681 3788 }
3682 3789
3683 3790 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
@@ -4148,18 +4255,20 @@
4148 4255 return $optimization;
4149 4256 }
4150 4257
4151 4258 // Check if it's a local image
4152 - $attachment_id = attachment_url_to_postid($value);
4259 + $attachment_id = Attachment_Lookup::id_from_url($value);
4153 4260 if ($attachment_id) {
4154 4261 $image_meta = wp_get_attachment_metadata($attachment_id);
4155 4262
4156 4263 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
4157 - // Check recommended size
4264 + // Check recommended size, against the configured file itself
4265 + // rather than the upload it may have been generated from.
4158 4266 if (isset($config['recommended_size'])) {
4159 4267 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
4268 + $image_file = Attachment_Lookup::describe($attachment_id, $value);
4160 4269
4161 - if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
4270 + if ($image_file['width'] !== (int) $rec_width || $image_file['height'] !== (int) $rec_height) {
4162 4271 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
4163 4272 }
4164 4273 }
4165 4274