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 +178 -30 2.0.2 → 2.12.0 View file →
@@ -51,8 +51,33 @@
51 51 */
52 52 private ?string $separator = null;
53 53
54 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 + /**
55 80 * Constructor
56 81 *
57 82 * @since 1.0.0
58 83 */
@@ -269,9 +294,11 @@
269 294 // filename-based formats and for images that need no injection.
270 295 $needs_attachment =
271 296 ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) ||
272 297 ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? ''));
273 - $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;
274 301
275 302 // Handle ALT attribute
276 303 if ($alt_missing) {
277 304 $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
@@ -378,28 +405,23 @@
378 405 || strpos($format, '%image_caption%') !== false;
379 406 }
380 407
381 408 /**
382 - * Resolve an attachment ID from a source URL, memoized per request.
409 + * Resolve an attachment ID from a source URL.
383 410 *
384 - * `attachment_url_to_postid()` issues its own DB query, so repeated identical
385 - * 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).
386 416 *
387 417 * @since 1.19.1
388 - * @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}.
389 420 * @return int Attachment ID, or 0 if not a media-library image.
390 421 */
391 - private function url_to_attachment_id(string $src): int {
392 - if ($src === '') {
393 - return 0;
394 - }
395 -
396 - static $cache = [];
397 - if (!array_key_exists($src, $cache)) {
398 - $cache[$src] = attachment_url_to_postid($src);
399 - }
400 -
401 - 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));
402 424 }
403 425
404 426 /**
405 427 * Inject (or replace an empty) alt/title attribute on a single <img> tag.
@@ -455,9 +477,14 @@
455 477 private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string {
456 478 $replacements = [
457 479 '%site_title%' => get_bloginfo('name'),
458 480 '%sitename%' => get_bloginfo('name'),
459 - '%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) : '',
460 487 '%count%' => (string) $count,
461 488 '%filename%' => '',
462 489 '%image_title%' => '',
463 490 '%image_caption%' => '',
@@ -525,11 +552,13 @@
525 552 *
526 553 * @since 1.19.1
527 554 * @param int $attachment_id The attachment ID.
528 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.
529 558 * @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
530 559 */
531 - 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 {
532 561 if (!wp_attachment_is_image($attachment_id)) {
533 562 return false;
534 563 }
535 564
@@ -569,12 +598,106 @@
569 598 // Already correct — treat as success without a redundant write.
570 599 return true;
571 600 }
572 601
573 - 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;
574 609 }
575 610
576 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 + /**
577 700 * Describe an attachment with the vision model.
578 701 *
579 702 * Never throws: alt text generation runs in batches over a whole media
580 703 * library, and one unreadable image or a rate-limit blip must not abort
@@ -656,24 +779,39 @@
656 779 'offset' => $offset,
657 780 'fields' => 'ids',
658 781 'orderby' => 'ID',
659 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).
660 784 'suppress_filters' => true,
661 785 ]);
662 786
663 - $updated = 0;
664 - $skipped = 0;
665 787 $processed = 0;
666 788
667 - foreach ($ids as $id) {
668 - $processed++;
669 - if ($this->fill_attachment_alt((int) $id, $overwrite)) {
670 - $updated++;
671 - } else {
672 - $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);
673 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);
674 801 }
675 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 +
676 814 $next_offset = $offset + count($ids);
677 815
678 816 // An empty batch means there is nothing left to walk, whatever the
679 817 // total claims. Deriving `done` from the count alone let any drift
@@ -696,8 +834,12 @@
696 834 'offset' => $offset,
697 835 'next_offset' => $next_offset,
698 836 'remaining' => $remaining,
699 837 'done' => $exhausted || $next_offset >= $total,
838 + 'cache' => [
839 + 'posts_purged' => count($cache['posts']),
840 + 'warnings' => $cache['warnings'],
841 + ],
700 842 ];
701 843 }
702 844
703 845 /**
@@ -737,9 +879,12 @@
737 879 if (!wp_attachment_is_image($attachment_id)) {
738 880 return;
739 881 }
740 882
741 - $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);
742 887 }
743 888
744 889 /**
745 890 * Total number of image attachments in the library.
@@ -799,8 +944,11 @@
799 944 * @since 1.19.1
800 945 * @return void
801 946 */
802 947 private function flush_analyzer_cache(): void {
803 - // Matches SEO_Analyzer::CACHE_KEY.
804 - 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 + }
805 953 }
806 954 }