PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.0 1.0.1 All 51 releases
← All changes | includes/seo/class-site-identity-manager.php +1235 -139 1.0.2 → 2.9.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Site Identity Manager Class
4 5 *
5 6 * Comprehensive site identity management with title formats, separators,
@@ -14,8 +15,13 @@
14 15 declare(strict_types=1);
15 16
16 17 namespace ThinkRank\SEO;
17 18
19 +// Prevent direct access
20 +if (!defined('ABSPATH')) {
21 + exit;
22 +}
23 +
18 24 // Ensure dependencies are loaded
19 25 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
20 26 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
21 27 }
@@ -34,8 +40,36 @@
34 40 */
35 41 class Site_Identity_Manager extends Abstract_SEO_Manager {
36 42
37 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 + /**
38 72 * WordPress filesystem instance
39 73 *
40 74 * @since 1.0.0
41 75 * @var \WP_Filesystem_Base|null
@@ -66,9 +100,9 @@
66 100 *
67 101 * @since 1.0.0
68 102 * @var array
69 103 */
70 - private array $title_separators = [
104 + public static array $title_separators = [
71 105 'pipe' => [
72 106 'symbol' => '|',
73 107 'name' => 'Pipe',
74 108 'description' => 'Vertical bar separator (most common)',
@@ -106,8 +140,22 @@
106 140 ]
107 141 ];
108 142
109 143 /**
144 + * Get the currently active title separator symbol
145 + *
146 + * @since 1.0.0
147 + * @return string Separator symbol
148 + */
149 + public static function get_active_separator_symbol(): string {
150 + $manager = new self();
151 + $settings = $manager->get_settings('site');
152 + $separator_key = $settings['title_separator'] ?? 'pipe';
153 +
154 + return self::$title_separators[$separator_key]['symbol'] ?? '|';
155 + }
156 +
157 + /**
110 158 * Breadcrumb types and their configurations
111 159 *
112 160 * @since 1.0.0
113 161 * @var array
@@ -225,13 +273,358 @@
225 273 * Constructor
226 274 *
227 275 * @since 1.0.0
228 276 */
277 + /**
278 + * The square derivatives wp_site_icon() asks for.
279 + *
280 + * Core generates these only through its own Site Icon crop flow, so an
281 + * image chosen as a ThinkRank favicon straight from the media library has
282 + * none of them and every sizes="" declaration is a near miss (#571).
283 + *
284 + * @since 2.3.1
285 + * @var int[]
286 + */
287 + public const ICON_SIZES = [32, 180, 192, 270];
288 +
289 + /**
290 + * Transient holding resolved icon URLs, keyed by configured URL and size.
291 + *
292 + * The site-icon filter runs in wp_head on every FRONT-END request, and
293 + * resolving a URL to its attachment costs an uncached postmeta query. The
294 + * mapping only changes when the icon setting does, so it is cached here and
295 + * dropped on save.
296 + *
297 + * @since 2.3.1
298 + * @var string
299 + */
300 + public const ICON_URL_TRANSIENT = 'thinkrank_site_icon_urls';
301 +
302 + /**
303 + * Marker for the one-time derivative backfill on existing installs.
304 + *
305 + * @since 2.3.1
306 + * @var string
307 + */
308 + public const ICON_BACKFILL_OPTION = 'thinkrank_site_icon_sizes_backfilled';
309 +
310 + /**
311 + * Whether the icon-derivative listener has been registered this request.
312 + *
313 + * Static because `thinkrank_seo_settings_saved` is a global hook — one
314 + * listener serves every instance, and this class is constructed on the
315 + * front end as well as in admin.
316 + *
317 + * @since 2.3.1
318 + * @var bool
319 + */
320 + private static bool $icon_sizes_listener_registered = false;
321 +
229 322 public function __construct() {
230 323 parent::__construct('site_identity');
324 +
325 + if (!self::$icon_sizes_listener_registered) {
326 + self::$icon_sizes_listener_registered = true;
327 + add_action('thinkrank_seo_settings_saved', [$this, 'generate_icon_sizes_on_save'], 10, 2);
328 + // Admin only: resizing is not front-end work, and admin traffic is
329 + // enough to run a one-time backfill promptly.
330 + add_action('admin_init', [self::class, 'maybe_backfill_icon_sizes']);
331 + }
231 332 }
232 333
233 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 + /**
418 + * Build the icon derivatives for a newly chosen favicon.
419 + *
420 + * Runs on save, which is the only moment the choice changes and the only
421 + * place image work belongs — resolving a size on the front end must stay a
422 + * lookup. Failure is silent by design: a missing derivative degrades to the
423 + * next best file, so a site whose host cannot resize still renders an icon.
424 + *
425 + * @since 2.3.1
426 + *
427 + * @param string $manager_type Settings category that was saved.
428 + * @param array $settings The settings that were written.
429 + * @return void
430 + */
431 + public function generate_icon_sizes_on_save(string $manager_type, array $settings): void {
432 + if ('site_identity' !== $manager_type) {
433 + return;
434 + }
435 +
436 + // The choice, or the derivatives behind it, may have just changed.
437 + delete_transient(self::ICON_URL_TRANSIENT);
438 +
439 + foreach (['favicon_url', 'apple_touch_icon_url'] as $key) {
440 + if (empty($settings[$key]) || !is_string($settings[$key])) {
441 + continue;
442 + }
443 +
444 + $attachment_id = self::icon_attachment_id($settings[$key]);
445 +
446 + if ($attachment_id) {
447 + self::ensure_icon_sizes($attachment_id);
448 + }
449 + }
450 +
451 + // Dropped again after the resizes finish. Resizing is not instant, and a
452 + // front-end request arriving mid-generation would otherwise repopulate
453 + // the transient with the pre-derivative URLs and pin them for the full
454 + // TTL — leaving the sizes= declarations untrue until the next save.
455 + delete_transient(self::ICON_URL_TRANSIENT);
456 + }
457 +
458 + /**
459 + * Build the derivatives for a site that configured its icons before this
460 + * existed.
461 + *
462 + * generate_icon_sizes_on_save() only fires on a settings write, so every
463 + * site with an icon already chosen would keep serving whatever
464 + * wp_get_attachment_image_url() could find — in practice the 150x150
465 + * thumbnail behind a sizes="32x32" declaration — until someone happened to
466 + * re-save Site Identity. That is the bug this is meant to fix, so the
467 + * derivatives are built once on upgrade instead of waiting for a save.
468 + *
469 + * Guarded by its own option rather than the plugin version so it runs once
470 + * and stays cheap: the check is a single autoloaded read on requests after
471 + * the first.
472 + *
473 + * @since 2.3.1
474 + *
475 + * @return void
476 + */
477 + public static function maybe_backfill_icon_sizes(): void {
478 + if (get_option(self::ICON_BACKFILL_OPTION)) {
479 + return;
480 + }
481 +
482 + // Written before the work, not after: a host that cannot resize must
483 + // not retry on every admin request forever.
484 + update_option(self::ICON_BACKFILL_OPTION, time(), true);
485 +
486 + $settings = (new self())->get_settings('site');
487 +
488 + if (!is_array($settings)) {
489 + return;
490 + }
491 +
492 + foreach (['favicon_url', 'apple_touch_icon_url'] as $key) {
493 + if (empty($settings[$key]) || !is_string($settings[$key])) {
494 + continue;
495 + }
496 +
497 + $attachment_id = self::icon_attachment_id($settings[$key]);
498 +
499 + if ($attachment_id) {
500 + self::ensure_icon_sizes($attachment_id);
501 + }
502 + }
503 +
504 + delete_transient(self::ICON_URL_TRANSIENT);
505 + }
506 +
507 + /**
508 + * Attachment ID behind a configured icon URL, or 0 when it is not ours.
509 + *
510 + * attachment_url_to_postid() matches _wp_attached_file, which holds the
511 + * ORIGINAL upload path, so the URL of a generated derivative
512 + * (`logo-512.png`) returns 0 — and that is exactly what the media picker
513 + * hands back when the user chooses a size. Strip the dimension suffix and
514 + * try the original once.
515 + *
516 + * Shared with SEO_Manager's site-icon filter so both sides of the feature
517 + * agree on which attachment a configured URL means.
518 + *
519 + * @since 2.3.1
520 + *
521 + * @param string $url Configured icon URL.
522 + * @return int Attachment ID, or 0.
523 + */
524 + public static function icon_attachment_id(string $url): int {
525 + $attachment_id = (int) attachment_url_to_postid($url);
526 +
527 + if ($attachment_id) {
528 + return $attachment_id;
529 + }
530 +
531 + $original = preg_replace('/-\d+x\d+(?=\.[a-zA-Z0-9]+$)/', '', $url);
532 +
533 + if (is_string($original) && $original !== $url) {
534 + return (int) attachment_url_to_postid($original);
535 + }
536 +
537 + return 0;
538 + }
539 +
540 + /**
541 + * Which ICON_SIZES derivatives this attachment still needs.
542 + *
543 + * Split out from the generation so the decision can be asserted on its
544 + * own: whether a size is skipped because it already exists or because it
545 + * would upscale is invisible once both answers are "nothing was built".
546 + *
547 + * A source is measured by its SHORTER edge — a 400x40 banner cannot yield
548 + * a true 192x192 — and anything reporting no dimensions at all (SVGs) is
549 + * left alone.
550 + *
551 + * @since 2.3.1
552 + *
553 + * @param array $meta Attachment metadata.
554 + * @return array<string, array{width: int, height: int, crop: bool}> Sizes to build.
555 + */
556 + public static function missing_icon_sizes(array $meta): array {
557 + $source = min((int) ($meta['width'] ?? 0), (int) ($meta['height'] ?? 0));
558 +
559 + if ($source < 1) {
560 + return [];
561 + }
562 +
563 + $wanted = [];
564 + foreach (self::ICON_SIZES as $size) {
565 + // Never upscale: a stretched source behind an accurate sizes=""
566 + // label is worse than the honest near miss it would replace.
567 + if (isset($meta['sizes']["site_icon-{$size}"]) || $size > $source) {
568 + continue;
569 + }
570 +
571 + $wanted["site_icon-{$size}"] = ['width' => $size, 'height' => $size, 'crop' => true];
572 + }
573 +
574 + return $wanted;
575 + }
576 +
577 + /**
578 + * Generate whatever ICON_SIZES derivatives this attachment is missing.
579 + *
580 + * Only the missing ones, and never one larger than the source: upscaling a
581 + * small favicon would put a blurrier file behind an accurate sizes="" label
582 + * than the honest near-miss it replaced.
583 + *
584 + * @since 2.3.1
585 + *
586 + * @param int $attachment_id Attachment to build derivatives for.
587 + * @return string[] Size names generated, empty when there was nothing to do.
588 + */
589 + public static function ensure_icon_sizes(int $attachment_id): array {
590 + $meta = wp_get_attachment_metadata($attachment_id);
591 +
592 + if (!is_array($meta)) {
593 + return [];
594 + }
595 +
596 + $wanted = self::missing_icon_sizes($meta);
597 +
598 + if (empty($wanted)) {
599 + return [];
600 + }
601 +
602 + $file = get_attached_file($attachment_id);
603 +
604 + if (!$file || !file_exists($file)) {
605 + return [];
606 + }
607 +
608 + $editor = wp_get_image_editor($file);
609 +
610 + if (is_wp_error($editor)) {
611 + return [];
612 + }
613 +
614 + $generated = $editor->multi_resize($wanted);
615 +
616 + if (empty($generated)) {
617 + return [];
618 + }
619 +
620 + $meta['sizes'] = array_merge($meta['sizes'] ?? [], $generated);
621 + wp_update_attachment_metadata($attachment_id, $meta);
622 +
623 + return array_keys($generated);
624 + }
625 +
626 + /**
234 627 * Initialize WordPress filesystem
235 628 *
236 629 * @since 1.0.0
237 630 * @return bool True if filesystem is initialized, false otherwise
@@ -284,52 +677,25 @@
284 677 }
285 678
286 679 return $this->filesystem->is_writable($file);
287 680 }
288 -
289 - /**
290 - * Set file permissions using WP_Filesystem
291 - *
292 - * @since 1.0.0
293 - * @param string $file File path
294 - * @param int $mode File permissions mode
295 - * @return bool True if successful, false otherwise
296 - */
297 - private function set_file_permissions(string $file, int $mode): bool {
298 - if (!$this->init_filesystem()) {
299 - return false;
300 - }
301 -
302 - return $this->filesystem->chmod($file, $mode);
303 - }
304 -
305 - /**
306 - * Generate dynamic title using templates and placeholders
307 - *
308 - * @since 1.0.0
309 - *
310 - * @param string $template_name Template name to use
311 - * @param array $data Data for placeholder replacement
312 - * @param string $context Context type
313 - * @return string Generated title
314 - */
315 681 public function generate_title(string $template_name = 'default', array $data = [], string $context = 'site'): string {
316 682 // Get template
317 683 $template = $this->title_templates[$template_name] ?? $this->title_templates['default'];
318 -
684 +
319 685 // Get site settings
320 686 $settings = $this->get_settings('site');
321 687 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
322 -
688 +
323 689 // Prepare placeholder data
324 690 $placeholders = $this->prepare_title_placeholders($data, $context, $settings);
325 -
691 +
326 692 // Replace placeholders
327 693 $title = $this->replace_title_placeholders($template, $placeholders, $separator);
328 -
694 +
329 695 // Clean and optimize title
330 696 $title = $this->optimize_title($title, $context);
331 -
697 +
332 698 return $title;
333 699 }
334 700
335 701 /**
@@ -371,12 +737,12 @@
371 737 }
372 738
373 739 // Generate schema markup
374 740 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($breadcrumbs['items']);
375 -
741 +
376 742 // Generate HTML output
377 743 $breadcrumbs['html'] = $this->generate_breadcrumb_html($breadcrumbs['items'], $breadcrumb_settings);
378 -
744 +
379 745 // Set count
380 746 $breadcrumbs['count'] = count($breadcrumbs['items']);
381 747
382 748 return $breadcrumbs;
@@ -408,15 +774,15 @@
408 774 $settings = $this->get_settings('site');
409 775
410 776 // Generate default rules (pass full settings so sitemap_url is available)
411 777 $default_rules = $this->generate_default_robots_rules($settings);
412 -
778 +
413 779 // Merge with custom rules
414 780 $all_rules = array_merge($default_rules, $custom_rules);
415 -
781 +
416 782 // Validate rules
417 783 $robots_data['validation'] = $this->validate_robots_rules($all_rules);
418 -
784 +
419 785 // Generate robots.txt content
420 786 $robots_data['content'] = $this->build_robots_txt_content($all_rules);
421 787 $robots_data['rules'] = $all_rules;
422 788
@@ -423,8 +789,214 @@
423 789 return $robots_data;
424 790 }
425 791
426 792 /**
793 + * Resolve the robots.txt that should actually be served.
794 + *
795 + * The Robots.txt textarea (`robots_txt_content`) is the source of truth the
796 + * admin sees and edits; per the UI, an empty value means "auto-generate".
797 + * Both the virtual `robots_txt` filter and the physical file are rendered
798 + * through here so what is served always matches what the textarea shows —
799 + * previously the served output was regenerated from rules and silently
800 + * ignored any manual edit.
801 + *
802 + * @since 1.20.0
803 + * @return string Robots.txt body, always newline-terminated.
804 + */
805 + public function render_robots_txt(): string {
806 + $settings = $this->get_settings('site');
807 +
808 + // A site-wide crawl block — "Allow Search Engines" off, or WordPress's
809 + // "Discourage search engines" (Settings → Reading, blog_public=0) — must
810 + // win over any custom robots.txt content. Otherwise a stored override
811 + // that permits crawling would silently defeat the block on every serving
812 + // and persistence path. When blocked, force the generated output, which
813 + // resolves to `User-agent: * / Disallow: /` via generate_default_robots_rules().
814 + $allow_search = $settings['allow_search_engines'] ?? true;
815 + $fully_blocked = empty($allow_search) || !get_option('blog_public');
816 +
817 + $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
818 + // A user edit may still carry the old header if it was stored before the
819 + // header/body split — strip it so we don't emit two headers.
820 + $body = ($custom !== '' && !$fully_blocked)
821 + ? $this->strip_robots_header($custom)
822 + : trim($this->generate_robots_txt()['content']);
823 +
824 + // The per-agent AI directives are machine-owned, so they are composed
825 + // here rather than stored: the textarea holds the user's body, with
826 + // the fenced block stripped out of every read and re-applied on every
827 + // render. A site-wide block already disallows everyone, so adding the
828 + // per-agent group there would be noise restating the same refusal.
829 + if (!$fully_blocked) {
830 + $body = $this->apply_ai_crawler_block($body, $settings);
831 + }
832 +
833 + if ($body === '') {
834 + return '';
835 + }
836 +
837 + return $this->robots_txt_header() . $body . "\n";
838 + }
839 +
840 + /**
841 + * Resolve the robots.txt actually served to crawlers, with its origin.
842 + *
843 + * Lets an API/MCP consumer see the effective output without crawling the
844 + * URL. Mirrors serving precedence: a physical robots.txt in the web root is
845 + * served verbatim by the web server; otherwise the rendered content (custom
846 + * override or generated defaults) is served through the `robots_txt` filter.
847 + *
848 + * @since 1.20.0
849 + * @return array{content: string, is_default: bool, source: string} Effective
850 + * robots.txt, whether it is ThinkRank's generated default (vs. a
851 + * custom override), and where it originates from.
852 + */
853 + public function get_effective_robots_txt(): array {
854 + // A real file in the web root wins — the web server serves it directly.
855 + $robots_file = ABSPATH . 'robots.txt';
856 + if (file_exists($robots_file) && is_readable($robots_file)) {
857 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a public web-root file; WP_Filesystem is not available on front-end requests.
858 + return [
859 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a local file the plugin just located; WP_Filesystem would need credentials on some hosts.
860 + 'content' => (string) file_get_contents($robots_file),
861 + 'is_default' => false,
862 + 'source' => 'file',
863 + ];
864 + }
865 +
866 + $settings = $this->get_settings('site');
867 +
868 + // Management disabled — WordPress serves its own core default.
869 + if (empty($settings['robots_txt_enabled'])) {
870 + return [
871 + 'content' => '',
872 + 'is_default' => true,
873 + 'source' => 'wordpress',
874 + ];
875 + }
876 +
877 + // A non-empty stored override replaces the generated defaults.
878 + $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
879 +
880 + return [
881 + 'content' => $this->render_robots_txt(),
882 + 'is_default' => $custom === '',
883 + 'source' => $custom === '' ? 'generated' : 'custom',
884 + ];
885 + }
886 +
887 + /**
888 + * Describe how /robots.txt is actually delivered, and whether that still
889 + * matches the saved settings.
890 + *
891 + * The admin screen edits settings, but a physical robots.txt in the web root
892 + * is served directly by the web server and bypasses the `robots_txt` filter
893 + * entirely. When those two drift, the editor is showing content no crawler
894 + * ever sees — the conflict this exists to surface.
895 + *
896 + * @since 1.31.0
897 + *
898 + * @return array{content: string, source: string, is_default: bool, in_sync: bool, out_of_sync_reason: string, url: string}
899 + * The served content and its origin, whether it still reflects the
900 + * body the editor is showing, why it does not when it does not
901 + * ('file_drift' or 'crawl_blocked'), and the public URL it is
902 + * served from.
903 + */
904 + public function get_robots_txt_delivery(): array {
905 + $effective = $this->get_effective_robots_txt();
906 + $settings = $this->get_settings('site');
907 +
908 + // Compare bodies, not raw strings: the auto-generated header carries a
909 + // regeneration timestamp that always differs and means nothing here.
910 + // The AI crawler block is composed at render time on both sides, so it
911 + // is identical by construction and comparing it would only ever report
912 + // a false drift the admin cannot act on.
913 + $served = $this->strip_ai_crawler_block($this->strip_robots_header($effective['content']));
914 +
915 + // Measure against the body the editor is displaying — get_served_robots_body()
916 + // — not against render_robots_txt(). Two things made the old comparison
917 + // report "in sync" while the screen showed rules no crawler receives:
918 + // a physical file was compared to a freshly rendered body rather than
919 + // to the stored override the textarea shows, and a site-wide crawl
920 + // block makes render_robots_txt() return the generated "Disallow: /"
921 + // on both sides of the comparison, so it always matched.
922 + $expected = $this->get_served_robots_body();
923 +
924 + // Management off: WordPress serves its own default and the editor is not
925 + // claiming anything is live, so there is nothing to be out of sync with.
926 + $managed = !empty($settings['robots_txt_enabled']);
927 + $in_sync = !$managed || $served === $expected;
928 +
929 + $reason = '';
930 + if (!$in_sync) {
931 + // A crawl block is a deliberate override, not a stale file, and the
932 + // admin needs to be told which of the two they are looking at.
933 + $blocked = empty($settings['allow_search_engines'] ?? true) || !get_option('blog_public');
934 + $reason = $blocked ? 'crawl_blocked' : 'file_drift';
935 + }
936 +
937 + return [
938 + 'content' => $effective['content'],
939 + 'source' => $effective['source'],
940 + 'is_default' => $effective['is_default'],
941 + 'in_sync' => $in_sync,
942 + 'out_of_sync_reason' => $reason,
943 + 'url' => home_url('/robots.txt'),
944 + ];
945 + }
946 +
947 + /**
948 + * Keep the physical robots.txt file in step with the saved settings.
949 + *
950 + * When management is enabled the physical file is the source of truth the
951 + * web server serves, so this makes sure it exists and matches the effective
952 + * content — creating it if missing. When management is disabled it removes
953 + * any existing file so WordPress serves its default again. Callers invoke
954 + * this after saving robots settings so a plain Save both creates and
955 + * refreshes the file without a separate "Generate" step.
956 + *
957 + * @since 1.20.0
958 + * @return bool True if the file was written or removed as intended.
959 + */
960 + public function sync_robots_txt_file(): bool {
961 + $robots_file = ABSPATH . 'robots.txt';
962 + $settings = $this->get_settings('site');
963 +
964 + // Management turned off: drop any existing file so WordPress serves its
965 + // default again, rather than leaving a stale ThinkRank file behind.
966 + if (empty($settings['robots_txt_enabled'])) {
967 + if (file_exists($robots_file) && $this->init_filesystem()) {
968 + $this->filesystem->delete($robots_file);
969 + }
970 + return true;
971 + }
972 +
973 + $content = $this->render_robots_txt();
974 + if ($content === '') {
975 + return false;
976 + }
977 +
978 + // Run the effective content through the standard robots_txt filter so
979 + // lines added by other integrations (ThinkRank Pro's News/Video
980 + // Publisher Sitemaps at priority 999, and any third-party plugin) are
981 + // baked into the physical file. A physical robots.txt bypasses core's
982 + // do_robots()/robots_txt filter entirely, so without this those lines
983 + // are silently dropped. ThinkRank's own filter_robots_txt callback just
984 + // re-returns this same content (it calls render_robots_txt(), which does
985 + // not re-apply the filter), so there is no recursion or double-append.
986 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
987 + $content = (string) apply_filters('robots_txt', $content, (bool) get_option('blog_public'));
988 + if ($content === '') {
989 + return false;
990 + }
991 +
992 + // write_robots_txt() creates the file when absent and overwrites it
993 + // otherwise, so this covers both first-time creation and re-sync.
994 + $result = $this->write_robots_txt($content);
995 + return !empty($result['success']);
996 + }
997 +
998 + /**
427 999 * Write robots.txt content to filesystem
428 1000 *
429 1001 * @since 1.0.0
430 1002 *
@@ -471,22 +1043,23 @@
471 1043 return $result;
472 1044 }
473 1045
474 1046 try {
475 - // Write new content (directly replace existing file)
476 - $bytes_written = file_put_contents($robots_file, $content, LOCK_EX);
1047 + // Write new content using WP_Filesystem
1048 + if (!$this->init_filesystem()) {
1049 + $result['message'] = 'Could not initialize WordPress filesystem.';
1050 + return $result;
1051 + }
477 1052
478 - if ($bytes_written !== false) {
1053 + $write_success = $this->filesystem->put_contents($robots_file, $content, FS_CHMOD_FILE);
1054 +
1055 + if ($write_success) {
479 1056 $result['success'] = true;
480 1057 $result['message'] = 'Robots.txt file written successfully.';
481 - $result['bytes_written'] = $bytes_written;
482 -
483 - // Set appropriate file permissions (644)
484 - $this->set_file_permissions($robots_file, 0644);
1058 + $result['bytes_written'] = strlen($content);
485 1059 } else {
486 1060 $result['message'] = 'Failed to write robots.txt file.';
487 1061 }
488 -
489 1062 } catch (\Exception $e) {
490 1063 $result['message'] = 'Error writing robots.txt file: ' . $e->getMessage();
491 1064 }
492 1065
@@ -593,18 +1166,11 @@
593 1166 'warnings' => [],
594 1167 'improvements' => []
595 1168 ];
596 1169
597 - // Validate title template
598 - $template = $settings['title_template'] ?? 'default';
599 - if ($template === 'default') {
600 - $optimization['suggestions'][] = 'Consider using "Post Title | Site Name" template for better SEO';
601 - $optimization['score'] -= 10;
602 - }
603 -
604 - // Check separator choice
1170 + // Check separator choice (applies to every context template).
605 1171 $separator = $settings['title_separator'] ?? 'pipe';
606 - $separator_data = $this->title_separators[$separator] ?? null;
1172 + $separator_data = self::$title_separators[$separator] ?? null;
607 1173 if ($separator_data) {
608 1174 $seo_score = $separator_data['seo_score'] ?? 5;
609 1175 if ($seo_score < 8) {
610 1176 $optimization['suggestions'][] = "Consider using '|' or '-' separators for better SEO performance";
@@ -611,26 +1177,56 @@
611 1177 $optimization['score'] -= (10 - $seo_score);
612 1178 }
613 1179 }
614 1180
615 - // Test title length with sample data
616 - $sample_title = $this->generate_sample_title($settings);
617 - $title_length = strlen($sample_title);
618 - if ($title_length > 60) {
619 - $optimization['warnings'][] = "Title template generates {$title_length}-character titles (over 60-character limit)";
620 - $optimization['score'] -= 15;
621 - } elseif ($title_length < 30) {
622 - $optimization['suggestions'][] = "Title template generates short {$title_length}-character titles";
623 - $optimization['score'] -= 5;
1181 + // Analyze the per-context templates the Title Formats UI actually edits
1182 + // and the front end actually renders — not the legacy `title_template`
1183 + // enum, which this screen never sets.
1184 + $context_labels = [
1185 + 'homepage_title' => 'Homepage',
1186 + 'post_title' => 'Post',
1187 + 'page_title' => 'Page',
1188 + 'category_title' => 'Category',
1189 + 'tag_title' => 'Tag',
1190 + 'author_title' => 'Author',
1191 + 'search_title' => 'Search',
1192 + 'archive_title' => 'Archive',
1193 + ];
1194 +
1195 + $configured = 0;
1196 + foreach ($context_labels as $key => $label) {
1197 + $template = isset($settings[$key]) ? trim((string) $settings[$key]) : '';
1198 + if ($template === '') {
1199 + continue; // Unconfigured — the front end falls back for this context.
1200 + }
1201 + $configured++;
1202 +
1203 + // Brand recognition: the title should carry the site name.
1204 + if (strpos($template, '%site_title%') === false && strpos($template, '%site_name%') === false) {
1205 + $optimization['suggestions'][] = "{$label} title has no site name — add %site_title% for brand recognition";
1206 + $optimization['score'] -= 5;
1207 + }
1208 +
1209 + // Length check against the ~60-char guideline, measured on the
1210 + // resolved title for THIS context (with representative sample data).
1211 + $sample_length = strlen($this->generate_sample_title($settings, $key));
1212 + if ($sample_length > 60) {
1213 + $optimization['warnings'][] = "{$label} title renders about {$sample_length} characters (over the 60-character limit)";
1214 + $optimization['score'] -= 10;
1215 + } elseif ($sample_length > 0 && $sample_length < 20) {
1216 + $optimization['suggestions'][] = "{$label} title renders only about {$sample_length} characters — consider adding more context";
1217 + $optimization['score'] -= 3;
1218 + }
624 1219 }
625 1220
626 - // Check for site name inclusion
627 - $template_content = $this->title_templates[$template] ?? '';
628 - if (strpos($template_content, '%sitename%') === false) {
629 - $optimization['suggestions'][] = 'Include site name in title template for brand recognition';
1221 + // No context templates set at all — ThinkRank won't control any titles.
1222 + if ($configured === 0) {
1223 + $optimization['suggestions'][] = 'No title formats are configured — set templates so ThinkRank controls your page titles';
630 1224 $optimization['score'] -= 10;
631 1225 }
632 1226
1227 + $optimization['score'] = max(0, min(100, $optimization['score']));
1228 +
633 1229 return $optimization;
634 1230 }
635 1231
636 1232 /**
@@ -693,17 +1289,19 @@
693 1289 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
694 1290 if (empty($home_text)) {
695 1291 $optimization['warnings'][] = 'Empty home text reduces accessibility for screen readers';
696 1292 $optimization['score'] -= 15;
697 - } elseif (strlen($home_text) > 20) {
698 - $optimization['suggestions'][] = 'Keep home text concise (current: ' . strlen($home_text) . ' chars)';
1293 + } elseif (mb_strlen($home_text) > 20) {
1294 + // mb_strlen: this number is shown to the user as "chars" (#687).
1295 + $optimization['suggestions'][] = 'Keep home text concise (current: ' . mb_strlen($home_text) . ' chars)';
699 1296 $optimization['score'] -= 5;
700 1297 }
701 1298
702 1299 // Check prefix usage
703 1300 $prefix = $settings['breadcrumb_prefix'] ?? '';
704 - if (!empty($prefix) && strlen($prefix) > 50) {
705 - $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . strlen($prefix) . ' chars) - consider shortening';
1301 + if (!empty($prefix) && mb_strlen($prefix) > 50) {
1302 + // mb_strlen: this number is shown to the user as "chars" (#687).
1303 + $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . mb_strlen($prefix) . ' chars) - consider shortening';
706 1304 $optimization['score'] -= 5;
707 1305 }
708 1306
709 1307 // Current page display
@@ -837,12 +1435,14 @@
837 1435 if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
838 1436 $attachment_id = attachment_url_to_postid($logo_url);
839 1437 if ($attachment_id) {
840 1438 $image_meta = wp_get_attachment_metadata($attachment_id);
841 - if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
842 - $width = $image_meta['width'];
843 - $height = $image_meta['height'];
1439 + $width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1440 + $height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
844 1441
1442 + // SVG logos store 0x0 metadata — no dimension/ratio analysis
1443 + // is possible (and dividing by 0 is fatal).
1444 + if ($image_meta && $width > 0 && $height > 0) {
845 1445 if ($width < 112 || $height < 112) {
846 1446 $optimization['warnings'][] = "Logo dimensions ({$width}x{$height}) are below recommended minimum (112x112)";
847 1447 $optimization['score'] -= 10;
848 1448 }
@@ -1148,34 +1748,72 @@
1148 1748 }
1149 1749 }
1150 1750
1151 1751 /**
1152 - * Generate sample title for testing template length
1752 + * Generate a sample rendered title for a given context template, so the
1753 + * optimizer can measure the length users will actually see.
1153 1754 *
1755 + * Resolves the per-context template (e.g. `post_title`) with representative
1756 + * sample values for the same variable tokens the front-end renderer fills
1757 + * in (see SEO_Manager::get_title_placeholders()).
1758 + *
1154 1759 * @since 1.0.0
1155 1760 *
1156 - * @param array $settings Title format settings
1157 - * @return string Generated sample title
1761 + * @param array $settings Title format settings
1762 + * @param string $context_key Per-context template key (e.g. 'post_title')
1763 + * @return string Resolved sample title (empty string when the template is unset)
1158 1764 */
1159 - private function generate_sample_title(array $settings): string {
1160 - $template = $settings['title_template'] ?? 'default';
1765 + private function generate_sample_title(array $settings, string $context_key = 'post_title'): string {
1766 + $template = isset($settings[$context_key]) ? trim((string) $settings[$context_key]) : '';
1767 + if ($template === '') {
1768 + return '';
1769 + }
1770 +
1161 1771 $separator = $settings['title_separator'] ?? 'pipe';
1772 + $separator_symbol = self::$title_separators[$separator]['symbol'] ?? '|';
1162 1773
1163 - $template_content = $this->title_templates[$template] ?? $this->title_templates['default'];
1164 - $separator_symbol = $this->title_separators[$separator]['symbol'] ?? '|';
1774 + $site_name = $settings['site_name'] ?? '';
1775 + if ($site_name === '') {
1776 + $site_name = get_bloginfo('name') ?: 'Your Site Name';
1777 + }
1778 + $site_description = $settings['site_description'] ?? '';
1779 + if ($site_description === '') {
1780 + $site_description = get_bloginfo('description') ?: 'Your Site Description';
1781 + }
1782 + $tagline = $settings['tagline'] ?? '';
1783 + if ($tagline === '') {
1784 + $tagline = $site_description;
1785 + }
1165 1786
1166 - // Sample data for testing
1787 + // Representative sample values for the variable tokens the front end
1788 + // substitutes per request. Keys mirror get_title_placeholders().
1167 1789 $sample_data = [
1168 - '%title%' => 'How to Optimize Your Website for Better SEO Results',
1169 - '%sitename%' => get_bloginfo('name') ?: 'Your Site Name',
1170 - '%separator%' => ' ' . $separator_symbol . ' ',
1171 - '%tagline%' => get_bloginfo('description') ?: 'Your Site Tagline',
1172 - '%category%' => 'SEO Tips',
1173 - '%author%' => 'John Doe',
1174 - '%date%' => gmdate('F Y')
1790 + '%site_title%' => $site_name,
1791 + '%site_name%' => $site_name,
1792 + '%site_description%' => $site_description,
1793 + '%tagline%' => $tagline,
1794 + '%sep%' => ' ' . $separator_symbol . ' ',
1795 + '%separator%' => ' ' . $separator_symbol . ' ',
1796 + '%post_title%' => 'How to Optimize Your Website for Better SEO Results',
1797 + '%page_title%' => 'About Our Company',
1798 + '%category_title%' => 'SEO Tips',
1799 + '%category%' => 'SEO Tips',
1800 + '%tag_title%' => 'On-Page SEO',
1801 + '%tag%' => 'On-Page SEO',
1802 + '%author_name%' => 'Jane Doe',
1803 + '%author%' => 'Jane Doe',
1804 + '%search_term%' => 'keyword research',
1805 + '%search_phrase%' => 'keyword research',
1806 + '%archive_title%' => 'July 2026',
1807 + '%date%' => gmdate('F Y'),
1175 1808 ];
1176 1809
1177 - return str_replace(array_keys($sample_data), array_values($sample_data), $template_content);
1810 + $title = str_replace(array_keys($sample_data), array_values($sample_data), $template);
1811 +
1812 + // Collapse whitespace left by any empty/unresolved tokens, then trim.
1813 + $title = preg_replace('/\s+/', ' ', $title);
1814 +
1815 + return trim($title);
1178 1816 }
1179 1817
1180 1818 /**
1181 1819 * Store optimization results in seo_analysis table
@@ -1200,9 +1838,9 @@
1200 1838 if ($focus !== 'all') {
1201 1839 $analysis_type .= '_' . $focus;
1202 1840 }
1203 1841
1204 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Site identity analysis storage requires direct database access
1842 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Site identity analysis storage requires direct database access
1205 1843 $wpdb->insert(
1206 1844 $table_name,
1207 1845 [
1208 1846 'context_type' => 'site',
@@ -1252,10 +1890,10 @@
1252 1890 }
1253 1891
1254 1892 // Validate title separator
1255 1893 if (isset($settings['title_separator'])) {
1256 - if (!isset($this->title_separators[$settings['title_separator']])) {
1257 - $validation['errors'][] = 'Invalid title separator specified';
1894 + if (!isset(self::$title_separators[$settings['title_separator']])) {
1895 + $validation['errors'][] = __('Invalid title separator specified.', 'thinkrank');
1258 1896 $validation['valid'] = false;
1259 1897 }
1260 1898 }
1261 1899
@@ -2045,9 +2683,8 @@
2045 2683 'Current page display preference is set to default.',
2046 2684 'status' => 'valid',
2047 2685 'icon' => '✓'
2048 2686 ];
2049 -
2050 2687 } else {
2051 2688 $field_details[] = [
2052 2689 'field' => 'breadcrumbs_enabled',
2053 2690 'label' => 'Breadcrumbs recommended for better user experience and SEO.',
@@ -2074,12 +2711,15 @@
2074 2711 'warnings' => [],
2075 2712 'suggestions' => []
2076 2713 ];
2077 2714
2078 - // Validate business name (required for local SEO)
2715 + // Business name is what makes the LocalBusiness schema useful, but it
2716 + // cannot be a blocking error: the toggle is what reveals the business
2717 + // fields, so requiring the name up front makes enabling Local SEO
2718 + // impossible. The frontend already skips the output while the name is
2719 + // empty (see Seo_Manager::output_local_seo_meta_tags()).
2079 2720 if (empty($settings['business_name'])) {
2080 - $validation['errors'][] = 'Business name is required when local SEO is enabled';
2081 - $validation['valid'] = false;
2721 + $validation['warnings'][] = 'Business name is missing - required before local business schema is output';
2082 2722 } elseif (strlen($settings['business_name']) > 100) {
2083 2723 $validation['warnings'][] = 'Business name is very long, consider shortening for better display';
2084 2724 }
2085 2725
@@ -2220,8 +2860,125 @@
2220 2860 return $output;
2221 2861 }
2222 2862
2223 2863 /**
2864 + * Keys the Site Identity screens store beyond the 16 defaults.
2865 + *
2866 + * Title formats, breadcrumb configuration, the hero fields, the business
2867 + * block and the wizard's identity fields are all real settings written by
2868 + * this manager, none of which get_default_settings() names — it seeds only
2869 + * the values a fresh install needs. Gating on defaults alone would stop
2870 + * every one of them saving (#452).
2871 + *
2872 + * @since 2.0.1
2873 + *
2874 + * @return string[]
2875 + */
2876 + /**
2877 + * The stored alternate name(s), shaped for schema output.
2878 + *
2879 + * schema.org and Google both allow `alternateName` to carry one value or
2880 + * several, and the store already round-trips either shape, so this accepts
2881 + * both and normalises: null when there is nothing to publish, a bare string
2882 + * for one name, a list for more. Emitting a one-element array would be
2883 + * valid but noisier than it needs to be.
2884 + *
2885 + * Shared because both WebSite producers need it and must agree — a property
2886 + * added to one and not the other is how #688 happened.
2887 + *
2888 + * @since 2.7.0
2889 + *
2890 + * @param mixed $value Stored alternate_name value.
2891 + * @return string|string[]|null
2892 + */
2893 + public static function alternate_name_for_schema($value) {
2894 + $names = [];
2895 +
2896 + foreach ((array) $value as $name) {
2897 + if (!is_scalar($name)) {
2898 + continue;
2899 + }
2900 +
2901 + $name = trim((string) $name);
2902 +
2903 + if ('' !== $name && !in_array($name, $names, true)) {
2904 + $names[] = $name;
2905 + }
2906 + }
2907 +
2908 + if (empty($names)) {
2909 + return null;
2910 + }
2911 +
2912 + return 1 === count($names) ? $names[0] : $names;
2913 + }
2914 +
2915 + protected function additional_setting_keys(): array {
2916 + return [
2917 + // Title formats, one per context.
2918 + 'homepage_title', 'post_title', 'page_title', 'category_title',
2919 + 'tag_title', 'author_title', 'search_title', 'archive_title',
2920 + // Breadcrumbs.
2921 + 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
2922 + // Identity, as written by the setup wizard and the importers.
2923 + 'alternate_name', 'identity_type', 'represents',
2924 + 'default_meta_description', 'default_social_image',
2925 + 'social_media_accounts',
2926 + // Schema toggles that live on this screen.
2927 + 'organization_schema', 'knowledge_graph',
2928 + // Robots rules composed by the Robots.txt panel.
2929 + 'custom_robots_rules',
2930 + // Per-agent AI crawler allow/block map (#657).
2931 + 'ai_crawler_rules',
2932 + // Hero section.
2933 + 'hero_title', 'hero_subtitle', 'hero_cta_text', 'hero_cta_url',
2934 + 'hero_background_image',
2935 + // Local SEO / business details.
2936 + 'local_seo_enabled', 'business_type', 'business_name',
2937 + 'business_address', 'business_city', 'business_state',
2938 + 'business_postal_code', 'business_country', 'business_phone',
2939 + 'business_email', 'business_latitude', 'business_longitude',
2940 + 'business_price_range', 'business_hours',
2941 + ];
2942 + }
2943 +
2944 + /**
2945 + * Sanitize settings, normalising the AI crawler rule map.
2946 + *
2947 + * The generic array sanitizer keeps the shape but says nothing about the
2948 + * values: a payload could store `ai_crawler_rules[gptbot] = "maybe"`, or a
2949 + * slug no crawler answers to, and both would round-trip through every
2950 + * later response. Normalising here rather than in the REST handler puts it
2951 + * on the one path every writer shares — the settings route, the robots
2952 + * route and the MCP abilities all land in save_settings() (#657).
2953 + *
2954 + * @since 2.5.0
2955 + *
2956 + * @param array $settings Settings to sanitize.
2957 + * @param string $context_type Context type.
2958 + * @return array Sanitized settings.
2959 + */
2960 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
2961 + $sanitized = parent::sanitize_settings($settings, $context_type);
2962 +
2963 + if (array_key_exists('ai_crawler_rules', $sanitized)) {
2964 + $sanitized['ai_crawler_rules'] = AI_Crawlers::normalize_rules($sanitized['ai_crawler_rules']);
2965 + }
2966 +
2967 + // Same reasoning one key up, for the scheme override (#638). Anything
2968 + // that is not one of the three modes means "follow WordPress", and is
2969 + // stored as that rather than kept verbatim — otherwise get-site-identity
2970 + // -settings would report a scheme the site does not actually publish.
2971 + if (array_key_exists('canonical_scheme', $sanitized)) {
2972 + $sanitized['canonical_scheme'] = in_array($sanitized['canonical_scheme'], Url_Scheme::MODES, true)
2973 + ? $sanitized['canonical_scheme']
2974 + : Url_Scheme::AUTOMATIC;
2975 + }
2976 +
2977 + return $sanitized;
2978 + }
2979 +
2980 + /**
2224 2981 * Get default settings for a context type (implements interface)
2225 2982 *
2226 2983 * @since 1.0.0
2227 2984 *
@@ -2241,9 +2998,32 @@
2241 2998 'breadcrumb_home_text' => 'Home',
2242 2999 'breadcrumb_separator' => '>',
2243 3000 'robots_txt_enabled' => true,
2244 3001 'allow_search_engines' => true,
3002 + // Answer 404 when a content selector in the URL resolved to
3003 + // nothing (#634). On by default, unlike the other new settings
3004 + // here: it changes no URL a visitor or a correct crawler uses, only
3005 + // ones where WordPress resolved nothing and served the blog listing
3006 + // at 200 anyway.
3007 + 'query_protection' => true,
3008 +
3009 + // Feed controls (#635). All three off, so an upgrade changes
3010 + // nothing about what an existing site already sends its
3011 + // subscribers; a brand-new install is seeded with the signature and
3012 + // the noindex on, in Activator::seed_feed_defaults().
3013 + 'feed_excerpt_only' => false,
3014 + 'feed_source_link' => false,
3015 + 'feed_noindex' => false,
3016 +
3017 + // The scheme self-referential URLs go out with (#638). 'automatic'
3018 + // means substitute nothing and follow WordPress, which is what
3019 + // every site did before the setting existed.
3020 + 'canonical_scheme' => Url_Scheme::AUTOMATIC,
2245 3021 'robots_txt_content' => '',
3022 + // Empty map = every AI crawler allowed. Defaults must stay
3023 + // permissive so an upgrade never starts blocking a crawler a site
3024 + // was happily serving (#657).
3025 + 'ai_crawler_rules' => [],
2246 3026 'logo_url' => '',
2247 3027 'favicon_url' => '',
2248 3028 'apple_touch_icon_url' => ''
2249 3029 ];
@@ -2296,9 +3076,9 @@
2296 3076 'title_separator' => [
2297 3077 'type' => 'string',
2298 3078 'title' => 'Title Separator',
2299 3079 'description' => 'Character used to separate title elements',
2300 - 'enum' => array_keys($this->title_separators),
3080 + 'enum' => array_keys(self::$title_separators),
2301 3081 'default' => 'pipe'
2302 3082 ],
2303 3083 'site_name' => [
2304 3084 'type' => 'string',
@@ -2362,10 +3142,13 @@
2362 3142 */
2363 3143 private function prepare_title_placeholders(array $data, string $context, array $settings): array {
2364 3144 $placeholders = [
2365 3145 '%title%' => $data['title'] ?? '',
2366 - '%sitename%' => $settings['site_name'] ?? get_bloginfo('name'),
2367 - '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
3146 + // `?:` rather than `??`: these are persisted as '' rather than left
3147 + // unset, and '' is not null, so the null-coalesce never reached the
3148 + // WordPress fallback (#398).
3149 + '%sitename%' => ($settings['site_name'] ?? '') ?: get_bloginfo('name'),
3150 + '%tagline%' => ($settings['tagline'] ?? '') ?: get_bloginfo('description'),
2368 3151 '%separator%' => '', // Will be replaced with actual separator
2369 3152 '%category%' => '',
2370 3153 '%author%' => '',
2371 3154 '%date%' => '',
@@ -2432,9 +3215,9 @@
2432 3215 * @param string $separator_key Separator key
2433 3216 * @return string Separator symbol
2434 3217 */
2435 3218 private function get_title_separator(string $separator_key): string {
2436 - return $this->title_separators[$separator_key]['symbol'] ?? $this->title_separators['pipe']['symbol'];
3219 + return self::$title_separators[$separator_key]['symbol'] ?? self::$title_separators['pipe']['symbol'];
2437 3220 }
2438 3221
2439 3222 /**
2440 3223 * Optimize title for SEO
@@ -2449,16 +3232,17 @@
2449 3232 // Remove extra whitespace
2450 3233 $title = preg_replace('/\s+/', ' ', $title);
2451 3234 $title = trim($title);
2452 3235
2453 - // Ensure title is not too long (60 characters max for SEO)
2454 - if (strlen($title) > 60) {
2455 - // Try to truncate at word boundary
2456 - $title = wp_trim_words($title, 8, '...');
2457 - if (strlen($title) > 60) {
2458 - $title = substr($title, 0, 57) . '...';
2459 - }
2460 - }
3236 + // Ensure title is not too long (60 characters max for SEO).
3237 + // All three units here were wrong for non-Latin text: strlen() counts
3238 + // BYTES so the gate fired at 20 Thai characters, wp_trim_words() counts
3239 + // CHARACTERS on th/ja/zh_* so `8` cut the title to 8 of them, and
3240 + // substr() cuts bytes so it split a character mid-sequence (#687).
3241 + $title = \ThinkRank\Core\Seo_Text::trim_to_length(
3242 + $title,
3243 + \ThinkRank\Core\Seo_Text::TITLE_MAX_LENGTH
3244 + );
2461 3245
2462 3246 // Ensure title is not empty
2463 3247 if (empty($title)) {
2464 3248 $title = get_bloginfo('name');
@@ -2755,8 +3539,19 @@
2755 3539 */
2756 3540 private function generate_default_robots_rules(array $settings): array {
2757 3541 $rules = [];
2758 3542
3543 + // Full block: when the admin turns off "Allow Search Engines" or enables
3544 + // WordPress's "Discourage search engines" (Settings → Reading, stored as
3545 + // blog_public=0), serve a robots.txt that disallows everything rather
3546 + // than the default per-path rules — otherwise the toggle has no effect.
3547 + $allow_search = $settings['allow_search_engines'] ?? true;
3548 + if (empty($allow_search) || !get_option('blog_public')) {
3549 + $rules[] = ['directive' => 'user_agent', 'value' => '*'];
3550 + $rules[] = ['directive' => 'disallow', 'value' => '/'];
3551 + return $rules;
3552 + }
3553 +
2759 3554 // Default user agent rule
2760 3555 $rules[] = [
2761 3556 'directive' => 'user_agent',
2762 3557 'value' => '*'
@@ -2761,21 +3556,34 @@
2761 3556 'directive' => 'user_agent',
2762 3557 'value' => '*'
2763 3558 ];
2764 3559
2765 - // WordPress core disallows
3560 + // WordPress core disallows.
3561 + //
3562 + // Deliberately minimal, matching Yoast/Rank Math defaults. We do NOT
3563 + // block /wp-includes/, /wp-content/plugins/, or /wp-content/themes/:
3564 + // those paths serve the CSS and JS Google must fetch to render pages,
3565 + // and blocking them causes "blocked resource" warnings and can hurt
3566 + // rankings. /wp-json/ is left crawlable for the same reason (embeds,
3567 + // oEmbed, structured previews). Only wp-admin (bar admin-ajax) and the
3568 + // handful of non-content endpoints below are disallowed.
2766 3569 $default_disallows = [
2767 3570 '/wp-admin/',
2768 - '/wp-includes/',
2769 - '/wp-content/plugins/',
2770 - '/wp-content/themes/',
2771 - '/wp-json/',
2772 3571 '/xmlrpc.php',
2773 - '/wp-*.php',
2774 3572 '/readme.html',
2775 - '/license.txt'
3573 + '/license.txt',
2776 3574 ];
2777 3575
3576 + // WooCommerce: keep cart/checkout/account and add-to-cart query URLs out
3577 + // of the index to avoid crawl noise and duplicate/session URLs (parity
3578 + // with Rank Math's WooCommerce robots defaults).
3579 + if (class_exists('WooCommerce')) {
3580 + $default_disallows[] = '/cart/';
3581 + $default_disallows[] = '/checkout/';
3582 + $default_disallows[] = '/my-account/';
3583 + $default_disallows[] = '/*add-to-cart=*';
3584 + }
3585 +
2778 3586 foreach ($default_disallows as $disallow) {
2779 3587 $rules[] = [
2780 3588 'directive' => 'disallow',
2781 3589 'value' => $disallow
@@ -2824,8 +3632,29 @@
2824 3632 * @since 1.0.0
2825 3633 * @return array Array of sitemap URLs
2826 3634 */
2827 3635 private function get_sitemap_urls_for_robots(): array {
3636 + // One wrapper over every return path below, including the #104 extras.
3637 + // The Sitemap: line is the only absolute URL of ours in robots.txt and
3638 + // the one a crawler follows to find everything else, so it has to carry
3639 + // the site's scheme preference (#638). Applied here rather than where
3640 + // the body is assembled, because that path also renders a robots.txt a
3641 + // site owner typed themselves, and their text is not ours to rewrite.
3642 + return array_map(
3643 + static function (string $url): string {
3644 + return Url_Scheme::apply($url);
3645 + },
3646 + $this->collect_sitemap_urls_for_robots()
3647 + );
3648 + }
3649 +
3650 + /**
3651 + * The sitemap URLs robots.txt advertises, before the scheme preference.
3652 + *
3653 + * @since 1.0.0
3654 + * @return array Array of sitemap URLs
3655 + */
3656 + private function collect_sitemap_urls_for_robots(): array {
2828 3657 try {
2829 3658 // Get sitemap settings
2830 3659 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
2831 3660 $sitemap_settings = $sitemap_generator->get_settings('site');
@@ -2837,24 +3666,74 @@
2837 3666
2838 3667 $sitemap_urls = [];
2839 3668 $site_url = home_url();
2840 3669
2841 - // Extract enabled sitemap URLs
3670 + // Extract enabled sitemap URLs. When the index is enabled it is the
3671 + // only entry worth advertising: every child sitemap is already
3672 + // listed inside it, so naming them again in robots.txt is pure
3673 + // redundancy and drifts out of date as soon as a post type is added.
3674 + $index_url = '';
2842 3675 if (!empty($sitemap_settings['sitemap_urls']) && is_array($sitemap_settings['sitemap_urls'])) {
2843 3676 foreach ($sitemap_settings['sitemap_urls'] as $sitemap) {
2844 - if (!empty($sitemap['enabled']) && !empty($sitemap['url'])) {
2845 - $sitemap_urls[] = $site_url . $sitemap['url'];
3677 + if (empty($sitemap['enabled']) || empty($sitemap['url'])) {
3678 + continue;
2846 3679 }
3680 +
3681 + if (($sitemap['type'] ?? '') === 'index') {
3682 + $index_url = $site_url . $sitemap['url'];
3683 + continue;
3684 + }
3685 +
3686 + $sitemap_urls[] = $site_url . $sitemap['url'];
2847 3687 }
2848 3688 }
2849 3689
3690 + if ($index_url !== '') {
3691 + // The index alone — it covers the children and, on a segmented
3692 + // install, the local business sitemap too.
3693 + return [$index_url];
3694 + }
3695 +
2850 3696 // Fallback to default if no URLs found
2851 3697 if (empty($sitemap_urls)) {
2852 3698 $sitemap_urls[] = home_url('/sitemap.xml');
2853 3699 }
2854 3700
3701 + // No index on this install, so anything not already listed above has
3702 + // no other discovery path — advertise it directly. The local
3703 + // business sitemap and the sitemaps other plugins register both land
3704 + // here for the same reason, so they go through one list (#104).
3705 + $extra = [];
3706 +
3707 + // Not a file test. Under dynamic delivery the local sitemap is
3708 + // served from PHP and no file is ever written, so file_exists()
3709 + // silently dropped a sitemap the site really does publish (#752).
3710 + // On static sites the file is still what proves it, so both count.
3711 + $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
3712 +
3713 + if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
3714 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3715 +
3716 + $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
3717 + && $generator->publishes_local_sitemap();
3718 + }
3719 +
3720 + if ($local_sitemap_published) {
3721 + $extra[] = '/local-sitemap.xml';
3722 + }
3723 +
3724 + foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
3725 + $extra[] = $path;
3726 + }
3727 +
3728 + foreach ($extra as $path) {
3729 + $url = home_url($path);
3730 + if (!in_array($url, $sitemap_urls, true)) {
3731 + $sitemap_urls[] = $url;
3732 + }
3733 + }
3734 +
2855 3735 return $sitemap_urls;
2856 -
2857 3736 } catch (\Exception $e) {
2858 3737 // Fallback to default on error
2859 3738 return [home_url('/sitemap.xml')];
2860 3739 }
@@ -2933,12 +3812,16 @@
2933 3812 * @param array $rules Robots.txt rules
2934 3813 * @return string Robots.txt content
2935 3814 */
2936 3815 private function build_robots_txt_content(array $rules): string {
2937 - $content = "# Robots.txt generated by ThinkRank SEO\n";
2938 - $content .= "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
3816 + // Body only — no header. The "# generated by ThinkRank SEO" + timestamp
3817 + // block is added at render time (see robots_txt_header), so it never
3818 + // gets baked into the stored/editable content and can't show a stale
3819 + // timestamp on every update.
3820 + $content = '';
2939 3821
2940 3822 $current_user_agent = '';
3823 + $sitemap_started = false;
2941 3824
2942 3825 foreach ($rules as $rule) {
2943 3826 $directive = $rule['directive'] ?? '';
2944 3827 $value = $rule['value'] ?? '';
@@ -2959,37 +3842,247 @@
2959 3842 case 'crawl_delay':
2960 3843 $content .= "Crawl-delay: {$value}\n";
2961 3844 break;
2962 3845 case 'sitemap':
2963 - $content .= "\nSitemap: {$value}\n";
3846 + // One blank line separates the Sitemap block from the
3847 + // preceding group, and none appear inside it. A blank line
3848 + // terminates a record in the robots.txt grammar, so putting
3849 + // one between every directive was invalid formatting.
3850 + if (!$sitemap_started) {
3851 + $content .= "\n";
3852 + $sitemap_started = true;
3853 + }
3854 + $content .= "Sitemap: {$value}\n";
2964 3855 break;
2965 3856 }
2966 3857 }
2967 3858
2968 - return $content;
3859 + return ltrim($content, "\n");
2969 3860 }
2970 3861
2971 3862 /**
2972 - * Check if sitemap exists
3863 + * Parse a robots.txt body back into the {directive, value} rule shape.
2973 3864 *
2974 - * @since 1.0.0
3865 + * generate_robots_txt() returns `rules` alongside `content`, but callers
3866 + * replace `content` with the body actually being served (a stored override
3867 + * or a physical file). The generated rules then described something the
3868 + * response no longer contained. Re-deriving them from the served body keeps
3869 + * the two halves of the payload describing the same document.
2975 3870 *
2976 - * @param string $sitemap_url Sitemap URL to check
2977 - * @return bool True if sitemap exists
3871 + * @since 2.0.1
3872 + *
3873 + * @param string $content Robots.txt body (header optional).
3874 + * @return array<int, array{directive: string, value: string}> Parsed rules.
2978 3875 */
2979 - private function sitemap_exists(string $sitemap_url): bool {
2980 - $response = wp_remote_head($sitemap_url);
2981 - return !is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200;
3876 + public function parse_robots_txt_rules(string $content): array {
3877 + $map = [
3878 + 'user-agent' => 'user_agent',
3879 + 'disallow' => 'disallow',
3880 + 'allow' => 'allow',
3881 + 'crawl-delay' => 'crawl_delay',
3882 + 'sitemap' => 'sitemap',
3883 + ];
3884 +
3885 + $rules = [];
3886 +
3887 + foreach (preg_split('/\r\n|\r|\n/', $this->strip_robots_header($content)) as $line) {
3888 + $line = trim($line);
3889 +
3890 + // Blank lines separate groups and `#` starts a comment; neither is
3891 + // a rule.
3892 + if ($line === '' || str_starts_with($line, '#')) {
3893 + continue;
3894 + }
3895 +
3896 + $parts = explode(':', $line, 2);
3897 + if (count($parts) !== 2) {
3898 + continue;
3899 + }
3900 +
3901 + $field = strtolower(trim($parts[0]));
3902 + if (!isset($map[$field])) {
3903 + continue;
3904 + }
3905 +
3906 + $rules[] = [
3907 + 'directive' => $map[$field],
3908 + // Sitemap values are absolute URLs and contain the `:` the
3909 + // limited explode above deliberately preserved.
3910 + 'value' => trim($parts[1]),
3911 + ];
3912 + }
3913 +
3914 + return $rules;
2982 3915 }
2983 3916
2984 3917 /**
2985 - * Get site identity data
3918 + * Opening fence of the machine-owned AI crawler region.
2986 3919 *
2987 - * @since 1.0.0
3920 + * @since 2.5.0
3921 + * @var string
3922 + */
3923 + public const AI_BLOCK_BEGIN = '# BEGIN ThinkRank AI crawlers';
3924 +
3925 + /**
3926 + * Closing fence of the machine-owned AI crawler region.
2988 3927 *
2989 - * @param array $settings Site settings
2990 - * @return array Site identity data
3928 + * @since 2.5.0
3929 + * @var string
2991 3930 */
3931 + public const AI_BLOCK_END = '# END ThinkRank AI crawlers';
3932 +
3933 + /**
3934 + * Render the fenced AI crawler region for the current settings.
3935 + *
3936 + * One `User-agent:` / `Disallow: /` record per blocked crawler. Allowed
3937 + * crawlers emit nothing at all: `Disallow:` with an empty value is the
3938 + * robots.txt way of saying "allow everything", but writing eighteen such
3939 + * records to say what silence already says would triple the file and
3940 + * invite the reading that an unlisted crawler is therefore refused.
3941 + *
3942 + * @since 2.5.0
3943 + *
3944 + * @param array $settings Site settings.
3945 + * @return string Fenced block, newline-terminated, or '' when nothing is blocked.
3946 + */
3947 + private function build_ai_crawler_block(array $settings): string {
3948 + $blocked = AI_Crawlers::blocked_slugs($settings['ai_crawler_rules'] ?? []);
3949 +
3950 + if (empty($blocked)) {
3951 + return '';
3952 + }
3953 +
3954 + $agents = AI_Crawlers::all();
3955 +
3956 + $lines = [
3957 + self::AI_BLOCK_BEGIN,
3958 + '# Managed by ThinkRank — edits between these lines are overwritten.',
3959 + ];
3960 +
3961 + foreach ($blocked as $slug) {
3962 + $lines[] = '';
3963 + $lines[] = 'User-agent: ' . $agents[$slug]['token'];
3964 + $lines[] = 'Disallow: /';
3965 + }
3966 +
3967 + $lines[] = self::AI_BLOCK_END;
3968 +
3969 + return implode("\n", $lines) . "\n";
3970 + }
3971 +
3972 + /**
3973 + * Remove the fenced AI crawler region from a robots.txt body.
3974 + *
3975 + * Tolerates a missing closing fence rather than leaving the rest of the
3976 + * file swallowed: a truncated write, or someone deleting the END line by
3977 + * hand, would otherwise make every subsequent read drop everything below
3978 + * the opening fence.
3979 + *
3980 + * @since 2.5.0
3981 + *
3982 + * @param string $body Robots.txt body.
3983 + * @return string Body with the region removed.
3984 + */
3985 + public function strip_ai_crawler_block(string $body): string {
3986 + if (false === strpos($body, self::AI_BLOCK_BEGIN)) {
3987 + return $body;
3988 + }
3989 +
3990 + $pattern = '/\R*' . preg_quote(self::AI_BLOCK_BEGIN, '/')
3991 + . '.*?(?:' . preg_quote(self::AI_BLOCK_END, '/') . '|\z)\R*/s';
3992 +
3993 + return trim((string) preg_replace($pattern, "\n\n", $body, 1));
3994 + }
3995 +
3996 + /**
3997 + * Put the current AI crawler region into a robots.txt body.
3998 + *
3999 + * Replaces an existing region in place so the block keeps its position in
4000 + * a hand-ordered file, and appends when there is none. Everything outside
4001 + * the fences is returned untouched — that is the whole point of fencing
4002 + * it, since the body is also a free-text field the user edits.
4003 + *
4004 + * @since 2.5.0
4005 + *
4006 + * @param string $body Robots.txt body (fences optional).
4007 + * @param array $settings Site settings.
4008 + * @return string Body carrying the current region.
4009 + */
4010 + private function apply_ai_crawler_block(string $body, array $settings): string {
4011 + $stripped = $this->strip_ai_crawler_block($body);
4012 + $block = $this->build_ai_crawler_block($settings);
4013 +
4014 + if ('' === $block) {
4015 + return $stripped;
4016 + }
4017 +
4018 + if ('' === trim($stripped)) {
4019 + return trim($block);
4020 + }
4021 +
4022 + return rtrim($stripped) . "\n\n" . trim($block);
4023 + }
4024 +
4025 + /**
4026 + * The auto-generated header prepended to the served robots.txt.
4027 + *
4028 + * Kept separate from the body so it is only ever added at render time with
4029 + * a fresh timestamp, never stored or shown in the editable textarea.
4030 + *
4031 + * @return string
4032 + */
4033 + private function robots_txt_header(): string {
4034 + return "# Robots.txt generated by ThinkRank SEO\n"
4035 + . "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
4036 + }
4037 +
4038 + /**
4039 + * Strip our auto-generated header from a robots.txt string.
4040 + *
4041 + * Used when surfacing existing content for editing so the header/timestamp
4042 + * doesn't round-trip back into storage.
4043 + *
4044 + * @param string $content Raw robots.txt content.
4045 + * @return string Body without the ThinkRank header.
4046 + */
4047 + private function strip_robots_header(string $content): string {
4048 + $pattern = '/^# Robots\.txt generated by ThinkRank SEO\r?\n# [^\r\n]* UTC\r?\n\r?\n/';
4049 + return trim((string) preg_replace($pattern, '', $content, 1));
4050 + }
4051 +
4052 + /**
4053 + * The body that should populate the editor for the current site.
4054 + *
4055 + * Prefers what is actually being served: the physical file if one exists
4056 + * (header stripped), otherwise the effective body. This is what the admin
4057 + * screen shows so the textarea is never blank while /robots.txt has content.
4058 + *
4059 + * @return string
4060 + */
4061 + public function get_served_robots_body(): string {
4062 + $settings = $this->get_settings('site');
4063 +
4064 + // The AI block is stripped from every one of these paths. A physical
4065 + // robots.txt we wrote carries it, and the stored override is whatever
4066 + // the textarea last held — so without this the block round-trips into
4067 + // the editor, gets saved as ordinary body text, and is then appended
4068 + // to a second time on the next render.
4069 + $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
4070 + if ($custom !== '') {
4071 + return $this->strip_ai_crawler_block($this->strip_robots_header($custom));
4072 + }
4073 +
4074 + $robots_file = ABSPATH . 'robots.txt';
4075 + if (file_exists($robots_file)) {
4076 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable robots.txt is an expected state answered with an empty string.
4077 + $raw = (string) @file_get_contents($robots_file);
4078 + if ($raw !== '') {
4079 + return $this->strip_ai_crawler_block($this->strip_robots_header($raw));
4080 + }
4081 + }
4082 +
4083 + return $this->strip_ai_crawler_block(trim($this->generate_robots_txt()['content']));
4084 + }
2992 4085 private function get_site_identity_data(array $settings): array {
2993 4086 return [
2994 4087 'site_name' => $settings['site_name'] ?? get_bloginfo('name'),
2995 4088 'site_description' => $settings['site_description'] ?? get_bloginfo('description'),
@@ -3050,11 +4143,14 @@
3050 4143 $optimization['validation']['valid'] = false;
3051 4144 }
3052 4145
3053 4146 if (!empty($value) && isset($config['max_length'])) {
3054 - if (strlen($value) > $config['max_length']) {
4147 + // The warning says "characters", so measure and cut in characters:
4148 + // strlen()/substr() fired early on non-Latin values and the
4149 + // suggested replacement was cut mid-character (#687).
4150 + if (mb_strlen($value) > $config['max_length']) {
3055 4151 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
3056 - $optimization['optimized_value'] = substr($value, 0, $config['max_length']);
4152 + $optimization['optimized_value'] = \ThinkRank\Core\Seo_Text::trim_to_length($value, (int) $config['max_length']);
3057 4153 }
3058 4154 }
3059 4155
3060 4156 // SEO-specific optimizations
@@ -3102,9 +4198,9 @@
3102 4198 // Check recommended size
3103 4199 if (isset($config['recommended_size'])) {
3104 4200 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
3105 4201
3106 - if ($image_meta['width'] != $rec_width || $image_meta['height'] != $rec_height) {
4202 + if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
3107 4203 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
3108 4204 }
3109 4205 }
3110 4206