$token['raw-html'], 'offset' => $token['offset'], 'content' => '', ); foreach ( array( 'charset', 'viewport' ) as $structural ) { if ( ( 'charset' === $structural && isset( $attributes['charset'] ) ) || ( 'viewport' === $structural && 'viewport' === strtolower( $attributes['name']['value'] ?? '' ) ) ) { $found_meta_tags[ $structural ] = $entry; } } // Match actual tags through the output registry, independent of attribute order or quoting. foreach ( $metadata_field_descriptors as $identifier => $details ) { $type = $details['output-tag-type'] ?? ''; if ( 'title' === $type && 'title' === $token['tag'] && preg_match( '/^]*>(.*)<\/title\s*>$/is', $token['raw-html'], $title ) ) { $entry['content'] = trim( $title[1] ); } elseif ( 'meta' === $token['tag'] && in_array( $type, array( 'meta name', 'meta property' ), true ) && ( $details['output-tag-identifier'] ?? '' ) === strtolower( $attributes[ substr( $type, 5 ) ]['value'] ?? '' ) ) { $entry['content'] = trim( $attributes['content']['value'] ?? '' ); } else { continue; } $found_meta_tags[ $identifier ][] = $entry; } } return $found_meta_tags; } /** * Locate editable tags without changing bytes or interpreting raw-text resources as markup. * * @param string $html Buffered HTML. * @param bool $head_only Stop at the body when inspecting document-head boundaries. * @return array Tokens with original byte offsets; malformed raw-text tails remain opaque. */ function ai4seo_get_metadata_html_tokens( string $html, bool $head_only = false ): array { // Consume disjoint attribute chunks without a backtracking frame for every character. $attributes = '(?:[^>"\']++|"[^"]*"|\'[^\']*\')*+'; $pattern = '~' : ']]>'; $end = strpos( $html, $terminator, $offset ); $offset = false === $end ? $length : $end + strlen( $terminator ); continue; } preg_match( '~^<(/?)([a-z][a-z0-9:-]*)~i', $raw, $tag ); $name = strtolower( $tag[2] ); $closing = '/' === $tag[1]; if ( ! $closing && in_array( $name, array( 'script', 'style', 'textarea', 'noscript', 'title' ), true ) ) { // Search only for the closing delimiter; large opaque payloads never enter a wildcard match. $offset = preg_match( '~~i', $html, $end, PREG_OFFSET_CAPTURE, $offset ) ? $end[0][1] + strlen( $end[0][0] ) : $length; } if ( 'template' === $name ) { $template_depth = max( 0, $template_depth + ( $closing ? -1 : 1 ) ); continue; } if ( $template_depth || in_array( $name, array( 'script', 'style', 'textarea', 'noscript' ), true ) ) { continue; } $tokens[] = array( 'tag' => $name, 'closing' => $closing, 'raw-html' => substr( $html, $start, $offset - $start ), 'offset' => $start, ); if ( $head_only && 'body' === $name ) { break; } } return $tokens; } /** * Resolve the main document to a supported entry without invoking editor permissions or repairs. * * @return array|null|WP_Error Null means outside the output scope; errors preserve existing HTML. */ function ai4seo_get_frontend_metadata_entry_context() { global $wp_the_query; if ( ! $wp_the_query instanceof WP_Query || is_admin() || $wp_the_query->is_feed() || $wp_the_query->is_404() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || wp_doing_ajax() ) { return null; } if ( $wp_the_query->is_singular() ) { $id = ai4seo_get_current_post_id(); return $id ? array( 'kind' => 'post', 'post_id' => $id, ) : null; } if ( ! ( $wp_the_query->is_category() || $wp_the_query->is_tag() || $wp_the_query->is_tax() ) || $wp_the_query->is_paged() || (int) $wp_the_query->get( 'paged' ) > 1 || (int) $wp_the_query->get( 'page' ) > 1 ) { return null; } $term = $wp_the_query->get_queried_object(); if ( ! $term instanceof WP_Term ) { return null; } $proxy = ai4seo_read_taxonomy_proxy( (int) $term->term_taxonomy_id ); if ( is_wp_error( $proxy ) || ! $proxy ) { return $proxy; } $source = $proxy['source']; if ( ! $source || $source['term_id'] !== (int) $term->term_id || $source['taxonomy'] !== $term->taxonomy ) { return new WP_Error( 'taxonomy_output_identity_invalid' ); } $state = ai4seo_get_taxonomy_proxy_source_state( (int) $term->term_taxonomy_id, $term->taxonomy ); if ( is_wp_error( $state ) ) { return $state; } if ( 'available' !== $state['state'] || 'available' !== $source['availability'] || 'publish' !== $proxy['post_status'] ) { return null; } if ( $state['source']['term_id'] !== $source['term_id'] ) { return new WP_Error( 'taxonomy_output_identity_changed' ); } return array( 'kind' => 'taxonomy', 'post_id' => (int) $proxy['ID'], 'source' => $state['source'], ); } // phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound -- Preserve PHP 8 named callers. /** * Restores line breaks protected while buffered head HTML is split into tags. * * @param string $string The string containing protected line-break markers. * @return string The string with its original line breaks restored. */ function ai4seo_remove_header_line_break_placeholders( string $string ): string { // phpcs:enable Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound // Restore Windows and Unix markers separately so the original newline form is preserved. return str_replace( array( '#AI4SEO#LBRN#', '#AI4SEO#LBN#' ), array( "\r\n", "\n" ), $string ); } /** * Modify and add plugin metadata tags to the HTML head. * * @param string $full_html_buffer Full HTML response buffer. * @return string Modified HTML response buffer. */ function ai4seo_inject_our_meta_tags_into_the_html_head( string $full_html_buffer ): string { $metadata_field_descriptors = ai4seo_get_generation_field_descriptors( 'metadata' ); if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return $full_html_buffer; } if ( ! defined( 'AI4SEO_METADATA_DETAILS' ) ) { return $full_html_buffer; } // read setting AI4SEO_SETTING_META_TAG_OUTPUT_MODE. $policy = ai4seo_read_metadata_sync_policy_snapshot(); $meta_tag_output_mode = $policy['settings'][ AI4SEO_SETTING_META_TAG_OUTPUT_MODE ]; if ( 'disable' === $meta_tag_output_mode ) { return $full_html_buffer; // stop function if meta tag output is disabled. } // Normalize active identifiers before exact frontend-output comparisons. $active_meta_tags = $policy['settings'][ AI4SEO_SETTING_ACTIVE_META_TAGS ]; if ( ! $active_meta_tags ) { return $full_html_buffer; } $context = ai4seo_get_frontend_metadata_entry_context(); if ( is_wp_error( $context ) || ! $context ) { return $full_html_buffer; } $post_id = $context['post_id']; $is_taxonomy = 'taxonomy' === $context['kind']; // Require one explicit head before the body; protected resources never supply delimiters. $head_start_position = null; $head_end_position = null; foreach ( ai4seo_get_metadata_html_tokens( $full_html_buffer, true ) as $token ) { if ( 'body' === $token['tag'] && null === $head_end_position ) { return $full_html_buffer; } if ( 'head' !== $token['tag'] ) { continue; } if ( ! $token['closing'] && null === $head_start_position ) { $head_start_position = $token['offset'] + strlen( $token['raw-html'] ); } elseif ( $token['closing'] && null !== $head_start_position && null === $head_end_position ) { $head_end_position = $token['offset']; } else { return $full_html_buffer; } } if ( null === $head_start_position || null === $head_end_position ) { return $full_html_buffer; } $head_html = substr( $full_html_buffer, $head_start_position, $head_end_position - $head_start_position ); // analyze head html. $found_third_party_meta_tags = ai4seo_get_meta_tags_from_html( $head_html ); // read OUR metadata values for this post. $output_state = ai4seo_read_metadata_output_state( $post_id ); if ( ! $output_state['read_succeeded'] ) { return $full_html_buffer; } $our_metadata = $output_state['values']; // check post type. $current_post_type = get_post_type( $post_id ); if ( ! $is_taxonomy && ! ai4seo_is_metadata_sync_post_supported( $post_id, $policy ) ) { return $full_html_buffer; } // go through each meta tag and decide what to do with it. $add_this_metadata = array(); $remove_this_third_party_meta_tags = array(); $metadata_placeholder_replacements = ai4seo_get_metadata_output_placeholder_replacements( $post_id ); $current_post_title_for_placeholders = $metadata_placeholder_replacements['TITLE'] ?? ''; $current_product_price_for_placeholders = $metadata_placeholder_replacements['PRODUCT_PRICE'] ?? ''; // Keep repeated raw-content transformations within this output pass. $fallback_context = array(); foreach ( $metadata_field_descriptors as $this_metadata_identifier => $this_metadata_field_details ) { if ( ! in_array( $this_metadata_identifier, $active_meta_tags, true ) || in_array( $this_metadata_identifier, $output_state['unknown'], true ) ) { continue; } $this_found_third_party_meta_tags = $found_third_party_meta_tags[ $this_metadata_identifier ] ?? array(); $this_our_metadata = $our_metadata[ $this_metadata_identifier ] ?? ''; // Resolve supported third-party templates before deciding whether existing frontend tags should be replaced. if ( ! $is_taxonomy && $this_our_metadata ) { $this_our_metadata = ai4seo_resolve_third_party_seo_metadata_variables( $this_our_metadata, $post_id, $this_metadata_identifier ); $our_metadata[ $this_metadata_identifier ] = $this_our_metadata; } // find a fallback if neither we nor a third party have a value for this meta tag. if ( ! $is_taxonomy && empty( $this_our_metadata ) && empty( $this_found_third_party_meta_tags ) ) { ai4seo_apply_possible_fallbacks( $post_id, $this_metadata_identifier, $our_metadata, array(), $fallback_context ); $this_our_metadata = $our_metadata[ $this_metadata_identifier ] ?? ''; // Apply the same resolution rules to fallback values before they enter output-mode decisions. if ( $this_our_metadata ) { $this_our_metadata = ai4seo_resolve_third_party_seo_metadata_variables( $this_our_metadata, $post_id, $this_metadata_identifier ); $our_metadata[ $this_metadata_identifier ] = $this_our_metadata; } } // Providers can inherit our price template into an otherwise empty social field. // Reuse the existing renderer only for an exact known source in replace mode. if ( ! $is_taxonomy && 'product' === $current_post_type && 'replace' === $meta_tag_output_mode && empty( $this_our_metadata ) ) { foreach ( $this_found_third_party_meta_tags as $inherited_tag ) { $inherited_value = html_entity_decode( $inherited_tag['content'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( false !== strpos( $inherited_value, '{WC_PRICE=' ) && in_array( $inherited_value, $our_metadata, true ) ) { $this_our_metadata = $inherited_value; break; } } } // leave this meta tag alone if we do not have a value for it or we exclude this meta tag. if ( $is_taxonomy ? '' === trim( $this_our_metadata ) : ! $this_our_metadata ) { continue; } switch ( $meta_tag_output_mode ) { case 'force': $add_this_metadata[ $this_metadata_identifier ] = $this_our_metadata; break; case 'replace': $add_this_metadata[ $this_metadata_identifier ] = $this_our_metadata; // remove found third party meta tags. if ( $this_found_third_party_meta_tags ) { foreach ( $this_found_third_party_meta_tags as $this_found_third_party_meta_tag ) { if ( $this_found_third_party_meta_tag ) { $remove_this_third_party_meta_tags[ $this_found_third_party_meta_tag['offset'] ] = $this_found_third_party_meta_tag['raw-html']; } } } break; case 'complement': if ( ! $this_found_third_party_meta_tags ) { $add_this_metadata[ $this_metadata_identifier ] = $this_our_metadata; } else { // workaround: if all the found meta tags are empty -> add ours anyway and remove their empty ones. $this_found_third_party_meta_tag_got_content = false; $this_found_third_party_meta_tag_no_content_raw_html = array(); foreach ( $this_found_third_party_meta_tags as $this_found_third_party_meta_tag ) { if ( $is_taxonomy ? '' !== $this_found_third_party_meta_tag['content'] : (bool) $this_found_third_party_meta_tag['content'] ) { $this_found_third_party_meta_tag_got_content = true; break; } else { $this_found_third_party_meta_tag_no_content_raw_html[ $this_found_third_party_meta_tag['offset'] ] = $this_found_third_party_meta_tag['raw-html']; } } if ( ! $this_found_third_party_meta_tag_got_content ) { $add_this_metadata[ $this_metadata_identifier ] = $this_our_metadata; $remove_this_third_party_meta_tags += $this_found_third_party_meta_tag_no_content_raw_html; } } break; } } // Remove only identified tag spans, from the end so earlier byte offsets remain valid. if ( $remove_this_third_party_meta_tags ) { krsort( $remove_this_third_party_meta_tags, SORT_NUMERIC ); foreach ( $remove_this_third_party_meta_tags as $offset => $raw_html ) { $head_html = substr_replace( $head_html, '', $offset, strlen( $raw_html ) ); } } $found_third_party_meta_tags = ai4seo_get_meta_tags_from_html( $head_html ); // add our tags to the head, finding position first. if ( $add_this_metadata ) { $add_this_meta_tags = array(); // Read prefix- and suffix-settings. $ai4seo_metadata_prefixes = ai4seo_get_setting( AI4SEO_SETTING_METADATA_PREFIXES ); $ai4seo_metadata_suffixes = ai4seo_get_setting( AI4SEO_SETTING_METADATA_SUFFIXES ); // Build metadata tags from the selected values while preserving the registry's output shapes. foreach ( $add_this_metadata as $this_metadata_identifier => $this_metadata_content ) { // Read field details and affixes after output-mode selection has accepted the resolved metadata value. $this_metadata_field_details = $metadata_field_descriptors[ $this_metadata_identifier ] ?? array(); $this_metadata_prefix_raw = $ai4seo_metadata_prefixes[ $this_metadata_identifier ] ?? ''; $this_metadata_suffix_raw = $ai4seo_metadata_suffixes[ $this_metadata_identifier ] ?? ''; $this_metadata_prefix = trim( sanitize_text_field( $this_metadata_prefix_raw ) ); $this_metadata_suffix = trim( sanitize_text_field( $this_metadata_suffix_raw ) ); if ( 'product' !== $current_post_type && ai4seo_text_contains_product_placeholder( $this_metadata_prefix_raw ) ) { $this_metadata_prefix = ''; } else { $this_metadata_prefix = ai4seo_replace_text_placeholders( $this_metadata_prefix, $metadata_placeholder_replacements ); } if ( 'product' !== $current_post_type && ai4seo_text_contains_product_placeholder( $this_metadata_suffix_raw ) ) { $this_metadata_suffix = ''; } else { $this_metadata_suffix = ai4seo_replace_text_placeholders( $this_metadata_suffix, $metadata_placeholder_replacements ); } $this_metadata_prefix = ai4seo_replace_metadata_title_placeholder( $this_metadata_prefix, $current_post_title_for_placeholders ); $this_metadata_suffix = ai4seo_replace_metadata_title_placeholder( $this_metadata_suffix, $current_post_title_for_placeholders ); if ( ! $this_metadata_field_details ) { continue; } if ( false !== strpos( $this_metadata_content, '{WC_PRICE=' ) ) { $this_metadata_content = preg_replace_callback( '/\{WC_PRICE=([^}]+)}/', function ( $matches ) use ( $current_product_price_for_placeholders ) { $fallback_price = ai4seo_normalize_metadata_price_text( $matches[1] ); if ( '' !== $current_product_price_for_placeholders ) { return $current_product_price_for_placeholders; } return $fallback_price; }, $this_metadata_content ); } // Add prefix and suffix. $this_metadata_content = trim( $this_metadata_prefix . ' ' . $this_metadata_content . ' ' . $this_metadata_suffix ); // Prepare variables. $this_output_tag_type = $this_metadata_field_details['output-tag-type'] ?? ''; $this_output_tag_identifier = $this_metadata_field_details['output-tag-identifier'] ?? ''; // Render each configured field with the tag shape declared by its metadata registry entry. if ( 'title' === $this_output_tag_type ) { $add_this_meta_tags[] = '' . esc_attr( $this_metadata_content ) . ''; } elseif ( 'meta name' === $this_output_tag_type ) { $add_this_meta_tags[] = ''; } elseif ( 'meta property' === $this_output_tag_type ) { $add_this_meta_tags[] = ''; } } // output our meta tags. if ( $add_this_meta_tags ) { // find a suitable position for our meta tags. $our_meta_tags_position = 0; // consider the charset meta tag position, if it's near the head start. if ( isset( $found_third_party_meta_tags['charset'] ) ) { $charset_meta_tags_position = $found_third_party_meta_tags['charset']['offset'] + strlen( $found_third_party_meta_tags['charset']['raw-html'] ); // set $charset_meta_tags_position as our meta tags position if it's not further away than 100 characters. if ( $charset_meta_tags_position < 100 ) { $our_meta_tags_position = $charset_meta_tags_position; } } // consider the viewport meta tag position, if it's near the head start. if ( isset( $found_third_party_meta_tags['viewport'] ) ) { $viewport_meta_tags_position = $found_third_party_meta_tags['viewport']['offset'] + strlen( $found_third_party_meta_tags['viewport']['raw-html'] ); // set $viewport_meta_tags_position as our meta tags position if it's not further away than 200 characters. if ( $viewport_meta_tags_position < 200 ) { $our_meta_tags_position = $viewport_meta_tags_position; } } // Read start- and end-settings for generator hints. $add_generator_hints = ai4seo_get_setting( AI4SEO_SETTING_ADD_GENERATOR_HINTS ); if ( $add_generator_hints ) { $source_code_notes_start = ai4seo_get_setting( AI4SEO_SETTING_META_TAGS_BLOCK_STARTING_HINT ); $source_code_notes_end = ai4seo_get_setting( AI4SEO_SETTING_META_TAGS_BLOCK_ENDING_HINT ); // Replace placeholders in source-code-notes. $source_code_notes_start = ai4seo_replace_white_label_placeholders( $source_code_notes_start ); $source_code_notes_end = ai4seo_replace_white_label_placeholders( $source_code_notes_end ); // Make sure that $source_code_notes_start and $source_code_notes_end don't exceed the max. length // decode \" to ". $source_code_notes_start = str_replace( '\"', '"', $source_code_notes_start ); $source_code_notes_end = str_replace( '\"', '"', $source_code_notes_end ); $source_code_notes_start = ai4seo_mb_substr( $source_code_notes_start, 0, 250 ); $source_code_notes_end = ai4seo_mb_substr( $source_code_notes_end, 0, 250 ); // add plugin information to the meta tags block. array_unshift( $add_this_meta_tags, '' ); $add_this_meta_tags[] = ''; } $add_this_meta_tags = ai4seo_deep_sanitize( $add_this_meta_tags, 'ai4seo_wp_kses' ); // add our meta tags to the head. $head_html = substr_replace( $head_html, "\n\n\t" . implode( "\n\t", $add_this_meta_tags ) . "\n", $our_meta_tags_position, 0 ); } } return substr_replace( $full_html_buffer, $head_html, $head_start_position, $head_end_position - $head_start_position ); } // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Preserve the render_block callback contract. /** * Inject image attributes into an individual Gutenberg block render. * * @param string $content Rendered block HTML. * @param array $block Parsed block data retained for the WordPress render_block filter signature. * @return string Rendered block HTML with eligible image attributes injected. */ function ai4seo_inject_image_attributes_for_gutenberg( $content, $block ) { // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed // Prevent duplicate processing when more than one render path invokes this callback. if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return $content; } // Skip admin, feeds, REST API and AJAX requests. if ( is_admin() || is_feed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) { return $content; } return ai4seo_inject_image_attributes_into_html( $content ); } /** * Inject stored image attributes into rendered image tags when the matching settings are enabled. * * @param mixed $content The content value. * @return mixed Content with eligible image attributes injected. */ function ai4seo_inject_image_attributes_into_html( $content ) { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return $content; } $policy = ai4seo_read_metadata_sync_policy_snapshot(); $alt_enabled = $policy['settings'][ AI4SEO_SETTING_ENABLE_RENDER_LEVEL_ALT_TEXT_INJECTION ]; $title_injection_mode = $policy['settings'][ AI4SEO_SETTING_IMAGE_TITLE_INJECTION_MODE ]; if ( ! $alt_enabled && 'disabled' === $title_injection_mode ) { return $content; } $cache = array(); // Keep URL identities and attribute values within this render pass and the current site scope. $attachment_ids = array(); $attachment_ids_scope = ''; $attachment_ids_cache_enabled = true; return preg_replace_callback( '/]*?)>/i', function ( $matches ) use ( &$cache, &$attachment_ids, &$attachment_ids_scope, &$attachment_ids_cache_enabled, $alt_enabled, $title_injection_mode ) { global $wpdb; $this_full_tag = $matches[0]; $this_attr_str = $matches[1]; // Resolve the rendered source back to an attachment before consulting plugin-generated postmeta. if ( ! preg_match( '/\bsrc=["\']([^"\']+)["\']/', $this_attr_str, $this_src_matches ) ) { return $this_full_tag; } $this_src = $this_src_matches[1]; $this_scope = ai4seo_get_site_options_request_cache_scope(); // Drop the old site's identities, including when the current site cannot be determined. if ( $attachment_ids_scope !== $this_scope ) { $attachment_ids = array(); $attachment_ids_scope = $this_scope; $cache = array(); } if ( '' === $this_scope ) { $cache = array(); } // Resolver filters may be stateful or appear mid-render; once observed, preserve every later call. if ( $attachment_ids_cache_enabled && ( has_filter( 'pre_attachment_url_to_postid' ) || has_filter( 'attachment_url_to_postid' ) ) ) { $attachment_ids = array(); $attachment_ids_cache_enabled = false; } if ( $attachment_ids_cache_enabled && '' !== $this_scope && array_key_exists( $this_src, $attachment_ids ) ) { $this_post_id = $attachment_ids[ $this_src ]; } else { $this_post_id = ai4seo_get_attachment_id_from_src( $this_src ); // A lookup can itself install a filter or switch sites; never publish that result into the old scope. if ( $attachment_ids_cache_enabled && ( has_filter( 'pre_attachment_url_to_postid' ) || has_filter( 'attachment_url_to_postid' ) ) ) { $attachment_ids = array(); $attachment_ids_cache_enabled = false; } $resolved_scope = ai4seo_get_site_options_request_cache_scope(); if ( $resolved_scope !== $this_scope ) { $attachment_ids = array(); $attachment_ids_scope = $resolved_scope; } // Retain regular misses too, but never an observable database failure or more than 256 exact URLs. if ( $attachment_ids_cache_enabled && '' !== $this_scope && $resolved_scope === $this_scope && empty( $wpdb->last_error ) && count( $attachment_ids ) < 256 ) { $attachment_ids[ $this_src ] = $this_post_id; } } if ( ! $this_post_id ) { return $this_full_tag; } // Restrict render-level changes to attachments for which the plugin has generated and stored data. $attribute_cache_key = $this_scope . ':' . $this_post_id; if ( ! isset( $cache[ $attribute_cache_key ]['generated'] ) ) { try { $cache[ $attribute_cache_key ]['generated'] = ai4seo_read_generated_data_presence_for_post( $this_post_id ); } catch ( Throwable $throwable ) { $cache[ $attribute_cache_key ]['generated'] = false; } } if ( ! $cache[ $attribute_cache_key ]['generated'] ) { return $this_full_tag; } $this_to_add = array(); // Queue an alt replacement only when render-level injection is enabled and the stored value differs. if ( $alt_enabled ) { // Preserve an explicitly matching value while still replacing missing or stale rendered attributes. preg_match( '/\balt\s*=\s*["\']([^"\']*)["\']/', $this_attr_str, $this_alt_matches ); $this_existing_alt = $this_alt_matches[1] ?? null; // Cache postmeta within this render pass because the same attachment can appear more than once. if ( ! isset( $cache[ $attribute_cache_key ]['alt'] ) ) { $cache[ $attribute_cache_key ]['alt'] = ai4seo_read_frontend_attachment_alt( $this_post_id ); } $this_db_alt = $cache[ $attribute_cache_key ]['alt']; if ( $this_db_alt && $this_existing_alt !== $this_db_alt ) { $this_to_add['alt'] = $this_db_alt; } } // Queue the configured title source independently so alt-only and title-only modes keep working. if ( 'disabled' !== $title_injection_mode ) { preg_match( '/\btitle\s*=\s*["\']([^"\']*)["\']/', $this_attr_str, $this_title_matches ); $this_existing_title = $this_title_matches[1] ?? null; if ( ! isset( $cache[ $attribute_cache_key ][ $title_injection_mode ] ) ) { $cache[ $attribute_cache_key ][ $title_injection_mode ] = ai4seo_get_title_attribute_value( $this_post_id, $title_injection_mode, $cache ); } $this_db_title = $cache[ $attribute_cache_key ][ $title_injection_mode ]; if ( $this_db_title && $this_existing_title !== $this_db_title ) { $this_to_add['title'] = $this_db_title; } } if ( ! $this_to_add ) { return $this_full_tag; } // Remove only the attributes being replaced so unrelated image markup remains byte-for-byte intact. if ( isset( $this_to_add['alt'] ) ) { $this_attr_str = preg_replace( '/\s*alt\s*=\s*["\'][^"\']*["\']/', '', $this_attr_str ); } if ( isset( $this_to_add['title'] ) ) { $this_attr_str = preg_replace( '/\s*title\s*=\s*["\'][^"\']*["\']/', '', $this_attr_str ); } // Normalize the captured attribute suffix before rebuilding the original self-closing style below. $this_attr_str = preg_replace( '/\s*\/$/', '', rtrim( $this_attr_str ) ); // Rebuild the image once after both optional attributes have been collected. $this_self_closed = substr( rtrim( $this_full_tag ), -2 ) === '/>'; $this_tag_ending = $this_self_closed ? ' />' : '>'; $this_new_tag = ' $val ) { $this_new_tag .= ' ' . $name . '="' . esc_attr( $val ) . '"'; } $this_full_tag = $this_new_tag . $this_tag_ending; return $this_full_tag; }, $content ); } /** * Get the attachment title attribute value selected by the render-level setting. * * @param int $attachment_id Attachment ID. * @param string $setting_value Title injection setting value. * @param array &$cache Reference to the request-local cache array. * @return string|false Title attribute value, or false when none is available. */ function ai4seo_get_title_attribute_value( int $attachment_id, string $setting_value, array &$cache ) { $cache_key = ai4seo_get_site_options_request_cache_scope() . ':' . $attachment_id . ':' . $setting_value; if ( isset( $cache[ $attachment_id ][ $cache_key ] ) ) { return $cache[ $attachment_id ][ $cache_key ]; } $value = false; switch ( $setting_value ) { case 'inject_title': $value = get_the_title( $attachment_id ); break; case 'inject_alt_text': $value = ai4seo_read_frontend_attachment_alt( $attachment_id ); break; case 'inject_caption': case 'inject_description': $attachment = get_post( $attachment_id ); if ( $attachment ) { $value = 'inject_caption' === $setting_value ? $attachment->post_excerpt : $attachment->post_content; } break; } $value = is_string( $value ) ? $value : false; $cache[ $attachment_id ][ $cache_key ] = $value; return $value; } /** * Get an attachment ID from an image source URL. * * @param string $img_src Image source URL. * @return int|false Attachment ID, or false when no match is found. */ function ai4seo_get_attachment_id_from_src( string $img_src ) { global $wpdb; // Remove query parameters and fragments from URL. $img_src = strtok( $img_src, '?' ); $img_src = strtok( $img_src, '#' ); // First try WordPress built-in function. $attachment_id = attachment_url_to_postid( $img_src ); if ( $attachment_id ) { return $attachment_id; } // If that fails, try to match by filename in case of different sizes. $filename = basename( $img_src ); // Remove size suffixes like -150x150, -300x200, etc. $filename_without_size = preg_replace( '/-\d+x\d+(?=\.[^.]*$)/', '', $filename ); if ( $filename_without_size !== $filename ) { // Try to find by the original filename. $original_url = str_replace( $filename, $filename_without_size, $img_src ); $attachment_id = attachment_url_to_postid( $original_url ); if ( $attachment_id ) { return $attachment_id; } } // As last resort, search in postmeta for the URL. $cached_attachment_id = ai4seo_get_cached_attachment_id_from_filename( $filename ); if ( false !== $cached_attachment_id ) { return $cached_attachment_id ? $cached_attachment_id : false; } // The WordPress URL lookup cannot match every resized filename, so use the plugin cache before this final prepared query. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The plugin cache immediately above covers this filename lookup. $attachment_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1", esc_sql( $filename ) ) ); if ( $wpdb->last_error ) { ai4seo_debug_message( 984321670, 'Database error: ' . $wpdb->last_error, true ); return false; } $attachment_id = $attachment_id ? (int) $attachment_id : 0; ai4seo_set_cached_attachment_id_from_filename( $filename, $attachment_id ); return $attachment_id ? $attachment_id : false; } // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Preserve the WordPress image-filter contract. /** * Fill a missing alt attribute for WordPress-generated attachment image markup. * * @param array $attr Image attributes supplied by WordPress. * @param WP_Post $attachment Attachment post. * @param mixed $size Requested image size retained for the WordPress filter signature. * @return array Filtered image attributes. */ function ai4seo_filter_wp_image_attrs( $attr, $attachment, $size ) { // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed // Prevent duplicate processing when several image rendering paths invoke this callback. if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return $attr; } // Preserve author-supplied alt text and only fill an empty attribute from attachment postmeta. if ( empty( $attr['alt'] ) ) { $alt = ai4seo_read_frontend_attachment_alt( $attachment->ID ); // Sanitize the stored value before returning it to WordPress' image renderer. if ( $alt ) { $attr['alt'] = sanitize_text_field( $alt ); } } return $attr; } /** * Read an unambiguous native alt value without coercing damaged storage to text. * * @param int $attachment_id Attachment ID. * @return string|false Valid text or an unknown value. */ function ai4seo_read_frontend_attachment_alt( int $attachment_id ) { $succeeded = false; try { $rows = ai4seo_read_exact_third_party_postmeta_rows( $attachment_id, '_wp_attachment_image_alt', $succeeded ); } catch ( Throwable $throwable ) { return false; } if ( ! $succeeded || count( $rows ) > 1 ) { return false; } $value = $rows[0]['meta_value'] ?? ''; return is_serialized( $value ) ? false : $value; } /** * Adapt existing bounded readers to output without inventing values for unknown fields. * * Individually validated display values remain usable when another member is damaged; * malformed JSON, invalid row identity and conflicts never become missing-value fallbacks. * * @param int $post_id Entry ID. * @return array Read success, known values and unknown identifiers. */ function ai4seo_read_metadata_output_state( int $post_id ): array { $succeeded = false; $views = array(); $issues = array(); try { $rows = ai4seo_read_available_metadata_by_post_ids( array( $post_id ), false, $succeeded, $views, true, $issues ); } catch ( Throwable $throwable ) { return array( 'read_succeeded' => false, 'values' => array(), 'unknown' => AI4SEO_AVAILABLE_METADATA_IDENTIFIERS, ); } $view = $views[ $post_id ] ?? array(); $values = $rows[ $post_id ] ?? array(); $unknown = array(); if ( 'unreadable' === ( $view['classification'] ?? '' ) ) { $values = $view['display_metadata'] ?? array(); $unknown = array_values( array_diff( AI4SEO_AVAILABLE_METADATA_IDENTIFIERS, array_keys( $values ) ) ); } else { foreach ( AI4SEO_AVAILABLE_METADATA_IDENTIFIERS as $field ) { if ( isset( $view['conflicts'][ $field ] ) || ai4seo_is_posts_table_analysis_field_unknown( $issues, $post_id, $field ) ) { $unknown[] = $field; unset( $values[ $field ] ); } } } return array( 'read_succeeded' => $succeeded, 'values' => $values, 'unknown' => $unknown, ); }