File "image-seo.php"

Full Path: /home/fresvfqn/waterdamagerestorationandrepairsmithtown.com/wp-content/plugins/surerank/inc/frontend/image-seo.php
File size: 17.32 KB
MIME-type: text/x-php
Charset: utf-8

<?php
/**
 * Image SEO Enhancement Module
 *
 * Automatically enhances images with missing accessibility attributes.
 *
 * @package surerank
 * @since 1.4.0
 */

namespace SureRank\Inc\Frontend;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use SureRank\Inc\Traits\Get_Instance;

/**
 * Image SEO enhancement handler
 *
 * @since 1.4.0
 */
class Image_Seo {

	use Get_Instance;

	/**
	 * Initialize image enhancement
	 *
	 * @since 1.4.0
	 */
	public function __construct() {
		if ( $this->status() ) {
			$this->register_enhancement_hooks();
		}
	}

	/**
	 * Check if image enhancement is active
	 *
	 * @return bool
	 * @since 1.4.0
	 */
	public function status(): bool {
		return apply_filters( 'surerank_auto_set_image_title_and_alt', true );
	}

	/**
	 * Enhance content with missing image attributes
	 *
	 * @param string   $content Content to enhance.
	 * @param int|null $post_id Post ID context.
	 * @return string Enhanced content
	 * @since 1.4.0
	 */
	public function enhance_content( $content, $post_id = null ) {
		if ( empty( $content ) || strpos( $content, '<img' ) === false ) {
			return $content;
		}

		return $this->process_content_images( $content, $post_id );
	}

	/**
	 * Register WordPress content enhancement hooks
	 *
	 * @since 1.4.0
	 * @return void
	 */
	private function register_enhancement_hooks(): void {
		$filters = [
			'the_content'         => 11,
			'post_thumbnail_html' => 11,
			'woocommerce_single_product_image_thumbnail_html' => 11,
		];

		foreach ( $filters as $hook => $priority ) {
			add_filter( $hook, [ $this, 'enhance_content' ], $priority, 2 );
		}
	}

	/**
	 * Process all images in content
	 *
	 * @param string   $content Content with images.
	 * @param int|null $post_id Post context.
	 * @return string Processed content
	 * @since 1.4.0
	 */
	private function process_content_images( $content, $post_id ): string {
		$clean_content = $this->remove_script_style_tags( $content );
		$image_tags    = $this->extract_image_tags( $clean_content );

		if ( empty( $image_tags ) ) {
			return $content;
		}

		$context = $this->build_processing_context( $post_id );

		return $this->enhance_image_tags( $content, $image_tags, $context );
	}

	/**
	 * Remove script and style tags from content
	 *
	 * @param string $content Raw content.
	 * @return string Cleaned content
	 * @since 1.4.0
	 */
	private function remove_script_style_tags( $content ): string {
		/**
		 * Using regex to remove script and style tags
		 * 
		 * Regex pattern breakdown:
		 * 
		 * < matches the start of a tag
		 * (script|style) matches either script or style
		 * [^>]*? matches any character except > (using lazy quantifier)
		 * .*? matches any character any number of times (using lazy quantifier)
		 * <\/\1> matches the closing tag of the same type as the opening tag
		 * s single line mode
		 * i matches case insensitive
		 */
		$result = preg_replace( '/<(script|style)[^>]*?>.*?<\/\1>/si', '', $content );
		return $result !== null ? $result : $content;
	}

	/**
	 * Extract image tags from content that need enhancement
	 *
	 * @param string $content Clean content.
	 * @return array<string> Image tag matches that need enhancement
	 * @since 1.4.0
	 */
	private function extract_image_tags( $content ): array {
		$missing_alt_images   = $this->extract_images_missing_alt( $content );
		$missing_title_images = $this->extract_images_missing_title( $content );
		if ( empty( $missing_alt_images ) && empty( $missing_title_images ) ) {
			return [];
		}
		return array_unique( array_merge( $missing_alt_images, $missing_title_images ) );
	}

