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-site-identity-manager.php +344 -59 2.6.0 → 2.12.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
@@ -303,8 +331,91 @@
303 331 }
304 332 }
305 333
306 334 /**
335 + * Save settings, then refresh what a new canonical scheme invalidates.
336 + *
337 + * The static sitemap files are written with the scheme in force when they
338 + * were built, and nothing else rebuilds them until a post or term changes.
339 + * So a change of scheme left every `<loc>` on the old one while canonical
340 + * and og:url had already moved (#736). Every writer (the settings route,
341 + * the robots route, the MCP abilities, an import) lands here.
342 + *
343 + * @since 2.7.0
344 + *
345 + * @param string $context_type Context type.
346 + * @param int|null $context_id Context ID.
347 + * @param array $settings Settings to save.
348 + * @return bool
349 + */
350 + public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
351 + if (!self::touches_canonical_scheme($context_type, $context_id, $settings)) {
352 + return parent::save_settings($context_type, $context_id, $settings);
353 + }
354 +
355 + $before = Url_Scheme::preference();
356 + $saved = parent::save_settings($context_type, $context_id, $settings);
357 +
358 + if ($saved) {
359 + $this->on_canonical_scheme_saved($before);
360 + }
361 +
362 + return $saved;
363 + }
364 +
365 + /**
366 + * Whether a save can change the site-wide canonical scheme.
367 + *
368 + * @since 2.7.0
369 + *
370 + * @param string $context_type Context type.
371 + * @param int|null $context_id Context ID.
372 + * @param array $settings Settings being saved.
373 + * @return bool
374 + */
375 + public static function touches_canonical_scheme(string $context_type, ?int $context_id, array $settings): bool {
376 + return 'site' === sanitize_key($context_type)
377 + && empty($context_id)
378 + && array_key_exists('canonical_scheme', $settings);
379 + }
380 +
381 + /**
382 + * Rebuild the static sitemaps when the effective scheme changed.
383 + *
384 + * Compares the effective preference, filter included, so a site whose
385 + * scheme is pinned by `thinkrank_canonical_scheme` does not rebuild on a
386 + * stored value that changes nothing it publishes.
387 + *
388 + * @since 2.7.0
389 + *
390 + * @param string $before Effective scheme before the save.
391 + * @return void
392 + */
393 + protected function on_canonical_scheme_saved(string $before): void {
394 + // The preference is cached for the request; the save just changed it.
395 + Url_Scheme::reset();
396 +
397 + if (Url_Scheme::preference() === $before) {
398 + return;
399 + }
400 +
401 + $this->schedule_sitemap_rebuild();
402 + }
403 +
404 + /**
405 + * Queue a settings-driven sitemap rebuild.
406 + *
407 + * Debounced and run after the response, like any other settings change
408 + * that alters what the sitemap publishes.
409 + *
410 + * @since 2.7.0
411 + * @return void
412 + */
413 + protected function schedule_sitemap_rebuild(): void {
414 + (new Sitemap_Generator(false))->schedule_regeneration();
415 + }
416 +
417 + /**
307 418 * Build the icon derivatives for a newly chosen favicon.
308 419 *
309 420 * Runs on save, which is the only moment the choice changes and the only
310 421 * place image work belongs — resolving a size on the front end must stay a
@@ -397,11 +508,12 @@
397 508 * Attachment ID behind a configured icon URL, or 0 when it is not ours.
398 509 *
399 510 * attachment_url_to_postid() matches _wp_attached_file, which holds the
400 511 * ORIGINAL upload path, so the URL of a generated derivative
401 - * (`logo-512.png`) returns 0 — and that is exactly what the media picker
402 - * hands back when the user chooses a size. Strip the dimension suffix and
403 - * 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).
404 516 *
405 517 * Shared with SEO_Manager's site-icon filter so both sides of the feature
406 518 * agree on which attachment a configured URL means.
407 519 *
@@ -410,21 +522,9 @@
410 522 * @param string $url Configured icon URL.
411 523 * @return int Attachment ID, or 0.
412 524 */
413 525 public static function icon_attachment_id(string $url): int {
414 - $attachment_id = (int) attachment_url_to_postid($url);
415 -
416 - if ($attachment_id) {
417 - return $attachment_id;
418 - }
419 -
420 - $original = preg_replace('/-\d+x\d+(?=\.[a-zA-Z0-9]+$)/', '', $url);
421 -
422 - if (is_string($original) && $original !== $url) {
423 - return (int) attachment_url_to_postid($original);
424 - }
425 -
426 - return 0;
526 + return Attachment_Lookup::id_from_url($url);
427 527 }
428 528
429 529 /**
430 530 * Which ICON_SIZES derivatives this attachment still needs.
@@ -1178,17 +1278,19 @@
1178 1278 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
1179 1279 if (empty($home_text)) {
1180 1280 $optimization['warnings'][] = 'Empty home text reduces accessibility for screen readers';
1181 1281 $optimization['score'] -= 15;
1182 - } elseif (strlen($home_text) > 20) {
1183 - $optimization['suggestions'][] = 'Keep home text concise (current: ' . strlen($home_text) . ' chars)';
1282 + } elseif (mb_strlen($home_text) > 20) {
1283 + // mb_strlen: this number is shown to the user as "chars" (#687).
1284 + $optimization['suggestions'][] = 'Keep home text concise (current: ' . mb_strlen($home_text) . ' chars)';
1184 1285 $optimization['score'] -= 5;
1185 1286 }
1186 1287
1187 1288 // Check prefix usage
1188 1289 $prefix = $settings['breadcrumb_prefix'] ?? '';
1189 - if (!empty($prefix) && strlen($prefix) > 50) {
1190 - $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . strlen($prefix) . ' chars) - consider shortening';
1290 + if (!empty($prefix) && mb_strlen($prefix) > 50) {
1291 + // mb_strlen: this number is shown to the user as "chars" (#687).
1292 + $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . mb_strlen($prefix) . ' chars) - consider shortening';
1191 1293 $optimization['score'] -= 5;
1192 1294 }
1193 1295
1194 1296 // Current page display
@@ -1319,13 +1421,16 @@
1319 1421 }
1320 1422
1321 1423 // Additional logo analysis for local images
1322 1424 if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
1323 - $attachment_id = attachment_url_to_postid($logo_url);
1425 + $attachment_id = Attachment_Lookup::id_from_url($logo_url);
1324 1426 if ($attachment_id) {
1325 1427 $image_meta = wp_get_attachment_metadata($attachment_id);
1326 - $width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1327 - $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'];
1328 1433
1329 1434 // SVG logos store 0x0 metadata — no dimension/ratio analysis
1330 1435 // is possible (and dividing by 0 is fatal).
1331 1436 if ($image_meta && $width > 0 && $height > 0) {
@@ -1428,11 +1533,12 @@
1428 1533 $optimization['score'] -= 15;
1429 1534 }
1430 1535 }
1431 1536
1432 - // Business type validation
1433 - if (empty($settings['business_type'])) {
1434 - $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'];
1435 1541 $optimization['score'] -= 5;
1436 1542 }
1437 1543
1438 1544 // Email validation
@@ -1962,24 +2068,17 @@
1962 2068 'icon' => '✗'
1963 2069 ];
1964 2070 }
1965 2071
1966 - // Business Type validation
1967 - if (!empty($settings['business_type']) && $settings['business_type'] !== 'LocalBusiness') {
1968 - $field_details[] = [
1969 - 'field' => 'business_type',
1970 - 'label' => 'Business type is selected for proper schema markup.',
1971 - 'status' => 'valid',
1972 - 'icon' => '✓'
1973 - ];
1974 - } else {
1975 - $field_details[] = [
1976 - 'field' => 'business_type',
1977 - 'label' => 'Specific business type selection recommended for better schema markup.',
1978 - 'status' => 'suggestion',
1979 - 'icon' => '⚠'
1980 - ];
1981 - }
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 + ];
1982 2081
1983 2082 // Address validation (NAP consistency)
1984 2083 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1985 2084 $address_complete = true;
@@ -2663,11 +2762,15 @@
2663 2762 } else {
2664 2763 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
2665 2764 }
2666 2765
2667 - // Validate business type
2668 - if (empty($settings['business_type'])) {
2669 - $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'];
2670 2773 }
2671 2774
2672 2775 return $validation;
2673 2776 }
@@ -2759,8 +2862,47 @@
2759 2862 * @since 2.0.1
2760 2863 *
2761 2864 * @return string[]
2762 2865 */
2866 + /**
2867 + * The stored alternate name(s), shaped for schema output.
2868 + *
2869 + * schema.org and Google both allow `alternateName` to carry one value or
2870 + * several, and the store already round-trips either shape, so this accepts
2871 + * both and normalises: null when there is nothing to publish, a bare string
2872 + * for one name, a list for more. Emitting a one-element array would be
2873 + * valid but noisier than it needs to be.
2874 + *
2875 + * Shared because both WebSite producers need it and must agree — a property
2876 + * added to one and not the other is how #688 happened.
2877 + *
2878 + * @since 2.7.0
2879 + *
2880 + * @param mixed $value Stored alternate_name value.
2881 + * @return string|string[]|null
2882 + */
2883 + public static function alternate_name_for_schema($value) {
2884 + $names = [];
2885 +
2886 + foreach ((array) $value as $name) {
2887 + if (!is_scalar($name)) {
2888 + continue;
2889 + }
2890 +
2891 + $name = trim((string) $name);
2892 +
2893 + if ('' !== $name && !in_array($name, $names, true)) {
2894 + $names[] = $name;
2895 + }
2896 + }
2897 +
2898 + if (empty($names)) {
2899 + return null;
2900 + }
2901 +
2902 + return 1 === count($names) ? $names[0] : $names;
2903 + }
2904 +
2763 2905 protected function additional_setting_keys(): array {
2764 2906 return [
2765 2907 // Title formats, one per context.
2766 2908 'homepage_title', 'post_title', 'page_title', 'category_title',
@@ -2811,12 +2953,96 @@
2811 2953 if (array_key_exists('ai_crawler_rules', $sanitized)) {
2812 2954 $sanitized['ai_crawler_rules'] = AI_Crawlers::normalize_rules($sanitized['ai_crawler_rules']);
2813 2955 }
2814 2956
2957 + // Same reasoning one key up, for the scheme override (#638). Anything
2958 + // that is not one of the three modes means "follow WordPress", and is
2959 + // stored as that rather than kept verbatim — otherwise get-site-identity
2960 + // -settings would report a scheme the site does not actually publish.
2961 + if (array_key_exists('canonical_scheme', $sanitized)) {
2962 + $sanitized['canonical_scheme'] = in_array($sanitized['canonical_scheme'], Url_Scheme::MODES, true)
2963 + ? $sanitized['canonical_scheme']
2964 + : Url_Scheme::AUTOMATIC;
2965 + }
2966 +
2967 + // Same reasoning again for the business type. It goes straight into
2968 + // LocalBusiness schema, so a type that is not in the schema.org
2969 + // vocabulary is invalid structured data — and storing it verbatim would
2970 + // have get-site-identity-settings report a type the site cannot
2971 + // actually publish. An empty value keeps meaning "not set"; anything
2972 + // else unrecognised falls back to the general-purpose root (#623).
2973 + if (array_key_exists('business_type', $sanitized)) {
2974 + $type = (string) $sanitized['business_type'];
2975 +
2976 + if ('' !== $type && !\ThinkRank\Config\Local_Business_Types_Config::is_valid($type)) {
2977 + $type = \ThinkRank\Config\Local_Business_Types_Config::ROOT;
2978 + }
2979 +
2980 + $sanitized['business_type'] = $type;
2981 + }
2982 +
2815 2983 return $sanitized;
2816 2984 }
2817 2985
2818 2986 /**
2987 + * schema.org's general-purpose LocalBusiness type.
2988 + *
2989 + * The default, the first option in the control, and a valid answer in its
2990 + * own right — which is the whole point of #622.
2991 + *
2992 + * @since 2.10.0
2993 + * @var string
2994 + */
2995 + private const GENERAL_BUSINESS_TYPE = 'LocalBusiness';
2996 +
2997 + /**
2998 + * The one rule for whether a business type needs the user's attention.
2999 + *
3000 + * There were three, with two wordings and two different conditions. Two
3001 + * fired when the value was empty; the third fired when it WAS
3002 + * `LocalBusiness` — which is the default, the first option in the control
3003 + * and a perfectly valid schema.org type. So the warning appeared out of the
3004 + * box for every site, could not be cleared without choosing a type that
3005 + * might be inaccurate, and on an empty value it appeared three times in two
3006 + * different phrasings, which is why it was reported as showing twice (#622).
3007 + *
3008 + * The rule now: a type is expected, and any type in the vocabulary is a
3009 + * correct answer. Only an unset value is worth prompting about.
3010 + * `LocalBusiness` is the general-purpose answer and is accepted as one —
3011 + * with a note that a more specific type sharpens the schema, phrased as the
3012 + * guidance it is rather than as a fault the user has to clear.
3013 + *
3014 + * @since 2.10.0
3015 + *
3016 + * @param array $settings Site identity settings.
3017 + * @return array{status:string,message:string} `valid` or `suggestion`.
3018 + */
3019 + private function business_type_status(array $settings): array {
3020 + $type = trim((string) ($settings['business_type'] ?? ''));
3021 +
3022 + if ('' === $type) {
3023 + return [
3024 + 'status' => 'suggestion',
3025 + 'message' => __('Select a business type so your local schema describes the right kind of business.', 'thinkrank'),
3026 + ];
3027 + }
3028 +
3029 + // The literal rather than a constant from the expanded type list (#623):
3030 + // that lands on its own branch, and this fix must not wait on it.
3031 + if (self::GENERAL_BUSINESS_TYPE === $type) {
3032 + return [
3033 + 'status' => 'valid',
3034 + 'message' => __('Business type is set to Local Business. A more specific type sharpens your schema, if one fits.', 'thinkrank'),
3035 + ];
3036 + }
3037 +
3038 + return [
3039 + 'status' => 'valid',
3040 + 'message' => __('Business type is selected for proper schema markup.', 'thinkrank'),
3041 + ];
3042 + }
3043 +
3044 + /**
2819 3045 * Get default settings for a context type (implements interface)
2820 3046 *
2821 3047 * @since 1.0.0
2822 3048 *
@@ -2836,8 +3062,27 @@
2836 3062 'breadcrumb_home_text' => 'Home',
2837 3063 'breadcrumb_separator' => '>',
2838 3064 'robots_txt_enabled' => true,
2839 3065 'allow_search_engines' => true,
3066 + // Answer 404 when a content selector in the URL resolved to
3067 + // nothing (#634). On by default, unlike the other new settings
3068 + // here: it changes no URL a visitor or a correct crawler uses, only
3069 + // ones where WordPress resolved nothing and served the blog listing
3070 + // at 200 anyway.
3071 + 'query_protection' => true,
3072 +
3073 + // Feed controls (#635). All three off, so an upgrade changes
3074 + // nothing about what an existing site already sends its
3075 + // subscribers; a brand-new install is seeded with the signature and
3076 + // the noindex on, in Activator::seed_feed_defaults().
3077 + 'feed_excerpt_only' => false,
3078 + 'feed_source_link' => false,
3079 + 'feed_noindex' => false,
3080 +
3081 + // The scheme self-referential URLs go out with (#638). 'automatic'
3082 + // means substitute nothing and follow WordPress, which is what
3083 + // every site did before the setting existed.
3084 + 'canonical_scheme' => Url_Scheme::AUTOMATIC,
2840 3085 'robots_txt_content' => '',
2841 3086 // Empty map = every AI crawler allowed. Defaults must stay
2842 3087 // permissive so an upgrade never starts blocking a crawler a site
2843 3088 // was happily serving (#657).
@@ -3051,16 +3296,17 @@
3051 3296 // Remove extra whitespace
3052 3297 $title = preg_replace('/\s+/', ' ', $title);
3053 3298 $title = trim($title);
3054 3299
3055 - // Ensure title is not too long (60 characters max for SEO)
3056 - if (strlen($title) > 60) {
3057 - // Try to truncate at word boundary
3058 - $title = wp_trim_words($title, 8, '...');
3059 - if (strlen($title) > 60) {
3060 - $title = substr($title, 0, 57) . '...';
3061 - }
3062 - }
3300 + // Ensure title is not too long (60 characters max for SEO).
3301 + // All three units here were wrong for non-Latin text: strlen() counts
3302 + // BYTES so the gate fired at 20 Thai characters, wp_trim_words() counts
3303 + // CHARACTERS on th/ja/zh_* so `8` cut the title to 8 of them, and
3304 + // substr() cuts bytes so it split a character mid-sequence (#687).
3305 + $title = \ThinkRank\Core\Seo_Text::trim_to_length(
3306 + $title,
3307 + \ThinkRank\Core\Seo_Text::TITLE_MAX_LENGTH
3308 + );
3063 3309
3064 3310 // Ensure title is not empty
3065 3311 if (empty($title)) {
3066 3312 $title = get_bloginfo('name');
@@ -3450,8 +3696,29 @@
3450 3696 * @since 1.0.0
3451 3697 * @return array Array of sitemap URLs
3452 3698 */
3453 3699 private function get_sitemap_urls_for_robots(): array {
3700 + // One wrapper over every return path below, including the #104 extras.
3701 + // The Sitemap: line is the only absolute URL of ours in robots.txt and
3702 + // the one a crawler follows to find everything else, so it has to carry
3703 + // the site's scheme preference (#638). Applied here rather than where
3704 + // the body is assembled, because that path also renders a robots.txt a
3705 + // site owner typed themselves, and their text is not ours to rewrite.
3706 + return array_map(
3707 + static function (string $url): string {
3708 + return Url_Scheme::apply($url);
3709 + },
3710 + $this->collect_sitemap_urls_for_robots()
3711 + );
3712 + }
3713 +
3714 + /**
3715 + * The sitemap URLs robots.txt advertises, before the scheme preference.
3716 + *
3717 + * @since 1.0.0
3718 + * @return array Array of sitemap URLs
3719 + */
3720 + private function collect_sitemap_urls_for_robots(): array {
3454 3721 try {
3455 3722 // Get sitemap settings
3456 3723 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3457 3724 $sitemap_settings = $sitemap_generator->get_settings('site');
@@ -3500,9 +3767,22 @@
3500 3767 // business sitemap and the sitemaps other plugins register both land
3501 3768 // here for the same reason, so they go through one list (#104).
3502 3769 $extra = [];
3503 3770
3504 - if (file_exists(ABSPATH . 'local-sitemap.xml')) {
3771 + // Not a file test. Under dynamic delivery the local sitemap is
3772 + // served from PHP and no file is ever written, so file_exists()
3773 + // silently dropped a sitemap the site really does publish (#752).
3774 + // On static sites the file is still what proves it, so both count.
3775 + $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
3776 +
3777 + if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
3778 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3779 +
3780 + $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
3781 + && $generator->publishes_local_sitemap();
3782 + }
3783 +
3784 + if ($local_sitemap_published) {
3505 3785 $extra[] = '/local-sitemap.xml';
3506 3786 }
3507 3787
3508 3788 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
@@ -3927,11 +4207,14 @@
3927 4207 $optimization['validation']['valid'] = false;
3928 4208 }
3929 4209
3930 4210 if (!empty($value) && isset($config['max_length'])) {
3931 - if (strlen($value) > $config['max_length']) {
4211 + // The warning says "characters", so measure and cut in characters:
4212 + // strlen()/substr() fired early on non-Latin values and the
4213 + // suggested replacement was cut mid-character (#687).
4214 + if (mb_strlen($value) > $config['max_length']) {
3932 4215 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
3933 - $optimization['optimized_value'] = substr($value, 0, $config['max_length']);
4216 + $optimization['optimized_value'] = \ThinkRank\Core\Seo_Text::trim_to_length($value, (int) $config['max_length']);
3934 4217 }
3935 4218 }
3936 4219
3937 4220 // SEO-specific optimizations
@@ -3970,18 +4253,20 @@
3970 4253 return $optimization;
3971 4254 }
3972 4255
3973 4256 // Check if it's a local image
3974 - $attachment_id = attachment_url_to_postid($value);
4257 + $attachment_id = Attachment_Lookup::id_from_url($value);
3975 4258 if ($attachment_id) {
3976 4259 $image_meta = wp_get_attachment_metadata($attachment_id);
3977 4260
3978 4261 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
3979 - // Check recommended size
4262 + // Check recommended size, against the configured file itself
4263 + // rather than the upload it may have been generated from.
3980 4264 if (isset($config['recommended_size'])) {
3981 4265 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
4266 + $image_file = Attachment_Lookup::describe($attachment_id, $value);
3982 4267
3983 - if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
4268 + if ($image_file['width'] !== (int) $rec_width || $image_file['height'] !== (int) $rec_height) {
3984 4269 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
3985 4270 }
3986 4271 }
3987 4272