true,
'errors' => [],
'warnings' => [],
'suggestions' => [],
'score' => 100
];
$boolean_fields = ['add_missing_alt', 'add_missing_title', 'save_alt_to_media', 'auto_fill_on_upload', 'media_alt_overwrite'];
foreach ($boolean_fields as $field) {
if (isset($settings[$field]) && !is_bool($settings[$field])) {
$validation['errors'][] = sprintf('%s must be a boolean value', $field);
$validation['valid'] = false;
}
}
$string_fields = ['alt_format', 'title_format'];
foreach ($string_fields as $field) {
if (isset($settings[$field]) && !is_string($settings[$field])) {
$validation['errors'][] = sprintf('%s must be a string', $field);
$validation['valid'] = false;
}
}
// The schema declares alt_source as an enum but nothing used to check
// it, so any string persisted. The consumer falls back to the template
// path on an unknown value, which hid the drift rather than surfacing
// it — the settings screen just had no option to select (#323).
if (isset($settings['alt_source']) && !in_array($settings['alt_source'], self::ALT_SOURCES, true)) {
$validation['errors'][] = sprintf(
'alt_source must be one of: %s',
implode(', ', self::ALT_SOURCES)
);
$validation['valid'] = false;
}
return $validation;
}
/**
* Get output data for frontend rendering (implements interface)
*
* @since 1.0.0
*
* @param string $context_type The context type
* @param int|null $context_id Optional. Context ID
* @return array Output data ready for frontend rendering
*/
public function get_output_data(string $context_type, ?int $context_id): array {
return $this->get_settings($context_type, $context_id);
}
/**
* Get default settings for a context type (implements interface)
*
* @since 1.0.0
*
* @param string $context_type The context type to get defaults for
* @return array Default settings array
*/
/**
* Images per batch when alt text comes from the vision model.
*
* Each one is a paid call of a few seconds; 10 keeps a batch inside a
* normal PHP timeout and keeps the spend per click predictable.
*
* @since 1.28.0
* @var int
*/
private const AI_BATCH_LIMIT = 10;
public function get_default_settings(string $context_type): array {
return [
'add_missing_alt' => false,
'alt_format' => '%filename%',
'add_missing_title' => false,
'title_format' => '%title% %separator% %sitename%',
// Media Library alt persistence (writes _wp_attachment_image_alt)
'save_alt_to_media' => false,
'auto_fill_on_upload' => false,
'media_alt_overwrite' => false,
// 'template' rewrites the filename; 'ai' looks at the picture.
// Defaults to template because AI costs the user money per image.
'alt_source' => 'template',
];
}
/**
* Get settings schema definition (implements interface)
*
* @since 1.0.0
*
* @param string $context_type The context type to get schema for
* @return array Settings schema definition
*/
public function get_settings_schema(string $context_type): array {
return [
'add_missing_alt' => [
'type' => 'boolean',
'title' => __('Add Missing Alt Attributes', 'thinkrank'),
'description' => __('Automatically add ALT attributes to images if they are missing.', 'thinkrank'),
'default' => false
],
'alt_format' => [
'type' => 'string',
'title' => __('Alt attribute format', 'thinkrank'),
'description' => __('The format to use for automatically generated ALT attributes.', 'thinkrank'),
'default' => '%filename%'
],
'add_missing_title' => [
'type' => 'boolean',
'title' => __('Add Missing Title Attributes', 'thinkrank'),
'description' => __('Automatically add TITLE attributes to images if they are missing.', 'thinkrank'),
'default' => false
],
'title_format' => [
'type' => 'string',
'title' => __('Title attribute format', 'thinkrank'),
'description' => __('The format to use for automatically generated TITLE attributes.', 'thinkrank'),
'default' => '%title% %separator% %sitename%'
],
'alt_source' => [
'type' => 'string',
'title' => __('Alt text source', 'thinkrank'),
'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'),
'default' => 'template',
'enum' => self::ALT_SOURCES
],
'save_alt_to_media' => [
'type' => 'boolean',
'title' => __('Save alt text to the Media Library', 'thinkrank'),
'description' => __('Persist generated alt text onto the attachment record so it works everywhere, not just in rendered content.', 'thinkrank'),
'default' => false
],
'auto_fill_on_upload' => [
'type' => 'boolean',
'title' => __('Fill alt text on upload', 'thinkrank'),
'description' => __('When a new image is uploaded, automatically save generated alt text to the Media Library.', 'thinkrank'),
'default' => false
],
'media_alt_overwrite' => [
'type' => 'boolean',
'title' => __('Overwrite existing alt text', 'thinkrank'),
'description' => __('Replace alt text that is already set, instead of only filling images that are missing it.', 'thinkrank'),
'default' => false
]
];
}
/**
* Process content and inject missing image attributes
*
* @since 1.0.0
* @param string $content The content to process
* @return string Processed content
*/
public function process_content(string $content, $post_id = null): string {
$settings = $this->get_settings('site');
if (empty($settings['add_missing_alt']) && empty($settings['add_missing_title'])) {
return $content;
}
static $count = 0;
$post_id ??= get_the_ID();
$id_to_pass = is_int($post_id) ? $post_id : 0;
// Use regex for high performance, but careful with HTML structure
return preg_replace_callback('/
]+)>/i', function ($matches) use ($settings, &$count, $id_to_pass) {
$count++;
$img_tag = $matches[0];
$attributes_str = $matches[1];
// Parse attributes (keys lower-cased; quoted and unquoted values supported)
$attributes = $this->parse_attributes($attributes_str);
$alt_missing = !empty($settings['add_missing_alt']) && trim((string) ($attributes['alt'] ?? '')) === '';
$title_missing = !empty($settings['add_missing_title']) && trim((string) ($attributes['title'] ?? '')) === '';
// Nothing to inject on this image — skip before any source/attachment work.
if (!$alt_missing && !$title_missing) {
return $img_tag;
}
// Resolve the real image source. Lazy-load markup keeps the true URL in a
// data-* attribute while `src` is empty or a placeholder/data-URI.
$src = $this->resolve_image_src($attributes);
// No resolvable source (spacers, tracking pixels, pure placeholders) — nothing
// meaningful to describe, and nothing to derive %filename% from either.
if ($src === '') {
return $img_tag;
}
// Resolve the attachment ID only when a format that we're about to apply
// actually references attachment metadata (%image_title% / %image_caption%).
// attachment_url_to_postid() is a DB query, so avoid it for the common
// filename-based formats and for images that need no injection.
$needs_attachment =
($alt_missing && $this->format_uses_attachment($settings['alt_format'] ?? '')) ||
($title_missing && $this->format_uses_attachment($settings['title_format'] ?? ''));
$attachment_id = $needs_attachment ? $this->url_to_attachment_id($src) : 0;
// Handle ALT attribute
if ($alt_missing) {
$alt_val = $this->generate_attribute_value($settings['alt_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
if ($alt_val !== '') {
$img_tag = $this->inject_attribute($img_tag, 'alt', $alt_val, isset($attributes['alt']));
}
}
// Handle TITLE attribute
if ($title_missing) {
$title_val = $this->generate_attribute_value($settings['title_format'] ?? '', $attachment_id, $id_to_pass, $count, $src);
if ($title_val !== '') {
$img_tag = $this->inject_attribute($img_tag, 'title', $title_val, isset($attributes['title']));
}
}
return $img_tag;
}, $content);
}
/**
* Parse an
attribute string into a lower-cased key => value map.
*
* Handles double-quoted, single-quoted and unquoted attribute values so that
* existing attributes (e.g. an unquoted `alt=Something`) are correctly detected
* and not duplicated. Attribute names are normalised to lower-case so uppercase
* markup (`SRC=`, `ALT=`) is recognised.
*
* @since 1.19.1
* @param string $attributes_str The raw attribute portion of the tag.
* @return array Lower-cased attribute name => value.
*/
private function parse_attributes(string $attributes_str): array {
$matched = preg_match_all(
'/([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))/',
$attributes_str,
$matches,
PREG_SET_ORDER
);
if (!$matched) {
return [];
}
$attributes = [];
foreach ($matches as $m) {
$key = strtolower($m[1]);
if (isset($m[2]) && $m[2] !== '') {
$value = $m[2];
} elseif (isset($m[3]) && $m[3] !== '') {
$value = $m[3];
} elseif (isset($m[4]) && $m[4] !== '') {
$value = $m[4];
} else {
$value = '';
}
$attributes[$key] = $value;
}
return $attributes;
}
/**
* Resolve a usable image source from the parsed attributes.
*
* Prefers `src`, but falls back to common lazy-load attributes when `src` is
* empty or a `data:` URI placeholder, so generated alt/title reflect the real
* image rather than a base64 blob.
*
* @since 1.19.1
* @param array $attributes Parsed attributes.
* @return string The resolved source URL, or '' if none is usable.
*/
private function resolve_image_src(array $attributes): string {
$candidates = ['src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy'];
foreach ($candidates as $attr) {
$value = trim((string) ($attributes[$attr] ?? ''));
if ($value === '' || stripos($value, 'data:') === 0) {
continue;
}
return $value;
}
return '';
}
/**
* Whether a format string references attachment-only metadata tokens.
*
* Used to decide if an attachment lookup (a DB query) is actually needed;
* filename/site/title/count tokens do not require the attachment record.
*
* @since 1.19.1
* @param string $format The format string.
* @return bool
*/
private function format_uses_attachment(string $format): bool {
return strpos($format, '%image_title%') !== false
|| strpos($format, '%image_caption%') !== false;
}
/**
* Resolve an attachment ID from a source URL, memoized per request.
*
* `attachment_url_to_postid()` issues its own DB query, so repeated identical
* URLs on a page (galleries, duplicated images) are cached here.
*
* @since 1.19.1
* @param string $src Source URL.
* @return int Attachment ID, or 0 if not a media-library image.
*/
private function url_to_attachment_id(string $src): int {
if ($src === '') {
return 0;
}
static $cache = [];
if (!array_key_exists($src, $cache)) {
$cache[$src] = attachment_url_to_postid($src);
}
return $cache[$src];
}
/**
* Inject (or replace an empty) alt/title attribute on a single
tag.
*
* When replacing, the pattern is anchored to a whitespace/tag boundary and
* limited to one occurrence so it can never clobber a `data-alt`/`data-title`
* (or any `*-alt`/`*-title`) attribute. A callback is used for the replacement
* so `$` / `\` in the value are never treated as backreferences. Insertion is
* case-insensitive on the tag opener so uppercase `
` is handled.
*
* @since 1.19.1
* @param string $img_tag The full
tag.
* @param string $name Attribute name ('alt' or 'title').
* @param string $value Unescaped attribute value.
* @param bool $replace Whether an (empty) attribute already exists to replace.
* @return string The modified tag.
*/
private function inject_attribute(string $img_tag, string $name, string $value, bool $replace): string {
$attr = $name . '="' . esc_attr($value) . '"';
if ($replace) {
return preg_replace_callback(
'/(^|\s)' . preg_quote($name, '/') . '\s*=\s*(["\'])[^"\']*\2/i',
static function ($m) use ($attr) {
return $m[1] . $attr;
},
$img_tag,
1
);
}
return preg_replace_callback(
'/
get_bloginfo('name'),
'%sitename%' => get_bloginfo('name'),
// Empty, not the site name. The segment collapsing below drops an
// unresolved token together with its separator, and the default
// title_format already ends in %sitename% — substituting the site
// name here printed it twice ("Site Name | Site Name") on every
// image processed outside the loop (widgets, page builders, FSE).
'%title%' => $post_id > 0 ? get_the_title($post_id) : '',
'%count%' => (string) $count,
'%filename%' => '',
'%image_title%' => '',
'%image_caption%' => '',
];
// Get filename from src
if ($src) {
$filename = pathinfo($src, PATHINFO_FILENAME);
$replacements['%filename%'] = str_replace(['-', '_'], ' ', $filename);
}
// Get attachment data if ID exists
if ($attachment_id) {
$attachment = get_post($attachment_id);
if ($attachment) {
$replacements['%image_title%'] = $attachment->post_title;
$replacements['%image_caption%'] = $attachment->post_excerpt;
}
}
// Apply replacements for every token except the separator.
$value = str_replace(array_keys($replacements), array_values($replacements), $format);
// Split on the separator tokens, drop segments that resolved to empty, then
// re-join with the separator symbol. This prevents orphaned/leading/trailing
// separators such as "| Site Name" when a token (e.g. %filename%) is empty.
$segments = preg_split('/%sep(?:arator)?%/', $value);
$segments = array_filter(
array_map('trim', $segments),
static function ($segment) {
return $segment !== '';
}
);
$value = implode(' ' . $this->get_separator() . ' ', $segments);
// Clean up double spaces if any
$value = preg_replace('/\s+/', ' ', $value);
return trim($value);
}
/**
* Get site separator
*
* @since 1.0.0
* @return string
*/
private function get_separator(): string {
if ($this->separator === null) {
$this->separator = Site_Identity_Manager::get_active_separator_symbol();
}
return $this->separator;
}
// ─────────────────────────────────────────────────────────────────────
// Media Library alt-text persistence (writes _wp_attachment_image_alt)
// ─────────────────────────────────────────────────────────────────────
/**
* Generate and save alt text onto a single attachment's Media Library record.
*
* Uses the same `alt_format` token pipeline as output injection, so the value
* matches what the front-end filter would have produced. In this context
* `%title%` and `%image_title%` resolve to the attachment's own title.
*
* @since 1.19.1
* @param int $attachment_id The attachment ID.
* @param bool $overwrite When false, images that already have alt text are left untouched.
* @return bool True when the attachment now has the generated alt text; false when skipped or on failure.
*/
public function fill_attachment_alt(int $attachment_id, bool $overwrite = false): bool {
if (!wp_attachment_is_image($attachment_id)) {
return false;
}
$existing = (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
// Non-destructive by default: never clobber hand-written alt text.
if (!$overwrite && trim($existing) !== '') {
return false;
}
$settings = $this->get_settings('site');
$format = $settings['alt_format'] ?? '%filename%';
$src = (string) wp_get_attachment_url($attachment_id);
$value = '';
// AI describes the picture; the template can only rewrite its filename.
// Falls back to the template on any failure so a provider outage
// degrades to the old behaviour instead of leaving images bare.
if ('ai' === ($settings['alt_source'] ?? 'template')) {
$value = $this->generate_ai_alt($attachment_id);
}
if ('' === $value) {
// Pass the attachment ID as the post context so %title% falls back to the
// attachment's own title (there is no surrounding post here).
$value = sanitize_text_field(
$this->generate_attribute_value($format, $attachment_id, $attachment_id, 0, $src)
);
}
if ($value === '') {
return false;
}
if ($existing === $value) {
// Already correct — treat as success without a redundant write.
return true;
}
return update_post_meta($attachment_id, '_wp_attachment_image_alt', $value) !== false;
}
/**
* Describe an attachment with the vision model.
*
* Never throws: alt text generation runs in batches over a whole media
* library, and one unreadable image or a rate-limit blip must not abort
* the run. Returns '' so the caller falls back to the template.
*
* @since 1.28.0
* @param int $attachment_id Attachment to describe.
* @return string Alt text, or '' when unavailable.
*/
private function generate_ai_alt(int $attachment_id): string {
try {
$vision = new \ThinkRank\AI\Vision_Client();
if (!$vision->is_available()) {
return '';
}
// The parent post's title disambiguates images that are visually
// ambiguous on their own (a generic chart, a product on white).
$context = '';
$parent = (int) get_post_field('post_parent', $attachment_id);
if ($parent > 0) {
$context = (string) get_the_title($parent);
}
return sanitize_text_field($vision->describe_attachment($attachment_id, $context));
} catch (\Throwable $e) {
return '';
}
}
/**
* Fill alt text across the Media Library in a single batch.
*
* Iterates images by ascending ID using offset/limit so callers can page
* through large libraries without exhausting memory or hitting timeouts.
*
* @since 1.19.1
* @param array $args {
* @type int $offset Starting offset into the image set. Default 0.
* @type int $limit Batch size (clamped 1–200). Default 50.
* @type bool $overwrite Overwrite existing alt text. Default false.
* }
* @return array {
* @type int $total Total images in the library.
* @type int $processed Images looked at in this batch.
* @type int $updated Images whose alt text was written.
* @type int $skipped Images left unchanged (already had alt / no value).
* @type int $offset The offset this batch started at.
* @type int $next_offset The offset to pass for the next batch.
* @type int $remaining Images still to process after this batch.
* @type bool $done True when the whole library has been processed.
* }
*/
public function bulk_fill_missing_alt(array $args = []): array {
$offset = max(0, (int) ($args['offset'] ?? 0));
$limit = min(200, max(1, (int) ($args['limit'] ?? 50)));
$overwrite = !empty($args['overwrite']);
// In AI mode every image is a paid provider call that takes seconds,
// so a 200-image batch would both surprise the user's bill and blow
// past max_execution_time. Cap the batch and let the caller page —
// `remaining` already drives that loop.
if ('ai' === ($this->get_settings('site')['alt_source'] ?? 'template')) {
$limit = min($limit, self::AI_BATCH_LIMIT);
}
$total = $this->count_images();
$ids = get_posts([
'post_type' => 'attachment',
'post_mime_type' => 'image',
// Must cover the same set count_images() counts, or the pager can
// never reach the total. 'inherit' alone excluded private-status
// attachments — which media-protection and membership plugins do
// create — while count_images() still counted them (#322).
'post_status' => ['inherit', 'private', 'publish', 'draft', 'pending', 'future'],
'numberposts' => $limit,
'offset' => $offset,
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
// 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).
'suppress_filters' => true,
]);
$updated = 0;
$skipped = 0;
$processed = 0;
foreach ($ids as $id) {
$processed++;
if ($this->fill_attachment_alt((int) $id, $overwrite)) {
$updated++;
} else {
$skipped++;
}
}
$next_offset = $offset + count($ids);
// An empty batch means there is nothing left to walk, whatever the
// total claims. Deriving `done` from the count alone let any drift
// between the two queries strand the caller on a batch that could
// never advance the offset, and the admin UI answers that by
// re-requesting up to 10,000 times.
$exhausted = empty($ids);
$remaining = $exhausted ? 0 : max(0, $total - $next_offset);
// Bulk writes change the Site SEO Analyzer's "images have alt text" coverage.
if ($updated > 0) {
$this->flush_analyzer_cache();
}
return [
'total' => $total,
'processed' => $processed,
'updated' => $updated,
'skipped' => $skipped,
'offset' => $offset,
'next_offset' => $next_offset,
'remaining' => $remaining,
'done' => $exhausted || $next_offset >= $total,
];
}
/**
* Media Library alt-text coverage stats for the settings UI.
*
* @since 1.19.1
* @return array{total:int,with_alt:int,missing:int}
*/
public function get_media_alt_stats(): array {
$total = $this->count_images();
$with_alt = $this->count_images_with_alt();
return [
'total' => $total,
'with_alt' => $with_alt,
'missing' => max(0, $total - $with_alt),
];
}
/**
* Auto-fill hook target — save alt text for a freshly uploaded image.
*
* Gated by the `save_alt_to_media` + `auto_fill_on_upload` settings so it is a
* no-op unless the feature is enabled. Respects the overwrite preference.
*
* @since 1.19.1
* @param int $attachment_id The newly created attachment ID.
* @return void
*/
public function maybe_auto_fill_on_upload(int $attachment_id): void {
$settings = $this->get_settings('site');
if (empty($settings['save_alt_to_media']) || empty($settings['auto_fill_on_upload'])) {
return;
}
if (!wp_attachment_is_image($attachment_id)) {
return;
}
$this->fill_attachment_alt($attachment_id, !empty($settings['media_alt_overwrite']));
}
/**
* Total number of image attachments in the library.
*
* Counted with an explicit `post_status != 'trash'` rather than through
* wp_count_attachments(). The helper applies that filter internally, which
* looked equivalent — but it left the two halves of get_media_alt_stats()
* with different notions of which images exist, and only one of them said
* so out loud. Spelling the filter out here keeps this query and
* count_images_with_alt() visibly in step (#321).
*
* @since 1.19.1
* @return int
*/
private function count_images(): int {
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- indexed COUNT; short-lived admin action
return (int) $this->wpdb->get_var(
"SELECT COUNT(*) FROM {$this->wpdb->posts}
WHERE post_type = 'attachment'
AND post_mime_type LIKE 'image/%'
AND post_status != 'trash'"
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Number of image attachments that already have non-empty alt text.
*
* Carries the same `post_status != 'trash'` filter as count_images(), so a
* trashed image can never be counted as covered against a total it is not
* part of. Matches the Site SEO Analyzer's alt-text check, which applies
* the same filter to both of its counts.
*
* @since 1.19.1
* @return int
*/
private function count_images_with_alt(): int {
// 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
return (int) $this->wpdb->get_var(
"SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p
INNER JOIN {$this->wpdb->postmeta} pm
ON pm.post_id = p.ID
AND pm.meta_key = '_wp_attachment_image_alt'
AND pm.meta_value != ''
WHERE p.post_type = 'attachment'
AND p.post_mime_type LIKE 'image/%'
AND p.post_status != 'trash'"
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Bust the Site SEO Analyzer's cached result so its alt-text coverage refreshes.
*
* Uses the analyzer's transient key directly to avoid instantiating it here.
*
* @since 1.19.1
* @return void
*/
private function flush_analyzer_cache(): void {
// Ask the analyzer rather than duplicating its transient key here — the
// literal drifted out of sync the moment anyone renamed it.
if (class_exists('ThinkRank\\SEO\\SEO_Analyzer')) {
(new SEO_Analyzer())->flush_cache();
}
}
}