	/**
	 * Extract images missing alt attributes
	 *
	 * @param string $content Content to search.
	 * @return array<string> Image tags missing alt
	 * @since 1.4.0
	 */
	private function extract_images_missing_alt( $content ): array {
		/**
		 * Finds all <img> tags that are missing proper alt attributes for accessibility compliance.
		 * 
		 * Regex breakdown:
		 * <img                                    : Matches literal "<img"
		 * (?!                                     : Start negative lookahead (ensure pattern does NOT exist)
		 *   [^>]*                                 : Match any chars except ">" (stay within tag)
		 *   alt\s*=\s*                            : Match "alt" + optional whitespace + "=" + optional whitespace
		 *   ["\']                                 : Match opening quote (single or double)
		 *   [^"\'\s]                              : Match at least one non-quote, non-whitespace character
		 *   [^"\']*                               : Match remaining non-quote characters
		 *   ["\']                                 : Match closing quote
		 * )                                       : End negative lookahead
		 * [^>]*>                                  : Match remaining tag content until closing ">"
		 * i                                       : Case-insensitive flag
		 * 
		 * Examples of what this WILL match (accessibility violations):
		 * - <img src="photo.jpg">                 (no alt attribute)
		 * - <img src="photo.jpg" alt="">          (empty alt)
		 * - <img src="photo.jpg" alt=" ">         (whitespace-only alt)
		 * - <IMG SRC="photo.jpg" ALT="">          (case variations)
		 * 
		 * Examples of what this will NOT match (valid alt attributes):
		 * - <img src="photo.jpg" alt="A photo">   (valid alt text)
		 * - <img src="photo.jpg" alt="User avatar"> (descriptive alt text)
		 * 
		 * @param string $content The HTML content to search for non-compliant img tags
		 * @param array $matches Output array that will contain all matched img tags
		 * @return int Number of matches found
		 * 
		 * @see https://www.php.net/manual/en/reference.pcre.pattern.syntax.php
		 * @since 1.0.0
		 */
		preg_match_all( '/<img(?![^>]*alt\s*=\s*["\'][^"\'\s][^"\']*["\'])[^>]*>/i', $content, $matches );
		return $matches[0];
	}

	/**
	 * Extract images missing title attributes
	 *
	 * @param string $content Content to search.
	 * @return array<string> Image tags missing title
	 * @since 1.4.0
	 */
	private function extract_images_missing_title( $content ): array {
		/**
		 * Finds all <img> tags that are missing proper title attributes for accessibility compliance.
		 * 
		 * Regex breakdown:
		 * <img                                    : Matches literal "<img"
		 * (?!                                     : Start negative lookahead (ensure pattern does NOT exist)
		 *   [^>]*                                 : Match any chars except ">" (stay within tag)
		 *   title\s*=\s*                            : Match "title" + optional whitespace + "=" + optional whitespace
		 *   ["\']                                 : Match opening quote (single or double)
		 *   [^"\'\s]                              : Match at least one non-quote, non-whitespace character
		 *   [^"\']*                               : Match remaining non-quote characters
		 *   ["\']                                 : Match closing quote
		 * )                                       : End negative lookahead
		 * [^>]*>                                  : Match remaining tag content until closing ">"
		 * i                                       : Case-insensitive flag
		 * 
		 * Examples of what this WILL match (accessibility violations):
		 * - <img src="photo.jpg">                 (no title attribute)
		 * - <img src="photo.jpg" title="">          (empty title)
		 * - <img src="photo.jpg" title=" ">         (whitespace-only title)
		 * - <IMG SRC="photo.jpg" TITLE="">          (case variations)
		 * 
		 * Examples of what this will NOT match (valid title attributes):
		 * - <img src="photo.jpg" title="A photo">   (valid title text)
		 * - <img src="photo.jpg" title="User avatar"> (descriptive title text)
		 * 
		 * @param string $content The HTML content to search for non-compliant img tags
		 * @param array $matches Output array that will contain all matched img tags
		 * @return int Number of matches found
		 * 
		 * @see https://www.php.net/manual/en/reference.pcre.pattern.syntax.php
		 * @since 1.0.0
		 */
		preg_match_all( '/<img(?![^>]*title\s*=\s*["\'][^"\'\s][^"\']*["\'])[^>]*>/i', $content, $matches );
		return $matches[0];
	}

	/**
	 * Build processing context object
	 *
	 * @param int|null $post_id Post ID.
	 * @return object{title: string, slug: string, site_name: string} Context data
	 * @since 1.4.0
	 */
	private function build_processing_context( $post_id ): object {
		$post = get_post( $post_id );

		return (object) [
			'title'     => $post->post_title ?? '',
			'slug'      => $post->post_name ?? '',
			'site_name' => get_bloginfo( 'name' ),
		];
	}

