includes/Images/class-image-optimisation.php
Image Optimisation class for handling image conversion, preloading, and serving optimized images.
Class Image_Optimisation
Image Optimisation class.
class Image_OptimisationConstants
| Constant | Visibility | Value | Line |
|---|---|---|---|
SVG_PLACEHOLDER_MAX_DIMENSION | private | 4096 | 38 |
IMG_SIZE_CACHE_LIMIT | private | 100 | 45 |
HARDENED_TAGS | private | array( \'img\', \'image\', \'source\', \'video\', \'iframe\', \'audio\', \'embed\', \'object\', \'svg\', \'math\', \'use\', \'a\', \'table\', \'body\', \'td\', \'th\' ) | 60 |
HARDENED_URL_ATTRS | private | array( \'src\', \'data-src\', \'data\', \'codebase\', \'usemap\', \'poster\', \'srcdoc\', \'background\', \'lowsrc\', \'href\', \'xlink:href\', \'action\', \'formaction\', \'cite\', \'longdesc\' ) | 72 |
HARDENED_SRCSET_ATTRS | private | array( \'srcset\', \'data-srcset\' ) | 80 |
BENIGN_ON_PREFIX_ATTRS | private | array( \'only\', \'one\', \'online\', \'once\', \'onto\', \'onion\' ) | 91 |
FILE_EXISTS_CACHE_LIMIT | private | 500 | 182 |
Properties
| Property | Visibility | Type | Default | Line |
|---|---|---|---|---|
$options | private | array | — | 99 |
$picture_counter | private | int | 0 | 107 |
$exclude_convert_imgs | private | array | array() | 115 |
$preload_front_page_urls | private | array | array() | 123 |
$exclude_post_type_imgs | private | array | array() | 131 |
$exclude_sizes | private | array | array() | 139 |
$exclude_lazy_imgs | private | array | array() | 147 |
$exclude_lazy_videos | private | array | array() | 155 |
$img_converter | private | ?Img_Converter | null | 163 |
$file_exists_cache | private static | array | array() | 174 |
$img_size_cache | private static | array | array() | 197 |
$noscript_namespace | private | string | \'\' | 209 |
$lazy_lcp_exclusion_url | private | ?string | null | 229 |
$current_lcp_url | private | ?string | null | 250 |
$current_lcp_url_key | private | ?string | null | 261 |
$lazy_lcp_exclusion_url_key | private | ?string | null | 273 |
$fetchpriority_lcp_url | private | ?string | null | 290 |
$fetchpriority_lcp_key | private | ?string | null | 298 |
$manual_lcp_url | private | ?string | null | 313 |
$manual_lcp_url_key | private | ?int | null | 321 |
$auto_lcp_disabled | private | ?bool | null | 336 |
$auto_lcp_disabled_key | private | ?int | null | 344 |
$stable_signal_lcp_url | private | ?string | null | 359 |
$stable_signal_lcp_url_key | private | ?string | null | 367 |
$deferred_alt_entries | private static | array<int, | array() | 380 |
$derived_alt_memo | private static | array<int, | array() | 389 |
$alt_commit_registered | private static | bool | false | 399 |
$placeholder_info_cache | private static | array|null | null | 411 |
$placeholder_path_cache | private static | array<string, | array() | 419 |
$lcp_preload | private | ?Lcp_Preload | null | 851 |
publicstatic clear_runtime_caches()
public static function clear_runtime_caches(): voidClear the per-request runtime caches (file_exists + image sizes).
Return: void.
publicstatic has_emitted_preload()
public static function has_emitted_preload(string $url, string=\'\' $media): boolWhether a preload hint was already emitted for a URL this request. Parameter Type Default Description $urlstring— The raw preload URL. $mediastring=\'\'— The preload media attribute.
Return: bool — True when the URL + media pair already emitted. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::has_emitted_preload}.
publicstatic mark_preload_emitted()
public static function mark_preload_emitted(string $url, string=\'\' $media): voidRecord a preload hint as emitted for this request. Parameter Type Default Description $urlstring— The raw preload URL. $mediastring=\'\'— The preload media attribute.
Return: void — Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::mark_preload_emitted}.
privatestatic record_direct_preload_url()
private static function record_direct_preload_url(string $url): voidRecord a directly-emitted hero URL for same-response lazy exclusion. Parameter Type Default Description $urlstring— The raw preload URL.
Return: void — Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::record_direct_preload_url}.
privatestatic get_direct_preload_normalized_urls()
private static function get_direct_preload_normalized_urls(): arrayNormalized forms of the directly-emitted preload URLs.
Return: string[] — Normalized direct-preload URLs. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_direct_preload_normalized_urls}.
privatestatic build_preload_dedup_key()
private static function build_preload_dedup_key(string $url, string $media): stringBuild the dedup key for a preload item (normalized URL + query + media). Parameter Type Default Description $urlstring— The raw preload URL. $mediastring— The preload media attribute.
Return: string — The dedup key. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::build_preload_dedup_key}.
privatestatic is_hero_preload_claimed()
private static function is_hero_preload_claimed(string $url): boolWhether any preload was already claimed for a hero URL, any media. Parameter Type Default Description $urlstring— The raw hero URL.
Return: bool — True when the URL already emitted with any media. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_hero_preload_claimed}.
private claim_hero_preload_slot()
private function claim_hero_preload_slot(string $url, string=\'\' $media, ?string=null $buffer): boolClaim the single hero preload slot for a URL. Parameter Type Default Description $urlstring— The raw hero URL. $mediastring=\'\'— The preload media attribute (\’\’ for buffer companions). $buffer?string=null— Optional HTML buffer to scan for an existing hint.
Return: bool — True when the caller may emit (slot claimed), false to skip. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::claim_hero_preload_slot}.
privatestatic release_hero_preload_slot()
private static function release_hero_preload_slot(string $url, string=\'\' $media): voidRelease a previously claimed hero preload slot. Parameter Type Default Description $urlstring— The raw hero URL. $mediastring=\'\'— The preload media attribute (\’\’ for buffer companions).
Return: void — Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::release_hero_preload_slot}.
private is_cdn_preload_url()
private function is_cdn_preload_url(string $url): boolWhether a preload candidate lives on the configured CDN. Parameter Type Default Description $urlstring— The candidate URL.
Return: bool — True when the URL host matches a configured CDN host. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_cdn_preload_url}.
private is_allowed_hero_preload_url()
private function is_allowed_hero_preload_url(string $url): boolWhether a hero URL may be preloaded (same-origin or configured CDN). Parameter Type Default Description $urlstring— The candidate URL.
Return: bool — True when the URL may be preloaded. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_allowed_hero_preload_url}.
private is_html_api_available()
private function is_html_api_available(): boolWhether the WP HTML API may be used for hero scanning.
Return: bool — True when the HTML API may be used. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_html_api_available}.
private get_computed_css_hero_url()
private function get_computed_css_hero_url(?string=null $buffer): stringServer-side computed CSS-hero URL passed via filter. Parameter Type Default Description $buffer?string=null— Optional HTML buffer passed to the filter for context.
Return: string — The computed hero URL, or empty string. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_computed_css_hero_url}.
private sweep_lazy_high_conflicts()
private function sweep_lazy_high_conflicts(string $buffer): stringForce eager on any element already marked fetchpriority high. Parameter Type Default Description $bufferstring— The HTML buffer.
Return: string — The buffer with high-priority nodes forced eager. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::sweep_lazy_high_conflicts}.
private is_occlusion_fetchpriority_low_enabled()
private function is_occlusion_fetchpriority_low_enabled(): boolWhether occlusion-aware fetchpriority=low demotion is enabled.
Return: bool — True when occluded nodes should be demoted to low. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_occlusion_fetchpriority_low_enabled}.
private get_occluded_image_urls_for_request()
private function get_occluded_image_urls_for_request(): arrayResolve OD-occluded image URLs for the current request.
Return: string[] — Occluded image URLs (may be empty). Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_occluded_image_urls_for_request}.
private apply_occlusion_fetchpriority_low()
private function apply_occlusion_fetchpriority_low(string $buffer, array $occluded_urls, ?string=null $lcp_url): stringDemote OD-occluded in-viewport images to fetchpriority=low. Parameter Type Default Description $bufferstring— The HTML buffer. $occluded_urlsarray— Raw occluded image URLs. $lcp_url?string=null— Optional true-LCP URL to protect.
Return: string — The buffer with occluded nodes demoted to low. Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::apply_occlusion_fetchpriority_low}.
public clear_instance_lcp_memo()
public function clear_instance_lcp_memo(): voidReset the per-instance LCP memos ($current_lcp_url, $lazy_lcp_exclusion_url).
Return: void.
private lcp_preload()
private function lcp_preload(): Lcp_PreloadGet (and lazily create) the LCP/preload service bound to this instance.
Return: Lcp_Preload — Service bound to this instance.
public lcp_get_options()
public function lcp_get_options(): arrayConfiguration options snapshot (read-only; written only by the constructor). (ARCH-008 internal bridge).
Return: array.
public lcp_get_preload_front_page_urls()
public function lcp_get_preload_front_page_urls(): arrayFront-page preload URL list derived at construction. (ARCH-008 internal bridge).
Return: array.
public lcp_get_exclude_post_type_imgs()
public function lcp_get_exclude_post_type_imgs(): arrayPost-type preload exclusion list derived at construction. (ARCH-008 internal bridge).
Return: array.
public lcp_get_exclude_sizes()
public function lcp_get_exclude_sizes(): arrayExcluded image widths derived at construction. (ARCH-008 internal bridge).
Return: array.
publicabstract ()
public abstract function (private lcp_state_current_lcp_url():?string{return->current_lcp_url;}/** * Direct reference to LCP memo `$current_lcp_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->current_lcp_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_current_lcp_url_key():?string{return->current_lcp_url_key;}/** * Direct reference to LCP memo `$lazy_lcp_exclusion_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->lazy_lcp_exclusion_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_lazy_lcp_exclusion_url():?string{return->lazy_lcp_exclusion_url;}/** * Direct reference to LCP memo `$lazy_lcp_exclusion_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->lazy_lcp_exclusion_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_lazy_lcp_exclusion_url_key():?string{return->lazy_lcp_exclusion_url_key;}/** * Direct reference to LCP memo `$fetchpriority_lcp_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->fetchpriority_lcp_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_fetchpriority_lcp_url():?string{return->fetchpriority_lcp_url;}/** * Direct reference to LCP memo `$fetchpriority_lcp_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->fetchpriority_lcp_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_fetchpriority_lcp_key():?string{return->fetchpriority_lcp_key;}/** * Direct reference to LCP memo `$manual_lcp_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->manual_lcp_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_manual_lcp_url():?string{return->manual_lcp_url;}/** * Direct reference to LCP memo `$manual_lcp_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->manual_lcp_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?int Reference to the live memo. */function&lcp_state_manual_lcp_url_key():?int{return->manual_lcp_url_key;}/** * Direct reference to LCP memo `$auto_lcp_disabled` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->auto_lcp_disabled`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?bool Reference to the live memo. */function&lcp_state_auto_lcp_disabled():?bool{return->auto_lcp_disabled;}/** * Direct reference to LCP memo `$auto_lcp_disabled_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->auto_lcp_disabled_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?int Reference to the live memo. */function&lcp_state_auto_lcp_disabled_key():?int{return->auto_lcp_disabled_key;}/** * Direct reference to LCP memo `$stable_signal_lcp_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->stable_signal_lcp_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_stable_signal_lcp_url():?string{return->stable_signal_lcp_url;}/** * Direct reference to LCP memo `$stable_signal_lcp_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->stable_signal_lcp_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_stable_signal_lcp_url_key():?string{return->stable_signal_lcp_url_key;}/** * Lazy/media collaborator `normalize_url()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $url The URL to normalize. * @return string Normalized URL. */functionlcp_normalize_url(string):string{return->normalize_url();}/** * Lazy/media collaborator `unlazyload_first_images()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $buffer HTML buffer. * @param array $image_optimisation Image settings. * @return string Buffer with first images un-lazy-loaded. */functionlcp_unlazyload_first_images(string,array):string{return->unlazyload_first_images(,);}/** * Lazy/media collaborator `sanitize_loading_triple()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param array $attrs Loading attributes. * @return array Sanitized attributes. */functionlcp_sanitize_loading_triple(array):array{return->sanitize_loading_triple();}/** * Lazy/media collaborator `promote_eager_picture_sources()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $buffer HTML buffer. * @return string Buffer with eager picture sources promoted. */functionlcp_promote_eager_picture_sources(string):string{return->promote_eager_picture_sources();}/** * Lazy/media collaborator `remove_lazy_classes()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param mixed $tags Tag processor. * @return bool Whether any class was removed. */functionlcp_remove_lazy_classes():bool{return->remove_lazy_classes();}/** * Lazy/media collaborator `restore_js_lazy_placeholders()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param mixed $tags Tag processor. * @return bool Whether any placeholder was restored. */functionlcp_restore_js_lazy_placeholders():bool{return->restore_js_lazy_placeholders();}/** * Lazy/media collaborator `should_use_html_processor()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @return bool True when the HTML processor path may be used. */functionlcp_should_use_html_processor():bool{return->should_use_html_processor();}/** * Lazy/media collaborator `cached_file_exists()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $path Absolute path. * @return bool Whether the file exists. */functionlcp_cached_file_exists(string):bool{return->cached_file_exists();}/** * Lazy/media collaborator `get_cached_image_size()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $local_path Absolute path. * @return array|false Image size or false. */functionlcp_get_cached_image_size(string):array|false{return->get_cached_image_size();}/** * Lazy/media collaborator `is_dimension_lookup_allowed()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $url Image URL. * @return bool Whether dimension lookup is allowed. */functionlcp_is_dimension_lookup_allowed(string):bool{return->is_dimension_lookup_allowed();}/** * Lazy/media collaborator `get_css_hero_url_from_buffer()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $buffer HTML buffer. * @return string CSS hero URL or empty string. */functionlcp_get_css_hero_url_from_buffer(string):string{return->get_css_hero_url_from_buffer();}/** * Lazy/media collaborator `split_srcset_candidates()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $raw Raw srcset attribute. * @return array Candidate strings. */functionlcp_split_srcset_candidates(string):array{return->split_srcset_candidates();}/** * Lazy/media collaborator `split_srcset_item()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $item Single srcset candidate. * @return array URL plus descriptor parts. */functionlcp_split_srcset_item(string):array{return->split_srcset_item();}/** * Constructor. * * @since 1.0.0 * * @param array $options Configuration options for image optimization. */function__construct(){->options=;if(!isset(->options[\'image_optimisation\'][\'placeholderType\'])){if(isset(->options[\'image_optimisation\'][\'replacePlaceholderWithSVG\'])){->options[\'image_optimisation\'][\'placeholderType\']=(bool)->options[\'image_optimisation\'][\'replacePlaceholderWithSVG\']?\'svg\':\'none\';}else{->options[\'image_optimisation\'][\'placeholderType\']=\'none\';}}->exclude_convert_imgs=Util::process_urls(->options[\'image_optimisation\'][\'excludeConvertImages\']??array());->preload_front_page_urls=Util::process_urls(->options[\'image_optimisation\'][\'preloadFrontPageImagesUrls\']??array());->exclude_post_type_imgs=Util::process_urls(->options[\'image_optimisation\'][\'excludePostTypeImgUrl\']??array());->exclude_sizes=array_map(\'absint\',array_map(\'trim\',explode(\',\',(->options[\'image_optimisation\'][\'excludeSize\']??\'\'))));->exclude_lazy_imgs=Util::process_urls(->options[\'image_optimisation\'][\'excludeImages\']??array());->exclude_lazy_videos=Util::process_urls(->options[\'image_optimisation\'][\'excludeVideos\']??array());->setup_hooks();}/** * Sets up hooks for image optimization features. * * @since 1.0.0 */functionsetup_hooks(){if(!empty(->options[\'image_optimisation\'][\'convertImg\'])){=->get_img_converter();=\'none\'!==->get_format();=::core_handles_next_gen();if(||){add_filter(\'wp_generate_attachment_metadata\',array(,\'convert_image_to_next_gen_format\'),10,2);}add_filter(\'wp_get_attachment_image_src\',array(,\'maybe_serve_next_gen_image\'));}add_action(\'delete_attachment\',array(\'PerformanceOptimise\\Inc\\Img_Converter\',\'clean_placeholder_on_delete\'));add_filter(\'wp_get_attachment_image_attributes\',array(,\'wppo_add_fetchpriority\'),10,3);if(function_exists(\'wp_is_client_side_media_processing_enabled\')&&!empty(->options[\'image_optimisation\'][\'clientSideMimeTypeOverride\'])){add_filter(\'client_side_supported_mime_types\',array(,\'filter_client_side_supported_mime_types\'));}if(function_exists(\'wp_is_client_side_media_processing_enabled\')&&!empty(->options[\'image_optimisation\'][\'forceServerSideConversion\'])){add_filter(\'wp_client_side_media_processing_enabled\',\'__return_false\');}}/** * Replace the MIME types handled by WP 7.1+ client-side media processing. * * This filter is only registered when the override toggle is enabled. * The stored selection becomes the set of formats the in-browser Web * Worker should process, intersected with the formats core reports it * can support so an unsupported selection (e.g. HEIC/JXL on a build * without a wasm-vips decoder) can never shadow core\'s authoritative * list. A non-array stored value leaves core\'s default list untouched * (graceful degradation); an enabled override with an empty selection * returns an empty list, which disables browser-side processing * entirely (core supports empty list). Future decoders (HEIC Sequence, * JPEG XL) are additive via the same intersection — the UI surfaces * them but core\'s list gates availability. * * Trac #64876 proposes a public `client_side_supported_mime_types` * filter; until it lands the plugin keeps the intersection guard so an * unavailable decoder (e.g. HEIC/JXL without wasm-vips) cannot be added * additively. When the public filter lands, widen to additive * HEIC/JPEG-XL pass-through with documented HEIC/JPEG-XL path. * * Guarded by `function_exists(\'wp_is_client_side_media_processing_enabled\')` * for <7.1 (filter not registered there). Wasm gating: ~13 MB lazy-loaded * wasm-vips gated by Document-Isolation-Policy / SharedArrayBuffer. * * @since 2.0.0 * * @param string[] $supported_mime_types The MIME types core supports client-side. * @return string[] The filtered MIME types. */functionfilter_client_side_supported_mime_types(){=->options[\'image_optimisation\'][\'clientSideMimeTypes\']??array();if(!is_array()){return;}=array_map(\'sanitize_text_field\',);=array_filter();=array_values(array_unique());=array_intersect(,array_map(\'sanitize_text_field\',(array)));returnarray_values();}/** * Preloads images for optimization. * * Emits exactly one `<link rel=\"preload\" as=\"image\" * fetchpriority=\"high\">` per URL: `get_all_preload_data()` dedups by * normalized URL + query + media within one call (so the single * RUM-field → PageSpeed LCP candidate, manual meta, front-page and * post-type items collapse to one tag, while `?v=` variants stay * distinct), and the per-request `$preload_emitted` guard below skips * repeats across repeated `wp_head` invocations. * * No-duplicate note (issue #991): core 6.9 stamps `fetchpriority` on * the `<img>` node itself via `wp_get_loading_optimization_attributes()` * — a separate concern from this early `<link>` hint. The stamp is * never double-applied (see `prioritize_lcp_image()` and * `set_loading_optimization_attributes()`, which only fill gaps via * `function_exists()`-guarded core calls), so this hint and core\'s * node stamp coexist without fighting. * * @since 1.0.0 * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::preload_images}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionpreload_images(){return->lcp_preload()->preload_images();}/** * Get (and lazily mint) the per-request noscript token namespace. * * Uses cryptographically random hex via `random_bytes()` when available, * falling back to `wp_generate_password()` (sanitized to alphanumerics) * and finally to a `uniqid()`/`wp_rand()` token. Never fatals: any * failure degrades to a static fallback namespace (fail-open). * * @since 2.0.0 * @return string Non-empty namespace string. */functionget_noscript_namespace():string{if(\'\'!==->noscript_namespace){return->noscript_namespace;}->noscript_namespace=Util::mint_placeholder_namespace();return->noscript_namespace;}/** * Resolve a single noscript placeholder token against the allowlist. * * Strict restore discipline (CVE-2026-3220 shape): the token must be an * exact key of `$noscript_tokens`, carry this request\'s namespace * (constant-time comparison), and reference a bounds-checked index. Any * anomaly returns null so the caller emits the node unmodified * (fail-open). * * @since 2.0.0 * @param string $token The matched placeholder comment. * @param array $noscript_tokens The exact-token allowlist (token => HTML). * @return string|null Restored HTML, or null on anomaly. */functionresolve_noscript_token(string,array):?string{if(!isset([])||!is_string([])){returnnull;}if(1!==preg_match(\'/^<!--WPPO_NOSCRIPT_([A-Za-z0-9]+)_(\\d+)-->$/\',,)){returnnull;}=[1];=[2];=->noscript_namespace;if(\'\'===||\'\'===){returnnull;}if(strlen()!==strlen()){returnnull;}if(function_exists(\'hash_equals\')){if(!hash_equals(,)){returnnull;}}elseif(!==){returnnull;}if(!ctype_digit()){returnnull;}=(int);if(<0||>=count()){returnnull;}return[];}/** * Restore stashed `<noscript>` blocks via strict allowlist lookup. * * Unknown, foreign-namespace, or out-of-range tokens pass through * unmodified (fail-open) so attacker-controlled markup shaped like a * token stays inert. PCRE failure degrades to the unmodified buffer. * * @since 2.0.0 * @param string $buffer The HTML buffer containing tokens. * @param array $noscript_tokens The exact-token allowlist (token => HTML). * @return string Buffer with known tokens restored. */functionrestore_noscript_tokens(string,array):string{if(array()===){return;}=preg_replace_callback(\'/<!--WPPO_NOSCRIPT_[A-Za-z0-9_-]+_\\d+-->/\',function()use(){=->resolve_noscript_token([0],);if(null===){return[0];}return->sanitize_comment_images_in_buffer();},);returnnull!==?:;}/** * Whether a lazy `data-src` value is safe to rewrite with a placeholder. * * Fail-open ownership gate: hostile placeholder-shaped input (empty, * oversized, markup-bearing, or dangerous-scheme `data-src`) is not a * locally generated lazy node and must be emitted unmodified without * any placeholder rewrite. * * @since 2.0.0 * @param string $data_src The `data-src` URL of the image. * @return bool True when the node may receive a placeholder `src`. */functionis_valid_lazy_placeholder_candidate(string):bool{=trim();if(\'\'===){returnfalse;}if(strlen()>2048){returnfalse;}if(str_contains(,\'<\')||str_contains(,\'>\')){returnfalse;}=strtolower(ltrim());if(str_starts_with(,\'javascript:\')||str_starts_with(,\'vbscript:\')){returnfalse;}if(str_starts_with(,\'data:\')){return1===preg_match(\'#^data:image/(?:png|jpe?g|gif|webp|avif)[;,]#i\',);}returntrue;}/** * Post-processes the serialized buffer to inject placeholders into lazy-loaded images * that have data-src but no src attribute. Called after the WP_HTML_Tag_Processor pass. * * Duplication note (D-14): `post_process_placeholders`, `post_process_img_dimensions` * and `post_process_auto_sizes` intentionally scan the buffer in three separate * `preg_replace_callback` passes. Each stage mutates a distinct attribute set * (placeholder src, width/height, data-sizes=auto) via the shared * `get_placeholder_src_for_image()` helper and a per-request bounded LRU * (`IMG_SIZE_CACHE_LIMIT` / `FILE_EXISTS_CACHE_LIMIT`). Merging into a single * pass would conflate concerns and break the dimensions→auto-sizes ordering * dependency. The three-pass cost is linear and acceptable (see audit D-14). * * Anomaly gate: candidate nodes failing {@see is_valid_lazy_placeholder_candidate()} * are emitted unmodified without any lazy/placeholder rewrite (fail-open). * * @since 2.0.0 * * @param string $buffer The HTML buffer after WP_HTML_Tag_Processor serialization. * @param bool $enable_placeholder Whether placeholders are enabled. * @return string The modified buffer. */functionpost_process_placeholders(string,bool):string{if(!){return;}if(->should_use_html_processor()){=->post_process_placeholders_with_processor();if(null!==){return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){=->post_process_placeholders_with_tag_processor();if(null!==){return;}}=preg_replace_callback(\'#<img\\b[^>]*\\sdata-src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',function(){=[0];if(preg_match(\'#\\ssrc=#i\',)){return;}=[1];if(!->is_valid_lazy_placeholder_candidate()){return;}=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){=\'\';foreach([\'attrs\']as=>){.=\' \'.->normalize_data_attribute_name().\'=\"\'.esc_attr().\'\"\';}=preg_replace(\'#<img\\b#i\',\'<img src=\"\'.esc_attr([\'src\']).\'\"\'.,,1);returnnull!==?:;}return;},);returnnull!==?:;}/** * Processor-based placeholder injection using WP_HTML_Processor::serialize_token(). * * Mirrors the regex fallback byte-for-byte but uses token streaming so * nested <picture>, comments, SVG/mathML and malformed HTML are handled * without PCRE fragility. Falls back to regex on parse errors or when * WP_HTML_Processor is unavailable (WP <6.9 fallback). * * @since 2.0.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure (triggers regex fallback). */functionpost_process_placeholders_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){.=->serialize_token();continue;}=->get_tag();=->is_tag_closer();if(\'IMG\'===&&!){=->get_attribute(\'data-src\');=->get_attribute(\'src\');if(null!==&&null===){=->serialize_token();=(string);if(!->is_valid_lazy_placeholder_candidate()){.=;continue;}=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){->set_attribute(\'src\',[\'src\']);foreach([\'attrs\']as=>){->set_attribute(->normalize_data_attribute_name(),);}=->serialize_token();if(null===->get_attribute(\'src\')){=\'\';foreach([\'attrs\']as=>){.=\' \'.->normalize_data_attribute_name().\'=\"\'.esc_attr().\'\"\';}=preg_replace(\'#<img\\b#i\',\'<img src=\"\'.esc_attr([\'src\']).\'\"\'.,,1);.=null!==?:;continue;}.=;continue;}}}.=->serialize_token();}if(null!==->get_last_error()){returnnull;}return;}/** * Processor-based placeholder injection using WP_HTML_Tag_Processor (WP 6.2+). * * Middle tier between the WP 6.9+ `WP_HTML_Processor::serialize_token()` * fast path and the legacy regex fallback: single-pass `next_tag()` * traversal with `get_attribute()`/`set_attribute()` plus * `get_updated_html()`, so the WP 6.2-6.8 happy path never runs * `preg_replace` on `<img>` tags. Mirrors * `post_process_placeholders_with_processor()` exactly (placeholder * validity gate, extra data attrs). `data:` placeholder sources are * staged through a sentinel URL plus `str_replace()` because the Tag * Processor blocks `data:` URIs in `src`. Fail-open: returns null on * any failure so the caller falls through to the regex fallback. * * @since 2.2.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure (triggers regex fallback). */functionpost_process_placeholders_with_tag_processor(string):?string{if(!class_exists(\'WP_HTML_Tag_Processor\')){returnnull;}try{=new\\WP_HTML_Tag_Processor();=array();=0;while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-src\');=->get_attribute(\'src\');if(null===||null!==){continue;}=(string);if(!->is_valid_lazy_placeholder_candidate()){continue;}=->get_attribute(\'width\');=->get_attribute(\'height\');=\'<img\';if(is_string()||is_int()){.=\' width=\"\'.(string).\'\"\';}if(is_string()||is_int()){.=\' height=\"\'.(string).\'\"\';}.=\'>\';=->get_placeholder_src_for_image(,);if(empty([\'src\'])){continue;}=(string)[\'src\'];if(0===stripos(ltrim(),\'data:\')){=\'https://wppo.invalid/__wppo_ph_\'..\'__\';++;[]=;=;}->set_attribute(\'src\',);if(null===->get_attribute(\'src\')){continue;}foreach([\'attrs\']as=>){->set_attribute(->normalize_data_attribute_name(),);}}=->get_updated_html();if(!is_string()){returnnull;}if(!empty()){foreach(as=>){=function_exists(\'esc_attr\')?esc_attr():htmlspecialchars(,ENT_QUOTES,\'UTF-8\');=str_replace(\'\"\'..\'\"\',\'\"\'..\'\"\',);=str_replace(\"\'\"..\"\'\",\"\'\"..\"\'\",);}}return;}catch(\\Throwable){unset();returnnull;}}/** * Whether a dimension-lookup URL may touch the local filesystem. * * The regex/processor dimension tiers resolve `data-src ?? src` via * `Util::get_local_path()`, which maps any http(s) path onto ABSPATH * without checking the host. An external image whose path collides * with a local file would inherit the wrong dimensions, and every * dimension-less external image costs a wasted `file_exists` stat. * Relative URLs (empty host) are local by construction; absolute * URLs must be same-origin (home host) or a configured CDN host. * Fail-open to true when the verdict is unverifiable so markup is * never worse than the pre-guard behaviour. * * @since 2.3.0 * @param string $url The candidate image URL. * @return bool True when the filesystem lookup may run. */functionis_dimension_lookup_allowed(string):bool{try{=trim();if(\'\'===){returnfalse;}=strtolower(ltrim());if(str_starts_with(,\'data:\')||str_starts_with(,\'blob:\')||str_starts_with(,\'javascript:\')||str_starts_with(,\'vbscript:\')){returnfalse;}if(!function_exists(\'wp_parse_url\')){returntrue;}=wp_parse_url(,PHP_URL_HOST);if(!is_string()||\'\'===trim()){returntrue;}if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'is_same_site_host\')){try{if(Util::is_same_site_host()){returntrue;}}catch(\\Throwable){unset();}}if(->is_same_origin_preload_url()){returntrue;}if(->is_cdn_preload_url()){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Post-processes the serialized buffer to add missing width/height attributes to lazy-loaded images. * * Eager heroes excluded from lazy keep `src` (never `data-src`), so * the lookup prefers `data-src` and falls back to `src` — otherwise * a dimension-less hero keeps causing CLS (issue #1467). Fail-open: * unknown local paths leave the tag untouched. * * @since 2.0.0 * @since 2.3.0 Falls back to `src` when `data-src` is absent so eager heroes get stable dimensions. * * @param string $buffer The HTML buffer after WP_HTML_Tag_Processor serialization. * @return string The modified buffer. */functionpost_process_img_dimensions(string):string{if(->should_use_html_processor()){=->post_process_img_dimensions_with_processor();if(null!==){return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){=->post_process_img_dimensions_with_tag_processor();if(null!==){return;}}=preg_replace_callback(\'#<img\\b[^>]*>#i\',function(){=[0];=\'\';if(1===preg_match(\'#\\sdata-src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)&&\'\'!==trim([1])){=[1];}elseif(1===preg_match(\'#\\ssrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)&&\'\'!==trim([1])){=[1];}else{return;}=false;=false;if(1===preg_match(\'/\\bwidth\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+width\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(1===preg_match(\'/\\bheight\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+height\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(!||!){if(!->is_dimension_lookup_allowed()){return;}try{=Util::get_local_path();}catch(\\Throwable){unset();return;}if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){try{=->get_cached_image_size();}catch(\\Throwable){unset();return;}if(is_array()&&isset([0],[1])&&(int)[0]>0&&(int)[1]>0){if(!){=preg_replace(\'/<img\\b/i\',\'<img width=\"\'.(int)[0].\'\"\',,1);}if(!){=preg_replace(\'/<img\\b/i\',\'<img height=\"\'.(int)[1].\'\"\',,1);}}}}return;},);returnnull!==?:;}/** * Processor-based dimension injection using serialize_token(). * * @since 2.0.0 * @since 2.3.0 Falls back to `src` when `data-src` is absent so eager heroes get stable dimensions. * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_img_dimensions_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){.=->serialize_token();continue;}=->get_tag();=->is_tag_closer();if(\'IMG\'===&&!){=->get_attribute(\'data-src\');if(null===||\'\'===trim((string))){=->get_attribute(\'src\');}if(null!==&&\'\'!==trim((string))){=is_numeric(->get_attribute(\'width\'));=is_numeric(->get_attribute(\'height\'));if(!||!){if(!->is_dimension_lookup_allowed((string))){.=->serialize_token();continue;}try{=Util::get_local_path((string));}catch(\\Throwable){unset();.=->serialize_token();continue;}if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){try{=->get_cached_image_size();}catch(\\Throwable){unset();.=->serialize_token();continue;}if(is_array()&&isset([0],[1])&&(int)[0]>0&&(int)[1]>0){if(!){->set_attribute(\'width\',(string)(int)[0]);}if(!){->set_attribute(\'height\',(string)(int)[1]);}}}}}}.=->serialize_token();}if(null!==->get_last_error()){returnnull;}return;}/** * Processor-based dimension injection using WP_HTML_Tag_Processor (WP 6.2+). * * Middle tier between the WP 6.9+ serializer fast path and the legacy * regex fallback: single `next_tag()` pass over `<img>` with * `get_attribute()`/`set_attribute()` plus `get_updated_html()`. * Mirrors `post_process_img_dimensions_with_processor()` (cached file * existence + LRU size lookup). Fail-open: returns null so the caller * falls through to the regex fallback. * * @since 2.2.0 * @since 2.3.0 Falls back to `src` when `data-src` is absent so eager heroes get stable dimensions. * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_img_dimensions_with_tag_processor(string):?string{if(!class_exists(\'WP_HTML_Tag_Processor\')){returnnull;}try{=new\\WP_HTML_Tag_Processor();while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-src\');if(null===||\'\'===trim((string))){=->get_attribute(\'src\');}if(null===||\'\'===trim((string))){continue;}=is_numeric(->get_attribute(\'width\'));=is_numeric(->get_attribute(\'height\'));if(&&){continue;}if(!->is_dimension_lookup_allowed((string))){continue;}try{=Util::get_local_path((string));}catch(\\Throwable){unset();continue;}if(empty()||!->cached_file_exists()||!is_readable()||!is_file()){continue;}try{=->get_cached_image_size();}catch(\\Throwable){unset();continue;}if(!is_array()||!isset([0],[1])||(int)[0]<=0||(int)[1]<=0){continue;}if(!){->set_attribute(\'width\',(string)(int)[0]);}if(!){->set_attribute(\'height\',(string)(int)[1]);}}=->get_updated_html();returnis_string()?:null;}catch(\\Throwable){unset();returnnull;}}/** * Post-processes lazy-loaded images and <picture> sources to enable auto-sizes (WP 6.7+). * * Runs after post_process_img_dimensions() so width/height are guaranteed to be * present. For each lazy tag carrying a srcset the stored `data-sizes` value is * upgraded so supporting browsers can derive the source size from the rendered layout: * - values that already include `auto` are left untouched, * - static values get `auto, ` prepended as a progressive enhancement, * - images without any `data-sizes` (but with srcset + width + height) get a bare `auto`. * * @since 1.8.0 * * @param string $buffer The HTML buffer. * @return string The modified buffer. */functionpost_process_auto_sizes(string):string{if(!Util::is_auto_sizes_available()){return;}if(->should_use_html_processor()){=->post_process_auto_sizes_with_processor();if(null!==){return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){=->post_process_auto_sizes_with_tag_processor();if(null!==){return;}}=preg_replace_callback(\'#<(img|source)\\b[^>]*\\s(?:data-src|data-srcset)=[\"\\\'][^\"\\\']+[\"\\\'][^>]*>#i\',function(){=[0];=\'img\'===strtolower([1]);=(bool)preg_match(\'#\\b(?:data-)?srcset=[\"\\\']#i\',);if(!){return;}if(){=(bool)preg_match(\'/\\bwidth=[\"\\\']\\d+[\"\\\']/i\',);=(bool)preg_match(\'/\\bheight=[\"\\\']\\d+[\"\\\']/i\',);if(!||!){return;}}if(preg_match(\'#\\bdata-sizes=[\"\\\']([^\"\\\']*)[\"\\\']#i\',,)){=[1];if(->sizes_attribute_includes_auto()){return;}=\'auto, \'.;returnpreg_replace(\'#\\bdata-sizes=[\"\\\']([^\"\\\']*)[\"\\\']#i\',\'data-sizes=\"\'.esc_attr().\'\"\',,1);}if(!){return;}returnpreg_replace(\'/<img\\b/i\',\'<img data-sizes=\"auto\"\',,1);},);returnnull!==?:;}/** * Processor-based auto-sizes upgrade using serialize_token(). * * @since 2.0.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_auto_sizes_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){.=->serialize_token();continue;}=->get_tag();=->is_tag_closer();if((\'IMG\'===||\'SOURCE\'===)&&!){=null!==->get_attribute(\'data-src\')||null!==->get_attribute(\'data-srcset\');if(!){.=->serialize_token();continue;}=null!==->get_attribute(\'srcset\')||null!==->get_attribute(\'data-srcset\');if(!){.=->serialize_token();continue;}if(\'IMG\'===){=null!==->get_attribute(\'width\');=null!==->get_attribute(\'height\');if(!||!){.=->serialize_token();continue;}}=->get_attribute(\'data-sizes\');if(null!==){if(->sizes_attribute_includes_auto((string))){.=->serialize_token();continue;}->set_attribute(\'data-sizes\',\'auto, \'.(string));.=->serialize_token();continue;}if(\'SOURCE\'===){.=->serialize_token();continue;}->set_attribute(\'data-sizes\',\'auto\');}.=->serialize_token();}if(null!==->get_last_error()){returnnull;}return;}/** * Processor-based auto-sizes upgrade using WP_HTML_Tag_Processor (WP 6.2+). * * Middle tier between the WP 6.9+ serializer fast path and the legacy * regex fallback: one filtered `next_tag()` pass per tag name over * `<img>` and `<source>` with `get_attribute()`/`set_attribute()` * plus `get_updated_html()`. * Mirrors `post_process_auto_sizes_with_processor()` (lazy gate, * srcset presence, `<img>` width/height CLS gate, `data-sizes` auto * handling). Fail-open: returns null so the caller falls through to * the regex fallback. * * @since 2.2.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_auto_sizes_with_tag_processor(string):?string{if(!class_exists(\'WP_HTML_Tag_Processor\')){returnnull;}try{foreach(array(\'img\',\'source\')as){=new\\WP_HTML_Tag_Processor();while(->next_tag(array(\'tag_name\'=>))){->apply_auto_sizes_to_tag(,\'img\'===);}=->get_updated_html();if(!is_string()){returnnull;}=;}return;}catch(\\Throwable){unset();returnnull;}}/** * Apply the auto-sizes upgrade to the current Tag Processor tag. * * Shared per-tag step for the filtered `<img>`/`<source>` passes in * `post_process_auto_sizes_with_tag_processor()`: lazy gate, srcset * presence, `<img>` width/height CLS gate, then `data-sizes` auto * handling. Mirrors `post_process_auto_sizes_with_processor()` and the * regex fallback (which requires quoted-numeric dimensions, so empty, * boolean or non-numeric values count as missing here too). * * @since 2.2.0 * @param \\WP_HTML_Tag_Processor $tags The tag processor on an `<img>` or `<source>` tag. * @param bool $is_img Whether the current tag is an `<img>` (vs `<source>`). * @return void */functionapply_auto_sizes_to_tag(,bool):void{=null!==->get_attribute(\'data-src\')||null!==->get_attribute(\'data-srcset\');if(!){return;}=null!==->get_attribute(\'srcset\')||null!==->get_attribute(\'data-srcset\');if(!){return;}if(){=is_numeric(->get_attribute(\'width\'));=is_numeric(->get_attribute(\'height\'));if(!||!){return;}}=->get_attribute(\'data-sizes\');if(null!==){if(->sizes_attribute_includes_auto((string))){return;}->set_attribute(\'data-sizes\',\'auto, \'.(string));return;}if(!){return;}->set_attribute(\'data-sizes\',\'auto\');}/** * Whether the WP 6.9+ HTML API picture parser is available. * * Delegates to {@see Util::should_use_html_processor()} so every * processor-based rewrite shares one reflection guard for the public * `WP_HTML_Processor::serialize_token()` (WP 6.9). * * @since 2.0.0 * @return bool */functionshould_use_html_processor():bool{returnUtil::should_use_html_processor();}/** * Map a data attribute name via WP 6.9+ helpers when available. * * Guards `wp_html_custom_data_attribute_name()` so data-* mapping * uses core helper on WP 6.9+ without breaking WP <6.9. Falls back * to the raw attribute name. * * @since 2.0.0 * @param string $attr Raw attribute name (e.g. `data-wppo-dominant-color`). * @return string Normalized attribute name. */functionnormalize_data_attribute_name(string):string{if(function_exists(\'wp_html_custom_data_attribute_name\')){=\\wp_html_custom_data_attribute_name();if(is_string()&&\'\'!==){return;}}return;}/** * Processes <picture> blocks using WP_HTML_Processor for reliable block extraction with depth tracking. * * Uses spec-compliant token walking via serialize_token() with manual nesting * tracking so nested <picture>, comments, SVG/mathML and malformed HTML are * handled without the fragility of PCRE. * * Duplication note (D-13): the sibling `process_picture_blocks_regex()` is kept * intentionally as a fallback for hosts without `WP_HTML_Processor` (WP < 6.4) * or when `serialize_token()` is unavailable. Both share `process_picture_tag()` * for the per-picture decision logic; the `srcset` rewriting helpers are * similarly split (TagProcessor vs regex) for the same fallback reason. * Consolidated via shared helpers; no further dedup is safe without losing * the version-gated fallback. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param int $img_counter Current image counter. * @param int $exclude_img_count Number of first images to exclude. * @param array $exclude_imgs List of image URLs to exclude. * @return string The modified buffer. */functionprocess_picture_blocks_processor(string,int,int,array):string{if(!->should_use_html_processor()){return->process_picture_blocks_regex(,,,);}=Util::create_html_processor();if(null===){return->process_picture_blocks_regex(,,,);}=\'\';=false;=0;=\'\';try{while(->next_token()){=->get_token_type();if(\'#tag\'!==){=(string)->serialize_token();if(){.=;}else{.=;}continue;}=->is_tag_closer();=->get_tag();if(!&&\'PICTURE\'===&&!){=true;=1;=(string)->serialize_token();continue;}if(){.=(string)->serialize_token();if(\'PICTURE\'===){if(!){++;}else{--;if(0===){list(,)=->extract_picture_img();if(\'\'!==&&\'\'!==){++;if(>=){[]=;}.=->process_picture_tag(array(),,,);}else{.=;}=false;=\'\';=0;}}}continue;}.=(string)->serialize_token();}}catch(\\Throwable){unset();return->process_picture_blocks_regex(,,,);}if(method_exists(,\'get_last_error\')&&null!==->get_last_error()){return->process_picture_blocks_regex(,,,);}if(&&\'\'!==){.=;}return;}/** * Extract the inner `<img>` tag and its src from a `<picture>` block. * * Strict fallback chain (issue #1120): Tag Processor first, then the * regex last-resort. Processor `null` returns cast to empty string so * malformed markup fails open to the unoptimised block, never fatal. * Guards `class_exists(\'WP_HTML_Tag_Processor\')` so WP 6.2 behaviour * stays byte-identical when the Tag Processor is unavailable. * * @since 2.2.0 * * @param string $picture_html Serialized `<picture>...</picture>` block. * @return array{0:string,1:string} Tuple of (img tag, src); empty strings when none found. */functionextract_picture_img(string):array{=\'\';=\'\';if(class_exists(\'WP_HTML_Tag_Processor\')){try{=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-src\');if(null===){=->get_attribute(\'src\');}=is_string()?:\'\';}}catch(\\Throwable){unset();=\'\';}if(preg_match(\'#<img\\b[^>]*>#i\',,)){=[0];}if(\'\'!==&&\'\'!==){returnarray(,);}}if(preg_match(\'#<img\\b[^>]*?(?:data-)?src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',,)){returnarray([0],[1]);}if(\'\'===&&preg_match(\'#<img\\b[^>]*>#i\',,)){=[0];}returnarray(,);}/** * Processes <picture> blocks using regex fallback when WP_HTML_Processor is unavailable. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param int $img_counter Current image counter. * @param int $exclude_img_count Number of first images to exclude. * @param array $exclude_imgs List of image URLs to exclude. * @return string The modified buffer. */functionprocess_picture_blocks_regex(string,int,int,array):string{=preg_replace_callback(\'#<picture\\b[^>]*>.*?</picture>#is\',function()use(,,){preg_match(\'#<img\\b[^>]*?(?:data-)?src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',[0],);if(!empty()){++;if(>=){[]=[1];}return->process_picture_tag(,[0],[1],);}return[0];},);returnnull!==?:;}/** * Whether comment-image hardening is enabled (issue #1271). * * Additive `image_optimisation.hardenCommentImages` key; absent key * reads as enabled (fail-safe) so legacy installs get the * denylist/escape gate without a DB write. Explicit `false` * restores the legacy byte-identical rewrite path. * * @since 2.2.0 * @return bool True when comment image markup must be sanitized. */functionis_comment_hardening_enabled():bool{=->options[\'image_optimisation\'][\'hardenCommentImages\']??true;return!empty();}/** * Whether an image URL value is scriptable and must never be rewritten. * * Rejects `javascript:`/`vbscript:` and `data:` payloads that are not * `data:image/` (e.g. `data:text/html`), plus any other scheme that * is not `http`/`https`. Scheme-less values (relative paths, * root-relative paths, protocol-relative URLs) are not scriptable. * Control/whitespace obfuscation (`java\\tscript:`) is normalized * before the scheme check. Fail-open: undecodable input returns * false so the caller keeps its existing validity gate. * * @since 2.2.0 * @param string $url Raw attribute URL value. * @return bool True when the URL is scriptable. */functionis_scriptable_image_url(string):bool{static=array();=md5();if(isset([])){return[];}=->compute_is_scriptable_image_url();if(count()>=200){array_shift();}[]=;return;}/** * Core scriptable-URL check backing the memoized wrapper. * * Full entity-decode (repeated until stable, bounded at 5 passes) * plus control/whitespace stripping before the scheme regex, so * `javascript:` / `javascript:` obfuscation cannot * smuggle a scheme past the gate. `data:image/svg+xml` is treated * as scriptable (raster-only allowlist: png/jpeg/gif/webp/avif). * * @since 2.2.0 * @param string $url Raw attribute URL value. * @return bool True when the URL is scriptable. */functioncompute_is_scriptable_image_url(string):bool{try{=trim();for(=0;<5;++){=html_entity_decode(htmlspecialchars_decode(,ENT_QUOTES),ENT_QUOTES|ENT_HTML5,\'UTF-8\');if(===){break;}=;if(strlen()>4096){=substr(,0,4096);break;}}if(\'\'===){returnfalse;}=(string)preg_replace(\'/[\\x00-\\x20]+/\',\'\',ltrim());if(\'\'===||null===){returnfalse;}if(0===strpos(,\'//\')){returnfalse;}if(!preg_match(\'/^([a-zA-Z][a-zA-Z0-9+.-]*)\\s*:/\',,)){returnfalse;}=strtolower([1]);if(\'http\'===||\'https\'===){returnfalse;}if(\'data\'===){return1!==preg_match(\'#^data:image/(?:png|jpe?g|gif|webp|avif)[;,]#i\',);}returntrue;}catch(\\Throwable){unset();returnfalse;}}/** * Whether a `style` attribute value carries a scriptable payload. * * Single shared gate for the regex and Tag Processor sanitizer * paths so they stay in parity (issue #1271 follow-up). * * @since 2.2.0 * @param string $value Raw style value. * @return bool True when the style value is hostile. */functionis_hostile_style_value(string):bool{=;for(=0;<5;++){=html_entity_decode(,ENT_QUOTES|ENT_HTML5,\'UTF-8\');if(===){break;}=;if(strlen()>4096){=substr(,0,4096);break;}}=(string)preg_replace(\'/[\\x00-\\x20]+/\',\'\',strtolower());if(\'\'===){returnfalse;}returnfalse!==strpos(,\'expression(\')||false!==strpos(,\'javascript:\')||false!==strpos(,\'vbscript:\')||false!==strpos(,\'behaviour:\')||false!==strpos(,\'behavior:\')||false!==strpos(,\'-moz-binding\');}/** * Split a `srcset` value into candidates without breaking `data:` URIs. * * `data:image/png;base64,...` contains a comma that a naive * `explode(\',\', ...)` would split. The negative lookahead keeps * `base64` payload commas intact. * * @since 2.2.0 * @param string $raw Raw srcset attribute value. * @return string[] Trimmed non-empty candidate items. */functionsplit_srcset_candidates(string):array{=\";base64\\x00WPPO_COMMA\\x00\";=str_ireplace(\';base64,\',,);=explode(\',\',);=array();=count();for(=0;<;++){=trim((string)[]);if(\'\'===){continue;}if(1===preg_match(\'#^data:image/(?:png|jpe?g|gif|webp|avif)[;,][^\\\\s]*$#i\',)&&isset([+1])){=.\',\'.trim((string)[+1]);++;=trim();if(\'\'===){continue;}}[]=;}=array();foreach(as){[]=str_replace(,\';base64,\',);}return;}/** * Split a srcset candidate item into URL + descriptor without TypeError. * * `preg_split()` returns `false` on PCRE failure; `array_pad(false)` * TypeErrors on PHP 8.2. Shared by the regex and Tag Processor * srcset loops (issue #1271 follow-up). * * @since 2.2.0 * @param string $item Single srcset candidate item. * @return string[] Two-element [url, descriptor] array. */functionsplit_srcset_item(string):array{=preg_split(\'/\\s+/\',,2);if(!is_array()){=array();}=array_pad(,2,\'\');returnarray((string)[0],(string)[1]);}/** * Whether an attribute name is an inline event handler (`on*`). * * Fail-closed for unknown `on*` names but spares benign * non-handler attributes starting with `on` (`only`, `one`, * `online`). Shared by the regex and Tag Processor sanitizer * paths so they stay in parity (issue #1271 follow-up). * * @since 2.2.0 * @param string $name Raw attribute name. * @return bool True when the attribute is an event handler. */functionis_event_attribute_name(string):bool{=strtolower();if(in_array(,self::BENIGN_ON_PREFIX_ATTRS,true)){returnfalse;}return1===preg_match(\'/^on[a-z]{2,}$/\',);}/** * Whether an upper-case Tag Processor tag name is hardening-scoped. * * O(1) lookup replacing the inline 15-way `===` chain in the hot * loops. The unfiltered `next_tag()` traversal is kept deliberately: * the multi-tag `tag_names` query filter is unavailable on the * minimum supported WP 6.2 core, and per-tag filtered passes would * re-parse the full buffer N times. * * @since 2.2.0 * @param string $tag_name Upper-case tag name from `get_tag()`. * @return bool True when the tag is in `HARDENED_TAGS`. */functionis_hardened_tag(string):bool{static=null;if(null===){=array();foreach(self::HARDENED_TAGSas){[strtoupper()]=true;}}returnisset([]);}/** * Regex alternation for the hardening tag scope (e.g. `img|image|...`). * * @since 2.2.0 * @return string Alternation safe for `#<(...)\\b` patterns. */functionhardened_tag_alternation():string{returnimplode(\'|\',self::HARDENED_TAGS);}/** * Strip hostile attributes from a single opening tag. * * Handles every tag in `HARDENED_TAGS` (img/image/source/video/ * iframe/audio/embed/object/svg/math plus `use`/`a`/`table`/`body`/ * `td`/`th` carriers of href/background attributes) — not just * img/source/video. Named `sanitize_image_tag_html` for history; * runs site-wide on the full buffer (not comment-only) so hostile * markup can never be laundered into the static cache file. * * Denylist approach: event-handler attributes (`on*`), scriptable * URL attributes, and `style` payloads carrying `expression(` / * `javascript:` / `vbscript:` are removed; safe attributes (`src`, * `srcset`, `alt`, `width`, `height`, `loading`, `decoding`, * `fetchpriority`, `sizes`, `media`, `type`, `class`, `id`, …) are * preserved byte-identical so galleries/`<picture>` fixtures keep * their layout. Regex-based so the legacy WP 6.2 path (no Tag * Processor) gets the same gate. Fail-open: returns the input tag * unchanged on any PCRE failure. * * @since 2.2.0 * @param string $tag Raw opening tag HTML. * @return string Sanitized tag. */functionsanitize_image_tag_html(string):string{try{=false!==stripos(,\'on\');=false!==stripos(,\'src\')||false!==stripos(,\'href\')||false!==stripos(,\'data\')||false!==stripos(,\'poster\')||false!==stripos(,\'srcdoc\')||false!==stripos(,\'background\')||false!==stripos(,\'lowsrc\')||false!==stripos(,\'action\')||false!==stripos(,\'cite\')||false!==stripos(,\'longdesc\')||false!==stripos(,\'codebase\')||false!==stripos(,\'usemap\');=false!==stripos(,\'srcset\');=false!==stripos(,\'style\');if(!&&!&&!&&!){return;}if(){=preg_replace_callback(\'#[\\s/]+(on[a-z]+)\\s*=\\s*(?:\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>\"\\\']+)#i\',function(){return->is_event_attribute_name([1])?\'\':[0];},);if(null===){return;}=;}if(){=(string)preg_replace_callback(\'#[\\s/]+(src|data-src|data|codebase|usemap|poster|srcdoc|background|lowsrc|href|xlink:href|action|formaction|cite|longdesc)\\s*=\\s*(\"([^\"]*)\"|\\\'([^\\\']*)\\\'|([^\\s>\"\\\']+))#i\',function(){=strtolower([1]);=\'\';if(isset([3])&&\'\'!==[3]){=[3];}elseif(isset([4])&&\'\'!==[4]){=[4];}elseif(isset([5])){=[5];}if(\'srcdoc\'===){return\'\';}if(->is_scriptable_image_url()){return\'\';}return[0];},);}if(){=(string)preg_replace_callback(\'#[\\s/]+((?:data-)?srcset)\\s*=\\s*(\"([^\"]*)\"|\\\'([^\\\']*)\\\'|([^\\s>\"\\\']+))#i\',function(){=[1];=substr([2],0,1);=(\'\"\'===||\"\'\"===)?:\'\"\';=\'\';if(isset([3])&&\'\'!==[3]){=[3];}elseif(isset([4])&&\'\'!==[4]){=[4];}elseif(isset([5])){=[5];}=array();foreach(->split_srcset_candidates()as){list()=->split_srcset_item();if(->is_scriptable_image_url()){continue;}[]=;}if(array()===){return\'\';}=implode(\', \',);if(===){return[0];}return\' \'..\'=\'...;},);}if(){=(string)preg_replace_callback(\'#[\\s/]+style\\s*=\\s*(\"([^\"]*)\"|\\\'([^\\\']*)\\\'|([^\\s>\"\\\']+))#i\',function(){=\'\';if(isset([2])&&\'\'!==[2]){=[2];}elseif(isset([3])&&\'\'!==[3]){=[3];}elseif(isset([4])){=[4];}if(->is_hostile_style_value()){return\'\';}return[0];},);}return;}catch(\\Throwable){unset();return;}}/** * Sanitize hardened tags across a full buffer. * * Runs before next-gen/lazy rewriting so hostile comment-authored * markup (`img`/`image` `onerror`, `picture source`, inline `on*` * handlers, scriptable URLs, `<svg>`/`<math>` active content) * renders inert and can never be laundered into the static cache * file. Named `sanitize_comment_images_*` for history; runs * site-wide on the full buffer (not comment-only) because the * cache layer caches full pages. Safe gallery/`<picture>` markup * has no such attributes and passes through unchanged (no layout * regression). Fail-open: returns the input buffer unchanged when * hardening is disabled, the buffer is empty, or PCRE fails. * * @since 2.2.0 * @param string $buffer Full HTML buffer. * @return string Sanitized buffer. */functionsanitize_comment_images_in_buffer(string):string{if(\'\'===||!->is_comment_hardening_enabled()){return;}=->hardened_tag_alternation();if(1!==preg_match(\'#<(\'..\')\\b#i\',)){return;}try{=preg_replace_callback(\'#<(\'..\')\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function(){return->sanitize_image_tag_html([0]);},);=is_string()?:;if(false!==stripos(,\'<svg\')||false!==stripos(,\'<math\')){=(string)preg_replace_callback(\'#<(svg|math)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>(.*?)(</\\1\\s*>|$)#is\',function(){return->sanitize_svg_math_block([0]);},);}return;}catch(\\Throwable){unset();return;}}/** * Sanitize an `<svg>...</svg>` / `<math>...</math>` block, including inner content. * * Drops executable inner elements (`script`, `animate`, * `animateTransform`, `foreignObject`, `set`, `discard`) entirely * and strips hostile attributes from the remaining inner tags via * the shared {@see sanitize_image_tag_html()} gate, so nested * `<img onerror>` / `<a xlink:href=\"javascript:\">` / * `<mi href=\"javascript:\">` cannot survive into cached HTML. * * @since 2.2.0 * @param string $block Full svg/math block HTML. * @return string Sanitized block. */functionsanitize_svg_math_block(string):string{try{=preg_replace(\'#<(script|animate|animateTransform|foreignObject|set|discard)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>.*?</\\1\\s*>#is\',\'\',);if(null!==){=;}=preg_replace(\'#<(script|animate|animateTransform|foreignObject|set|discard)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*/?>#i\',\'\',);=preg_replace_callback(\'#<(?!/)([a-zA-Z][a-zA-Z0-9:_.-]*)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#\',function(){return->sanitize_image_tag_html([0]);},);returnis_string()?:;}catch(\\Throwable){unset();return;}}/** * Strip hostile attributes from the current Tag Processor tag. * * Defense-in-depth for the `WP_HTML_Tag_Processor` rewrite loops: * the buffer pre-pass already removed `on*`/scriptable attributes, * but a hostile node that survived (e.g. entity-obfuscated input the * regex missed) is neutralized here before `set_attribute()` can * re-emit it into cached HTML. Guards `get_attribute_names()` / * `remove_attribute()` so WP 6.2 cores without those methods stay * byte-identical. Never fatals: any failure leaves the tag * untouched for the caller to skip or fail open. * * @since 2.2.0 * @param object $tags Active `WP_HTML_Tag_Processor` positioned on a tag. * @return void */functionsanitize_tag_attributes_processor():void{try{if(!->is_comment_hardening_enabled()){return;}if(!is_object()||!method_exists(,\'get_attribute\')||!method_exists(,\'remove_attribute\')){return;}=array();if(method_exists(,\'get_attribute_names\')){=->get_attribute_names();if(is_array()){=;}}if(array()===){=array(\'onerror\',\'onload\',\'onclick\',\'onmouseover\',\'onmouseout\',\'onmouseenter\',\'onmouseleave\',\'onmousemove\',\'onmousedown\',\'onmouseup\',\'onfocus\',\'onblur\',\'onkeydown\',\'onkeyup\',\'onkeypress\',\'onsubmit\',\'onchange\',\'oninput\',\'onanimationend\',\'onanimationstart\',\'ontoggle\',\'onplay\',\'onpause\',\'onended\',\'onpointerover\',\'onpointerdown\',\'ontouchstart\',\'onwheel\',\'onscroll\',\'ondblclick\',\'oncontextmenu\',\'ondragstart\',\'ondrop\',\'onbegin\',\'onend\',\'onrepeat\',\'formaction\',\'xlink:href\',\'href\',\'action\',\'src\',\'data-src\',\'data\',\'codebase\',\'usemap\',\'srcset\',\'data-srcset\',\'poster\',\'srcdoc\',\'background\',\'lowsrc\',\'style\');}foreach(as){if(!is_string()||\'\'===){continue;}=strtolower();if(->is_event_attribute_name()){->remove_attribute();continue;}if(\'srcdoc\'===){=->get_attribute();if(null!==){->remove_attribute();}continue;}if(in_array(,self::HARDENED_URL_ATTRS,true)&&\'srcdoc\'!==){=->get_attribute();if(is_string()&&->is_scriptable_image_url()){->remove_attribute();}continue;}if(in_array(,self::HARDENED_SRCSET_ATTRS,true)){=->get_attribute();if(!is_string()||\'\'===){continue;}=array();foreach(->split_srcset_candidates()as){list()=->split_srcset_item();if(->is_scriptable_image_url()){continue;}[]=;}if(array()===){->remove_attribute();}elseif(implode(\', \',)!==){->set_attribute(,implode(\', \',));}continue;}if(\'style\'===){=->get_attribute();if(is_string()&&->is_hostile_style_value()){->remove_attribute();}}}}catch(\\Throwable){unset();}}/** * Serves next-generation images if supported by the browser. * * @since 1.0.0 * * @param string $buffer The HTML content buffer. * * @return string Modified HTML content buffer. */functionmaybe_serve_next_gen_images(){if(is_string()&&\'\'!==){=->sanitize_comment_images_in_buffer();}if(!empty(->options[\'image_optimisation\'][\'convertImg\'])){=->options[\'image_optimisation\'][\'conversionFormat\']??\'webp\';=->exclude_convert_imgs;=isset([\'HTTP_ACCEPT\'])?sanitize_text_field(wp_unslash([\'HTTP_ACCEPT\'])):\'\';=false!==strpos(,\'image/avif\');=false!==strpos(,\'image/webp\');if(!&&!){return;}if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();=->is_comment_hardening_enabled();while(->next_tag()){=->get_tag();if(&&is_string()&&->is_hardened_tag()){->sanitize_tag_attributes_processor();}if(\'IMG\'===||\'IMAGE\'===){=->get_attribute(\'src\');if(){=->normalize_url();if(->is_valid_url()&&!->is_scriptable_image_url()){=->replace_image_with_next_gen(,,,);if(!==){->set_attribute(\'src\',);}}}}if(\'IMG\'===||\'IMAGE\'===||\'SOURCE\'===){=->get_attribute(\'srcset\');if(){=array();=->split_srcset_candidates();foreach(as){list(,)=->split_srcset_item(trim());=->normalize_url();if(->is_scriptable_image_url()||->is_scriptable_image_url()){continue;}if(->is_valid_url()){=->replace_image_with_next_gen(,,,);=(!==)?:;[]=.(?\" \":\'\');}else{[]=.(?\" \":\'\');}}=implode(\', \',);if(array()===){->remove_attribute(\'srcset\');}elseif(!==){->set_attribute(\'srcset\',);}}}elseif(\'VIDEO\'===){=->get_attribute(\'poster\');if(){if(->is_scriptable_image_url()){->remove_attribute(\'poster\');}else{=->normalize_url();if(->is_valid_url()&&!->is_scriptable_image_url()){=->replace_image_with_next_gen(,,,);if(!==){->set_attribute(\'poster\',);}}}}}}return->get_updated_html();}else{=preg_replace_callback(\'#<(?:img|image)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function()use(,,){=[0];=preg_replace_callback(\'#src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];if(->is_scriptable_image_url()){return\'\';}if(->is_valid_url()){return\'src=\"\'.->replace_image_with_next_gen([1],,,).\'\"\';}return[0];},);=preg_replace_callback(\'#srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];=array();foreach(->split_srcset_candidates()as){list(,)=->split_srcset_item(trim());if(->is_scriptable_image_url()){continue;}=->replace_image_with_next_gen(,,,);[]=.(?\" \":\'\');}if(array()===){return\'\';}return\'srcset=\"\'.implode(\', \',).\'\"\';},);return;},);=preg_replace_callback(\'#<source\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function()use(,,){=[0];=preg_replace_callback(\'#\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];if(->is_scriptable_image_url()){return\'\';}if(->is_valid_url()){return\'src=\"\'.->replace_image_with_next_gen(,,,).\'\"\';}return[0];},);=preg_replace_callback(\'#\\bsrcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];=array();foreach(->split_srcset_candidates()as){list(,)=->split_srcset_item(trim());if(->is_scriptable_image_url()){continue;}=->replace_image_with_next_gen(,,,);[]=.(?\" \":\'\');}if(array()===){return\'\';}return\'srcset=\"\'.implode(\', \',).\'\"\';},);return;},);=preg_replace_callback(\'#<video\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function()use(,,){=[0];returnpreg_replace_callback(\'#\\bposter=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];if(->is_scriptable_image_url()){return\'\';}if(->is_valid_url()){=->replace_image_with_next_gen(,,,);if(!==){return\'poster=\"\'..\'\"\';}}return[0];},);},);return;}}return;}/** * Gets a cached instance of Img_Converter. * * @since 1.1.2 * * @return Img_Converter The Img_Converter instance. */functionget_img_converter(){if(null===->img_converter){->img_converter=newImg_Converter(->options);}return->img_converter;}/** * Cached file_exists check to avoid repeated stat calls per image per request. * * @since 2.0.0 * @param string $path Absolute file path. * @return bool Whether the file exists. */functioncached_file_exists(string):bool{if(\'\'===){returnfalse;}if(isset(self::[])){returnself::[];}=file_exists();if(count(self::)>=self::FILE_EXISTS_CACHE_LIMIT){array_shift(self::);}self::[]=;return;}/** * Clear the file_exists cache (for testing isolation). * * @since 2.0.0 * @return void */staticfunctionclear_file_exists_cache():void{self::=array();}/** * Get image dimensions with a bounded per-request LRU cache. * * Consolidates the `getimagesize` LRU that was copy-pasted between * `post_process_img_dimensions()` and `add_delay_load_img()` (D-14). * * @since 2.0.0 * @param string $local_path Absolute file path. * @return array|false Image size array or false on failure. */functionget_cached_image_size(string):array|false{if(isset(self::[])){=self::[];unset(self::[]);self::[]=;return;}if(count(self::)>=self::IMG_SIZE_CACHE_LIMIT){array_shift(self::);}=getimagesize();self::[]=;return;}/** * Replaces image URLs with next-generation formats. * * @since 1.0.0 * * @param string $img_url The image URL. * @param array $exclude_imgs Images to exclude. * @param boolean $supports_avif Whether AVIF is supported. * @param boolean $supports_webp Whether WebP is supported. * * @return string Updated image URL. */functionreplace_image_with_next_gen(,,,){=strtolower(pathinfo((string)wp_parse_url((string),PHP_URL_PATH),PATHINFO_EXTENSION));=->get_img_converter();=->get_format();if(\'avif\'===){return;}if(!empty()){foreach(as){if(false!==strpos(,)){return;}}}=->get_img_path(,\'avif\');=->get_img_path(,\'webp\');=null;if(\'avif\'===||\'both\'===){if(!->cached_file_exists()){=Util::get_local_path();if(->cached_file_exists()){->add_img_into_queue(,\'avif\');}}}if(\'webp\'===||\'both\'===){if(!->cached_file_exists()){if(null===){=Util::get_local_path();}if(->cached_file_exists()){->add_img_into_queue();}}}if((\'avif\'===||\'both\'===)&&&&->cached_file_exists()){return->get_img_url(,\'avif\');}if((\'webp\'===||\'both\'===)&&&&->cached_file_exists()){return->get_img_url();}return;}/** * Determine whether a string is a syntactically valid URL. * * @param string $url The URL to validate. * @return bool `true` if the URL is a valid URL string, `false` otherwise. */functionis_valid_url(){returnfalse!==filter_var(,FILTER_VALIDATE_URL);}/** * Convert various URL forms into an absolute URL. * * Leaves empty strings and `data:` URLs unchanged. Handles protocol-relative (`//...`), root-relative (`/...`) and relative paths (e.g., `images/foo.jpg`, `../img.jpg`) by resolving them against the site\'s home URL and the current request path. Returns the original value unchanged when it is already an absolute `http...` URL. * * @since 1.4.0 * @param string $url The input URL to normalize. * @return string The normalized absolute URL, or the original value for empty/data URLs. */functionnormalize_url(string):string{if(empty()||0===strpos(,\'data:\')){return;}=Util::cached_home_url();if(0===strpos(,\'//\')){static=array();=get_current_blog_id();if(!isset([])){[]=wp_parse_url(,PHP_URL_SCHEME);if(empty([])){[]=is_ssl()?\'https\':\'http\';}}return[].\':\'.;}if(0===strpos(,\'/\')){return.\'/\'.ltrim(,\'/\');}if(0!==strpos(,\'http\')){static=array();=get_current_blog_id();if(!isset([])){[]=wp_parse_url(add_query_arg(array()),PHP_URL_PATH);if(empty([])){[]=\'/\';}}=->resolve_relative_path([],);return.\'/\'.ltrim(,\'/\');}return;}/** * Resolve a relative path against a base path and return an absolute path starting with \'/\'. * * The function treats $base_path as a file (removing its final segment) when it has no * trailing slash and the last segment contains a dot. It preserves an absolute input * $relative_path (one that starts with \'/\') and resolves \'.\' and \'..\' segments. * * @since 1.4.0 * @param string $base_path Base path to resolve against; may represent a directory (trailing slash) or a file. * @param string $relative_path Relative path to resolve; if it starts with \'/\' it will be returned unchanged. * @return string The resolved absolute path beginning with \'/\'. */functionresolve_relative_path(string,string):string{if(0===strpos(,\'/\')){return;}=\'/\'===substr(,-1);=array_filter(explode(\'/\',),function():bool{returnis_string()&&\'\'!==;});=explode(\'/\',);if(!&&!empty()&&false!==strpos(end(),\'.\')){array_pop();}foreach(as){if(\'.\'===||\'\'===){continue;}if(\'..\'===){array_pop();}else{[]=;}}return\'/\'.implode(\'/\',);}/** * Retrieves all preloading data from front-page, post meta, and post types. * * @since 1.5.1 * @return array List of preload data items. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_all_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_all_preload_data():array{return->lcp_preload()->get_all_preload_data();}/** * Whether a candidate URL is a plausible LCP image (text-LCP guard). * * Text-only LCP (PageSpeed `largest-contentful-paint-element` without * an image URL) must never produce a preload hint, so candidates are * rejected unless they look like an image: data/blob/javascript URIs * are refused, and the URL must either map to a known image MIME * type, carry an image file extension, or (for extensionless image * CDN URLs) carry image-ish query params. A non-image URL is never * preloaded. Any failure returns false. * * @since 2.2.0 * @param string $url The candidate URL. * @return bool True when the URL may be preloaded as an image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_image_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_image_lcp_url(string):bool{return->lcp_preload()->is_image_lcp_url();}/** * Read the manual per-post LCP URL picker value (`_wppo_lcp_preload_url`). * * The manual picker is the fallback path: it wins over auto-detect * (RUM / Optimization Detective / PageSpeed / heuristic) so site owners * can pin the hero before field data exists. Returns an empty string * when not on a singular view, when the meta is absent, or when the * value fails the `is_image_lcp_url()` guard. Fail-open: any failure * returns an empty string, never fatal. * * @since 2.2.0 * @return string The manual LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_manual_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_manual_lcp_url():string{return->lcp_preload()->get_manual_lcp_url();}/** * Whether a preload candidate URL is same-origin with this site. * * Emission-path guard (issue #1180): stored PageSpeed values, OD * real-visit data, and the DOM-first heuristic flow into the single * `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` unchecked * today — only the RUM beacon intake validates origin. Absolute URLs * are validated via `RUM::is_same_origin_url()`; root-relative and bare * relative paths resolve against the home URL and are same-origin by * construction, except scheme-like values (`data:`, `blob:`, * `javascript:`, `mailto:`, …) which are rejected. Fail-closed for the * page: any failure (including an unavailable RUM class, consistent * with the catch block below — `resolve_auto_lcp_url()` already * fails open by falling through to the next tier) returns false * (candidate skipped), never fatal. * * @since 2.2.0 * @param string $url The candidate URL. * @return bool True when the URL may be preloaded. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_same_origin_preload_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_same_origin_preload_url(string):bool{return->lcp_preload()->is_same_origin_preload_url();}/** * Whether core\'s loading-optimization API is available. * * Explicit guard (issue #1180) for the decoding gap-fill: core is * consulted for gap-fill input (`decoding`) only, while the * measured hero keeps `fetchpriority=\"high\"` (field truth beats * the core heuristic) and an already-stamped fetchpriority is * never overridden. Requires * `wp_get_loading_optimization_attributes()` plus WordPress 6.2+ * (the HTML API era); every call is guarded with `function_exists()` * and `version_compare()` with a legacy fallback to the unmodified * gap-fill behaviour. Fail-open: any failure returns false. * * @since 2.2.0 * @return bool True when core may be consulted for a node verdict. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_core_loading_optimization_available}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_core_loading_optimization_available():bool{return->lcp_preload()->is_core_loading_optimization_available();}/** * Ask core for its loading-optimization verdict on the current tag. * * Gap-fill companion to `is_core_loading_optimization_available()`: * builds the tag-attribute array core expects and returns the * `wp_loading_optimization_attributes` filter verdict * (`decoding` when offered), or null when core is * unavailable, throws, or offers no verdict. The filter — not * `wp_get_loading_optimization_attributes()` — is consulted on * purpose: the latter runs core\'s stateful per-context image * counter, so a second direct call from the buffer path would * double-count this image and skew core\'s later lazy/eager * decisions (and core never returns `high` for our synthetic * context anyway). The filter lets hooked optimizers weigh in * without touching the counter; the already-stamped * `fetchpriority` attribute check in callers remains the * authoritative no-double-stamp guard. Callers always stamp the * measured hero `high` (field truth beats the heuristic), while * `decoding` defers to the verdict whenever one is offered. * `fetchpriority` is intentionally not collected: no caller reads * it (the stamp is unconditional when absent), so returning it * would be dead data inviting future misuse. * Fail-open: any failure returns null. * * @since 2.2.0 * @param mixed $tags Tag processor positioned on an `<img>` node. * @return array{decoding?:string}|null Core\'s verdict, or null. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_core_loading_verdict_for_tag}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_core_loading_verdict_for_tag():?array{return->lcp_preload()->get_core_loading_verdict_for_tag();}/** * Whether automatic (signal-driven) LCP preload is disabled for the current post. * * Reads the per-post `_wppo_disable_auto_lcp` meta (see Metabox). * The manual picker (`_wppo_lcp_preload_url`) is explicit opt-in and * is unaffected — only the RUM/OD signal tiers are suppressed. Off * (empty meta) by default so existing behaviour is unchanged. * Fail-open: any failure returns false (auto-LCP stays enabled). * * @since 2.2.0 * @return bool True when auto-LCP must be skipped for this post. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_auto_lcp_disabled_for_post}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_auto_lcp_disabled_for_post():bool{return->lcp_preload()->is_auto_lcp_disabled_for_post();}/** * Resolve the stable signal-only LCP image URL for the current page. * * Signal-driven subset of `resolve_auto_lcp_url()` (issue #1273): * the RUM field candidate (stable by construction — sample-count * gate via `get_field_lcp_min_samples()`, 24 h freshness TTL, and * same-origin re-check inside `RUM::get_field_lcp_url()`) wins * first, then the stability-gated OD real-visit candidate * (`OD_Bridge::get_stable_lcp_url()` — at least two agreeing * viewport observations, or a single measured group). Manual picker, * stored PageSpeed, and DOM-heuristic tiers are deliberately * excluded here. Every candidate must pass `is_image_lcp_url()` + * `is_allowed_hero_preload_url()` (same-origin or configured CDN). * Returns \'\' when the per-post * disable meta is set, when no stable signal exists, or on any * failure (fail-open to no-preload, never broken markup). * * @since 2.2.0 * @return string The stable signal LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_stable_signal_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_stable_signal_lcp_url():string{return->lcp_preload()->get_stable_signal_lcp_url();}/** * Resolve the OD-only LCP image URL (manual picker + OD real-visit data). * * Subset of `resolve_auto_lcp_url()` needing no RUM state (issue * #1216): the manual picker and the Optimization Detective tiers are * guarded (image + allowed origin: same-origin or configured CDN) * and fire no RUM lookups, so they stay * available when the RUM gate is unsatisfied. The OD tier relies on * the stability-gated `OD_Bridge::get_stable_lcp_url()` (issue * #1273 — at least two agreeing viewport observations, or a single * measured group; \'\' on viewport disagreement so a disagreeing * mobile/desktop pair never preloads the wrong hero), falling back * to `OD_Bridge::get_lcp_url()` only when the stable accessor is * unavailable. Gating lives in `OD_Bridge::is_enabled()` — the * single firing of the `wppo_od_should_optimize` filter * (current-URL context, memoized per request) — so no separate * filter pre-check exists here. The per-post * `_wppo_disable_auto_lcp` meta suppresses the OD tier; the manual * picker is explicit opt-in and still applies. Fail-open: any * failure returns \'\'. * * @since 2.2.0 * @return string The OD-only LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_od_only_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_od_only_lcp_url():string{return->lcp_preload()->resolve_od_only_lcp_url();}/** * Resolve the single auto-detected LCP image URL for the current page. * * Unified priority chain (fail-open, never fatal): * Manual per-post picker (`_wppo_lcp_preload_url`) wins first, then * P0 stability-gated Optimization Detective real-visit data (shared * `resolve_od_only_lcp_url()` helper — `get_stable_lcp_url()` so a * disagreeing mobile/desktop pair yields \'\' instead of the wrong * hero; the `wppo_od_should_optimize` filter fires inside * `OD_Bridge::get_stable_lcp_url()` via `is_enabled()` with * current-URL context, memoized per request), then stored PageSpeed * LCP (via `get_current_lcp_url()`, which covers RUM-field override + * post-meta/front-page/transient tiers), then the stability-gated * signal-only tier (`get_stable_signal_lcp_url()` — RUM field + * agreement-gated OD, issue #1273), then the DOM-first heuristic * (first non-trivial `<img src>` in `$buffer` when provided — DOM * order, not viewport-aware, buffer-only, and only a fallback when no * measured/stored data exists). The per-post * `_wppo_disable_auto_lcp` meta suppresses every automatic tier * (P0 OD, P1 stored, P1b signal, P2 heuristic) but never the * manual picker, which is explicit opt-in. Text-only LCP never resolves: every * candidate must pass `is_image_lcp_url()`. Untrusted-origin candidates never resolve either: * every tier must pass `is_allowed_hero_preload_url()` (same-origin * or configured CDN) so at most one allowed-origin * `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` * is ever emitted. Multisite-safe: the * stored tier uses `Util::transient_key()` blog-aware keys. * * @since 2.2.0 * @param string|null $buffer Optional HTML buffer for the heuristic fallback. * @return string The LCP image URL, or empty string when none resolves. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_auto_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_auto_lcp_url(?string=null):string{return->lcp_preload()->resolve_auto_lcp_url();}/** * Resolve responsive srcset/sizes for an LCP URL via the media library. * * Attachment-based lookup for the `wp_head` emission paths (which run * before any HTML buffer exists, so the buffer scanners cannot help): * maps the LCP URL to an attachment via `attachment_url_to_postid()` * and fetches `wp_get_attachment_image_srcset()` / * `wp_get_attachment_image_sizes()` (`full` size). Returns an empty * pair when the URL is not an attachment image, when the WP helpers * are unavailable, or on any failure (fail-open: callers fall back to * a plain `href` preload). Callers must never emit srcset without * sizes: when either value is empty both are treated as empty. * * @since 2.2.0 * @param string $lcp_url The resolved LCP image URL. * @return array{srcset: string, sizes: string} Responsive data (empty strings when unavailable). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_responsive_data_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */staticfunctionget_lcp_responsive_data_for_url(string):array{returnLcp_Preload::get_lcp_responsive_data_for_url();}/** * Find the responsive srcset for an LCP URL inside an HTML buffer. * * Scans `<img>` tags for the first node whose `src`/`data-src` * matches the LCP URL via normalized-URL equality only (absolute vs * relative and size-suffix variants match; no substring fallback so * a short relative URL cannot attach an unrelated srcset) and * returns its `srcset` (or `data-srcset`) value. Returns an empty * string when no match or no srcset exists. Fail-open: any failure * returns \'\'. * * @since 2.2.0 * @param string $lcp_url The resolved LCP image URL. * @param string|null $buffer Optional HTML buffer to scan. * @return string The srcset value, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_srcset_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lcp_srcset_for_url(string,?string=null):string{return->lcp_preload()->get_lcp_srcset_for_url(,);}/** * Find the responsive sizes value for an LCP URL inside an HTML buffer. * * Mirrors `get_lcp_srcset_for_url()`: normalized-URL equality only, * `sizes` (then `data-sizes`) of the matching `<img>`. Returns an * empty string when no match or no sizes exists. Fail-open: any * failure returns \'\'. * * @since 2.2.0 * @param string $lcp_url The resolved LCP image URL. * @param string|null $buffer Optional HTML buffer to scan. * @return string The sizes value, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_sizes_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lcp_sizes_for_url(string,?string=null):string{return->lcp_preload()->get_lcp_sizes_for_url(,);}/** * Emit a breakpoint-specific responsive LCP preload. * * Breakpoint-correct single-preload emitter (issue #1429): resolves * OD per-viewport LCP elements first * (`OD_Bridge::get_breakpoint_lcp_elements()`, guarded), RUM * field-LCP second (`RUM::get_field_lcp_url()`, guarded), and emits * exactly one `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` * with matching `imagesrcset`+`imagesizes` (escaped via `esc_attr()` * inside `Util::get_preload_link()`). Picture, CSS-background, and * video-poster LCP variants are covered per `type`; art-directed * `picture` entries carrying `media` are skipped fail-open (returns * \'\') instead of mispredicting. Without OD/RUM data returns \'\' so * the caller falls back to the legacy single-URL hero preload; * never more than one fetchpriority high per response (per-response * flag + `claim_hero_preload_slot()` + buffer high-hint scan). * Guards OD/RUM/WP calls with `function_exists()` / * `class_exists()` / `has_filter()` / `version_compare()` where * applicable. Multisite-safe: per-site metrics only (current-URL * context, `Util::transient_key()` blog-aware keys downstream). * * Standalone single-emission entry point for direct buffer/`wp_head` * callers needing a self-contained responsive preload (public for * testability, not part of the external plugin API): the existing * `wp_head` (`get_auto_lcp_preload_data()`) and buffer companions * (`maybe_preload_hero_image()`, `maybe_inject_css_hero_preload()`) * share the same resolver via `get_breakpoint_srcset_for_url()` so * their toggles/gates stay unchanged, while direct callers should * prefer this emitter instead of reimplementing the OD → RUM → * single-high flow. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer for responsive fallback scans. * @return string The preload `<link>` tag, or empty string when skipped. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::emit_responsive_lcp_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionemit_responsive_lcp_preload(?string=null):string{return->lcp_preload()->emit_responsive_lcp_preload();}/** * Resolve the responsive LCP candidate (OD breakpoints → RUM field). * * Shared resolver for `emit_responsive_lcp_preload()` and the * `wp_head`/buffer wiring: OD breakpoint winner first (with * attachment + buffer gap-fill when the element carries no srcset), * RUM field-LCP second. Returns `array()` when nothing resolves or * when the art-directed case must be skipped. Fail-open: any failure * returns `array()`. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer for fallback scans. * @return array{url: string, srcset: string, sizes: string, type: string, media: string}|array Empty when unresolved. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_responsive_lcp_candidate}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_responsive_lcp_candidate(?string=null):array{return->lcp_preload()->get_responsive_lcp_candidate();}/** * Pick the breakpoint winner from OD per-viewport entries. * * Majority-votes the normalized URL (mobile-first tie-break, mirroring * `OD_Bridge::get_lcp_url()`); the winner\'s `srcset`/`sizes` pair is * kept only when both are non-empty, otherwise gap-filled via the * attachment lookup then the buffer scan. Picture, background, and * video-poster `type` values all qualify (background/poster winners * legitimately carry no srcset and emit a plain href preload). * Art-directed output — distinct viewport URLs where any entry * carries a non-empty `media`, or a picture winner with `media` — * returns `array()` (fail-open skip). Winners failing * `is_image_lcp_url()` / `is_allowed_hero_preload_url()` are * skipped entry by entry (next-most-common) so one poisoned entry * cannot suppress a valid runner-up. * * @since 2.3.0 * @param array $entries OD breakpoint entries. * @param string|null $buffer Optional HTML buffer for gap-fill. * @return array{url: string, srcset: string, sizes: string, type: string, media: string}|array Winner or empty. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::pick_breakpoint_winner}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionpick_breakpoint_winner(array,?string=null):array{return->lcp_preload()->pick_breakpoint_winner(,);}/** * Resolve the RUM field-LCP fallback candidate. * * Second tier behind OD breakpoints (issue #1429): reads * `RUM::get_field_lcp_url()` (guarded) for the current path and * gap-fills srcset/sizes via the attachment lookup then the buffer * scan. Returns `array()` when RUM is unavailable, has no data, or * the candidate fails validation. Fail-open: any failure returns * `array()`. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer for gap-fill. * @return array{url: string, srcset: string, sizes: string, type: string, media: string}|array Candidate or empty. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_rum_fallback_candidate}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_rum_fallback_candidate(?string=null):array{return->lcp_preload()->resolve_rum_fallback_candidate();}/** * Whether the response already carries a fetchpriority-high hint. * * Single-high guard (issue #1429): true when the responsive * per-response flag is set or when the buffer already contains an * exact `fetchpriority=\"high\"` hint. Slot-claim enforcement * (exact + any-media `has_emitted_preload()` / * `is_hero_preload_claimed()` checks plus buffer URL matching) * lives in `claim_hero_preload_slot()`, not here. Fail-open to * false. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer to inspect. * @return bool True when a high hint already exists. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::response_already_has_high_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresponse_already_has_high_preload(?string=null):bool{return->lcp_preload()->response_already_has_high_preload();}/** * Retrieves the manual per-post LCP image preload item. * * Emits the `_wppo_lcp_preload_url` picker value via * `prepare_preload_item()` so a pinned hero preloads with * fetchpriority high even before auto-detect (RUM / OD / PageSpeed) * has data — and even when the `autoPreloadLCP` toggle is off, * because pinning the URL is explicit opt-in. Ordered first in * `get_all_preload_data()` so it wins the normalized-URL dedup. * Fail-open: any failure returns an empty list. * * @since 2.2.0 * @return array List of preload items (zero or one item). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_manual_lcp_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_manual_lcp_preload_data():array{return->lcp_preload()->get_manual_lcp_preload_data();}/** * Retrieves the single auto-detected LCP image preload item. * * Resolves via the unified `resolve_auto_lcp_url()` chain * (manual picker → P0 Optimization Detective real-visit data → P1 * stored PageSpeed/RUM-field → P2 DOM-first heuristic when a buffer is * available). Emits at most one item via `prepare_preload_item()` * so \"once per URL\" holds; the item is ordered ahead of front-page * and generic meta preloads (after the manual picker item) and * participates in the normalized-URL dedup. The legacy * `image_optimisation.autoPreloadLCP` toggle enables the legacy * path unchanged; the additive `preload_settings.autoLcpPreload` * toggle (issue #1216) enables the same chain but stays off until * RUM-gated (`RUM::is_enabled()`, guarded) with an off switch. * * RUM gates only the RUM-dependent tiers (issue #1216): with the new * toggle on but RUM unsatisfied, the OD-only subset (manual + OD, * needing no RUM state) still resolves instead of dropping OD * optimisation silently. The P2 heuristic is buffer-only by design — * this `wp_head` path passes no buffer, so heuristic heroes never * preload here; the buffer path (`maybe_preload_hero_image()`) * emits the companion preload link in the same pass it marks the * hero eager, keeping exclusion and emission consistent. * * @since 2.0.0 * @since 2.2.0 Resolves via the unified `resolve_auto_lcp_url()` chain * (OD → stored PageSpeed → heuristic) with a text-LCP guard; emits at * most one item. Adds the RUM-gated `preload_settings.autoLcpPreload` * path (off by default, manual lists win, never lazy+high). * @since 2.2.0 RUM gates only the RUM-dependent tiers: with RUM * unsatisfied the OD-only subset still resolves. * @return array List of preload items (zero or one item). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_auto_lcp_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_auto_lcp_preload_data():array{return->lcp_preload()->get_auto_lcp_preload_data();}/** * Look up breakpoint srcset/sizes for a resolved LCP URL. * * Thin wrapper over `get_responsive_lcp_candidate()` (issue #1429) * for the `wp_head` emission path: when OD breakpoints (or RUM * field data) resolve the same normalized URL, the breakpoint pair * wins over the attachment lookup; otherwise returns an empty pair * so callers fall back to the legacy single-href data. Fail-open to * an empty pair on any failure. * * @since 2.3.0 * @param string $lcp_url The resolved LCP image URL. * @param string|null $buffer Optional HTML buffer for gap-fill. * @return array{srcset: string, sizes: string} Responsive pair. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_breakpoint_srcset_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_breakpoint_srcset_for_url(string,?string=null):array{return->lcp_preload()->get_breakpoint_srcset_for_url(,);}/** * Whether the RUM gate for the additive auto-LCP toggle is satisfied. * * The `preload_settings.autoLcpPreload` path (issue #1216) stays off * until real-user measurement is enabled (`RUM::is_enabled()`, * guarded with class_exists/method_exists). Fail-closed when RUM is * unavailable or disabled so detection failure degrades to the * current manual behavior; fail-open only via the legacy * `autoPreloadLCP` path handled by the caller. Never fatal. * * @since 2.2.0 * @return bool True when RUM gating passes. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_auto_lcp_rum_satisfied}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_auto_lcp_rum_satisfied():bool{return->lcp_preload()->is_auto_lcp_rum_satisfied();}/** * Resolves the currently-detected LCP image URL for the current page. * * Checks mobile strategy first, then desktop, to support responsive sites * that serve different images per viewport. Data sources (in order): * * 1. Singular post meta (`_wppo_lcp_image_url_{strategy}`). * 2. Front-page option (`wppo_front_page_lcp_{strategy}`). * 3. Transient keyed by strategy + current URL hash (`wppo_lcp_url_{strategy}_{md5}`). * * Tiers 1-3 are read via the shared * `RUM::get_stored_pagespeed_lcp_url()` helper so strategy order and * key formats stay in sync with the preload candidate path. * * When the `fieldLcpOverride` toggle is enabled, field-measured RUM data * (issue #935) is consulted between Optimization Detective and the * PageSpeed chain: the top real-user LCP URL for the current path wins * only after enough samples (default 20) and while fresh (<24h). * * @since 2.0.0 * @return string The LCP image URL, or empty string when none is stored. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_current_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_current_lcp_url():string{return->lcp_preload()->get_current_lcp_url();}/** * Current-URL key for the per-instance LCP memos (issue #1216). * * Returns `Util::get_current_url()` (fail-open to \'\' when the URL is * unresolvable, e.g. early boot or bare unit contexts): both * `get_current_lcp_url()` and the null-buffer * `get_lazy_lcp_exclusion_url()` memo compare against this key so a * long-lived instance reused across pages re-resolves per page * instead of serving the first page\'s hero everywhere. Never fatal. * * @since 2.2.0 * @return string Memo key (possibly empty). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_memo_key}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lcp_memo_key():string{return->lcp_preload()->get_lcp_memo_key();}/** * P2 DOM-first heuristic LCP URL, memoized per buffer hash (issue #1216). * * Wraps `get_first_image_src_in_buffer()` so the full-HTML * `WP_HTML_Tag_Processor` scan runs once per distinct buffer per * request no matter how many callers (preload data, lazy exclusion, * hero inject) resolve the same buffer. Buffer-only by design: the * `wp_head` (null-buffer) path never fires the heuristic, so a hero * that is only heuristically detectable is marked eager in the * buffer path and its companion preload link is emitted by * `maybe_preload_hero_image()` in the same pass — emission and * exclusion stay consistent because both resolve with the buffer. * Bounded (reset past 30 entries); fail-open to \'\'. * * @since 2.2.0 * @param string $buffer HTML buffer to scan. * @return string Heuristic LCP URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_heuristic_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_heuristic_lcp_url(string):string{return->lcp_preload()->get_heuristic_lcp_url();}/** * Resolve the LCP-candidate URL excluded from lazy load (memoized per instance). * * Gated on the LCP toggles so default lazy behaviour is unchanged when * all LCP features are off: the field-measured branch needs * `fieldLcpOverride`, the stored-PageSpeed branch (shared read-only * lookup, no new scans) needs `autoPreloadLCP` or `prioritizeLCP`. * The manual per-post picker (`_wppo_lcp_preload_url`) is exempt from * the gate — pinning the hero is explicit opt-in, so the pinned URL * is always excluded from lazy load with width/height preserved. * Returns an empty string when no branch applies or nothing resolves. * Fail-open: any failure returns an empty string, never fatal. * * @since 2.0.0 * @since 2.2.0 Resolves via the unified `resolve_auto_lcp_url()` chain * so the never-lazy URL is always the same URL that gets preloaded. * The optional `$buffer` enables the P2 DOM-first heuristic tier so * `add_delay_load_img()` stays in parity with * `maybe_preload_hero_image()` (which resolves with the buffer); * without a buffer only the manual + OD + stored tiers apply. * @param array $image_optimisation Image optimisation settings. * @param string|null $buffer Optional HTML buffer for the heuristic tier. * @return string The candidate URL, or empty string when none applies. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lazy_lcp_exclusion_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lazy_lcp_exclusion_url(array,?string=null):string{return->lcp_preload()->get_lazy_lcp_exclusion_url(,);}/** * Get the effective excludeFirstImages count, preferring OD measured data. * * When OD is available and enabled, returns the measured count (1-3) * from viewport groups; otherwise returns the stored heuristic. The * `lcp_first_n` setting (default 3) takes precedence over the legacy * `excludeFirstImages` key. The result is filterable via * `wppo_lcp_first_n` (manual preload list / lazy-threshold override * when detection is inconclusive) and clamped to 0-10. When the * `lcp_guardrails` kill-switch is explicitly disabled, returns 0 so * the first-N never-lazy pass is skipped. Fail-open: any filter * failure falls back to the unfiltered count. * * @since 2.0.0 * @param array $image_optimisation Image optimisation settings. * @return int Exclude count. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_effective_exclude_first_images_count}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_effective_exclude_first_images_count(array):int{return->lcp_preload()->get_effective_exclude_first_images_count();}/** * Retrieves front page preload data if enabled. * * @since 1.5.1 * @param array $image_optimisation Image optimization configuration. * @return array List of preload items for the front page. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_front_page_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_front_page_preload_data(array):array{return->lcp_preload()->get_front_page_preload_data();}/** * Retrieves preload data from post meta. * * @since 1.5.1 * @return array List of preload items from meta. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_meta_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_meta_preload_data():array{return->lcp_preload()->get_meta_preload_data();}/** * Retrieves preload data for specific post types. * * @since 1.5.1 * @param array $image_optimisation Image optimization configuration. * @return array List of preload items for the post type. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_post_type_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_post_type_preload_data(array):array{return->lcp_preload()->get_post_type_preload_data();}/** * Retrieves the URL of the featured image for the current post type. * * @since 1.0.0 * * @param int $thumbnail_id The ID of the thumbnail image. * @return string The URL of the image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_image_url_by_post_type}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_image_url_by_post_type(int):string{return->lcp_preload()->get_image_url_by_post_type();}/** * Check if an image should be excluded from preloading or optimization. * * @since 1.0.0 * * @param string $image_url The URL of the image. * @param array $exclude_img_urls Array of URLs to exclude. * @return bool True if the image should be excluded, false otherwise. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::should_exclude_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionshould_exclude_image(string,array):bool{return->lcp_preload()->should_exclude_image(,);}/** * Parse srcset data from an image tag. * * @since 1.5.1 * @param string $srcset The srcset string from the image tag. * @param array $image_optimisation Image optimization configuration array. * @return array Array of parsed sources: array( \'url\' => string, \'width\' => int ). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::parse_srcset_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionparse_srcset_data(,):array{return->lcp_preload()->parse_srcset_data(,);}/** * Retrieves preload data items from an image\'s srcset. * * Capped at MAX_LCP_PRELOADS (issue #1216) so one post-type hero * can never expand to N media-variant links. * * @since 1.5.1 * @since 2.2.0 Keeps the largest MAX_LCP_PRELOADS widths (the likely * hero variants) instead of the smallest; media ranges are generated * after the slice so coverage stays gapless. * @param string $srcset The srcset string from the image tag. * @param string $default_image The fallback image URL. * @param array $image_optimisation Image optimization configuration array. * @return array List of preload items. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_srcset_preload_items}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_srcset_preload_items(,,):array{return->lcp_preload()->get_srcset_preload_items(,,);}/** * Prepares a URL for preloading, handling specific prefixes and resolving relative paths. * * @since 1.5.1 * @since 2.2.0 Adds optional $imagesrcset/$imagesizes for responsive LCP heroes. * @param string $img_url The original URL to prepare. * @param string $imagesrcset Optional responsive srcset for the preload link. * @param string $imagesizes Optional sizes for the preload link. * @return array Structured preload item. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::prepare_preload_item}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionprepare_preload_item(string,string=\'\',string=\'\'):array{return->lcp_preload()->prepare_preload_item(,,);}/** * Generates a preload link for a given image URL. * * Signal-driven single-preload entry point (issue #1273): with an * empty `$img_url` the stable RUM/OD candidate from * `get_stable_signal_lcp_url()` is used, so exactly one * `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` is emitted * per URL per request (shared `has/mark_preload_emitted()` dedup, * also consulted by `preload_images()` and the Critical-CSS * field-LCP path). The emitted URL is recorded in the per-request * emitted set so `add_delay_load_img()` exempts it from lazy-load * in the same response. Guards: per-post `_wppo_disable_auto_lcp` * meta suppresses the signal-resolved path; image-ness and * same-origin validators apply to every candidate; failures emit * nothing (fail-open, never broken markup). The `fetchpriority` * attribute is emitted directly (legacy-safe; no new core API * required — core gap-fill paths stay `function_exists()`-guarded * elsewhere). * * @since 1.0.0 * @since 2.2.0 Resolves the stable signal candidate when empty, * enforces per-URL dedup + per-post disable + lazy-exclusion * coupling with `fetchpriority=\"high\"`. * * @param string $img_url The URL of the image to preload. Empty resolves the stable signal candidate. * @return void * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::generate_img_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functiongenerate_img_preload(=\'\'){->lcp_preload()->generate_img_preload();}/** * Whether a `sizes` value already includes the `auto` keyword. * * Uses the Core helper (WP 6.7+) when available and falls back to a regex * mirroring its \"auto first in the list\" behaviour. * * @since 1.8.0 * * @param string $sizes The `sizes` attribute value. * @return bool True when the value already starts with `auto`. */functionsizes_attribute_includes_auto(string):bool{if(function_exists(\'wp_sizes_attribute_includes_valid_auto\')){returnwp_sizes_attribute_includes_valid_auto();}return(bool)preg_match(\'/^\\s*auto\\b/i\',);}/** * Whether an <img> tag qualifies for the auto-sizes enhancement. * * Auto-sizes requires a srcset (so the browser has candidates to choose from) * and explicit dimensions (so layout is stable and CLS is prevented). * * @since 1.8.0 * * @param \\WP_HTML_Tag_Processor $tags The tag processor instance. * @return bool True when the tag supports auto-sizes. */functiontag_supports_auto_sizes():bool{return(null!==->get_attribute(\'srcset\')||null!==->get_attribute(\'data-srcset\'))&&null!==->get_attribute(\'width\')&&null!==->get_attribute(\'height\');}/** * Prepares the value stored in `data-sizes` so auto-sizes can be restored. * * When the current WP version supports auto-sizes and the image qualifies * (srcset + width + height), any static `sizes` value is prefixed with * `auto, ` as a progressive enhancement; values that already include a valid * `auto` keyword (Core\'s \"auto, …\" output) are preserved verbatim. Otherwise * the value is returned unchanged so pre-6.7 behaviour is untouched. * * @since 1.8.0 * * @param string $sizes The original `sizes` attribute value. * @param \\WP_HTML_Tag_Processor $tags The tag processor instance used for the srcset/width/height checks. * @return string The value to store in `data-sizes`. */functionprepare_auto_sizes_value(string,):string{if(!Util::is_auto_sizes_available()||!->tag_supports_auto_sizes()){return;}if(->sizes_attribute_includes_auto()){return;}return\'auto, \'.;}/** * Query core for its loading/fetchpriority/decoding decision for an image. * * Single-sourced wrapper around `wp_get_loading_optimization_attributes()` * (WP 6.3+). All call-sites route through here so core owns the loading * decision (threshold/exception rules included) and the plugin only * fills gaps. Fail-open: returns an empty array when the function is * missing or throws, so callers fall back to internal lazy/high logic * and markup is emitted unoptimised, never fatal. Output transform * only, hence multisite-safe by construction. * * @since 2.2.0 * * @param array $tag_attr Image attributes (src/width/height/loading/decoding/fetchpriority). * @param string $context Context string passed to core (kept per call-site: * \'wp-html-tag-processor\', \'regex-fallback\', or * \'performance_optimisation_delay_load\'). * @return array Core\'s loading/fetchpriority/decoding/sizes triple (possibly empty). */functionmerge_core_loading_attributes(array,string):array{if(!function_exists(\'wp_get_loading_optimization_attributes\')){returnarray();}try{=wp_get_loading_optimization_attributes(\'img\',,);}catch(\\Throwable){unset();returnarray();}if(!is_array()){returnarray();}=array(\'loading\',\'fetchpriority\',\'decoding\',\'sizes\');returnarray_intersect_key(,array_flip());}/** * Enforce one valid loading/fetchpriority/decoding triple per element. * * Core-parity invariant: never pair `loading=\"lazy\"` with * `fetchpriority=\"high\"`. When both are present the high hint is * dropped so the hero gets high+eager and below-fold gets lazy. * * @since 2.2.0 * * @param array $attrs Triple to sanitize (loading/fetchpriority/decoding). * @return array Sanitized triple. */functionsanitize_loading_triple(array):array{if(isset([\'loading\'],[\'fetchpriority\'])&&\'lazy\'===[\'loading\']&&\'high\'===[\'fetchpriority\']){unset([\'fetchpriority\']);}return;}/** * Sets loading optimization attributes (fetchpriority, decoding) on a tag processor. * * Uses wp_get_loading_optimization_attributes() (WP 6.7+) when available, * falling back to manual attribute assignment. Also handles occluded * detection (Image Prioritizer) when core returns fetchpriority low for * below-fold images. * * @since 2.0.0 * @since 2.2.0 Excluded images pass `$allow_lazy = false` so core\'s * `loading=\"lazy\"` is never stamped on an image the user excluded from * lazy-loading; the exclusion wins and the high-priority default applies. * * @param \\WP_HTML_Tag_Processor $tags The tag processor instance. * @param array $defaults Default attributes to set if core function is unavailable. * @param bool $allow_lazy Whether core may contribute `loading=\"lazy\"`. * @return void */functionset_loading_optimization_attributes(,array=array(),bool=true):void{=array();if(->is_core_loading_optimization_available()){=array();=->get_attribute(\'src\');if(null!==){[\'src\']=;}=->get_attribute(\'width\');if(null!==){[\'width\']=(int);}=->get_attribute(\'height\');if(null!==){[\'height\']=(int);}=->get_attribute(\'loading\');if(null!==){[\'loading\']=;}=->get_attribute(\'decoding\');if(null!==){[\'decoding\']=;}=->get_attribute(\'fetchpriority\');if(null!==){[\'fetchpriority\']=;}=->merge_core_loading_attributes(,\'wp-html-tag-processor\');=->sanitize_loading_triple();if(!&&isset([\'loading\'])&&\'lazy\'===[\'loading\']){unset([\'loading\']);}if(isset([\'loading\'])&&null===->get_attribute(\'loading\')){->set_attribute(\'loading\',[\'loading\']);}if(isset([\'fetchpriority\'])&&null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',[\'fetchpriority\']);}if(isset([\'decoding\'])&&null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',[\'decoding\']);}if(isset([\'sizes\'])&&\'lazy\'===->get_attribute(\'loading\')&&null===->get_attribute(\'sizes\')){->set_attribute(\'sizes\',[\'sizes\']);}}if(isset([\'fetchpriority\'])&&null===->get_attribute(\'fetchpriority\')){if(!(\'lazy\'===->get_attribute(\'loading\')&&\'high\'===[\'fetchpriority\'])){->set_attribute(\'fetchpriority\',[\'fetchpriority\']);}}if(isset([\'decoding\'])&&null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',[\'decoding\']);}if(\'lazy\'===->get_attribute(\'loading\')&&\'high\'===->get_attribute(\'fetchpriority\')){->remove_attribute(\'fetchpriority\');}}/** * Whether missing-alt autofill is enabled. * * Off by default (fail-open): when disabled `process_img_tag()` * returns byte-identical HTML with respect to `alt`. The value is * filterable via `wppo_auto_alt_enabled` for host-level overrides. * * @since 2.0.0 * * @return bool True when missing `alt` attributes should be derived. */functionis_auto_alt_enabled():bool{=!empty(->options[\'image_optimisation\'][\'autoAltText\']);if(function_exists(\'apply_filters\')){/** * Filter whether missing-alt autofill is enabled. * * @since 2.0.0 * @param bool $enabled Whether autofill is enabled. */=(bool)apply_filters(\'wppo_auto_alt_enabled\',);}return;}/** * Derive a human-readable alt candidate from an image URL filename. * * Deterministic and offline: basename → strip `-{width}x{height}` * thumbnail suffix → replace `-/_/+/.` with spaces → collapse * whitespace → title-case. Returns an empty string when no usable * filename remains (e.g. `data:` URIs, query-only URLs). Makes no * external HTTP requests and no database queries. * * @since 2.0.0 * * @param string $src The image `src` URL. * @return string The filename-derived alt, or empty string. */functionfilename_to_alt(string):string{if(\'\'===||1===preg_match(\'#^data:image/#i\',)){return\'\';}=;if(function_exists(\'wp_parse_url\')){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==){=;}}else{=strpos(,\'#\');if(false!==){=substr(,0,);}=strpos(,\'?\');if(false!==){=substr(,0,);}}=basename((string));if(\'\'===){return\'\';}=pathinfo(,PATHINFO_FILENAME);if(!is_string()||\'\'===){return\'\';}=(string)preg_replace(\'/-\\d+x\\d+$/\',\'\',);=str_replace(array(\'-\',\'_\',\'+\',\'.\'),\' \',);=trim((string)preg_replace(\'/\\s+/\',\' \',));if(\'\'===){return\'\';}if(function_exists(\'sanitize_text_field\')){=sanitize_text_field();=trim();if(\'\'===){return\'\';}}if(function_exists(\'mb_substr\')){=mb_substr(,0,125);}else{=substr(,0,125);}=trim();if(\'\'===){return\'\';}if(function_exists(\'mb_convert_case\')){returnmb_convert_case(mb_strtolower(,\'UTF-8\'),MB_CASE_TITLE,\'UTF-8\');}returnucwords(strtolower());}/** * Read the bounded persistent src-to-title map for derived alt text. * * @since 2.0.0 * @return array<string, string> */staticfunctionget_derived_alt_map():array{try{=function_exists(\'get_current_blog_id\')?(int)get_current_blog_id():0;if(array_key_exists(,self::)&&is_array(self::[])){returnself::[];}=function_exists(\'wp_using_ext_object_cache\')?!wp_using_ext_object_cache():true;if(&&function_exists(\'wp_cache_get\')){=wp_cache_get(Util::transient_key(\'wppo_derived_alt_map\'),\'wppo\');if(is_array()){self::[]=;return;}}if(function_exists(\'get_transient\')){=get_transient(Util::transient_key(\'wppo_derived_alt_map\'));=is_array()?:array();if(&&function_exists(\'wp_cache_set\')){wp_cache_set(Util::transient_key(\'wppo_derived_alt_map\'),,\'wppo\',DAY_IN_SECONDS);}self::[]=;return;}}catch(\\Throwable){unset();}returnarray();}/** * Store one src-to-title entry in the bounded persistent map. * * Deferred (audit #1338): entries buffer per request and persist once * on shutdown, so a page with N new images issues one write instead * of N read-modify-writes on the render path. Capped at 200 entries * (drop-oldest) with a day TTL so the map cannot grow unbounded. * * @since 2.0.0 * @param string $src Image src URL. * @param string $title Resolved title (may be \'\'). * @return void */staticfunctionset_derived_alt_map_entry(string,string):void{try{=substr(,0,2048);=substr(,0,200);if(\'\'===){return;}=function_exists(\'get_current_blog_id\')?(int)get_current_blog_id():0;if(!isset(self::[])||!is_array(self::[])){self::[]=array();if(count(self::)>10){self::=array_slice(self::,-10,null,true);}}self::[][]=;if(count(self::[])>200){self::[]=array_slice(self::[],-200,null,true);}if(!self::&&function_exists(\'add_action\')){add_action(\'shutdown\',array(__CLASS__,\'commit_derived_alt_map\'));self::=true;}}catch(\\Throwable){unset();}}/** * Persist buffered alt-map entries (shutdown handler). * * Merges the request buffer into the persistent map in one write. * Fail-open: any failure drops the buffer silently. * * @since 2.2.0 * @return void */staticfunctioncommit_derived_alt_map():void{try{do{if(empty(self::)){break;}=self::;self::=array();self::=false;=function_exists(\'get_current_blog_id\')?(int)get_current_blog_id():0;foreach(as=>){if(!is_array()||empty()){continue;}=(int);=false;if(!==&&function_exists(\'switch_to_blog\')){switch_to_blog();=function_exists(\'restore_current_blog\');}try{=Util::transient_key(\'wppo_derived_alt_map\');=self::get_derived_alt_map();foreach(as=>){[]=;}if(count()>200){=array_slice(,-200,200,true);}self::[]=;=function_exists(\'wp_using_ext_object_cache\')?!wp_using_ext_object_cache():true;if(&&function_exists(\'wp_cache_set\')){wp_cache_set(,,\'wppo\',DAY_IN_SECONDS);}if(function_exists(\'set_transient\')){set_transient(,,DAY_IN_SECONDS);}}catch(\\Throwable){unset();}finally{if(){restore_current_blog();}}}}while(!empty(self::));if(!empty(self::)&&!self::&&function_exists(\'add_action\')){add_action(\'shutdown\',array(__CLASS__,\'commit_derived_alt_map\'));self::=true;}}catch(\\Throwable){unset();}}/** * Derive a deterministic alt for an image `src`. * * Primary source is the sanitized filename (`filename_to_alt()`); * when that yields nothing, falls back to the title of the image * attachment\'s parent post (resolved from `$src`, not global loop * context, and cached per request so each unique src is looked up at * most once). The result is filterable via `wppo_auto_alt_text` and * always sanitized, trimmed, and capped at 125 chars. Never performs * external HTTP; the title lookup runs only when the filename path * produced nothing. Fail-open: any failure returns an empty string * (caller then leaves the tag untouched). * * @since 2.0.0 * * @param string $src The image `src` URL. * @return string The derived alt, or empty string when none applies. */functionget_derived_alt(string):string{=->filename_to_alt();if(\'\'===&&function_exists(\'wp_get_post_parent_id\')&&function_exists(\'get_the_title\')&&function_exists(\'attachment_url_to_postid\')){try{static=array();if(!array_key_exists(,)){=self::get_derived_alt_map();if(array_key_exists(,)){[]=[];}else{=(int)attachment_url_to_postid();=>0?(int)wp_get_post_parent_id():0;=>0?get_the_title():\'\';if(function_exists(\'sanitize_text_field\')){=sanitize_text_field((string));}[]=is_string()?trim():\'\';self::set_derived_alt_map_entry(,[]);}}if(\'\'!==[]){=[];}}catch(\\Throwable){}}if(function_exists(\'apply_filters\')){/** * Filter the derived alt text for images missing an alt attribute. * * @since 2.0.0 * @param string $alt The derived alt text (may be empty). * @param string $src The image `src` URL. */=apply_filters(\'wppo_auto_alt_text\',,);if(is_string()){=;}}if(function_exists(\'sanitize_text_field\')){=sanitize_text_field();}if(function_exists(\'mb_substr\')){=mb_substr(,0,125);}else{=substr(,0,125);}returntrim();}/** * Autofill a missing `alt` via Tag Processor (fail-open, byte-identical when off). * * Only fills when the toggle is on AND the tag has no `alt` attribute * at all (`get_attribute()` returns `null`). An explicit empty * `alt=\"\"` is treated as an intentional decorative image and left * untouched. Tag Processor escapes the value on serialize. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor $tags Processor positioned on the `<img>` tag. * @param string $original_src The original image `src` value. * @return void */functionmaybe_autofill_alt_processor(,string):void{if(!->is_auto_alt_enabled()){return;}if(null!==->get_attribute(\'alt\')){return;}=->get_derived_alt();if(\'\'!==){->set_attribute(\'alt\',);}}/** * Autofill a missing `alt` via regex fallback (fail-open, byte-identical when off). * * Presence check is `#(?<![\\w-])alt\\s*=#i`, so both `alt=\"x\"` and decorative * `alt=\"\"` are preserved verbatim while hyphenated `data-alt` attributes * do not count as an `alt`. Escapes at emit because the regex * path concatenates raw strings. * * @since 2.0.0 * * @param string $img_tag The original `<img>` tag HTML. * @param string $original_src The original image `src` value. * @return string The tag with a derived `alt`, or unchanged. */functionmaybe_autofill_alt_regex(string,string):string{if(!->is_auto_alt_enabled()){return;}if(1===preg_match(\'#(?<![\\w-])alt\\s*=#i\',)){return;}=->get_derived_alt();if(\'\'===){return;}=function_exists(\'esc_attr\')?esc_attr():htmlspecialchars(,ENT_QUOTES,\'UTF-8\');=preg_replace(\'#<img\\b#i\',\'<img alt=\"\'..\'\"\',,1);returnnull===?:;}/** * Optimize an <img> tag for lazy loading, placeholders, dimensions, and performance attributes. * * If the image URL matches any exclusion substring, ensures the tag has `decoding=\"sync\"` and * `fetchpriority=\"high\"` (if missing) and returns the tag unchanged otherwise. For non-excluded * images, moves `src` → `data-src`, `srcset` → `data-srcset`, and `sizes` → `data-sizes` * (skipping `data:image/*` sources), optionally replaces `src` with an SVG placeholder, and * populates missing `width`/`height` attributes from the local file when available. * * @since 1.0.0 * * @param string $img_tag The original <img> tag HTML. * @param string $original_src The original value of the image `src` attribute. * @param string[] $exclude_imgs Array of URL substrings; if any is found in `$original_src` the image is treated as excluded. * @return string The modified <img> tag. */functionprocess_img_tag(,,){if(class_exists(\'WP_HTML_Tag_Processor\')){if(!empty()){foreach(as){if(\'\'!==&&false!==strpos(,)){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'high\',\'decoding\'=>\'sync\',),false);->maybe_autofill_alt_processor(,);return->get_updated_html();}return;}}}=!empty(->options[\'image_optimisation\'][\'lazyLoadNative\']);=new\\WP_HTML_Tag_Processor();if(!->next_tag(array(\'tag_name\'=>\'img\'))){return;}if(null===->get_attribute(\'data-src\')){=htmlspecialchars_decode(,ENT_QUOTES);if(!preg_match(\'#^data:image/#i\',)){if(||\'lazy\'===->get_attribute(\'loading\')){if(null===->get_attribute(\'loading\')){=true;if(function_exists(\'wp_get_loading_optimization_attributes\')){=array();=->get_attribute(\'src\');if(null!==){[\'src\']=;}=->get_attribute(\'width\');if(null!==){[\'width\']=(int);}=->get_attribute(\'height\');if(null!==){[\'height\']=(int);}=->merge_core_loading_attributes(,\'wp-html-tag-processor\');=isset([\'loading\']);}if(){->set_attribute(\'loading\',\'lazy\');}}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');}if(null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'low\',\'decoding\'=>\'async\',));if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}if(\'none\'!==->get_placeholder_type()){=->get_native_lazy_placeholder_attrs(,);foreach(as=>){if(null===->get_attribute()){->set_attribute(->normalize_data_attribute_name(),);}}}}else{if(function_exists(\'wp_get_loading_optimization_attributes\')&&null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes();if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}elseif(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}->set_attribute(\'data-src\',);if(\'none\'!==->get_placeholder_type()){=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){=->get_updated_html();=function_exists(\'esc_attr\')?esc_attr([\'src\']):[\'src\'];=preg_replace(\'#(?<!data-)src=([\"\\\'])[^\"\\\']*\\1#i\',\'src=\"\'..\'\"\',,1);if(null===){=;}=new\\WP_HTML_Tag_Processor();->next_tag(array(\'tag_name\'=>\'img\'));foreach([\'attrs\']as=>){->set_attribute(,);}=->get_updated_html();=new\\WP_HTML_Tag_Processor();->next_tag(array(\'tag_name\'=>\'img\'));}else{->remove_attribute(\'src\');}}else{->remove_attribute(\'src\');}=->get_attribute(\'srcset\');if(){->set_attribute(\'data-srcset\',);->remove_attribute(\'srcset\');}=->get_attribute(\'sizes\');if(){->set_attribute(\'data-sizes\',->prepare_auto_sizes_value(,));->remove_attribute(\'sizes\');}}}}=null!==->get_attribute(\'width\');=null!==->get_attribute(\'height\');if(!||!){=Util::get_local_path();if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){=->get_cached_image_size();if(is_array()){if(!){->set_attribute(\'width\',(string)[0]);}if(!){->set_attribute(\'height\',(string)[1]);}}}}->maybe_autofill_alt_processor(,);return->get_updated_html();}else{if(!empty()){foreach(as){if(\'\'!==&&false!==strpos(,)){if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}if(preg_match(\'/\\bloading=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'loading\']=[2];}if(preg_match(\'/\\bdecoding=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'decoding\']=[2];}if(preg_match(\'/\\bfetchpriority=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'fetchpriority\']=[2];}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'regex-fallback\'));if(isset([\'loading\'])&&\'lazy\'===[\'loading\']){unset([\'loading\']);}if(isset([\'loading\'])&&false===strpos(,\'loading\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 loading=\"\'.esc_attr([\'loading\']).\'\"\',);}if(isset([\'decoding\'])&&false===strpos(,\'decoding\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 decoding=\"\'.esc_attr([\'decoding\']).\'\"\',);}if(isset([\'fetchpriority\'])&&false===strpos(,\'fetchpriority\')){=false!==stripos(,\'loading=\"lazy\"\')||false!==stripos(,\"loading=\'lazy\'\");if(!(&&\'high\'===[\'fetchpriority\'])){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"\'.esc_attr([\'fetchpriority\']).\'\"\',);}}}else{if(false===strpos(,\'decoding\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 decoding=\"sync\"\',);}if(false===strpos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"high\"\',);}}return->maybe_autofill_alt_regex(,);}}}=!empty(->options[\'image_optimisation\'][\'lazyLoadNative\']);if(false===strpos(,\'data-src\')){=htmlspecialchars_decode(,ENT_QUOTES);if(preg_match(\'#^data:image/#i\',)){return->maybe_autofill_alt_regex(,);}if(||1===preg_match(\'/\\bloading=[\"\\\']lazy[\"\\\']/i\',)){if(false===stripos(,\'loading=\')){=true;if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}=->merge_core_loading_attributes(,\'regex-fallback\');=isset([\'loading\']);}if(){=preg_replace(\'#<img\\b#i\',\'<img loading=\"lazy\"\',);}}if(false===stripos(,\'decoding=\')){=preg_replace(\'#<img\\b#i\',\'<img decoding=\"async\"\',);}if(false===stripos(,\'fetchpriority\')){if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}if(preg_match(\'/\\bloading=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'loading\']=[2];}if(preg_match(\'/\\bdecoding=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'decoding\']=[2];}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'regex-fallback\'));if(isset([\'fetchpriority\'])&&false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"\'.esc_attr([\'fetchpriority\']).\'\"\',);}}if(false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"low\"\',);}}}else{if(false===stripos(,\'fetchpriority\')){if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}if(preg_match(\'/\\bloading=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'loading\']=[2];}if(preg_match(\'/\\bdecoding=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'decoding\']=[2];}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'regex-fallback\'));if(isset([\'fetchpriority\'])&&false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"\'.esc_attr([\'fetchpriority\']).\'\"\',);}}if(false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"low\"\',);}}=preg_replace_callback(\'#src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(){return\'data-src=\"\'.esc_attr().\'\"\';},);if(null!==){=;}if(\'none\'!==->get_placeholder_type()){=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){=preg_replace_callback(\'#<img\\b([^>]*)#i\',function()use(){=\'\';foreach([\'attrs\']as=>){.=\' \'..\'=\"\'.esc_attr().\'\"\';}return\'<img src=\"\'.esc_attr([\'src\']).\'\"\'..[1];},);if(null!==){=;}}}if(preg_match(\'#srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=preg_replace(\'#srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-srcset=\"\'.esc_attr([1]).\'\"\',);}if(preg_match(\'#\\bsizes=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=[1];if(Util::is_auto_sizes_available()&&!->sizes_attribute_includes_auto()){=(bool)preg_match(\'#\\b(?:data-)?srcset=[\"\\\']#i\',);=(bool)preg_match(\'/\\bwidth=[\"\\\']\\d+[\"\\\']/i\',);=(bool)preg_match(\'/\\bheight=[\"\\\']\\d+[\"\\\']/i\',);if(&&&&){=\'auto, \'.;}}=preg_replace(\'#\\bsizes=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-sizes=\"\'.esc_attr().\'\"\',);}}}=false;=false;if(1===preg_match(\'/\\bwidth\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+width\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(1===preg_match(\'/\\bheight\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+height\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(!||!){=Util::get_local_path();if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){=->get_cached_image_size();if(is_array()){if(!){=preg_replace(\'/<img\\b/i\',\'<img width=\"\'.(int)[0].\'\"\',);}if(!){=preg_replace(\'/<img\\b/i\',\'<img height=\"\'.(int)[1].\'\"\',);}}}}return->maybe_autofill_alt_regex(,);}}/** * Extract the YouTube video ID from an iframe src URL. * * @since 2.0.0 * * @param string $src The iframe src URL. * @return string The video ID, or empty string if not a YouTube embed. */functionget_youtube_video_id(string):string{if(preg_match(\'#(?:youtube(?:-nocookie)?\\.com/embed/|youtu\\.be/)([a-zA-Z0-9_-]{11})#i\',,)){return[1];}return\'\';}/** * Generate a lightweight video placeholder HTML for a YouTube iframe. * * Replaces the YouTube embed iframe with a static thumbnail and play button. * The actual iframe is loaded only on user click via JavaScript. * * @since 2.0.0 * * @param string $iframe_tag The original <iframe> tag HTML. * @param string $original_src The original src attribute value. * @param string $video_id Optional pre-extracted YouTube video ID. * @return string The placeholder HTML or the original iframe tag if excluded. */functiongenerate_video_placeholder(string,string,string=\'\'):string{=->sanitize_comment_images_in_buffer();if(!empty(->exclude_lazy_videos)){foreach(->exclude_lazy_videosas){if(false!==strpos(,)){return;}}}=apply_filters(\'wppo_video_placeholder_allowed\',true,,);if(!){return;}if(empty()){=->get_youtube_video_id();}if(empty()){return;}=false!==strpos(,\'youtube-nocookie.com\')?\'youtube-nocookie\':\'youtube\';=\'https://img.youtube.com/vi/\'..\'/maxresdefault.jpg\';=\'https://img.youtube.com/vi/\'..\'/hqdefault.jpg\';=\'<noscript>\'..\'</noscript>\';=\'<button type=\"button\" class=\"wppo-video-play-btn\" aria-label=\"\'.esc_attr__(\'Play video\',\'performance-optimisation\').\'\"> <svg aria-hidden=\"true\" focusable=\"false\" width=\"68\" height=\"48\" viewBox=\"0 0 68 48\"> <path class=\"wppo-play-btn-bg\" d=\"M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-.13,27.1-1.55c2.93-.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z\" fill=\"#f00\"></path> <path d=\"M 45,24 27,14 27,34\" fill=\"#fff\"></path> </svg> </button>\';=has_filter(\'wppo_video_play_button_html\');=has_filter(\'wppo_video_placeholder_html\');=apply_filters(\'wppo_video_play_button_html\',,,);=array(\'id\',\'class\',\'sandbox\',\'referrerpolicy\',\'title\',\'name\',\'frameborder\',\'allow\',\'allowfullscreen\');=array();if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){foreach(as){=->get_attribute();if(null!==){[]=;}}}}=!empty()?wp_json_encode():\'\';=sprintf(esc_attr__(\'Video thumbnail (%s)\',\'performance-optimisation\'),esc_attr());=\'<div class=\"wppo-video-placeholder\" data-wppo-video-src=\"\'.esc_url().\'\" data-wppo-video-type=\"\'.esc_attr().\'\"\'.(?\' data-wppo-iframe-attrs=\"\'.esc_attr().\'\"\':\'\').\'> \'..\' <picture> <img src=\"\'.esc_url().\'\" alt=\"\'..\'\" width=\"1280\" height=\"720\" loading=\"lazy\" data-wppo-fallback=\"\'.esc_url().\'\"> </picture> \'..\' </div>\';=apply_filters(\'wppo_video_placeholder_html\',,,,);if(){=->ensure_video_play_button_label();=->ensure_video_thumbnail_alt(,);}elseif(){=->ensure_video_play_button_label();}return;}/** * Default accessible name for a video thumbnail image. * * Single home for the sprintf( __( \'Video thumbnail (%s)\' ) ) * construction used by the placeholder default markup and both * repair paths, so translator comments cannot drift between copies. * Returns the raw translated string — callers escape for their sink * (Tag Processor set_attribute() escapes on output; regex splices * use esc_attr()). * * @since 2.2.0 * @param string $video_id YouTube video ID. * @return string Default thumbnail alt text. */functiondefault_video_thumbnail_alt(string):string{returnsprintf(__(\'Video thumbnail (%s)\',\'performance-optimisation\'),);}/** * Re-inject the default accessible name on video play buttons. * * Parses the given HTML with WP_HTML_Tag_Processor and repairs every * <button> that lacks both aria-label and aria-labelledby and has no * text content, adding the default aria-label. Markup that already * names the control (either attribute or visible text) is returned * untouched so third-party button HTML survives except the repair. * * Trusted-filter contract: this only re-adds accessible names — it is * not a sanitizer. Filter-supplied HTML (event handlers, * javascript: URLs, inner active content) passes through unchanged; * filters are privileged code, so no new XSS frontier is introduced, * but future untrusted callers must sanitize separately. * * @since 2.2.0 * @param string $html Button or placeholder HTML to validate. * @return string Validated HTML with accessible button names. */functionensure_video_play_button_label(string):string{if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}=array();if(preg_match_all(\'/<button\\b[^>]*>(.*?)<\\/button>/is\',,)){foreach([1]as){[]=trim((string)wp_strip_all_tags());}}=0;if(preg_match_all(\'/<button\\b/i\',,)){=count([0]);}if(count()!==){return;}=new\\WP_HTML_Tag_Processor();=0;=false;while(->next_tag(array(\'tag_name\'=>\'button\'))){=[]??\'\';++;=->get_attribute(\'aria-label\');=->get_attribute(\'aria-labelledby\');if((is_string()&&\'\'!==trim())||(is_string()&&\'\'!==trim())){continue;}if(\'\'!==){continue;}->set_attribute(\'aria-label\',__(\'Play video\',\'performance-optimisation\'));=true;}return?(string)->get_updated_html():;}/** * Re-inject the default alt on the video thumbnail image. * * Primary target is the placeholder thumbnail (identified by its * data-wppo-fallback attribute) so the verbatim <noscript> embed is * never rewritten. When no marked thumbnail exists — e.g. a * placeholder filter stripped the marker or swapped the thumbnail — * falls back to the first image outside any <noscript> block so the * repair cannot silently no-op. Images with a non-empty alt are left * untouched. * * Trusted-filter contract: this only re-adds the alt text — it is * not a sanitizer (see ensure_video_play_button_label()). * * @since 2.2.0 * @param string $html Placeholder HTML to validate. * @param string $video_id YouTube video ID used in the default alt. * @return string Validated HTML with a meaningful thumbnail alt. */functionensure_video_thumbnail_alt(string,string):string{if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}=;=array();=preg_replace_callback(\'#<noscript\\b[^>]*>.*?</noscript>#is\',staticfunction()use(&,){=\'<!--wppo-noscript-\'.count().\'-\'.md5(.count()).\'-->\';[]=[0];return;},);if(is_string()){=;}=new\\WP_HTML_Tag_Processor();=false;=false;while(->next_tag(array(\'tag_name\'=>\'img\'))){if(null===->get_attribute(\'data-wppo-fallback\')){continue;}=true;=->get_attribute(\'alt\');if(is_string()&&\'\'!==trim()){continue;}->set_attribute(\'alt\',->default_video_thumbnail_alt());=true;}if(||){=?(string)->get_updated_html():;if(!empty()){=strtr(,);}return;}return->ensure_first_content_image_alt(,);}/** * Repair the alt of the first image outside any <noscript> block. * * Fallback for ensure_video_thumbnail_alt() when the placeholder * thumbnail marker is gone. <noscript> ranges and <img> positions are * located by byte offset so the verbatim no-JS embed is never * touched; the repair splices an alt attribute into the first * content image that lacks a non-empty one. Fail-open: any parse * failure returns the input unchanged. * * @since 2.2.0 * @param string $html Placeholder HTML to validate. * @param string $video_id YouTube video ID used in the default alt. * @return string HTML with the fallback image alt repaired, or unchanged. */functionensure_first_content_image_alt(string,string):string{try{=array();if(preg_match_all(\'#<noscript\\b[^>]*>.*?</noscript>#is\',,,PREG_OFFSET_BYTES)){foreach([0]as){[]=array([1],[1]+strlen([0]));}}if(!preg_match_all(\'#<img\\b[^>]*>#i\',,,PREG_OFFSET_BYTES)){return;}foreach([0]as){=[0];=[1];=false;foreach(as){if(>=[0]&&<[1]){=true;break;}}if(){continue;}if(preg_match(\'/\\balt\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]*)?/i\',,)){=trim([1]??\'\',\"\\\"\' \\t\\n\\r\\0\\x0B\");if(\'\'!==){return;}=preg_replace(\'/\\balt\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]*)?/i\',\'alt=\"\'.esc_attr(->default_video_thumbnail_alt()).\'\"\',,1);}elseif(preg_match(\'/\\balt(?=\\s|\\/?>)/i\',)){=preg_replace(\'/\\balt(?=\\s|\\/?>)/i\',\'alt=\"\'.esc_attr(->default_video_thumbnail_alt()).\'\"\',,1);}else{=preg_replace(\'/<img\\b/i\',\'<img alt=\"\'.esc_attr(->default_video_thumbnail_alt()).\'\"\',,1);}if(!is_string()){return;}returnsubstr(,0,)..substr(,+strlen());}}catch(\\Throwable){unset();}return;}/** * Prepare an <iframe> tag for lazy loading and exclusion-aware optimization. * * If the iframe\'s source matches any exclusion substring, the tag is returned unchanged. * When native lazy loading is active (lazyLoadNative), the `src` attribute is preserved and * `loading=\"lazy\"` is added so the browser handles deferral (matching how core\'s * wp_get_loading_optimization_attributes() treats images). Otherwise the function moves * `src` to `data-src`, removes the `src` attribute, and ensures the `wppo-lazyload` class is * present for the JS IntersectionObserver path. Uses WP_HTML_Tag_Processor when available and * falls back to regex-based attribute manipulation. * * @since 1.0.0 * @since 2.0.0 Native lazy-load path for iframes. * * @param string $iframe_tag The original `<iframe>` tag HTML. * @param string $original_src The original `src` attribute value (absolute or relative URL). * @param string[] $exclude_imgs List of substrings; if any appear in `$original_src` the tag is left unchanged. * @return string The modified `<iframe>` tag HTML. */functionprocess_iframe_tag(,,){if(!empty()){foreach(as){if(\'\'!==&&false!==strpos(,)){return;}}}=apply_filters(\'wppo_lazyload_iframe_allowed\',true,,);if(!){return;}if(1===preg_match(\'#fetchpriority\\s*=\\s*[\"\\\']?high[\"\\\']?#i\',)){if(class_exists(\'WP_HTML_Tag_Processor\')){try{=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){=->get_attribute(\'loading\');if((is_string()&&\'lazy\'===strtolower(trim()))||null===){->set_attribute(\'loading\',\'eager\');}=->get_updated_html();if(is_string()&&\'\'!==){return;}}}catch(\\Throwable){unset();}}if(1===preg_match(\'#loading\\s*=\\s*[\"\\\']?lazy[\"\\\']?#i\',)){=preg_replace(\'#loading\\s*=\\s*[\"\\\']?lazy[\"\\\']?#i\',\'loading=\"eager\"\',,1);if(is_string()&&\'\'!==){return;}}elseif(false===stripos(,\'loading=\')){=preg_replace(\'#<iframe\\b#i\',\'<iframe loading=\"eager\"\',,1);if(is_string()&&\'\'!==){return;}}return;}=!empty(->options[\'image_optimisation\'][\'lazyLoadNative\']);if(){if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'lazy\');}=->get_updated_html();}}elseif(false===stripos(,\'loading=\')){=preg_replace(\'#<iframe\\b#i\',\'<iframe loading=\"lazy\"\',);if(null!==){=;}}return;}if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){->set_attribute(\'data-src\',);->remove_attribute(\'src\');->add_class(\'wppo-lazyload\');=->get_updated_html();}}else{=(string)preg_replace_callback(\'/\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']/i\',staticfunction(){=htmlspecialchars_decode([1],ENT_QUOTES);=function_exists(\'esc_attr\')?esc_attr():;return\'data-src=\"\'..\'\"\';},);if(preg_match(\'/class=[\"\\\']([^\"\\\']+)[\"\\\']/\',,)){=htmlspecialchars_decode([1],ENT_QUOTES);=function_exists(\'esc_attr\')?esc_attr():;=str_replace([0],\'class=\"\'..\' wppo-lazyload\"\',);}else{=preg_replace(\'/<iframe\\b/i\',\'<iframe class=\"wppo-lazyload\"\',);}}return;}/** * Whether the current request advertises AVIF support via Accept header. * * Fail-open contract lives with the caller: when false, AVIF sources * are omitted and WebP/original delivery is used instead. * * @since 2.0.0 * * @return bool True when the request Accept header allows image/avif. */functionclient_accepts_avif():bool{if(!isset([\'HTTP_ACCEPT\'])){returnfalse;}if(!function_exists(\'wp_unslash\')||!function_exists(\'sanitize_text_field\')){returnfalse;}=sanitize_text_field(wp_unslash([\'HTTP_ACCEPT\']));returnfalse!==strpos(,\'image/avif\');}/** * Build AVIF-first <source> tags for a <picture> wrapper. * * Emits `<source type=\"image/avif\">` first, `<source type=\"image/webp\">` * second, and falls back to a single original-MIME source when no * converted file exists (fail-open, never fatal). The AVIF source is * only emitted when the request Accept header allows AVIF and the * converted `.avif` file exists; the fallback `<img>` (with * width/height intact) is left to the caller. The original source * file is never deleted, so delivery stays restorable. * * Shared by the TagProcessor and regex-fallback wrap paths. * * @since 2.0.0 * * @param string $original_src Original image URL. * @param string $srcset Raw srcset value from the processed img (may be empty). * @param string $sizes Raw sizes value from the processed img (may be empty). * @param bool $is_lazy Whether lazy attributes (data-srcset/data-sizes) are in use. * @param bool $should_exclude Whether the image is excluded from conversion. * @return string One or more <source> tags. */functionbuild_avif_first_sources(string,string=\'\',string=\'\',bool=false,bool=false):string{=?\'data-srcset\':\'srcset\';=?\'data-sizes\':\'sizes\';=Util::get_image_mime_type();if(||empty(->options[\'image_optimisation\'][\'convertImg\'])){if(\'\'!==){=\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}return.\'>\';}return\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\">\';}=->get_img_converter();=method_exists(,\'get_format\')?->get_format():\'webp\';=!empty(->options[\'image_optimisation\'][\'avifFirst\']??true);=->client_accepts_avif();=array();if(\'\'!==){foreach(explode(\',\',)as){=array_pad(preg_split(\'/\\s+/\',trim(),2),2,\'\');if(\'\'!==[0]){[]=array(\'url\'=>[0],\'descriptor\'=>[1],);}}}if(empty()){[]=array(\'url\'=>,\'descriptor\'=>\'\',);}=\'\';if(&&in_array(,array(\'avif\',\'both\'),true)&&){=array();=false;foreach(as){=->get_img_path([\'url\'],\'avif\');if(\'\'!==&&->cached_file_exists()){=->get_img_url([\'url\'],\'avif\');[]=.(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');=true;}else{[]=[\'url\'].(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');}}if(){.=\'<source type=\"image/avif\" \'..\'=\"\'.esc_attr(implode(\', \',)).\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}.=\'>\';}}if(in_array(,array(\'webp\',\'both\'),true)){=array();=false;foreach(as){=->get_img_path([\'url\'],\'webp\');if(\'\'!==&&->cached_file_exists()){=->get_img_url([\'url\']);[]=.(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');=true;}else{[]=[\'url\'].(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');}}if(){.=\'<source type=\"image/webp\" \'..\'=\"\'.esc_attr(implode(\', \',)).\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}.=\'>\';}}if(\'\'===){if(\'\'!==){=\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}return.\'>\';}return\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\">\';}return;}/** * Wraps an image in a <picture> element or updates an existing <picture> by adding appropriate <source> * attributes for optimized delivery and lazy-loading based on current options and exclusions. * * Processes the provided image tag (or the <img> inside an existing <picture>) and returns the resulting * HTML fragment. Honors the configured wrapInPicture option and skips adding <source> descriptors when * the image URL matches any entry in the exclusion list. * * @since 1.0.0 * * @param array $matches Regex match array containing the matched <img> or <picture> fragment. * @param string $img_tag The original <img> tag to process. * @param string $original_src The original src attribute value of the image. * @param array $exclude_imgs List of URL substrings; if any is present in the image URL, source descriptors are not added. * @return string The processed <picture> or <img> HTML fragment (or the original fragment if unchanged). */functionprocess_picture_tag(,,,){=false;foreach(as){if(\'\'!==&&false!==strpos(,)){=true;break;}}if(class_exists(\'WP_HTML_Processor\')){=new\\WP_HTML_Processor([0]);if(null===->get_last_error()&&->next_tag(array(\'tag_name\'=>\'picture\'))){=->get_current_depth();=null;=null;=false;while(->next_tag()){if(->get_current_depth()<=){break;}if(\'IMG\'===->get_tag()&&!->is_tag_closer()){=->get_attribute(\'data-srcset\')??->get_attribute(\'srcset\');=->get_attribute(\'data-sizes\')??->get_attribute(\'sizes\');=null!==->get_attribute(\'data-src\');}}=new\\WP_HTML_Processor([0]);->next_tag(array(\'tag_name\'=>\'picture\'));=->get_current_depth();while(->next_tag()){if(->get_current_depth()<=){break;}if(\'SOURCE\'===->get_tag()&&!->is_tag_closer()){->set_attribute(\'type\',Util::get_image_mime_type());if(!){if(){->set_attribute(?\'data-srcset\':\'srcset\',);}if(){->set_attribute(?\'data-sizes\':\'sizes\',);}}}}=->get_updated_html();if(preg_match(\'#<img\\b[^>]*>#i\',[0],)){=[0];=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))&&null!==->get_attribute(\'data-src\')){if(\'none\'!==->get_placeholder_type()){=->get_attribute(\'data-src\')??\'\';=->get_placeholder_src_for_image(,htmlspecialchars_decode(,ENT_QUOTES));if(!empty([\'src\'])){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){->set_attribute(\'src\',[\'src\']);foreach([\'attrs\']as=>){->set_attribute(,);}return->get_updated_html();}}}return;}=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');if(){=;}}=->process_img_tag(,,);++->picture_counter;=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){if(1===->picture_counter){if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'high\');}}else{if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');}if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}=->get_updated_html();}returnpreg_replace_callback(\'#<img\\b[^>]*>#i\',function()use(){return;},,1);}return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){if(!preg_match(\'#<picture\\b[^>]*>.*?</picture>#is\',[0])){=->process_img_tag(,,);if(!isset(->options[\'image_optimisation\'][\'wrapInPicture\'])||(bool)->options[\'image_optimisation\'][\'wrapInPicture\']){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-srcset\')??->get_attribute(\'srcset\');=->get_attribute(\'data-sizes\')??->get_attribute(\'sizes\');=null!==->get_attribute(\'data-src\');=->build_avif_first_sources(,(string)(??\'\'),(string)(??\'\'),,);=\'<picture>\'...\'</picture>\';}}return;}elseif(preg_match(\'#<img\\b[^>]*>#i\',[0],)){=[0];=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))&&null!==->get_attribute(\'data-src\')){if(\'none\'!==->get_placeholder_type()){=->get_attribute(\'data-src\')??\'\';=->get_placeholder_src_for_image(,htmlspecialchars_decode(,ENT_QUOTES));if(!empty([\'src\'])){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))&&null===->get_attribute(\'src\')){->set_attribute(\'src\',[\'src\']);foreach([\'attrs\']as=>){->set_attribute(,);}returnstr_replace(,->get_updated_html(),[0]);}}}return[0];}=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');if(){=;}}=->process_img_tag(,,);returnstr_replace(,,[0]);}return[0];}else{if(!preg_match(\'#<picture\\b[^>]*>.*?</picture>#is\',[0])){=->process_img_tag(,,);if(!isset(->options[\'image_optimisation\'][\'wrapInPicture\'])||(bool)->options[\'image_optimisation\'][\'wrapInPicture\']){=\'\';if(preg_match(\'#\\b(?:data-)?srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=[1];}=\'\';if(preg_match(\'#\\b(?:data-)?sizes=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=[1];}=(bool)strpos(,\'data-src\');=->build_avif_first_sources(,(string),(string),,);=\'<picture>\'...\'</picture>\';}return;}else{preg_match(\'#<img\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',[0],);if(!empty()){=[0];=[2];=->process_img_tag(,,);returnpreg_replace(\'#<img\\b[^>]*?>#i\',,[0]);}}return[0];}}/** * Post-render LCP image prioritization (optional enhancement). * * When the \"prioritizeLCPImages\" toggle is enabled, this filter callback * runs on the finalized HTML (WP 6.9+ template-enhancement output buffer, * or the legacy outermost output buffer on older WP) and: * * 1. Removes `loading=\"lazy\"` from the first N images (matching the * `excludeFirstImages` heuristic) so above-the-fold images load eagerly. * 2. Sets `fetchpriority=\"high\"` on the detected LCP <img> unless the * attribute already exists, preserving core\'s own loading-optimization * decisions and the plugin\'s existing excludeFirstImages handling. * The matched node never keeps `loading=\"lazy\"` alongside * `fetchpriority=\"high\"`. * 3. When the `cssHeroPreload` toggle is enabled and the LCP target is * a CSS background hero (no matching <img>), injects exactly one * `<link rel=\"preload\" as=\"image\">` tag before `</head>`. * 4. When the `occlusionFetchpriorityLow` toggle is enabled (issue * #1426), OD-measured occluded in-viewport nodes get * `fetchpriority=\"low\"` with no `loading` change, skipping the * true-LCP node so the single-high invariant holds. * * Uses `WP_HTML_Processor::serialize_token()` (public since WP 6.9) when * available, falling back to `WP_HTML_Tag_Processor` on older versions. * * @since 2.0.0 * * @param string $filtered_output The filtered output from previous callbacks. * @param string $output The raw output buffer content (unused; present * for parity with the 6.9 filter signature and * safe when used as an ob_start callback). * @return string The processed buffer. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::prioritize_lcp_in_buffer}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionprioritize_lcp_in_buffer(,=\'\'){return->lcp_preload()->prioritize_lcp_in_buffer(,);}/** * Stamp fetchpriority=\"high\" on the LCP attachment at render time. * * `wp_get_attachment_image_attributes` filter callback (issue #1234): * when the attachment being rendered matches the resolved LCP * candidate, stamps `fetchpriority=\"high\"` with `loading=\"eager\"` * (any `loading=\"lazy\"` is replaced) and `decoding=\"async\"` when * absent, so attachment images rendered by core carry the correct * priority hint without regex post-processing. Core-parity by * delegation: the hint is stamped where core builds the `<img>` * tag, so it composes with core 6.3+ loading optimization output * instead of fighting it. * * The LCP candidate reuses the existing no-new-queries chain: * manual picker + Optimization Detective real-visit data * (`resolve_od_only_lcp_url()`), then the stored chain * (`get_current_lcp_url()` — RUM-field override + stored * PageSpeed). The DOM-heuristic tier is skipped (no buffer in * filter context). The candidate is resolved at most once per * page via the `$fetchpriority_lcp_url` memo (keyed by * `get_lcp_memo_key()`), since this filter fires per image. * Core\'s stateful * `wp_get_loading_optimization_attributes()` is deliberately not * consulted here: a second direct call would double-count this * image in core\'s per-context counter and skew core\'s later * lazy/eager decisions. * * Size-aware matching: the rendered file must correspond to the * LCP candidate exactly (size suffix preserved) before stamping, * so a below-fold thumbnail reuse of the same attachment is left * lazy. The size-suffix-insensitive fallback applies only when * the requested `$size` is `\'full\'`, where whatever file core * returns for the attachment is the hero itself. * * Fail-open: any failure (unresolvable candidate, missing core * API, unexpected input) returns `$attr` unchanged, never fatal. * * @since 2.2.0 * * @param mixed $attr Image attributes (expected array). * @param mixed $attachment Attachment post object, ID, or array with ID. * @param mixed $size Requested image size. * @return mixed The (possibly stamped) attributes, unchanged on miss. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::wppo_add_fetchpriority}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionwppo_add_fetchpriority(,=null,=null){return->lcp_preload()->wppo_add_fetchpriority(,,);}/** * Resolve the LCP candidate for the render-time fetchpriority filter, memoized per page. * * Same chain as the filter needs on every image render (manual * picker + Optimization Detective via `resolve_od_only_lcp_url()`, * then the stored chain via `get_current_lcp_url()`), but resolved * at most once per page: the result is cached in * `$fetchpriority_lcp_url` keyed by `get_lcp_memo_key()` so a page * with N images pays the OD/manual chain once instead of N times. * The DOM-heuristic tier is skipped (no buffer in filter context). * Fail-open to \'\'. * * @since 2.2.0 * @return string The validated LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_fetchpriority_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_fetchpriority_lcp_url():string{return->lcp_preload()->resolve_fetchpriority_lcp_url();}/** * Size-aware LCP candidate comparison for the fetchpriority filter. * * An exact (size-suffix-preserving) normalized match always stamps, * so the hero file itself is recognized at any requested size. A * size-suffix-insensitive match stamps only when the requested * `$size` is `\'full\'`, where the file core returns for the * attachment is the hero itself — a thumbnail/sidebar reuse of the * same attachment at a smaller size stays lazy. Fail-open to false. * * @since 2.2.0 * @param string $candidate The rendered file URL to test. * @param string $normalized_lcp Normalized LCP URL (size suffix stripped). * @param string $exact_lcp Normalized LCP URL (size suffix preserved). * @param bool $size_is_full Whether the requested image size is \'full\'. * @return bool True when the candidate corresponds to the LCP image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::fetchpriority_candidate_matches}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionfetchpriority_candidate_matches(string,string,string,bool):bool{return->lcp_preload()->fetchpriority_candidate_matches(,,,);}/** * Remove lazy-loading from the first N images in the buffer. * * Mirrors the excludeFirstImages heuristic used by add_delay_load_img() so * the same count semantics apply to the finalized HTML. Images carrying * either `src` or a JS-lazy `data-src` placeholder are counted, so a * JS-lazy hero is never missed. For each of the first N images the * transform is fail-open per node: `loading=\"lazy\"` is stripped and * replaced with `loading=\"eager\"`, JS-lazy placeholders are restored * (`data-src` to `src`, `data-srcset` to `srcset`, `data-sizes` to * `sizes`), lazy classes are removed, and `decoding=\"async\"` is * stamped when absent. Nodes that fail to parse keep their markup. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param array $image_optimisation Image optimization settings. * @return string The buffer with lazy-loading removed from the first N images. */functionunlazyload_first_images(string,array):string{=->get_effective_exclude_first_images_count();if(<=0){return;}try{=new\\WP_HTML_Tag_Processor();=0;=false;while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');=->get_attribute(\'data-src\');if((null===||\'\'===)&&(null===||\'\'===)){continue;}++;if(>){break;}try{if(->restore_js_lazy_placeholders()){=true;}if(->remove_lazy_classes()){=true;}if(\'lazy\'===->get_attribute(\'loading\')){->remove_attribute(\'loading\');=true;}if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'eager\');=true;}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');=true;}}catch(\\Throwable){unset();continue;}}=?->get_updated_html():;return->promote_eager_picture_sources();}catch(\\Throwable){return;}}/** * Restore JS-lazy placeholder attributes on the current tag. * * Promotes `data-src` to `src`, `data-srcset` to `srcset` and * `data-sizes` to `sizes` (non-empty values only; empty placeholders * are dropped). Fail-open per attribute: any failure leaves the tag * untouched. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor|\\WP_HTML_Processor $tags The tag processor matched on an <img>. * @return bool True when any attribute was changed. */functionrestore_js_lazy_placeholders():bool{=false;try{=->get_attribute(\'data-src\');if(is_string()&&\'\'!==){->set_attribute(\'src\',);->remove_attribute(\'data-src\');=true;}=->get_attribute(\'data-srcset\');if(null!==){if(\'\'!==){->set_attribute(\'srcset\',(string));}->remove_attribute(\'data-srcset\');=true;}=->get_attribute(\'data-sizes\');if(null!==){if(\'\'!==){->set_attribute(\'sizes\',(string));}->remove_attribute(\'data-sizes\');=true;}}catch(\\Throwable){unset();}return;}/** * Promote lazy `<source>` placeholders inside `<picture>` blocks whose * IMG was stamped eager. * * `WP_HTML_Tag_Processor` is forward-only and exposes no parent node, * so responsive heroes are handled in a second pass: for each * `<picture>` block containing a `loading=\"eager\"` image, sibling * `<source data-srcset>`/`data-sizes` placeholders are promoted to * `srcset`/`sizes`. Fail-open: any parse failure returns the buffer * unchanged. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @return string The buffer with eager-picture sources promoted. */functionpromote_eager_picture_sources(string):string{try{if(false===stripos(,\'<picture\')){return;}if(->should_use_html_processor()){=->promote_eager_picture_sources_with_processor();if(null!==){return;}}=preg_replace_callback(\'#<picture\\b[^>]*>.*?</picture>#is\',function(array):string{return->promote_eager_sources_in_block([0]);},);returnis_string()?:;}catch(\\Throwable){return;}}/** * Promote lazy `<source>` placeholders inside a single `<picture>` block. * * Shared by the `serialize_token()` builder path and the regex fallback * so both stay behaviorally identical: blocks without an eager image * pass through untouched, otherwise sibling `<source data-srcset>` / * `data-sizes` placeholders are promoted. Fail-open per tag. * * @since 2.2.0 * * @param string $block Serialized `<picture>...</picture>` block. * @return string The block with eager-picture sources promoted. */functionpromote_eager_sources_in_block(string):string{if(false===stripos(,\'loading=\"eager\"\')&&false===stripos(,\"loading=\'eager\'\")){return;}=preg_replace_callback(\'#<source\\b[^>]*>#i\',function(array):string{try{if(!class_exists(\'WP_HTML_Tag_Processor\')){return[0];}=new\\WP_HTML_Tag_Processor([0]);if(!->next_tag(array(\'tag_name\'=>\'source\'))){return[0];}if(!->restore_js_lazy_placeholders()){return[0];}return->get_updated_html();}catch(\\Throwable){unset();return[0];}},);returnis_string()?:;}/** * Promote eager-picture sources via the WP 6.9+ HTML API token stream. * * Walks tokens with `serialize_token()` and depth tracking to extract * each outer `<picture>` block, then delegates per-block promotion to * {@see promote_eager_sources_in_block()}. Returns null when the * processor is unavailable or the token stream ends with a parse * error so the caller falls back to the byte-identical regex path. * * @since 2.2.0 * * @param string $buffer The HTML buffer. * @return string|null The buffer with sources promoted, or null on failure. */functionpromote_eager_picture_sources_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}try{=\'\';=false;=0;=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){=(string)->serialize_token();if(){.=;}else{.=;}continue;}=->is_tag_closer();=->get_tag();if(!&&\'PICTURE\'===&&!){=true;=1;=(string)->serialize_token();continue;}if(){.=(string)->serialize_token();if(\'PICTURE\'===){if(!){++;}else{--;if(0===){.=->promote_eager_sources_in_block();=false;=\'\';=0;}}}continue;}.=(string)->serialize_token();}}catch(\\Throwable){unset();returnnull;}if(method_exists(,\'get_last_error\')&&null!==->get_last_error()){returnnull;}if(&&\'\'!==){.=->promote_eager_sources_in_block();}return;}/** * Strip JS-lazy placeholder classes from the current IMG tag. * * Removes `wppo-lazy`, `wppo-lazyload`, `lazyload`, `lazyloaded`, * `lazyloading` and the bare `lazy` token (exact-token match, so * classes like `lazy-button` are preserved) while keeping all other * classes. No-op when the tag carries no class attribute. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor|\\WP_HTML_Processor $tags The tag processor matched on an <img>. * @return bool True when a class token was stripped. */functionremove_lazy_classes():bool{=->get_attribute(\'class\');if(null===){returnfalse;}=array(\'wppo-lazy\',\'wppo-lazyload\',\'lazyload\',\'lazyloaded\',\'lazyloading\',\'lazy\');=preg_split(\'/\\s+/\',(string),-1,PREG_SPLIT_NO_EMPTY);if(!is_array()){returnfalse;}=array_values(array_diff(,));if(count()===count()){returnfalse;}if(empty()){->remove_attribute(\'class\');}else{->set_attribute(\'class\',implode(\' \',));}returntrue;}/** * Stamp the hero (LCP) triple: high fetchpriority + eager loading. * * Merges core\'s fetchpriority/decoding decision first (gap-fill only, * never core\'s loading value), then forces eager + high so the hero is * never lazy. Guarantees one valid triple per element (never lazy+high). * * @since 2.2.0 * * @param object $tags Tag/HTML processor positioned on the hero <img>. * @return bool True when any attribute was added, changed, or removed. */functionstamp_hero_loading_triple():bool{=false;=null;=null;if(function_exists(\'wp_get_loading_optimization_attributes\')&&null===->get_attribute(\'decoding\')){=array();=->get_attribute(\'src\');if(null===){=->get_attribute(\'data-src\');}if(null!==){[\'src\']=;}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'wp-html-tag-processor\'));if(isset([\'fetchpriority\'])&&\'high\'===[\'fetchpriority\']){=\'high\';}if(isset([\'decoding\'])){=[\'decoding\'];}}if(->restore_js_lazy_placeholders()){=true;}if(->remove_lazy_classes()){=true;}if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',null!==?:\'high\');=true;}if(\'lazy\'===->get_attribute(\'loading\')){->remove_attribute(\'loading\');=true;}if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'eager\');=true;}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',null!==?:\'async\');=true;}return;}/** * Set fetchpriority=\"high\" on the detected LCP image. * * Stamps `fetchpriority=\"high\"` on the matching <img> only when no fetchpriority * attribute already exists, so core\'s wp_get_loading_optimization_attributes() * output and the plugin\'s existing excludeFirstImages high-priority assignment * are never double-applied. The matched LCP image is also un-lazy-loaded so an * in-viewport LCP image is actually fetched eagerly at high priority: * `loading=\"lazy\"` is replaced with `loading=\"eager\"` and * `decoding=\"async\"` is stamped when absent (progressive enhancement, * ignored by old browsers). * * @since 2.0.0 * @since 2.2.0 Callers pass the unified `resolve_auto_lcp_url()` target so * the never-lazy/fetchpriority stamp always matches the preloaded URL. * * @param string $buffer The HTML buffer. * @param string|null $lcp_url Optional pre-resolved LCP URL. When null the * URL is resolved via resolve_auto_lcp_url() * (same-origin guarded OD/stored/heuristic * chain; the stored tier internally reads * get_current_lcp_url()). * @return string The buffer with fetchpriority=\"high\" on the LCP image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::prioritize_lcp_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionprioritize_lcp_image(string,?string=null):string{return->lcp_preload()->prioritize_lcp_image(,);}/** * Hero fallback: ensure the first-viewport image preloads with fetchpriority=high and is never lazy. * * When stored LCP data exists the companion preload link is emitted for * it; when detection fails the first <img src> in the buffer is treated * as the hero (eager, fetchpriority high, never data-src lazy). Core\'s * wp_get_loading_optimization_attributes() decision is honoured — gaps * are only filled. Fail-open: any failure returns the buffer unchanged. * * @since 2.0.0 * @since 2.2.0 Resolves via the unified `resolve_auto_lcp_url()` chain * (OD → stored PageSpeed → in-viewport heuristic) and emits at most * one preload link with `imagesrcset` when the hero carries a srcset. * Accepts a pre-resolved LCP URL so all buffer passes share one target. * * @param string $buffer The HTML buffer. * @param array $image_optimisation Image optimisation settings. * @param string|null $lcp_url Optional pre-resolved LCP URL. When null * the URL is resolved via resolve_auto_lcp_url(). * @return string The buffer with hero preload link injected. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::maybe_preload_hero_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionmaybe_preload_hero_image(string,array,?string=null):string{return->lcp_preload()->maybe_preload_hero_image(,,);}/** * Get the first <img src> URL in the buffer (hero fallback). * * DOM-order only (not viewport-aware): iterates `<img>` tags and * returns the first non-trivial candidate, skipping tracking pixels, * hidden nodes, and tiny dimensions so a logo/pixel does not consume * the preload slot. Used only when no OD/stored LCP data exists. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @return string First image src, or empty string when none found. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_first_image_src_in_buffer}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_first_image_src_in_buffer(string):string{return->lcp_preload()->get_first_image_src_in_buffer();}/** * Whether a heuristic `<img>` candidate is trivial (pixel/hidden/tiny). * * Skips tracking pixels (`pixel`/`tracking`/`spacer`/`1x1` in the URL), * hidden nodes (`hidden` attribute or `display:none` / * `visibility:hidden` inline style), and tiny dimensions (`width` / * `height` attributes <= 10px). Fail-open: any failure returns false. * * @since 2.2.0 * @param \\WP_HTML_Tag_Processor $tags The tag processor on the candidate `<img>`. * @param string $src The candidate src URL. * @return bool True when the candidate should be skipped. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_trivial_heuristic_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_trivial_heuristic_image(,string):bool{return->lcp_preload()->is_trivial_heuristic_image(,);}/** * Whether the buffer already contains a preload link for the image URL. * * Scans `<link>` tags whose `rel` token list contains `preload` and * whose `as` attribute is either `image` or absent, then compares the * normalized href (absolute-vs-relative agnostic) plus the raw query * string, so versioned assets (`hero.jpg?v=1` vs `hero.jpg?v=2`) * emit distinct hints. WordPress size-suffix variants only collapse * when the requested URL itself carries a size suffix — a * `hero-300x200.jpg` preload never suppresses the full-size * `hero.jpg` hint. Fail-open: any parse failure returns false (emit * the hint) rather than skipping it. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param string $url The image URL to look for. * @return bool True when a matching preload link exists. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::buffer_has_image_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionbuffer_has_image_preload(string,string):bool{return->lcp_preload()->buffer_has_image_preload(,);}/** * Tag Processor scan for an existing image preload link (WP 6.2+). * * Single-pass `next_tag()` traversal over `<link>` with * `get_attribute()` reads, so the happy path never runs `preg_replace` * on `<link>` tags. Mirrors the regex fallback matching exactly (rel * token list contains `preload`, any present quoted `as` value — * including an empty string — must equal `image` while an absent or * boolean `as` counts as image-eligible, normalized-href plus * raw-query comparison with size-suffix rules). * Returns null when the processor is unavailable or throws so the * caller falls through to the regex fallback. Fail-open: any parse * failure returns null (caller then runs the legacy scan). * * @since 2.2.0 * @param string $buffer The HTML buffer. * @param string $needle Normalized target URL. * @param string $needle_exact Normalized target URL without size-suffix collapsing. * @param bool $needle_has_sizes Whether the target itself carries a size suffix. * @param string $needle_query Raw query string of the target URL. * @return bool|null True/false on success, null on failure (fallback). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::buffer_has_image_preload_with_tag_processor}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionbuffer_has_image_preload_with_tag_processor(string,string,string,bool,string):?bool{return->lcp_preload()->buffer_has_image_preload_with_tag_processor(,,,,);}/** * Get the raw query string of a URL for preload-dedup comparison. * * `normalize_image_url()` deliberately drops the query string for LCP * matching, but preload hints are per-resource: `img.jpg?v=1` and * `img.jpg?v=2` are distinct. Fail-open: any parse failure returns an * empty string. * * @since 2.0.0 * * @param string $url The URL to inspect. * @return string The query string without the leading `?`, or empty. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_url_query}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_url_query(string):string{return->lcp_preload()->get_url_query();}/** * Whether the matched image tag references the given LCP URL. * * Checks src, data-src (JS-lazy placeholder), and srcset attributes. Both * sides are normalized (scheme-relative/relative URLs resolved against * home_url(), query strings and WordPress size suffixes stripped) so that * absolute-vs-relative matches work and derived assets cannot false-positive. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor $tags The tag processor matched on an <img>. * @param string $lcp_url The detected LCP image URL. * @return bool True if the image references the LCP URL. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::tag_matches_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functiontag_matches_lcp_url(,string):bool{return->lcp_preload()->tag_matches_lcp_url(,);}/** * Normalize an image URL for LCP matching. * * Resolves protocol-relative and root-relative URLs against home_url(), * drops the scheme and any query string, and strips WordPress generated * size suffixes (-NNNxNNN, -scaled, -eNNN) so derived assets are treated * as the same image as their full-size original. Pass * `$strip_size_suffix = false` to keep the suffix (used by preload * dedup, which only collapses size variants when the requested URL * itself carries one). * * @since 2.0.0 * * @param string $url The raw URL to normalize. * @param bool $strip_size_suffix Whether to strip WordPress size suffixes. Default true. * @return string Normalized host + path, or an empty string when unparseable. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::normalize_image_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionnormalize_image_url(string,bool=true):string{return->lcp_preload()->normalize_image_url(,);}/** * Static normalization behind normalize_image_url(). * * The body touches no instance state (only Util helpers and * wp_parse_url()), so it lives here statically for the shared * preload-dedup key builder. Kept private: external callers use * has_emitted_preload()/mark_preload_emitted(). * * @since 2.2.0 * * @param string $url The image URL to normalize. * @param bool $strip_size_suffix Whether to strip WP size suffixes. * @return string Normalized host + path, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::normalize_image_url_static}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */staticfunctionnormalize_image_url_static(string,bool=true):string{returnLcp_Preload::normalize_image_url_static(,);}/** * Build the dedup key for a preload item (normalized URL + query + media). * * `normalize_image_url()` deliberately drops the scheme and query * string for LCP matching, but `img.jpg?v=1` and `img.jpg?v=2` are * distinct preload resources, so the raw query string is re-attached * here: versioned duplicates each emit their own hint instead of * collapsing to one. The normalized base also strips WordPress size * suffixes, so responsive variants of the same image * (`hero-1024x768.jpg`) intentionally collapse to a single preload * hint alongside the full-size original (`hero.jpg`). Fail-open: any * parse failure falls back to the normalized URL + media key. * * @since 2.0.0 * @since 2.2.0 Delegates to build_preload_dedup_key() so the shared * cross-emitter helpers use the identical key space. * * @param string $url The raw preload URL. * @param string $media The preload media attribute. * @return string The dedup key. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_preload_dedup_key}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_preload_dedup_key(string,string):string{return->lcp_preload()->get_preload_dedup_key(,);}/** * Extract the first CSS background-image hero URL from an HTML buffer. * * Scans inline style attributes first (including the background * shorthand) for the first url() candidate, then falls back to * `<style>` blocks so stylesheet-defined heroes are also detected * (issue #1312). Skips data:, blob:, and javascript: URIs and elements * already deferred for lazy backgrounds (data-wppo-bg). Relative URLs * are resolved against the home URL so they compare against the LCP * URL. Any scan failure returns an empty string (fail-open to * heuristic). * * @since 2.0.0 * @since 2.2.0 Adds `<style>`-block fallback for stylesheet heroes. * @since 2.2.0 Adds a pre-6.2 regex fallback for inline `style=\"\"` * heroes when the HTML API is unavailable. * * @param string $buffer The HTML buffer. * @return string The hero background image URL, or empty string. */functionget_css_hero_url_from_buffer(string):string{if(\'\'===||false===stripos(,\'background\')){return\'\';}if(!->is_html_api_available()&&false===strpos(,\'style=\')&&false===stripos(,\'<style\')){return\'\';}try{if(->is_html_api_available()){=new\\WP_HTML_Tag_Processor();while(->next_tag()){if(null!==->get_attribute(\'data-wppo-bg\')){continue;}=->get_attribute(\'style\');if(!is_string()||\'\'===||false===stripos(,\'background\')){continue;}=\'\';if(preg_match(\'#background(?:-image)?\\s*:[^;]*?url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){=trim([1]);}if(\'\'===||0===stripos(,\'data:\')||0===stripos(,\'blob:\')||0===stripos(,\'javascript:\')){continue;}if(0===strpos(,\'//\')){=\'https:\'.;}elseif(0===strpos(,\'/\')||false===strpos(,\'://\')){=Util::cached_home_url().\'/\'.ltrim(,\'/\');}return;}}if(!->is_html_api_available()&&false!==strpos(,\'style=\')){=array();if(preg_match_all(\'#<[^>]+\\bstyle\\s*=\\s*([\"\\\'])(.*?)\\1[^>]*>#is\',,)&&isset([0])&&isset([2])){foreach([0]as=>){if(false!==stripos((string),\'data-wppo-bg\')){continue;}[]=[2][];}}foreach(as){if(!is_string()||\'\'===||false===stripos(,\'background\')){continue;}=\'\';if(preg_match(\'#background(?:-image)?\\s*:[^;]*?url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){=trim([1]);}if(\'\'===||0===stripos(,\'data:\')||0===stripos(,\'blob:\')||0===stripos(,\'javascript:\')){continue;}if(0===strpos(,\'//\')){=\'https:\'.;}elseif(0===strpos(,\'/\')||false===strpos(,\'://\')){=Util::cached_home_url().\'/\'.ltrim(,\'/\');}return;}}if(function_exists(\'wp_parse_url\')&&false!==stripos(,\'<style\')){=array();if(preg_match_all(\'#<style\\b[^>]*>(.*?)</style>#is\',,)&&isset([1])){=[1];}foreach(as){if(!is_string()||\'\'===||false===stripos(,\'background\')){continue;}if(1!==preg_match(\'#background(?:-image)?\\s*:[^;{]*?url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){continue;}=trim([1]);if(\'\'===||0===stripos(,\'data:\')||0===stripos(,\'blob:\')||0===stripos(,\'javascript:\')){continue;}if(0===strpos(,\'//\')){=\'https:\'.;}elseif(0===strpos(,\'/\')||false===strpos(,\'://\')){=Util::cached_home_url().\'/\'.ltrim(,\'/\');}return;}}}catch(\\Throwable){return\'\';}return\'\';}/** * Whether any img element in the buffer references the given LCP URL. * * Used to choose between the img preload path and the CSS-hero * preload path so exactly one preload link is ever emitted. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param string $lcp_url The detected LCP image URL. * @return bool True when an img matches the LCP URL. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::buffer_has_matching_img}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionbuffer_has_matching_img(string,string):bool{return->lcp_preload()->buffer_has_matching_img(,);}/** * Inject exactly one CSS-hero preload link into the buffer head. * * When the resolved LCP target has no matching img in the buffer but * matches the first CSS background hero (inline `style=\"\"` or * `<style>`-block stylesheet hero, plus the server-side computed * `wppo_computed_css_hero_url` context), a single preload link (as * image with fetchpriority high, eager) is injected before the head * close. Emits nothing when an img hero matches (covered by the img * preload path), when the hero is unrelated to the LCP target, when * the URL is neither same-origin nor on the configured CDN, or when * the single hero slot was already claimed by any emitter * (`preload_images()`, the img companion, or a prior call). Manual * lists stay authoritative: automation only fills the gap. * * @since 2.0.0 * @since 2.2.0 Accepts a pre-resolved LCP URL so buffer passes share one * unified target instead of re-resolving stored data per pass. * @since 2.2.0 Uses the centralised hero slot, the same-origin/CDN * allowlist, stylesheet-block heroes, and the computed-URL filter. * * @param string $buffer The HTML buffer. * @param string|null $lcp_url Optional pre-resolved LCP URL. When null the * URL is resolved via resolve_auto_lcp_url() * (same-origin guarded OD/stored/heuristic * chain), matching every other emission path. * @return string The buffer with at most one added preload link. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::maybe_inject_css_hero_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionmaybe_inject_css_hero_preload(string,?string=null):string{return->lcp_preload()->maybe_inject_css_hero_preload(,);}/** * Transforms <picture>, <img>, and <iframe> elements in the provided HTML to enable lazy loading and delayed loading based on the image_optimisation options. * * Applies exclusions derived from the options (including preload-selected images and the first N images specified by `excludeFirstImages`) and rewrites matched tags to use data-* attributes and lazy classes when appropriate. * YouTube embed iframes are replaced with lightweight video placeholders when the feature is enabled. * * @since 1.0.0 * * @param string $buffer The HTML buffer to process. * @return string The modified HTML buffer with lazy-load and delay-load attributes applied. */functionadd_delay_load_img(){=->options[\'image_optimisation\']??array();=->get_effective_exclude_first_images_count();=array();=!empty([\'enableVideoPlaceholder\']);=!empty([\'lazyLoadVideos\']);if(&&){=preg_replace_callback(\'#<iframe\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>\\s*</iframe>#is\',function(){=->get_youtube_video_id([2]);if(){return->generate_video_placeholder([0],[2],);}return[0];},);}=array();=->get_noscript_namespace();=preg_replace_callback(\'#<noscript>.*?</noscript>#is\',function()use(&,){=\'<!--WPPO_NOSCRIPT_\'..\'_\'.count().\'-->\';[]=->sanitize_comment_images_in_buffer([0]);return;},);if(null!==){=;}if(is_string()&&\'\'!==){=->sanitize_comment_images_in_buffer();}if(!empty([\'lazyLoadImages\'])){=->exclude_lazy_imgs;=->get_preload_images_urls();=array_unique(array_merge(,));=\'\';if(class_exists(\'PerformanceOptimise\\Inc\\OD_Bridge\')){try{if(\\PerformanceOptimise\\Inc\\OD_Bridge::is_enabled()){=\\PerformanceOptimise\\Inc\\OD_Bridge::get_lcp_url();if(\'\'!==){[]=;=Util::normalize_url();if(\'\'!==&&!in_array(,,true)){[]=;}=array_unique();}}}catch(\\Throwable){if(defined(\'WP_DEBUG\')&&WP_DEBUG){error_log(\'WPPO Image optimisation OD error: \'.str_replace(ABSPATH,\'\',->getMessage()));}}}=\'\';=(!isset([\'lcpHeroPreload\'])||!empty([\'lcpHeroPreload\']))&&(!empty([\'prioritizeLCPImages\'])||!empty([\'autoPreloadLCP\']));if(){try{=->get_current_lcp_url();if(\'\'!==&&!in_array(,,true)){[]=;=array_unique();}if(\'\'===){=->get_first_image_src_in_buffer();if(\'\'!==&&!in_array(,,true)){[]=;=array_unique();}}}catch(\\Throwable){do_action(\'wppo_debug_log\',\'WPPO hero exclusion failed: \'.->getMessage(),array(\'exception\'=>));}}=\'\';try{=->get_lazy_lcp_exclusion_url(,);if(\'\'!==){if(!in_array(,,true)){[]=;}=Util::normalize_url();if(\'\'!==&&!in_array(,,true)){[]=;}=array_unique();}}catch(\\Throwable){do_action(\'wppo_debug_log\',\'WPPO LCP candidate exclusion failed: \'.->getMessage(),array(\'exception\'=>));}=array();try{=self::get_direct_preload_normalized_urls();}catch(\\Throwable){unset();=array();}=0;=!empty([\'lazyLoadNative\']);=\'none\'!==([\'placeholderType\']??\'none\');if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();=->is_comment_hardening_enabled();while(->next_tag()){=->get_tag();if(&&is_string()&&->is_hardened_tag()){->sanitize_tag_attributes_processor();}if(\'IMG\'===||\'IMAGE\'===){=->get_attribute(\'src\');=->get_attribute(\'data-src\');if((null===||\'\'===)&&(null===||\'\'===)){continue;}++;if(>=){=(is_string()&&\'\'!==)?:;if(is_string()&&\'\'!==){[]=;}}=false;=(is_string()&&\'\'!==)?:(string);if(\'\'!==||\'\'!==||array()!==){try{=Util::normalize_url();if((\'\'!==&&===)||(\'\'!==&&===)||(\'\'!==&&in_array(,,true))){=true;}}catch(\\Throwable){unset();}}if(!){foreach(as){if(\'\'!==&&\'\'!==&&false!==strpos(,)){=true;break;}}}try{=->get_attribute(\'fetchpriority\');if(is_string()&&\'high\'===strtolower(trim())){=true;}}catch(\\Throwable){unset();}if(){try{=->get_attribute(\'loading\');if(is_string()&&\'lazy\'===strtolower(trim())){->set_attribute(\'loading\',\'eager\');}}catch(\\Throwable){unset();}->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'high\',\'decoding\'=>\'sync\',),false);->maybe_autofill_alt_processor(,);continue;}if(null!==->get_attribute(\'data-src\')){continue;}=htmlspecialchars_decode(,ENT_QUOTES);if(preg_match(\'#^data:image/#i\',)){->maybe_autofill_alt_processor(,);continue;}if(||\'lazy\'===->get_attribute(\'loading\')){if(null===->get_attribute(\'loading\')){=true;if(function_exists(\'wp_get_loading_optimization_attributes\')){=array();=->get_attribute(\'src\');if(null!==){[\'src\']=;}=->get_attribute(\'width\');if(null!==){[\'width\']=(int);}=->get_attribute(\'height\');if(null!==){[\'height\']=(int);}=->merge_core_loading_attributes(,\'performance_optimisation_delay_load\');if(!isset([\'loading\'])){=false;}}if(){->set_attribute(\'loading\',\'lazy\');}}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');}if(null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'low\',\'decoding\'=>\'async\',));if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}if(){=->get_native_lazy_placeholder_attrs(,,,);foreach(as=>){if(null===->get_attribute()){->set_attribute(->normalize_data_attribute_name(),);}}}}else{if(function_exists(\'wp_get_loading_optimization_attributes\')&&null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes();if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}elseif(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}->set_attribute(\'data-src\',);->remove_attribute(\'src\');=->get_attribute(\'srcset\');if(){->set_attribute(\'data-srcset\',);->remove_attribute(\'srcset\');}=->get_attribute(\'sizes\');if(){->set_attribute(\'data-sizes\',->prepare_auto_sizes_value(,));->remove_attribute(\'sizes\');}}}elseif(\'IFRAME\'===){=->get_attribute(\'src\');if(null===){continue;}=false;foreach(as){if(false!==strpos(,)){=true;break;}}if(){continue;}=apply_filters(\'wppo_lazyload_iframe_allowed\',true,,\'\');if(!){continue;}try{=->get_attribute(\'fetchpriority\');if(is_string()&&\'high\'===strtolower(trim())){=->get_attribute(\'loading\');if(is_string()&&\'lazy\'===strtolower(trim())){->set_attribute(\'loading\',\'eager\');}elseif(null===){->set_attribute(\'loading\',\'eager\');}continue;}}catch(\\Throwable){unset();}if(){if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'lazy\');}continue;}->set_attribute(\'data-src\',);->remove_attribute(\'src\');->add_class(\'wppo-lazyload\');}}=->get_updated_html();=->post_process_placeholders(,);=->post_process_img_dimensions();=->post_process_auto_sizes();if(->should_use_html_processor()){=->process_picture_blocks_processor(,,,);}else{=->process_picture_blocks_regex(,,,);}}else{=preg_replace_callback(\'#<picture\\b[^>]*>.*?</picture>|<img\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>|<iframe\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#is\',function()use(&,,&){if(isset([4])){return->process_iframe_tag([0],[4],);}if(!isset([2])){=\'\';if(preg_match(\'#<img\\b[^>]*?src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',[0],)){=[1];}++;if(\'\'!==&&>=){[]=;}return->process_picture_tag(,[0],,);}++;if(>=){[]=[2];}return->process_picture_tag(,[0],[2],);},);if(null!==){=->post_process_img_dimensions();=->post_process_auto_sizes();}}}elseif(->is_auto_alt_enabled()){=->autofill_alt_in_buffer();}=->restore_noscript_tokens(,);return;}/** * Autofill missing `alt` attributes across a full HTML buffer. * * Standalone pass used when lazy-loading is disabled but * `autoAltText` is enabled. Uses `WP_HTML_Tag_Processor` when * available, otherwise a regex fallback. Fail-open: returns the * buffer unchanged when disabled or on any processing failure. * * @since 2.0.0 * * @param string $buffer The HTML buffer to process. * @return string The buffer with missing `alt` attributes filled. */functionautofill_alt_in_buffer(string):string{if(!->is_auto_alt_enabled()){return;}try{if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');if(null===){=->get_attribute(\'data-src\');}if(null===||\'\'===){continue;}->maybe_autofill_alt_processor(,(string));}=->get_updated_html();if(is_string()){return;}return;}=preg_replace_callback(\'#<img\\b[^>]*>#i\',function(){=[0];=\'\';if(preg_match(\'#(?<![\\w-])src\\s*=\\s*(?:([\"\\\'])(.*?)\\1|([^\\s>]+))#is\',,)){=(isset([3])&&\'\'!==[3])?[3]:([2]??\'\');=htmlspecialchars_decode(,ENT_QUOTES);}if(\'\'===){return;}return->maybe_autofill_alt_regex(,);},);returnis_string()?:;}catch(\\Throwable){return;}}/** * Retrieves URLs of images to preload for lazy-load exclusion. * * @since 1.0.0 * @return array List of preload image URLs. */functionget_preload_images_urls():array{=->get_all_preload_data();=array_unique(array_column(,\'url\'));try{if(array()!==self::){=array_unique(array_merge(,array_keys(self::),self::get_direct_preload_normalized_urls()));}}catch(\\Throwable){unset();}return;}/** * Generates a base64-encoded SVG image with the given width and height. * * @since 1.0.0 * * @param string $img_attributes The image\'s attributes (including width and height). * @param string $color Optional hex fill color. Default \'#cfd4db\'. * @return string The base64-encoded SVG. */functiongenerate_svg_base64(,=\'#cfd4db\'){preg_match(\'/\\bwidth=[\"\\\']?(\\d+)[\"\\\']?/i\',,);preg_match(\'/\\bheight=[\"\\\']?(\\d+)[\"\\\']?/i\',,);=isset([1])?min(absint([1]),self::SVG_PLACEHOLDER_MAX_DIMENSION):100;=isset([1])?min(absint([1]),self::SVG_PLACEHOLDER_MAX_DIMENSION):100;=\'<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"\'..\'\" height=\"\'..\'\" viewBox=\"0 0 \'..\' \'..\'\"><rect width=\"100%\" height=\"100%\" fill=\"\'.esc_attr().\'\" /></svg>\';return\'data:image/svg+xml;base64,\'.base64_encode();}/** * Get the current placeholder type. * * @since 2.0.0 * * @return string One of \'none\', \'svg\', \'dominant_color\', \'lqip\'. */functionget_placeholder_type():string{=->options[\'image_optimisation\'][\'placeholderType\']??\'none\';=array(\'none\',\'svg\',\'dominant_color\',\'lqip\');returnin_array(,,true)?:\'none\';}/** * Get the appropriate placeholder src and extra attributes for a lazy-loaded image. * * Looks up stored placeholder data (dominant color, LQIP) from Img_Converter\'s * image info by resolving the data-src URL to a local path. * * @since 2.0.0 * * @param string $img_tag The <img> tag HTML. * @param string $data_src The data-src URL of the image. * @return array{src: string, attrs: array<string, string>} Placeholder src and extra attributes. */functionget_placeholder_src_for_image(string,string):array{=array(\'src\'=>\'\',\'attrs\'=>array(),);=->get_placeholder_type();if(\'none\'===){return;}=\'\';if(!isset(self::[])){=Util::get_local_path();if(!empty()){self::[]=str_replace(wp_normalize_path(ABSPATH),\'\',wp_normalize_path());}else{self::[]=\'\';}}=self::[];if(null===self::){self::=Img_Converter::get_placeholder_info();}=self::;if(\'svg\'===){[\'src\']=->generate_svg_base64();return;}if(\'dominant_color\'===){=[\'dominant_color\'][]??\'\';if(!empty()&&preg_match(\'/^#[a-f0-9]{6}$/i\',)){[\'src\']=\'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%221%22%20height%3D%221%22%2F%3E\';[\'attrs\'][\'data-wppo-dominant-color\']=;}else{[\'src\']=->generate_svg_base64();}return;}if(\'lqip\'===){=[\'lqip\'][]??\'\';if(!empty()){[\'src\']=;[\'attrs\'][\'data-wppo-lqip\']=\'1\';}else{[\'src\']=->generate_svg_base64();}return;}return;}/** * Whether a URL is the LCP hero image (explicit blur-exclusion check). * * Centralizes the hero matching used by add_delay_load_img(): substring * membership in the never-lazy exclusion list plus normalized-URL * equality against the OD and candidate LCP URLs (covers http/https * and WordPress size-suffix variants). Used to keep the LCP hero out * of LQIP blur even if it ever reaches the placeholder path. * * @since 2.2.0 * * @param string $url The image URL to test. * @param string[] $exclude_imgs The never-lazy exclusion list. * @param string $od_lcp_normalized Normalized OD LCP URL (or \'\'). * @param string $candidate_normalized Normalized candidate LCP URL (or \'\'). * @return bool True when the URL is the LCP hero. */functionis_lcp_hero_url(string,array,string=\'\',string=\'\'):bool{if(\'\'===){returnfalse;}=array();try{=self::get_direct_preload_normalized_urls();}catch(\\Throwable){unset();}if(\'\'!==||\'\'!==||array()!==){try{=Util::normalize_url();if((\'\'!==&&===)||(\'\'!==&&===)){returntrue;}if(\'\'!==&&in_array(,,true)){returntrue;}}catch(\\Throwable){unset();}}foreach(as){if(\'\'!==&&false!==strpos(,)){returntrue;}}returnfalse;}/** * Whether the local LQIP placeholder pipeline is enabled. * * Shares the `wppo_smart_pipeline_enabled` kill-switch filter with the * converter\'s size-compare path (issue #1158) so one filter disables * both features, and defaults from the * `image_optimisation.discardOversizedSibling` setting (like * `Img_Converter::is_smart_compress_enabled()`) so one toggle * disables both. Placeholders are server-side only (inline data-URI / * dominant-color attributes) — zero external HTTP either way. * * @since 2.2.0 * * @return bool True when LQIP placeholder emission is enabled. */functionis_local_lqip_pipeline_enabled():bool{=(bool)(->options[\'image_optimisation\'][\'discardOversizedSibling\']??true);if(function_exists(\'apply_filters\')&&function_exists(\'has_filter\')&&has_filter(\'wppo_smart_pipeline_enabled\')){/** * Filter the size-compare smart-compress + local LQIP pipeline. * * @since 2.2.0 * @param bool $enabled Whether the pipeline is enabled. */return(bool)apply_filters(\'wppo_smart_pipeline_enabled\',);}return;}/** * Placeholder attributes for a native-lazy (`loading=\"lazy\"`) image. * * The JS-lazy path swaps `src` for a placeholder via * post_process_placeholders(); native-lazy keeps the real `src` (the * browser defers it), so only the extra attributes are emitted: * `data-wppo-dominant-color` (background wash) and `data-wppo-lqip` * (blur hook consumed by lazyload.js). The LCP hero is explicitly * excluded from blur; data: URIs are never touched. Fail-open: * returns an empty array on any failure or when disabled. * * @since 2.2.0 * * @param string $src_url The image src URL. * @param string[] $exclude_imgs The never-lazy exclusion list. * @param string $od_lcp_normalized Normalized OD LCP URL (or \'\'). * @param string $candidate_normalized Normalized candidate LCP URL (or \'\'). * @return array<string, string> Extra attributes (empty when none apply). */functionget_native_lazy_placeholder_attrs(string,array,string=\'\',string=\'\'):array{try{if(!->is_local_lqip_pipeline_enabled()){returnarray();}if(\'none\'===->get_placeholder_type()){returnarray();}if(1===preg_match(\'#^data:image/#i\',htmlspecialchars_decode(,ENT_QUOTES))){returnarray();}if(->is_lcp_hero_url(,,,)){returnarray();}=->get_placeholder_src_for_image(\'<img>\',);returnis_array([\'attrs\']??null)?[\'attrs\']:array();}catch(\\Throwable){unset();returnarray();}}/** * Defer inline CSS background-image URLs until the element is near the viewport. * * Moves the `background-image` declaration into a `data-wppo-bg` attribute and * tags the element with the `wppo-lazy-bg` class so the frontend runtime can * restore it on intersection. The first N backgrounds (hero heuristics) and * data: URIs are left untouched. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @return string The processed buffer. */functionadd_delay_load_backgrounds(string):string{=->options[\'image_optimisation\']??array();if(empty([\'lazyLoadBackgroundImages\'])){return;}if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}=->get_effective_exclude_first_images_count();=0;=\'\';if(!empty([\'cssHeroPreload\'])){try{=->get_current_lcp_url();if(\'\'!==){=->normalize_image_url();}}catch(\\Throwable){=\'\';}}=new\\WP_HTML_Tag_Processor();while(->next_tag()){=->get_attribute(\'style\');if(null===||false===stripos(,\'background-image\')){continue;}if(null!==->get_attribute(\'data-wppo-bg\')){continue;}if(!preg_match(\'#background-image\\s*:\\s*([^;]+)#i\',,)){continue;}=trim([1]);if(\'\'===||false!==stripos(,\'data:\')){continue;}if(\'\'!==&&preg_match(\'#url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){=trim([1]);if(\'\'!==&&0!==stripos(,\'data:\')&&->normalize_image_url()===){continue;}}++;if(<=){continue;}=(string)->get_attribute(\'class\');if(false===strpos(,\'wppo-lazy-bg\')){->set_attribute(\'class\',trim(.\' wppo-lazy-bg\'));}->set_attribute(\'data-wppo-bg\',);=trim(preg_replace(\'#background-image\\s*:\\s*[^;]+;?#i\',\'\',));if(\'\'===){->remove_attribute(\'style\');}else{->set_attribute(\'style\',);}}return->get_updated_html();}/** * Rewrites <video> elements so their media sources are deferred and restored later for lazy loading. * * Skips videos whose attributes or inner markup match configured exclusion patterns. For processed videos: * - moves `src` attributes to `data-src` (on <video> and inner <source> tags), * - removes `autoplay` and sets `data-wppo-autoplay=\"1\"` when autoplay was present, * - ensures `preload=\"none\"` is set, * - adds the `wppo-lazy-video` class, * - defers `poster` to `data-poster` for core\'s animated-GIF companion videos (WP 7.1+, the `autoplay` + `loop` + `muted` + `playsinline` + `poster` signature), which the client restores on intersect. * * @since 2.0.0 * @since 2.0.0 Defer companion-video `poster` frames to `data-poster`. * * @param string $buffer HTML markup to process. * @return string The HTML with video elements rewritten for lazy loading. */functionlazy_load_videos(string):string{=->options[\'image_optimisation\']??array();if(empty([\'lazyLoadVideos\'])){return;}=->exclude_lazy_videos;if(class_exists(\'WP_HTML_Processor\')){=true;=preg_replace_callback(\'#<video\\b([^>]*)>(.*?)</video>#is\',function()use(,&){=[0];=[1];=[2];foreach(as){if(false!==strpos(,)||false!==strpos(,)){return;}}=new\\WP_HTML_Processor();if(null===->get_last_error()&&->next_tag(array(\'tag_name\'=>\'video\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}=null!==->get_attribute(\'autoplay\')&&null!==->get_attribute(\'loop\')&&null!==->get_attribute(\'muted\')&&null!==->get_attribute(\'playsinline\');=->get_attribute(\'poster\');if(null!==->get_attribute(\'autoplay\')){->remove_attribute(\'autoplay\');->set_attribute(\'data-wppo-autoplay\',\'1\');}if(&&!empty()){->set_attribute(\'data-poster\',);->remove_attribute(\'poster\');}->set_attribute(\'preload\',\'none\');->add_class(\'wppo-lazy-video\');while(->next_tag(array(\'tag_name\'=>\'source\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}}return->get_updated_html();}=false;return;},);if(){return;}=;}if(class_exists(\'WP_HTML_Tag_Processor\')){returnpreg_replace_callback(\'#<video\\b([^>]*)>(.*?)</video>#is\',function()use(){=[1];=[2];=[0];foreach(as){if(false!==strpos(,)||false!==strpos(,)){return;}}=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'video\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}=null!==->get_attribute(\'autoplay\')&&null!==->get_attribute(\'loop\')&&null!==->get_attribute(\'muted\')&&null!==->get_attribute(\'playsinline\');=->get_attribute(\'poster\');if(->get_attribute(\'autoplay\')!==null){->remove_attribute(\'autoplay\');->set_attribute(\'data-wppo-autoplay\',\'1\');}if(&&!empty()){->set_attribute(\'data-poster\',);->remove_attribute(\'poster\');}->set_attribute(\'preload\',\'none\');->add_class(\'wppo-lazy-video\');}while(->next_tag(array(\'tag_name\'=>\'source\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}}return->get_updated_html();},);}else{returnpreg_replace_callback(\'#<video\\b([^>]*)>(.*?)</video>#is\',function()use(){=[1];=[2];foreach(as){if(false!==strpos(,)||false!==strpos(,)){return[0];}}if(preg_match(\'#\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',)){=preg_replace(\'#\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-src=\"$1\"\',);}=preg_replace(\'#(<source\\b[^>]*)\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'$1 data-src=\"$2\"\',);=preg_match(\'#\\bautoplay\\b#i\',);=preg_replace(\'#\\bautoplay(=[\"\\\'][^\"\\\']*[\"\\\'])?#i\',\'\',);if(){.=\' data-wppo-autoplay=\"1\"\';}if(&&preg_match(\'#\\bloop\\b#i\',)&&preg_match(\'#\\bmuted\\b#i\',)&&preg_match(\'#\\bplaysinline\\b#i\',)&&preg_match(\'#\\bposter=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=preg_replace(\'#\\bposter=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-poster=\"$1\"\',);}if(false===stripos(,\'preload\')){.=\' preload=\"none\"\';}else{=preg_replace(\'#\\bpreload=[\"\\\'][^\"\\\']*[\"\\\']#i\',\'preload=\"none\"\',);}if(false===strpos(,\'wppo-lazy-video\')){if(preg_match(\'#\\bclass=[\"\\\']([^\"\\\']*)[\"\\\']#i\',,)){=str_replace([0],\'class=\"\'.[1].\' wppo-lazy-video\"\',);}else{.=\' class=\"wppo-lazy-video\"\';}}return\"<video ></video>\";},);}}/** * Lazily render below-fold containers via content-visibility. * * Opt-in, CSS-only progressive enhancement: tags below-fold candidate * containers (builder sections, footer widgets, comments) with * `content-visibility:auto` plus a precomputed `contain-intrinsic-size` * reserve so layout stays stable (no CLS) while the browser defers * render work until the node nears the viewport. Unsupported browsers * ignore the declarations. Any failure returns markup unmodified * (fail-open). * * Hero safety: the first matching section stays eager (positional), * and any later section carrying LCP-hero markers (`fetchpriority` * high, `data-lcp`/`data-hero`, hero/LCP class or id, or an LCP * image in its scope) is skipped too, so the LCP hero never gets * `content-visibility` regardless of which section carries it. * Skipping is fail-safe (unoptimised, never fatal). * * @since 2.0.0 * * @param string $buffer The HTML buffer to process. * @return string The modified HTML buffer. */functionlazy_render_elements(string):string{=->options[\'image_optimisation\']??array();if(empty([\'lazyRenderBelowFold\'])){return;}if(!is_string()||\'\'===){return;}if(function_exists(\'is_admin\')&&is_admin()){return;}if(function_exists(\'is_user_logged_in\')&&is_user_logged_in()){return;}if((function_exists(\'is_feed\')&&is_feed())||(function_exists(\'is_preview\')&&is_preview())||(function_exists(\'is_embed\')&&is_embed())||(function_exists(\'wp_doing_ajax\')&&wp_doing_ajax())){return;}if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}try{=!isset([\'lazyRenderExcludeBuilders\'])||!empty([\'lazyRenderExcludeBuilders\']);=array(\'elementor-section\',\'et_pb_section\');if(function_exists(\'apply_filters\')){=apply_filters(\'wppo_lazy_render_excluded_classes\',);if(is_array()){=array_values(array_filter(array_map(\'strval\',)));}}=\'auto 600px\';if(function_exists(\'apply_filters\')){=apply_filters(\'wppo_lazy_render_intrinsic_size\',);if(is_string()&&\'\'!==trim()){=trim();}}=array(\'elementor-section\',\'et_pb_section\',\'wp-block-group\',\'footer-widget\',\'widget-area\',\'comments-area\',\'comment-list\',);=new\\WP_HTML_Tag_Processor();=->get_lazy_render_lcp_windows();=-1;=false;while(->next_tag()){=strtoupper(->get_tag()??\'\');if(\'SECTION\'!==&&\'FOOTER\'!==&&\'ASIDE\'!==&&\'DIV\'!==){continue;}if(\'SECTION\'===||\'DIV\'===){++;}=(string)(->get_attribute(\'class\')??\'\');=(string)(->get_attribute(\'id\')??\'\');=strtolower(.\' \'.);=(\'FOOTER\'===||\'ASIDE\'===);=;if(!){foreach(as){if(false!==strpos(,)){=true;break;}}if(!&&\'comments\'===strtolower()){=true;}}if(!){continue;}if(){=preg_split(\'/\\s+/\',strtolower(),-1,PREG_SPLIT_NO_EMPTY);=is_array()?:array();foreach(as){=strtolower(trim((string)));if(\'\'!==&&in_array(,,true)){=false;break;}}if(!){continue;}}if(!&&false===strpos(,\'comment\')&&false===strpos(,\'footer\')){if(!){=true;continue;}if(->is_lazy_render_hero_tag(,)){continue;}if(isset([])&&[]){continue;}}=(string)(->get_attribute(\'style\')??\'\');if(\'\'!==&&preg_match(\'#content-visibility\\s*:#i\',)){continue;}=\'content-visibility:auto;contain-intrinsic-size:\'.;=\'\'===trim()?:rtrim(trim(),\';\').\';\'.;->set_attribute(\'style\',);}=->get_updated_html();returnis_string()&&\'\'!==?:;}catch(\\Throwable){if(function_exists(\'do_action\')){do_action(\'wppo_debug_log\',\'WPPO lazy render failed: \'.->getMessage(),array(\'exception\'=>));}return;}}/** * Whether a lazy-render candidate carries LCP-hero markers on its opening tag. * * Checks `fetchpriority=\"high\"`, `data-lcp`/`data-hero` attributes, * and hero/LCP tokens in the class/id string. Any match means the * node may hold above-fold LCP content and must stay eager. * Fail-safe: any failure returns false (caller falls back to the * positional skip and the scoped window check). * * @since 2.2.0 * * @param mixed $processor Tag processor positioned on the candidate tag. * @param string $lower_cls Lowercased class + id string of the candidate. * @return bool True when the opening tag itself marks an LCP hero. */functionis_lazy_render_hero_tag(,string):bool{try{if(is_object()&&method_exists(,\'get_attribute\')){=strtolower((string)(->get_attribute(\'fetchpriority\')??\'\'));if(\'high\'===){returntrue;}foreach(array(\'data-lcp\',\'data-hero\',\'data-od-hero\',\'data-wppo-lcp\')as){if(null!==->get_attribute()){returntrue;}}}if(false!==strpos(,\'hero\')||false!==strpos(,\'lcp\')){returntrue;}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Map section/div opening-tag order to scoped LCP presence. * * Walks the same `WP_HTML_Tag_Processor` tag stream the * `lazy_render_elements()` consumer loop walks (plain `next_tag()`, * which skips closers, comments, and RAWTEXT/RCDATA bodies such as * `<script>`/`<style>`/`<textarea>`/`<title>` contents), so the * sequence index cannot diverge from `$section_seq`: index `$i` * always describes the same opening tag in both enumerations. * Each window spans the tokens from one `<section>`/`<div>` * opener up to (but excluding) the next one, unbounded. A window * is marked when its opener or any tag inside it carries an LCP * marker (`fetchpriority=\"high\"`, `data-lcp`, `data-hero`, * `data-od-hero`, `data-wppo-lcp` as real attributes on real * tags). Lets a plain container wrapping an LCP image still count * as hero. Fail-open: any failure returns an empty map (no scoped * skips). Note the marker check is intentionally narrower than a * raw substring search: marker-looking text inside comments, * script bodies, or plain text no longer marks a window, which * only removes false-positive skips (missed optimisation), never * mistags a hero. * * @since 2.2.0 * * @param string $buffer The HTML buffer to scan. * @return bool[] LCP presence by section/div sequence index. */functionget_lazy_render_lcp_windows(string):array{try{if(\'\'===){returnarray();}=new\\WP_HTML_Tag_Processor();=array();=-1;while(->next_tag()){=strtoupper(->get_tag()??\'\');if(\'SECTION\'===||\'DIV\'===){++;[]=->processor_tag_has_lcp_marker();continue;}if(<0||!empty([])){continue;}if(->processor_tag_has_lcp_marker()){[]=true;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Whether the processor\'s current tag carries an LCP marker attribute. * * Checks `fetchpriority=\"high\"` and the `data-lcp`/`data-hero`/ * `data-od-hero`/`data-wppo-lcp` attributes. Only real attributes * on real tags match: marker-looking text inside comments, script * bodies, or attribute values does not count. * Fail-safe: any failure returns false. * * @since 2.2.0 * * @param mixed $processor Tag processor positioned on the current tag. * @return bool True when the current tag carries an LCP marker. */functionprocessor_tag_has_lcp_marker():bool{try{if(!is_object()||!method_exists(,\'get_attribute\')){returnfalse;}=strtolower((string)(->get_attribute(\'fetchpriority\')??\'\'));if(\'high\'===){returntrue;}foreach(array(\'data-lcp\',\'data-hero\',\'data-od-hero\',\'data-wppo-lcp\')as){if(null!==->get_attribute()){returntrue;}}returnfalse;}catch(\\Throwable){unset();returnfalse;}} $e): }Direct reference to LCP memo `$current_lcp_url` (ARCH-008 internal bridge). Parameter Type Default Description $elcp_state_current_lcp_url():?string{return->current_lcp_url;}/** * Direct reference to LCP memo `$current_lcp_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->current_lcp_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_current_lcp_url_key():?string{return->current_lcp_url_key;}/** * Direct reference to LCP memo `$lazy_lcp_exclusion_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->lazy_lcp_exclusion_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_lazy_lcp_exclusion_url():?string{return->lazy_lcp_exclusion_url;}/** * Direct reference to LCP memo `$lazy_lcp_exclusion_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->lazy_lcp_exclusion_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_lazy_lcp_exclusion_url_key():?string{return->lazy_lcp_exclusion_url_key;}/** * Direct reference to LCP memo `$fetchpriority_lcp_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->fetchpriority_lcp_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_fetchpriority_lcp_url():?string{return->fetchpriority_lcp_url;}/** * Direct reference to LCP memo `$fetchpriority_lcp_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->fetchpriority_lcp_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_fetchpriority_lcp_key():?string{return->fetchpriority_lcp_key;}/** * Direct reference to LCP memo `$manual_lcp_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->manual_lcp_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_manual_lcp_url():?string{return->manual_lcp_url;}/** * Direct reference to LCP memo `$manual_lcp_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->manual_lcp_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?int Reference to the live memo. */function&lcp_state_manual_lcp_url_key():?int{return->manual_lcp_url_key;}/** * Direct reference to LCP memo `$auto_lcp_disabled` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->auto_lcp_disabled`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?bool Reference to the live memo. */function&lcp_state_auto_lcp_disabled():?bool{return->auto_lcp_disabled;}/** * Direct reference to LCP memo `$auto_lcp_disabled_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->auto_lcp_disabled_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?int Reference to the live memo. */function&lcp_state_auto_lcp_disabled_key():?int{return->auto_lcp_disabled_key;}/** * Direct reference to LCP memo `$stable_signal_lcp_url` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->stable_signal_lcp_url`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_stable_signal_lcp_url():?string{return->stable_signal_lcp_url;}/** * Direct reference to LCP memo `$stable_signal_lcp_url_key` (ARCH-008 internal bridge). * * Gives {@see Lcp_Preload} the same live memo access the moved * bodies had via `$this->stable_signal_lcp_url_key`. Only `Lcp_Preload` calls * this (no other runtime or test caller exists). Do not call * from new code; the public visibility exists solely for the * extraction bridge. * * @internal * @since 2.4.0 * @return ?string Reference to the live memo. */function&lcp_state_stable_signal_lcp_url_key():?string{return->stable_signal_lcp_url_key;}/** * Lazy/media collaborator `normalize_url()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $url The URL to normalize. * @return string Normalized URL. */functionlcp_normalize_url(string):string{return->normalize_url();}/** * Lazy/media collaborator `unlazyload_first_images()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $buffer HTML buffer. * @param array $image_optimisation Image settings. * @return string Buffer with first images un-lazy-loaded. */functionlcp_unlazyload_first_images(string,array):string{return->unlazyload_first_images(,);}/** * Lazy/media collaborator `sanitize_loading_triple()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param array $attrs Loading attributes. * @return array Sanitized attributes. */functionlcp_sanitize_loading_triple(array):array{return->sanitize_loading_triple();}/** * Lazy/media collaborator `promote_eager_picture_sources()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $buffer HTML buffer. * @return string Buffer with eager picture sources promoted. */functionlcp_promote_eager_picture_sources(string):string{return->promote_eager_picture_sources();}/** * Lazy/media collaborator `remove_lazy_classes()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param mixed $tags Tag processor. * @return bool Whether any class was removed. */functionlcp_remove_lazy_classes():bool{return->remove_lazy_classes();}/** * Lazy/media collaborator `restore_js_lazy_placeholders()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param mixed $tags Tag processor. * @return bool Whether any placeholder was restored. */functionlcp_restore_js_lazy_placeholders():bool{return->restore_js_lazy_placeholders();}/** * Lazy/media collaborator `should_use_html_processor()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @return bool True when the HTML processor path may be used. */functionlcp_should_use_html_processor():bool{return->should_use_html_processor();}/** * Lazy/media collaborator `cached_file_exists()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $path Absolute path. * @return bool Whether the file exists. */functionlcp_cached_file_exists(string):bool{return->cached_file_exists();}/** * Lazy/media collaborator `get_cached_image_size()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $local_path Absolute path. * @return array|false Image size or false. */functionlcp_get_cached_image_size(string):array|false{return->get_cached_image_size();}/** * Lazy/media collaborator `is_dimension_lookup_allowed()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $url Image URL. * @return bool Whether dimension lookup is allowed. */functionlcp_is_dimension_lookup_allowed(string):bool{return->is_dimension_lookup_allowed();}/** * Lazy/media collaborator `get_css_hero_url_from_buffer()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $buffer HTML buffer. * @return string CSS hero URL or empty string. */functionlcp_get_css_hero_url_from_buffer(string):string{return->get_css_hero_url_from_buffer();}/** * Lazy/media collaborator `split_srcset_candidates()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $raw Raw srcset attribute. * @return array Candidate strings. */functionlcp_split_srcset_candidates(string):array{return->split_srcset_candidates();}/** * Lazy/media collaborator `split_srcset_item()` for the LCP service (ARCH-008 internal bridge). * * The moved LCP bodies called this private helper via `$this`; * the logic stays here (lazy/media ownership) and is reached * through this bridge. Only `Lcp_Preload` calls this (no other * runtime or test caller exists). Do not call from new code; the * public visibility exists solely for the extraction bridge. * * @internal * @since 2.4.0 @param string $item Single srcset candidate. * @return array URL plus descriptor parts. */functionlcp_split_srcset_item(string):array{return->split_srcset_item();}/** * Constructor. * * @since 1.0.0 * * @param array $options Configuration options for image optimization. */function__construct(){->options=;if(!isset(->options[\'image_optimisation\'][\'placeholderType\'])){if(isset(->options[\'image_optimisation\'][\'replacePlaceholderWithSVG\'])){->options[\'image_optimisation\'][\'placeholderType\']=(bool)->options[\'image_optimisation\'][\'replacePlaceholderWithSVG\']?\'svg\':\'none\';}else{->options[\'image_optimisation\'][\'placeholderType\']=\'none\';}}->exclude_convert_imgs=Util::process_urls(->options[\'image_optimisation\'][\'excludeConvertImages\']??array());->preload_front_page_urls=Util::process_urls(->options[\'image_optimisation\'][\'preloadFrontPageImagesUrls\']??array());->exclude_post_type_imgs=Util::process_urls(->options[\'image_optimisation\'][\'excludePostTypeImgUrl\']??array());->exclude_sizes=array_map(\'absint\',array_map(\'trim\',explode(\',\',(->options[\'image_optimisation\'][\'excludeSize\']??\'\'))));->exclude_lazy_imgs=Util::process_urls(->options[\'image_optimisation\'][\'excludeImages\']??array());->exclude_lazy_videos=Util::process_urls(->options[\'image_optimisation\'][\'excludeVideos\']??array());->setup_hooks();}/** * Sets up hooks for image optimization features. * * @since 1.0.0 */functionsetup_hooks(){if(!empty(->options[\'image_optimisation\'][\'convertImg\'])){=->get_img_converter();=\'none\'!==->get_format();=::core_handles_next_gen();if(||){add_filter(\'wp_generate_attachment_metadata\',array(,\'convert_image_to_next_gen_format\'),10,2);}add_filter(\'wp_get_attachment_image_src\',array(,\'maybe_serve_next_gen_image\'));}add_action(\'delete_attachment\',array(\'PerformanceOptimise\\Inc\\Img_Converter\',\'clean_placeholder_on_delete\'));add_filter(\'wp_get_attachment_image_attributes\',array(,\'wppo_add_fetchpriority\'),10,3);if(function_exists(\'wp_is_client_side_media_processing_enabled\')&&!empty(->options[\'image_optimisation\'][\'clientSideMimeTypeOverride\'])){add_filter(\'client_side_supported_mime_types\',array(,\'filter_client_side_supported_mime_types\'));}if(function_exists(\'wp_is_client_side_media_processing_enabled\')&&!empty(->options[\'image_optimisation\'][\'forceServerSideConversion\'])){add_filter(\'wp_client_side_media_processing_enabled\',\'__return_false\');}}/** * Replace the MIME types handled by WP 7.1+ client-side media processing. * * This filter is only registered when the override toggle is enabled. * The stored selection becomes the set of formats the in-browser Web * Worker should process, intersected with the formats core reports it * can support so an unsupported selection (e.g. HEIC/JXL on a build * without a wasm-vips decoder) can never shadow core\'s authoritative * list. A non-array stored value leaves core\'s default list untouched * (graceful degradation); an enabled override with an empty selection * returns an empty list, which disables browser-side processing * entirely (core supports empty list). Future decoders (HEIC Sequence, * JPEG XL) are additive via the same intersection — the UI surfaces * them but core\'s list gates availability. * * Trac #64876 proposes a public `client_side_supported_mime_types` * filter; until it lands the plugin keeps the intersection guard so an * unavailable decoder (e.g. HEIC/JXL without wasm-vips) cannot be added * additively. When the public filter lands, widen to additive * HEIC/JPEG-XL pass-through with documented HEIC/JPEG-XL path. * * Guarded by `function_exists(\'wp_is_client_side_media_processing_enabled\')` * for <7.1 (filter not registered there). Wasm gating: ~13 MB lazy-loaded * wasm-vips gated by Document-Isolation-Policy / SharedArrayBuffer. * * @since 2.0.0 * * @param string[] $supported_mime_types The MIME types core supports client-side. * @return string[] The filtered MIME types. */functionfilter_client_side_supported_mime_types(){=->options[\'image_optimisation\'][\'clientSideMimeTypes\']??array();if(!is_array()){return;}=array_map(\'sanitize_text_field\',);=array_filter();=array_values(array_unique());=array_intersect(,array_map(\'sanitize_text_field\',(array)));returnarray_values();}/** * Preloads images for optimization. * * Emits exactly one `<link rel=\"preload\" as=\"image\" * fetchpriority=\"high\">` per URL: `get_all_preload_data()` dedups by * normalized URL + query + media within one call (so the single * RUM-field → PageSpeed LCP candidate, manual meta, front-page and * post-type items collapse to one tag, while `?v=` variants stay * distinct), and the per-request `$preload_emitted` guard below skips * repeats across repeated `wp_head` invocations. * * No-duplicate note (issue #991): core 6.9 stamps `fetchpriority` on * the `<img>` node itself via `wp_get_loading_optimization_attributes()` * — a separate concern from this early `<link>` hint. The stamp is * never double-applied (see `prioritize_lcp_image()` and * `set_loading_optimization_attributes()`, which only fill gaps via * `function_exists()`-guarded core calls), so this hint and core\'s * node stamp coexist without fighting. * * @since 1.0.0 * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::preload_images}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionpreload_images(){return->lcp_preload()->preload_images();}/** * Get (and lazily mint) the per-request noscript token namespace. * * Uses cryptographically random hex via `random_bytes()` when available, * falling back to `wp_generate_password()` (sanitized to alphanumerics) * and finally to a `uniqid()`/`wp_rand()` token. Never fatals: any * failure degrades to a static fallback namespace (fail-open). * * @since 2.0.0 * @return string Non-empty namespace string. */functionget_noscript_namespace():string{if(\'\'!==->noscript_namespace){return->noscript_namespace;}->noscript_namespace=Util::mint_placeholder_namespace();return->noscript_namespace;}/** * Resolve a single noscript placeholder token against the allowlist. * * Strict restore discipline (CVE-2026-3220 shape): the token must be an * exact key of `$noscript_tokens`, carry this request\'s namespace * (constant-time comparison), and reference a bounds-checked index. Any * anomaly returns null so the caller emits the node unmodified * (fail-open). * * @since 2.0.0 * @param string $token The matched placeholder comment. * @param array $noscript_tokens The exact-token allowlist (token => HTML). * @return string|null Restored HTML, or null on anomaly. */functionresolve_noscript_token(string,array):?string{if(!isset([])||!is_string([])){returnnull;}if(1!==preg_match(\'/^<!--WPPO_NOSCRIPT_([A-Za-z0-9]+)_(\\d+)-->$/\',,)){returnnull;}=[1];=[2];=->noscript_namespace;if(\'\'===||\'\'===){returnnull;}if(strlen()!==strlen()){returnnull;}if(function_exists(\'hash_equals\')){if(!hash_equals(,)){returnnull;}}elseif(!==){returnnull;}if(!ctype_digit()){returnnull;}=(int);if(<0||>=count()){returnnull;}return[];}/** * Restore stashed `<noscript>` blocks via strict allowlist lookup. * * Unknown, foreign-namespace, or out-of-range tokens pass through * unmodified (fail-open) so attacker-controlled markup shaped like a * token stays inert. PCRE failure degrades to the unmodified buffer. * * @since 2.0.0 * @param string $buffer The HTML buffer containing tokens. * @param array $noscript_tokens The exact-token allowlist (token => HTML). * @return string Buffer with known tokens restored. */functionrestore_noscript_tokens(string,array):string{if(array()===){return;}=preg_replace_callback(\'/<!--WPPO_NOSCRIPT_[A-Za-z0-9_-]+_\\d+-->/\',function()use(){=->resolve_noscript_token([0],);if(null===){return[0];}return->sanitize_comment_images_in_buffer();},);returnnull!==?:;}/** * Whether a lazy `data-src` value is safe to rewrite with a placeholder. * * Fail-open ownership gate: hostile placeholder-shaped input (empty, * oversized, markup-bearing, or dangerous-scheme `data-src`) is not a * locally generated lazy node and must be emitted unmodified without * any placeholder rewrite. * * @since 2.0.0 * @param string $data_src The `data-src` URL of the image. * @return bool True when the node may receive a placeholder `src`. */functionis_valid_lazy_placeholder_candidate(string):bool{=trim();if(\'\'===){returnfalse;}if(strlen()>2048){returnfalse;}if(str_contains(,\'<\')||str_contains(,\'>\')){returnfalse;}=strtolower(ltrim());if(str_starts_with(,\'javascript:\')||str_starts_with(,\'vbscript:\')){returnfalse;}if(str_starts_with(,\'data:\')){return1===preg_match(\'#^data:image/(?:png|jpe?g|gif|webp|avif)[;,]#i\',);}returntrue;}/** * Post-processes the serialized buffer to inject placeholders into lazy-loaded images * that have data-src but no src attribute. Called after the WP_HTML_Tag_Processor pass. * * Duplication note (D-14): `post_process_placeholders`, `post_process_img_dimensions` * and `post_process_auto_sizes` intentionally scan the buffer in three separate * `preg_replace_callback` passes. Each stage mutates a distinct attribute set * (placeholder src, width/height, data-sizes=auto) via the shared * `get_placeholder_src_for_image()` helper and a per-request bounded LRU * (`IMG_SIZE_CACHE_LIMIT` / `FILE_EXISTS_CACHE_LIMIT`). Merging into a single * pass would conflate concerns and break the dimensions→auto-sizes ordering * dependency. The three-pass cost is linear and acceptable (see audit D-14). * * Anomaly gate: candidate nodes failing {@see is_valid_lazy_placeholder_candidate()} * are emitted unmodified without any lazy/placeholder rewrite (fail-open). * * @since 2.0.0 * * @param string $buffer The HTML buffer after WP_HTML_Tag_Processor serialization. * @param bool $enable_placeholder Whether placeholders are enabled. * @return string The modified buffer. */functionpost_process_placeholders(string,bool):string{if(!){return;}if(->should_use_html_processor()){=->post_process_placeholders_with_processor();if(null!==){return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){=->post_process_placeholders_with_tag_processor();if(null!==){return;}}=preg_replace_callback(\'#<img\\b[^>]*\\sdata-src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',function(){=[0];if(preg_match(\'#\\ssrc=#i\',)){return;}=[1];if(!->is_valid_lazy_placeholder_candidate()){return;}=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){=\'\';foreach([\'attrs\']as=>){.=\' \'.->normalize_data_attribute_name().\'=\"\'.esc_attr().\'\"\';}=preg_replace(\'#<img\\b#i\',\'<img src=\"\'.esc_attr([\'src\']).\'\"\'.,,1);returnnull!==?:;}return;},);returnnull!==?:;}/** * Processor-based placeholder injection using WP_HTML_Processor::serialize_token(). * * Mirrors the regex fallback byte-for-byte but uses token streaming so * nested <picture>, comments, SVG/mathML and malformed HTML are handled * without PCRE fragility. Falls back to regex on parse errors or when * WP_HTML_Processor is unavailable (WP <6.9 fallback). * * @since 2.0.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure (triggers regex fallback). */functionpost_process_placeholders_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){.=->serialize_token();continue;}=->get_tag();=->is_tag_closer();if(\'IMG\'===&&!){=->get_attribute(\'data-src\');=->get_attribute(\'src\');if(null!==&&null===){=->serialize_token();=(string);if(!->is_valid_lazy_placeholder_candidate()){.=;continue;}=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){->set_attribute(\'src\',[\'src\']);foreach([\'attrs\']as=>){->set_attribute(->normalize_data_attribute_name(),);}=->serialize_token();if(null===->get_attribute(\'src\')){=\'\';foreach([\'attrs\']as=>){.=\' \'.->normalize_data_attribute_name().\'=\"\'.esc_attr().\'\"\';}=preg_replace(\'#<img\\b#i\',\'<img src=\"\'.esc_attr([\'src\']).\'\"\'.,,1);.=null!==?:;continue;}.=;continue;}}}.=->serialize_token();}if(null!==->get_last_error()){returnnull;}return;}/** * Processor-based placeholder injection using WP_HTML_Tag_Processor (WP 6.2+). * * Middle tier between the WP 6.9+ `WP_HTML_Processor::serialize_token()` * fast path and the legacy regex fallback: single-pass `next_tag()` * traversal with `get_attribute()`/`set_attribute()` plus * `get_updated_html()`, so the WP 6.2-6.8 happy path never runs * `preg_replace` on `<img>` tags. Mirrors * `post_process_placeholders_with_processor()` exactly (placeholder * validity gate, extra data attrs). `data:` placeholder sources are * staged through a sentinel URL plus `str_replace()` because the Tag * Processor blocks `data:` URIs in `src`. Fail-open: returns null on * any failure so the caller falls through to the regex fallback. * * @since 2.2.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure (triggers regex fallback). */functionpost_process_placeholders_with_tag_processor(string):?string{if(!class_exists(\'WP_HTML_Tag_Processor\')){returnnull;}try{=new\\WP_HTML_Tag_Processor();=array();=0;while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-src\');=->get_attribute(\'src\');if(null===||null!==){continue;}=(string);if(!->is_valid_lazy_placeholder_candidate()){continue;}=->get_attribute(\'width\');=->get_attribute(\'height\');=\'<img\';if(is_string()||is_int()){.=\' width=\"\'.(string).\'\"\';}if(is_string()||is_int()){.=\' height=\"\'.(string).\'\"\';}.=\'>\';=->get_placeholder_src_for_image(,);if(empty([\'src\'])){continue;}=(string)[\'src\'];if(0===stripos(ltrim(),\'data:\')){=\'https://wppo.invalid/__wppo_ph_\'..\'__\';++;[]=;=;}->set_attribute(\'src\',);if(null===->get_attribute(\'src\')){continue;}foreach([\'attrs\']as=>){->set_attribute(->normalize_data_attribute_name(),);}}=->get_updated_html();if(!is_string()){returnnull;}if(!empty()){foreach(as=>){=function_exists(\'esc_attr\')?esc_attr():htmlspecialchars(,ENT_QUOTES,\'UTF-8\');=str_replace(\'\"\'..\'\"\',\'\"\'..\'\"\',);=str_replace(\"\'\"..\"\'\",\"\'\"..\"\'\",);}}return;}catch(\\Throwable){unset();returnnull;}}/** * Whether a dimension-lookup URL may touch the local filesystem. * * The regex/processor dimension tiers resolve `data-src ?? src` via * `Util::get_local_path()`, which maps any http(s) path onto ABSPATH * without checking the host. An external image whose path collides * with a local file would inherit the wrong dimensions, and every * dimension-less external image costs a wasted `file_exists` stat. * Relative URLs (empty host) are local by construction; absolute * URLs must be same-origin (home host) or a configured CDN host. * Fail-open to true when the verdict is unverifiable so markup is * never worse than the pre-guard behaviour. * * @since 2.3.0 * @param string $url The candidate image URL. * @return bool True when the filesystem lookup may run. */functionis_dimension_lookup_allowed(string):bool{try{=trim();if(\'\'===){returnfalse;}=strtolower(ltrim());if(str_starts_with(,\'data:\')||str_starts_with(,\'blob:\')||str_starts_with(,\'javascript:\')||str_starts_with(,\'vbscript:\')){returnfalse;}if(!function_exists(\'wp_parse_url\')){returntrue;}=wp_parse_url(,PHP_URL_HOST);if(!is_string()||\'\'===trim()){returntrue;}if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'is_same_site_host\')){try{if(Util::is_same_site_host()){returntrue;}}catch(\\Throwable){unset();}}if(->is_same_origin_preload_url()){returntrue;}if(->is_cdn_preload_url()){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Post-processes the serialized buffer to add missing width/height attributes to lazy-loaded images. * * Eager heroes excluded from lazy keep `src` (never `data-src`), so * the lookup prefers `data-src` and falls back to `src` — otherwise * a dimension-less hero keeps causing CLS (issue #1467). Fail-open: * unknown local paths leave the tag untouched. * * @since 2.0.0 * @since 2.3.0 Falls back to `src` when `data-src` is absent so eager heroes get stable dimensions. * * @param string $buffer The HTML buffer after WP_HTML_Tag_Processor serialization. * @return string The modified buffer. */functionpost_process_img_dimensions(string):string{if(->should_use_html_processor()){=->post_process_img_dimensions_with_processor();if(null!==){return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){=->post_process_img_dimensions_with_tag_processor();if(null!==){return;}}=preg_replace_callback(\'#<img\\b[^>]*>#i\',function(){=[0];=\'\';if(1===preg_match(\'#\\sdata-src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)&&\'\'!==trim([1])){=[1];}elseif(1===preg_match(\'#\\ssrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)&&\'\'!==trim([1])){=[1];}else{return;}=false;=false;if(1===preg_match(\'/\\bwidth\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+width\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(1===preg_match(\'/\\bheight\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+height\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(!||!){if(!->is_dimension_lookup_allowed()){return;}try{=Util::get_local_path();}catch(\\Throwable){unset();return;}if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){try{=->get_cached_image_size();}catch(\\Throwable){unset();return;}if(is_array()&&isset([0],[1])&&(int)[0]>0&&(int)[1]>0){if(!){=preg_replace(\'/<img\\b/i\',\'<img width=\"\'.(int)[0].\'\"\',,1);}if(!){=preg_replace(\'/<img\\b/i\',\'<img height=\"\'.(int)[1].\'\"\',,1);}}}}return;},);returnnull!==?:;}/** * Processor-based dimension injection using serialize_token(). * * @since 2.0.0 * @since 2.3.0 Falls back to `src` when `data-src` is absent so eager heroes get stable dimensions. * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_img_dimensions_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){.=->serialize_token();continue;}=->get_tag();=->is_tag_closer();if(\'IMG\'===&&!){=->get_attribute(\'data-src\');if(null===||\'\'===trim((string))){=->get_attribute(\'src\');}if(null!==&&\'\'!==trim((string))){=is_numeric(->get_attribute(\'width\'));=is_numeric(->get_attribute(\'height\'));if(!||!){if(!->is_dimension_lookup_allowed((string))){.=->serialize_token();continue;}try{=Util::get_local_path((string));}catch(\\Throwable){unset();.=->serialize_token();continue;}if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){try{=->get_cached_image_size();}catch(\\Throwable){unset();.=->serialize_token();continue;}if(is_array()&&isset([0],[1])&&(int)[0]>0&&(int)[1]>0){if(!){->set_attribute(\'width\',(string)(int)[0]);}if(!){->set_attribute(\'height\',(string)(int)[1]);}}}}}}.=->serialize_token();}if(null!==->get_last_error()){returnnull;}return;}/** * Processor-based dimension injection using WP_HTML_Tag_Processor (WP 6.2+). * * Middle tier between the WP 6.9+ serializer fast path and the legacy * regex fallback: single `next_tag()` pass over `<img>` with * `get_attribute()`/`set_attribute()` plus `get_updated_html()`. * Mirrors `post_process_img_dimensions_with_processor()` (cached file * existence + LRU size lookup). Fail-open: returns null so the caller * falls through to the regex fallback. * * @since 2.2.0 * @since 2.3.0 Falls back to `src` when `data-src` is absent so eager heroes get stable dimensions. * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_img_dimensions_with_tag_processor(string):?string{if(!class_exists(\'WP_HTML_Tag_Processor\')){returnnull;}try{=new\\WP_HTML_Tag_Processor();while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-src\');if(null===||\'\'===trim((string))){=->get_attribute(\'src\');}if(null===||\'\'===trim((string))){continue;}=is_numeric(->get_attribute(\'width\'));=is_numeric(->get_attribute(\'height\'));if(&&){continue;}if(!->is_dimension_lookup_allowed((string))){continue;}try{=Util::get_local_path((string));}catch(\\Throwable){unset();continue;}if(empty()||!->cached_file_exists()||!is_readable()||!is_file()){continue;}try{=->get_cached_image_size();}catch(\\Throwable){unset();continue;}if(!is_array()||!isset([0],[1])||(int)[0]<=0||(int)[1]<=0){continue;}if(!){->set_attribute(\'width\',(string)(int)[0]);}if(!){->set_attribute(\'height\',(string)(int)[1]);}}=->get_updated_html();returnis_string()?:null;}catch(\\Throwable){unset();returnnull;}}/** * Post-processes lazy-loaded images and <picture> sources to enable auto-sizes (WP 6.7+). * * Runs after post_process_img_dimensions() so width/height are guaranteed to be * present. For each lazy tag carrying a srcset the stored `data-sizes` value is * upgraded so supporting browsers can derive the source size from the rendered layout: * - values that already include `auto` are left untouched, * - static values get `auto, ` prepended as a progressive enhancement, * - images without any `data-sizes` (but with srcset + width + height) get a bare `auto`. * * @since 1.8.0 * * @param string $buffer The HTML buffer. * @return string The modified buffer. */functionpost_process_auto_sizes(string):string{if(!Util::is_auto_sizes_available()){return;}if(->should_use_html_processor()){=->post_process_auto_sizes_with_processor();if(null!==){return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){=->post_process_auto_sizes_with_tag_processor();if(null!==){return;}}=preg_replace_callback(\'#<(img|source)\\b[^>]*\\s(?:data-src|data-srcset)=[\"\\\'][^\"\\\']+[\"\\\'][^>]*>#i\',function(){=[0];=\'img\'===strtolower([1]);=(bool)preg_match(\'#\\b(?:data-)?srcset=[\"\\\']#i\',);if(!){return;}if(){=(bool)preg_match(\'/\\bwidth=[\"\\\']\\d+[\"\\\']/i\',);=(bool)preg_match(\'/\\bheight=[\"\\\']\\d+[\"\\\']/i\',);if(!||!){return;}}if(preg_match(\'#\\bdata-sizes=[\"\\\']([^\"\\\']*)[\"\\\']#i\',,)){=[1];if(->sizes_attribute_includes_auto()){return;}=\'auto, \'.;returnpreg_replace(\'#\\bdata-sizes=[\"\\\']([^\"\\\']*)[\"\\\']#i\',\'data-sizes=\"\'.esc_attr().\'\"\',,1);}if(!){return;}returnpreg_replace(\'/<img\\b/i\',\'<img data-sizes=\"auto\"\',,1);},);returnnull!==?:;}/** * Processor-based auto-sizes upgrade using serialize_token(). * * @since 2.0.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_auto_sizes_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){.=->serialize_token();continue;}=->get_tag();=->is_tag_closer();if((\'IMG\'===||\'SOURCE\'===)&&!){=null!==->get_attribute(\'data-src\')||null!==->get_attribute(\'data-srcset\');if(!){.=->serialize_token();continue;}=null!==->get_attribute(\'srcset\')||null!==->get_attribute(\'data-srcset\');if(!){.=->serialize_token();continue;}if(\'IMG\'===){=null!==->get_attribute(\'width\');=null!==->get_attribute(\'height\');if(!||!){.=->serialize_token();continue;}}=->get_attribute(\'data-sizes\');if(null!==){if(->sizes_attribute_includes_auto((string))){.=->serialize_token();continue;}->set_attribute(\'data-sizes\',\'auto, \'.(string));.=->serialize_token();continue;}if(\'SOURCE\'===){.=->serialize_token();continue;}->set_attribute(\'data-sizes\',\'auto\');}.=->serialize_token();}if(null!==->get_last_error()){returnnull;}return;}/** * Processor-based auto-sizes upgrade using WP_HTML_Tag_Processor (WP 6.2+). * * Middle tier between the WP 6.9+ serializer fast path and the legacy * regex fallback: one filtered `next_tag()` pass per tag name over * `<img>` and `<source>` with `get_attribute()`/`set_attribute()` * plus `get_updated_html()`. * Mirrors `post_process_auto_sizes_with_processor()` (lazy gate, * srcset presence, `<img>` width/height CLS gate, `data-sizes` auto * handling). Fail-open: returns null so the caller falls through to * the regex fallback. * * @since 2.2.0 * @param string $buffer The HTML buffer. * @return string|null Processed buffer or null on failure. */functionpost_process_auto_sizes_with_tag_processor(string):?string{if(!class_exists(\'WP_HTML_Tag_Processor\')){returnnull;}try{foreach(array(\'img\',\'source\')as){=new\\WP_HTML_Tag_Processor();while(->next_tag(array(\'tag_name\'=>))){->apply_auto_sizes_to_tag(,\'img\'===);}=->get_updated_html();if(!is_string()){returnnull;}=;}return;}catch(\\Throwable){unset();returnnull;}}/** * Apply the auto-sizes upgrade to the current Tag Processor tag. * * Shared per-tag step for the filtered `<img>`/`<source>` passes in * `post_process_auto_sizes_with_tag_processor()`: lazy gate, srcset * presence, `<img>` width/height CLS gate, then `data-sizes` auto * handling. Mirrors `post_process_auto_sizes_with_processor()` and the * regex fallback (which requires quoted-numeric dimensions, so empty, * boolean or non-numeric values count as missing here too). * * @since 2.2.0 * @param \\WP_HTML_Tag_Processor $tags The tag processor on an `<img>` or `<source>` tag. * @param bool $is_img Whether the current tag is an `<img>` (vs `<source>`). * @return void */functionapply_auto_sizes_to_tag(,bool):void{=null!==->get_attribute(\'data-src\')||null!==->get_attribute(\'data-srcset\');if(!){return;}=null!==->get_attribute(\'srcset\')||null!==->get_attribute(\'data-srcset\');if(!){return;}if(){=is_numeric(->get_attribute(\'width\'));=is_numeric(->get_attribute(\'height\'));if(!||!){return;}}=->get_attribute(\'data-sizes\');if(null!==){if(->sizes_attribute_includes_auto((string))){return;}->set_attribute(\'data-sizes\',\'auto, \'.(string));return;}if(!){return;}->set_attribute(\'data-sizes\',\'auto\');}/** * Whether the WP 6.9+ HTML API picture parser is available. * * Delegates to {@see Util::should_use_html_processor()} so every * processor-based rewrite shares one reflection guard for the public * `WP_HTML_Processor::serialize_token()` (WP 6.9). * * @since 2.0.0 * @return bool */functionshould_use_html_processor():bool{returnUtil::should_use_html_processor();}/** * Map a data attribute name via WP 6.9+ helpers when available. * * Guards `wp_html_custom_data_attribute_name()` so data-* mapping * uses core helper on WP 6.9+ without breaking WP <6.9. Falls back * to the raw attribute name. * * @since 2.0.0 * @param string $attr Raw attribute name (e.g. `data-wppo-dominant-color`). * @return string Normalized attribute name. */functionnormalize_data_attribute_name(string):string{if(function_exists(\'wp_html_custom_data_attribute_name\')){=\\wp_html_custom_data_attribute_name();if(is_string()&&\'\'!==){return;}}return;}/** * Processes <picture> blocks using WP_HTML_Processor for reliable block extraction with depth tracking. * * Uses spec-compliant token walking via serialize_token() with manual nesting * tracking so nested <picture>, comments, SVG/mathML and malformed HTML are * handled without the fragility of PCRE. * * Duplication note (D-13): the sibling `process_picture_blocks_regex()` is kept * intentionally as a fallback for hosts without `WP_HTML_Processor` (WP < 6.4) * or when `serialize_token()` is unavailable. Both share `process_picture_tag()` * for the per-picture decision logic; the `srcset` rewriting helpers are * similarly split (TagProcessor vs regex) for the same fallback reason. * Consolidated via shared helpers; no further dedup is safe without losing * the version-gated fallback. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param int $img_counter Current image counter. * @param int $exclude_img_count Number of first images to exclude. * @param array $exclude_imgs List of image URLs to exclude. * @return string The modified buffer. */functionprocess_picture_blocks_processor(string,int,int,array):string{if(!->should_use_html_processor()){return->process_picture_blocks_regex(,,,);}=Util::create_html_processor();if(null===){return->process_picture_blocks_regex(,,,);}=\'\';=false;=0;=\'\';try{while(->next_token()){=->get_token_type();if(\'#tag\'!==){=(string)->serialize_token();if(){.=;}else{.=;}continue;}=->is_tag_closer();=->get_tag();if(!&&\'PICTURE\'===&&!){=true;=1;=(string)->serialize_token();continue;}if(){.=(string)->serialize_token();if(\'PICTURE\'===){if(!){++;}else{--;if(0===){list(,)=->extract_picture_img();if(\'\'!==&&\'\'!==){++;if(>=){[]=;}.=->process_picture_tag(array(),,,);}else{.=;}=false;=\'\';=0;}}}continue;}.=(string)->serialize_token();}}catch(\\Throwable){unset();return->process_picture_blocks_regex(,,,);}if(method_exists(,\'get_last_error\')&&null!==->get_last_error()){return->process_picture_blocks_regex(,,,);}if(&&\'\'!==){.=;}return;}/** * Extract the inner `<img>` tag and its src from a `<picture>` block. * * Strict fallback chain (issue #1120): Tag Processor first, then the * regex last-resort. Processor `null` returns cast to empty string so * malformed markup fails open to the unoptimised block, never fatal. * Guards `class_exists(\'WP_HTML_Tag_Processor\')` so WP 6.2 behaviour * stays byte-identical when the Tag Processor is unavailable. * * @since 2.2.0 * * @param string $picture_html Serialized `<picture>...</picture>` block. * @return array{0:string,1:string} Tuple of (img tag, src); empty strings when none found. */functionextract_picture_img(string):array{=\'\';=\'\';if(class_exists(\'WP_HTML_Tag_Processor\')){try{=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-src\');if(null===){=->get_attribute(\'src\');}=is_string()?:\'\';}}catch(\\Throwable){unset();=\'\';}if(preg_match(\'#<img\\b[^>]*>#i\',,)){=[0];}if(\'\'!==&&\'\'!==){returnarray(,);}}if(preg_match(\'#<img\\b[^>]*?(?:data-)?src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',,)){returnarray([0],[1]);}if(\'\'===&&preg_match(\'#<img\\b[^>]*>#i\',,)){=[0];}returnarray(,);}/** * Processes <picture> blocks using regex fallback when WP_HTML_Processor is unavailable. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param int $img_counter Current image counter. * @param int $exclude_img_count Number of first images to exclude. * @param array $exclude_imgs List of image URLs to exclude. * @return string The modified buffer. */functionprocess_picture_blocks_regex(string,int,int,array):string{=preg_replace_callback(\'#<picture\\b[^>]*>.*?</picture>#is\',function()use(,,){preg_match(\'#<img\\b[^>]*?(?:data-)?src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',[0],);if(!empty()){++;if(>=){[]=[1];}return->process_picture_tag(,[0],[1],);}return[0];},);returnnull!==?:;}/** * Whether comment-image hardening is enabled (issue #1271). * * Additive `image_optimisation.hardenCommentImages` key; absent key * reads as enabled (fail-safe) so legacy installs get the * denylist/escape gate without a DB write. Explicit `false` * restores the legacy byte-identical rewrite path. * * @since 2.2.0 * @return bool True when comment image markup must be sanitized. */functionis_comment_hardening_enabled():bool{=->options[\'image_optimisation\'][\'hardenCommentImages\']??true;return!empty();}/** * Whether an image URL value is scriptable and must never be rewritten. * * Rejects `javascript:`/`vbscript:` and `data:` payloads that are not * `data:image/` (e.g. `data:text/html`), plus any other scheme that * is not `http`/`https`. Scheme-less values (relative paths, * root-relative paths, protocol-relative URLs) are not scriptable. * Control/whitespace obfuscation (`java\\tscript:`) is normalized * before the scheme check. Fail-open: undecodable input returns * false so the caller keeps its existing validity gate. * * @since 2.2.0 * @param string $url Raw attribute URL value. * @return bool True when the URL is scriptable. */functionis_scriptable_image_url(string):bool{static=array();=md5();if(isset([])){return[];}=->compute_is_scriptable_image_url();if(count()>=200){array_shift();}[]=;return;}/** * Core scriptable-URL check backing the memoized wrapper. * * Full entity-decode (repeated until stable, bounded at 5 passes) * plus control/whitespace stripping before the scheme regex, so * `javascript:` / `javascript:` obfuscation cannot * smuggle a scheme past the gate. `data:image/svg+xml` is treated * as scriptable (raster-only allowlist: png/jpeg/gif/webp/avif). * * @since 2.2.0 * @param string $url Raw attribute URL value. * @return bool True when the URL is scriptable. */functioncompute_is_scriptable_image_url(string):bool{try{=trim();for(=0;<5;++){=html_entity_decode(htmlspecialchars_decode(,ENT_QUOTES),ENT_QUOTES|ENT_HTML5,\'UTF-8\');if(===){break;}=;if(strlen()>4096){=substr(,0,4096);break;}}if(\'\'===){returnfalse;}=(string)preg_replace(\'/[\\x00-\\x20]+/\',\'\',ltrim());if(\'\'===||null===){returnfalse;}if(0===strpos(,\'//\')){returnfalse;}if(!preg_match(\'/^([a-zA-Z][a-zA-Z0-9+.-]*)\\s*:/\',,)){returnfalse;}=strtolower([1]);if(\'http\'===||\'https\'===){returnfalse;}if(\'data\'===){return1!==preg_match(\'#^data:image/(?:png|jpe?g|gif|webp|avif)[;,]#i\',);}returntrue;}catch(\\Throwable){unset();returnfalse;}}/** * Whether a `style` attribute value carries a scriptable payload. * * Single shared gate for the regex and Tag Processor sanitizer * paths so they stay in parity (issue #1271 follow-up). * * @since 2.2.0 * @param string $value Raw style value. * @return bool True when the style value is hostile. */functionis_hostile_style_value(string):bool{=;for(=0;<5;++){=html_entity_decode(,ENT_QUOTES|ENT_HTML5,\'UTF-8\');if(===){break;}=;if(strlen()>4096){=substr(,0,4096);break;}}=(string)preg_replace(\'/[\\x00-\\x20]+/\',\'\',strtolower());if(\'\'===){returnfalse;}returnfalse!==strpos(,\'expression(\')||false!==strpos(,\'javascript:\')||false!==strpos(,\'vbscript:\')||false!==strpos(,\'behaviour:\')||false!==strpos(,\'behavior:\')||false!==strpos(,\'-moz-binding\');}/** * Split a `srcset` value into candidates without breaking `data:` URIs. * * `data:image/png;base64,...` contains a comma that a naive * `explode(\',\', ...)` would split. The negative lookahead keeps * `base64` payload commas intact. * * @since 2.2.0 * @param string $raw Raw srcset attribute value. * @return string[] Trimmed non-empty candidate items. */functionsplit_srcset_candidates(string):array{=\";base64\\x00WPPO_COMMA\\x00\";=str_ireplace(\';base64,\',,);=explode(\',\',);=array();=count();for(=0;<;++){=trim((string)[]);if(\'\'===){continue;}if(1===preg_match(\'#^data:image/(?:png|jpe?g|gif|webp|avif)[;,][^\\\\s]*$#i\',)&&isset([+1])){=.\',\'.trim((string)[+1]);++;=trim();if(\'\'===){continue;}}[]=;}=array();foreach(as){[]=str_replace(,\';base64,\',);}return;}/** * Split a srcset candidate item into URL + descriptor without TypeError. * * `preg_split()` returns `false` on PCRE failure; `array_pad(false)` * TypeErrors on PHP 8.2. Shared by the regex and Tag Processor * srcset loops (issue #1271 follow-up). * * @since 2.2.0 * @param string $item Single srcset candidate item. * @return string[] Two-element [url, descriptor] array. */functionsplit_srcset_item(string):array{=preg_split(\'/\\s+/\',,2);if(!is_array()){=array();}=array_pad(,2,\'\');returnarray((string)[0],(string)[1]);}/** * Whether an attribute name is an inline event handler (`on*`). * * Fail-closed for unknown `on*` names but spares benign * non-handler attributes starting with `on` (`only`, `one`, * `online`). Shared by the regex and Tag Processor sanitizer * paths so they stay in parity (issue #1271 follow-up). * * @since 2.2.0 * @param string $name Raw attribute name. * @return bool True when the attribute is an event handler. */functionis_event_attribute_name(string):bool{=strtolower();if(in_array(,self::BENIGN_ON_PREFIX_ATTRS,true)){returnfalse;}return1===preg_match(\'/^on[a-z]{2,}$/\',);}/** * Whether an upper-case Tag Processor tag name is hardening-scoped. * * O(1) lookup replacing the inline 15-way `===` chain in the hot * loops. The unfiltered `next_tag()` traversal is kept deliberately: * the multi-tag `tag_names` query filter is unavailable on the * minimum supported WP 6.2 core, and per-tag filtered passes would * re-parse the full buffer N times. * * @since 2.2.0 * @param string $tag_name Upper-case tag name from `get_tag()`. * @return bool True when the tag is in `HARDENED_TAGS`. */functionis_hardened_tag(string):bool{static=null;if(null===){=array();foreach(self::HARDENED_TAGSas){[strtoupper()]=true;}}returnisset([]);}/** * Regex alternation for the hardening tag scope (e.g. `img|image|...`). * * @since 2.2.0 * @return string Alternation safe for `#<(...)\\b` patterns. */functionhardened_tag_alternation():string{returnimplode(\'|\',self::HARDENED_TAGS);}/** * Strip hostile attributes from a single opening tag. * * Handles every tag in `HARDENED_TAGS` (img/image/source/video/ * iframe/audio/embed/object/svg/math plus `use`/`a`/`table`/`body`/ * `td`/`th` carriers of href/background attributes) — not just * img/source/video. Named `sanitize_image_tag_html` for history; * runs site-wide on the full buffer (not comment-only) so hostile * markup can never be laundered into the static cache file. * * Denylist approach: event-handler attributes (`on*`), scriptable * URL attributes, and `style` payloads carrying `expression(` / * `javascript:` / `vbscript:` are removed; safe attributes (`src`, * `srcset`, `alt`, `width`, `height`, `loading`, `decoding`, * `fetchpriority`, `sizes`, `media`, `type`, `class`, `id`, …) are * preserved byte-identical so galleries/`<picture>` fixtures keep * their layout. Regex-based so the legacy WP 6.2 path (no Tag * Processor) gets the same gate. Fail-open: returns the input tag * unchanged on any PCRE failure. * * @since 2.2.0 * @param string $tag Raw opening tag HTML. * @return string Sanitized tag. */functionsanitize_image_tag_html(string):string{try{=false!==stripos(,\'on\');=false!==stripos(,\'src\')||false!==stripos(,\'href\')||false!==stripos(,\'data\')||false!==stripos(,\'poster\')||false!==stripos(,\'srcdoc\')||false!==stripos(,\'background\')||false!==stripos(,\'lowsrc\')||false!==stripos(,\'action\')||false!==stripos(,\'cite\')||false!==stripos(,\'longdesc\')||false!==stripos(,\'codebase\')||false!==stripos(,\'usemap\');=false!==stripos(,\'srcset\');=false!==stripos(,\'style\');if(!&&!&&!&&!){return;}if(){=preg_replace_callback(\'#[\\s/]+(on[a-z]+)\\s*=\\s*(?:\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>\"\\\']+)#i\',function(){return->is_event_attribute_name([1])?\'\':[0];},);if(null===){return;}=;}if(){=(string)preg_replace_callback(\'#[\\s/]+(src|data-src|data|codebase|usemap|poster|srcdoc|background|lowsrc|href|xlink:href|action|formaction|cite|longdesc)\\s*=\\s*(\"([^\"]*)\"|\\\'([^\\\']*)\\\'|([^\\s>\"\\\']+))#i\',function(){=strtolower([1]);=\'\';if(isset([3])&&\'\'!==[3]){=[3];}elseif(isset([4])&&\'\'!==[4]){=[4];}elseif(isset([5])){=[5];}if(\'srcdoc\'===){return\'\';}if(->is_scriptable_image_url()){return\'\';}return[0];},);}if(){=(string)preg_replace_callback(\'#[\\s/]+((?:data-)?srcset)\\s*=\\s*(\"([^\"]*)\"|\\\'([^\\\']*)\\\'|([^\\s>\"\\\']+))#i\',function(){=[1];=substr([2],0,1);=(\'\"\'===||\"\'\"===)?:\'\"\';=\'\';if(isset([3])&&\'\'!==[3]){=[3];}elseif(isset([4])&&\'\'!==[4]){=[4];}elseif(isset([5])){=[5];}=array();foreach(->split_srcset_candidates()as){list()=->split_srcset_item();if(->is_scriptable_image_url()){continue;}[]=;}if(array()===){return\'\';}=implode(\', \',);if(===){return[0];}return\' \'..\'=\'...;},);}if(){=(string)preg_replace_callback(\'#[\\s/]+style\\s*=\\s*(\"([^\"]*)\"|\\\'([^\\\']*)\\\'|([^\\s>\"\\\']+))#i\',function(){=\'\';if(isset([2])&&\'\'!==[2]){=[2];}elseif(isset([3])&&\'\'!==[3]){=[3];}elseif(isset([4])){=[4];}if(->is_hostile_style_value()){return\'\';}return[0];},);}return;}catch(\\Throwable){unset();return;}}/** * Sanitize hardened tags across a full buffer. * * Runs before next-gen/lazy rewriting so hostile comment-authored * markup (`img`/`image` `onerror`, `picture source`, inline `on*` * handlers, scriptable URLs, `<svg>`/`<math>` active content) * renders inert and can never be laundered into the static cache * file. Named `sanitize_comment_images_*` for history; runs * site-wide on the full buffer (not comment-only) because the * cache layer caches full pages. Safe gallery/`<picture>` markup * has no such attributes and passes through unchanged (no layout * regression). Fail-open: returns the input buffer unchanged when * hardening is disabled, the buffer is empty, or PCRE fails. * * @since 2.2.0 * @param string $buffer Full HTML buffer. * @return string Sanitized buffer. */functionsanitize_comment_images_in_buffer(string):string{if(\'\'===||!->is_comment_hardening_enabled()){return;}=->hardened_tag_alternation();if(1!==preg_match(\'#<(\'..\')\\b#i\',)){return;}try{=preg_replace_callback(\'#<(\'..\')\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function(){return->sanitize_image_tag_html([0]);},);=is_string()?:;if(false!==stripos(,\'<svg\')||false!==stripos(,\'<math\')){=(string)preg_replace_callback(\'#<(svg|math)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>(.*?)(</\\1\\s*>|$)#is\',function(){return->sanitize_svg_math_block([0]);},);}return;}catch(\\Throwable){unset();return;}}/** * Sanitize an `<svg>...</svg>` / `<math>...</math>` block, including inner content. * * Drops executable inner elements (`script`, `animate`, * `animateTransform`, `foreignObject`, `set`, `discard`) entirely * and strips hostile attributes from the remaining inner tags via * the shared {@see sanitize_image_tag_html()} gate, so nested * `<img onerror>` / `<a xlink:href=\"javascript:\">` / * `<mi href=\"javascript:\">` cannot survive into cached HTML. * * @since 2.2.0 * @param string $block Full svg/math block HTML. * @return string Sanitized block. */functionsanitize_svg_math_block(string):string{try{=preg_replace(\'#<(script|animate|animateTransform|foreignObject|set|discard)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>.*?</\\1\\s*>#is\',\'\',);if(null!==){=;}=preg_replace(\'#<(script|animate|animateTransform|foreignObject|set|discard)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*/?>#i\',\'\',);=preg_replace_callback(\'#<(?!/)([a-zA-Z][a-zA-Z0-9:_.-]*)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#\',function(){return->sanitize_image_tag_html([0]);},);returnis_string()?:;}catch(\\Throwable){unset();return;}}/** * Strip hostile attributes from the current Tag Processor tag. * * Defense-in-depth for the `WP_HTML_Tag_Processor` rewrite loops: * the buffer pre-pass already removed `on*`/scriptable attributes, * but a hostile node that survived (e.g. entity-obfuscated input the * regex missed) is neutralized here before `set_attribute()` can * re-emit it into cached HTML. Guards `get_attribute_names()` / * `remove_attribute()` so WP 6.2 cores without those methods stay * byte-identical. Never fatals: any failure leaves the tag * untouched for the caller to skip or fail open. * * @since 2.2.0 * @param object $tags Active `WP_HTML_Tag_Processor` positioned on a tag. * @return void */functionsanitize_tag_attributes_processor():void{try{if(!->is_comment_hardening_enabled()){return;}if(!is_object()||!method_exists(,\'get_attribute\')||!method_exists(,\'remove_attribute\')){return;}=array();if(method_exists(,\'get_attribute_names\')){=->get_attribute_names();if(is_array()){=;}}if(array()===){=array(\'onerror\',\'onload\',\'onclick\',\'onmouseover\',\'onmouseout\',\'onmouseenter\',\'onmouseleave\',\'onmousemove\',\'onmousedown\',\'onmouseup\',\'onfocus\',\'onblur\',\'onkeydown\',\'onkeyup\',\'onkeypress\',\'onsubmit\',\'onchange\',\'oninput\',\'onanimationend\',\'onanimationstart\',\'ontoggle\',\'onplay\',\'onpause\',\'onended\',\'onpointerover\',\'onpointerdown\',\'ontouchstart\',\'onwheel\',\'onscroll\',\'ondblclick\',\'oncontextmenu\',\'ondragstart\',\'ondrop\',\'onbegin\',\'onend\',\'onrepeat\',\'formaction\',\'xlink:href\',\'href\',\'action\',\'src\',\'data-src\',\'data\',\'codebase\',\'usemap\',\'srcset\',\'data-srcset\',\'poster\',\'srcdoc\',\'background\',\'lowsrc\',\'style\');}foreach(as){if(!is_string()||\'\'===){continue;}=strtolower();if(->is_event_attribute_name()){->remove_attribute();continue;}if(\'srcdoc\'===){=->get_attribute();if(null!==){->remove_attribute();}continue;}if(in_array(,self::HARDENED_URL_ATTRS,true)&&\'srcdoc\'!==){=->get_attribute();if(is_string()&&->is_scriptable_image_url()){->remove_attribute();}continue;}if(in_array(,self::HARDENED_SRCSET_ATTRS,true)){=->get_attribute();if(!is_string()||\'\'===){continue;}=array();foreach(->split_srcset_candidates()as){list()=->split_srcset_item();if(->is_scriptable_image_url()){continue;}[]=;}if(array()===){->remove_attribute();}elseif(implode(\', \',)!==){->set_attribute(,implode(\', \',));}continue;}if(\'style\'===){=->get_attribute();if(is_string()&&->is_hostile_style_value()){->remove_attribute();}}}}catch(\\Throwable){unset();}}/** * Serves next-generation images if supported by the browser. * * @since 1.0.0 * * @param string $buffer The HTML content buffer. * * @return string Modified HTML content buffer. */functionmaybe_serve_next_gen_images(){if(is_string()&&\'\'!==){=->sanitize_comment_images_in_buffer();}if(!empty(->options[\'image_optimisation\'][\'convertImg\'])){=->options[\'image_optimisation\'][\'conversionFormat\']??\'webp\';=->exclude_convert_imgs;=isset([\'HTTP_ACCEPT\'])?sanitize_text_field(wp_unslash([\'HTTP_ACCEPT\'])):\'\';=false!==strpos(,\'image/avif\');=false!==strpos(,\'image/webp\');if(!&&!){return;}if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();=->is_comment_hardening_enabled();while(->next_tag()){=->get_tag();if(&&is_string()&&->is_hardened_tag()){->sanitize_tag_attributes_processor();}if(\'IMG\'===||\'IMAGE\'===){=->get_attribute(\'src\');if(){=->normalize_url();if(->is_valid_url()&&!->is_scriptable_image_url()){=->replace_image_with_next_gen(,,,);if(!==){->set_attribute(\'src\',);}}}}if(\'IMG\'===||\'IMAGE\'===||\'SOURCE\'===){=->get_attribute(\'srcset\');if(){=array();=->split_srcset_candidates();foreach(as){list(,)=->split_srcset_item(trim());=->normalize_url();if(->is_scriptable_image_url()||->is_scriptable_image_url()){continue;}if(->is_valid_url()){=->replace_image_with_next_gen(,,,);=(!==)?:;[]=.(?\" \":\'\');}else{[]=.(?\" \":\'\');}}=implode(\', \',);if(array()===){->remove_attribute(\'srcset\');}elseif(!==){->set_attribute(\'srcset\',);}}}elseif(\'VIDEO\'===){=->get_attribute(\'poster\');if(){if(->is_scriptable_image_url()){->remove_attribute(\'poster\');}else{=->normalize_url();if(->is_valid_url()&&!->is_scriptable_image_url()){=->replace_image_with_next_gen(,,,);if(!==){->set_attribute(\'poster\',);}}}}}}return->get_updated_html();}else{=preg_replace_callback(\'#<(?:img|image)\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function()use(,,){=[0];=preg_replace_callback(\'#src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];if(->is_scriptable_image_url()){return\'\';}if(->is_valid_url()){return\'src=\"\'.->replace_image_with_next_gen([1],,,).\'\"\';}return[0];},);=preg_replace_callback(\'#srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];=array();foreach(->split_srcset_candidates()as){list(,)=->split_srcset_item(trim());if(->is_scriptable_image_url()){continue;}=->replace_image_with_next_gen(,,,);[]=.(?\" \":\'\');}if(array()===){return\'\';}return\'srcset=\"\'.implode(\', \',).\'\"\';},);return;},);=preg_replace_callback(\'#<source\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function()use(,,){=[0];=preg_replace_callback(\'#\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];if(->is_scriptable_image_url()){return\'\';}if(->is_valid_url()){return\'src=\"\'.->replace_image_with_next_gen(,,,).\'\"\';}return[0];},);=preg_replace_callback(\'#\\bsrcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];=array();foreach(->split_srcset_candidates()as){list(,)=->split_srcset_item(trim());if(->is_scriptable_image_url()){continue;}=->replace_image_with_next_gen(,,,);[]=.(?\" \":\'\');}if(array()===){return\'\';}return\'srcset=\"\'.implode(\', \',).\'\"\';},);return;},);=preg_replace_callback(\'#<video\\b(?:[^>\"\\\']|\"[^\"]*\"|\\\'[^\\\']*\\\')*>#i\',function()use(,,){=[0];returnpreg_replace_callback(\'#\\bposter=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(,,){=[1];if(->is_scriptable_image_url()){return\'\';}if(->is_valid_url()){=->replace_image_with_next_gen(,,,);if(!==){return\'poster=\"\'..\'\"\';}}return[0];},);},);return;}}return;}/** * Gets a cached instance of Img_Converter. * * @since 1.1.2 * * @return Img_Converter The Img_Converter instance. */functionget_img_converter(){if(null===->img_converter){->img_converter=newImg_Converter(->options);}return->img_converter;}/** * Cached file_exists check to avoid repeated stat calls per image per request. * * @since 2.0.0 * @param string $path Absolute file path. * @return bool Whether the file exists. */functioncached_file_exists(string):bool{if(\'\'===){returnfalse;}if(isset(self::[])){returnself::[];}=file_exists();if(count(self::)>=self::FILE_EXISTS_CACHE_LIMIT){array_shift(self::);}self::[]=;return;}/** * Clear the file_exists cache (for testing isolation). * * @since 2.0.0 * @return void */staticfunctionclear_file_exists_cache():void{self::=array();}/** * Get image dimensions with a bounded per-request LRU cache. * * Consolidates the `getimagesize` LRU that was copy-pasted between * `post_process_img_dimensions()` and `add_delay_load_img()` (D-14). * * @since 2.0.0 * @param string $local_path Absolute file path. * @return array|false Image size array or false on failure. */functionget_cached_image_size(string):array|false{if(isset(self::[])){=self::[];unset(self::[]);self::[]=;return;}if(count(self::)>=self::IMG_SIZE_CACHE_LIMIT){array_shift(self::);}=getimagesize();self::[]=;return;}/** * Replaces image URLs with next-generation formats. * * @since 1.0.0 * * @param string $img_url The image URL. * @param array $exclude_imgs Images to exclude. * @param boolean $supports_avif Whether AVIF is supported. * @param boolean $supports_webp Whether WebP is supported. * * @return string Updated image URL. */functionreplace_image_with_next_gen(,,,){=strtolower(pathinfo((string)wp_parse_url((string),PHP_URL_PATH),PATHINFO_EXTENSION));=->get_img_converter();=->get_format();if(\'avif\'===){return;}if(!empty()){foreach(as){if(false!==strpos(,)){return;}}}=->get_img_path(,\'avif\');=->get_img_path(,\'webp\');=null;if(\'avif\'===||\'both\'===){if(!->cached_file_exists()){=Util::get_local_path();if(->cached_file_exists()){->add_img_into_queue(,\'avif\');}}}if(\'webp\'===||\'both\'===){if(!->cached_file_exists()){if(null===){=Util::get_local_path();}if(->cached_file_exists()){->add_img_into_queue();}}}if((\'avif\'===||\'both\'===)&&&&->cached_file_exists()){return->get_img_url(,\'avif\');}if((\'webp\'===||\'both\'===)&&&&->cached_file_exists()){return->get_img_url();}return;}/** * Determine whether a string is a syntactically valid URL. * * @param string $url The URL to validate. * @return bool `true` if the URL is a valid URL string, `false` otherwise. */functionis_valid_url(){returnfalse!==filter_var(,FILTER_VALIDATE_URL);}/** * Convert various URL forms into an absolute URL. * * Leaves empty strings and `data:` URLs unchanged. Handles protocol-relative (`//...`), root-relative (`/...`) and relative paths (e.g., `images/foo.jpg`, `../img.jpg`) by resolving them against the site\'s home URL and the current request path. Returns the original value unchanged when it is already an absolute `http...` URL. * * @since 1.4.0 * @param string $url The input URL to normalize. * @return string The normalized absolute URL, or the original value for empty/data URLs. */functionnormalize_url(string):string{if(empty()||0===strpos(,\'data:\')){return;}=Util::cached_home_url();if(0===strpos(,\'//\')){static=array();=get_current_blog_id();if(!isset([])){[]=wp_parse_url(,PHP_URL_SCHEME);if(empty([])){[]=is_ssl()?\'https\':\'http\';}}return[].\':\'.;}if(0===strpos(,\'/\')){return.\'/\'.ltrim(,\'/\');}if(0!==strpos(,\'http\')){static=array();=get_current_blog_id();if(!isset([])){[]=wp_parse_url(add_query_arg(array()),PHP_URL_PATH);if(empty([])){[]=\'/\';}}=->resolve_relative_path([],);return.\'/\'.ltrim(,\'/\');}return;}/** * Resolve a relative path against a base path and return an absolute path starting with \'/\'. * * The function treats $base_path as a file (removing its final segment) when it has no * trailing slash and the last segment contains a dot. It preserves an absolute input * $relative_path (one that starts with \'/\') and resolves \'.\' and \'..\' segments. * * @since 1.4.0 * @param string $base_path Base path to resolve against; may represent a directory (trailing slash) or a file. * @param string $relative_path Relative path to resolve; if it starts with \'/\' it will be returned unchanged. * @return string The resolved absolute path beginning with \'/\'. */functionresolve_relative_path(string,string):string{if(0===strpos(,\'/\')){return;}=\'/\'===substr(,-1);=array_filter(explode(\'/\',),function():bool{returnis_string()&&\'\'!==;});=explode(\'/\',);if(!&&!empty()&&false!==strpos(end(),\'.\')){array_pop();}foreach(as){if(\'.\'===||\'\'===){continue;}if(\'..\'===){array_pop();}else{[]=;}}return\'/\'.implode(\'/\',);}/** * Retrieves all preloading data from front-page, post meta, and post types. * * @since 1.5.1 * @return array List of preload data items. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_all_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_all_preload_data():array{return->lcp_preload()->get_all_preload_data();}/** * Whether a candidate URL is a plausible LCP image (text-LCP guard). * * Text-only LCP (PageSpeed `largest-contentful-paint-element` without * an image URL) must never produce a preload hint, so candidates are * rejected unless they look like an image: data/blob/javascript URIs * are refused, and the URL must either map to a known image MIME * type, carry an image file extension, or (for extensionless image * CDN URLs) carry image-ish query params. A non-image URL is never * preloaded. Any failure returns false. * * @since 2.2.0 * @param string $url The candidate URL. * @return bool True when the URL may be preloaded as an image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_image_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_image_lcp_url(string):bool{return->lcp_preload()->is_image_lcp_url();}/** * Read the manual per-post LCP URL picker value (`_wppo_lcp_preload_url`). * * The manual picker is the fallback path: it wins over auto-detect * (RUM / Optimization Detective / PageSpeed / heuristic) so site owners * can pin the hero before field data exists. Returns an empty string * when not on a singular view, when the meta is absent, or when the * value fails the `is_image_lcp_url()` guard. Fail-open: any failure * returns an empty string, never fatal. * * @since 2.2.0 * @return string The manual LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_manual_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_manual_lcp_url():string{return->lcp_preload()->get_manual_lcp_url();}/** * Whether a preload candidate URL is same-origin with this site. * * Emission-path guard (issue #1180): stored PageSpeed values, OD * real-visit data, and the DOM-first heuristic flow into the single * `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` unchecked * today — only the RUM beacon intake validates origin. Absolute URLs * are validated via `RUM::is_same_origin_url()`; root-relative and bare * relative paths resolve against the home URL and are same-origin by * construction, except scheme-like values (`data:`, `blob:`, * `javascript:`, `mailto:`, …) which are rejected. Fail-closed for the * page: any failure (including an unavailable RUM class, consistent * with the catch block below — `resolve_auto_lcp_url()` already * fails open by falling through to the next tier) returns false * (candidate skipped), never fatal. * * @since 2.2.0 * @param string $url The candidate URL. * @return bool True when the URL may be preloaded. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_same_origin_preload_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_same_origin_preload_url(string):bool{return->lcp_preload()->is_same_origin_preload_url();}/** * Whether core\'s loading-optimization API is available. * * Explicit guard (issue #1180) for the decoding gap-fill: core is * consulted for gap-fill input (`decoding`) only, while the * measured hero keeps `fetchpriority=\"high\"` (field truth beats * the core heuristic) and an already-stamped fetchpriority is * never overridden. Requires * `wp_get_loading_optimization_attributes()` plus WordPress 6.2+ * (the HTML API era); every call is guarded with `function_exists()` * and `version_compare()` with a legacy fallback to the unmodified * gap-fill behaviour. Fail-open: any failure returns false. * * @since 2.2.0 * @return bool True when core may be consulted for a node verdict. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_core_loading_optimization_available}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_core_loading_optimization_available():bool{return->lcp_preload()->is_core_loading_optimization_available();}/** * Ask core for its loading-optimization verdict on the current tag. * * Gap-fill companion to `is_core_loading_optimization_available()`: * builds the tag-attribute array core expects and returns the * `wp_loading_optimization_attributes` filter verdict * (`decoding` when offered), or null when core is * unavailable, throws, or offers no verdict. The filter — not * `wp_get_loading_optimization_attributes()` — is consulted on * purpose: the latter runs core\'s stateful per-context image * counter, so a second direct call from the buffer path would * double-count this image and skew core\'s later lazy/eager * decisions (and core never returns `high` for our synthetic * context anyway). The filter lets hooked optimizers weigh in * without touching the counter; the already-stamped * `fetchpriority` attribute check in callers remains the * authoritative no-double-stamp guard. Callers always stamp the * measured hero `high` (field truth beats the heuristic), while * `decoding` defers to the verdict whenever one is offered. * `fetchpriority` is intentionally not collected: no caller reads * it (the stamp is unconditional when absent), so returning it * would be dead data inviting future misuse. * Fail-open: any failure returns null. * * @since 2.2.0 * @param mixed $tags Tag processor positioned on an `<img>` node. * @return array{decoding?:string}|null Core\'s verdict, or null. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_core_loading_verdict_for_tag}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_core_loading_verdict_for_tag():?array{return->lcp_preload()->get_core_loading_verdict_for_tag();}/** * Whether automatic (signal-driven) LCP preload is disabled for the current post. * * Reads the per-post `_wppo_disable_auto_lcp` meta (see Metabox). * The manual picker (`_wppo_lcp_preload_url`) is explicit opt-in and * is unaffected — only the RUM/OD signal tiers are suppressed. Off * (empty meta) by default so existing behaviour is unchanged. * Fail-open: any failure returns false (auto-LCP stays enabled). * * @since 2.2.0 * @return bool True when auto-LCP must be skipped for this post. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_auto_lcp_disabled_for_post}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_auto_lcp_disabled_for_post():bool{return->lcp_preload()->is_auto_lcp_disabled_for_post();}/** * Resolve the stable signal-only LCP image URL for the current page. * * Signal-driven subset of `resolve_auto_lcp_url()` (issue #1273): * the RUM field candidate (stable by construction — sample-count * gate via `get_field_lcp_min_samples()`, 24 h freshness TTL, and * same-origin re-check inside `RUM::get_field_lcp_url()`) wins * first, then the stability-gated OD real-visit candidate * (`OD_Bridge::get_stable_lcp_url()` — at least two agreeing * viewport observations, or a single measured group). Manual picker, * stored PageSpeed, and DOM-heuristic tiers are deliberately * excluded here. Every candidate must pass `is_image_lcp_url()` + * `is_allowed_hero_preload_url()` (same-origin or configured CDN). * Returns \'\' when the per-post * disable meta is set, when no stable signal exists, or on any * failure (fail-open to no-preload, never broken markup). * * @since 2.2.0 * @return string The stable signal LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_stable_signal_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_stable_signal_lcp_url():string{return->lcp_preload()->get_stable_signal_lcp_url();}/** * Resolve the OD-only LCP image URL (manual picker + OD real-visit data). * * Subset of `resolve_auto_lcp_url()` needing no RUM state (issue * #1216): the manual picker and the Optimization Detective tiers are * guarded (image + allowed origin: same-origin or configured CDN) * and fire no RUM lookups, so they stay * available when the RUM gate is unsatisfied. The OD tier relies on * the stability-gated `OD_Bridge::get_stable_lcp_url()` (issue * #1273 — at least two agreeing viewport observations, or a single * measured group; \'\' on viewport disagreement so a disagreeing * mobile/desktop pair never preloads the wrong hero), falling back * to `OD_Bridge::get_lcp_url()` only when the stable accessor is * unavailable. Gating lives in `OD_Bridge::is_enabled()` — the * single firing of the `wppo_od_should_optimize` filter * (current-URL context, memoized per request) — so no separate * filter pre-check exists here. The per-post * `_wppo_disable_auto_lcp` meta suppresses the OD tier; the manual * picker is explicit opt-in and still applies. Fail-open: any * failure returns \'\'. * * @since 2.2.0 * @return string The OD-only LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_od_only_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_od_only_lcp_url():string{return->lcp_preload()->resolve_od_only_lcp_url();}/** * Resolve the single auto-detected LCP image URL for the current page. * * Unified priority chain (fail-open, never fatal): * Manual per-post picker (`_wppo_lcp_preload_url`) wins first, then * P0 stability-gated Optimization Detective real-visit data (shared * `resolve_od_only_lcp_url()` helper — `get_stable_lcp_url()` so a * disagreeing mobile/desktop pair yields \'\' instead of the wrong * hero; the `wppo_od_should_optimize` filter fires inside * `OD_Bridge::get_stable_lcp_url()` via `is_enabled()` with * current-URL context, memoized per request), then stored PageSpeed * LCP (via `get_current_lcp_url()`, which covers RUM-field override + * post-meta/front-page/transient tiers), then the stability-gated * signal-only tier (`get_stable_signal_lcp_url()` — RUM field + * agreement-gated OD, issue #1273), then the DOM-first heuristic * (first non-trivial `<img src>` in `$buffer` when provided — DOM * order, not viewport-aware, buffer-only, and only a fallback when no * measured/stored data exists). The per-post * `_wppo_disable_auto_lcp` meta suppresses every automatic tier * (P0 OD, P1 stored, P1b signal, P2 heuristic) but never the * manual picker, which is explicit opt-in. Text-only LCP never resolves: every * candidate must pass `is_image_lcp_url()`. Untrusted-origin candidates never resolve either: * every tier must pass `is_allowed_hero_preload_url()` (same-origin * or configured CDN) so at most one allowed-origin * `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` * is ever emitted. Multisite-safe: the * stored tier uses `Util::transient_key()` blog-aware keys. * * @since 2.2.0 * @param string|null $buffer Optional HTML buffer for the heuristic fallback. * @return string The LCP image URL, or empty string when none resolves. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_auto_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_auto_lcp_url(?string=null):string{return->lcp_preload()->resolve_auto_lcp_url();}/** * Resolve responsive srcset/sizes for an LCP URL via the media library. * * Attachment-based lookup for the `wp_head` emission paths (which run * before any HTML buffer exists, so the buffer scanners cannot help): * maps the LCP URL to an attachment via `attachment_url_to_postid()` * and fetches `wp_get_attachment_image_srcset()` / * `wp_get_attachment_image_sizes()` (`full` size). Returns an empty * pair when the URL is not an attachment image, when the WP helpers * are unavailable, or on any failure (fail-open: callers fall back to * a plain `href` preload). Callers must never emit srcset without * sizes: when either value is empty both are treated as empty. * * @since 2.2.0 * @param string $lcp_url The resolved LCP image URL. * @return array{srcset: string, sizes: string} Responsive data (empty strings when unavailable). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_responsive_data_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */staticfunctionget_lcp_responsive_data_for_url(string):array{returnLcp_Preload::get_lcp_responsive_data_for_url();}/** * Find the responsive srcset for an LCP URL inside an HTML buffer. * * Scans `<img>` tags for the first node whose `src`/`data-src` * matches the LCP URL via normalized-URL equality only (absolute vs * relative and size-suffix variants match; no substring fallback so * a short relative URL cannot attach an unrelated srcset) and * returns its `srcset` (or `data-srcset`) value. Returns an empty * string when no match or no srcset exists. Fail-open: any failure * returns \'\'. * * @since 2.2.0 * @param string $lcp_url The resolved LCP image URL. * @param string|null $buffer Optional HTML buffer to scan. * @return string The srcset value, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_srcset_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lcp_srcset_for_url(string,?string=null):string{return->lcp_preload()->get_lcp_srcset_for_url(,);}/** * Find the responsive sizes value for an LCP URL inside an HTML buffer. * * Mirrors `get_lcp_srcset_for_url()`: normalized-URL equality only, * `sizes` (then `data-sizes`) of the matching `<img>`. Returns an * empty string when no match or no sizes exists. Fail-open: any * failure returns \'\'. * * @since 2.2.0 * @param string $lcp_url The resolved LCP image URL. * @param string|null $buffer Optional HTML buffer to scan. * @return string The sizes value, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_sizes_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lcp_sizes_for_url(string,?string=null):string{return->lcp_preload()->get_lcp_sizes_for_url(,);}/** * Emit a breakpoint-specific responsive LCP preload. * * Breakpoint-correct single-preload emitter (issue #1429): resolves * OD per-viewport LCP elements first * (`OD_Bridge::get_breakpoint_lcp_elements()`, guarded), RUM * field-LCP second (`RUM::get_field_lcp_url()`, guarded), and emits * exactly one `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` * with matching `imagesrcset`+`imagesizes` (escaped via `esc_attr()` * inside `Util::get_preload_link()`). Picture, CSS-background, and * video-poster LCP variants are covered per `type`; art-directed * `picture` entries carrying `media` are skipped fail-open (returns * \'\') instead of mispredicting. Without OD/RUM data returns \'\' so * the caller falls back to the legacy single-URL hero preload; * never more than one fetchpriority high per response (per-response * flag + `claim_hero_preload_slot()` + buffer high-hint scan). * Guards OD/RUM/WP calls with `function_exists()` / * `class_exists()` / `has_filter()` / `version_compare()` where * applicable. Multisite-safe: per-site metrics only (current-URL * context, `Util::transient_key()` blog-aware keys downstream). * * Standalone single-emission entry point for direct buffer/`wp_head` * callers needing a self-contained responsive preload (public for * testability, not part of the external plugin API): the existing * `wp_head` (`get_auto_lcp_preload_data()`) and buffer companions * (`maybe_preload_hero_image()`, `maybe_inject_css_hero_preload()`) * share the same resolver via `get_breakpoint_srcset_for_url()` so * their toggles/gates stay unchanged, while direct callers should * prefer this emitter instead of reimplementing the OD → RUM → * single-high flow. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer for responsive fallback scans. * @return string The preload `<link>` tag, or empty string when skipped. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::emit_responsive_lcp_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionemit_responsive_lcp_preload(?string=null):string{return->lcp_preload()->emit_responsive_lcp_preload();}/** * Resolve the responsive LCP candidate (OD breakpoints → RUM field). * * Shared resolver for `emit_responsive_lcp_preload()` and the * `wp_head`/buffer wiring: OD breakpoint winner first (with * attachment + buffer gap-fill when the element carries no srcset), * RUM field-LCP second. Returns `array()` when nothing resolves or * when the art-directed case must be skipped. Fail-open: any failure * returns `array()`. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer for fallback scans. * @return array{url: string, srcset: string, sizes: string, type: string, media: string}|array Empty when unresolved. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_responsive_lcp_candidate}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_responsive_lcp_candidate(?string=null):array{return->lcp_preload()->get_responsive_lcp_candidate();}/** * Pick the breakpoint winner from OD per-viewport entries. * * Majority-votes the normalized URL (mobile-first tie-break, mirroring * `OD_Bridge::get_lcp_url()`); the winner\'s `srcset`/`sizes` pair is * kept only when both are non-empty, otherwise gap-filled via the * attachment lookup then the buffer scan. Picture, background, and * video-poster `type` values all qualify (background/poster winners * legitimately carry no srcset and emit a plain href preload). * Art-directed output — distinct viewport URLs where any entry * carries a non-empty `media`, or a picture winner with `media` — * returns `array()` (fail-open skip). Winners failing * `is_image_lcp_url()` / `is_allowed_hero_preload_url()` are * skipped entry by entry (next-most-common) so one poisoned entry * cannot suppress a valid runner-up. * * @since 2.3.0 * @param array $entries OD breakpoint entries. * @param string|null $buffer Optional HTML buffer for gap-fill. * @return array{url: string, srcset: string, sizes: string, type: string, media: string}|array Winner or empty. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::pick_breakpoint_winner}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionpick_breakpoint_winner(array,?string=null):array{return->lcp_preload()->pick_breakpoint_winner(,);}/** * Resolve the RUM field-LCP fallback candidate. * * Second tier behind OD breakpoints (issue #1429): reads * `RUM::get_field_lcp_url()` (guarded) for the current path and * gap-fills srcset/sizes via the attachment lookup then the buffer * scan. Returns `array()` when RUM is unavailable, has no data, or * the candidate fails validation. Fail-open: any failure returns * `array()`. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer for gap-fill. * @return array{url: string, srcset: string, sizes: string, type: string, media: string}|array Candidate or empty. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_rum_fallback_candidate}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_rum_fallback_candidate(?string=null):array{return->lcp_preload()->resolve_rum_fallback_candidate();}/** * Whether the response already carries a fetchpriority-high hint. * * Single-high guard (issue #1429): true when the responsive * per-response flag is set or when the buffer already contains an * exact `fetchpriority=\"high\"` hint. Slot-claim enforcement * (exact + any-media `has_emitted_preload()` / * `is_hero_preload_claimed()` checks plus buffer URL matching) * lives in `claim_hero_preload_slot()`, not here. Fail-open to * false. * * @since 2.3.0 * @param string|null $buffer Optional HTML buffer to inspect. * @return bool True when a high hint already exists. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::response_already_has_high_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresponse_already_has_high_preload(?string=null):bool{return->lcp_preload()->response_already_has_high_preload();}/** * Retrieves the manual per-post LCP image preload item. * * Emits the `_wppo_lcp_preload_url` picker value via * `prepare_preload_item()` so a pinned hero preloads with * fetchpriority high even before auto-detect (RUM / OD / PageSpeed) * has data — and even when the `autoPreloadLCP` toggle is off, * because pinning the URL is explicit opt-in. Ordered first in * `get_all_preload_data()` so it wins the normalized-URL dedup. * Fail-open: any failure returns an empty list. * * @since 2.2.0 * @return array List of preload items (zero or one item). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_manual_lcp_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_manual_lcp_preload_data():array{return->lcp_preload()->get_manual_lcp_preload_data();}/** * Retrieves the single auto-detected LCP image preload item. * * Resolves via the unified `resolve_auto_lcp_url()` chain * (manual picker → P0 Optimization Detective real-visit data → P1 * stored PageSpeed/RUM-field → P2 DOM-first heuristic when a buffer is * available). Emits at most one item via `prepare_preload_item()` * so \"once per URL\" holds; the item is ordered ahead of front-page * and generic meta preloads (after the manual picker item) and * participates in the normalized-URL dedup. The legacy * `image_optimisation.autoPreloadLCP` toggle enables the legacy * path unchanged; the additive `preload_settings.autoLcpPreload` * toggle (issue #1216) enables the same chain but stays off until * RUM-gated (`RUM::is_enabled()`, guarded) with an off switch. * * RUM gates only the RUM-dependent tiers (issue #1216): with the new * toggle on but RUM unsatisfied, the OD-only subset (manual + OD, * needing no RUM state) still resolves instead of dropping OD * optimisation silently. The P2 heuristic is buffer-only by design — * this `wp_head` path passes no buffer, so heuristic heroes never * preload here; the buffer path (`maybe_preload_hero_image()`) * emits the companion preload link in the same pass it marks the * hero eager, keeping exclusion and emission consistent. * * @since 2.0.0 * @since 2.2.0 Resolves via the unified `resolve_auto_lcp_url()` chain * (OD → stored PageSpeed → heuristic) with a text-LCP guard; emits at * most one item. Adds the RUM-gated `preload_settings.autoLcpPreload` * path (off by default, manual lists win, never lazy+high). * @since 2.2.0 RUM gates only the RUM-dependent tiers: with RUM * unsatisfied the OD-only subset still resolves. * @return array List of preload items (zero or one item). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_auto_lcp_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_auto_lcp_preload_data():array{return->lcp_preload()->get_auto_lcp_preload_data();}/** * Look up breakpoint srcset/sizes for a resolved LCP URL. * * Thin wrapper over `get_responsive_lcp_candidate()` (issue #1429) * for the `wp_head` emission path: when OD breakpoints (or RUM * field data) resolve the same normalized URL, the breakpoint pair * wins over the attachment lookup; otherwise returns an empty pair * so callers fall back to the legacy single-href data. Fail-open to * an empty pair on any failure. * * @since 2.3.0 * @param string $lcp_url The resolved LCP image URL. * @param string|null $buffer Optional HTML buffer for gap-fill. * @return array{srcset: string, sizes: string} Responsive pair. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_breakpoint_srcset_for_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_breakpoint_srcset_for_url(string,?string=null):array{return->lcp_preload()->get_breakpoint_srcset_for_url(,);}/** * Whether the RUM gate for the additive auto-LCP toggle is satisfied. * * The `preload_settings.autoLcpPreload` path (issue #1216) stays off * until real-user measurement is enabled (`RUM::is_enabled()`, * guarded with class_exists/method_exists). Fail-closed when RUM is * unavailable or disabled so detection failure degrades to the * current manual behavior; fail-open only via the legacy * `autoPreloadLCP` path handled by the caller. Never fatal. * * @since 2.2.0 * @return bool True when RUM gating passes. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_auto_lcp_rum_satisfied}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_auto_lcp_rum_satisfied():bool{return->lcp_preload()->is_auto_lcp_rum_satisfied();}/** * Resolves the currently-detected LCP image URL for the current page. * * Checks mobile strategy first, then desktop, to support responsive sites * that serve different images per viewport. Data sources (in order): * * 1. Singular post meta (`_wppo_lcp_image_url_{strategy}`). * 2. Front-page option (`wppo_front_page_lcp_{strategy}`). * 3. Transient keyed by strategy + current URL hash (`wppo_lcp_url_{strategy}_{md5}`). * * Tiers 1-3 are read via the shared * `RUM::get_stored_pagespeed_lcp_url()` helper so strategy order and * key formats stay in sync with the preload candidate path. * * When the `fieldLcpOverride` toggle is enabled, field-measured RUM data * (issue #935) is consulted between Optimization Detective and the * PageSpeed chain: the top real-user LCP URL for the current path wins * only after enough samples (default 20) and while fresh (<24h). * * @since 2.0.0 * @return string The LCP image URL, or empty string when none is stored. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_current_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_current_lcp_url():string{return->lcp_preload()->get_current_lcp_url();}/** * Current-URL key for the per-instance LCP memos (issue #1216). * * Returns `Util::get_current_url()` (fail-open to \'\' when the URL is * unresolvable, e.g. early boot or bare unit contexts): both * `get_current_lcp_url()` and the null-buffer * `get_lazy_lcp_exclusion_url()` memo compare against this key so a * long-lived instance reused across pages re-resolves per page * instead of serving the first page\'s hero everywhere. Never fatal. * * @since 2.2.0 * @return string Memo key (possibly empty). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lcp_memo_key}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lcp_memo_key():string{return->lcp_preload()->get_lcp_memo_key();}/** * P2 DOM-first heuristic LCP URL, memoized per buffer hash (issue #1216). * * Wraps `get_first_image_src_in_buffer()` so the full-HTML * `WP_HTML_Tag_Processor` scan runs once per distinct buffer per * request no matter how many callers (preload data, lazy exclusion, * hero inject) resolve the same buffer. Buffer-only by design: the * `wp_head` (null-buffer) path never fires the heuristic, so a hero * that is only heuristically detectable is marked eager in the * buffer path and its companion preload link is emitted by * `maybe_preload_hero_image()` in the same pass — emission and * exclusion stay consistent because both resolve with the buffer. * Bounded (reset past 30 entries); fail-open to \'\'. * * @since 2.2.0 * @param string $buffer HTML buffer to scan. * @return string Heuristic LCP URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_heuristic_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_heuristic_lcp_url(string):string{return->lcp_preload()->get_heuristic_lcp_url();}/** * Resolve the LCP-candidate URL excluded from lazy load (memoized per instance). * * Gated on the LCP toggles so default lazy behaviour is unchanged when * all LCP features are off: the field-measured branch needs * `fieldLcpOverride`, the stored-PageSpeed branch (shared read-only * lookup, no new scans) needs `autoPreloadLCP` or `prioritizeLCP`. * The manual per-post picker (`_wppo_lcp_preload_url`) is exempt from * the gate — pinning the hero is explicit opt-in, so the pinned URL * is always excluded from lazy load with width/height preserved. * Returns an empty string when no branch applies or nothing resolves. * Fail-open: any failure returns an empty string, never fatal. * * @since 2.0.0 * @since 2.2.0 Resolves via the unified `resolve_auto_lcp_url()` chain * so the never-lazy URL is always the same URL that gets preloaded. * The optional `$buffer` enables the P2 DOM-first heuristic tier so * `add_delay_load_img()` stays in parity with * `maybe_preload_hero_image()` (which resolves with the buffer); * without a buffer only the manual + OD + stored tiers apply. * @param array $image_optimisation Image optimisation settings. * @param string|null $buffer Optional HTML buffer for the heuristic tier. * @return string The candidate URL, or empty string when none applies. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_lazy_lcp_exclusion_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_lazy_lcp_exclusion_url(array,?string=null):string{return->lcp_preload()->get_lazy_lcp_exclusion_url(,);}/** * Get the effective excludeFirstImages count, preferring OD measured data. * * When OD is available and enabled, returns the measured count (1-3) * from viewport groups; otherwise returns the stored heuristic. The * `lcp_first_n` setting (default 3) takes precedence over the legacy * `excludeFirstImages` key. The result is filterable via * `wppo_lcp_first_n` (manual preload list / lazy-threshold override * when detection is inconclusive) and clamped to 0-10. When the * `lcp_guardrails` kill-switch is explicitly disabled, returns 0 so * the first-N never-lazy pass is skipped. Fail-open: any filter * failure falls back to the unfiltered count. * * @since 2.0.0 * @param array $image_optimisation Image optimisation settings. * @return int Exclude count. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_effective_exclude_first_images_count}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_effective_exclude_first_images_count(array):int{return->lcp_preload()->get_effective_exclude_first_images_count();}/** * Retrieves front page preload data if enabled. * * @since 1.5.1 * @param array $image_optimisation Image optimization configuration. * @return array List of preload items for the front page. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_front_page_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_front_page_preload_data(array):array{return->lcp_preload()->get_front_page_preload_data();}/** * Retrieves preload data from post meta. * * @since 1.5.1 * @return array List of preload items from meta. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_meta_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_meta_preload_data():array{return->lcp_preload()->get_meta_preload_data();}/** * Retrieves preload data for specific post types. * * @since 1.5.1 * @param array $image_optimisation Image optimization configuration. * @return array List of preload items for the post type. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_post_type_preload_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_post_type_preload_data(array):array{return->lcp_preload()->get_post_type_preload_data();}/** * Retrieves the URL of the featured image for the current post type. * * @since 1.0.0 * * @param int $thumbnail_id The ID of the thumbnail image. * @return string The URL of the image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_image_url_by_post_type}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_image_url_by_post_type(int):string{return->lcp_preload()->get_image_url_by_post_type();}/** * Check if an image should be excluded from preloading or optimization. * * @since 1.0.0 * * @param string $image_url The URL of the image. * @param array $exclude_img_urls Array of URLs to exclude. * @return bool True if the image should be excluded, false otherwise. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::should_exclude_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionshould_exclude_image(string,array):bool{return->lcp_preload()->should_exclude_image(,);}/** * Parse srcset data from an image tag. * * @since 1.5.1 * @param string $srcset The srcset string from the image tag. * @param array $image_optimisation Image optimization configuration array. * @return array Array of parsed sources: array( \'url\' => string, \'width\' => int ). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::parse_srcset_data}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionparse_srcset_data(,):array{return->lcp_preload()->parse_srcset_data(,);}/** * Retrieves preload data items from an image\'s srcset. * * Capped at MAX_LCP_PRELOADS (issue #1216) so one post-type hero * can never expand to N media-variant links. * * @since 1.5.1 * @since 2.2.0 Keeps the largest MAX_LCP_PRELOADS widths (the likely * hero variants) instead of the smallest; media ranges are generated * after the slice so coverage stays gapless. * @param string $srcset The srcset string from the image tag. * @param string $default_image The fallback image URL. * @param array $image_optimisation Image optimization configuration array. * @return array List of preload items. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_srcset_preload_items}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_srcset_preload_items(,,):array{return->lcp_preload()->get_srcset_preload_items(,,);}/** * Prepares a URL for preloading, handling specific prefixes and resolving relative paths. * * @since 1.5.1 * @since 2.2.0 Adds optional $imagesrcset/$imagesizes for responsive LCP heroes. * @param string $img_url The original URL to prepare. * @param string $imagesrcset Optional responsive srcset for the preload link. * @param string $imagesizes Optional sizes for the preload link. * @return array Structured preload item. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::prepare_preload_item}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionprepare_preload_item(string,string=\'\',string=\'\'):array{return->lcp_preload()->prepare_preload_item(,,);}/** * Generates a preload link for a given image URL. * * Signal-driven single-preload entry point (issue #1273): with an * empty `$img_url` the stable RUM/OD candidate from * `get_stable_signal_lcp_url()` is used, so exactly one * `<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">` is emitted * per URL per request (shared `has/mark_preload_emitted()` dedup, * also consulted by `preload_images()` and the Critical-CSS * field-LCP path). The emitted URL is recorded in the per-request * emitted set so `add_delay_load_img()` exempts it from lazy-load * in the same response. Guards: per-post `_wppo_disable_auto_lcp` * meta suppresses the signal-resolved path; image-ness and * same-origin validators apply to every candidate; failures emit * nothing (fail-open, never broken markup). The `fetchpriority` * attribute is emitted directly (legacy-safe; no new core API * required — core gap-fill paths stay `function_exists()`-guarded * elsewhere). * * @since 1.0.0 * @since 2.2.0 Resolves the stable signal candidate when empty, * enforces per-URL dedup + per-post disable + lazy-exclusion * coupling with `fetchpriority=\"high\"`. * * @param string $img_url The URL of the image to preload. Empty resolves the stable signal candidate. * @return void * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::generate_img_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functiongenerate_img_preload(=\'\'){->lcp_preload()->generate_img_preload();}/** * Whether a `sizes` value already includes the `auto` keyword. * * Uses the Core helper (WP 6.7+) when available and falls back to a regex * mirroring its \"auto first in the list\" behaviour. * * @since 1.8.0 * * @param string $sizes The `sizes` attribute value. * @return bool True when the value already starts with `auto`. */functionsizes_attribute_includes_auto(string):bool{if(function_exists(\'wp_sizes_attribute_includes_valid_auto\')){returnwp_sizes_attribute_includes_valid_auto();}return(bool)preg_match(\'/^\\s*auto\\b/i\',);}/** * Whether an <img> tag qualifies for the auto-sizes enhancement. * * Auto-sizes requires a srcset (so the browser has candidates to choose from) * and explicit dimensions (so layout is stable and CLS is prevented). * * @since 1.8.0 * * @param \\WP_HTML_Tag_Processor $tags The tag processor instance. * @return bool True when the tag supports auto-sizes. */functiontag_supports_auto_sizes():bool{return(null!==->get_attribute(\'srcset\')||null!==->get_attribute(\'data-srcset\'))&&null!==->get_attribute(\'width\')&&null!==->get_attribute(\'height\');}/** * Prepares the value stored in `data-sizes` so auto-sizes can be restored. * * When the current WP version supports auto-sizes and the image qualifies * (srcset + width + height), any static `sizes` value is prefixed with * `auto, ` as a progressive enhancement; values that already include a valid * `auto` keyword (Core\'s \"auto, …\" output) are preserved verbatim. Otherwise * the value is returned unchanged so pre-6.7 behaviour is untouched. * * @since 1.8.0 * * @param string $sizes The original `sizes` attribute value. * @param \\WP_HTML_Tag_Processor $tags The tag processor instance used for the srcset/width/height checks. * @return string The value to store in `data-sizes`. */functionprepare_auto_sizes_value(string,):string{if(!Util::is_auto_sizes_available()||!->tag_supports_auto_sizes()){return;}if(->sizes_attribute_includes_auto()){return;}return\'auto, \'.;}/** * Query core for its loading/fetchpriority/decoding decision for an image. * * Single-sourced wrapper around `wp_get_loading_optimization_attributes()` * (WP 6.3+). All call-sites route through here so core owns the loading * decision (threshold/exception rules included) and the plugin only * fills gaps. Fail-open: returns an empty array when the function is * missing or throws, so callers fall back to internal lazy/high logic * and markup is emitted unoptimised, never fatal. Output transform * only, hence multisite-safe by construction. * * @since 2.2.0 * * @param array $tag_attr Image attributes (src/width/height/loading/decoding/fetchpriority). * @param string $context Context string passed to core (kept per call-site: * \'wp-html-tag-processor\', \'regex-fallback\', or * \'performance_optimisation_delay_load\'). * @return array Core\'s loading/fetchpriority/decoding/sizes triple (possibly empty). */functionmerge_core_loading_attributes(array,string):array{if(!function_exists(\'wp_get_loading_optimization_attributes\')){returnarray();}try{=wp_get_loading_optimization_attributes(\'img\',,);}catch(\\Throwable){unset();returnarray();}if(!is_array()){returnarray();}=array(\'loading\',\'fetchpriority\',\'decoding\',\'sizes\');returnarray_intersect_key(,array_flip());}/** * Enforce one valid loading/fetchpriority/decoding triple per element. * * Core-parity invariant: never pair `loading=\"lazy\"` with * `fetchpriority=\"high\"`. When both are present the high hint is * dropped so the hero gets high+eager and below-fold gets lazy. * * @since 2.2.0 * * @param array $attrs Triple to sanitize (loading/fetchpriority/decoding). * @return array Sanitized triple. */functionsanitize_loading_triple(array):array{if(isset([\'loading\'],[\'fetchpriority\'])&&\'lazy\'===[\'loading\']&&\'high\'===[\'fetchpriority\']){unset([\'fetchpriority\']);}return;}/** * Sets loading optimization attributes (fetchpriority, decoding) on a tag processor. * * Uses wp_get_loading_optimization_attributes() (WP 6.7+) when available, * falling back to manual attribute assignment. Also handles occluded * detection (Image Prioritizer) when core returns fetchpriority low for * below-fold images. * * @since 2.0.0 * @since 2.2.0 Excluded images pass `$allow_lazy = false` so core\'s * `loading=\"lazy\"` is never stamped on an image the user excluded from * lazy-loading; the exclusion wins and the high-priority default applies. * * @param \\WP_HTML_Tag_Processor $tags The tag processor instance. * @param array $defaults Default attributes to set if core function is unavailable. * @param bool $allow_lazy Whether core may contribute `loading=\"lazy\"`. * @return void */functionset_loading_optimization_attributes(,array=array(),bool=true):void{=array();if(->is_core_loading_optimization_available()){=array();=->get_attribute(\'src\');if(null!==){[\'src\']=;}=->get_attribute(\'width\');if(null!==){[\'width\']=(int);}=->get_attribute(\'height\');if(null!==){[\'height\']=(int);}=->get_attribute(\'loading\');if(null!==){[\'loading\']=;}=->get_attribute(\'decoding\');if(null!==){[\'decoding\']=;}=->get_attribute(\'fetchpriority\');if(null!==){[\'fetchpriority\']=;}=->merge_core_loading_attributes(,\'wp-html-tag-processor\');=->sanitize_loading_triple();if(!&&isset([\'loading\'])&&\'lazy\'===[\'loading\']){unset([\'loading\']);}if(isset([\'loading\'])&&null===->get_attribute(\'loading\')){->set_attribute(\'loading\',[\'loading\']);}if(isset([\'fetchpriority\'])&&null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',[\'fetchpriority\']);}if(isset([\'decoding\'])&&null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',[\'decoding\']);}if(isset([\'sizes\'])&&\'lazy\'===->get_attribute(\'loading\')&&null===->get_attribute(\'sizes\')){->set_attribute(\'sizes\',[\'sizes\']);}}if(isset([\'fetchpriority\'])&&null===->get_attribute(\'fetchpriority\')){if(!(\'lazy\'===->get_attribute(\'loading\')&&\'high\'===[\'fetchpriority\'])){->set_attribute(\'fetchpriority\',[\'fetchpriority\']);}}if(isset([\'decoding\'])&&null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',[\'decoding\']);}if(\'lazy\'===->get_attribute(\'loading\')&&\'high\'===->get_attribute(\'fetchpriority\')){->remove_attribute(\'fetchpriority\');}}/** * Whether missing-alt autofill is enabled. * * Off by default (fail-open): when disabled `process_img_tag()` * returns byte-identical HTML with respect to `alt`. The value is * filterable via `wppo_auto_alt_enabled` for host-level overrides. * * @since 2.0.0 * * @return bool True when missing `alt` attributes should be derived. */functionis_auto_alt_enabled():bool{=!empty(->options[\'image_optimisation\'][\'autoAltText\']);if(function_exists(\'apply_filters\')){/** * Filter whether missing-alt autofill is enabled. * * @since 2.0.0 * @param bool $enabled Whether autofill is enabled. */=(bool)apply_filters(\'wppo_auto_alt_enabled\',);}return;}/** * Derive a human-readable alt candidate from an image URL filename. * * Deterministic and offline: basename → strip `-{width}x{height}` * thumbnail suffix → replace `-/_/+/.` with spaces → collapse * whitespace → title-case. Returns an empty string when no usable * filename remains (e.g. `data:` URIs, query-only URLs). Makes no * external HTTP requests and no database queries. * * @since 2.0.0 * * @param string $src The image `src` URL. * @return string The filename-derived alt, or empty string. */functionfilename_to_alt(string):string{if(\'\'===||1===preg_match(\'#^data:image/#i\',)){return\'\';}=;if(function_exists(\'wp_parse_url\')){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==){=;}}else{=strpos(,\'#\');if(false!==){=substr(,0,);}=strpos(,\'?\');if(false!==){=substr(,0,);}}=basename((string));if(\'\'===){return\'\';}=pathinfo(,PATHINFO_FILENAME);if(!is_string()||\'\'===){return\'\';}=(string)preg_replace(\'/-\\d+x\\d+$/\',\'\',);=str_replace(array(\'-\',\'_\',\'+\',\'.\'),\' \',);=trim((string)preg_replace(\'/\\s+/\',\' \',));if(\'\'===){return\'\';}if(function_exists(\'sanitize_text_field\')){=sanitize_text_field();=trim();if(\'\'===){return\'\';}}if(function_exists(\'mb_substr\')){=mb_substr(,0,125);}else{=substr(,0,125);}=trim();if(\'\'===){return\'\';}if(function_exists(\'mb_convert_case\')){returnmb_convert_case(mb_strtolower(,\'UTF-8\'),MB_CASE_TITLE,\'UTF-8\');}returnucwords(strtolower());}/** * Read the bounded persistent src-to-title map for derived alt text. * * @since 2.0.0 * @return array<string, string> */staticfunctionget_derived_alt_map():array{try{=function_exists(\'get_current_blog_id\')?(int)get_current_blog_id():0;if(array_key_exists(,self::)&&is_array(self::[])){returnself::[];}=function_exists(\'wp_using_ext_object_cache\')?!wp_using_ext_object_cache():true;if(&&function_exists(\'wp_cache_get\')){=wp_cache_get(Util::transient_key(\'wppo_derived_alt_map\'),\'wppo\');if(is_array()){self::[]=;return;}}if(function_exists(\'get_transient\')){=get_transient(Util::transient_key(\'wppo_derived_alt_map\'));=is_array()?:array();if(&&function_exists(\'wp_cache_set\')){wp_cache_set(Util::transient_key(\'wppo_derived_alt_map\'),,\'wppo\',DAY_IN_SECONDS);}self::[]=;return;}}catch(\\Throwable){unset();}returnarray();}/** * Store one src-to-title entry in the bounded persistent map. * * Deferred (audit #1338): entries buffer per request and persist once * on shutdown, so a page with N new images issues one write instead * of N read-modify-writes on the render path. Capped at 200 entries * (drop-oldest) with a day TTL so the map cannot grow unbounded. * * @since 2.0.0 * @param string $src Image src URL. * @param string $title Resolved title (may be \'\'). * @return void */staticfunctionset_derived_alt_map_entry(string,string):void{try{=substr(,0,2048);=substr(,0,200);if(\'\'===){return;}=function_exists(\'get_current_blog_id\')?(int)get_current_blog_id():0;if(!isset(self::[])||!is_array(self::[])){self::[]=array();if(count(self::)>10){self::=array_slice(self::,-10,null,true);}}self::[][]=;if(count(self::[])>200){self::[]=array_slice(self::[],-200,null,true);}if(!self::&&function_exists(\'add_action\')){add_action(\'shutdown\',array(__CLASS__,\'commit_derived_alt_map\'));self::=true;}}catch(\\Throwable){unset();}}/** * Persist buffered alt-map entries (shutdown handler). * * Merges the request buffer into the persistent map in one write. * Fail-open: any failure drops the buffer silently. * * @since 2.2.0 * @return void */staticfunctioncommit_derived_alt_map():void{try{do{if(empty(self::)){break;}=self::;self::=array();self::=false;=function_exists(\'get_current_blog_id\')?(int)get_current_blog_id():0;foreach(as=>){if(!is_array()||empty()){continue;}=(int);=false;if(!==&&function_exists(\'switch_to_blog\')){switch_to_blog();=function_exists(\'restore_current_blog\');}try{=Util::transient_key(\'wppo_derived_alt_map\');=self::get_derived_alt_map();foreach(as=>){[]=;}if(count()>200){=array_slice(,-200,200,true);}self::[]=;=function_exists(\'wp_using_ext_object_cache\')?!wp_using_ext_object_cache():true;if(&&function_exists(\'wp_cache_set\')){wp_cache_set(,,\'wppo\',DAY_IN_SECONDS);}if(function_exists(\'set_transient\')){set_transient(,,DAY_IN_SECONDS);}}catch(\\Throwable){unset();}finally{if(){restore_current_blog();}}}}while(!empty(self::));if(!empty(self::)&&!self::&&function_exists(\'add_action\')){add_action(\'shutdown\',array(__CLASS__,\'commit_derived_alt_map\'));self::=true;}}catch(\\Throwable){unset();}}/** * Derive a deterministic alt for an image `src`. * * Primary source is the sanitized filename (`filename_to_alt()`); * when that yields nothing, falls back to the title of the image * attachment\'s parent post (resolved from `$src`, not global loop * context, and cached per request so each unique src is looked up at * most once). The result is filterable via `wppo_auto_alt_text` and * always sanitized, trimmed, and capped at 125 chars. Never performs * external HTTP; the title lookup runs only when the filename path * produced nothing. Fail-open: any failure returns an empty string * (caller then leaves the tag untouched). * * @since 2.0.0 * * @param string $src The image `src` URL. * @return string The derived alt, or empty string when none applies. */functionget_derived_alt(string):string{=->filename_to_alt();if(\'\'===&&function_exists(\'wp_get_post_parent_id\')&&function_exists(\'get_the_title\')&&function_exists(\'attachment_url_to_postid\')){try{static=array();if(!array_key_exists(,)){=self::get_derived_alt_map();if(array_key_exists(,)){[]=[];}else{=(int)attachment_url_to_postid();=>0?(int)wp_get_post_parent_id():0;=>0?get_the_title():\'\';if(function_exists(\'sanitize_text_field\')){=sanitize_text_field((string));}[]=is_string()?trim():\'\';self::set_derived_alt_map_entry(,[]);}}if(\'\'!==[]){=[];}}catch(\\Throwable){}}if(function_exists(\'apply_filters\')){/** * Filter the derived alt text for images missing an alt attribute. * * @since 2.0.0 * @param string $alt The derived alt text (may be empty). * @param string $src The image `src` URL. */=apply_filters(\'wppo_auto_alt_text\',,);if(is_string()){=;}}if(function_exists(\'sanitize_text_field\')){=sanitize_text_field();}if(function_exists(\'mb_substr\')){=mb_substr(,0,125);}else{=substr(,0,125);}returntrim();}/** * Autofill a missing `alt` via Tag Processor (fail-open, byte-identical when off). * * Only fills when the toggle is on AND the tag has no `alt` attribute * at all (`get_attribute()` returns `null`). An explicit empty * `alt=\"\"` is treated as an intentional decorative image and left * untouched. Tag Processor escapes the value on serialize. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor $tags Processor positioned on the `<img>` tag. * @param string $original_src The original image `src` value. * @return void */functionmaybe_autofill_alt_processor(,string):void{if(!->is_auto_alt_enabled()){return;}if(null!==->get_attribute(\'alt\')){return;}=->get_derived_alt();if(\'\'!==){->set_attribute(\'alt\',);}}/** * Autofill a missing `alt` via regex fallback (fail-open, byte-identical when off). * * Presence check is `#(?<![\\w-])alt\\s*=#i`, so both `alt=\"x\"` and decorative * `alt=\"\"` are preserved verbatim while hyphenated `data-alt` attributes * do not count as an `alt`. Escapes at emit because the regex * path concatenates raw strings. * * @since 2.0.0 * * @param string $img_tag The original `<img>` tag HTML. * @param string $original_src The original image `src` value. * @return string The tag with a derived `alt`, or unchanged. */functionmaybe_autofill_alt_regex(string,string):string{if(!->is_auto_alt_enabled()){return;}if(1===preg_match(\'#(?<![\\w-])alt\\s*=#i\',)){return;}=->get_derived_alt();if(\'\'===){return;}=function_exists(\'esc_attr\')?esc_attr():htmlspecialchars(,ENT_QUOTES,\'UTF-8\');=preg_replace(\'#<img\\b#i\',\'<img alt=\"\'..\'\"\',,1);returnnull===?:;}/** * Optimize an <img> tag for lazy loading, placeholders, dimensions, and performance attributes. * * If the image URL matches any exclusion substring, ensures the tag has `decoding=\"sync\"` and * `fetchpriority=\"high\"` (if missing) and returns the tag unchanged otherwise. For non-excluded * images, moves `src` → `data-src`, `srcset` → `data-srcset`, and `sizes` → `data-sizes` * (skipping `data:image/*` sources), optionally replaces `src` with an SVG placeholder, and * populates missing `width`/`height` attributes from the local file when available. * * @since 1.0.0 * * @param string $img_tag The original <img> tag HTML. * @param string $original_src The original value of the image `src` attribute. * @param string[] $exclude_imgs Array of URL substrings; if any is found in `$original_src` the image is treated as excluded. * @return string The modified <img> tag. */functionprocess_img_tag(,,){if(class_exists(\'WP_HTML_Tag_Processor\')){if(!empty()){foreach(as){if(\'\'!==&&false!==strpos(,)){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'high\',\'decoding\'=>\'sync\',),false);->maybe_autofill_alt_processor(,);return->get_updated_html();}return;}}}=!empty(->options[\'image_optimisation\'][\'lazyLoadNative\']);=new\\WP_HTML_Tag_Processor();if(!->next_tag(array(\'tag_name\'=>\'img\'))){return;}if(null===->get_attribute(\'data-src\')){=htmlspecialchars_decode(,ENT_QUOTES);if(!preg_match(\'#^data:image/#i\',)){if(||\'lazy\'===->get_attribute(\'loading\')){if(null===->get_attribute(\'loading\')){=true;if(function_exists(\'wp_get_loading_optimization_attributes\')){=array();=->get_attribute(\'src\');if(null!==){[\'src\']=;}=->get_attribute(\'width\');if(null!==){[\'width\']=(int);}=->get_attribute(\'height\');if(null!==){[\'height\']=(int);}=->merge_core_loading_attributes(,\'wp-html-tag-processor\');=isset([\'loading\']);}if(){->set_attribute(\'loading\',\'lazy\');}}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');}if(null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'low\',\'decoding\'=>\'async\',));if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}if(\'none\'!==->get_placeholder_type()){=->get_native_lazy_placeholder_attrs(,);foreach(as=>){if(null===->get_attribute()){->set_attribute(->normalize_data_attribute_name(),);}}}}else{if(function_exists(\'wp_get_loading_optimization_attributes\')&&null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes();if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}elseif(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}->set_attribute(\'data-src\',);if(\'none\'!==->get_placeholder_type()){=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){=->get_updated_html();=function_exists(\'esc_attr\')?esc_attr([\'src\']):[\'src\'];=preg_replace(\'#(?<!data-)src=([\"\\\'])[^\"\\\']*\\1#i\',\'src=\"\'..\'\"\',,1);if(null===){=;}=new\\WP_HTML_Tag_Processor();->next_tag(array(\'tag_name\'=>\'img\'));foreach([\'attrs\']as=>){->set_attribute(,);}=->get_updated_html();=new\\WP_HTML_Tag_Processor();->next_tag(array(\'tag_name\'=>\'img\'));}else{->remove_attribute(\'src\');}}else{->remove_attribute(\'src\');}=->get_attribute(\'srcset\');if(){->set_attribute(\'data-srcset\',);->remove_attribute(\'srcset\');}=->get_attribute(\'sizes\');if(){->set_attribute(\'data-sizes\',->prepare_auto_sizes_value(,));->remove_attribute(\'sizes\');}}}}=null!==->get_attribute(\'width\');=null!==->get_attribute(\'height\');if(!||!){=Util::get_local_path();if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){=->get_cached_image_size();if(is_array()){if(!){->set_attribute(\'width\',(string)[0]);}if(!){->set_attribute(\'height\',(string)[1]);}}}}->maybe_autofill_alt_processor(,);return->get_updated_html();}else{if(!empty()){foreach(as){if(\'\'!==&&false!==strpos(,)){if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}if(preg_match(\'/\\bloading=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'loading\']=[2];}if(preg_match(\'/\\bdecoding=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'decoding\']=[2];}if(preg_match(\'/\\bfetchpriority=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'fetchpriority\']=[2];}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'regex-fallback\'));if(isset([\'loading\'])&&\'lazy\'===[\'loading\']){unset([\'loading\']);}if(isset([\'loading\'])&&false===strpos(,\'loading\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 loading=\"\'.esc_attr([\'loading\']).\'\"\',);}if(isset([\'decoding\'])&&false===strpos(,\'decoding\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 decoding=\"\'.esc_attr([\'decoding\']).\'\"\',);}if(isset([\'fetchpriority\'])&&false===strpos(,\'fetchpriority\')){=false!==stripos(,\'loading=\"lazy\"\')||false!==stripos(,\"loading=\'lazy\'\");if(!(&&\'high\'===[\'fetchpriority\'])){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"\'.esc_attr([\'fetchpriority\']).\'\"\',);}}}else{if(false===strpos(,\'decoding\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 decoding=\"sync\"\',);}if(false===strpos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"high\"\',);}}return->maybe_autofill_alt_regex(,);}}}=!empty(->options[\'image_optimisation\'][\'lazyLoadNative\']);if(false===strpos(,\'data-src\')){=htmlspecialchars_decode(,ENT_QUOTES);if(preg_match(\'#^data:image/#i\',)){return->maybe_autofill_alt_regex(,);}if(||1===preg_match(\'/\\bloading=[\"\\\']lazy[\"\\\']/i\',)){if(false===stripos(,\'loading=\')){=true;if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}=->merge_core_loading_attributes(,\'regex-fallback\');=isset([\'loading\']);}if(){=preg_replace(\'#<img\\b#i\',\'<img loading=\"lazy\"\',);}}if(false===stripos(,\'decoding=\')){=preg_replace(\'#<img\\b#i\',\'<img decoding=\"async\"\',);}if(false===stripos(,\'fetchpriority\')){if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}if(preg_match(\'/\\bloading=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'loading\']=[2];}if(preg_match(\'/\\bdecoding=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'decoding\']=[2];}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'regex-fallback\'));if(isset([\'fetchpriority\'])&&false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"\'.esc_attr([\'fetchpriority\']).\'\"\',);}}if(false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"low\"\',);}}}else{if(false===stripos(,\'fetchpriority\')){if(function_exists(\'wp_get_loading_optimization_attributes\')){=array(\'src\'=>);if(preg_match(\'/\\bwidth=([\"\\\'])(\\d+)\\1/i\',,)){[\'width\']=(int)[2];}if(preg_match(\'/\\bheight=([\"\\\'])(\\d+)\\1/i\',,)){[\'height\']=(int)[2];}if(preg_match(\'/\\bloading=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'loading\']=[2];}if(preg_match(\'/\\bdecoding=([\"\\\'])([^\"\\\']+)\\1/i\',,)){[\'decoding\']=[2];}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'regex-fallback\'));if(isset([\'fetchpriority\'])&&false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"\'.esc_attr([\'fetchpriority\']).\'\"\',);}}if(false===stripos(,\'fetchpriority\')){=preg_replace(\'#<img\\b([^>]*?)#i\',\'<img $1 fetchpriority=\"low\"\',);}}=preg_replace_callback(\'#src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',function()use(){return\'data-src=\"\'.esc_attr().\'\"\';},);if(null!==){=;}if(\'none\'!==->get_placeholder_type()){=->get_placeholder_src_for_image(,);if(!empty([\'src\'])){=preg_replace_callback(\'#<img\\b([^>]*)#i\',function()use(){=\'\';foreach([\'attrs\']as=>){.=\' \'..\'=\"\'.esc_attr().\'\"\';}return\'<img src=\"\'.esc_attr([\'src\']).\'\"\'..[1];},);if(null!==){=;}}}if(preg_match(\'#srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=preg_replace(\'#srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-srcset=\"\'.esc_attr([1]).\'\"\',);}if(preg_match(\'#\\bsizes=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=[1];if(Util::is_auto_sizes_available()&&!->sizes_attribute_includes_auto()){=(bool)preg_match(\'#\\b(?:data-)?srcset=[\"\\\']#i\',);=(bool)preg_match(\'/\\bwidth=[\"\\\']\\d+[\"\\\']/i\',);=(bool)preg_match(\'/\\bheight=[\"\\\']\\d+[\"\\\']/i\',);if(&&&&){=\'auto, \'.;}}=preg_replace(\'#\\bsizes=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-sizes=\"\'.esc_attr().\'\"\',);}}}=false;=false;if(1===preg_match(\'/\\bwidth\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+width\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(1===preg_match(\'/\\bheight\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',,)){=trim([1],\"\\\"\' \\t\\n\\r\\0\\x0B\");=is_numeric();if(!){=(string)preg_replace(\'/\\s+height\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]+)/i\',\'\',,1);}}if(!||!){=Util::get_local_path();if(!empty()&&->cached_file_exists()&&is_readable()&&is_file()){=->get_cached_image_size();if(is_array()){if(!){=preg_replace(\'/<img\\b/i\',\'<img width=\"\'.(int)[0].\'\"\',);}if(!){=preg_replace(\'/<img\\b/i\',\'<img height=\"\'.(int)[1].\'\"\',);}}}}return->maybe_autofill_alt_regex(,);}}/** * Extract the YouTube video ID from an iframe src URL. * * @since 2.0.0 * * @param string $src The iframe src URL. * @return string The video ID, or empty string if not a YouTube embed. */functionget_youtube_video_id(string):string{if(preg_match(\'#(?:youtube(?:-nocookie)?\\.com/embed/|youtu\\.be/)([a-zA-Z0-9_-]{11})#i\',,)){return[1];}return\'\';}/** * Generate a lightweight video placeholder HTML for a YouTube iframe. * * Replaces the YouTube embed iframe with a static thumbnail and play button. * The actual iframe is loaded only on user click via JavaScript. * * @since 2.0.0 * * @param string $iframe_tag The original <iframe> tag HTML. * @param string $original_src The original src attribute value. * @param string $video_id Optional pre-extracted YouTube video ID. * @return string The placeholder HTML or the original iframe tag if excluded. */functiongenerate_video_placeholder(string,string,string=\'\'):string{=->sanitize_comment_images_in_buffer();if(!empty(->exclude_lazy_videos)){foreach(->exclude_lazy_videosas){if(false!==strpos(,)){return;}}}=apply_filters(\'wppo_video_placeholder_allowed\',true,,);if(!){return;}if(empty()){=->get_youtube_video_id();}if(empty()){return;}=false!==strpos(,\'youtube-nocookie.com\')?\'youtube-nocookie\':\'youtube\';=\'https://img.youtube.com/vi/\'..\'/maxresdefault.jpg\';=\'https://img.youtube.com/vi/\'..\'/hqdefault.jpg\';=\'<noscript>\'..\'</noscript>\';=\'<button type=\"button\" class=\"wppo-video-play-btn\" aria-label=\"\'.esc_attr__(\'Play video\',\'performance-optimisation\').\'\"> <svg aria-hidden=\"true\" focusable=\"false\" width=\"68\" height=\"48\" viewBox=\"0 0 68 48\"> <path class=\"wppo-play-btn-bg\" d=\"M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-.13,27.1-1.55c2.93-.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z\" fill=\"#f00\"></path> <path d=\"M 45,24 27,14 27,34\" fill=\"#fff\"></path> </svg> </button>\';=has_filter(\'wppo_video_play_button_html\');=has_filter(\'wppo_video_placeholder_html\');=apply_filters(\'wppo_video_play_button_html\',,,);=array(\'id\',\'class\',\'sandbox\',\'referrerpolicy\',\'title\',\'name\',\'frameborder\',\'allow\',\'allowfullscreen\');=array();if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){foreach(as){=->get_attribute();if(null!==){[]=;}}}}=!empty()?wp_json_encode():\'\';=sprintf(esc_attr__(\'Video thumbnail (%s)\',\'performance-optimisation\'),esc_attr());=\'<div class=\"wppo-video-placeholder\" data-wppo-video-src=\"\'.esc_url().\'\" data-wppo-video-type=\"\'.esc_attr().\'\"\'.(?\' data-wppo-iframe-attrs=\"\'.esc_attr().\'\"\':\'\').\'> \'..\' <picture> <img src=\"\'.esc_url().\'\" alt=\"\'..\'\" width=\"1280\" height=\"720\" loading=\"lazy\" data-wppo-fallback=\"\'.esc_url().\'\"> </picture> \'..\' </div>\';=apply_filters(\'wppo_video_placeholder_html\',,,,);if(){=->ensure_video_play_button_label();=->ensure_video_thumbnail_alt(,);}elseif(){=->ensure_video_play_button_label();}return;}/** * Default accessible name for a video thumbnail image. * * Single home for the sprintf( __( \'Video thumbnail (%s)\' ) ) * construction used by the placeholder default markup and both * repair paths, so translator comments cannot drift between copies. * Returns the raw translated string — callers escape for their sink * (Tag Processor set_attribute() escapes on output; regex splices * use esc_attr()). * * @since 2.2.0 * @param string $video_id YouTube video ID. * @return string Default thumbnail alt text. */functiondefault_video_thumbnail_alt(string):string{returnsprintf(__(\'Video thumbnail (%s)\',\'performance-optimisation\'),);}/** * Re-inject the default accessible name on video play buttons. * * Parses the given HTML with WP_HTML_Tag_Processor and repairs every * <button> that lacks both aria-label and aria-labelledby and has no * text content, adding the default aria-label. Markup that already * names the control (either attribute or visible text) is returned * untouched so third-party button HTML survives except the repair. * * Trusted-filter contract: this only re-adds accessible names — it is * not a sanitizer. Filter-supplied HTML (event handlers, * javascript: URLs, inner active content) passes through unchanged; * filters are privileged code, so no new XSS frontier is introduced, * but future untrusted callers must sanitize separately. * * @since 2.2.0 * @param string $html Button or placeholder HTML to validate. * @return string Validated HTML with accessible button names. */functionensure_video_play_button_label(string):string{if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}=array();if(preg_match_all(\'/<button\\b[^>]*>(.*?)<\\/button>/is\',,)){foreach([1]as){[]=trim((string)wp_strip_all_tags());}}=0;if(preg_match_all(\'/<button\\b/i\',,)){=count([0]);}if(count()!==){return;}=new\\WP_HTML_Tag_Processor();=0;=false;while(->next_tag(array(\'tag_name\'=>\'button\'))){=[]??\'\';++;=->get_attribute(\'aria-label\');=->get_attribute(\'aria-labelledby\');if((is_string()&&\'\'!==trim())||(is_string()&&\'\'!==trim())){continue;}if(\'\'!==){continue;}->set_attribute(\'aria-label\',__(\'Play video\',\'performance-optimisation\'));=true;}return?(string)->get_updated_html():;}/** * Re-inject the default alt on the video thumbnail image. * * Primary target is the placeholder thumbnail (identified by its * data-wppo-fallback attribute) so the verbatim <noscript> embed is * never rewritten. When no marked thumbnail exists — e.g. a * placeholder filter stripped the marker or swapped the thumbnail — * falls back to the first image outside any <noscript> block so the * repair cannot silently no-op. Images with a non-empty alt are left * untouched. * * Trusted-filter contract: this only re-adds the alt text — it is * not a sanitizer (see ensure_video_play_button_label()). * * @since 2.2.0 * @param string $html Placeholder HTML to validate. * @param string $video_id YouTube video ID used in the default alt. * @return string Validated HTML with a meaningful thumbnail alt. */functionensure_video_thumbnail_alt(string,string):string{if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}=;=array();=preg_replace_callback(\'#<noscript\\b[^>]*>.*?</noscript>#is\',staticfunction()use(&,){=\'<!--wppo-noscript-\'.count().\'-\'.md5(.count()).\'-->\';[]=[0];return;},);if(is_string()){=;}=new\\WP_HTML_Tag_Processor();=false;=false;while(->next_tag(array(\'tag_name\'=>\'img\'))){if(null===->get_attribute(\'data-wppo-fallback\')){continue;}=true;=->get_attribute(\'alt\');if(is_string()&&\'\'!==trim()){continue;}->set_attribute(\'alt\',->default_video_thumbnail_alt());=true;}if(||){=?(string)->get_updated_html():;if(!empty()){=strtr(,);}return;}return->ensure_first_content_image_alt(,);}/** * Repair the alt of the first image outside any <noscript> block. * * Fallback for ensure_video_thumbnail_alt() when the placeholder * thumbnail marker is gone. <noscript> ranges and <img> positions are * located by byte offset so the verbatim no-JS embed is never * touched; the repair splices an alt attribute into the first * content image that lacks a non-empty one. Fail-open: any parse * failure returns the input unchanged. * * @since 2.2.0 * @param string $html Placeholder HTML to validate. * @param string $video_id YouTube video ID used in the default alt. * @return string HTML with the fallback image alt repaired, or unchanged. */functionensure_first_content_image_alt(string,string):string{try{=array();if(preg_match_all(\'#<noscript\\b[^>]*>.*?</noscript>#is\',,,PREG_OFFSET_BYTES)){foreach([0]as){[]=array([1],[1]+strlen([0]));}}if(!preg_match_all(\'#<img\\b[^>]*>#i\',,,PREG_OFFSET_BYTES)){return;}foreach([0]as){=[0];=[1];=false;foreach(as){if(>=[0]&&<[1]){=true;break;}}if(){continue;}if(preg_match(\'/\\balt\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]*)?/i\',,)){=trim([1]??\'\',\"\\\"\' \\t\\n\\r\\0\\x0B\");if(\'\'!==){return;}=preg_replace(\'/\\balt\\s*=\\s*(\"[^\"]*\"|\\\'[^\\\']*\\\'|[^\\s>]*)?/i\',\'alt=\"\'.esc_attr(->default_video_thumbnail_alt()).\'\"\',,1);}elseif(preg_match(\'/\\balt(?=\\s|\\/?>)/i\',)){=preg_replace(\'/\\balt(?=\\s|\\/?>)/i\',\'alt=\"\'.esc_attr(->default_video_thumbnail_alt()).\'\"\',,1);}else{=preg_replace(\'/<img\\b/i\',\'<img alt=\"\'.esc_attr(->default_video_thumbnail_alt()).\'\"\',,1);}if(!is_string()){return;}returnsubstr(,0,)..substr(,+strlen());}}catch(\\Throwable){unset();}return;}/** * Prepare an <iframe> tag for lazy loading and exclusion-aware optimization. * * If the iframe\'s source matches any exclusion substring, the tag is returned unchanged. * When native lazy loading is active (lazyLoadNative), the `src` attribute is preserved and * `loading=\"lazy\"` is added so the browser handles deferral (matching how core\'s * wp_get_loading_optimization_attributes() treats images). Otherwise the function moves * `src` to `data-src`, removes the `src` attribute, and ensures the `wppo-lazyload` class is * present for the JS IntersectionObserver path. Uses WP_HTML_Tag_Processor when available and * falls back to regex-based attribute manipulation. * * @since 1.0.0 * @since 2.0.0 Native lazy-load path for iframes. * * @param string $iframe_tag The original `<iframe>` tag HTML. * @param string $original_src The original `src` attribute value (absolute or relative URL). * @param string[] $exclude_imgs List of substrings; if any appear in `$original_src` the tag is left unchanged. * @return string The modified `<iframe>` tag HTML. */functionprocess_iframe_tag(,,){if(!empty()){foreach(as){if(\'\'!==&&false!==strpos(,)){return;}}}=apply_filters(\'wppo_lazyload_iframe_allowed\',true,,);if(!){return;}if(1===preg_match(\'#fetchpriority\\s*=\\s*[\"\\\']?high[\"\\\']?#i\',)){if(class_exists(\'WP_HTML_Tag_Processor\')){try{=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){=->get_attribute(\'loading\');if((is_string()&&\'lazy\'===strtolower(trim()))||null===){->set_attribute(\'loading\',\'eager\');}=->get_updated_html();if(is_string()&&\'\'!==){return;}}}catch(\\Throwable){unset();}}if(1===preg_match(\'#loading\\s*=\\s*[\"\\\']?lazy[\"\\\']?#i\',)){=preg_replace(\'#loading\\s*=\\s*[\"\\\']?lazy[\"\\\']?#i\',\'loading=\"eager\"\',,1);if(is_string()&&\'\'!==){return;}}elseif(false===stripos(,\'loading=\')){=preg_replace(\'#<iframe\\b#i\',\'<iframe loading=\"eager\"\',,1);if(is_string()&&\'\'!==){return;}}return;}=!empty(->options[\'image_optimisation\'][\'lazyLoadNative\']);if(){if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'lazy\');}=->get_updated_html();}}elseif(false===stripos(,\'loading=\')){=preg_replace(\'#<iframe\\b#i\',\'<iframe loading=\"lazy\"\',);if(null!==){=;}}return;}if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'iframe\'))){->set_attribute(\'data-src\',);->remove_attribute(\'src\');->add_class(\'wppo-lazyload\');=->get_updated_html();}}else{=(string)preg_replace_callback(\'/\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']/i\',staticfunction(){=htmlspecialchars_decode([1],ENT_QUOTES);=function_exists(\'esc_attr\')?esc_attr():;return\'data-src=\"\'..\'\"\';},);if(preg_match(\'/class=[\"\\\']([^\"\\\']+)[\"\\\']/\',,)){=htmlspecialchars_decode([1],ENT_QUOTES);=function_exists(\'esc_attr\')?esc_attr():;=str_replace([0],\'class=\"\'..\' wppo-lazyload\"\',);}else{=preg_replace(\'/<iframe\\b/i\',\'<iframe class=\"wppo-lazyload\"\',);}}return;}/** * Whether the current request advertises AVIF support via Accept header. * * Fail-open contract lives with the caller: when false, AVIF sources * are omitted and WebP/original delivery is used instead. * * @since 2.0.0 * * @return bool True when the request Accept header allows image/avif. */functionclient_accepts_avif():bool{if(!isset([\'HTTP_ACCEPT\'])){returnfalse;}if(!function_exists(\'wp_unslash\')||!function_exists(\'sanitize_text_field\')){returnfalse;}=sanitize_text_field(wp_unslash([\'HTTP_ACCEPT\']));returnfalse!==strpos(,\'image/avif\');}/** * Build AVIF-first <source> tags for a <picture> wrapper. * * Emits `<source type=\"image/avif\">` first, `<source type=\"image/webp\">` * second, and falls back to a single original-MIME source when no * converted file exists (fail-open, never fatal). The AVIF source is * only emitted when the request Accept header allows AVIF and the * converted `.avif` file exists; the fallback `<img>` (with * width/height intact) is left to the caller. The original source * file is never deleted, so delivery stays restorable. * * Shared by the TagProcessor and regex-fallback wrap paths. * * @since 2.0.0 * * @param string $original_src Original image URL. * @param string $srcset Raw srcset value from the processed img (may be empty). * @param string $sizes Raw sizes value from the processed img (may be empty). * @param bool $is_lazy Whether lazy attributes (data-srcset/data-sizes) are in use. * @param bool $should_exclude Whether the image is excluded from conversion. * @return string One or more <source> tags. */functionbuild_avif_first_sources(string,string=\'\',string=\'\',bool=false,bool=false):string{=?\'data-srcset\':\'srcset\';=?\'data-sizes\':\'sizes\';=Util::get_image_mime_type();if(||empty(->options[\'image_optimisation\'][\'convertImg\'])){if(\'\'!==){=\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}return.\'>\';}return\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\">\';}=->get_img_converter();=method_exists(,\'get_format\')?->get_format():\'webp\';=!empty(->options[\'image_optimisation\'][\'avifFirst\']??true);=->client_accepts_avif();=array();if(\'\'!==){foreach(explode(\',\',)as){=array_pad(preg_split(\'/\\s+/\',trim(),2),2,\'\');if(\'\'!==[0]){[]=array(\'url\'=>[0],\'descriptor\'=>[1],);}}}if(empty()){[]=array(\'url\'=>,\'descriptor\'=>\'\',);}=\'\';if(&&in_array(,array(\'avif\',\'both\'),true)&&){=array();=false;foreach(as){=->get_img_path([\'url\'],\'avif\');if(\'\'!==&&->cached_file_exists()){=->get_img_url([\'url\'],\'avif\');[]=.(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');=true;}else{[]=[\'url\'].(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');}}if(){.=\'<source type=\"image/avif\" \'..\'=\"\'.esc_attr(implode(\', \',)).\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}.=\'>\';}}if(in_array(,array(\'webp\',\'both\'),true)){=array();=false;foreach(as){=->get_img_path([\'url\'],\'webp\');if(\'\'!==&&->cached_file_exists()){=->get_img_url([\'url\']);[]=.(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');=true;}else{[]=[\'url\'].(\'\'!==[\'descriptor\']?\' \'.[\'descriptor\']:\'\');}}if(){.=\'<source type=\"image/webp\" \'..\'=\"\'.esc_attr(implode(\', \',)).\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}.=\'>\';}}if(\'\'===){if(\'\'!==){=\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\"\';if(\'\'!==){.=\' \'..\'=\"\'.esc_attr().\'\"\';}return.\'>\';}return\'<source type=\"\'..\'\" \'..\'=\"\'.esc_attr().\'\">\';}return;}/** * Wraps an image in a <picture> element or updates an existing <picture> by adding appropriate <source> * attributes for optimized delivery and lazy-loading based on current options and exclusions. * * Processes the provided image tag (or the <img> inside an existing <picture>) and returns the resulting * HTML fragment. Honors the configured wrapInPicture option and skips adding <source> descriptors when * the image URL matches any entry in the exclusion list. * * @since 1.0.0 * * @param array $matches Regex match array containing the matched <img> or <picture> fragment. * @param string $img_tag The original <img> tag to process. * @param string $original_src The original src attribute value of the image. * @param array $exclude_imgs List of URL substrings; if any is present in the image URL, source descriptors are not added. * @return string The processed <picture> or <img> HTML fragment (or the original fragment if unchanged). */functionprocess_picture_tag(,,,){=false;foreach(as){if(\'\'!==&&false!==strpos(,)){=true;break;}}if(class_exists(\'WP_HTML_Processor\')){=new\\WP_HTML_Processor([0]);if(null===->get_last_error()&&->next_tag(array(\'tag_name\'=>\'picture\'))){=->get_current_depth();=null;=null;=false;while(->next_tag()){if(->get_current_depth()<=){break;}if(\'IMG\'===->get_tag()&&!->is_tag_closer()){=->get_attribute(\'data-srcset\')??->get_attribute(\'srcset\');=->get_attribute(\'data-sizes\')??->get_attribute(\'sizes\');=null!==->get_attribute(\'data-src\');}}=new\\WP_HTML_Processor([0]);->next_tag(array(\'tag_name\'=>\'picture\'));=->get_current_depth();while(->next_tag()){if(->get_current_depth()<=){break;}if(\'SOURCE\'===->get_tag()&&!->is_tag_closer()){->set_attribute(\'type\',Util::get_image_mime_type());if(!){if(){->set_attribute(?\'data-srcset\':\'srcset\',);}if(){->set_attribute(?\'data-sizes\':\'sizes\',);}}}}=->get_updated_html();if(preg_match(\'#<img\\b[^>]*>#i\',[0],)){=[0];=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))&&null!==->get_attribute(\'data-src\')){if(\'none\'!==->get_placeholder_type()){=->get_attribute(\'data-src\')??\'\';=->get_placeholder_src_for_image(,htmlspecialchars_decode(,ENT_QUOTES));if(!empty([\'src\'])){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){->set_attribute(\'src\',[\'src\']);foreach([\'attrs\']as=>){->set_attribute(,);}return->get_updated_html();}}}return;}=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');if(){=;}}=->process_img_tag(,,);++->picture_counter;=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){if(1===->picture_counter){if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'high\');}}else{if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');}if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}=->get_updated_html();}returnpreg_replace_callback(\'#<img\\b[^>]*>#i\',function()use(){return;},,1);}return;}}if(class_exists(\'WP_HTML_Tag_Processor\')){if(!preg_match(\'#<picture\\b[^>]*>.*?</picture>#is\',[0])){=->process_img_tag(,,);if(!isset(->options[\'image_optimisation\'][\'wrapInPicture\'])||(bool)->options[\'image_optimisation\'][\'wrapInPicture\']){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'data-srcset\')??->get_attribute(\'srcset\');=->get_attribute(\'data-sizes\')??->get_attribute(\'sizes\');=null!==->get_attribute(\'data-src\');=->build_avif_first_sources(,(string)(??\'\'),(string)(??\'\'),,);=\'<picture>\'...\'</picture>\';}}return;}elseif(preg_match(\'#<img\\b[^>]*>#i\',[0],)){=[0];=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))&&null!==->get_attribute(\'data-src\')){if(\'none\'!==->get_placeholder_type()){=->get_attribute(\'data-src\')??\'\';=->get_placeholder_src_for_image(,htmlspecialchars_decode(,ENT_QUOTES));if(!empty([\'src\'])){=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))&&null===->get_attribute(\'src\')){->set_attribute(\'src\',[\'src\']);foreach([\'attrs\']as=>){->set_attribute(,);}returnstr_replace(,->get_updated_html(),[0]);}}}return[0];}=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');if(){=;}}=->process_img_tag(,,);returnstr_replace(,,[0]);}return[0];}else{if(!preg_match(\'#<picture\\b[^>]*>.*?</picture>#is\',[0])){=->process_img_tag(,,);if(!isset(->options[\'image_optimisation\'][\'wrapInPicture\'])||(bool)->options[\'image_optimisation\'][\'wrapInPicture\']){=\'\';if(preg_match(\'#\\b(?:data-)?srcset=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=[1];}=\'\';if(preg_match(\'#\\b(?:data-)?sizes=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=[1];}=(bool)strpos(,\'data-src\');=->build_avif_first_sources(,(string),(string),,);=\'<picture>\'...\'</picture>\';}return;}else{preg_match(\'#<img\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#i\',[0],);if(!empty()){=[0];=[2];=->process_img_tag(,,);returnpreg_replace(\'#<img\\b[^>]*?>#i\',,[0]);}}return[0];}}/** * Post-render LCP image prioritization (optional enhancement). * * When the \"prioritizeLCPImages\" toggle is enabled, this filter callback * runs on the finalized HTML (WP 6.9+ template-enhancement output buffer, * or the legacy outermost output buffer on older WP) and: * * 1. Removes `loading=\"lazy\"` from the first N images (matching the * `excludeFirstImages` heuristic) so above-the-fold images load eagerly. * 2. Sets `fetchpriority=\"high\"` on the detected LCP <img> unless the * attribute already exists, preserving core\'s own loading-optimization * decisions and the plugin\'s existing excludeFirstImages handling. * The matched node never keeps `loading=\"lazy\"` alongside * `fetchpriority=\"high\"`. * 3. When the `cssHeroPreload` toggle is enabled and the LCP target is * a CSS background hero (no matching <img>), injects exactly one * `<link rel=\"preload\" as=\"image\">` tag before `</head>`. * 4. When the `occlusionFetchpriorityLow` toggle is enabled (issue * #1426), OD-measured occluded in-viewport nodes get * `fetchpriority=\"low\"` with no `loading` change, skipping the * true-LCP node so the single-high invariant holds. * * Uses `WP_HTML_Processor::serialize_token()` (public since WP 6.9) when * available, falling back to `WP_HTML_Tag_Processor` on older versions. * * @since 2.0.0 * * @param string $filtered_output The filtered output from previous callbacks. * @param string $output The raw output buffer content (unused; present * for parity with the 6.9 filter signature and * safe when used as an ob_start callback). * @return string The processed buffer. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::prioritize_lcp_in_buffer}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionprioritize_lcp_in_buffer(,=\'\'){return->lcp_preload()->prioritize_lcp_in_buffer(,);}/** * Stamp fetchpriority=\"high\" on the LCP attachment at render time. * * `wp_get_attachment_image_attributes` filter callback (issue #1234): * when the attachment being rendered matches the resolved LCP * candidate, stamps `fetchpriority=\"high\"` with `loading=\"eager\"` * (any `loading=\"lazy\"` is replaced) and `decoding=\"async\"` when * absent, so attachment images rendered by core carry the correct * priority hint without regex post-processing. Core-parity by * delegation: the hint is stamped where core builds the `<img>` * tag, so it composes with core 6.3+ loading optimization output * instead of fighting it. * * The LCP candidate reuses the existing no-new-queries chain: * manual picker + Optimization Detective real-visit data * (`resolve_od_only_lcp_url()`), then the stored chain * (`get_current_lcp_url()` — RUM-field override + stored * PageSpeed). The DOM-heuristic tier is skipped (no buffer in * filter context). The candidate is resolved at most once per * page via the `$fetchpriority_lcp_url` memo (keyed by * `get_lcp_memo_key()`), since this filter fires per image. * Core\'s stateful * `wp_get_loading_optimization_attributes()` is deliberately not * consulted here: a second direct call would double-count this * image in core\'s per-context counter and skew core\'s later * lazy/eager decisions. * * Size-aware matching: the rendered file must correspond to the * LCP candidate exactly (size suffix preserved) before stamping, * so a below-fold thumbnail reuse of the same attachment is left * lazy. The size-suffix-insensitive fallback applies only when * the requested `$size` is `\'full\'`, where whatever file core * returns for the attachment is the hero itself. * * Fail-open: any failure (unresolvable candidate, missing core * API, unexpected input) returns `$attr` unchanged, never fatal. * * @since 2.2.0 * * @param mixed $attr Image attributes (expected array). * @param mixed $attachment Attachment post object, ID, or array with ID. * @param mixed $size Requested image size. * @return mixed The (possibly stamped) attributes, unchanged on miss. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::wppo_add_fetchpriority}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionwppo_add_fetchpriority(,=null,=null){return->lcp_preload()->wppo_add_fetchpriority(,,);}/** * Resolve the LCP candidate for the render-time fetchpriority filter, memoized per page. * * Same chain as the filter needs on every image render (manual * picker + Optimization Detective via `resolve_od_only_lcp_url()`, * then the stored chain via `get_current_lcp_url()`), but resolved * at most once per page: the result is cached in * `$fetchpriority_lcp_url` keyed by `get_lcp_memo_key()` so a page * with N images pays the OD/manual chain once instead of N times. * The DOM-heuristic tier is skipped (no buffer in filter context). * Fail-open to \'\'. * * @since 2.2.0 * @return string The validated LCP image URL, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::resolve_fetchpriority_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionresolve_fetchpriority_lcp_url():string{return->lcp_preload()->resolve_fetchpriority_lcp_url();}/** * Size-aware LCP candidate comparison for the fetchpriority filter. * * An exact (size-suffix-preserving) normalized match always stamps, * so the hero file itself is recognized at any requested size. A * size-suffix-insensitive match stamps only when the requested * `$size` is `\'full\'`, where the file core returns for the * attachment is the hero itself — a thumbnail/sidebar reuse of the * same attachment at a smaller size stays lazy. Fail-open to false. * * @since 2.2.0 * @param string $candidate The rendered file URL to test. * @param string $normalized_lcp Normalized LCP URL (size suffix stripped). * @param string $exact_lcp Normalized LCP URL (size suffix preserved). * @param bool $size_is_full Whether the requested image size is \'full\'. * @return bool True when the candidate corresponds to the LCP image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::fetchpriority_candidate_matches}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionfetchpriority_candidate_matches(string,string,string,bool):bool{return->lcp_preload()->fetchpriority_candidate_matches(,,,);}/** * Remove lazy-loading from the first N images in the buffer. * * Mirrors the excludeFirstImages heuristic used by add_delay_load_img() so * the same count semantics apply to the finalized HTML. Images carrying * either `src` or a JS-lazy `data-src` placeholder are counted, so a * JS-lazy hero is never missed. For each of the first N images the * transform is fail-open per node: `loading=\"lazy\"` is stripped and * replaced with `loading=\"eager\"`, JS-lazy placeholders are restored * (`data-src` to `src`, `data-srcset` to `srcset`, `data-sizes` to * `sizes`), lazy classes are removed, and `decoding=\"async\"` is * stamped when absent. Nodes that fail to parse keep their markup. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param array $image_optimisation Image optimization settings. * @return string The buffer with lazy-loading removed from the first N images. */functionunlazyload_first_images(string,array):string{=->get_effective_exclude_first_images_count();if(<=0){return;}try{=new\\WP_HTML_Tag_Processor();=0;=false;while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');=->get_attribute(\'data-src\');if((null===||\'\'===)&&(null===||\'\'===)){continue;}++;if(>){break;}try{if(->restore_js_lazy_placeholders()){=true;}if(->remove_lazy_classes()){=true;}if(\'lazy\'===->get_attribute(\'loading\')){->remove_attribute(\'loading\');=true;}if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'eager\');=true;}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');=true;}}catch(\\Throwable){unset();continue;}}=?->get_updated_html():;return->promote_eager_picture_sources();}catch(\\Throwable){return;}}/** * Restore JS-lazy placeholder attributes on the current tag. * * Promotes `data-src` to `src`, `data-srcset` to `srcset` and * `data-sizes` to `sizes` (non-empty values only; empty placeholders * are dropped). Fail-open per attribute: any failure leaves the tag * untouched. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor|\\WP_HTML_Processor $tags The tag processor matched on an <img>. * @return bool True when any attribute was changed. */functionrestore_js_lazy_placeholders():bool{=false;try{=->get_attribute(\'data-src\');if(is_string()&&\'\'!==){->set_attribute(\'src\',);->remove_attribute(\'data-src\');=true;}=->get_attribute(\'data-srcset\');if(null!==){if(\'\'!==){->set_attribute(\'srcset\',(string));}->remove_attribute(\'data-srcset\');=true;}=->get_attribute(\'data-sizes\');if(null!==){if(\'\'!==){->set_attribute(\'sizes\',(string));}->remove_attribute(\'data-sizes\');=true;}}catch(\\Throwable){unset();}return;}/** * Promote lazy `<source>` placeholders inside `<picture>` blocks whose * IMG was stamped eager. * * `WP_HTML_Tag_Processor` is forward-only and exposes no parent node, * so responsive heroes are handled in a second pass: for each * `<picture>` block containing a `loading=\"eager\"` image, sibling * `<source data-srcset>`/`data-sizes` placeholders are promoted to * `srcset`/`sizes`. Fail-open: any parse failure returns the buffer * unchanged. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @return string The buffer with eager-picture sources promoted. */functionpromote_eager_picture_sources(string):string{try{if(false===stripos(,\'<picture\')){return;}if(->should_use_html_processor()){=->promote_eager_picture_sources_with_processor();if(null!==){return;}}=preg_replace_callback(\'#<picture\\b[^>]*>.*?</picture>#is\',function(array):string{return->promote_eager_sources_in_block([0]);},);returnis_string()?:;}catch(\\Throwable){return;}}/** * Promote lazy `<source>` placeholders inside a single `<picture>` block. * * Shared by the `serialize_token()` builder path and the regex fallback * so both stay behaviorally identical: blocks without an eager image * pass through untouched, otherwise sibling `<source data-srcset>` / * `data-sizes` placeholders are promoted. Fail-open per tag. * * @since 2.2.0 * * @param string $block Serialized `<picture>...</picture>` block. * @return string The block with eager-picture sources promoted. */functionpromote_eager_sources_in_block(string):string{if(false===stripos(,\'loading=\"eager\"\')&&false===stripos(,\"loading=\'eager\'\")){return;}=preg_replace_callback(\'#<source\\b[^>]*>#i\',function(array):string{try{if(!class_exists(\'WP_HTML_Tag_Processor\')){return[0];}=new\\WP_HTML_Tag_Processor([0]);if(!->next_tag(array(\'tag_name\'=>\'source\'))){return[0];}if(!->restore_js_lazy_placeholders()){return[0];}return->get_updated_html();}catch(\\Throwable){unset();return[0];}},);returnis_string()?:;}/** * Promote eager-picture sources via the WP 6.9+ HTML API token stream. * * Walks tokens with `serialize_token()` and depth tracking to extract * each outer `<picture>` block, then delegates per-block promotion to * {@see promote_eager_sources_in_block()}. Returns null when the * processor is unavailable or the token stream ends with a parse * error so the caller falls back to the byte-identical regex path. * * @since 2.2.0 * * @param string $buffer The HTML buffer. * @return string|null The buffer with sources promoted, or null on failure. */functionpromote_eager_picture_sources_with_processor(string):?string{=Util::create_html_processor();if(null===){returnnull;}try{=\'\';=false;=0;=\'\';while(->next_token()){=->get_token_type();if(\'#tag\'!==){=(string)->serialize_token();if(){.=;}else{.=;}continue;}=->is_tag_closer();=->get_tag();if(!&&\'PICTURE\'===&&!){=true;=1;=(string)->serialize_token();continue;}if(){.=(string)->serialize_token();if(\'PICTURE\'===){if(!){++;}else{--;if(0===){.=->promote_eager_sources_in_block();=false;=\'\';=0;}}}continue;}.=(string)->serialize_token();}}catch(\\Throwable){unset();returnnull;}if(method_exists(,\'get_last_error\')&&null!==->get_last_error()){returnnull;}if(&&\'\'!==){.=->promote_eager_sources_in_block();}return;}/** * Strip JS-lazy placeholder classes from the current IMG tag. * * Removes `wppo-lazy`, `wppo-lazyload`, `lazyload`, `lazyloaded`, * `lazyloading` and the bare `lazy` token (exact-token match, so * classes like `lazy-button` are preserved) while keeping all other * classes. No-op when the tag carries no class attribute. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor|\\WP_HTML_Processor $tags The tag processor matched on an <img>. * @return bool True when a class token was stripped. */functionremove_lazy_classes():bool{=->get_attribute(\'class\');if(null===){returnfalse;}=array(\'wppo-lazy\',\'wppo-lazyload\',\'lazyload\',\'lazyloaded\',\'lazyloading\',\'lazy\');=preg_split(\'/\\s+/\',(string),-1,PREG_SPLIT_NO_EMPTY);if(!is_array()){returnfalse;}=array_values(array_diff(,));if(count()===count()){returnfalse;}if(empty()){->remove_attribute(\'class\');}else{->set_attribute(\'class\',implode(\' \',));}returntrue;}/** * Stamp the hero (LCP) triple: high fetchpriority + eager loading. * * Merges core\'s fetchpriority/decoding decision first (gap-fill only, * never core\'s loading value), then forces eager + high so the hero is * never lazy. Guarantees one valid triple per element (never lazy+high). * * @since 2.2.0 * * @param object $tags Tag/HTML processor positioned on the hero <img>. * @return bool True when any attribute was added, changed, or removed. */functionstamp_hero_loading_triple():bool{=false;=null;=null;if(function_exists(\'wp_get_loading_optimization_attributes\')&&null===->get_attribute(\'decoding\')){=array();=->get_attribute(\'src\');if(null===){=->get_attribute(\'data-src\');}if(null!==){[\'src\']=;}=->sanitize_loading_triple(->merge_core_loading_attributes(,\'wp-html-tag-processor\'));if(isset([\'fetchpriority\'])&&\'high\'===[\'fetchpriority\']){=\'high\';}if(isset([\'decoding\'])){=[\'decoding\'];}}if(->restore_js_lazy_placeholders()){=true;}if(->remove_lazy_classes()){=true;}if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',null!==?:\'high\');=true;}if(\'lazy\'===->get_attribute(\'loading\')){->remove_attribute(\'loading\');=true;}if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'eager\');=true;}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',null!==?:\'async\');=true;}return;}/** * Set fetchpriority=\"high\" on the detected LCP image. * * Stamps `fetchpriority=\"high\"` on the matching <img> only when no fetchpriority * attribute already exists, so core\'s wp_get_loading_optimization_attributes() * output and the plugin\'s existing excludeFirstImages high-priority assignment * are never double-applied. The matched LCP image is also un-lazy-loaded so an * in-viewport LCP image is actually fetched eagerly at high priority: * `loading=\"lazy\"` is replaced with `loading=\"eager\"` and * `decoding=\"async\"` is stamped when absent (progressive enhancement, * ignored by old browsers). * * @since 2.0.0 * @since 2.2.0 Callers pass the unified `resolve_auto_lcp_url()` target so * the never-lazy/fetchpriority stamp always matches the preloaded URL. * * @param string $buffer The HTML buffer. * @param string|null $lcp_url Optional pre-resolved LCP URL. When null the * URL is resolved via resolve_auto_lcp_url() * (same-origin guarded OD/stored/heuristic * chain; the stored tier internally reads * get_current_lcp_url()). * @return string The buffer with fetchpriority=\"high\" on the LCP image. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::prioritize_lcp_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionprioritize_lcp_image(string,?string=null):string{return->lcp_preload()->prioritize_lcp_image(,);}/** * Hero fallback: ensure the first-viewport image preloads with fetchpriority=high and is never lazy. * * When stored LCP data exists the companion preload link is emitted for * it; when detection fails the first <img src> in the buffer is treated * as the hero (eager, fetchpriority high, never data-src lazy). Core\'s * wp_get_loading_optimization_attributes() decision is honoured — gaps * are only filled. Fail-open: any failure returns the buffer unchanged. * * @since 2.0.0 * @since 2.2.0 Resolves via the unified `resolve_auto_lcp_url()` chain * (OD → stored PageSpeed → in-viewport heuristic) and emits at most * one preload link with `imagesrcset` when the hero carries a srcset. * Accepts a pre-resolved LCP URL so all buffer passes share one target. * * @param string $buffer The HTML buffer. * @param array $image_optimisation Image optimisation settings. * @param string|null $lcp_url Optional pre-resolved LCP URL. When null * the URL is resolved via resolve_auto_lcp_url(). * @return string The buffer with hero preload link injected. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::maybe_preload_hero_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionmaybe_preload_hero_image(string,array,?string=null):string{return->lcp_preload()->maybe_preload_hero_image(,,);}/** * Get the first <img src> URL in the buffer (hero fallback). * * DOM-order only (not viewport-aware): iterates `<img>` tags and * returns the first non-trivial candidate, skipping tracking pixels, * hidden nodes, and tiny dimensions so a logo/pixel does not consume * the preload slot. Used only when no OD/stored LCP data exists. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @return string First image src, or empty string when none found. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_first_image_src_in_buffer}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_first_image_src_in_buffer(string):string{return->lcp_preload()->get_first_image_src_in_buffer();}/** * Whether a heuristic `<img>` candidate is trivial (pixel/hidden/tiny). * * Skips tracking pixels (`pixel`/`tracking`/`spacer`/`1x1` in the URL), * hidden nodes (`hidden` attribute or `display:none` / * `visibility:hidden` inline style), and tiny dimensions (`width` / * `height` attributes <= 10px). Fail-open: any failure returns false. * * @since 2.2.0 * @param \\WP_HTML_Tag_Processor $tags The tag processor on the candidate `<img>`. * @param string $src The candidate src URL. * @return bool True when the candidate should be skipped. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::is_trivial_heuristic_image}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionis_trivial_heuristic_image(,string):bool{return->lcp_preload()->is_trivial_heuristic_image(,);}/** * Whether the buffer already contains a preload link for the image URL. * * Scans `<link>` tags whose `rel` token list contains `preload` and * whose `as` attribute is either `image` or absent, then compares the * normalized href (absolute-vs-relative agnostic) plus the raw query * string, so versioned assets (`hero.jpg?v=1` vs `hero.jpg?v=2`) * emit distinct hints. WordPress size-suffix variants only collapse * when the requested URL itself carries a size suffix — a * `hero-300x200.jpg` preload never suppresses the full-size * `hero.jpg` hint. Fail-open: any parse failure returns false (emit * the hint) rather than skipping it. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param string $url The image URL to look for. * @return bool True when a matching preload link exists. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::buffer_has_image_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionbuffer_has_image_preload(string,string):bool{return->lcp_preload()->buffer_has_image_preload(,);}/** * Tag Processor scan for an existing image preload link (WP 6.2+). * * Single-pass `next_tag()` traversal over `<link>` with * `get_attribute()` reads, so the happy path never runs `preg_replace` * on `<link>` tags. Mirrors the regex fallback matching exactly (rel * token list contains `preload`, any present quoted `as` value — * including an empty string — must equal `image` while an absent or * boolean `as` counts as image-eligible, normalized-href plus * raw-query comparison with size-suffix rules). * Returns null when the processor is unavailable or throws so the * caller falls through to the regex fallback. Fail-open: any parse * failure returns null (caller then runs the legacy scan). * * @since 2.2.0 * @param string $buffer The HTML buffer. * @param string $needle Normalized target URL. * @param string $needle_exact Normalized target URL without size-suffix collapsing. * @param bool $needle_has_sizes Whether the target itself carries a size suffix. * @param string $needle_query Raw query string of the target URL. * @return bool|null True/false on success, null on failure (fallback). * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::buffer_has_image_preload_with_tag_processor}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionbuffer_has_image_preload_with_tag_processor(string,string,string,bool,string):?bool{return->lcp_preload()->buffer_has_image_preload_with_tag_processor(,,,,);}/** * Get the raw query string of a URL for preload-dedup comparison. * * `normalize_image_url()` deliberately drops the query string for LCP * matching, but preload hints are per-resource: `img.jpg?v=1` and * `img.jpg?v=2` are distinct. Fail-open: any parse failure returns an * empty string. * * @since 2.0.0 * * @param string $url The URL to inspect. * @return string The query string without the leading `?`, or empty. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_url_query}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_url_query(string):string{return->lcp_preload()->get_url_query();}/** * Whether the matched image tag references the given LCP URL. * * Checks src, data-src (JS-lazy placeholder), and srcset attributes. Both * sides are normalized (scheme-relative/relative URLs resolved against * home_url(), query strings and WordPress size suffixes stripped) so that * absolute-vs-relative matches work and derived assets cannot false-positive. * * @since 2.0.0 * * @param \\WP_HTML_Tag_Processor $tags The tag processor matched on an <img>. * @param string $lcp_url The detected LCP image URL. * @return bool True if the image references the LCP URL. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::tag_matches_lcp_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functiontag_matches_lcp_url(,string):bool{return->lcp_preload()->tag_matches_lcp_url(,);}/** * Normalize an image URL for LCP matching. * * Resolves protocol-relative and root-relative URLs against home_url(), * drops the scheme and any query string, and strips WordPress generated * size suffixes (-NNNxNNN, -scaled, -eNNN) so derived assets are treated * as the same image as their full-size original. Pass * `$strip_size_suffix = false` to keep the suffix (used by preload * dedup, which only collapses size variants when the requested URL * itself carries one). * * @since 2.0.0 * * @param string $url The raw URL to normalize. * @param bool $strip_size_suffix Whether to strip WordPress size suffixes. Default true. * @return string Normalized host + path, or an empty string when unparseable. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::normalize_image_url}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionnormalize_image_url(string,bool=true):string{return->lcp_preload()->normalize_image_url(,);}/** * Static normalization behind normalize_image_url(). * * The body touches no instance state (only Util helpers and * wp_parse_url()), so it lives here statically for the shared * preload-dedup key builder. Kept private: external callers use * has_emitted_preload()/mark_preload_emitted(). * * @since 2.2.0 * * @param string $url The image URL to normalize. * @param bool $strip_size_suffix Whether to strip WP size suffixes. * @return string Normalized host + path, or empty string. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::normalize_image_url_static}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */staticfunctionnormalize_image_url_static(string,bool=true):string{returnLcp_Preload::normalize_image_url_static(,);}/** * Build the dedup key for a preload item (normalized URL + query + media). * * `normalize_image_url()` deliberately drops the scheme and query * string for LCP matching, but `img.jpg?v=1` and `img.jpg?v=2` are * distinct preload resources, so the raw query string is re-attached * here: versioned duplicates each emit their own hint instead of * collapsing to one. The normalized base also strips WordPress size * suffixes, so responsive variants of the same image * (`hero-1024x768.jpg`) intentionally collapse to a single preload * hint alongside the full-size original (`hero.jpg`). Fail-open: any * parse failure falls back to the normalized URL + media key. * * @since 2.0.0 * @since 2.2.0 Delegates to build_preload_dedup_key() so the shared * cross-emitter helpers use the identical key space. * * @param string $url The raw preload URL. * @param string $media The preload media attribute. * @return string The dedup key. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::get_preload_dedup_key}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionget_preload_dedup_key(string,string):string{return->lcp_preload()->get_preload_dedup_key(,);}/** * Extract the first CSS background-image hero URL from an HTML buffer. * * Scans inline style attributes first (including the background * shorthand) for the first url() candidate, then falls back to * `<style>` blocks so stylesheet-defined heroes are also detected * (issue #1312). Skips data:, blob:, and javascript: URIs and elements * already deferred for lazy backgrounds (data-wppo-bg). Relative URLs * are resolved against the home URL so they compare against the LCP * URL. Any scan failure returns an empty string (fail-open to * heuristic). * * @since 2.0.0 * @since 2.2.0 Adds `<style>`-block fallback for stylesheet heroes. * @since 2.2.0 Adds a pre-6.2 regex fallback for inline `style=\"\"` * heroes when the HTML API is unavailable. * * @param string $buffer The HTML buffer. * @return string The hero background image URL, or empty string. */functionget_css_hero_url_from_buffer(string):string{if(\'\'===||false===stripos(,\'background\')){return\'\';}if(!->is_html_api_available()&&false===strpos(,\'style=\')&&false===stripos(,\'<style\')){return\'\';}try{if(->is_html_api_available()){=new\\WP_HTML_Tag_Processor();while(->next_tag()){if(null!==->get_attribute(\'data-wppo-bg\')){continue;}=->get_attribute(\'style\');if(!is_string()||\'\'===||false===stripos(,\'background\')){continue;}=\'\';if(preg_match(\'#background(?:-image)?\\s*:[^;]*?url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){=trim([1]);}if(\'\'===||0===stripos(,\'data:\')||0===stripos(,\'blob:\')||0===stripos(,\'javascript:\')){continue;}if(0===strpos(,\'//\')){=\'https:\'.;}elseif(0===strpos(,\'/\')||false===strpos(,\'://\')){=Util::cached_home_url().\'/\'.ltrim(,\'/\');}return;}}if(!->is_html_api_available()&&false!==strpos(,\'style=\')){=array();if(preg_match_all(\'#<[^>]+\\bstyle\\s*=\\s*([\"\\\'])(.*?)\\1[^>]*>#is\',,)&&isset([0])&&isset([2])){foreach([0]as=>){if(false!==stripos((string),\'data-wppo-bg\')){continue;}[]=[2][];}}foreach(as){if(!is_string()||\'\'===||false===stripos(,\'background\')){continue;}=\'\';if(preg_match(\'#background(?:-image)?\\s*:[^;]*?url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){=trim([1]);}if(\'\'===||0===stripos(,\'data:\')||0===stripos(,\'blob:\')||0===stripos(,\'javascript:\')){continue;}if(0===strpos(,\'//\')){=\'https:\'.;}elseif(0===strpos(,\'/\')||false===strpos(,\'://\')){=Util::cached_home_url().\'/\'.ltrim(,\'/\');}return;}}if(function_exists(\'wp_parse_url\')&&false!==stripos(,\'<style\')){=array();if(preg_match_all(\'#<style\\b[^>]*>(.*?)</style>#is\',,)&&isset([1])){=[1];}foreach(as){if(!is_string()||\'\'===||false===stripos(,\'background\')){continue;}if(1!==preg_match(\'#background(?:-image)?\\s*:[^;{]*?url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){continue;}=trim([1]);if(\'\'===||0===stripos(,\'data:\')||0===stripos(,\'blob:\')||0===stripos(,\'javascript:\')){continue;}if(0===strpos(,\'//\')){=\'https:\'.;}elseif(0===strpos(,\'/\')||false===strpos(,\'://\')){=Util::cached_home_url().\'/\'.ltrim(,\'/\');}return;}}}catch(\\Throwable){return\'\';}return\'\';}/** * Whether any img element in the buffer references the given LCP URL. * * Used to choose between the img preload path and the CSS-hero * preload path so exactly one preload link is ever emitted. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @param string $lcp_url The detected LCP image URL. * @return bool True when an img matches the LCP URL. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::buffer_has_matching_img}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionbuffer_has_matching_img(string,string):bool{return->lcp_preload()->buffer_has_matching_img(,);}/** * Inject exactly one CSS-hero preload link into the buffer head. * * When the resolved LCP target has no matching img in the buffer but * matches the first CSS background hero (inline `style=\"\"` or * `<style>`-block stylesheet hero, plus the server-side computed * `wppo_computed_css_hero_url` context), a single preload link (as * image with fetchpriority high, eager) is injected before the head * close. Emits nothing when an img hero matches (covered by the img * preload path), when the hero is unrelated to the LCP target, when * the URL is neither same-origin nor on the configured CDN, or when * the single hero slot was already claimed by any emitter * (`preload_images()`, the img companion, or a prior call). Manual * lists stay authoritative: automation only fills the gap. * * @since 2.0.0 * @since 2.2.0 Accepts a pre-resolved LCP URL so buffer passes share one * unified target instead of re-resolving stored data per pass. * @since 2.2.0 Uses the centralised hero slot, the same-origin/CDN * allowlist, stylesheet-block heroes, and the computed-URL filter. * * @param string $buffer The HTML buffer. * @param string|null $lcp_url Optional pre-resolved LCP URL. When null the * URL is resolved via resolve_auto_lcp_url() * (same-origin guarded OD/stored/heuristic * chain), matching every other emission path. * @return string The buffer with at most one added preload link. * Facade proxy (ARCH-008): logic lives in {@see Lcp_Preload::maybe_inject_css_hero_preload}. * * @since 2.4.0 Proxied to Lcp_Preload (ARCH-008). */functionmaybe_inject_css_hero_preload(string,?string=null):string{return->lcp_preload()->maybe_inject_css_hero_preload(,);}/** * Transforms <picture>, <img>, and <iframe> elements in the provided HTML to enable lazy loading and delayed loading based on the image_optimisation options. * * Applies exclusions derived from the options (including preload-selected images and the first N images specified by `excludeFirstImages`) and rewrites matched tags to use data-* attributes and lazy classes when appropriate. * YouTube embed iframes are replaced with lightweight video placeholders when the feature is enabled. * * @since 1.0.0 * * @param string $buffer The HTML buffer to process. * @return string The modified HTML buffer with lazy-load and delay-load attributes applied. */functionadd_delay_load_img(){=->options[\'image_optimisation\']??array();=->get_effective_exclude_first_images_count();=array();=!empty([\'enableVideoPlaceholder\']);=!empty([\'lazyLoadVideos\']);if(&&){=preg_replace_callback(\'#<iframe\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>\\s*</iframe>#is\',function(){=->get_youtube_video_id([2]);if(){return->generate_video_placeholder([0],[2],);}return[0];},);}=array();=->get_noscript_namespace();=preg_replace_callback(\'#<noscript>.*?</noscript>#is\',function()use(&,){=\'<!--WPPO_NOSCRIPT_\'..\'_\'.count().\'-->\';[]=->sanitize_comment_images_in_buffer([0]);return;},);if(null!==){=;}if(is_string()&&\'\'!==){=->sanitize_comment_images_in_buffer();}if(!empty([\'lazyLoadImages\'])){=->exclude_lazy_imgs;=->get_preload_images_urls();=array_unique(array_merge(,));=\'\';if(class_exists(\'PerformanceOptimise\\Inc\\OD_Bridge\')){try{if(\\PerformanceOptimise\\Inc\\OD_Bridge::is_enabled()){=\\PerformanceOptimise\\Inc\\OD_Bridge::get_lcp_url();if(\'\'!==){[]=;=Util::normalize_url();if(\'\'!==&&!in_array(,,true)){[]=;}=array_unique();}}}catch(\\Throwable){if(defined(\'WP_DEBUG\')&&WP_DEBUG){error_log(\'WPPO Image optimisation OD error: \'.str_replace(ABSPATH,\'\',->getMessage()));}}}=\'\';=(!isset([\'lcpHeroPreload\'])||!empty([\'lcpHeroPreload\']))&&(!empty([\'prioritizeLCPImages\'])||!empty([\'autoPreloadLCP\']));if(){try{=->get_current_lcp_url();if(\'\'!==&&!in_array(,,true)){[]=;=array_unique();}if(\'\'===){=->get_first_image_src_in_buffer();if(\'\'!==&&!in_array(,,true)){[]=;=array_unique();}}}catch(\\Throwable){do_action(\'wppo_debug_log\',\'WPPO hero exclusion failed: \'.->getMessage(),array(\'exception\'=>));}}=\'\';try{=->get_lazy_lcp_exclusion_url(,);if(\'\'!==){if(!in_array(,,true)){[]=;}=Util::normalize_url();if(\'\'!==&&!in_array(,,true)){[]=;}=array_unique();}}catch(\\Throwable){do_action(\'wppo_debug_log\',\'WPPO LCP candidate exclusion failed: \'.->getMessage(),array(\'exception\'=>));}=array();try{=self::get_direct_preload_normalized_urls();}catch(\\Throwable){unset();=array();}=0;=!empty([\'lazyLoadNative\']);=\'none\'!==([\'placeholderType\']??\'none\');if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();=->is_comment_hardening_enabled();while(->next_tag()){=->get_tag();if(&&is_string()&&->is_hardened_tag()){->sanitize_tag_attributes_processor();}if(\'IMG\'===||\'IMAGE\'===){=->get_attribute(\'src\');=->get_attribute(\'data-src\');if((null===||\'\'===)&&(null===||\'\'===)){continue;}++;if(>=){=(is_string()&&\'\'!==)?:;if(is_string()&&\'\'!==){[]=;}}=false;=(is_string()&&\'\'!==)?:(string);if(\'\'!==||\'\'!==||array()!==){try{=Util::normalize_url();if((\'\'!==&&===)||(\'\'!==&&===)||(\'\'!==&&in_array(,,true))){=true;}}catch(\\Throwable){unset();}}if(!){foreach(as){if(\'\'!==&&\'\'!==&&false!==strpos(,)){=true;break;}}}try{=->get_attribute(\'fetchpriority\');if(is_string()&&\'high\'===strtolower(trim())){=true;}}catch(\\Throwable){unset();}if(){try{=->get_attribute(\'loading\');if(is_string()&&\'lazy\'===strtolower(trim())){->set_attribute(\'loading\',\'eager\');}}catch(\\Throwable){unset();}->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'high\',\'decoding\'=>\'sync\',),false);->maybe_autofill_alt_processor(,);continue;}if(null!==->get_attribute(\'data-src\')){continue;}=htmlspecialchars_decode(,ENT_QUOTES);if(preg_match(\'#^data:image/#i\',)){->maybe_autofill_alt_processor(,);continue;}if(||\'lazy\'===->get_attribute(\'loading\')){if(null===->get_attribute(\'loading\')){=true;if(function_exists(\'wp_get_loading_optimization_attributes\')){=array();=->get_attribute(\'src\');if(null!==){[\'src\']=;}=->get_attribute(\'width\');if(null!==){[\'width\']=(int);}=->get_attribute(\'height\');if(null!==){[\'height\']=(int);}=->merge_core_loading_attributes(,\'performance_optimisation_delay_load\');if(!isset([\'loading\'])){=false;}}if(){->set_attribute(\'loading\',\'lazy\');}}if(null===->get_attribute(\'decoding\')){->set_attribute(\'decoding\',\'async\');}if(null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes(,array(\'fetchpriority\'=>\'low\',\'decoding\'=>\'async\',));if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}if(){=->get_native_lazy_placeholder_attrs(,,,);foreach(as=>){if(null===->get_attribute()){->set_attribute(->normalize_data_attribute_name(),);}}}}else{if(function_exists(\'wp_get_loading_optimization_attributes\')&&null===->get_attribute(\'fetchpriority\')){->set_loading_optimization_attributes();if(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}}elseif(null===->get_attribute(\'fetchpriority\')){->set_attribute(\'fetchpriority\',\'low\');}->set_attribute(\'data-src\',);->remove_attribute(\'src\');=->get_attribute(\'srcset\');if(){->set_attribute(\'data-srcset\',);->remove_attribute(\'srcset\');}=->get_attribute(\'sizes\');if(){->set_attribute(\'data-sizes\',->prepare_auto_sizes_value(,));->remove_attribute(\'sizes\');}}}elseif(\'IFRAME\'===){=->get_attribute(\'src\');if(null===){continue;}=false;foreach(as){if(false!==strpos(,)){=true;break;}}if(){continue;}=apply_filters(\'wppo_lazyload_iframe_allowed\',true,,\'\');if(!){continue;}try{=->get_attribute(\'fetchpriority\');if(is_string()&&\'high\'===strtolower(trim())){=->get_attribute(\'loading\');if(is_string()&&\'lazy\'===strtolower(trim())){->set_attribute(\'loading\',\'eager\');}elseif(null===){->set_attribute(\'loading\',\'eager\');}continue;}}catch(\\Throwable){unset();}if(){if(null===->get_attribute(\'loading\')){->set_attribute(\'loading\',\'lazy\');}continue;}->set_attribute(\'data-src\',);->remove_attribute(\'src\');->add_class(\'wppo-lazyload\');}}=->get_updated_html();=->post_process_placeholders(,);=->post_process_img_dimensions();=->post_process_auto_sizes();if(->should_use_html_processor()){=->process_picture_blocks_processor(,,,);}else{=->process_picture_blocks_regex(,,,);}}else{=preg_replace_callback(\'#<picture\\b[^>]*>.*?</picture>|<img\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>|<iframe\\b([^>]*?)src=[\"\\\']([^\"\\\']+)[\"\\\'][^>]*>#is\',function()use(&,,&){if(isset([4])){return->process_iframe_tag([0],[4],);}if(!isset([2])){=\'\';if(preg_match(\'#<img\\b[^>]*?src=[\"\\\']([^\"\\\']+)[\"\\\']#i\',[0],)){=[1];}++;if(\'\'!==&&>=){[]=;}return->process_picture_tag(,[0],,);}++;if(>=){[]=[2];}return->process_picture_tag(,[0],[2],);},);if(null!==){=->post_process_img_dimensions();=->post_process_auto_sizes();}}}elseif(->is_auto_alt_enabled()){=->autofill_alt_in_buffer();}=->restore_noscript_tokens(,);return;}/** * Autofill missing `alt` attributes across a full HTML buffer. * * Standalone pass used when lazy-loading is disabled but * `autoAltText` is enabled. Uses `WP_HTML_Tag_Processor` when * available, otherwise a regex fallback. Fail-open: returns the * buffer unchanged when disabled or on any processing failure. * * @since 2.0.0 * * @param string $buffer The HTML buffer to process. * @return string The buffer with missing `alt` attributes filled. */functionautofill_alt_in_buffer(string):string{if(!->is_auto_alt_enabled()){return;}try{if(class_exists(\'WP_HTML_Tag_Processor\')){=new\\WP_HTML_Tag_Processor();while(->next_tag(array(\'tag_name\'=>\'img\'))){=->get_attribute(\'src\');if(null===){=->get_attribute(\'data-src\');}if(null===||\'\'===){continue;}->maybe_autofill_alt_processor(,(string));}=->get_updated_html();if(is_string()){return;}return;}=preg_replace_callback(\'#<img\\b[^>]*>#i\',function(){=[0];=\'\';if(preg_match(\'#(?<![\\w-])src\\s*=\\s*(?:([\"\\\'])(.*?)\\1|([^\\s>]+))#is\',,)){=(isset([3])&&\'\'!==[3])?[3]:([2]??\'\');=htmlspecialchars_decode(,ENT_QUOTES);}if(\'\'===){return;}return->maybe_autofill_alt_regex(,);},);returnis_string()?:;}catch(\\Throwable){return;}}/** * Retrieves URLs of images to preload for lazy-load exclusion. * * @since 1.0.0 * @return array List of preload image URLs. */functionget_preload_images_urls():array{=->get_all_preload_data();=array_unique(array_column(,\'url\'));try{if(array()!==self::){=array_unique(array_merge(,array_keys(self::),self::get_direct_preload_normalized_urls()));}}catch(\\Throwable){unset();}return;}/** * Generates a base64-encoded SVG image with the given width and height. * * @since 1.0.0 * * @param string $img_attributes The image\'s attributes (including width and height). * @param string $color Optional hex fill color. Default \'#cfd4db\'. * @return string The base64-encoded SVG. */functiongenerate_svg_base64(,=\'#cfd4db\'){preg_match(\'/\\bwidth=[\"\\\']?(\\d+)[\"\\\']?/i\',,);preg_match(\'/\\bheight=[\"\\\']?(\\d+)[\"\\\']?/i\',,);=isset([1])?min(absint([1]),self::SVG_PLACEHOLDER_MAX_DIMENSION):100;=isset([1])?min(absint([1]),self::SVG_PLACEHOLDER_MAX_DIMENSION):100;=\'<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"\'..\'\" height=\"\'..\'\" viewBox=\"0 0 \'..\' \'..\'\"><rect width=\"100%\" height=\"100%\" fill=\"\'.esc_attr().\'\" /></svg>\';return\'data:image/svg+xml;base64,\'.base64_encode();}/** * Get the current placeholder type. * * @since 2.0.0 * * @return string One of \'none\', \'svg\', \'dominant_color\', \'lqip\'. */functionget_placeholder_type():string{=->options[\'image_optimisation\'][\'placeholderType\']??\'none\';=array(\'none\',\'svg\',\'dominant_color\',\'lqip\');returnin_array(,,true)?:\'none\';}/** * Get the appropriate placeholder src and extra attributes for a lazy-loaded image. * * Looks up stored placeholder data (dominant color, LQIP) from Img_Converter\'s * image info by resolving the data-src URL to a local path. * * @since 2.0.0 * * @param string $img_tag The <img> tag HTML. * @param string $data_src The data-src URL of the image. * @return array{src: string, attrs: array<string, string>} Placeholder src and extra attributes. */functionget_placeholder_src_for_image(string,string):array{=array(\'src\'=>\'\',\'attrs\'=>array(),);=->get_placeholder_type();if(\'none\'===){return;}=\'\';if(!isset(self::[])){=Util::get_local_path();if(!empty()){self::[]=str_replace(wp_normalize_path(ABSPATH),\'\',wp_normalize_path());}else{self::[]=\'\';}}=self::[];if(null===self::){self::=Img_Converter::get_placeholder_info();}=self::;if(\'svg\'===){[\'src\']=->generate_svg_base64();return;}if(\'dominant_color\'===){=[\'dominant_color\'][]??\'\';if(!empty()&&preg_match(\'/^#[a-f0-9]{6}$/i\',)){[\'src\']=\'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%221%22%20height%3D%221%22%2F%3E\';[\'attrs\'][\'data-wppo-dominant-color\']=;}else{[\'src\']=->generate_svg_base64();}return;}if(\'lqip\'===){=[\'lqip\'][]??\'\';if(!empty()){[\'src\']=;[\'attrs\'][\'data-wppo-lqip\']=\'1\';}else{[\'src\']=->generate_svg_base64();}return;}return;}/** * Whether a URL is the LCP hero image (explicit blur-exclusion check). * * Centralizes the hero matching used by add_delay_load_img(): substring * membership in the never-lazy exclusion list plus normalized-URL * equality against the OD and candidate LCP URLs (covers http/https * and WordPress size-suffix variants). Used to keep the LCP hero out * of LQIP blur even if it ever reaches the placeholder path. * * @since 2.2.0 * * @param string $url The image URL to test. * @param string[] $exclude_imgs The never-lazy exclusion list. * @param string $od_lcp_normalized Normalized OD LCP URL (or \'\'). * @param string $candidate_normalized Normalized candidate LCP URL (or \'\'). * @return bool True when the URL is the LCP hero. */functionis_lcp_hero_url(string,array,string=\'\',string=\'\'):bool{if(\'\'===){returnfalse;}=array();try{=self::get_direct_preload_normalized_urls();}catch(\\Throwable){unset();}if(\'\'!==||\'\'!==||array()!==){try{=Util::normalize_url();if((\'\'!==&&===)||(\'\'!==&&===)){returntrue;}if(\'\'!==&&in_array(,,true)){returntrue;}}catch(\\Throwable){unset();}}foreach(as){if(\'\'!==&&false!==strpos(,)){returntrue;}}returnfalse;}/** * Whether the local LQIP placeholder pipeline is enabled. * * Shares the `wppo_smart_pipeline_enabled` kill-switch filter with the * converter\'s size-compare path (issue #1158) so one filter disables * both features, and defaults from the * `image_optimisation.discardOversizedSibling` setting (like * `Img_Converter::is_smart_compress_enabled()`) so one toggle * disables both. Placeholders are server-side only (inline data-URI / * dominant-color attributes) — zero external HTTP either way. * * @since 2.2.0 * * @return bool True when LQIP placeholder emission is enabled. */functionis_local_lqip_pipeline_enabled():bool{=(bool)(->options[\'image_optimisation\'][\'discardOversizedSibling\']??true);if(function_exists(\'apply_filters\')&&function_exists(\'has_filter\')&&has_filter(\'wppo_smart_pipeline_enabled\')){/** * Filter the size-compare smart-compress + local LQIP pipeline. * * @since 2.2.0 * @param bool $enabled Whether the pipeline is enabled. */return(bool)apply_filters(\'wppo_smart_pipeline_enabled\',);}return;}/** * Placeholder attributes for a native-lazy (`loading=\"lazy\"`) image. * * The JS-lazy path swaps `src` for a placeholder via * post_process_placeholders(); native-lazy keeps the real `src` (the * browser defers it), so only the extra attributes are emitted: * `data-wppo-dominant-color` (background wash) and `data-wppo-lqip` * (blur hook consumed by lazyload.js). The LCP hero is explicitly * excluded from blur; data: URIs are never touched. Fail-open: * returns an empty array on any failure or when disabled. * * @since 2.2.0 * * @param string $src_url The image src URL. * @param string[] $exclude_imgs The never-lazy exclusion list. * @param string $od_lcp_normalized Normalized OD LCP URL (or \'\'). * @param string $candidate_normalized Normalized candidate LCP URL (or \'\'). * @return array<string, string> Extra attributes (empty when none apply). */functionget_native_lazy_placeholder_attrs(string,array,string=\'\',string=\'\'):array{try{if(!->is_local_lqip_pipeline_enabled()){returnarray();}if(\'none\'===->get_placeholder_type()){returnarray();}if(1===preg_match(\'#^data:image/#i\',htmlspecialchars_decode(,ENT_QUOTES))){returnarray();}if(->is_lcp_hero_url(,,,)){returnarray();}=->get_placeholder_src_for_image(\'<img>\',);returnis_array([\'attrs\']??null)?[\'attrs\']:array();}catch(\\Throwable){unset();returnarray();}}/** * Defer inline CSS background-image URLs until the element is near the viewport. * * Moves the `background-image` declaration into a `data-wppo-bg` attribute and * tags the element with the `wppo-lazy-bg` class so the frontend runtime can * restore it on intersection. The first N backgrounds (hero heuristics) and * data: URIs are left untouched. * * @since 2.0.0 * * @param string $buffer The HTML buffer. * @return string The processed buffer. */functionadd_delay_load_backgrounds(string):string{=->options[\'image_optimisation\']??array();if(empty([\'lazyLoadBackgroundImages\'])){return;}if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}=->get_effective_exclude_first_images_count();=0;=\'\';if(!empty([\'cssHeroPreload\'])){try{=->get_current_lcp_url();if(\'\'!==){=->normalize_image_url();}}catch(\\Throwable){=\'\';}}=new\\WP_HTML_Tag_Processor();while(->next_tag()){=->get_attribute(\'style\');if(null===||false===stripos(,\'background-image\')){continue;}if(null!==->get_attribute(\'data-wppo-bg\')){continue;}if(!preg_match(\'#background-image\\s*:\\s*([^;]+)#i\',,)){continue;}=trim([1]);if(\'\'===||false!==stripos(,\'data:\')){continue;}if(\'\'!==&&preg_match(\'#url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)#i\',,)){=trim([1]);if(\'\'!==&&0!==stripos(,\'data:\')&&->normalize_image_url()===){continue;}}++;if(<=){continue;}=(string)->get_attribute(\'class\');if(false===strpos(,\'wppo-lazy-bg\')){->set_attribute(\'class\',trim(.\' wppo-lazy-bg\'));}->set_attribute(\'data-wppo-bg\',);=trim(preg_replace(\'#background-image\\s*:\\s*[^;]+;?#i\',\'\',));if(\'\'===){->remove_attribute(\'style\');}else{->set_attribute(\'style\',);}}return->get_updated_html();}/** * Rewrites <video> elements so their media sources are deferred and restored later for lazy loading. * * Skips videos whose attributes or inner markup match configured exclusion patterns. For processed videos: * - moves `src` attributes to `data-src` (on <video> and inner <source> tags), * - removes `autoplay` and sets `data-wppo-autoplay=\"1\"` when autoplay was present, * - ensures `preload=\"none\"` is set, * - adds the `wppo-lazy-video` class, * - defers `poster` to `data-poster` for core\'s animated-GIF companion videos (WP 7.1+, the `autoplay` + `loop` + `muted` + `playsinline` + `poster` signature), which the client restores on intersect. * * @since 2.0.0 * @since 2.0.0 Defer companion-video `poster` frames to `data-poster`. * * @param string $buffer HTML markup to process. * @return string The HTML with video elements rewritten for lazy loading. */functionlazy_load_videos(string):string{=->options[\'image_optimisation\']??array();if(empty([\'lazyLoadVideos\'])){return;}=->exclude_lazy_videos;if(class_exists(\'WP_HTML_Processor\')){=true;=preg_replace_callback(\'#<video\\b([^>]*)>(.*?)</video>#is\',function()use(,&){=[0];=[1];=[2];foreach(as){if(false!==strpos(,)||false!==strpos(,)){return;}}=new\\WP_HTML_Processor();if(null===->get_last_error()&&->next_tag(array(\'tag_name\'=>\'video\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}=null!==->get_attribute(\'autoplay\')&&null!==->get_attribute(\'loop\')&&null!==->get_attribute(\'muted\')&&null!==->get_attribute(\'playsinline\');=->get_attribute(\'poster\');if(null!==->get_attribute(\'autoplay\')){->remove_attribute(\'autoplay\');->set_attribute(\'data-wppo-autoplay\',\'1\');}if(&&!empty()){->set_attribute(\'data-poster\',);->remove_attribute(\'poster\');}->set_attribute(\'preload\',\'none\');->add_class(\'wppo-lazy-video\');while(->next_tag(array(\'tag_name\'=>\'source\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}}return->get_updated_html();}=false;return;},);if(){return;}=;}if(class_exists(\'WP_HTML_Tag_Processor\')){returnpreg_replace_callback(\'#<video\\b([^>]*)>(.*?)</video>#is\',function()use(){=[1];=[2];=[0];foreach(as){if(false!==strpos(,)||false!==strpos(,)){return;}}=new\\WP_HTML_Tag_Processor();if(->next_tag(array(\'tag_name\'=>\'video\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}=null!==->get_attribute(\'autoplay\')&&null!==->get_attribute(\'loop\')&&null!==->get_attribute(\'muted\')&&null!==->get_attribute(\'playsinline\');=->get_attribute(\'poster\');if(->get_attribute(\'autoplay\')!==null){->remove_attribute(\'autoplay\');->set_attribute(\'data-wppo-autoplay\',\'1\');}if(&&!empty()){->set_attribute(\'data-poster\',);->remove_attribute(\'poster\');}->set_attribute(\'preload\',\'none\');->add_class(\'wppo-lazy-video\');}while(->next_tag(array(\'tag_name\'=>\'source\'))){=->get_attribute(\'src\');if(){->set_attribute(\'data-src\',);->remove_attribute(\'src\');}}return->get_updated_html();},);}else{returnpreg_replace_callback(\'#<video\\b([^>]*)>(.*?)</video>#is\',function()use(){=[1];=[2];foreach(as){if(false!==strpos(,)||false!==strpos(,)){return[0];}}if(preg_match(\'#\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',)){=preg_replace(\'#\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-src=\"$1\"\',);}=preg_replace(\'#(<source\\b[^>]*)\\bsrc=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'$1 data-src=\"$2\"\',);=preg_match(\'#\\bautoplay\\b#i\',);=preg_replace(\'#\\bautoplay(=[\"\\\'][^\"\\\']*[\"\\\'])?#i\',\'\',);if(){.=\' data-wppo-autoplay=\"1\"\';}if(&&preg_match(\'#\\bloop\\b#i\',)&&preg_match(\'#\\bmuted\\b#i\',)&&preg_match(\'#\\bplaysinline\\b#i\',)&&preg_match(\'#\\bposter=[\"\\\']([^\"\\\']+)[\"\\\']#i\',,)){=preg_replace(\'#\\bposter=[\"\\\']([^\"\\\']+)[\"\\\']#i\',\'data-poster=\"$1\"\',);}if(false===stripos(,\'preload\')){.=\' preload=\"none\"\';}else{=preg_replace(\'#\\bpreload=[\"\\\'][^\"\\\']*[\"\\\']#i\',\'preload=\"none\"\',);}if(false===strpos(,\'wppo-lazy-video\')){if(preg_match(\'#\\bclass=[\"\\\']([^\"\\\']*)[\"\\\']#i\',,)){=str_replace([0],\'class=\"\'.[1].\' wppo-lazy-video\"\',);}else{.=\' class=\"wppo-lazy-video\"\';}}return\"<video ></video>\";},);}}/** * Lazily render below-fold containers via content-visibility. * * Opt-in, CSS-only progressive enhancement: tags below-fold candidate * containers (builder sections, footer widgets, comments) with * `content-visibility:auto` plus a precomputed `contain-intrinsic-size` * reserve so layout stays stable (no CLS) while the browser defers * render work until the node nears the viewport. Unsupported browsers * ignore the declarations. Any failure returns markup unmodified * (fail-open). * * Hero safety: the first matching section stays eager (positional), * and any later section carrying LCP-hero markers (`fetchpriority` * high, `data-lcp`/`data-hero`, hero/LCP class or id, or an LCP * image in its scope) is skipped too, so the LCP hero never gets * `content-visibility` regardless of which section carries it. * Skipping is fail-safe (unoptimised, never fatal). * * @since 2.0.0 * * @param string $buffer The HTML buffer to process. * @return string The modified HTML buffer. */functionlazy_render_elements(string):string{=->options[\'image_optimisation\']??array();if(empty([\'lazyRenderBelowFold\'])){return;}if(!is_string()||\'\'===){return;}if(function_exists(\'is_admin\')&&is_admin()){return;}if(function_exists(\'is_user_logged_in\')&&is_user_logged_in()){return;}if((function_exists(\'is_feed\')&&is_feed())||(function_exists(\'is_preview\')&&is_preview())||(function_exists(\'is_embed\')&&is_embed())||(function_exists(\'wp_doing_ajax\')&&wp_doing_ajax())){return;}if(!class_exists(\'WP_HTML_Tag_Processor\')){return;}try{=!isset([\'lazyRenderExcludeBuilders\'])||!empty([\'lazyRenderExcludeBuilders\']);=array(\'elementor-section\',\'et_pb_section\');if(function_exists(\'apply_filters\')){=apply_filters(\'wppo_lazy_render_excluded_classes\',);if(is_array()){=array_values(array_filter(array_map(\'strval\',)));}}=\'auto 600px\';if(function_exists(\'apply_filters\')){=apply_filters(\'wppo_lazy_render_intrinsic_size\',);if(is_string()&&\'\'!==trim()){=trim();}}=array(\'elementor-section\',\'et_pb_section\',\'wp-block-group\',\'footer-widget\',\'widget-area\',\'comments-area\',\'comment-list\',);=new\\WP_HTML_Tag_Processor();=->get_lazy_render_lcp_windows();=-1;=false;while(->next_tag()){=strtoupper(->get_tag()??\'\');if(\'SECTION\'!==&&\'FOOTER\'!==&&\'ASIDE\'!==&&\'DIV\'!==){continue;}if(\'SECTION\'===||\'DIV\'===){++;}=(string)(->get_attribute(\'class\')??\'\');=(string)(->get_attribute(\'id\')??\'\');=strtolower(.\' \'.);=(\'FOOTER\'===||\'ASIDE\'===);=;if(!){foreach(as){if(false!==strpos(,)){=true;break;}}if(!&&\'comments\'===strtolower()){=true;}}if(!){continue;}if(){=preg_split(\'/\\s+/\',strtolower(),-1,PREG_SPLIT_NO_EMPTY);=is_array()?:array();foreach(as){=strtolower(trim((string)));if(\'\'!==&&in_array(,,true)){=false;break;}}if(!){continue;}}if(!&&false===strpos(,\'comment\')&&false===strpos(,\'footer\')){if(!){=true;continue;}if(->is_lazy_render_hero_tag(,)){continue;}if(isset([])&&[]){continue;}}=(string)(->get_attribute(\'style\')??\'\');if(\'\'!==&&preg_match(\'#content-visibility\\s*:#i\',)){continue;}=\'content-visibility:auto;contain-intrinsic-size:\'.;=\'\'===trim()?:rtrim(trim(),\';\').\';\'.;->set_attribute(\'style\',);}=->get_updated_html();returnis_string()&&\'\'!==?:;}catch(\\Throwable){if(function_exists(\'do_action\')){do_action(\'wppo_debug_log\',\'WPPO lazy render failed: \'.->getMessage(),array(\'exception\'=>));}return;}}/** * Whether a lazy-render candidate carries LCP-hero markers on its opening tag. * * Checks `fetchpriority=\"high\"`, `data-lcp`/`data-hero` attributes, * and hero/LCP tokens in the class/id string. Any match means the * node may hold above-fold LCP content and must stay eager. * Fail-safe: any failure returns false (caller falls back to the * positional skip and the scoped window check). * * @since 2.2.0 * * @param mixed $processor Tag processor positioned on the candidate tag. * @param string $lower_cls Lowercased class + id string of the candidate. * @return bool True when the opening tag itself marks an LCP hero. */functionis_lazy_render_hero_tag(,string):bool{try{if(is_object()&&method_exists(,\'get_attribute\')){=strtolower((string)(->get_attribute(\'fetchpriority\')??\'\'));if(\'high\'===){returntrue;}foreach(array(\'data-lcp\',\'data-hero\',\'data-od-hero\',\'data-wppo-lcp\')as){if(null!==->get_attribute()){returntrue;}}}if(false!==strpos(,\'hero\')||false!==strpos(,\'lcp\')){returntrue;}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Map section/div opening-tag order to scoped LCP presence. * * Walks the same `WP_HTML_Tag_Processor` tag stream the * `lazy_render_elements()` consumer loop walks (plain `next_tag()`, * which skips closers, comments, and RAWTEXT/RCDATA bodies such as * `<script>`/`<style>`/`<textarea>`/`<title>` contents), so the * sequence index cannot diverge from `$section_seq`: index `$i` * always describes the same opening tag in both enumerations. * Each window spans the tokens from one `<section>`/`<div>` * opener up to (but excluding) the next one, unbounded. A window * is marked when its opener or any tag inside it carries an LCP * marker (`fetchpriority=\"high\"`, `data-lcp`, `data-hero`, * `data-od-hero`, `data-wppo-lcp` as real attributes on real * tags). Lets a plain container wrapping an LCP image still count * as hero. Fail-open: any failure returns an empty map (no scoped * skips). Note the marker check is intentionally narrower than a * raw substring search: marker-looking text inside comments, * script bodies, or plain text no longer marks a window, which * only removes false-positive skips (missed optimisation), never * mistags a hero. * * @since 2.2.0 * * @param string $buffer The HTML buffer to scan. * @return bool[] LCP presence by section/div sequence index. */functionget_lazy_render_lcp_windows(string):array{try{if(\'\'===){returnarray();}=new\\WP_HTML_Tag_Processor();=array();=-1;while(->next_tag()){=strtoupper(->get_tag()??\'\');if(\'SECTION\'===||\'DIV\'===){++;[]=->processor_tag_has_lcp_marker();continue;}if(<0||!empty([])){continue;}if(->processor_tag_has_lcp_marker()){[]=true;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Whether the processor\'s current tag carries an LCP marker attribute. * * Checks `fetchpriority=\"high\"` and the `data-lcp`/`data-hero`/ * `data-od-hero`/`data-wppo-lcp` attributes. Only real attributes * on real tags match: marker-looking text inside comments, script * bodies, or attribute values does not count. * Fail-safe: any failure returns false. * * @since 2.2.0 * * @param mixed $processor Tag processor positioned on the current tag. * @return bool True when the current tag carries an LCP marker. */functionprocessor_tag_has_lcp_marker():bool{try{if(!is_object()||!method_exists(,\'get_attribute\')){returnfalse;}=strtolower((string)(->get_attribute(\'fetchpriority\')??\'\'));if(\'high\'===){returntrue;}foreach(array(\'data-lcp\',\'data-hero\',\'data-od-hero\',\'data-wppo-lcp\')as){if(null!==->get_attribute()){returntrue;}}returnfalse;}catch(\\Throwable){unset();returnfalse;}}— —
Return: ?string — Reference to the live memo.
Hooks
Hooks referenced in includes/Images/class-image-optimisation.php: Hook Type Line Notes wppo_auto_alt_enabledfilter 4875 @param ×1 wppo_auto_alt_textfilter 5186 @param ×2 wppo_video_placeholder_allowedfilter 5758 — wppo_video_play_button_htmlfilter 5795 — wppo_video_placeholder_htmlfilter 5836 — wppo_lazyload_iframe_allowedfilter 6132 — wppo_debug_logaction 7694 — wppo_debug_logaction 7720 — wppo_lazyload_iframe_allowedfilter 7953 — wppo_smart_pipeline_enabledfilter 8328 @param ×1 wppo_lazy_render_excluded_classesfilter 8747 — wppo_lazy_render_intrinsic_sizefilter 8755 — wppo_debug_logaction 8864 —