PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
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 1.28.0 All 57 releases
← All changes | includes/seo/class-site-identity-manager.php +451 -59 2.7.0 → 2.14.2 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
@@ -290,8 +318,36 @@
290 318 * @var bool
291 319 */
292 320 private static bool $icon_sizes_listener_registered = false;
293 321
322 + /**
323 + * Whether the robots.txt resync listener is registered for this request.
324 + *
325 + * @since 2.14.0
326 + * @var bool
327 + */
328 + private static bool $robots_sync_listener_registered = false;
329 +
330 + /**
331 + * Flag set when a plugin change may have altered the sitemap set.
332 + *
333 + * @since 2.14.0
334 + * @var string
335 + */
336 + public const ROBOTS_RESYNC_OPTION = 'thinkrank_robots_txt_resync_pending';
337 +
338 + /**
339 + * Flag set when a plugin change may have altered the sitemap index.
340 + *
341 + * Separate from ROBOTS_RESYNC_OPTION because the two files exist
342 + * independently: that flag is only set when a physical robots.txt exists,
343 + * and the sitemap index needs rebuilding whether or not it does.
344 + *
345 + * @since 2.15.0
346 + * @var string
347 + */
348 + public const SITEMAP_RESYNC_OPTION = 'thinkrank_sitemap_contributors_changed';
349 +
294 350 public function __construct() {
295 351 parent::__construct('site_identity');
296 352
297 353 if (!self::$icon_sizes_listener_registered) {
@@ -300,11 +356,130 @@
300 356 // Admin only: resizing is not front-end work, and admin traffic is
301 357 // enough to run a one-time backfill promptly.
302 358 add_action('admin_init', [self::class, 'maybe_backfill_icon_sizes']);
303 359 }
360 +
361 + if (!self::$robots_sync_listener_registered) {
362 + self::$robots_sync_listener_registered = true;
363 +
364 + // A physical robots.txt bypasses PHP entirely, so composing the
365 + // Sitemap block at render time fixes the served output only on
366 + // sites with no file. Activating or deactivating a sitemap
367 + // contributor changes the set, and until #835 nothing rewrote the
368 + // file: the deactivated plugin's sitemap stayed advertised, serving
369 + // HTML to anything that followed it.
370 + add_action('activated_plugin', [self::class, 'flag_robots_txt_resync']);
371 + add_action('deactivated_plugin', [self::class, 'flag_robots_txt_resync']);
372 + add_action('init', [self::class, 'maybe_resync_robots_txt'], 99);
373 +
374 + // The sitemap index is a second static file listing the same
375 + // contributors, with its own rebuild path. #835 / #859 resynced
376 + // robots.txt only, so after Pro was deactivated the index kept
377 + // advertising news-sitemap.xml, which then served the home page
378 + // as HTML (#920).
379 + add_action('activated_plugin', [self::class, 'flag_sitemap_resync']);
380 + add_action('deactivated_plugin', [self::class, 'flag_sitemap_resync']);
381 + add_action('init', [self::class, 'maybe_resync_sitemap'], 99);
382 + }
304 383 }
305 384
306 385 /**
386 + * Note that the set of sitemap contributors may have changed.
387 + *
388 + * Deliberately unconditional about which plugin: a contributor is anything
389 + * hooking `thinkrank_additional_sitemaps`, which is resolved at runtime and
390 + * cannot be inspected for a plugin that is on its way out.
391 + *
392 + * The rewrite is not done here. `deactivated_plugin` fires inside the
393 + * request that deactivated it, while that plugin's filters are still
394 + * attached, so rendering now still sees the sitemap that is going away —
395 + * measured, not assumed: the first version of this fix wrote the
396 + * deactivated plugin's sitemap straight back into the file. The next
397 + * request has the real plugin set loaded, so the work waits for it.
398 + *
399 + * @since 2.14.0
400 + * @return void
401 + */
402 + public static function flag_robots_txt_resync(): void {
403 + if (!file_exists(ABSPATH . 'robots.txt')) {
404 + return;
405 + }
406 +
407 + update_option(self::ROBOTS_RESYNC_OPTION, 1, false);
408 + }
409 +
410 + /**
411 + * Rewrite the physical robots.txt once, on the request after a change.
412 + *
413 + * @since 2.14.0
414 + * @return void
415 + */
416 + public static function maybe_resync_robots_txt(): void {
417 + if (!get_option(self::ROBOTS_RESYNC_OPTION)) {
418 + return;
419 + }
420 +
421 + // Cleared first, so a render that fatals cannot retry on every request
422 + // for the rest of the site's life.
423 + delete_option(self::ROBOTS_RESYNC_OPTION);
424 +
425 + if (!file_exists(ABSPATH . 'robots.txt')) {
426 + return;
427 + }
428 +
429 + (new self())->sync_robots_txt_file();
430 + }
431 +
432 + /**
433 + * Note that the set of sitemap contributors may have changed.
434 + *
435 + * Unconditional, unlike flag_robots_txt_resync(): the sitemap files exist
436 + * whether or not robots.txt does. The rebuild waits for the next request
437 + * for the same reason as the robots.txt one, since `deactivated_plugin`
438 + * still runs with the outgoing plugin's `thinkrank_additional_sitemaps`
439 + * callback attached.
440 + *
441 + * @since 2.15.0
442 + * @return void
443 + */
444 + public static function flag_sitemap_resync(): void {
445 + update_option(self::SITEMAP_RESYNC_OPTION, 1, false);
446 + }
447 +
448 + /**
449 + * Queue a sitemap rebuild once, on the request after a contributor change.
450 + *
451 + * Goes through schedule_regeneration(), the debounced and lock-protected
452 + * path a sitemap settings save uses, so a burst of plugin changes (a bulk
453 + * deactivate, say) still produces one rebuild. That path also drops the
454 + * cached dynamic documents, so sites serving the sitemap from PHP drop the
455 + * entry as well.
456 + *
457 + * @since 2.15.0
458 + * @return void
459 + */
460 + public static function maybe_resync_sitemap(): void {
461 + if (!get_option(self::SITEMAP_RESYNC_OPTION)) {
462 + return;
463 + }
464 +
465 + // Cleared first, so a rebuild that fatals cannot be retried on every
466 + // request for the rest of the site's life.
467 + delete_option(self::SITEMAP_RESYNC_OPTION);
468 +
469 + $generator = new Sitemap_Generator(false);
470 + $settings = $generator->get_settings('site');
471 +
472 + // A disabled sitemap has no files to correct. Enabling it later builds
473 + // from the contributors present at that time.
474 + if (empty($settings['enabled'])) {
475 + return;
476 + }
477 +
478 + $generator->schedule_regeneration();
479 + }
480 +
481 + /**
307 482 * Save settings, then refresh what a new canonical scheme invalidates.
308 483 *
309 484 * The static sitemap files are written with the scheme in force when they
310 485 * were built, and nothing else rebuilds them until a post or term changes.
@@ -480,11 +655,12 @@
480 655 * Attachment ID behind a configured icon URL, or 0 when it is not ours.
481 656 *
482 657 * attachment_url_to_postid() matches _wp_attached_file, which holds the
483 658 * 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.
659 + * (`logo-512x512.png`) returns 0 — and that is exactly what the media
660 + * picker hands back when the user chooses a size. Attachment_Lookup falls
661 + * back to the original behind it; the fallback started here and moved
662 + * there when every other image lookup turned out to need it (#847).
487 663 *
488 664 * Shared with SEO_Manager's site-icon filter so both sides of the feature
489 665 * agree on which attachment a configured URL means.
490 666 *
@@ -493,21 +669,9 @@
493 669 * @param string $url Configured icon URL.
494 670 * @return int Attachment ID, or 0.
495 671 */
496 672 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;
673 + return Attachment_Lookup::id_from_url($url);
510 674 }
511 675
512 676 /**
513 677 * Which ICON_SIZES derivatives this attachment still needs.
@@ -797,9 +961,20 @@
797 961 // here rather than stored: the textarea holds the user's body, with
798 962 // the fenced block stripped out of every read and re-applied on every
799 963 // render. A site-wide block already disallows everyone, so adding the
800 964 // per-agent group there would be noise restating the same refusal.
965 + // Composed here rather than read from storage, for the same reason as
966 + // the AI block below: the set of sitemaps an install publishes is a
967 + // runtime fact. `robots_txt_content` is a snapshot of it taken at the
968 + // last save, and nothing invalidated that snapshot, so deactivating a
969 + // sitemap provider left its URL advertised and serving HTML (#835).
970 + // Composing it on every render means the advertisement agrees with what
971 + // the install publishes, in both directions, with no cache to expire.
801 972 if (!$fully_blocked) {
973 + $body = $this->apply_sitemap_block($body);
974 + }
975 +
976 + if (!$fully_blocked) {
802 977 $body = $this->apply_ai_crawler_block($body, $settings);
803 978 }
804 979
805 980 if ($body === '') {
@@ -809,8 +984,87 @@
809 984 return $this->robots_txt_header() . $body . "\n";
810 985 }
811 986
812 987 /**
988 + * Replace the generated Sitemap block with the one this install publishes.
989 + *
990 + * @since 2.14.0
991 + * @param string $body Robots.txt body, without the header.
992 + * @return string
993 + */
994 + private function apply_sitemap_block(string $body): string {
995 + $stripped = $this->strip_generated_sitemap_block($body);
996 + $urls = $this->get_sitemap_urls_for_robots();
997 +
998 + if (empty($urls)) {
999 + return $stripped;
1000 + }
1001 +
1002 + $block = '';
1003 + foreach ($urls as $url) {
1004 + $block .= 'Sitemap: ' . $url . "\n";
1005 + }
1006 +
1007 + if ('' === trim($stripped)) {
1008 + return trim($block);
1009 + }
1010 +
1011 + // The grammar build_robots_txt_content() writes: one blank line before
1012 + // the block, none inside it. A blank line terminates a record in the
1013 + // robots.txt grammar, so a line between every directive is invalid.
1014 + return rtrim($stripped) . "\n\n" . trim($block);
1015 + }
1016 +
1017 + /**
1018 + * Remove the plugin-written Sitemap block from a stored body.
1019 + *
1020 + * Only the trailing run of `Sitemap:` lines is removed, which is the exact
1021 + * shape `build_robots_txt_content()` writes: a blank line, then nothing but
1022 + * `Sitemap:` lines to the end of the body. A `Sitemap:` line anywhere else
1023 + * was typed by the site owner and is left exactly where they put it, which
1024 + * is why this cannot simply strip every matching line.
1025 + *
1026 + * @since 2.14.0
1027 + * @param string $body Robots.txt body.
1028 + * @return string
1029 + */
1030 + private function strip_generated_sitemap_block(string $body): string {
1031 + $lines = preg_split('/\R/', $body);
1032 +
1033 + if (!is_array($lines)) {
1034 + return $body;
1035 + }
1036 +
1037 + $cut = count($lines);
1038 +
1039 + // Walk back over the trailing block: sitemap lines, and the blank lines
1040 + // that separate or pad it. Anything else ends the block.
1041 + for ($i = count($lines) - 1; $i >= 0; $i--) {
1042 + $line = trim($lines[$i]);
1043 +
1044 + if ('' === $line) {
1045 + $cut = $i;
1046 + continue;
1047 + }
1048 +
1049 + if (0 === stripos($line, 'sitemap:')) {
1050 + $cut = $i;
1051 + continue;
1052 + }
1053 +
1054 + break;
1055 + }
1056 +
1057 + if ($cut >= count($lines)) {
1058 + return $body;
1059 + }
1060 +
1061 + // Nothing but sitemap lines in the whole body means there is no owner
1062 + // content to keep.
1063 + return rtrim(implode("\n", array_slice($lines, 0, $cut)));
1064 + }
1065 +
1066 + /**
813 1067 * Resolve the robots.txt actually served to crawlers, with its origin.
814 1068 *
815 1069 * Lets an API/MCP consumer see the effective output without crawling the
816 1070 * URL. Mirrors serving precedence: a physical robots.txt in the web root is
@@ -1382,9 +1636,9 @@
1382 1636 $optimization['suggestions'][] = 'Add a site logo for better branding and professional appearance';
1383 1637 $optimization['score'] -= 20;
1384 1638 } else {
1385 1639 // Validate logo URL and dimensions
1386 - if (!filter_var($logo_url, FILTER_VALIDATE_URL)) {
1640 + if (!\ThinkRank\Core\Url_Validator::is_http_url($logo_url)) {
1387 1641 $optimization['warnings'][] = 'Logo URL format is invalid';
1388 1642 $optimization['score'] -= 15;
1389 1643 }
1390 1644 }
@@ -1403,14 +1657,17 @@
1403 1657 $optimization['score'] -= 10;
1404 1658 }
1405 1659
1406 1660 // Additional logo analysis for local images
1407 - if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
1408 - $attachment_id = attachment_url_to_postid($logo_url);
1661 + if (!empty($logo_url) && \ThinkRank\Core\Url_Validator::is_http_url($logo_url)) {
1662 + $attachment_id = Attachment_Lookup::id_from_url($logo_url);
1409 1663 if ($attachment_id) {
1410 1664 $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;
1665 + // The configured file's own size — a logo picked at a generated
1666 + // size is not as large as the upload behind it.
1667 + $logo_file = Attachment_Lookup::describe($attachment_id, $logo_url);
1668 + $width = $logo_file['width'];
1669 + $height = $logo_file['height'];
1413 1670
1414 1671 // SVG logos store 0x0 metadata — no dimension/ratio analysis
1415 1672 // is possible (and dividing by 0 is fatal).
1416 1673 if ($image_meta && $width > 0 && $height > 0) {
@@ -1513,11 +1770,12 @@
1513 1770 $optimization['score'] -= 15;
1514 1771 }
1515 1772 }
1516 1773
1517 - // Business type validation
1518 - if (empty($settings['business_type'])) {
1519 - $optimization['suggestions'][] = 'Select a specific business type for better schema markup';
1774 + // Business type validation (shared rule, one message — #622).
1775 + $business_type = $this->business_type_status($settings);
1776 + if ('suggestion' === $business_type['status']) {
1777 + $optimization['suggestions'][] = $business_type['message'];
1520 1778 $optimization['score'] -= 5;
1521 1779 }
1522 1780
1523 1781 // Email validation
@@ -1887,12 +2145,37 @@
1887 2145 $validation['suggestions'][] = 'Consider making site description longer (120-160 characters)';
1888 2146 }
1889 2147 }
1890 2148
1891 - // Validate logo URL
1892 - if (isset($settings['logo_url']) && !empty($settings['logo_url'])) {
1893 - if (!filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
1894 - $validation['errors'][] = 'Logo URL must be a valid URL';
2149 + // Image and link URLs. These are written into src and href
2150 + // attributes (the schema logo, the admin previews, the hero section),
2151 + // so only web URLs are accepted. is_valid() takes any scheme, and
2152 + // "javascript://%0Aalert(1)" passed it and was stored as
2153 + // "javascript://alert(1)" once sanitize_text_field() dropped the %0A.
2154 + // The logo and default social image must be absolute: they are
2155 + // published in schema and Open Graph, which require it. The others
2156 + // may also be a path on this site.
2157 + $url_fields = [
2158 + 'logo_url' => ['Logo URL', false],
2159 + 'default_social_image' => ['Default social image URL', false],
2160 + 'favicon_url' => ['Favicon URL', true],
2161 + 'apple_touch_icon_url' => ['Apple touch icon URL', true],
2162 + 'hero_background_image' => ['Hero background image URL', true],
2163 + 'hero_cta_url' => ['Call-to-action URL', true],
2164 + ];
2165 + foreach ($url_fields as $key => [$label, $allow_path]) {
2166 + if (!isset($settings[$key]) || '' === $settings[$key] || null === $settings[$key]) {
2167 + continue;
2168 + }
2169 +
2170 + $ok = $allow_path
2171 + ? \ThinkRank\Core\Url_Validator::is_http_url_or_path($settings[$key])
2172 + : \ThinkRank\Core\Url_Validator::is_http_url($settings[$key]);
2173 +
2174 + if (!$ok) {
2175 + $validation['errors'][] = $allow_path
2176 + ? sprintf('%s must be an http or https URL, or a path starting with /', $label)
2177 + : sprintf('%s must be an http or https URL', $label);
1895 2178 $validation['valid'] = false;
1896 2179 }
1897 2180 }
1898 2181
@@ -2047,24 +2330,17 @@
2047 2330 'icon' => '✗'
2048 2331 ];
2049 2332 }
2050 2333
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 - }
2334 + // Business Type validation — see business_type_status() for why there
2335 + // is exactly one rule here now (#622).
2336 + $business_type = $this->business_type_status($settings);
2337 + $field_details[] = [
2338 + 'field' => 'business_type',
2339 + 'label' => $business_type['message'],
2340 + 'status' => $business_type['status'],
2341 + 'icon' => 'valid' === $business_type['status'] ? '✓' : '⚠',
2342 + ];
2067 2343
2068 2344 // Address validation (NAP consistency)
2069 2345 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
2070 2346 $address_complete = true;
@@ -2337,9 +2613,9 @@
2337 2613 }
2338 2614
2339 2615 // CTA URL validation
2340 2616 if (!empty($settings['hero_cta_url'])) {
2341 - if (filter_var($settings['hero_cta_url'], FILTER_VALIDATE_URL) || strpos($settings['hero_cta_url'], '/') === 0) {
2617 + if (\ThinkRank\Core\Url_Validator::is_http_url_or_path($settings['hero_cta_url'])) {
2342 2618 $field_details[] = [
2343 2619 'field' => 'hero_cta_url',
2344 2620 'label' => 'Call-to-action URL is properly configured.',
2345 2621 'status' => 'valid',
@@ -2380,9 +2656,9 @@
2380 2656 }
2381 2657
2382 2658 // Site Logo validation (from Site Assets section)
2383 2659 if (!empty($settings['logo_url'])) {
2384 - if (filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
2660 + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['logo_url'])) {
2385 2661 $field_details[] = [
2386 2662 'field' => 'logo_url',
2387 2663 'label' => 'Site logo is properly configured.',
2388 2664 'status' => 'valid',
@@ -2748,11 +3024,15 @@
2748 3024 } else {
2749 3025 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
2750 3026 }
2751 3027
2752 - // Validate business type
2753 - if (empty($settings['business_type'])) {
2754 - $validation['suggestions'][] = 'Select a specific business type for better schema markup';
3028 + // Business type, through the shared rule (#622). This is the only place
3029 + // it is reported on the generic path: validate_settings() with no tab
3030 + // context attaches basic-info field details, not business-info ones, so
3031 + // without this the setting would go unreported there entirely.
3032 + $business_type = $this->business_type_status($settings);
3033 + if ('suggestion' === $business_type['status']) {
3034 + $validation['suggestions'][] = $business_type['message'];
2755 3035 }
2756 3036
2757 3037 return $validation;
2758 3038 }
@@ -2888,8 +3168,10 @@
2888 3168 return [
2889 3169 // Title formats, one per context.
2890 3170 'homepage_title', 'post_title', 'page_title', 'category_title',
2891 3171 'tag_title', 'author_title', 'search_title', 'archive_title',
3172 + // The blog-index homepage's meta description (#897).
3173 + 'homepage_description',
2892 3174 // Breadcrumbs.
2893 3175 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
2894 3176 // Identity, as written by the setup wizard and the importers.
2895 3177 'alternate_name', 'identity_type', 'represents',
@@ -2945,12 +3227,86 @@
2945 3227 ? $sanitized['canonical_scheme']
2946 3228 : Url_Scheme::AUTOMATIC;
2947 3229 }
2948 3230
3231 + // Same reasoning again for the business type. It goes straight into
3232 + // LocalBusiness schema, so a type that is not in the schema.org
3233 + // vocabulary is invalid structured data — and storing it verbatim would
3234 + // have get-site-identity-settings report a type the site cannot
3235 + // actually publish. An empty value keeps meaning "not set"; anything
3236 + // else unrecognised falls back to the general-purpose root (#623).
3237 + if (array_key_exists('business_type', $sanitized)) {
3238 + $type = (string) $sanitized['business_type'];
3239 +
3240 + if ('' !== $type && !\ThinkRank\Config\Local_Business_Types_Config::is_valid($type)) {
3241 + $type = \ThinkRank\Config\Local_Business_Types_Config::ROOT;
3242 + }
3243 +
3244 + $sanitized['business_type'] = $type;
3245 + }
3246 +
2949 3247 return $sanitized;
2950 3248 }
2951 3249
2952 3250 /**
3251 + * schema.org's general-purpose LocalBusiness type.
3252 + *
3253 + * The default, the first option in the control, and a valid answer in its
3254 + * own right — which is the whole point of #622.
3255 + *
3256 + * @since 2.10.0
3257 + * @var string
3258 + */
3259 + private const GENERAL_BUSINESS_TYPE = 'LocalBusiness';
3260 +
3261 + /**
3262 + * The one rule for whether a business type needs the user's attention.
3263 + *
3264 + * There were three, with two wordings and two different conditions. Two
3265 + * fired when the value was empty; the third fired when it WAS
3266 + * `LocalBusiness` — which is the default, the first option in the control
3267 + * and a perfectly valid schema.org type. So the warning appeared out of the
3268 + * box for every site, could not be cleared without choosing a type that
3269 + * might be inaccurate, and on an empty value it appeared three times in two
3270 + * different phrasings, which is why it was reported as showing twice (#622).
3271 + *
3272 + * The rule now: a type is expected, and any type in the vocabulary is a
3273 + * correct answer. Only an unset value is worth prompting about.
3274 + * `LocalBusiness` is the general-purpose answer and is accepted as one —
3275 + * with a note that a more specific type sharpens the schema, phrased as the
3276 + * guidance it is rather than as a fault the user has to clear.
3277 + *
3278 + * @since 2.10.0
3279 + *
3280 + * @param array $settings Site identity settings.
3281 + * @return array{status:string,message:string} `valid` or `suggestion`.
3282 + */
3283 + private function business_type_status(array $settings): array {
3284 + $type = trim((string) ($settings['business_type'] ?? ''));
3285 +
3286 + if ('' === $type) {
3287 + return [
3288 + 'status' => 'suggestion',
3289 + 'message' => __('Select a business type so your local schema describes the right kind of business.', 'thinkrank'),
3290 + ];
3291 + }
3292 +
3293 + // The literal rather than a constant from the expanded type list (#623):
3294 + // that lands on its own branch, and this fix must not wait on it.
3295 + if (self::GENERAL_BUSINESS_TYPE === $type) {
3296 + return [
3297 + 'status' => 'valid',
3298 + 'message' => __('Business type is set to Local Business. A more specific type sharpens your schema, if one fits.', 'thinkrank'),
3299 + ];
3300 + }
3301 +
3302 + return [
3303 + 'status' => 'valid',
3304 + 'message' => __('Business type is selected for proper schema markup.', 'thinkrank'),
3305 + ];
3306 + }
3307 +
3308 + /**
2953 3309 * Get default settings for a context type (implements interface)
2954 3310 *
2955 3311 * @since 1.0.0
2956 3312 *
@@ -3659,11 +4015,29 @@
3659 4015 }
3660 4016 }
3661 4017
3662 4018 if ($index_url !== '') {
3663 - // The index alone — it covers the children and, on a segmented
3664 - // install, the local business sitemap too.
3665 - return [$index_url];
4019 + // The index covers the children and, on a segmented install,
4020 + // the local business sitemap too.
4021 + //
4022 + // It does not cover a sitemap contributed through
4023 + // `thinkrank_additional_sitemaps`: the index is built by this
4024 + // plugin's own generator and never lists them. Returning the
4025 + // index alone therefore left a contributed sitemap with no
4026 + // discovery path at all — absent from robots.txt and absent
4027 + // from the index — so Pro's News sitemap was unreachable on any
4028 + // install with the index enabled, which is the default (#835).
4029 + $contributed = [];
4030 +
4031 + foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
4032 + $url = home_url($path);
4033 +
4034 + if ($url !== $index_url && !in_array($url, $contributed, true)) {
4035 + $contributed[] = $url;
4036 + }
4037 + }
4038 +
4039 + return array_merge([$index_url], $contributed);
3666 4040 }
3667 4041
3668 4042 // Fallback to default if no URLs found
3669 4043 if (empty($sitemap_urls)) {
@@ -3675,9 +4049,22 @@
3675 4049 // business sitemap and the sitemaps other plugins register both land
3676 4050 // here for the same reason, so they go through one list (#104).
3677 4051 $extra = [];
3678 4052
3679 - if (file_exists(ABSPATH . 'local-sitemap.xml')) {
4053 + // Not a file test. Under dynamic delivery the local sitemap is
4054 + // served from PHP and no file is ever written, so file_exists()
4055 + // silently dropped a sitemap the site really does publish (#752).
4056 + // On static sites the file is still what proves it, so both count.
4057 + $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
4058 +
4059 + if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
4060 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
4061 +
4062 + $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
4063 + && $generator->publishes_local_sitemap();
4064 + }
4065 +
4066 + if ($local_sitemap_published) {
3680 4067 $extra[] = '/local-sitemap.xml';
3681 4068 }
3682 4069
3683 4070 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
@@ -3740,9 +4127,12 @@
3740 4127 $validation['warnings'][] = "Path '{$value}' should start with '/'";
3741 4128 }
3742 4129 break;
3743 4130 case 'sitemap':
3744 - if (!filter_var($value, FILTER_VALIDATE_URL)) {
4131 + // Url_Validator, not the raw PHP filter: on a site with an
4132 + // internationalised domain the site's own sitemap URL is
4133 + // non-ASCII and the raw filter refused it.
4134 + if (!\ThinkRank\Core\Url_Validator::is_valid($value)) {
3745 4135 $validation['errors'][] = "Invalid sitemap URL: {$value}";
3746 4136 $validation['valid'] = false;
3747 4137 }
3748 4138 break;
@@ -4141,25 +4531,27 @@
4141 4531 return $optimization;
4142 4532 }
4143 4533
4144 4534 // Validate URL
4145 - if (!filter_var($value, FILTER_VALIDATE_URL)) {
4146 - $optimization['validation']['errors'][] = "{$element} must be a valid URL";
4535 + if (!\ThinkRank\Core\Url_Validator::is_http_url($value)) {
4536 + $optimization['validation']['errors'][] = "{$element} must be an http or https URL";
4147 4537 $optimization['validation']['valid'] = false;
4148 4538 return $optimization;
4149 4539 }
4150 4540
4151 4541 // Check if it's a local image
4152 - $attachment_id = attachment_url_to_postid($value);
4542 + $attachment_id = Attachment_Lookup::id_from_url($value);
4153 4543 if ($attachment_id) {
4154 4544 $image_meta = wp_get_attachment_metadata($attachment_id);
4155 4545
4156 4546 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
4157 - // Check recommended size
4547 + // Check recommended size, against the configured file itself
4548 + // rather than the upload it may have been generated from.
4158 4549 if (isset($config['recommended_size'])) {
4159 4550 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
4551 + $image_file = Attachment_Lookup::describe($attachment_id, $value);
4160 4552
4161 - if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
4553 + if ($image_file['width'] !== (int) $rec_width || $image_file['height'] !== (int) $rec_height) {
4162 4554 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
4163 4555 }
4164 4556 }
4165 4557