	/**
	 * Enhance individual image tags
	 *
	 * @param string                                                 $content Original content.
	 * @param array<string>                                          $images Image tag array.
	 * @param object{title: string, slug: string, site_name: string} $context Processing context.
	 * @return string Enhanced content
	 * @since 1.4.0
	 */
	private function enhance_image_tags( $content, $images, $context ): string {
		foreach ( $images as $original_tag ) {
			$enhanced_tag = $this->enhance_single_image( $original_tag, $context );

			if ( $enhanced_tag !== $original_tag ) {
				$content = str_replace( $original_tag, $enhanced_tag, $content );
			}
		}

		return $content;
	}

	/**
	 * Enhance single image tag
	 *
	 * @param string                                                 $tag Original image tag.
	 * @param object{title: string, slug: string, site_name: string} $context Processing context.
	 * @return string Enhanced tag
	 * @since 1.4.0
	 */
	private function enhance_single_image( $tag, $context ): string {
		$attributes = $this->parse_image_attributes( $tag );

		if ( empty( $attributes ) ) {
			return $tag;
		}

		$image_src = $this->resolve_image_source( $attributes );

		if ( empty( $image_src ) ) {
			return $tag;
		}

		$enhancements = $this->calculate_needed_enhancements( $attributes );
		$enhancements = apply_filters( 'surerank_image_seo_enhancements', $enhancements, $attributes, $image_src, $context );

		if ( empty( $enhancements ) ) {
			return $tag;
		}

		return $this->apply_enhancements( $attributes, $enhancements, $image_src, $context );
	}

	/**
	 * Parse attributes from image tag
	 *
	 * @param string $tag Image tag.
	 * @return array<string, string> Parsed attributes
	 * @since 1.4.0
	 */
	private function parse_image_attributes( $tag ): array {
		$attributes = [];

		/**
		 * Using regex to parse image attributes
		 * 
		 * Regex pattern breakdown:
		 *  ([a-zA-Z_:][a-zA-Z0-9\-_.:]*)        : Check for the attribute name.
		 *   [a-zA-Z_:]                         : First char: letter, underscore, or colon
		 *   [a-zA-Z0-9\-_.]*                   : Remaining chars: alphanumeric, hyphen, dot, underscore, colon
		 * =                                    : Literal equals sign
		 * ["\']                                : Opening quote (single or double)
		 * ([^"\']*)                            : Capture group 2 - Attribute value (any chars except quotes)
		 * ["\']                                : Closing quote (single or double)
		 * i                                    : Case-insensitive flag
		 * 
		 * Examples of what this WILL match (accessibility violations):
		 * - <img src="photo.jpg">                 (no alt attribute)
		 * - <img src="photo.jpg" alt="">          (empty alt)
		 * - <img src="photo.jpg" alt=" ">         (whitespace-only alt)
		 * - <IMG SRC="photo.jpg" ALT="">          (case variations)
		 * 
		 * Examples of what this will NOT match (valid alt attributes):
		 * - <img src="photo.jpg" alt="A photo">   (valid alt text)
		 * - <img src="photo.jpg" alt="User avatar"> (descriptive alt text)
		 */
		if ( preg_match_all( '/([a-zA-Z_:][a-zA-Z0-9\-_.:]*)=["\']([^"\']*)["\']/', $tag, $matches, PREG_SET_ORDER ) ) {
			/**
			 * [0] => src="photo.jpg"      // Full match
			 * [1] => src                  // Attribute name
			 * [2] => photo.jpg            // Attribute value
			 */
			foreach ( $matches as $match ) {
				$attributes[ $match[1] ] = $match[2];
			}
		}

		return $attributes;
	}

	/**
	 * Resolve image source URL (supports lazy loading)
	 *
	 * @param array<string, string> $attributes Image attributes.
	 * @return string Image source
	 * @since 1.4.0
	 */
	private function resolve_image_source( $attributes ): string {
		$lazy_attrs = [ 'data-src', 'data-lazy-src', 'data-layzr' ];

		foreach ( $lazy_attrs as $attr ) {
			if ( ! empty( $attributes[ $attr ] ) ) {
				return $attributes[ $attr ];
			}
		}

		return $attributes['src'] ?? '';
	}

	/**
	 * Calculate which enhancements are needed
	 *
	 * @param array<string, string> $attributes Current attributes.
	 * @return array<string, string> Needed enhancements
	 * @since 1.4.0
	 */
	private function calculate_needed_enhancements( $attributes ): array {
		$needed = [];

		if ( apply_filters( 'surerank_image_seo_enable_alt', true ) && empty( $attributes['alt'] ) ) {
			$needed['alt'] = apply_filters( 'surerank_image_seo_alt_template', '%filename%' );
		}

		if ( apply_filters( 'surerank_image_seo_enable_title', true ) && empty( $attributes['title'] ) ) {
			$needed['title'] = apply_filters( 'surerank_image_seo_title_template', '%title%' );
		}

		return $needed;
	}

