PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-image-seo-manager.php +319 -54 1.27.0 → 2.12.0 View file →
@@ -29,8 +29,19 @@
29 29 */
30 30 class Image_SEO_Manager extends Abstract_SEO_Manager {
31 31
32 32 /**
33 + * Accepted values for the `alt_source` setting.
34 + *
35 + * Single source of truth for the schema's enum, the REST arg constraint and
36 + * validate_settings(), so the three cannot disagree about what is legal.
37 + *
38 + * @since 1.29.1
39 + * @var string[]
40 + */
41 + public const ALT_SOURCES = ['template', 'ai'];
42 +
43 + /**
33 44 * Memoized site separator symbol.
34 45 *
35 46 * Resolved once per request rather than on every image processed during
36 47 * the_content, since the separator is a site-wide option.
@@ -40,8 +51,33 @@
40 51 */
41 52 private ?string $separator = null;
42 53
43 54 /**
55 + * Attachment IDs whose cache purge is being held back, or null when not.
56 + *
57 + * A bulk fill writes alt text onto hundreds of attachments that between
58 + * them appear on a handful of pages. Resolving usage per attachment would
59 + * run one unindexed postmeta scan per image; holding the IDs and resolving
60 + * the whole batch in one query at the end is the same answer for a
61 + * fraction of the work.
62 + *
63 + * @since 2.12.0
64 + * @var int[]|null
65 + */
66 + private ?array $deferred_alt_purge = null;
67 +
68 + /**
69 + * What the most recent purge_alt_caches() call did.
70 + *
71 + * Read by the MCP abilities, which have to report the cache outcome
72 + * alongside the write and cannot see inside fill_attachment_alt().
73 + *
74 + * @since 2.12.0
75 + * @var array{posts: int[], warnings: string[]}
76 + */
77 + private array $last_alt_cache_purge = ['posts' => [], 'warnings' => []];
78 +
79 + /**
44 80 * Constructor
45 81 *
46 82 * @since 1.0.0
47 83 */
@@ -81,8 +117,20 @@
81 117 $validation['valid'] = false;
82 118 }
83 119 }
84 120
121 + // The schema declares alt_source as an enum but nothing used to check
122 + // it, so any string persisted. The consumer falls back to the template
123 + // path on an unknown value, which hid the drift rather than surfacing
124 + // it — the settings screen just had no option to select (#323).
125 + if (isset($settings['alt_source']) && !in_array($settings['alt_source'], self::ALT_SOURCES, true)) {
126 + $validation['errors'][] = sprintf(
127 + 'alt_source must be one of: %s',
128 + implode(', ', self::ALT_SOURCES)
129 + );
130 + $validation['valid'] = false;
131 + }
132 +
85 133 return $validation;
86 134 }
87 135
88 136 /**
@@ -105,8 +153,19 @@
105 153 *
106 154 * @param string $context_type The context type to get defaults for
107 155 * @return array Default settings array
108 156 */
157 + /**
158 + * Images per batch when alt text comes from the vision model.
159 + *
160 + * Each one is a paid call of a few seconds; 10 keeps a batch inside a
161 + * normal PHP timeout and keeps the spend per click predictable.
162 + *
163 + * @since 1.28.0
164 + * @var int
165 + */
166 + private const AI_BATCH_LIMIT = 10;
167 +
109 168 public function get_default_settings(string $context_type): array {
110 169 return [
111 170 'add_missing_alt' => false,
112 171 'alt_format' => '%filename%',
@@ -115,8 +174,11 @@
115 174 // Media Library alt persistence (writes _wp_attachment_image_alt)
116 175 'save_alt_to_media' => false,
117 176 'auto_fill_on_upload' => false,
118 177 'media_alt_overwrite' => false,
178 + // 'template' rewrites the filename; 'ai' looks at the picture.
179 + // Defaults to template because AI costs the user money per image.
180 + 'alt_source' => 'template',
119 181 ];
120 182 }
121 183
122 184 /**
@@ -152,8 +214,15 @@
152 214 'title' => __('Title attribute format', 'thinkrank'),
153 215 'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'),
154 216 'default' => '%title% %separator% %sitename%'
155 217 ],
218 + 'alt_source' => [
219 + 'type' => 'string',
220 + 'title' => __('Alt text source', 'thinkrank'),
221 + 'description' => __('“Template” builds alt text from the filename and title. “AI” looks at the image itself and describes what is in it — this uses your AI provider key and costs one call per image.', 'thinkrank'),
222 + 'default' => 'template',
223 + 'enum' => self::ALT_SOURCES
224 + ],
156 225 'save_alt_to_media' => [
157 226 'type' => 'boolean',
158 227 'title' => __('Save alt text to the Media Library', 'thinkrank'),
159 228 'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'),
@@ -225,9 +294,11 @@
225 294 // filename-based formats and for images that need no injection.
226 295 $needs_attachment =
227 296 ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) ||
228 297 ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? ''));
229 - $attachment_id = $needs_attachment ? $this->url_to_attachment_id($src) : 0;
298 + $attachment_id = $needs_attachment
299 + ? $this->url_to_attachment_id($src, (string) ($attributes['class'] ?? ''))
300 + : 0;
230 301
231 302 // Handle ALT attribute
232 303 if ($alt_missing) {
233 304 $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
@@ -334,28 +405,23 @@
334 405 || strpos($format, '%image_caption%') !== false;
335 406 }
336 407
337 408 /**
338 - * Resolve an attachment ID from a source URL, memoized per request.
409 + * Resolve an attachment ID from a source URL.
339 410 *
340 - * `attachment_url_to_postid()` issues its own DB query, so repeated identical
341 - * URLs on a page (galleries, duplicated images) are cached here.
411 + * Body images are almost always inserted at a generated size, which
412 + * `attachment_url_to_postid()` cannot match, so %image_title% and
413 + * %image_caption% resolved to nothing for them. Attachment_Lookup reads the
414 + * `wp-image-{ID}` class the editor wrote first, and caches whatever still
415 + * has to be asked of the database (#847).
342 416 *
343 417 * @since 1.19.1
344 - * @param string $src Source URL.
418 + * @param string $src Source URL.
419 + * @param string $classes The image's class attribute, for its wp-image-{ID}.
345 420 * @return int Attachment ID, or 0 if not a media-library image.
346 421 */
347 - private function url_to_attachment_id(string $src): int {
348 - if ($src === '') {
349 - return 0;
350 - }
351 -
352 - static $cache = [];
353 - if (!array_key_exists($src, $cache)) {
354 - $cache[$src] = attachment_url_to_postid($src);
355 - }
356 -
357 - return $cache[$src];
422 + private function url_to_attachment_id(string $src, string $classes = ''): int {
423 + return Attachment_Lookup::id_from_url($src, Attachment_Lookup::hint_from_markup($classes));
358 424 }
359 425
360 426 /**
361 427 * Inject (or replace an empty) alt/title attribute on a single <img> tag.
@@ -411,9 +477,14 @@
411 477 private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string {
412 478 $replacements = [
413 479 '%site_title%' => get_bloginfo('name'),
414 480 '%sitename%' => get_bloginfo('name'),
415 - '%title%' => $post_id > 0 ? get_the_title($post_id) : get_bloginfo('name'),
481 + // Empty, not the site name. The segment collapsing below drops an
482 + // unresolved token together with its separator, and the default
483 + // title_format already ends in %sitename% — substituting the site
484 + // name here printed it twice ("Site Name | Site Name") on every
485 + // image processed outside the loop (widgets, page builders, FSE).
486 + '%title%' => $post_id > 0 ? get_the_title($post_id) : '',
416 487 '%count%' => (string) $count,
417 488 '%filename%' => '',
418 489 '%image_title%' => '',
419 490 '%image_caption%' => '',
@@ -481,11 +552,13 @@
481 552 *
482 553 * @since 1.19.1
483 554 * @param int $attachment_id The attachment ID.
484 555 * @param bool $overwrite When false, images that already have alt text are left untouched.
556 + * @param bool $purge Clear the caches that already rendered this image. Pass false only
557 + * when the attachment cannot yet appear on any page.
485 558 * @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
486 559 */
487 - public function fill_attachment_alt(int $attachment_id, bool $overwrite = false): bool {
560 + public function fill_attachment_alt(int $attachment_id, bool $overwrite = false, bool $purge = true): bool {
488 561 if (!wp_attachment_is_image($attachment_id)) {
489 562 return false;
490 563 }
491 564
@@ -499,14 +572,25 @@
499 572 $settings = $this->get_settings('site');
500 573 $format = $settings['alt_format'] ?? '%filename%';
501 574 $src = (string) wp_get_attachment_url($attachment_id);
502 575
503 - // Pass the attachment ID as the post context so %title% falls back to the
504 - // attachment's own title (there is no surrounding post here).
505 - $value = sanitize_text_field(
506 - $this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
507 - );
576 + $value = '';
508 577
578 + // AI describes the picture; the template can only rewrite its filename.
579 + // Falls back to the template on any failure so a provider outage
580 + // degrades to the old behaviour instead of leaving images bare.
581 + if ('ai' === ($settings['alt_source'] ?? 'template')) {
582 + $value = $this->generate_ai_alt($attachment_id);
583 + }
584 +
585 + if ('' === $value) {
586 + // Pass the attachment ID as the post context so %title% falls back to the
587 + // attachment's own title (there is no surrounding post here).
588 + $value = sanitize_text_field(
589 + $this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
590 + );
591 + }
592 +
509 593 if ($value === '') {
510 594 return false;
511 595 }
512 596
@@ -514,12 +598,139 @@
514 598 // Already correct — treat as success without a redundant write.
515 599 return true;
516 600 }
517 601
518 - return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
602 + $written = update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
603 +
604 + if ($written) {
605 + $this->record_alt_write($attachment_id, $purge);
606 + }
607 +
608 + return $written;
519 609 }
520 610
521 611 /**
612 + * Note that an attachment's alt text changed, and clear what rendered it.
613 + *
614 + * @since 2.12.0
615 + * @param int $attachment_id Attachment whose alt text was just written.
616 + * @param bool $purge False when nothing can be displaying this attachment yet.
617 + * @return void
618 + */
619 + protected function record_alt_write(int $attachment_id, bool $purge = true): void {
620 + // Recorded during a bulk run whatever $purge says: the batch resolves
621 + // the whole list at the end, and bulk_fill_missing_alt() counts its
622 + // writes from this list.
623 + if (null !== $this->deferred_alt_purge) {
624 + $this->deferred_alt_purge[] = $attachment_id;
625 +
626 + return;
627 + }
628 +
629 + // A fresh upload cannot be on any page, so there is nothing to clear
630 + // and nothing for thinkrank_image_alt_updated to report. Listeners
631 + // that care about new files already have core's add_attachment.
632 + if (!$purge) {
633 + return;
634 + }
635 +
636 + $this->purge_alt_caches([$attachment_id]);
637 + }
638 +
639 + /**
640 + * Drop cached renderings of every page that shows these attachments.
641 + *
642 + * Alt text is written to post meta but read out of HTML that other
643 + * software has already rendered and stored, so the write alone changes
644 + * nothing a visitor sees. Elementor keeps rendered widgets for 24 hours by
645 + * default and page caches keep whole documents, which is how an agent came
646 + * to report a successful alt-text write against a page still showing the
647 + * old words (#763).
648 + *
649 + * @since 2.12.0
650 + * @param int[] $attachment_ids Attachments whose alt text changed.
651 + * @return array{posts: int[], warnings: string[]} Posts purged, and caches left for the user to clear.
652 + */
653 + public function purge_alt_caches(array $attachment_ids): array {
654 + $ids = array_values(array_unique(array_filter(array_map('intval', $attachment_ids))));
655 +
656 + if ([] === $ids) {
657 + $this->last_alt_cache_purge = ['posts' => [], 'warnings' => []];
658 +
659 + return $this->last_alt_cache_purge;
660 + }
661 +
662 + $posts = Attachment_Usage::posts_using($ids);
663 + $purged = Cache_Purger::purge_posts($posts);
664 +
665 + foreach ($ids as $id) {
666 + /**
667 + * Fires after ThinkRank writes an image's alt text and clears the
668 + * caches it knows about.
669 + *
670 + * Builders and cache layers ThinkRank does not handle can listen
671 + * here to drop their own rendering of the affected posts.
672 + *
673 + * @since 2.12.0
674 + *
675 + * @param int $attachment_id The attachment whose alt text changed.
676 + * @param int[] $post_ids Posts found to display that attachment.
677 + */
678 + do_action('thinkrank_image_alt_updated', $id, $posts);
679 + }
680 +
681 + $this->last_alt_cache_purge = [
682 + 'posts' => $purged,
683 + 'warnings' => Cache_Purger::warnings(),
684 + ];
685 +
686 + return $this->last_alt_cache_purge;
687 + }
688 +
689 + /**
690 + * The outcome of the most recent alt-text cache purge in this request.
691 + *
692 + * @since 2.12.0
693 + * @return array{posts: int[], warnings: string[]}
694 + */
695 + public function last_alt_cache_purge(): array {
696 + return $this->last_alt_cache_purge;
697 + }
698 +
699 + /**
700 + * Describe an attachment with the vision model.
701 + *
702 + * Never throws: alt text generation runs in batches over a whole media
703 + * library, and one unreadable image or a rate-limit blip must not abort
704 + * the run. Returns '' so the caller falls back to the template.
705 + *
706 + * @since 1.28.0
707 + * @param int $attachment_id Attachment to describe.
708 + * @return string Alt text, or '' when unavailable.
709 + */
710 + private function generate_ai_alt(int $attachment_id): string {
711 + try {
712 + $vision = new \ThinkRank\AI\Vision_Client();
713 +
714 + if (!$vision->is_available()) {
715 + return '';
716 + }
717 +
718 + // The parent post's title disambiguates images that are visually
719 + // ambiguous on their own (a generic chart, a product on white).
720 + $context = '';
721 + $parent = (int) get_post_field('post_parent', $attachment_id);
722 + if ($parent > 0) {
723 + $context = (string) get_the_title($parent);
724 + }
725 +
726 + return sanitize_text_field($vision->describe_attachment($attachment_id, $context));
727 + } catch (\Throwable $e) {
728 + return '';
729 + }
730 + }
731 +
732 + /**
522 733 * Fill alt text across the Media Library in a single batch.
523 734 *
524 735 * Iterates images by ascending ID using offset/limit so callers can page
525 736 * through large libraries without exhausting memory or hitting timeouts.
@@ -545,38 +756,72 @@
545 756 $offset = max(0, (int) ($args['offset'] ?? 0));
546 757 $limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
547 758 $overwrite = !empty($args['overwrite']);
548 759
760 + // In AI mode every image is a paid provider call that takes seconds,
761 + // so a 200-image batch would both surprise the user's bill and blow
762 + // past max_execution_time. Cap the batch and let the caller page —
763 + // `remaining` already drives that loop.
764 + if ('ai' === ($this->get_settings('site')['alt_source'] ?? 'template')) {
765 + $limit = min($limit, self::AI_BATCH_LIMIT);
766 + }
767 +
549 768 $total = $this->count_images();
550 769
551 770 $ids = get_posts([
552 - 'post_type' => 'attachment',
553 - 'post_mime_type' => 'image',
554 - 'post_status' => 'inherit',
771 + 'post_type' => 'attachment',
772 + 'post_mime_type' => 'image',
773 + // Must cover the same set count_images() counts, or the pager can
774 + // never reach the total. 'inherit' alone excluded private-status
775 + // attachments — which media-protection and membership plugins do
776 + // create — while count_images() still counted them (#322).
777 + 'post_status' => ['inherit', 'private', 'publish', 'draft', 'pending', 'future'],
555 778 'numberposts' => $limit,
556 779 'offset' => $offset,
557 780 'fields' => 'ids',
558 781 'orderby' => 'ID',
559 782 'order' => 'ASC',
783 + // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- The pager must walk the same unfiltered set count_images() counts, or it can never reach the total (#322).
560 784 'suppress_filters' => true,
561 785 ]);
562 786
563 - $updated = 0;
564 - $skipped = 0;
565 787 $processed = 0;
566 788
567 - foreach ($ids as $id) {
568 - $processed++;
569 - if ($this->fill_attachment_alt((int) $id, $overwrite)) {
570 - $updated++;
571 - } else {
572 - $skipped++;
789 + // Hold every purge until the batch is done: see $deferred_alt_purge.
790 + $this->deferred_alt_purge = [];
791 +
792 + try {
793 + foreach ($ids as $id) {
794 + $processed++;
795 + $this->fill_attachment_alt((int) $id, $overwrite);
573 796 }
797 + } finally {
798 + $written = array_values(array_unique($this->deferred_alt_purge ?? []));
799 + $this->deferred_alt_purge = null;
800 + $cache = $this->purge_alt_caches($written);
574 801 }
575 802
803 + // Counted from the writes that actually happened, not from what
804 + // fill_attachment_alt() returned. It answers true for an image whose
805 + // stored alt already equals the generated value, which is the right
806 + // answer to "does this image have its alt text?" and the wrong one to
807 + // "how many did you change" — a re-run over a correct library reported
808 + // every image as updated while writing nothing. `updated` and
809 + // `skipped` have documented the write, not the return value, since
810 + // this method was added.
811 + $updated = count($written);
812 + $skipped = max(0, $processed - $updated);
813 +
576 814 $next_offset = $offset + count($ids);
577 - $remaining = max(0, $total - $next_offset);
578 815
816 + // An empty batch means there is nothing left to walk, whatever the
817 + // total claims. Deriving `done` from the count alone let any drift
818 + // between the two queries strand the caller on a batch that could
819 + // never advance the offset, and the admin UI answers that by
820 + // re-requesting up to 10,000 times.
821 + $exhausted = empty($ids);
822 + $remaining = $exhausted ? 0 : max(0, $total - $next_offset);
823 +
579 824 // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
580 825 if ($updated > 0) {
581 826 $this->flush_analyzer_cache();
582 827 }
@@ -588,9 +833,13 @@
588 833 'skipped' => $skipped,
589 834 'offset' => $offset,
590 835 'next_offset' => $next_offset,
591 836 'remaining' => $remaining,
592 - 'done' => $next_offset >= $total,
837 + 'done' => $exhausted || $next_offset >= $total,
838 + 'cache' => [
839 + 'posts_purged' => count($cache['posts']),
840 + 'warnings' => $cache['warnings'],
841 + ],
593 842 ];
594 843 }
595 844
596 845 /**
@@ -630,41 +879,51 @@
630 879 if (!wp_attachment_is_image($attachment_id)) {
631 880 return;
632 881 }
633 882
634 - $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']));
883 + // No cache purge: this fires on add_attachment, so the file was created
884 + // seconds ago and no page can be displaying it yet. Skipping the lookup
885 + // keeps a bulk media import off two unindexed postmeta scans per file.
886 + $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']), false);
635 887 }
636 888
637 889 /**
638 890 * Total number of image attachments in the library.
639 891 *
892 + * Counted with an explicit `post_status != 'trash'` rather than through
893 + * wp_count_attachments(). The helper applies that filter internally, which
894 + * looked equivalent — but it left the two halves of get_media_alt_stats()
895 + * with different notions of which images exist, and only one of them said
896 + * so out loud. Spelling the filter out here keeps this query and
897 + * count_images_with_alt() visibly in step (#321).
898 + *
640 899 * @since 1.19.1
641 900 * @return int
642 901 */
643 902 private function count_images(): int {
644 - $counts = wp_count_attachments();
645 - $total = 0;
646 -
647 - foreach ((array) $counts as $mime => $count) {
648 - if (strpos((string) $mime, 'image/') === 0) {
649 - $total += (int) $count;
650 - }
651 - }
652 -
653 - return $total;
903 + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT; short-lived admin action
904 + return (int) $this->wpdb->get_var(
905 + "SELECT COUNT(*) FROM {$this->wpdb->posts}
906 + WHERE post_type = 'attachment'
907 + AND post_mime_type LIKE 'image/%'
908 + AND post_status != 'trash'"
909 + );
910 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
654 911 }
655 912
656 913 /**
657 914 * Number of image attachments that already have non-empty alt text.
658 915 *
659 - * Mirrors the query used by the Site SEO Analyzer's alt-text check so the two
660 - * features report consistent coverage.
916 + * Carries the same `post_status != 'trash'` filter as count_images(), so a
917 + * trashed image can never be counted as covered against a total it is not
918 + * part of. Matches the Site SEO Analyzer's alt-text check, which applies
919 + * the same filter to both of its counts.
661 920 *
662 921 * @since 1.19.1
663 922 * @return int
664 923 */
665 924 private function count_images_with_alt(): int {
666 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index; short-lived admin action
925 + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT via postmeta meta_key index; short-lived admin action
667 926 return (int) $this->wpdb->get_var(
668 927 "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
669 928 INNER JOIN {$this->wpdb->postmeta} pm
670 929 ON pm.post_id = p.ID
@@ -669,10 +928,13 @@
669 928 INNER JOIN {$this->wpdb->postmeta} pm
670 929 ON pm.post_id = p.ID
671 930 AND pm.meta_key = '_wp_attachment_image_alt'
672 931 AND pm.meta_value != ''
673 - WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%'"
932 + WHERE p.post_type = 'attachment'
933 + AND p.post_mime_type LIKE 'image/%'
934 + AND p.post_status != 'trash'"
674 935 );
936 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
675 937 }
676 938
677 939 /**
678 940 * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
@@ -682,8 +944,11 @@
682 944 * @since 1.19.1
683 945 * @return void
684 946 */
685 947 private function flush_analyzer_cache(): void {
686 - // Matches SEO_Analyzer::CACHE_KEY.
687 - delete_transient('thinkrank_site_seo_analysis');
948 + // Ask the analyzer rather than duplicating its transient key here — the
949 + // literal drifted out of sync the moment anyone renamed it.
950 + if (class_exists('ThinkRank\\SEO\\SEO_Analyzer')) {
951 + (new SEO_Analyzer())->flush_cache();
952 + }
688 953 }
689 954 }