PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-image-seo-manager.php

class-image-seo-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.28.0, at includes/seo/class-image-seo-manager.php

690 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Image SEO Manager Class
5 *
6 * Manages image-specific SEO settings including automatic
7 * ALT and TITLE attribute management.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Image SEO Manager Class
25 *
26 * Handles image attribute optimization and settings management.
27 *
28 * @since 1.0.0
29 */
30 class Image_SEO_Manager extends Abstract_SEO_Manager {
31
32 /**
33 * Memoized site separator symbol.
34 *
35 * Resolved once per request rather than on every image processed during
36 * the_content, since the separator is a site-wide option.
37 *
38 * @since 1.16.0
39 * @var string|null
40 */
41 private ?string $separator = null;
42
43 /**
44 * Constructor
45 *
46 * @since 1.0.0
47 */
48 public function __construct() {
49 parent::__construct('image_seo');
50 }
51
52 /**
53 * Validate SEO settings (implements interface)
54 *
55 * @since 1.0.0
56 *
57 * @param array $settings Settings array to validate
58 * @return array Validation results
59 */
60 public function validate_settings(array $settings): array {
61 $validation = [
62 'valid' => true,
63 'errors' => [],
64 'warnings' => [],
65 'suggestions' => [],
66 'score' => 100
67 ];
68
69 $boolean_fields = ['add_missing_alt', 'add_missing_title', 'save_alt_to_media', 'auto_fill_on_upload', 'media_alt_overwrite'];
70 foreach ($boolean_fields as $field) {
71 if (isset($settings[$field]) && !is_bool($settings[$field])) {
72 $validation['errors'][] = sprintf('%s must be a boolean value', $field);
73 $validation['valid'] = false;
74 }
75 }
76
77 $string_fields = ['alt_format', 'title_format'];
78 foreach ($string_fields as $field) {
79 if (isset($settings[$field]) && !is_string($settings[$field])) {
80 $validation['errors'][] = sprintf('%s must be a string', $field);
81 $validation['valid'] = false;
82 }
83 }
84
85 return $validation;
86 }
87
88 /**
89 * Get output data for frontend rendering (implements interface)
90 *
91 * @since 1.0.0
92 *
93 * @param string $context_type The context type
94 * @param int|null $context_id Optional. Context ID
95 * @return array Output data ready for frontend rendering
96 */
97 public function get_output_data(string $context_type, ?int $context_id): array {
98 return $this->get_settings($context_type, $context_id);
99 }
100
101 /**
102 * Get default settings for a context type (implements interface)
103 *
104 * @since 1.0.0
105 *
106 * @param string $context_type The context type to get defaults for
107 * @return array Default settings array
108 */
109 public function get_default_settings(string $context_type): array {
110 return [
111 'add_missing_alt' => false,
112 'alt_format' => '%filename%',
113 'add_missing_title' => false,
114 'title_format' => '%title% %separator% %sitename%',
115 // Media Library alt persistence (writes _wp_attachment_image_alt)
116 'save_alt_to_media' => false,
117 'auto_fill_on_upload' => false,
118 'media_alt_overwrite' => false,
119 ];
120 }
121
122 /**
123 * Get settings schema definition (implements interface)
124 *
125 * @since 1.0.0
126 *
127 * @param string $context_type The context type to get schema for
128 * @return array Settings schema definition
129 */
130 public function get_settings_schema(string $context_type): array {
131 return [
132 'add_missing_alt' => [
133 'type' => 'boolean',
134 'title' => __('Add Missing Alt Attributes', 'thinkrank'),
135 'description' => __('Automatically add ALT attributes to images if they are missing.', 'thinkrank'),
136 'default' => false
137 ],
138 'alt_format' => [
139 'type' => 'string',
140 'title' => __('Alt attribute format', 'thinkrank'),
141 'description' => __('The format to use for automatically generated ALT attributes.', 'thinkrank'),
142 'default' => '%filename%'
143 ],
144 'add_missing_title' => [
145 'type' => 'boolean',
146 'title' => __('Add Missing Title Attributes', 'thinkrank'),
147 'description' => __('Automatically add TITLE attributes to images if they are missing.', 'thinkrank'),
148 'default' => false
149 ],
150 'title_format' => [
151 'type' => 'string',
152 'title' => __('Title attribute format', 'thinkrank'),
153 'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'),
154 'default' => '%title% %separator% %sitename%'
155 ],
156 'save_alt_to_media' => [
157 'type' => 'boolean',
158 'title' => __('Save alt text to the Media Library', 'thinkrank'),
159 'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'),
160 'default' => false
161 ],
162 'auto_fill_on_upload' => [
163 'type' => 'boolean',
164 'title' => __('Fill alt text on upload', 'thinkrank'),
165 'description' => __('When a new image is uploaded, automatically save generated alt text to the Media Library.', 'thinkrank'),
166 'default' => false
167 ],
168 'media_alt_overwrite' => [
169 'type' => 'boolean',
170 'title' => __('Overwrite existing alt text', 'thinkrank'),
171 'description' => __('Replace alt text that is already set, instead of only filling images that are missing it.', 'thinkrank'),
172 'default' => false
173 ]
174 ];
175 }
176
177 /**
178 * Process content and inject missing image attributes
179 *
180 * @since 1.0.0
181 * @param string $content The content to process
182 * @return string Processed content
183 */
184 public function process_content(string $content, $post_id = null): string {
185 $settings = $this->get_settings('site');
186
187 if (empty($settings['add_missing_alt']) && empty($settings['add_missing_title'])) {
188 return $content;
189 }
190
191 static $count = 0;
192 $post_id ??= get_the_ID();
193 $id_to_pass = is_int($post_id) ? $post_id : 0;
194
195 // Use regex for high performance, but careful with HTML structure
196 return preg_replace_callback('/<img([^>]+)>/i', function ($matches) use ($settings, &$count, $id_to_pass) {
197 $count++;
198 $img_tag = $matches[0];
199 $attributes_str = $matches[1];
200
201 // Parse attributes (keys lower-cased; quoted and unquoted values supported)
202 $attributes = $this->parse_attributes($attributes_str);
203
204 $alt_missing = !empty($settings['add_missing_alt']) && trim((string) ($attributes['alt'] ?? '')) === '';
205 $title_missing = !empty($settings['add_missing_title']) && trim((string) ($attributes['title'] ?? '')) === '';
206
207 // Nothing to inject on this image — skip before any source/attachment work.
208 if (!$alt_missing && !$title_missing) {
209 return $img_tag;
210 }
211
212 // Resolve the real image source. Lazy-load markup keeps the true URL in a
213 // data-* attribute while `src` is empty or a placeholder/data-URI.
214 $src = $this->resolve_image_src($attributes);
215
216 // No resolvable source (spacers, tracking pixels, pure placeholders) — nothing
217 // meaningful to describe, and nothing to derive %filename% from either.
218 if ($src === '') {
219 return $img_tag;
220 }
221
222 // Resolve the attachment ID only when a format that we're about to apply
223 // actually references attachment metadata (%image_title% / %image_caption%).
224 // attachment_url_to_postid() is a DB query, so avoid it for the common
225 // filename-based formats and for images that need no injection.
226 $needs_attachment =
227 ($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) ||
228 ($title_missing && $this->format_uses_attachment($settings['title_format'] ?? ''));
229 $attachment_id = $needs_attachment ? $this->url_to_attachment_id($src) : 0;
230
231 // Handle ALT attribute
232 if ($alt_missing) {
233 $alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
234 if ($alt_val !== '') {
235 $img_tag = $this->inject_attribute($img_tag, 'alt', $alt_val, isset($attributes['alt']));
236 }
237 }
238
239 // Handle TITLE attribute
240 if ($title_missing) {
241 $title_val = $this->generate_attribute_value($settings['title_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
242 if ($title_val !== '') {
243 $img_tag = $this->inject_attribute($img_tag, 'title', $title_val, isset($attributes['title']));
244 }
245 }
246
247 return $img_tag;
248 }, $content);
249 }
250
251 /**
252 * Parse an <img> attribute string into a lower-cased key => value map.
253 *
254 * Handles double-quoted, single-quoted and unquoted attribute values so that
255 * existing attributes (e.g. an unquoted `alt=Something`) are correctly detected
256 * and not duplicated. Attribute names are normalised to lower-case so uppercase
257 * markup (`SRC=`, `ALT=`) is recognised.
258 *
259 * @since 1.19.1
260 * @param string $attributes_str The raw attribute portion of the tag.
261 * @return array<string,string> Lower-cased attribute name => value.
262 */
263 private function parse_attributes(string $attributes_str): array {
264 $matched = preg_match_all(
265 '/([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))/',
266 $attributes_str,
267 $matches,
268 PREG_SET_ORDER
269 );
270
271 if (!$matched) {
272 return [];
273 }
274
275 $attributes = [];
276 foreach ($matches as $m) {
277 $key = strtolower($m[1]);
278
279 if (isset($m[2]) && $m[2] !== '') {
280 $value = $m[2];
281 } elseif (isset($m[3]) && $m[3] !== '') {
282 $value = $m[3];
283 } elseif (isset($m[4]) && $m[4] !== '') {
284 $value = $m[4];
285 } else {
286 $value = '';
287 }
288
289 $attributes[$key] = $value;
290 }
291
292 return $attributes;
293 }
294
295 /**
296 * Resolve a usable image source from the parsed attributes.
297 *
298 * Prefers `src`, but falls back to common lazy-load attributes when `src` is
299 * empty or a `data:` URI placeholder, so generated alt/title reflect the real
300 * image rather than a base64 blob.
301 *
302 * @since 1.19.1
303 * @param array<string,string> $attributes Parsed attributes.
304 * @return string The resolved source URL, or '' if none is usable.
305 */
306 private function resolve_image_src(array $attributes): string {
307 $candidates = ['src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy'];
308
309 foreach ($candidates as $attr) {
310 $value = trim((string) ($attributes[$attr] ?? ''));
311
312 if ($value === '' || stripos($value, 'data:') === 0) {
313 continue;
314 }
315
316 return $value;
317 }
318
319 return '';
320 }
321
322 /**
323 * Whether a format string references attachment-only metadata tokens.
324 *
325 * Used to decide if an attachment lookup (a DB query) is actually needed;
326 * filename/site/title/count tokens do not require the attachment record.
327 *
328 * @since 1.19.1
329 * @param string $format The format string.
330 * @return bool
331 */
332 private function format_uses_attachment(string $format): bool {
333 return strpos($format, '%image_title%') !== false
334 || strpos($format, '%image_caption%') !== false;
335 }
336
337 /**
338 * Resolve an attachment ID from a source URL, memoized per request.
339 *
340 * `attachment_url_to_postid()` issues its own DB query, so repeated identical
341 * URLs on a page (galleries, duplicated images) are cached here.
342 *
343 * @since 1.19.1
344 * @param string $src Source URL.
345 * @return int Attachment ID, or 0 if not a media-library image.
346 */
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];
358 }
359
360 /**
361 * Inject (or replace an empty) alt/title attribute on a single <img> tag.
362 *
363 * When replacing, the pattern is anchored to a whitespace/tag boundary and
364 * limited to one occurrence so it can never clobber a `data-alt`/`data-title`
365 * (or any `*-alt`/`*-title`) attribute. A callback is used for the replacement
366 * so `$` / `\` in the value are never treated as backreferences. Insertion is
367 * case-insensitive on the tag opener so uppercase `<IMG>` is handled.
368 *
369 * @since 1.19.1
370 * @param string $img_tag The full <img> tag.
371 * @param string $name Attribute name ('alt' or 'title').
372 * @param string $value Unescaped attribute value.
373 * @param bool $replace Whether an (empty) attribute already exists to replace.
374 * @return string The modified tag.
375 */
376 private function inject_attribute(string $img_tag, string $name, string $value, bool $replace): string {
377 $attr = $name . '="' . esc_attr($value) . '"';
378
379 if ($replace) {
380 return preg_replace_callback(
381 '/(^|\s)' . preg_quote($name, '/') . '\s*=\s*(["\'])[^"\']*\2/i',
382 static function ($m) use ($attr) {
383 return $m[1] . $attr;
384 },
385 $img_tag,
386 1
387 );
388 }
389
390 return preg_replace_callback(
391 '/<img\b/i',
392 static function ($m) use ($attr) {
393 return $m[0] . ' ' . $attr;
394 },
395 $img_tag,
396 1
397 );
398 }
399
400 /**
401 * Generate attribute value based on format and context
402 *
403 * @since 1.0.0
404 * @param string $format The format string
405 * @param int $attachment_id Attachment ID
406 * @param int $post_id Current Post ID
407 * @param int $count Image counter
408 * @param string $src Image source URL
409 * @return string Generated value
410 */
411 private function generate_attribute_value(string $format, int $attachment_id, int $post_id, int $count, string $src): string {
412 $replacements = [
413 '%site_title%' => get_bloginfo('name'),
414 '%sitename%' => get_bloginfo('name'),
415 '%title%' => $post_id > 0 ? get_the_title($post_id) : get_bloginfo('name'),
416 '%count%' => (string) $count,
417 '%filename%' => '',
418 '%image_title%' => '',
419 '%image_caption%' => '',
420 ];
421
422 // Get filename from src
423 if ($src) {
424 $filename = pathinfo($src, PATHINFO_FILENAME);
425 $replacements['%filename%'] = str_replace(['-', '_'], ' ', $filename);
426 }
427
428 // Get attachment data if ID exists
429 if ($attachment_id) {
430 $attachment = get_post($attachment_id);
431 if ($attachment) {
432 $replacements['%image_title%'] = $attachment->post_title;
433 $replacements['%image_caption%'] = $attachment->post_excerpt;
434 }
435 }
436
437 // Apply replacements for every token except the separator.
438 $value = str_replace(array_keys($replacements), array_values($replacements), $format);
439
440 // Split on the separator tokens, drop segments that resolved to empty, then
441 // re-join with the separator symbol. This prevents orphaned/leading/trailing
442 // separators such as "| Site Name" when a token (e.g. %filename%) is empty.
443 $segments = preg_split('/%sep(?:arator)?%/', $value);
444 $segments = array_filter(
445 array_map('trim', $segments),
446 static function ($segment) {
447 return $segment !== '';
448 }
449 );
450 $value = implode(' ' . $this->get_separator() . ' ', $segments);
451
452 // Clean up double spaces if any
453 $value = preg_replace('/\s+/', ' ', $value);
454
455 return trim($value);
456 }
457
458 /**
459 * Get site separator
460 *
461 * @since 1.0.0
462 * @return string
463 */
464 private function get_separator(): string {
465 if ($this->separator === null) {
466 $this->separator = Site_Identity_Manager::get_active_separator_symbol();
467 }
468 return $this->separator;
469 }
470
471 // ─────────────────────────────────────────────────────────────────────
472 // Media Library alt-text persistence (writes _wp_attachment_image_alt)
473 // ─────────────────────────────────────────────────────────────────────
474
475 /**
476 * Generate and save alt text onto a single attachment's Media Library record.
477 *
478 * Uses the same `alt_format` token pipeline as output injection, so the value
479 * matches what the front-end filter would have produced. In this context
480 * `%title%` and `%image_title%` resolve to the attachment's own title.
481 *
482 * @since 1.19.1
483 * @param int $attachment_id The attachment ID.
484 * @param bool $overwrite When false, images that already have alt text are left untouched.
485 * @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
486 */
487 public function fill_attachment_alt(int $attachment_id, bool $overwrite = false): bool {
488 if (!wp_attachment_is_image($attachment_id)) {
489 return false;
490 }
491
492 $existing = (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
493
494 // Non-destructive by default: never clobber hand-written alt text.
495 if (!$overwrite && trim($existing) !== '') {
496 return false;
497 }
498
499 $settings = $this->get_settings('site');
500 $format = $settings['alt_format'] ?? '%filename%';
501 $src = (string) wp_get_attachment_url($attachment_id);
502
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 );
508
509 if ($value === '') {
510 return false;
511 }
512
513 if ($existing === $value) {
514 // Already correct — treat as success without a redundant write.
515 return true;
516 }
517
518 return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
519 }
520
521 /**
522 * Fill alt text across the Media Library in a single batch.
523 *
524 * Iterates images by ascending ID using offset/limit so callers can page
525 * through large libraries without exhausting memory or hitting timeouts.
526 *
527 * @since 1.19.1
528 * @param array $args {
529 * @type int $offset Starting offset into the image set. Default 0.
530 * @type int $limit Batch size (clamped 1–200). Default 50.
531 * @type bool $overwrite Overwrite existing alt text. Default false.
532 * }
533 * @return array {
534 * @type int $total Total images in the library.
535 * @type int $processed Images looked at in this batch.
536 * @type int $updated Images whose alt text was written.
537 * @type int $skipped Images left unchanged (already had alt / no value).
538 * @type int $offset The offset this batch started at.
539 * @type int $next_offset The offset to pass for the next batch.
540 * @type int $remaining Images still to process after this batch.
541 * @type bool $done True when the whole library has been processed.
542 * }
543 */
544 public function bulk_fill_missing_alt(array $args = []): array {
545 $offset = max(0, (int) ($args['offset'] ?? 0));
546 $limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
547 $overwrite = !empty($args['overwrite']);
548
549 $total = $this->count_images();
550
551 $ids = get_posts([
552 'post_type' => 'attachment',
553 'post_mime_type' => 'image',
554 'post_status' => 'inherit',
555 'numberposts' => $limit,
556 'offset' => $offset,
557 'fields' => 'ids',
558 'orderby' => 'ID',
559 'order' => 'ASC',
560 'suppress_filters' => true,
561 ]);
562
563 $updated = 0;
564 $skipped = 0;
565 $processed = 0;
566
567 foreach ($ids as $id) {
568 $processed++;
569 if ($this->fill_attachment_alt((int) $id, $overwrite)) {
570 $updated++;
571 } else {
572 $skipped++;
573 }
574 }
575
576 $next_offset = $offset + count($ids);
577 $remaining = max(0, $total - $next_offset);
578
579 // Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
580 if ($updated > 0) {
581 $this->flush_analyzer_cache();
582 }
583
584 return [
585 'total' => $total,
586 'processed' => $processed,
587 'updated' => $updated,
588 'skipped' => $skipped,
589 'offset' => $offset,
590 'next_offset' => $next_offset,
591 'remaining' => $remaining,
592 'done' => $next_offset >= $total,
593 ];
594 }
595
596 /**
597 * Media Library alt-text coverage stats for the settings UI.
598 *
599 * @since 1.19.1
600 * @return array{total:int,with_alt:int,missing:int}
601 */
602 public function get_media_alt_stats(): array {
603 $total = $this->count_images();
604 $with_alt = $this->count_images_with_alt();
605
606 return [
607 'total' => $total,
608 'with_alt' => $with_alt,
609 'missing' => max(0, $total - $with_alt),
610 ];
611 }
612
613 /**
614 * Auto-fill hook target — save alt text for a freshly uploaded image.
615 *
616 * Gated by the `save_alt_to_media` + `auto_fill_on_upload` settings so it is a
617 * no-op unless the feature is enabled. Respects the overwrite preference.
618 *
619 * @since 1.19.1
620 * @param int $attachment_id The newly created attachment ID.
621 * @return void
622 */
623 public function maybe_auto_fill_on_upload(int $attachment_id): void {
624 $settings = $this->get_settings('site');
625
626 if (empty($settings['save_alt_to_media']) || empty($settings['auto_fill_on_upload'])) {
627 return;
628 }
629
630 if (!wp_attachment_is_image($attachment_id)) {
631 return;
632 }
633
634 $this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']));
635 }
636
637 /**
638 * Total number of image attachments in the library.
639 *
640 * @since 1.19.1
641 * @return int
642 */
643 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;
654 }
655
656 /**
657 * Number of image attachments that already have non-empty alt text.
658 *
659 * Mirrors the query used by the Site SEO Analyzer's alt-text check so the two
660 * features report consistent coverage.
661 *
662 * @since 1.19.1
663 * @return int
664 */
665 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
667 return (int) $this->wpdb->get_var(
668 "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
669 INNER JOIN {$this->wpdb->postmeta} pm
670 ON pm.post_id = p.ID
671 AND pm.meta_key = '_wp_attachment_image_alt'
672 AND pm.meta_value != ''
673 WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%'"
674 );
675 }
676
677 /**
678 * Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
679 *
680 * Uses the analyzer's transient key directly to avoid instantiating it here.
681 *
682 * @since 1.19.1
683 * @return void
684 */
685 private function flush_analyzer_cache(): void {
686 // Matches SEO_Analyzer::CACHE_KEY.
687 delete_transient('thinkrank_site_seo_analysis');
688 }
689 }
690