	/**
	 * Apply enhancements to image attributes
	 *
	 * @param array<string, string>                                  $attributes Original attributes.
	 * @param array<string, string>                                  $enhancements Needed enhancements.
	 * @param string                                                 $src Image source.
	 * @param object{title: string, slug: string, site_name: string} $context Processing context.
	 * @return string Enhanced image tag
	 * @since 1.4.0
	 */
	private function apply_enhancements( $attributes, $enhancements, $src, $context ): string {
		$filename = $this->extract_clean_filename( $src );

		foreach ( $enhancements as $attr => $template ) {
			$attributes[ $attr ] = $this->resolve_template( $template, $context, $filename );
		}

		return $this->build_image_tag( $attributes );
	}

	/**
	 * Extract and clean filename from URL
	 *
	 * @param string $url Image URL.
	 * @return string Clean filename
	 * @since 1.4.0
	 */
	private function extract_clean_filename( $url ): string {
		if ( empty( $url ) ) {
			return '';
		}

		return $this->sanitize_filename( $this->get_basename_without_extension( $url ) );
	}

	/**
	 * Get filename without extension
	 *
	 * @param string $url URL.
	 * @return string Basename
	 * @since 1.4.0
	 */
	private function get_basename_without_extension( $url ): string {
		$filename = basename( $url );
		/**
		 * Using regex to get the basename without the extension
		 * Regex pattern breakdown:
		 * 
		 * \. matches a literal dot
		 * [^.]+ matches one or more characters that are not a dot
		 * $ matches the end of the string
		 */
		$result = preg_replace( '/\.[^.]+$/', '', $filename );
		return $result !== null ? $result : $filename;
	}

	/**
	 * Sanitize filename for readability
	 *
	 * @param string $filename Raw filename.
	 * @return string Sanitized filename
	 * @since 1.4.0
	 */
	private function sanitize_filename( $filename ): string {
		/**
		 * Using regex to sanitize the filename
		 * Regex pattern breakdown:
		 * 
		 * [-_] matches a hyphen or underscore
		 * + matches one or more of the preceding element
		 * $ matches the end of the string
		 */
		$cleaned      = preg_replace( '/[-_]+/', ' ', $filename );
		$safe_cleaned = $cleaned !== null ? $cleaned : $filename;
		return ucwords( trim( $safe_cleaned ) );
	}

	/**
	 * Resolve template variables
	 *
	 * @param string                                                 $template Template string.
	 * @param object{title: string, slug: string, site_name: string} $context Context data.
	 * @param string                                                 $filename Clean filename.
	 * @return string Resolved string
	 * @since 1.4.0
	 */
	private function resolve_template( $template, $context, $filename ): string {
		if ( empty( $template ) ) {
			return '';
		}

		$variables = $this->build_variable_map( $context, $filename );

		$resolved = trim( strtr( $template, $variables ) );

		return apply_filters( 'surerank_image_seo_resolved_text', $resolved, $template, $context, $filename );
	}

	/**
	 * Build variable replacement map
	 *
	 * @param object{title: string, slug: string, site_name: string} $context Context data.
	 * @param string                                                 $filename Filename.
	 * @return array<string, string> Variable mappings
	 * @since 1.4.0
	 */
	private function build_variable_map( $context, $filename ): array {
		$default_vars = [
			'%title%'     => $context->title,
			'%filename%'  => $filename,
			'%site_name%' => $context->site_name,
			'%slug%'      => $context->slug,
		];

		return apply_filters( 'surerank_image_seo_variable_map', $default_vars, $context, $filename );
	}

	/**
	 * Build complete image tag from attributes
	 *
	 * @param array<string, string> $attributes Attribute pairs.
	 * @return string Complete image tag
	 * @since 1.4.0
	 */
	private function build_image_tag( $attributes ): string {
		$attr_pairs = [];

		foreach ( $attributes as $name => $value ) {
			$attr_pairs[] = $this->format_attribute_pair( $name, $value );
		}

		return sprintf( '<img %s>', implode( ' ', $attr_pairs ) );
	}

	/**
	 * Format single attribute pair
	 *
	 * @param string $name Attribute name.
	 * @param string $value Attribute value.
	 * @return string Formatted pair
	 * @since 1.4.0
	 */
	private function format_attribute_pair( $name, $value ): string {
		return sprintf( '%s="%s"', esc_attr( $name ), esc_attr( $value ) );
	}
}