class-main.php

Performance Optimisation main functionality.

Source includes/Core/class-main.php317 min readPart of Performance Optimisation

includes/Core/class-main.php

Performance Optimisation main functionality.

Namespace: PerformanceOptimise\\Inc · Lines: 10211

Class Main

Main Class for Performance Optimisation.

Source: includes/Core/class-main.php, line 30

class Main

Tags: @since 1.0.0

Constants

ConstantVisibilityValueLine
MAX_AUTO_FONT_PRELOADSprivate2228

Properties

PropertyVisibilityTypeDefaultLine
$exclude_cssprivatearrayarray( \'wppo-combine-css\' )38
$exclude_jsprivatearrayarray( \'jquery\', )46
$exclude_defer_jsprivatearrayarray()56
$exclude_delay_jsprivatearrayarray()64
$resolved_delay_exclusionsprivate?arraynull73
$page_preset_opt_out_removeprivate?arraynull88
$delay_js_default_strategyprivatestring\'interaction\'96
$delay_js_idle_listprivatearrayarray()104
$delay_js_viewport_listprivatearrayarray()112
$delay_js_per_page_interactionprivatearrayarray()129
$delay_js_priorityprivatearrayarray()137
$delay_js_idle_timeoutprivateint3000145
$delay_disabled_for_pageprivateboolfalse156
$defer_disabled_for_pageprivateboolfalse168
$defer_disabled_page_cacheprivate staticarrayarray()181
$font_preload_emittedprivate staticarrayarray()194
$font_stamp_memoprivate staticarrayarray()208
$auto_fonts_memoprivate staticarrayarray()221
$deferred_handlesprivatearrayarray()236
$speculation_url_validity_memoprivate staticarrayarray()251
$speculation_commerce_paths_memoprivate static?arraynull263
$speculation_commerce_paths_sigprivate staticstring\'\'271
$speculation_normalize_memoprivate staticarrayarray()279
$speculation_rum_top_memoprivate staticarrayarray()289
$speculation_prerender_object_addedprivateboolfalse302
$cacheprivate?Cachenull310
$filesystemprivate\\WP_Filesystem_Base|false|nullnull321
$image_optimisationprivateImage_Optimisation—329
$google_fontsprivateGoogle_Fonts—337
$optionsprivate?arraynull352
$options_blog_idprivate?intnull366
$settings_migrationsprivate?Settings_Migrationsnull379
$script_strategyprivate?Script_Strategynull390
$minify_policyprivate?Minify\\Minify_Policynull401
$preload_buffer_coordinatorprivate?Preload_Buffer_Coordinatornull413
$server_timing_template_startprivatefloat0.0421
$instanceprivate static?Mainnull434
$delay_disabled_page_cacheprivate staticarrayarray()447

publicstatic get_instance()

public static function get_instance(): ?Main

Get the current Main instance (null before construction / in tests).

Return: Main|null.

Tags: @since 2.0.0

Source: includes/Core/class-main.php, line 455

publicstatic reset_instance()

public static function reset_instance(): void

Clear the tracked Main instance (test isolation).

Return: void.

Tags: @since 2.0.0

Source: includes/Core/class-main.php, line 465

public get_options()

public function get_options(): array

Get the resolved plugin options (lazy).

Return: array — Resolved plugin options.

Tags: @since 2.4.0 · @since NEXT Delegates effective resolution to Settings_Store.

Source: includes/Core/class-main.php, line 491

public refresh_options()

public function refresh_options(?array=null $settings): void

Invalidate the memoized options so the next read re-resolves.

ParameterTypeDefaultDescription
$settings?array=null—Optional resolved settings to adopt. Null drops the memo.

Return: void.

Tags: @since 2.4.0

Source: includes/Core/class-main.php, line 520

publicstatic on_settings_add()

public static function on_settings_add($option, $value): void

Invalidate the singleton options memo when `wppo_settings` is added.

ParameterTypeDefaultDescription
$optionstring—Option name.
$valuemixed—Option value.

Return: void.

Tags: @since 2.4.0

Source: includes/Core/class-main.php, line 544

publicstatic create_cache()

public static function create_cache(array $options, ?Image_Optimisation=null $image_optimisation, ?Google_Fonts=null $google_fonts)

Create the static-cache collaborator with a test seam.

ParameterTypeDefaultDescription
$optionsarray—Plugin options passed to Cache.
$image_optimisation?Image_Optimisation=null—Live collaborator, or null to build the equivalent lazily from the resolved options.
$google_fonts?Google_Fonts=null—Live collaborator, or null to build the equivalent lazily from the resolved options.

Return: mixed — Cache instance (or filtered stub).

Tags: @since 2.2.0 · @since 2.4.0 Accepts optional collaborator overrides for constructor injection.

Source: includes/Core/class-main.php, line 570

public __construct()

public function __construct()

Constructor.

Tags: @since 1.0.0

Source: includes/Core/class-main.php, line 581

private should_optimise_for_logged_in()

private function should_optimise_for_logged_in(): bool

Whether front-end optimisations (lazy load, defer, delay, minify, used CSS) should be applied for the current logged-in user.

Return: bool — True if optimisations may run for the current user.

Tags: @since 1.9.0

Source: includes/Core/class-main.php, line 655

private get_editable_role_names()

private function get_editable_role_names(): array

Get editable role names keyed by slug for the JS role selector.

Return: array<string, — string>.

Tags: @since 1.9.0

Source: includes/Core/class-main.php, line 668

public function set_role_hash_cookie(): void

Set a wppo_role_hash cookie for the current logged-in user so the advanced-cache.php drop-in can serve role-specific cache variants.

Return: void.

Tags: @since 1.9.0

Source: includes/Core/class-main.php, line 687

public function clear_role_hash_cookie(): void

Clear the wppo_role_hash cookie on logout.

Return: void.

Tags: @since 1.9.0

Source: includes/Core/class-main.php, line 727

private includes()

private function includes(): void

Include required files.

Return: void.

Tags: @since 1.0.0

Source: includes/Core/class-main.php, line 754

publicstatic should_load_litespeed_stack()

public static function should_load_litespeed_stack(): bool

Whether the LiteSpeed-only crawler + ESI stack should be loaded.

Return: bool — True when the LiteSpeed stack should be required.

Tags: @internal Exposed for Hook_Registry (registration gate); not part of the public plugin API. · @since 2.3.0

Source: includes/Core/class-main.php, line 852

private setup_hooks()

private function setup_hooks(): void

Setup WordPress hooks.

Return: void.

Tags: @since 1.0.0

Source: includes/Core/class-main.php, line 909

public ensure_hook_cache_for_registry()

public function ensure_hook_cache_for_registry(bool=false $force): mixed

Get or create the static-HTML cache collaborator for hook registration.

ParameterTypeDefaultDescription
$forcebool=false—Whether to recreate even when a cache exists.

Return: mixed — Cache instance (or filtered stub).

Tags: @internal For Hook_Registry use only. Centralizes the two cache-creation sites previously inline in setup_hooks() (the unconditional enableCache assignment and the combineCSS `if ( ! $this->cache )` fallback). The `$force` flag replicates the enableCache branch semantics exactly: force recreates (first site), default keeps an existing instance (second site). · @since 2.4.0

Source: includes/Core/class-main.php, line 928

public prepare_minify_excludes_for_registry()

public function prepare_minify_excludes_for_registry(string $kind): void

Prepare minify exclusion lists for hook registration.

ParameterTypeDefaultDescription
$kindstring—Either \’js\’ or \’css\’.

Return: void.

Tags: @internal For Hook_Registry use only. Verbatim relocation of the exclusion-list preparation previously inline in setup_hooks() (registration itself lives in the registry). · @since 2.4.0

Source: includes/Core/class-main.php, line 949

public prepare_defer_delay_state_for_registry()

public function prepare_defer_delay_state_for_registry(array $staged_for_registration): void

Prepare defer/delay script-exclusion and strategy state for hook registration.

ParameterTypeDefaultDescription
$staged_for_registrationarray—Staged sandbox settings widening registration.

Return: void.

Tags: @internal For Hook_Registry use only. Verbatim relocation of the adjacent defer-JS and delay-JS preparation blocks previously inline in setup_hooks() (neither block registers hooks; registration lives in the registry). Runs at the exact original position so filter firing order is unchanged. · @since 2.4.0

Source: includes/Core/class-main.php, line 987

public register_collaborators()

public function register_collaborators(): void

Instantiate collaborator services (metabox, cron, assets, abilities).

Return: void.

Tags: @internal For Hook_Registry use only: invoked at the exact original position so collaborator hook registration order is unchanged. · @since 2.2.0

Source: includes/Core/class-main.php, line 1129

private migrations()

private function migrations(): Settings_Migrations

Lazily-created settings-migration runner (ARCH-004, P3-016).

Return: Settings_Migrations — Migration runner bound to this application seam.

Tags: @since 2.4.0

Source: includes/Core/class-main.php, line 1146

private script_strategy()

private function script_strategy(): Script_Strategy

Lazily-created script-strategy runner (ARCH-005).

Return: Script_Strategy — Strategy runner bound to this instance.

Tags: @since 2.4.0

Source: includes/Core/class-main.php, line 1172

private minify_policy()

private function minify_policy(): Minify\\Minify_Policy

Lazily-created minification policy owner (P3-014).

Return: Minify\\Minify_Policy — Policy owner bound to this instance.

Tags: @since NEXT

Source: includes/Core/class-main.php, line 1185

public minify_should_optimise_for_logged_in()

public function minify_should_optimise_for_logged_in(): bool

Expose the live logged-in optimisation gate to Minify_Policy.

Return: bool — Whether optimisation applies to this viewer.

Tags: @internal · @since NEXT

Source: includes/Core/class-main.php, line 1199

public minify_css_exclusions()

public function minify_css_exclusions(): array

Expose the live CSS minification exclusions to Minify_Policy.

Return: array<int, — string> CSS exclusion handles/URLs.

Tags: @internal · @since NEXT

Source: includes/Core/class-main.php, line 1210

public minify_js_exclusions()

public function minify_js_exclusions(): array

Expose the live JS minification exclusions to Minify_Policy.

Return: array<int, — string> JS exclusion handles/URLs.

Tags: @internal · @since NEXT

Source: includes/Core/class-main.php, line 1221

public minify_is_core_block_asset_skipped()

public function minify_is_core_block_asset_skipped(string $handle): bool

Expose the core on-demand block-asset gate to Minify_Policy.

ParameterTypeDefaultDescription
$handlestring—Registered asset handle.

Return: bool — Whether core owns this handle conditionally.

Tags: @internal · @since NEXT

Source: includes/Core/class-main.php, line 1233

publicabstract ()

public abstract function (private script_state_exclude_defer_js(){return->exclude_defer_js;}/** * Direct reference to script-strategy hook state `$exclude_delay_js` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_exclude_delay_js(){return->exclude_delay_js;}/** * Direct reference to script-strategy hook state `$resolved_delay_exclusions` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_resolved_delay_exclusions(){return->resolved_delay_exclusions;}/** * Direct reference to script-strategy hook state `$page_preset_opt_out_remove` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_page_preset_opt_out_remove(){return->page_preset_opt_out_remove;}/** * Direct reference to script-strategy hook state `$delay_js_default_strategy` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_default_strategy(){return->delay_js_default_strategy;}/** * Direct reference to script-strategy hook state `$delay_js_idle_list` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_idle_list(){return->delay_js_idle_list;}/** * Direct reference to script-strategy hook state `$delay_js_viewport_list` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_viewport_list(){return->delay_js_viewport_list;}/** * Direct reference to script-strategy hook state `$delay_js_per_page_interaction` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_per_page_interaction(){return->delay_js_per_page_interaction;}/** * Direct reference to script-strategy hook state `$delay_js_priority` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_priority(){return->delay_js_priority;}/** * Direct reference to script-strategy hook state `$delay_disabled_for_page` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_disabled_for_page(){return->delay_disabled_for_page;}/** * Direct reference to script-strategy hook state `$defer_disabled_for_page` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_defer_disabled_for_page(){return->defer_disabled_for_page;}/** * Direct reference to script-strategy hook state `$deferred_handles` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_deferred_handles(){return->deferred_handles;}/** * Logged-in optimisation gate for the script-strategy cluster (ARCH-005 internal bridge). * * Same Tahoe semantics as the direct `$this->should_optimise_for_logged_in()` * call the relocated bodies made on `Main`. Only `Script_Strategy` * calls this. * * @internal * @since 2.4.0 * @return bool True when optimisation applies to the current viewer. */functionscript_should_optimise_for_logged_in():bool{return->should_optimise_for_logged_in();}/** * One-time upgrade for the block-assets toggle on WP 6.9+. * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_block_assets_setting():void{->migrations()->migrate_block_assets_setting(function_exists(\'wp_load_classic_theme_block_styles_on_demand\'));}/** * One-time upgrade core for the block-assets toggle on WP 6.9+. * * Backward-compatibility shim (ARCH-004): delegates to * {@see Settings_Migrations::migrate_block_assets_setting()} so reflective * callers observe identical behavior. * * Retention note: intentionally kept despite looking unreachable — * `tests/php/BlockAssetsMigrationTest.php` invokes it via reflection * and external reflective callers may do the same. Do not remove in * dead-code sweeps. * * @since 2.4.0 Delegates to Settings_Migrations (ARCH-004). * @param bool $loads_separate_core_block_assets_on_demand Whether WP 6.9+ is active. * @return void */functionmigrate_block_assets_setting(bool):void{->migrations()->migrate_block_assets_setting();}/** * One-time backfill for the CCSS inline size cap. * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.0.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ccss_max_size():void{->migrations()->migrate_ccss_max_size();}/** * One-time backfill for the Critical CSS user safelist (issue #1038). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.0.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ccss_safelist():void{->migrations()->migrate_ccss_safelist();}/** * One-time backfill for the RUM-weighted CSS queue keys (issue #1164). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 Also backfills the 25s `ccssGenTimeout` generation budget. * @since 2.3.0 Also backfills the #1388 keys (`ccssInlineBudgetKb`, `ccssCommerceExclude`, `ccssChecksumRegen`). * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_css_queue_defaults():void{->migrations()->migrate_css_queue_defaults();}/** * One-time backfill for the RUM-weighted top-URL prefetch cap (issue #1183). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_speculation_top_urls():void{->migrations()->migrate_speculation_top_urls();}/** * One-time backfill for the high-value prerender list toggle (issue #1237). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_speculation_prerender_list():void{->migrations()->migrate_speculation_prerender_list();}/** * One-time backfill for the RUM beacon sample rate (issue #1214). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_rum_sample_rate():void{->migrations()->migrate_rum_sample_rate();}/** * One-time backfill for the missing-alt autofill toggle and the longest-edge downscale cap (issue #985). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.0.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_image_alt_edge_defaults():void{->migrations()->migrate_image_alt_edge_defaults();}/** * One-time backfill for the unified safe-mode kill switch (issue #1098). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_safe_mode():void{->migrations()->migrate_safe_mode();}/** * Per-request memo for is_elementor_built_page() (issue #1259). * * Both combine_css() and will_combine_css_inline() run the full * builder detection on the same request; without a memo that is 2x * queried-object lookups, up to 4x get_post_meta, and 2x * is_elementor_context() calls. Keyed by resolved post ID * (\'post:{id}\') or by queried object (\'queried:{id}\') when no * explicit post ID was passed. * * @since 2.2.0 * @var array<string,bool> */staticarray=array();/** * Reset the Elementor-built memo (for tests). * * @since 2.2.0 * @return void */staticfunctionreset_elementor_memo():void{self::=array();}/** * Native-only Elementor presence pre-gate (issue #1259). * * Centralizes the cheap class/constant/query-var predicate so * combine_css() and will_combine_css_inline() cannot drift apart. * Zero WP calls: class_exists() with autoload disabled, defined(), * and isset() on $_GET only. Callers run the full guarded * detection only when this returns true. * * Note: a bare `?elementor-preview=1` query var forces a skip on * Elementor-active sites for any visitor appending it. Impact is * performance-only (unoptimized markup served, no data or * cache-write exposure); legit preview links need the bypass even * before per-post builder meta is readable, so this stays lenient * by design. * * Fail direction: detection failure degrades to \"looks like * Elementor\" (true) so the full guarded detection runs and, failing * that, combine is skipped while safe mode is on (perf-only cost * instead of risking broken Elementor layout/FOUC). * * @since 2.2.0 * @return bool True when Elementor looks present on this request. */staticfunctionlooks_like_elementor_request():bool{try{if(class_exists(\'Elementor\\Plugin\',false)||defined(\'ELEMENTOR_VERSION\')){returntrue;}if(isset([\'elementor-preview\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Backfill the additive elementorSafeMode key (issue #1259). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_elementor_safe_mode():void{->migrations()->migrate_elementor_safe_mode();}/** * Whether Elementor-safe mode is active (issue #1259). * * When on (default), combine/inline step aside on Elementor-built * pages. Fail-open to enabled when settings are unreadable so * unknown builder markup degrades to uncombined (never broken). * Every builder call is guarded; non-Elementor sites carry zero * weight beyond two cheap array lookups. * * @since 2.2.0 * * @param array $file_optimisation Optional `file_optimisation` settings slice. * @return bool True when Elementor-safe mode is on. */staticfunctionis_elementor_safe_mode_active(array=array()):bool{try{if(empty()&&class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){try{=(array)Util::get_settings();=isset([\'file_optimisation\'])&&is_array([\'file_optimisation\'])?[\'file_optimisation\']:array();}catch(\\Throwable){unset();}}=!array_key_exists(\'elementorSafeMode\',)||!empty([\'elementorSafeMode\']);if(function_exists(\'has_filter\')&&function_exists(\'apply_filters\')&&has_filter(\'wppo_elementor_safe_mode_enabled\')){try{=(bool)apply_filters(\'wppo_elementor_safe_mode_enabled\',);}catch(\\Throwable){unset();}}return;}catch(\\Throwable){unset();returntrue;}}/** * Whether the current page was built with Elementor (issue #1259). * * Lazy boot: class_exists() / function_exists() guards first so * non-Elementor sites pay nothing. Checks the Elementor plugin class, * version markers, per-post `_elementor_data` / `_elementor_edit_mode` * meta for the resolved post, and `data-elementor-type` is left to * markup-level callers. Detection failure degrades to skip (true) * while safe mode is on (see fail-direction note below). * * Per-request memoized by resolved post ID: combine_css() and * will_combine_css_inline() share one verdict per request. * * Single-post scope: only the resolved post\'s meta is inspected. * Archives, home, loops (queried ID 0), Elementor Theme Builder * archive/header/footer/popup contexts, and posts rendered inside * another loop are not covered — pass an explicit $post_id for those * contexts instead of relying on the queried-object fallback. * Theme Builder templates, translated copies (different IDs/URLs), * and non-builder consumers of builder templates are a known * limitation: purging a template ID purges the template permalink, * not its consumers. Hosts covering those contexts should pass an * explicit post ID or override via the `wppo_is_elementor_page` * filter (return non-null to force a verdict; receives the resolved * post ID as 2nd arg and the raw caller $post_id as 3rd arg for BC). * * Fail direction: detection failure degrades to skip (true) while * Elementor-safe mode is on — uncombined markup costs perf only, * while combining through a detection failure risks broken * Elementor layout/FOUC. With safe mode off, failure returns false * (optimisations run). * * @since 2.2.0 * * @param int|null $post_id Optional post ID (defaults to queried object; * falls back to get_the_ID() only on singular * views so archives/home never inherit a loop * member\'s builder verdict). * @param array $file_opt Optional `file_optimisation` settings slice, * forwarded to is_elementor_safe_mode_active() * in the fail-safe path so a staged * elementorSafeMode=off stays previewable. * @return bool True when this looks like an Elementor-built page. */staticfunctionis_elementor_built_page(?int=null,array=array()):bool{try{=self::resolve_elementor_post_id();if(function_exists(\'has_filter\')&&function_exists(\'apply_filters\')&&has_filter(\'wppo_is_elementor_page\')){try{=apply_filters(\'wppo_is_elementor_page\',null,??,);if(null!==){return(bool);}}catch(\\Throwable){unset();}}=self::is_elementor_safe_mode_active()?\'s1\':\'s0\';=(null!==&&>0?\'post:\'.:\'queried:0\').\':\'.;if(array_key_exists(,self::)){returnself::[];}=self::detect_elementor_built_page(,);self::[]=;return;}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}}/** * Resolve the Elementor post ID from explicit, queried, and loop sources (issue #1259). * * Queried-object ID first; the get_the_ID() loop fallback applies on * singular views only — on archives/home/loop (queried ID 0) * get_the_ID() returns whichever post the loop currently points at, * so inheriting it would skip combine for a whole archive containing * one Elementor post and memoize under a loop-position-dependent key. * Fail-open to null when no ID resolves. * * @since 2.2.0 * * @param int|null $post_id Explicit post ID (or null to resolve). * @return int|null Resolved post ID, or null when unknown. */staticfunctionresolve_elementor_post_id(?int):?int{if(null!==&&>0){return;}if(function_exists(\'get_queried_object_id\')){try{=(int)get_queried_object_id();if(>0){return;}}catch(\\Throwable){unset();}}if(function_exists(\'is_singular\')&&function_exists(\'get_the_ID\')){try{if(is_singular()){=(int)get_the_ID();if(>0){return;}}}catch(\\Throwable){unset();}}returnnull;}/** * Fail-safe verdict for Elementor detection failures (issue #1259). * * Detection failure degrades to skip (true) while safe mode is on — * uncombined markup costs perf only, while combining through a * detection failure risks broken Elementor layout/FOUC. With safe * mode off, failure returns false (optimisations run). * * @since 2.2.0 * * @param array $file_opt Optional `file_optimisation` settings slice. * @return bool Safe-mode-active verdict (true on unreadable settings). */staticfunctionelementor_safe_fallback(array=array()):bool{try{returnself::is_elementor_safe_mode_active();}catch(\\Throwable){unset();returntrue;}}/** * Unmemoized Elementor-built detection (issue #1259). * * Split from is_elementor_built_page() so the memo wrapper stays * trivial. Plugin presence is probed with cheap markers only * (class_exists with autoload disabled, version constants, * builder function markers — zero meta reads), then a SINGLE * `get_post_meta` pair decides the verdict: the tiny * `_elementor_edit_mode` value is checked first and the large * `_elementor_data` blob (100KB–1MB, unserialize + memory spike) * is fetched only on miss, so non-builder pages never pay the * largest meta cost on the combine hot path. The * Critical_CSS::is_elementor_context() precedent is deliberately * not delegated to here: it performs its own meta reads (doubling * memcache/DB payload plus the unserialize cost of the largest * builder meta on the combine hot path) and treats any non-empty * edit mode as a context, while this path requires a strict * `\'builder\' === (string) $edit_mode` comparison so stale or * third-party `_elementor_edit_mode` values never bypass combine. * * The `?elementor-preview` check applies uniformly (not only when * the Critical_CSS class is loaded) so preview URLs behave the * same in full and minimal boots — but only when Elementor looks * active (cheap markers above): a bare preview query var on a * non-Elementor site must not disable combine for any visitor * appending it (perf-only kill-switch otherwise). * * Fail direction: unexpected failure degrades to skip (true) while * safe mode is on (perf-only cost over broken-layout risk). * * @since 2.2.0 * * @param int|null $resolved Resolved post ID (or null when unknown). * @param array $file_opt Optional `file_optimisation` settings slice, * forwarded to is_elementor_safe_mode_active() * in the fail-safe path so a staged * elementorSafeMode=off stays previewable. * @return bool True when this looks like an Elementor-built page. */staticfunctiondetect_elementor_built_page(?int,array=array()):bool{try{=false;try{if(class_exists(\'Elementor\\Plugin\',false)||defined(\'ELEMENTOR_VERSION\')){=true;}elseif(function_exists(\'elementor_pro_load_plugin\')){=true;}}catch(\\Throwable){unset();}if(null!==&&>0&&function_exists(\'get_post_meta\')){try{=get_post_meta(,\'_elementor_edit_mode\',true);if(\'builder\'===(string)){returntrue;}=get_post_meta(,\'_elementor_data\',true);if(!empty()){returntrue;}}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}if(!){returnfalse;}}if(&&isset([\'elementor-preview\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}}/** * Whether combine/inline must be skipped for this request (issue #1259). * * True only when Elementor-safe mode is on AND the current page is * Elementor-built. * * Fail direction: detection failure degrades to skip (true) while * safe mode is on — uncombined markup costs perf only, while * combining through a detection failure risks broken Elementor * layout/FOUC. With safe mode off, failure returns false. * * Single-post scope (inherited from is_elementor_built_page()): pass * an explicit $post_id for archive/loop/Theme Builder contexts where * the queried object is not the Elementor-built post. * * @since 2.2.0 * * @param array $file_optimisation Optional `file_optimisation` settings slice. * @param int|null $post_id Optional post ID forwarded to is_elementor_built_page(). * @return bool True when combine/inline must be skipped. */staticfunctionshould_skip_combine_for_elementor(array=array(),?int=null):bool{try{if(!self::is_elementor_safe_mode_active()){returnfalse;}returnself::is_elementor_built_page(,);}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}}/** * One-time backfill for automatic LCP hero preload + font discovery (issue #1216). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_preload_auto_defaults():void{->migrations()->migrate_preload_auto_defaults();}/** * Backfill the additive Redis outage status flag (issue #1233). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_object_cache_outage_flag():void{->migrations()->migrate_object_cache_outage_flag();}/** * One-time backfill for RUM-segmented speculation auto-tune (issue #1425). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.3.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ai_speculation_autotune():void{->migrations()->migrate_ai_speculation_autotune();}/** * One-time backfill for anomaly detector v2 keys (issue #1313). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.3.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ai_anomaly_v2():void{->migrations()->migrate_ai_anomaly_v2();}/** * One-time backfill for comment-image hardening (issue #1271). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_comment_image_hardening():void{->migrations()->migrate_comment_image_hardening();}/** * Backfill the additive builder purge watcher keys (issue #1288). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_builder_watcher():void{->migrations()->migrate_builder_watcher();}/** * Backfill the additive auto third-party delay key (issue #1314). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_third_party_auto():void{->migrations()->migrate_third_party_auto();}/** * Automatically try to fix WP_CACHE if it is missing or disabled. * * Runs on admin_init. * * @return void */functionmaybe_fix_wp_cache():void{if(defined(\'WP_CACHE\')&&WP_CACHE){return;}if(get_transient(Util::transient_key(\'wppo_wp_cache_fix_checked\'))){return;}=Activate::add_wp_cache_constant();set_transient(Util::transient_key(\'wppo_wp_cache_fix_checked\'),1,HOUR_IN_SECONDS);if(!empty()){=get_transient(Util::transient_key(\'wppo_activation_notices\'));=is_array()?:array();=array_unique(array_merge(,(array)));set_transient(Util::transient_key(\'wppo_activation_notices\'),,30);}}/** * Runs one-time upgrade routines after a plugin update. * * Routine plugin updates never fire register_activation_hook, so this is * triggered on admin_init. Activate::maybe_run_upgrades() exits early once * the stored plugin version has reached the migration floor. * * @return void * @since 1.9.0 */functionmaybe_run_upgrades():void{if(!current_user_can(\'manage_options\')){return;}Activate::maybe_run_upgrades();}/** * Schedule the upgrade routine in the background after a plugin update. * * Fires on upgrader_process_complete when this plugin was updated, giving * sites updated via WP-CLI, background auto-updates, or managed-hosting * pipelines a reliable trigger that does not depend on an admin visit. * * @param object $upgrader The upgrader instance (unused). * @param array $hook_extra Extra arguments passed to the hook. * @return void * @since 1.9.0 */functionmaybe_schedule_upgrade_routine(,):void{if(empty()||!is_array()){return;}=\'performance-optimisation/performance-optimisation.php\';if(!empty([\'plugin\'])&&===[\'plugin\']){Activate::schedule_upgrade_routine();return;}if(!empty([\'plugins\'])&&is_array([\'plugins\'])&&in_array(,[\'plugins\'],true)){Activate::schedule_upgrade_routine();}}/** * Run one-time upgrade routines when the plugin version changes. * * Regenerates the advanced-cache.php drop-in so it honours the * DONOTCACHEPAGE no-cache marker, then clears the full cache once to * remove any pre-existing stale pages the old drop-in would keep serving. * Runs on admin_init and upgrader_process_complete (covering admin-initiated * and CLI updates); the one-time wppo_version gate keeps it idempotent. * * @return void * @since 1.9.0 */functionmaybe_run_version_upgrade():void{if(!current_user_can(\'manage_options\')){return;}=get_option(\'wppo_version\',\'\');if(version_compare((string),WPPO_VERSION,\'>=\')){return;}if(!Advanced_Cache_Handler::create()){return;}if(!Advanced_Cache_Handler::foreign_dropin_present()){Cache::clear_cache();}try{global;if(isset(->options)){=->query(->prepare(\"UPDATE {->options} SET autoload = %s WHERE option_name = %s AND autoload NOT IN (%s, %s)\",\'no\',\'wppo_settings\',\'no\',\'off\'));if(false===){return;}}}catch(\\Throwable){unset();return;}update_option(\'wppo_version\',WPPO_VERSION,false);}/** * Callback for when plugin settings are updated. * * Drops the {@see self::get_options()} memo on the live instance so * same-request post-save reads re-resolve (with backfills) instead of * serving the pre-save snapshot. Covers every save path (REST, CLI, * `Util::save_settings()`) because all of them persist via * `update_option( \'wppo_settings\' )`, which fires this hook. * * @param mixed $old_value The old option value. * @param mixed $value The new option value. * @since 1.2.0 * @since 2.4.0 Invalidates the get_options() memo on the live instance. */staticfunctionon_settings_update(,){Settings_Store::invalidate_resolved_settings();if(null!==self::){self::->refresh_options();}=array(\'cache_settings\',\'file_optimisation\',\'image_optimisation\',\'preload_settings\',\'core_tweaks\');=array(\'database_cleanup\',\'object_cache\',\'performance_audit\');=false;=false;foreach(as){=isset([])?[]:null;=isset([])?[]:null;if(!==){=true;break;}}if(!){foreach(as){=isset([])?[]:null;=isset([])?[]:null;if(!==){=true;break;}}}if(){self::clear_all_cache();Advanced_Cache_Handler::create();}elseif(){Cache::flush_runtime();}=[\'image_optimisation\']??array();=[\'image_optimisation\']??array();if(!==){Img_Converter::invalidate_img_info_cache();}=[\'performance_audit\']??array();=[\'performance_audit\']??array();if(!==){Telemetry::invalidate_audit_cache();}=isset([\'file_optimisation\'][\'enableServerRules\'])?(bool)[\'file_optimisation\'][\'enableServerRules\']:false;=isset([\'file_optimisation\'][\'enableServerRules\'])?(bool)[\'file_optimisation\'][\'enableServerRules\']:false;=isset([\'litespeed_integration\'][\'enableNextGenRewrite\'])?(bool)[\'litespeed_integration\'][\'enableNextGenRewrite\']:false;=isset([\'litespeed_integration\'][\'enableNextGenRewrite\'])?(bool)[\'litespeed_integration\'][\'enableNextGenRewrite\']:false;=!==;=isset([\'image_optimisation\'][\'convertImg\'])?(bool)[\'image_optimisation\'][\'convertImg\']:false;=isset([\'image_optimisation\'][\'convertImg\'])?(bool)[\'image_optimisation\'][\'convertImg\']:false;=!==;=class_exists(\'PerformanceOptimise\\Inc\\Server_Rules\')&&method_exists(\'PerformanceOptimise\\Inc\\Server_Rules\',\'should_skip_htaccess_write\')&&Server_Rules::should_skip_htaccess_write();if(!==){=?true:Htaccess_Handler::update_rules();if(&&&&class_exists(\'PerformanceOptimise\\Inc\\LiteSpeed_Integration\')&&LiteSpeed_Integration::is_litespeed()){Log::add(__(\'Server rules updated on LiteSpeed — restart OpenLiteSpeed if changes do not appear immediately.\',\'performance-optimisation\'));}if(!){[\'file_optimisation\'][\'enableServerRules\']=;remove_action(\'update_option_wppo_settings\',array(__CLASS__,\'on_settings_update\'),10);Settings_Command::save();add_action(\'update_option_wppo_settings\',array(__CLASS__,\'on_settings_update\'),10,2);add_action(\'admin_notices\',array(__CLASS__,\'render_htaccess_failure_notice\'));}}elseif((||)&&){=?true:Htaccess_Handler::update_rules(true);if(&&class_exists(\'PerformanceOptimise\\Inc\\LiteSpeed_Integration\')&&LiteSpeed_Integration::is_litespeed()){Log::add(__(\'Server rules updated on LiteSpeed — restart OpenLiteSpeed if changes do not appear immediately.\',\'performance-optimisation\'));}if(!){add_action(\'admin_notices\',array(__CLASS__,\'render_htaccess_failure_notice\'));}}=[\'file_optimisation\'][\'hostGoogleFontsLocally\']??false;=[\'file_optimisation\'][\'hostGoogleFontsLocally\']??false;=[\'file_optimisation\'][\'fontSubset\']??false;=[\'file_optimisation\'][\'fontSubset\']??false;=[\'file_optimisation\'][\'fontSubsetSubsets\']??\'latin\';=[\'file_optimisation\'][\'fontSubsetSubsets\']??\'latin\';if(!==||!==||!==){Google_Fonts::clear_font_cache();}}/** * Render the admin notice for a failed .htaccess rules update. * * Shared by the enable/disable and next-gen-refresh branches so the * message and ARIA contract cannot drift. `role=\"alert\"` + * `aria-live=\"assertive\"` announce the failure immediately, matching * the React NoticeBanner contract used across the SPA. * * @since 2.0.0 * @return void */staticfunctionrender_htaccess_failure_notice():void{echo\'<div class=\"notice notice-error is-dismissible\" role=\"alert\" aria-live=\"assertive\"><p>\'.esc_html__(\'Performance Optimisation: Failed to update .htaccess rules. Please check file permissions.\',\'performance-optimisation\').\'</p></div>\';}/** * Clear the entire plugin cache. * * Called when structural changes (permalink update, theme switch, etc.) * invalidate all cached pages. * * @since 1.1.0 */staticfunctionclear_all_cache(){Cache::clear_cache();}/** * Auto-purge minify + page cache after any plugin/theme update. * * Hooks `upgrader_process_complete` (priority 20, after the builder * watcher). Only fires for `action=update` + `type=plugin|theme`; * every other upgrade path (core, translation, install) is ignored. * Fail-open: purge failure degrades to the current manual-clear * behavior and is never fatal. * * @since 2.2.0 * @param mixed $upgrader Upgrader instance (unused). * @param mixed $hook_extra Update context (action/type/plugin/plugins/theme/themes). * @return void */staticfunctionon_extension_update(=null,=null):void{unset();try{if(!is_array()){return;}if(\'update\'!==([\'action\']??\'\')){return;}if(!in_array(([\'type\']??\'\'),array(\'plugin\',\'theme\'),true)){return;}=!empty([\'plugin\'])||!empty([\'plugins\'])||!empty([\'theme\'])||!empty([\'themes\'])||!empty([\'bulk\']);if(!){return;}self::clear_all_cache();try{=\'theme\'===([\'type\']??\'\');if(&&class_exists(\'PerformanceOptimise\\Inc\\Used_CSS\')&&method_exists(\'PerformanceOptimise\\Inc\\Used_CSS\',\'request_targeted_regen\')){Used_CSS::request_targeted_regen(\'theme-update\');}}catch(\\Throwable){unset();}}catch(\\Throwable){unset();}}/** * Queue a bounded targeted used-CSS regen after a theme switch (issue #1220). * * Runs alongside clear_all_cache on `switch_theme`. Cooldown-gated * inside Used_CSS::request_targeted_regen(); no-op when * removeUnusedCSS is off or Action Scheduler is unavailable. * Fail-open: never throws. * * @param string $new_name New theme name (unused). * @param mixed $new_theme New theme object (unused). * @param mixed $old_theme Old theme object (unused). * @return void * @since 2.2.0 */staticfunctionon_theme_switch_used_css(=\'\',=null,=null):void{unset(,,);try{if(class_exists(\'PerformanceOptimise\\Inc\\Used_CSS\')&&method_exists(\'PerformanceOptimise\\Inc\\Used_CSS\',\'request_targeted_regen\')){Used_CSS::request_targeted_regen(\'theme-switch\');}}catch(\\Throwable){unset();}}/** * Regenerate the advanced-cache.php drop-in when the home or site URL * changes (domain migration). * * The canonical host is baked into the drop-in at create() time; without * a re-bake every request would mismatch the stale host and silently * run uncached. No cache clear here — the old-domain files are keyed * under a different host directory and simply stop being served. * * @param mixed $old_value Previous option value. * @param mixed $value New option value. * @param string $option Option name. * @return bool True when the drop-in is left in a correct state, false on * filesystem failure. Skipped (unchanged value, or * scheme/path-only change with an identical host) returns true. * @since 2.0.0 */staticfunctionon_site_url_change(=null,=null,=\'\'):bool{if(===){returntrue;}if(function_exists(\'wp_parse_url\')){=Util::normalize_cache_host((string)wp_parse_url((string),PHP_URL_HOST));=Util::normalize_cache_host((string)wp_parse_url((string),PHP_URL_HOST));if(\'\'!==&&===){returntrue;}}if(!Advanced_Cache_Handler::create()){do_action(\'wppo_debug_log\',\'WPPO advanced-cache.php drop-in regeneration failed after \'..\' change\');returnfalse;}returntrue;}/** * Process a single image conversion in the background via Action Scheduler. * * @param array $args { source_path, format } for the image to convert. * @since 1.1.0 */functionprocess_background_image(){if(empty([\'source_path\'])||empty([\'format\'])){return;}=Util::get_settings();=newImg_Converter();=wp_normalize_path([\'source_path\']);=sanitize_text_field([\'format\']);if(method_exists(\'PerformanceOptimise\\Inc\\Img_Converter\',\'is_path_in_allowlist\')&&!Img_Converter::is_path_in_allowlist()){return;}if(file_exists()){->convert_image(,);}}/** * Invalidate cache on save_post, skipping revisions and autosaves. * * @param int $post_id Post ID. * @param \\WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * @since 2.0.0 */functionon_save_post_invalidate_cache(,,){if(wp_is_post_revision()||wp_is_post_autosave()){return;}=null;if(is_object()&&isset(->post_type)){=->post_type;}elseif(function_exists(\'get_post_type\')){=get_post_type();}=null;if(\'product\'===){=\'product\';}elseif(\'product_variation\'===){=0;if(is_object()&&isset(->post_parent)){=(int)->post_parent;}elseif(function_exists(\'wp_get_post_parent_id\')){try{=(int)wp_get_post_parent_id();}catch(\\Throwable){unset();=0;}}if(>0){=\'product\';=;}}elseif(\'shop_order\'===||\'shop_order_placehold\'===||(function_exists(\'wc_get_order_types\')&&in_array(,(array)wc_get_order_types(),true))){=\'order\';}elseif(\'shop_coupon\'===){=\'coupon\';}if(null!==&&->cache&&method_exists(->cache,\'invalidate_woo_object\')){->cache->invalidate_woo_object((int),);if(\'order\'===||\'coupon\'===){return;}if(\'product\'===){return;}}elseif(->cache){->cache->invalidate_dynamic_static_html();}->preload_buffer_coordinator->queue_crawler_warm_after_cache_invalidation((int));}/** * Surgically invalidate cache when a WooCommerce product is updated. * * @param int $product_id Product ID. * @return void * @since 2.0.0 */functionon_woocommerce_product_updated():void{if(!->cache||!method_exists(->cache,\'invalidate_woo_object\')){return;}->cache->invalidate_woo_object((int),\'product\');}/** * Surgically invalidate cache when a WooCommerce order is created/updated. * * Accepts either an order ID or a WC_Order object (hook signatures * differ between `woocommerce_checkout_order_created` and * `woocommerce_update_order` across WC versions). * * @param mixed $order Order ID or WC_Order object. * @return void * @since 2.0.0 */functionon_woocommerce_order_changed():void{if(!->cache||!method_exists(->cache,\'invalidate_woo_object\')){return;}=;if(is_object()&&method_exists(,\'get_id\')){try{=->get_id();}catch(\\Throwable){unset();return;}}=(int);if(<=0){return;}->cache->invalidate_woo_object(,\'order\');}/** * Surgically invalidate cache when a WooCommerce coupon is saved. * * @param mixed $coupon Coupon ID or WC_Coupon object. * @return void * @since 2.0.0 */functionon_woocommerce_coupon_saved():void{if(!->cache||!method_exists(->cache,\'invalidate_woo_object\')){return;}=;if(is_object()&&method_exists(,\'get_id\')){try{=->get_id();}catch(\\Throwable){unset();return;}}=(int);if(<=0){return;}->cache->invalidate_woo_object(,\'coupon\');}/** * Queue used-CSS generation when post content is saved. * * Skips revisions and autosaves, and checks the removeUnusedCSS setting * before enqueueing. Uses atomic unique enqueue (issue #1310) to * prevent duplicate jobs, with the legacy as_has_scheduled_action() * guard as fallback. * * @param int $post_id Post ID. * @param \\WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * @return void * @since 1.9.0 */functionon_save_post_queue_used_css(,,){if(wp_is_post_revision()||wp_is_post_autosave()){return;}if(class_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\')&&method_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\',\'maybe_regen_on_save\')){try{\\PerformanceOptimise\\Inc\\Critical_CSS::maybe_regen_on_save(,);}catch(\\Throwable){unset();}}->preload_buffer_coordinator->queue_used_css_regeneration((int));}/** * Refresh the matching critical-CSS template after an LCP-triggered used-CSS refresh. * * In-repo consumer for the `wppo_ai_css_refresh_queued` action fired by * AI_Adaptive::maybe_queue_css_refresh() (issue #1407): maps the queued * post to its coarse template (`home` for the front page, `page` for * pages, `single` otherwise) and regenerates that single template via * Critical_CSS::regenerate_single(). Fail-open: any failure (unknown * template, missing scheduler, throwable) is swallowed so the used-CSS * job that already queued is never affected. * * @param string $url regressed URL. * @param int $post_id Queued post ID. * @param array $anomaly The firing LCP anomaly. * @return void * @since 2.3.0 */functionon_ai_css_refresh_queued(,,){try{=(int);if(<=0){return;}if(!class_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\')||!method_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\',\'regenerate_single\')){return;}=\'single\';if(is_string()&&\'\'!==&&class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')&&method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'is_homepage_url\')){try{if(AI_Adaptive::is_homepage_url()){=\'home\';}}catch(\\Throwable){unset();}}if(\'home\'!==&&function_exists(\'get_post_type\')){try{if(\'page\'===get_post_type()){=\'page\';}}catch(\\Throwable){unset();}}try{\\PerformanceOptimise\\Inc\\Critical_CSS::regenerate_single();}catch(\\Throwable){unset();}}catch(\\Throwable){unset();}}/** * Process used-CSS when cache is disabled. * * @param string $filtered_output The filtered output from previous callbacks. * @param string $output The raw output buffer content. * @return string The processed output. * @since 1.9.0 */functionprocess_used_css_only(,){return->preload_buffer_coordinator->process_used_css_only(,);}/** * Whether the Server-Timing debug header is enabled. * * Reads the performance_audit.server_timing_enabled setting (default false, off). * Operators may override it via the wppo_server_timing_enabled filter, e.g. to * restrict emission to logged-in administrators with manage_options capability. * * Enabling forces the template-enhancement output buffer via * wp_finalized_template_enhancement_output_buffer registration in * {@see Main::setup_hooks()} (priority 1000 by default), which disables * response streaming / early flush. TTFB increases while TTLB unchanged — * intentional when Server-Timing is active; keep disabled by default and * emit only on cache-miss generation passes. Cached hits served by * advanced-cache.php never boot WordPress so the header never appears there. * Paired with {@see Main::capture_template_start()} / * {@see Main::emit_server_timing_header()}. * * @since 1.9.0 * @since 2.2.0 Streaming tradeoff note and cross-reference. * @return bool True when Server-Timing telemetry is active. */functionserver_timing_enabled():bool{=!empty(->get_options()[\'performance_audit\'][\'server_timing_enabled\']??false);return(bool)apply_filters(\'wppo_server_timing_enabled\',);}/** * Capture the template render start time for Server-Timing telemetry. * * Records microtime(true) at template_redirect:0 before the template is * included, for later duration calculation in * {@see Main::emit_server_timing_header()}. Early bail for admin, AJAX, * REST, or when {@see Main::server_timing_enabled()} is false; stores * the timestamp in {@see Main::$server_timing_template_start}. Paired * with emit_server_timing_header() on * wp_finalized_template_enhancement_output_buffer. * * @since 1.9.0 * @since 2.0.0 Expanded documentation for buffering opt-in context. * @return void */functioncapture_template_start():void{if(!->server_timing_enabled()||is_admin()||wp_doing_ajax()||(defined(\'REST_REQUEST\')&&REST_REQUEST)){return;}->server_timing_template_start=microtime(true);}/** * Emit a Server-Timing response header on live front-end renders (WP 6.9+). * * Hook: wp_finalized_template_enhancement_output_buffer (also wp_send_late_headers * alias) — WP 6.9 canonical late-header spot before flush. WP 6.9 standardised * the former ad-hoc ob_start() at template_redirect / template_include into * wp_should_output_buffer_template_for_enhancement() / * wp_start_template_enhancement_output_buffer() / * wp_finalize_template_enhancement_output_buffer() with filter * wp_template_enhancement_output_buffer and action * wp_finalized_template_enhancement_output_buffer ($final) (Trac #64126 / #63636 * / #43258; Performance Lab #2225/#2515), try/catch wrapped with WP_DEBUG_DISPLAY * on error. * * Performance Lab interop: when the Performance Lab Server-Timing module is * active it owns the Server-Timing header and its default metrics surface as * `wp-before-template`, `wp-template` and `wp-total` (Performance Lab prefixes * every registered metric slug with `wp-`). Emitting our own raw header with * the same metric names would produce duplicate/conflicting entries in * DevTools, so: * * - Performance Lab with output buffering enabled already measures * before-template + template + total from the same underlying timestamps * (timestart / template render window), so our emission is suppressed * entirely and Performance Lab\'s single header carries the data. * - Performance Lab without output buffering sends its header at * template_include (before the template renders) and only carries * `wp-before-template`; the `wp-template` name stays unclaimed, so only * the template render duration is emitted as a distinct appended entry — * the duplicate `wp-before-template` is dropped. When the buffering-state * helper (`perflab_server_timing_use_output_buffer()`) is absent the * buffering mode is unknown and the emission is suppressed instead. * * When Performance Lab is inactive nothing changes: both * `wp-before-template` and `wp-template` are emitted as before. * * Param $output ($final) is the final HTML string passed by Core to the action * (not the filtered value); reserved for future ETag hashing without re-registration * and currently unused. * * Header must be sent via header(\'Server-Timing: ...\', false) before flush; the * second arg false appends to preserve coexisting metrics. Guards headers_sent() * and null === System_Info::get_request_start_microtime() before emitting. Core * wraps this action in try/catch and appends WP_DEBUG_DISPLAY on error, so the * plugin does not add an extra try/catch. * * Streaming tradeoff: registering this action automatically opts into the * template-enhancement buffer (priority 1000 by default), which disables response * streaming / early flush. TTFB increases while TTLB unchanged — intentional when * Server-Timing is enabled; keep disabled by default and emit only on cache-miss * generation passes (advanced-cache.php serves cached pages without booting * WordPress). * * No ETag / 304 computation here — conditional GET (If-Modified-Since / * If-None-Match → 304 with ETag / Last-Modified) is already handled in the * Advanced_Cache_Handler drop-in (advanced-cache.php). * * @param string $output The finalized output buffer content (final HTML string, alias $final). * @return void * @since 2.0.0 Performance Lab Server-Timing interop: defer to the * Performance Lab-owned header (no duplicate/conflicting * metric names); {@see is_pl_server_timing_active()}. */functionemit_server_timing_header(string=\'\'):void{if(!->server_timing_enabled()||is_admin()||wp_doing_ajax()||(defined(\'REST_REQUEST\')&&REST_REQUEST)){return;}if(headers_sent()){return;}=System_Info::get_request_start_microtime();if(null===){return;}=microtime(true);=\'\';if(->server_timing_template_start>){=\'wp-before-template;dur=\'.round((->server_timing_template_start-)*1000,2);}=\'\';if(->server_timing_template_start>0){=(-->server_timing_template_start)*1000;if(>0){=\'wp-template;dur=\'.round(,2);}}if(\'\'===&&\'\'===){return;}if(->is_pl_server_timing_active()){if(!function_exists(\'perflab_server_timing_use_output_buffer\')||perflab_server_timing_use_output_buffer()){return;}if(\'\'===){return;}header(\'Server-Timing: \'.,false);return;}header(\'Server-Timing: \'.implode(\', \',array_filter(array(,))),false);}/** * Whether the Performance Lab Server-Timing module is active. * * Detects the canonical Performance Lab Server-Timing API surface * (`perflab_server_timing_register_metric()` / `perflab_wrap_server_timed_call()`). * Performance Lab is a plugin (not core), so this is a function_exists * gate with no WordPress version check. Used by * {@see emit_server_timing_header()} to defer to the Performance Lab-owned * Server-Timing header instead of emitting a second, conflicting one. * * @since 2.0.0 * @return bool True when the Performance Lab Server-Timing API is present. */functionis_pl_server_timing_active():bool{returnfunction_exists(\'perflab_server_timing_register_metric\')||function_exists(\'perflab_wrap_server_timed_call\');}/** * Start output buffer for used-CSS (legacy path, WP &lt; 6.9). * * Tracked by #829: do not remove until minimum supported WP is raised * to 6.9 (`Requires at least: 6.9`). * * @return void * @since 1.9.0 */functionstart_used_css_buffer(){->preload_buffer_coordinator->start_used_css_buffer();}/** * Start output buffer for LCP image prioritization (legacy path, WP &lt; 6.9). * * Tracked by #829: do not remove until minimum supported WP is raised * to 6.9 (`Requires at least: 6.9`). * * Registers at priority 20, after the cache and used-CSS buffers (default * priority 10), so its inner buffer callback runs first on the raw buffer * and the cache callback then stores the LCP-enhanced HTML. The callback * no-ops when the feature is disabled, the buffer is empty, the request is * non-HTML (feeds, robots, AJAX, REST), or the user is not eligible. * * @return void * @since 1.9.0 */functionstart_lcp_priority_buffer(){->preload_buffer_coordinator->start_lcp_priority_buffer();}/** * Capture and process buffer for used-CSS. * * Wrapped in try/catch so an unexpected failure inside the used-CSS or * Google Fonts pipeline can never throw out of the output-buffer * callback: the callback must always return a string or the buffered * page output would be lost/corrupted when the buffer is closed * (audit #888 finding 12 — balanced buffer lifecycle). * * @param string $buffer The output buffer content. * @return string The processed buffer. * @since 1.9.0 */functionprocess_used_css_capture(){return->preload_buffer_coordinator->process_used_css_capture();}/** * Initialize the admin menu. * * Adds the Performance Optimisation menu to the WordPress admin dashboard. * * @return void * @since 1.0.0 */functioninit_menu():void{add_menu_page(__(\'Performance Optimisation\',\'performance-optimisation\'),__(\'Performance Optimisation\',\'performance-optimisation\'),\'manage_options\',\'performance-optimisation\',array(,\'admin_page\'),\'dashicons-admin-post\',\'2.1\',);}/** * Display the admin page. * * Includes the admin page template for rendering. * * @return void * @since 1.0.0 */functionadmin_page():void{require_onceWPPO_PLUGIN_PATH.\'templates/app.html\';}/** * Add available post types to options. * * Filters out non-public post types and adds the available post types to options. * * @return void * @since 1.0.0 */functionadd_available_post_types_to_options(){=get_post_types(array(\'public\'=>true),\'names\');if(!is_array()){=array();}=array(\'attachment\');=array_keys(array_diff(,));->options[\'image_optimisation\'][\'availablePostTypes\']=;}/** * Extract the active frontend theme\'s primary color. * * Checks block theme (theme.json) first, then classic theme (customizer). * * @since 2.0.0 * @return array{primary?: string, secondary?: string, text?: string} */functionget_frontend_theme_colors():array{=array(\'primary\'=>\'\',\'secondary\'=>\'\',\'text\'=>\'\',);if(function_exists(\'wp_get_global_settings\')){=wp_get_global_settings();=[\'color\'][\'palette\'][\'theme\']??array();foreach(as){=sanitize_title([\'slug\']??\'\');=sanitize_hex_color([\'color\']??\'\');if(!){continue;}if(in_array(,array(\'primary\',\'brand\',\'accent\'),true)){[\'primary\']=;}elseif(in_array(,array(\'secondary\',\'secondary-brand\'),true)){[\'secondary\']=;}elseif(in_array(,array(\'foreground\',\'contrast\',\'body-text\'),true)){[\'text\']=;}}}if(empty([\'primary\'])){=get_theme_mod(\'primary_color\',\'\');if(empty()){=get_theme_mod(\'accent_color\',\'\');}if(!empty()){[\'primary\']=sanitize_hex_color();}}if(empty([\'text\'])){=get_header_textcolor();if(\'blank\'!==&&!empty()){[\'text\']=\'#\'.ltrim(sanitize_hex_color_no_hash(),\'#\');}}returnarray_filter();}/** * Enqueue admin scripts and styles. * * Loads CSS and JavaScript files for the admin dashboard page. * * @return void * @since 1.0.0 */functionadmin_enqueue_scripts():void{=get_current_screen();if(!||\'toplevel_page_performance-optimisation\'!==->base){return;}->enqueue_admin_bar_script();=WPPO_PLUGIN_PATH.\'build/index.asset.php\';=wp_normalize_path(realpath());if(false!==&&0===strpos(,(string)WPPO_PLUGIN_PATH)){=require;}else{=array(\'dependencies\'=>array(),\'version\'=>false,);}wp_enqueue_style(\'performance-optimisation-style\',WPPO_PLUGIN_URL.\'build/style-index.css\',array(),[\'version\'],\'all\');wp_enqueue_script(\'performance-optimisation-script\',WPPO_PLUGIN_URL.\'build/index.js\',[\'dependencies\'],[\'version\'],true);->add_available_post_types_to_options();=Cache::get_cache_stats();=isset([\'size\'])?(string)[\'size\']:__(\'N/A\',\'performance-optimisation\');=Util::cache_salt(\'wppo_cache_last_cleared\');if(function_exists(\'wp_cache_get_salted\')&&function_exists(\'wp_using_ext_object_cache\')&&wp_using_ext_object_cache()){=wp_cache_get_salted(\'wppo_total_js_css\',\'wppo\',);if(false===){=Util::get_js_css_minified_file();wp_cache_set_salted(\'wppo_total_js_css\',,\'wppo\',,15*MINUTE_IN_SECONDS);}}else{=get_transient(Util::transient_key(\'wppo_total_js_css\'));if(false===){=Util::get_js_css_minified_file();set_transient(Util::transient_key(\'wppo_total_js_css\'),,15*MINUTE_IN_SECONDS);}}=0;try{if(class_exists(\'PerformanceOptimise\\Inc\\Cache\')&&method_exists(\'PerformanceOptimise\\Inc\\Cache\',\'get_cache_stats\')){if(function_exists(\'wp_cache_get_salted\')&&function_exists(\'wp_using_ext_object_cache\')&&wp_using_ext_object_cache()){=wp_cache_get_salted(\'wppo_cache_stats\',\'wppo\',);if(is_array()&&isset([\'count\'])){=(int)[\'count\'];}else{=Cache::get_cache_stats();=(int)([\'cached_pages\']??0);}}else{=get_transient(Util::transient_key(\'wppo_cache_count\'));if(false!==&&is_numeric()){=(int);}else{=Cache::get_cache_stats();=(int)([\'cached_pages\']??0);}}}}catch(\\Throwable){unset();=0;}=->get_options();if(isset([\'performance_audit\'][\'pagespeed_api_key\'])){unset([\'performance_audit\'][\'pagespeed_api_key\']);}if(isset([\'object_cache\'][\'password\'])){unset([\'object_cache\'][\'password\']);}=class_exists(\'PerformanceOptimise\\Inc\\Img_Converter\')?Img_Converter::get_img_info():get_option(\'wppo_img_info\',array());=->sanitize_image_info_for_client((array));wp_localize_script(\'performance-optimisation-script\',\'wppoSettings\',array(\'apiUrl\'=>get_rest_url(null,\'performance-optimisation/v1/\'),\'ajaxUrl\'=>admin_url(\'admin-ajax.php\'),\'nonce\'=>wp_create_nonce(\'wp_rest\'),\'nonce_refresh\'=>wp_create_nonce(\'wppo_nonce_refresh\'),\'version\'=>WPPO_VERSION,\'settings\'=>,\'show_welcome\'=>!(bool)get_user_meta(get_current_user_id(),\'wppo_welcome_dismissed\',true),\'image_info\'=>,\'cache_size\'=>,\'cache_count\'=>,\'total_js_css\'=>,\'client_side_media_processing_enabled\'=>function_exists(\'wp_is_client_side_media_processing_enabled\')&&wp_is_client_side_media_processing_enabled(),\'performance_audit\'=>array(\'homeUrl\'=>Util::cached_home_url(\'/\'),\'pagespeedApiKeyConfigured\'=>!empty(->get_options()[\'performance_audit\'][\'pagespeed_api_key\']),\'highValueUrls\'=>->get_options()[\'performance_audit\'][\'high_value_urls\']??array(),\'autoFixEnabled\'=>(bool)(->get_options()[\'performance_audit\'][\'auto_fix_enabled\']??false),\'autoRescan\'=>->get_options()[\'performance_audit\'][\'auto_rescan\']??\'\',),\'themeColors\'=>->get_frontend_theme_colors(),\'userRoles\'=>->get_editable_role_names(),\'speculation_rules\'=>array(\'mode_override\'=>->get_speculation_default_override(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'),\'eagerness_override\'=>->get_speculation_default_override(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'),\'static_cache_active\'=>!empty(->get_options()[\'cache_settings\'][\'enableCache\']),),\'litespeed\'=>class_exists(\'PerformanceOptimise\\Inc\\LiteSpeed_Integration\')?LiteSpeed_Integration::get_info():array(\'detected\'=>false,\'server_type\'=>Server_Rules::get_server_type(),\'lscache_active\'=>false,\'mode\'=>\'auto\',\'effective_mode\'=>\'standalone\',\'wppo_owns_cache\'=>true,\'optimizer_disabled\'=>false,),\'allowedSettingsKeys\'=>Util::ALLOWED_SETTINGS_KEYS,\'presetBundles\'=>array(\'safe\'=>self::get_safe_preset_bundle(),\'aggressive\'=>self::get_aggressive_preset_bundle(),),\'upgradePurge\'=>->get_upgrade_purge_for_client(),),);wp_set_script_translations(\'performance-optimisation-script\',\'performance-optimisation\');}/** * Enqueues scripts for performance optimization. * * @since 1.0.0 */functionenqueue_scripts(){if(is_admin_bar_showing()&&current_user_can(\'manage_options\')){->enqueue_admin_bar_script();}if(->should_optimise_for_logged_in()){=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadImages\']);=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadBackgroundImages\']);=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadVideos\']);=!empty(->get_options()[\'image_optimisation\'][\'enableVideoPlaceholder\'])&&;=!empty(->get_options()[\'file_optimisation\'][\'delayJS\']);=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadNative\']);=(!&&)||||||||;if(){=array();if(){[\'nativeLazy\']=true;}[\'videoPlayerLabel\']=__(\'Video player\',\'performance-optimisation\');if(){=!empty(->get_options()[\'file_optimisation\'][\'delayJSIdleTimeout\'])?absint(->get_options()[\'file_optimisation\'][\'delayJSIdleTimeout\']):3000;=in_array(->delay_js_default_strategy,array(\'interaction\',\'idle\',\'viewport\'),true)?->delay_js_default_strategy:\'interaction\';[\'delayConfig\']=array(\'idleTimeout\'=>,\'defaultStrategy\'=>,);=apply_filters(\'wppo_delay_js_allowed_hosts\',array());if(!empty()){=array();foreach((array)as){if(!is_string()){continue;}=trim();if(\'\'===){continue;}if(\'*\'===){[]=\'*\';continue;}if(0===strpos(,\'//\')){=\'https:\'.;}if(false!==strpos(,\'://\')){=wp_parse_url(,PHP_URL_HOST);if(!is_string()||\'\'===){continue;}=;}=trim();if(0===strpos(,\'[\')){=strpos(,\']\');if(false===){continue;}=substr(,+1);if(\'\'!==&&1!==preg_match(\'/^:\\d+$/\',)){continue;}=substr(,1,-1);}elseif(1===preg_match(\'/^(.*):(\\d+)$/\',,)&&false===strpos([1],\':\')){=[1];}=sanitize_text_field(strtolower(trim(,\'.\')));if(\'\'===){continue;}if(function_exists(\'filter_var\')&&filter_var(,FILTER_VALIDATE_IP)){[]=;continue;}=;if(function_exists(\'idn_to_ascii\')&&defined(\'INTL_IDNA_VARIANT_UTS46\')&&defined(\'IDNA_DEFAULT\')&&1===preg_match(\'/[^\\x00-\\x7F]/\',)){=idn_to_ascii(,IDNA_DEFAULT,INTL_IDNA_VARIANT_UTS46);if(!is_string()||\'\'===){continue;}=strtolower();}if(1!==preg_match(\'/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\',)){continue;}[]=;}if(!empty()){[\'delayConfig\'][\'allowedScriptHosts\']=array_values(array_unique());}}}=self::supports_native_script_fetchpriority();=array(\'in_footer\'=>true,\'fetchpriority\'=>\'low\',);if(){if(function_exists(\'wp_enqueue_script_module\')){wp_enqueue_script_module(\'wppo-lazyload\',WPPO_PLUGIN_URL.\'build/lazyload.js\',array(),WPPO_VERSION,);}elseif(function_exists(\'wp_register_script_module\')){wp_register_script_module(\'wppo-lazyload\',WPPO_PLUGIN_URL.\'build/lazyload.js\',array(),WPPO_VERSION,);if(function_exists(\'wp_enqueue_script_module\')){wp_enqueue_script_module(\'wppo-lazyload\');}}add_filter(\'script_module_data_wppo-lazyload\',staticfunction(array)use(){returnarray_merge(,);});}else{wp_enqueue_script(\'wppo-lazyload\',WPPO_PLUGIN_URL.\'build/lazyload.js\',array(),WPPO_VERSION,array(\'in_footer\'=>true));if(){wp_add_inline_script(\'wppo-lazyload\',\'window.wppoNativeLazy=true;\',\'before\');}if(){=wp_json_encode([\'delayConfig\'],JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP);wp_add_inline_script(\'wppo-lazyload\',\'window.wppoDelayConfig=\'..\';\',\'before\');}}}}}/** * Apply defer optimisations to script modules (WP 6.9+). * * Script modules are already deferred by the browser; the remaining wins * are printing them in the footer and lowering their fetch priority so * critical CSS/images win the network queue. Uses the core API when * available (WP 6.9+ native fetchpriority/in_footer via * WP_Script_Modules::set_in_footer / set_fetchpriority or * wp_register_script_module with fetchpriority/in_footer args) and is a * no-op on older core. Guarded by version_compare and * function_exists/class_exists for backward compat. * * @since 2.0.0 * @return void */functionapply_module_loading_strategies():void{if(empty(->get_options()[\'file_optimisation\'][\'deferJS\'])){return;}if(!->should_optimise_for_logged_in()){return;}if(!self::supports_native_script_fetchpriority()){return;}if(!function_exists(\'wp_script_modules\')){return;}if(!function_exists(\'wp_enqueue_script_module\')){return;}if(!class_exists(\'WP_Script_Modules\')){return;}=wp_script_modules();if(!is_object()){return;}=is_array(->exclude_defer_js)?->exclude_defer_js:array();=(string)(->get_options()[\'file_optimisation\'][\'excludeDeferJS\']??\'\');if(\'\'!==){=array_unique(array_merge(,Util::process_urls()));}=array_unique(array_merge(,self::get_defer_js_preset_exclusions()));if(!in_array(\'wppo-lazyload\',,true)){[]=\'wppo-lazyload\';}foreach(array(\'wp-interactivity\',\'@wordpress/interactivity\',\'@wordpress/interactivity-router\')as){if(!in_array(,,true)){[]=;}}=array();if(method_exists(,\'get_print_queue\')){=(array)->get_print_queue();}=null;=false;if(empty()){=->get_registered_module_ids(,);=true;}=!method_exists(,\'get_registered\');if(&&!){=->read_private_module_store(,\'registered\');}=null;if(){=->read_private_module_store(,\'all\');}foreach(as){if(in_array((string),,true)){continue;}if(method_exists(,\'set_in_footer\')){if(->should_move_deferred_to_footer((string))){->set_in_footer((string),true);}}if(method_exists(,\'set_fetchpriority\')){=->get_module_fetchpriority(,(string),,,);if(is_string()&&\'\'!==trim()&&\'auto\'!==strtolower(trim())){continue;}=->get_filtered_deferred_fetchpriority((string));if(\'\'===){continue;}->set_fetchpriority((string),);}}}/** * Read a script module\'s fetchpriority without touching private state directly. * * Uses the public get_registered() getter when available, otherwise reads * the private $registered store via reflection. Returns null when the * module is unregistered, carries no fetchpriority key, or the store is * unreadable (fail-open: callers treat null as a gap and write \'low\'). * Never accesses $modules->registered directly: that property is private * in core, so isset()/direct reads from outside are always false and * would silently overwrite explicit values in production. * * @since 2.0.0 * * @param object $modules Script modules instance from wp_script_modules(). * @param string $id Module id. * @param mixed $registered_store Optional pre-read $registered store (hoisted by the * caller to avoid per-module reflection; pass-through * even when null so an unreadable store is not re-read). * @param mixed $all_store Optional pre-read $all store used as a fallback * when the id is absent from the registered store * (mirrors get_registered_module_ids()). * @param bool $fallback_ready Hoisted `! method_exists( $modules, \'get_registered\' )` * flag so the callee skips the per-module probe. * @return mixed Fetchpriority value, or null when missing/unreadable. */functionget_module_fetchpriority(object,string,=null,=null,bool=false):mixed{if(!&&method_exists(,\'get_registered\')){=->get_registered();if(is_array()&&array_key_exists(\'fetchpriority\',)){return[\'fetchpriority\'];}if(is_object()&&isset(->fetchpriority)){return->fetchpriority;}returnnull;}=func_num_args()>=3?:->read_private_module_store(,\'registered\');=func_num_args()>=4?:->read_private_module_store(,\'all\');foreach(array(,)as){if(is_array()&&array_key_exists(,)){=[];if(is_array()&&array_key_exists(\'fetchpriority\',)){return[\'fetchpriority\'];}if(is_object()&&isset(->fetchpriority)){return->fetchpriority;}}}returnnull;}/** * Collect registered module ids without touching private state directly. * * Reflection fallback for environments where get_print_queue() is empty or * unavailable; reads the private $registered (then $all) store. Returns an * empty array when the store is unreadable. * * @since 2.0.0 * * @param object $modules Script modules instance from wp_script_modules(). * @param mixed $registered_store Optional out-param receiving the already-read * `registered` store (even when null/unreadable) * so callers can hoist it without re-reflecting. * @return string[] Module ids. */functionget_registered_module_ids(object,&=null):array{=->read_private_module_store(,\'registered\');if(is_array()&&!empty()){returnarray_map(\'strval\',array_keys());}=->read_private_module_store(,\'all\');if(is_array()&&!empty()){returnarray_map(\'strval\',array_keys());}returnarray();}/** * Read a (possibly private) property from the script-modules instance. * * Returns null when the property does not exist or is unreadable instead * of raising. Public properties are read directly; non-public ones go * through reflection (no setAccessible() call: it is deprecated on * PHP 8.5 and a no-op since PHP 8.1, and the plugin requires PHP 8.2+). * * @since 2.0.0 * * @param object $modules Script modules instance. * @param string $property Property name. * @return mixed Property value, or null when unreadable. */functionread_private_module_store(object,string):mixed{try{=new\\ReflectionObject();if(!->hasProperty()){returnnull;}=->getProperty();return->getValue();}catch(\\Throwable){returnnull;}}/** * Dequeues configured WooCommerce CSS and JS handles unless the current URL is excluded. * * Reads `file_optimisation.excludeUrlToKeepJSCSS` and, if the current front-end URL matches any entry * (exact match or prefix match when an entry contains the `(.*)` suffix), preserves scripts/styles. * Otherwise reads `file_optimisation.removeCssJsHandle` and dequeues each entry prefixed with * `style:` (dequeues a style handle) or `script:` (dequeues a script handle). * * @since 1.0.0 * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::remove_woocommerce_scripts}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionremove_woocommerce_scripts(){return->script_strategy()->remove_woocommerce_scripts();}/** * Adds custom settings to the WordPress admin bar. * * Capability-gated: only users with `manage_options` see cache-clear nodes. * The REST handlers behind the nodes also enforce `manage_options` + nonce, * so this is a UI disclosure guard (defence in depth). * * @param \\WP_Admin_Bar $wp_admin_bar The WordPress admin bar object used to add nodes and settings. * * @since 1.0.0 * @since 2.0.0 Added `manage_options` capability check. */functionadd_setting_to_admin_bar(){if(!current_user_can(\'manage_options\')){return;}->add_node(array(\'id\'=>\'wppo_setting\',\'title\'=>__(\'Performance Optimisation\',\'performance-optimisation\'),\'href\'=>admin_url(\'admin.php?page=performance-optimisation\'),\'meta\'=>array(\'class\'=>\'performance-optimisation-setting\',\'title\'=>__(\'Go to Performance Optimisation Setting\',\'performance-optimisation\'),),),);->add_node(array(\'id\'=>\'wppo_clear_all\',\'parent\'=>\'wppo_setting\',\'title\'=>__(\'Clear All Cache\',\'performance-optimisation\'),\'href\'=>\'#\',));if(!is_admin()){=get_the_ID();->add_node(array(\'id\'=>\'wppo_clear_this_page\',\'parent\'=>\'wppo_setting\',\'title\'=>__(\'Clear This Page Cache\',\'performance-optimisation\'),\'href\'=>\'#\',\'meta\'=>array(\'title\'=>__(\'Clear cache for this specific page or post\',\'performance-optimisation\'),\'class\'=>\'page-\'.,),));}}/** * Whether the native WP 6.3+ script loading strategy API can be used. * * Canonical predicate for the defer pipeline (issue #1218): the native * `wp_script_add_data( $handle, \'strategy\', \'defer\' )` path is only * honoured by core since WP 6.3, so both setup_hooks() routing and * add_defer_strategy() fail-open on this helper. Three guards: * version >= 6.3-alpha; `wp_script_add_data()` present (guards a * stripped/missing API — the function itself predates 6.3, so on its * own it cannot detect a backported/filtered version string); and, * when the WP_Scripts class is available, the genuinely-6.3 * `WP_Scripts::get_eligible_loading_strategy()` method (changeset * 56033), which IS absent on pre-6.3 core and therefore catches the * inflated-version case. When the class is unavailable (e.g. very * early load), version + function probes decide. Fail-open: false on * any unreadable version or missing API, in which case callers fall * back to the pre-6.3 script_loader_tag regex. * * @since 2.2.0 * * @return bool True when the native defer strategy path is allowed. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::supports_native_defer_strategy}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionsupports_native_defer_strategy():bool{returnScript_Strategy::supports_native_defer_strategy();}/** * Whether the native WP 6.9+ script fetchpriority API can be used. * * Canonical predicate for the fetchpriority pipeline (issue #1218): * native fetchpriority rendering arrived in WP 6.9 (Trac #61734), so * setup_hooks() fallback routing, add_defer_strategy(), and * apply_module_loading_strategies() all fail-open on this helper * instead of a bare version_compare, which a backported/filtered * version string could defeat. Guards: version >= 6.9-alpha plus the * genuinely-6.9 `WP_Script_Modules::set_fetchpriority()` method when * the class is available; when it is not (e.g. unit-test doubles), * the 6.5+ module functions decide alongside the version gate. * Fail-open: false on any unreadable version or missing API, in which * case callers fall back to the pre-6.9 script_loader_tag regex. * * @since 2.2.0 * * @return bool True when the native fetchpriority path is allowed. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::supports_native_script_fetchpriority}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionsupports_native_script_fetchpriority():bool{returnScript_Strategy::supports_native_script_fetchpriority();}/** * Whether a queued handle is eligible for a deferred loading strategy. * * Mirrors core\'s WP_Scripts::get_eligible_loading_strategy() gate * (changeset 56033, issue #1466) so the native strategy write never * fights core output: core renders a handle blocking when it carries * an inline `after` script, when a blocking queued dependent relies * on it, or when it is a module/import-map script. Core keeps * get_eligible_loading_strategy() private with pre-stamp semantics * (\'\' when no intended strategy is set), so it can neither be called * nor reused here; eligibility is decided by the manual fallback * below, which is the only path that can run against real core. The * existing supports_native_defer_strategy() method_exists probe * already covers capability detection. Fail-open: true on any * unreadable state so output degrades to the current behaviour, never * fatal or white-screen. * * Dependents are judged against the run\'s intended set plus live * state (issue #1466 review): a queued dependent carrying no explicit * strategy counts as deferred when this run intends to defer it, so * the verdict never depends on queue order. Transitive poisoning is * handled by recursing into each deferred dependent with a visited * set (mirroring core\'s $checked param): a dependent that is itself * blocked by a third handle poisons its own dependencies. * * @since 2.3.0 * * @param object $wp_scripts WP_Scripts registry. * @param string $handle Script handle. * @param string[] $intended Handles this run intends to defer (pass * the interactivity guard and exclusion * list). Defaults to empty for direct calls. * @param bool[] $checked Visited handles for the recursion guard * (mirrors core\'s $checked). Callers leave * this at its default; a fresh map is used * per top-level evaluation. * @return bool True when the handle may receive strategy defer. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_defer_eligible_for_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_defer_eligible_for_handle(object,string,array=array(),array&=array()):bool{return->script_strategy()->is_defer_eligible_for_handle(,,,);}/** * Whether the WP 6.9+ core template-enhancement buffer should carry plugin post-processing. * * Canonical predicate for the single-buffer routing (issue #1386): * version >= 6.9-alpha plus the genuinely-6.9 * `wp_should_output_buffer_template_for_enhancement()` API. This is an * availability probe only: it never calls the predicate itself, so * registration-time routing cannot confuse a mid-request opt-out * (filter returning false) with a missing core. When true, * setup_hooks() registers ONLY the `wp_template_enhancement_output_buffer` * filter / `wp_finalized_template_enhancement_output_buffer` action * callbacks and no private `ob_start()` capture; when false (pre-6.9) * only the legacy `template_redirect` captures are registered. * Honoring a runtime `false` (opt-out / no consumers) means degrading * to uncached streaming output, never opening a private buffer — * intentional, since a private capture would stack on core\'s buffer * and re-process HTML; caching resumes when core opts back in. * Fail-open: false on any unreadable version or missing API, in which * case callers fall back to the pre-6.9 legacy path. * * @since 2.3.0 * * @return bool True when the core template-enhancement buffer path is allowed. * Facade proxy (P3-015): logic lives in {@see Preload_Buffer_Coordinator::should_use_core_template_buffer}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). * @since NEXT Proxied to Preload_Buffer_Coordinator (P3-015). */staticfunctionshould_use_core_template_buffer():bool{returnPreload_Buffer_Coordinator::should_use_core_template_buffer();}/** * Resolve the filtered fetchpriority for a deferred handle. * * Shared by the native classic path (add_defer_strategy()), the * pre-6.9 regex fallback (add_fetchpriority_to_deferred()), and the * module path (apply_module_loading_strategies()) so all three honor * the same filter contract. Guarded by function_exists + has_filter * so installs without the filter keep the \'low\' default with no * extra dispatch; fail-open to \'low\' on any throwable and to \'\' * (suppress) when the filter returns a non-listed value. * * @since 2.2.0 * * @param string $handle Script handle or module id. * @return string Validated \'high\'|\'low\'|\'auto\', or \'\' to suppress. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_filtered_deferred_fetchpriority}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_filtered_deferred_fetchpriority(string):string{return->script_strategy()->get_filtered_deferred_fetchpriority();}/** * Whether a deferred handle should be moved to the footer. * * Shared by the native classic path (add_defer_strategy()) and the * module path (apply_module_loading_strategies()) so both honor the * same filter contract. Guarded by function_exists + has_filter; * fail-open to true (move) when the filter is absent or throws. * * @since 2.2.0 * * @param string $handle Script handle or module id. * @return bool True when the handle should be footer-bound. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::should_move_deferred_to_footer}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionshould_move_deferred_to_footer(string):bool{return->script_strategy()->should_move_deferred_to_footer();}/** * Applies defer strategy to non-logged-in users\' scripts using wp_script_add_data. * * Canonical defer path on WP 6.3+ (issue #1218); the pre-6.3 * script_loader_tag regex fallback (add_defer_attribute_legacy()) is * never registered on the same request via setup_hooks(). Iterates * `$wp_scripts->queue` in order with no sorting so dependency chain * order is preserved. Fill-gaps-only for the strategy itself: an * explicit `async` strategy is never rewritten to `defer`. * * On WP 6.9+ deferred handles also receive native fetchpriority/in_footer * args via the Script Loader API (Trac #61734 / #63486) so core renders * them with dependency bumping; the regex fallback * add_fetchpriority_to_deferred() stays disabled on 6.9+ via setup_hooks(). * The footer move uses the \'group\' data key (core maps the in_footer * enqueue arg to group=1; the \'in_footer\' data key itself is never read * for classic scripts) and can be disabled per handle via the * `wppo_deferred_in_footer` filter. * * @since 2.0.0 * * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_defer_strategy}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_defer_strategy():void{->script_strategy()->add_defer_strategy();}/** * Whether a script tag\'s type attribute is executable JavaScript. * * A tag with no `type` attribute is treated as executable because HTML * implies `text/javascript`. Executable JS types (including `module`, * which HTML treats as JavaScript) may be delay-rewritten; every other * type is a data block — `application/json`, `application/ld+json`, * `text/template`, `speculationrules`, and so on — that never executes * and whose `src` must not be moved to `wppo-src`. * * The leading `\\s` in the pattern is what keeps `wppo-type=\"…\"` from * matching: the character before `type` there is `-`, not whitespace. * * @since 2.2.0 * * @param string $tag The script tag markup. * @return bool True when the tag may be delay-rewritten. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_executable_script_type}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_executable_script_type(string):bool{return->script_strategy()->is_executable_script_type();}/** * Adds defer attribute to non-logged-in users\' scripts. * * @since 1.0.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @return string Modified script tag with defer attribute. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_defer_attribute}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_defer_attribute(,):string{return->script_strategy()->add_defer_attribute(,);}/** * Adds the defer attribute to script tags on WordPress < 6.3. * * WordPress 6.3+ natively honours the \'strategy\' script data added via * wp_script_add_data(), so this legacy fallback is only registered on older * core (WP 6.2) where the native strategy is silently ignored. Retained * while the plugin floor is 6.2 (issue #1203: removal deferred until the * minimum supported WP is raised to 6.3; see TODO(#553) in setup_hooks()). * Fill-gaps-only: a tag already carrying `defer`, `async` (core, theme, * or LiteSpeed delay), or `type=\"module\"` is returned untouched so no * `async defer` double-attribute markup is emitted (issue #1218). The * pre-checks are case-insensitive with attribute boundaries and * quote-masking, so DEFER, valued async=\"async\", single-quoted or * spaced type=\'module\', and `async` inside quoted values (ignored) * are all handled. * * @since 1.9.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @return string Modified script tag with the defer attribute. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_defer_attribute_legacy}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_defer_attribute_legacy(,):string{return->script_strategy()->add_defer_attribute_legacy(,);}/** * Check whether a script handle matches a delay pattern using word boundaries. * * The pattern (a handle or URL) is matched literally between `\\b` word * boundaries, preventing partial-word substring false positives such as * `slide` matching the unrelated handle `slider-custom`, while preserving * dash-delimited prefix matches users rely on (`jquery` → `jquery-core`). * URL metacharacters are escaped via preg_quote() so the pattern is matched * literally. Empty patterns are ignored so regex construction stays valid. * * @since 1.9.0 * * @param string $handle The script handle. * @param string $pattern The configured pattern (handle or URL). * @return bool True if the handle matches the pattern. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_delay_pattern}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionmatches_delay_pattern(string,string):bool{return->script_strategy()->matches_delay_pattern(,);}/** * Build (once per request) a combined alternation regex for a pattern list. * * @since 2.0.0 * @param string[] $patterns Pattern list. * @return string Empty string when no usable patterns; otherwise a ready regex. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_patterns_regex}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_patterns_regex(array):string{returnScript_Strategy::get_delay_patterns_regex();}/** * Whether a handle matches any pattern in a list via the precompiled alternation. * * Falls back to per-pattern matching only when the combined regex * fails to compile (extremely long lists). * * @since 2.0.0 * @param string $handle Script handle. * @param string[] $patterns Pattern list. * @return bool True on match. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_any_delay_pattern}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionmatches_any_delay_pattern(string,array):bool{return->script_strategy()->matches_any_delay_pattern(,);}/** * Whether a script handle is excluded from Delay JS. * * Checks exact membership first, then falls back to * matches_delay_pattern() word-boundary matching so builder-handle * variants (e.g. `oxygen-*` via `oxygen`, `et-*` via `et-core-api`) * stay excluded on the external-script path exactly as the inline * path in Minify\\HTML excludes them by substring. * * @since 2.0.0 * * @param string $handle The script\'s registered handle. * @return bool True when the handle must stay un-delayed. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_excluded_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_excluded_handle(string):bool{return->script_strategy()->is_delay_excluded_handle();}/** * Delay-JS exclusions with `wppo_exclude_delay_js` applied on first use. * * `setup_hooks()` applies the filter while the plugin bootstraps, and * plugins load before the active theme. A theme registering an * exclusion from functions.php therefore had no effect on the * handle-level rewrite, which silently leaves its script swapped to * `wppo-src` and never executed — a mobile menu that cannot open, for * instance. Re-applying here (during enqueue, after every plugin and * the theme have loaded) lets those late registrations count. The * result is memoized, so the filter still runs at most once per * request on this path. * * @since 2.2.0 * * @return array<int, string> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_exclusions():array{return->script_strategy()->get_delay_exclusions();}/** * Whether the current request must skip delay-JS rewriting. * * Woo guardrail: cart, checkout, and account pages stay excluded from delay * by default, plus wc-ajax and add-to-cart requests. Intentionally narrower * than Cache::is_woo_excluded(): visitor-scoped cookie signals (cart hash, * wp_woocommerce_session_*) are NOT mirrored — disabling delay site-wide * for every shopper would wipe out the INP win on the homepage/blog, and * mini-cart fragments elsewhere are already protected by the per-handle * Woo exclusions in get_delay_js_preset_exclusions(). Likewise there is no * blanket is_woocommerce() gate: shop/product archives rely on the same * per-handle exclusions (wc-add-to-cart, wc-single-product, …). * Builder guardrail: preview/edit contexts only — rendered frontend output * from builders still gets the INP win. * Fail-open: any detection failure returns true (skip delay) so scripts * stay un-delayed, never fatal. A positive match also returns true. * * The cart/checkout/account path fallback matches the slug as a full * path segment anywhere in the request path (covers subdirectory installs and multisite sub-sites) and * additionally resolves custom/translated slugs via wc_get_page_id() when * WooCommerce is active; on non-Woo installs only the default slugs apply. * * @since 2.0.0 * * @return bool True when delay must be skipped for this request. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_excluded_context}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionis_delay_excluded_context():bool{returnScript_Strategy::is_delay_excluded_context();}/** * Signature of the request inputs that drive the delay-context verdict. * * The verdict depends on the request URI, query string, and query * arguments (plus conditional tags, which a real request does not * change mid-flight). Hashing these lets the memo self-invalidate * when a new logical request reuses the same PHP process. * * @since 2.0.0 * @return string Signature string. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::delay_context_request_signature}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctiondelay_context_request_signature():string{returnScript_Strategy::delay_context_request_signature();}/** * Reset the per-request delay-context memo (for tests). * * @since 2.0.0 * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::reset_delay_context_memo}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionreset_delay_context_memo():void{Script_Strategy::reset_delay_context_memo();}/** * Compute whether the current request must skip delay-JS rewriting. * * @since 2.0.0 * @return bool True when delay must be skipped for this request. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::compute_delay_excluded_context}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctioncompute_delay_excluded_context():bool{returnScript_Strategy::compute_delay_excluded_context();}/** * Whether a request path belongs to a WooCommerce cart/checkout/account page. * * Matches the slug as a full path segment anywhere in the request path * (fail-safe: covers subdirectory/multisite prefixes such as /shop/checkout * and /subsite/cart; a non-Woo page containing the segment is also * treated as dynamic). * Custom/translated slugs are resolved via wc_get_page_id() when * WooCommerce is active; otherwise only the default slugs apply. * * @since 2.0.0 * * @param string $local_path Request path with a leading slash. * @return bool True when the path is a Woo page path. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_woo_page_path}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionmatches_woo_page_path(string):bool{returnScript_Strategy::matches_woo_page_path();}/** * Get the delay strategy for a given script handle. * * Checks idle list, viewport list, and then falls back to default strategy. * * Contract: callers must gate on is_delay_third_party_auto_candidate() * first; this resolver does NOT re-check the allowlist or the * builder/commerce exclusions. Pass the gate verdict via * $is_auto_matched to avoid a second pattern scan; null means * \"not precomputed\" and falls back to matching here. * * @since 1.9.0 * * @param string $handle The script handle. * @param string $tag Optional script tag markup (for auto-mode src matching). * @param bool|null $is_auto_matched Optional precomputed auto-candidate verdict. * @return string The strategy: \'interaction\', \'idle\', or \'viewport\'. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_strategy_for_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_strategy_for_handle(string,string=\'\',?bool=null):string{return->script_strategy()->get_delay_strategy_for_handle(,,);}/** * Get the delay priority for a given script handle. * * @since 1.9.0 * * @param string $handle The script handle. * @return string The priority: \'high\', \'normal\', or \'low\'. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_priority_for_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_priority_for_handle(string):string{return->script_strategy()->get_delay_priority_for_handle();}/** * Apply per-page delay configuration overrides from the Asset Manager metabox. * * Runs at `wp` hook to merge per-page strategy/priority overrides into the * global delay lists before the `script_loader_tag` filter fires. * * @since 1.9.0 * * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::apply_per_page_delay_config}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionapply_per_page_delay_config():void{->script_strategy()->apply_per_page_delay_config();}/** * Curated per-builder Delay JS exclusions (issue #966). * * Builder runtimes must stay un-delayed by default — delaying them * breaks Elementor/Divi/Bricks/WPBakery/Oxygen rendering and the * block-interactivity runtime. Only the exclusion list is shared with * Minify\\HTML so the lists cannot drift; matching semantics differ by * design. The external path matches handles via * is_delay_excluded_handle() (exact, dash/underscore variants, pure * prefixes, word-boundary fallback) while the inline path intentionally * over-matches by substring over attributes+content (fail-open * direction), so over/under-exclusion can still diverge. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_builder_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_builder_exclusions():array{returnScript_Strategy::get_delay_js_builder_exclusions();}/** * Curated commerce Delay JS exclusions (issue #988). * * The jQuery plus cart-fragments/checkout handles stay un-delayed when * the commerce preset is on so carts and checkouts never break. * Filterable via wppo_delay_js_commerce_exclusions. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_commerce_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_commerce_exclusions():array{returnScript_Strategy::get_delay_js_commerce_exclusions();}/** * Curated slider Delay JS exclusions (issue #988). * * Slider runtimes stay un-delayed with the builder preset so hero * sliders keep working. Filterable via wppo_delay_js_slider_exclusions. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_slider_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_slider_exclusions():array{returnScript_Strategy::get_delay_js_slider_exclusions();}/** * Curated first-click interaction Delay JS exclusions (issue #1055). * * Popup/dialog, mobile-menu, and add-to-cart handles must stay * un-delayed so first-click interactions never need a second click. * Filterable via wppo_delay_js_interaction_exclusions. Merged into * the global preset when `delayJSInteractionPreset` is on (default). * Per-site settings only; multisite-safe. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_interaction_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_interaction_exclusions():array{returnScript_Strategy::get_delay_js_interaction_exclusions();}/** * One-click Delay-JS preset levels (issue #1385). * * Single source of truth for the Safe / Balanced / Aggressive * one-click presets. Builder plus commerce presets are forced ON at * every level so carts, checkouts, and builder runtimes never break. * * @since 2.3.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_levels}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_preset_levels():array{returnScript_Strategy::get_delay_js_preset_levels();}/** * Toggle map applied by a one-click Delay-JS preset level (issue #1385). * * Maps a level to the existing exclusion-getter toggles only — no new * delay semantics. Fail-open: unknown levels degrade to the safe map. * Guarded by function_exists/has_filter plus a legacy fallback so the * current delay path is used when the filter API is unavailable. * * @since 2.3.0 * * @param string $level Preset level (safe|balanced|aggressive). * @return array<string, bool> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_level_settings}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_preset_level_settings(string):array{returnScript_Strategy::get_delay_js_preset_level_settings();}/** * Merged exclusion list for a one-click Delay-JS preset level (issue #1385). * * Maps Safe / Balanced / Aggressive to the existing exclusion getters * only (builder, slider, commerce, interaction, consent, analytics, * gallery, jquery plus the always-on base preset). Manual exclusions * and the delayJSThirdPartyAuto patterns are merged by the caller, so * the manual textarea plus filter always win. Fail-open: any failure * degrades to the base preset list (safe direction — pages exclude * more, never delay everything), never fatal. * * @since 2.3.0 * * @param string $level Preset level (safe|balanced|aggressive). * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_level_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_preset_level_exclusions(string):array{returnScript_Strategy::get_delay_js_preset_level_exclusions();}/** * Apply a preset exclusion filter with fail-open guards (issue #1308). * * Shared by the opt-in compat presets so a misbehaving filter callback * degrades to the curated list, never fatal and never white screen. * Guarded by function_exists/has_filter so behaviour is identical with * and without the filter API (WP 6.2+ always provides it). * * @since 2.2.0 * * @param string $filter Filter hook name. * @param string[] $preset Curated preset exclusions. * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::filter_compat_preset_list}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionfilter_compat_preset_list(string,array):array{returnScript_Strategy::filter_compat_preset_list(,);}/** * Curated consent Delay JS exclusions (issue #1308). * * Consent banners and scanners (CookieYes, Cookiebot, Complianz, * Borlabs, OneTrust, …) must stay un-delayed when the consent preset * is on so banners render and scans see the real scripts. Opt-in * (default off); merged additively, never replacing manual excludes. * Filterable via wppo_delay_js_consent_exclusions. Fail-open: any * filter error degrades to the curated list (un-delayed output). * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_consent_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_consent_exclusions():array{returnScript_Strategy::get_delay_js_consent_exclusions();}/** * Curated analytics Delay JS exclusions (issue #1308). * * Analytics beacons (GA4 gtag, Matomo, Plausible, …) must stay * un-delayed when the analytics preset is on so hits are not lost * before interaction. Opt-in (default off); additive merge only. * Filterable via wppo_delay_js_analytics_exclusions. Fail-open to * the curated list on any filter error. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_analytics_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_analytics_exclusions():array{returnScript_Strategy::get_delay_js_analytics_exclusions();}/** * Curated gallery Delay JS exclusions (issue #1308). * * Galleries and lightboxes beyond the slider runtimes (PhotoSwipe, * Fancybox, Envira, FooGallery, …) must stay un-delayed when the * gallery preset is on so they work pre-interaction. Opt-in * (default off); additive merge only. Filterable via * wppo_delay_js_gallery_exclusions. Fail-open to the curated list. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_gallery_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_gallery_exclusions():array{returnScript_Strategy::get_delay_js_gallery_exclusions();}/** * Curated jQuery-legacy Delay JS exclusions (issue #1308). * * Legacy jQuery-dependent widgets on non-Woo sites must stay un-delayed * when the jquery preset is on. The commerce preset already owns the * core jQuery/cart handles for shops; this opt-in preset (default * off) extends cover to jQuery UI/plugins for legacy themes. * Filterable via wppo_delay_js_jquery_exclusions. Fail-open to the * curated list on any filter error. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_jquery_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_jquery_exclusions():array{returnScript_Strategy::get_delay_js_jquery_exclusions();}/** * Compat preset slugs keyed by their settings key (issue #1308). * * Single source of truth for the four opt-in presets: settings key * => preset slug used in per-page opt-out meta. * * @since 2.2.0 * @return array<string, string> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_compat_preset_map}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_compat_preset_map():array{returnScript_Strategy::get_delay_js_compat_preset_map();}/** * Exclusion list for one compat preset slug (issue #1308). * * Lazy-boots only the requested matcher so sites without delay pay * zero cost. Fail-open: unknown slugs return an empty list. * * @since 2.2.0 * * @param string $slug Preset slug (consent|analytics|gallery|jquery). * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_compat_preset_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_compat_preset_exclusions(string):array{returnScript_Strategy::get_delay_js_compat_preset_exclusions();}/** * One-click Safe preset bundle (issue #1442). * * Curated `file_optimisation` settings that enable minify plus defer * plus delay together with the builder, commerce, interaction and * jQuery exclusions pre-applied, so page builders, jQuery widgets and * WooCommerce never break. Returns only pre-existing settings keys: * additive, no schema change, safe-by-default. Consent, analytics and * gallery presets stay off (opt-in), combineCSS stays off (FOUC risk). * Multisite-safe: per-site `wppo_settings` only. * * @since 2.3.0 * @return array<string, mixed> */staticfunctionget_safe_preset_bundle():array{try{returnarray(\'minifyJS\'=>true,\'minifyCSS\'=>true,\'minifyHTML\'=>true,\'deferJS\'=>true,\'delayJS\'=>true,\'delayJSBuilderPreset\'=>true,\'delayJSCommercePreset\'=>true,\'delayJSInteractionPreset\'=>true,\'delayJSJqueryPreset\'=>true,\'delayJSSafeMode\'=>true,\'elementorSafeMode\'=>true,\'delayJSConsentPreset\'=>false,\'delayJSAnalyticsPreset\'=>false,\'delayJSGalleryPreset\'=>false,\'combineCSS\'=>false,);}catch(\\Throwable){unset();returnarray();}}/** * Aggressive preset bundle (issue #1442). * * Same pipelines as the Safe preset but with the builder, commerce, * interaction and jQuery safe presets off plus CSS combining on, for * users who manage exclusions manually. UI-gated behind an explicit * warning with one-click revert via the settings snapshot. Returns * only pre-existing settings keys: additive, no schema change. * * @since 2.3.0 * @return array<string, mixed> */staticfunctionget_aggressive_preset_bundle():array{try{returnarray(\'minifyJS\'=>true,\'minifyCSS\'=>true,\'minifyHTML\'=>true,\'deferJS\'=>true,\'delayJS\'=>true,\'delayJSBuilderPreset\'=>false,\'delayJSCommercePreset\'=>false,\'delayJSInteractionPreset\'=>false,\'delayJSJqueryPreset\'=>false,\'delayJSSafeMode\'=>false,\'elementorSafeMode\'=>false,\'combineCSS\'=>true,);}catch(\\Throwable){unset();returnarray();}}/** * Merge a preset bundle additively into file-optimisation settings (issue #1442). * * Only allowlisted pre-existing keys from the bundle are applied; any * unknown key is skipped so a future bundle can never widen the schema * or persist unexpected values. Fail-open: any failure returns the * input unchanged. * * @since 2.3.0 * @param array<string, mixed> $current Current file_optimisation settings. * @param array<string, mixed> $bundle Preset bundle (e.g. get_safe_preset_bundle()). * @return array<string, mixed> Merged settings. */staticfunctionapply_preset_bundle(array,array):array{try{=array(\'minifyJS\',\'minifyCSS\',\'minifyHTML\',\'deferJS\',\'delayJS\',\'delayJSBuilderPreset\',\'delayJSCommercePreset\',\'delayJSInteractionPreset\',\'delayJSJqueryPreset\',\'delayJSSafeMode\',\'elementorSafeMode\',\'delayJSConsentPreset\',\'delayJSAnalyticsPreset\',\'delayJSGalleryPreset\',\'combineCSS\',);foreach(as=>){if(!is_string()||!in_array(,,true)){continue;}[]=;}return;}catch(\\Throwable){unset();return;}}/** * Whether file-optimisation settings match the Safe preset (issue #1442). * * True when the minify/defer/delay pipelines are on together with all * four safe exclusion presets plus the two safe-mode guards * (`delayJSSafeMode`, `elementorSafeMode`) the bundle applies, and * with `combineCSS` off (the bundle pins it false — FOUC risk — * while Aggressive pins it true), so enabling CSS combining after * applying Safe clears the confirmation instead of overstating * safety. `minifyHTML` is part of the pipeline gate because the * bundle pins it true. Fail-open: any failure returns false. * * @since 2.3.0 * @param array<string, mixed> $file_opt file_optimisation settings slice. * @return bool */staticfunctionis_safe_preset_active(array):bool{try{foreach(array(\'minifyJS\',\'minifyCSS\',\'minifyHTML\',\'deferJS\',\'delayJS\')as){if(empty([])){returnfalse;}}=array(\'delayJSBuilderPreset\',\'delayJSCommercePreset\',\'delayJSInteractionPreset\',\'delayJSJqueryPreset\',\'delayJSSafeMode\',\'elementorSafeMode\',);foreach(as){if(empty([])){returnfalse;}}if(!empty([\'combineCSS\'])){returnfalse;}returntrue;}catch(\\Throwable){unset();returnfalse;}}/** * Per-page disabled compat presets from post meta (issue #1308). * * Reads `_wppo_delay_presets_off` (array of slugs). A page can opt * out of a globally-enabled preset without touching global settings * so exceptions stay surgical. Multisite-safe: per-site post meta, * no cross-site leakage. Fail-open: any detection failure returns * an empty list (no opt-out applied). * * @since 2.2.0 * * @param int $post_id Optional post ID. Defaults to the current post. * @return string[] */staticfunctionget_page_disabled_delay_presets(int=0):array{try{if(0===){if(!function_exists(\'get_the_ID\')){returnarray();}=(int)get_the_ID();}if(<=0){returnarray();}static=array();=0;if(function_exists(\'is_multisite\')&&function_exists(\'get_current_blog_id\')){try{if(is_multisite()){=(int)get_current_blog_id();}}catch(\\Throwable){unset();=0;}}=.\':\'.;if(isset([])){return[];}if(!function_exists(\'get_post_meta\')){returnarray();}=get_post_meta(,\'_wppo_delay_presets_off\',true);if(!is_array()){[]=array();returnarray();}=array(\'consent\',\'analytics\',\'gallery\',\'jquery\');=array();foreach(as){if(!is_scalar()){continue;}=strtolower(trim((string)));if(in_array(,,true)&&!in_array(,,true)){[]=;}}[]=;return;}catch(\\Throwable){unset();returnarray();}}/** * Whether the current URL matches a newline-separated exclusion list (issue #988). * * Each non-empty line is a case-insensitive URL-substring match, or a * regex when wrapped in valid delimiters (e.g. `#...#`). Fail-open: * any detection failure returns false (no exclusion). * * @since 2.0.0 * * @param string $url_list Newline-separated exclusion list. * @return bool True when the current request URL is excluded. */staticfunctionis_url_excluded_by_list(string):bool{try{=trim();if(\'\'===){returnfalse;}=isset([\'REQUEST_URI\'])?wp_unslash([\'REQUEST_URI\']):\'/\';=sanitize_text_field((string));if(function_exists(\'wp_parse_url\')){=(string)wp_parse_url(,PHP_URL_PATH);}else{=strpos(,\'?\');=false===?:substr(,0,);}=strtolower(.\' \'.);if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'process_urls\')){=Util::process_urls();}else{=array_values(array_filter(array_map(\'trim\',explode(\"\\n\",))));}foreach(as){=trim((string));if(\'\'===){continue;}if(strlen()>2&&\'#\'===[0]&&false!==strrpos(,\'#\',1)){=false;set_error_handler(staticfunction(){});try{=false!==preg_match(,\'\');}catch(\\Throwable){unset();=false;}restore_error_handler();if(){set_error_handler(staticfunction(){});try{=preg_match(.\'i\',);}catch(\\Throwable){unset();=false;}restore_error_handler();if(1===){returntrue;}continue;}}if(false!==stripos(,strtolower())){returntrue;}}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Whether used CSS must be skipped for the current URL (issue #988). * * Checks the per-URL `usedCSSExcludeUrls` list plus the `_wppo_used_css_disabled` * per-page kill-switch. Fail-open: any detection failure returns false. * * @since 2.0.0 * @return bool True when used CSS must be skipped. */staticfunctionis_used_css_excluded_for_url():bool{try{=\'\';if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){=Util::get_settings();=isset([\'file_optimisation\'][\'usedCSSExcludeUrls\'])?(string)[\'file_optimisation\'][\'usedCSSExcludeUrls\']:\'\';}if(\'\'!==trim()&&self::is_url_excluded_by_list()){returntrue;}if(function_exists(\'is_singular\')&&function_exists(\'get_the_ID\')&&function_exists(\'get_post_meta\')){try{if(is_singular()){=(int)get_the_ID();if(>0&&!empty(get_post_meta(,\'_wppo_used_css_disabled\',true))){returntrue;}}}catch(\\Throwable){unset();}}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Whether Delay JS is disabled for a singular page (issue #966). * * Reads the `_wppo_delay_disabled` post-meta kill-switch. Fail-open: * any detection failure returns false (delay stays enabled) except * unexpected throwables, which return false as well — callers already * fail open to original scripts on rewrite errors. * * @since 2.0.0 * * @param int $post_id Optional post ID. Defaults to the current post. * @return bool True when delay must be skipped for this page. */staticfunctionis_delay_disabled_for_page(int=0):bool{try{if(function_exists(\'is_singular\')&&!is_singular()&&0===){returnfalse;}if(0===){if(!function_exists(\'get_the_ID\')){returnfalse;}=(int)get_the_ID();}if(<=0){returnfalse;}=0;if(function_exists(\'is_multisite\')&&function_exists(\'get_current_blog_id\')){try{if(is_multisite()){=(int)get_current_blog_id();}}catch(\\Throwable){unset();=0;}}=.\':\'.;if(isset(self::[])){returnself::[];}if(!function_exists(\'get_post_meta\')){returnfalse;}=!empty(get_post_meta(,\'_wppo_delay_disabled\',true));self::[]=;return;}catch(\\Throwable){unset();returnfalse;}}/** * Whether the unified safe-mode kill switch is enabled (issue #1098). * * When on, delay-JS + defer-JS + remove-unused-CSS (and Critical-CSS * stylesheet deferral) are all disabled in one click while the * underlying `delayJS` / `deferJS` / `removeUnusedCSS` settings are * preserved untouched, so turning safe mode back off restores the * previous configuration without re-entering settings (one-click * recovery). Additive `file_optimisation.safeMode` key, defaults to * off. Filterable via `wppo_safe_mode_enabled` (has_filter-guarded, * fail-open to the stored setting). Multisite-safe: per-site settings. * * @since 2.2.0 * * @return bool True when aggressive optimisations must be skipped. */functionis_safe_mode_enabled():bool{returnself::is_safe_mode_active(->get_options()[\'file_optimisation\']??array());}/** * Whether the current request is a valid sandbox asset-preview request. * * Delegates to Sandbox_Preview::is_preview_request() (admin-only query * param + nonce). Fail-open to false when the controller is missing. * * @since 2.2.0 * * @return bool True when experimental preview output may render. */staticfunctionis_sandbox_preview_active():bool{try{if(class_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\')&&method_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\',\'is_preview_request\')){return(bool)Sandbox_Preview::is_preview_request();}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Effective file_optimisation slice for this request. * * Visitors get production unchanged; admin preview requests get * production overlaid with staged sandbox values. Fail-open to * production on any error. * * @since 2.2.0 * * @param array $file_optimisation Production slice. * @return array Effective slice. */staticfunctionget_effective_file_optimisation(array=array()):array{try{if(class_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\')&&method_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\',\'get_effective_file_optimisation\')){returnSandbox_Preview::get_effective_file_optimisation();}return;}catch(\\Throwable){unset();return;}}/** * Static safe-mode predicate shared by Main / Used_CSS / Critical_CSS. * * Reads `file_optimisation.safeMode` from the passed settings (or from * `Util::get_settings()` when empty) so static buffer callbacks that * have no Main instance can gate identically. Any failure fails open * to disabled (optimisations run) except an explicit stored `true`. * * @since 2.2.0 * * @param array $file_optimisation Optional `file_optimisation` settings slice. * @return bool True when safe mode is on. */staticfunctionis_safe_mode_active(array=array()):bool{try{if(empty()&&class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){try{=(array)Util::get_settings();=isset([\'file_optimisation\'])&&is_array([\'file_optimisation\'])?[\'file_optimisation\']:array();}catch(\\Throwable){unset();}}=!empty([\'safeMode\']);if(function_exists(\'has_filter\')&&function_exists(\'apply_filters\')&&has_filter(\'wppo_safe_mode_enabled\')){try{=apply_filters(\'wppo_safe_mode_enabled\',);return(bool);}catch(\\Throwable){unset();return;}}return;}catch(\\Throwable){unset();returnfalse;}}/** * Whether aggressive optimisations must be bypassed for this request. * * Shared nocache bypass (issue #1098) for delay + defer + used-CSS + * Critical-CSS deferral: `?nocache` / `?wppo_nocache` query args and * preview contexts (`is_preview()` when available, * function_exists-guarded for WP 6.2+ compat). `DONOTCACHEPAGE` is * intentionally NOT checked here: it gates page-cache storage (and * Used_CSS::process_buffer() keeps its own pre-existing explicit * check), while delay/defer rewriting still applies on such pages. * Any detection failure fails open to bypassed (unoptimised output, * never fatal). Multisite-safe: request-local only. * * @since 2.2.0 * * @return bool True when optimisations must be skipped for this request. */staticfunctionis_aggressive_bypass_active():bool{try{if(function_exists(\'is_preview\')){try{if(is_preview()){returntrue;}}catch(\\Throwable){unset();}}if(isset([\'nocache\'])||isset([\'wppo_nocache\'])){returntrue;}if(!empty([\'QUERY_STRING\'])&&function_exists(\'wp_unslash\')){=(string)wp_unslash([\'QUERY_STRING\']);if(preg_match(\'/(?:^|&)(nocache|wppo_nocache)(?:=|&|$)/i\',)){returntrue;}}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Curated defer-JS preset exclusions (issue #1098). * * Built-in jQuery + Elementor/Divi + WooCommerce handles stay un-deferred by * default so carts, checkouts, and builders never break. Filterable * via `wppo_defer_js_preset_exclusions` (has_filter-guarded, fail-open * to the built-in preset). Merged with user `excludeDeferJS` via * array_unique by callers. Per-site settings only; multisite-safe. * * @since 2.2.0 * * @return string[] */staticfunctionget_defer_js_preset_exclusions():array{=array(\'jquery\',\'jquery-core\',\'jquery-migrate\',\'elementor-frontend\',\'elementor-pro-frontend\',\'elementor-common\',\'et-core-api\',\'divi-custom-script\',\'wc-cart-fragments\',\'wc-checkout\',\'woocommerce\',\'wc-add-to-cart\',\'add-to-cart\',\'cart-fragments\',\'wc-blocks\',\'wc-store\',\'wp-interactivity\',\'@wordpress/interactivity\',\'@wordpress/interactivity-router\',);/** * Filters defer-JS preset exclusions. * * @since 2.2.0 * @param string[] $preset Defer preset exclusions. */if(!function_exists(\'has_filter\')||!function_exists(\'apply_filters\')||!has_filter(\'wppo_defer_js_preset_exclusions\')){return;}try{=apply_filters(\'wppo_defer_js_preset_exclusions\',);}catch(\\Throwable){unset();return;}if(!is_array()){return;}returnarray_values(array_unique(array_filter(array_map(staticfunction():string{returnis_string()||is_numeric()?(string):\'\';},),staticfunction():bool{return\'\'!==;})));}/** * Fragile-handle map for the auto-exclude detector (issue #1465). * * Ordered jQuery first, then cart fragments, then builders so the * detector names the most breakage-prone handle first. Each entry * maps a lowercase handle fragment to its exclude field(s) and a * short human-readable reason. Filterable via * `wppo_fragile_handle_map` (has_filter-guarded, fail-open to the * built-in map). Multisite-safe: static data only. * * @since 2.3.0 * * @return array<string,array{fields:string[],reason:string}> Fragment => meta. */staticfunctionget_fragile_handle_map():array{=array(\'jquery-core\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'jQuery core — deferring or delaying breaks dependent scripts.\',),\'jquery-migrate\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'jQuery Migrate — deferring or delaying breaks dependent scripts.\',),\'jquery\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'jQuery — deferring or delaying breaks dependent scripts.\',),\'wc-cart-fragments\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce cart fragments — delaying breaks the mini-cart AJAX refresh.\',),\'cart-fragments\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce cart fragments — delaying breaks the mini-cart AJAX refresh.\',),\'wc-checkout\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce checkout — deferring breaks payment and validation scripts.\',),\'wc-add-to-cart\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce add-to-cart — delaying breaks shop interactions.\',),\'wc-blocks\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce Blocks — deferring breaks Store API interactivity.\',),\'wc-store\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce Store API — deferring breaks cart and checkout blocks.\',),\'woocommerce\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce — deferring breaks cart and checkout flows.\',),\'elementor-frontend\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Elementor frontend runtime — delaying breaks builder layout and widgets.\',),\'elementor-pro-frontend\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Elementor Pro runtime — delaying breaks builder widgets.\',),\'et-core-api\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Divi builder runtime — delaying breaks builder layout.\',),\'divi-custom-script\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Divi custom script — delaying breaks builder layout.\',),\'kadence\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Kadence runtime — delaying breaks blocks and layout.\',),\'wp-interactivity\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Block interactivity runtime — deferring breaks interactive blocks.\',),);if(!function_exists(\'has_filter\')||!function_exists(\'apply_filters\')||!has_filter(\'wppo_fragile_handle_map\')){return;}try{=apply_filters(\'wppo_fragile_handle_map\',);}catch(\\Throwable){unset();return;}if(!is_array()){return;}=array();foreach(as=>){if(!is_string()&&!is_numeric()){continue;}=trim((string));if(\'\'===||!is_array()){continue;}=strtolower();if(isset([])){continue;}=array(\'excludeDeferJS\',\'excludeDelayJS\');=array();if(isset([\'fields\'])&&is_array([\'fields\'])){foreach([\'fields\']as){if(is_string()||is_numeric()){=trim((string));if(in_array(,,true)){[]=;}}}=array_values(array_unique());}if(empty()){=array(\'excludeDeferJS\',\'excludeDelayJS\');}=isset([\'reason\'])&&is_string([\'reason\'])?[\'reason\']:\'\';[]=array(\'fields\'=>,\'reason\'=>,);}return!empty()?:;}/** * Map enqueued handles to fragile-handle exclude suggestions (issue #1465). * * Case-insensitive substring match against {@see get_fragile_handle_map()}, * jQuery/cart/builders first via map order. Returns at most 20 * suggestions, deduped by handle. Fail-open: any failure returns an * empty array (unoptimised guidance only, never fatal). * * @since 2.3.0 * * @param string[] $handles Enqueued script/style handles. * @return array<int,array{handle:string,fields:string[],reason:string}> Suggestions. */staticfunctiondetect_fragile_handles(array):array{try{=self::get_fragile_handle_map();if(empty()||empty()){returnarray();}=array();=array();foreach(as){if(!is_string()&&!is_numeric()){continue;}=(string);if(\'\'===||isset([strtolower()])){continue;}=strtolower();foreach(as=>){=strtolower((string));if(\'\'===){continue;}if(false!==strpos(,)){=isset([\'fields\'])&&is_array([\'fields\'])?array_values([\'fields\']):array(\'excludeDeferJS\',\'excludeDelayJS\');=isset([\'reason\'])&&is_string([\'reason\'])?[\'reason\']:\'\';[]=array(\'handle\'=>,\'fields\'=>,\'reason\'=>,);[strtolower()]=true;break;}}if(count()>=20){break;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Current minify/combine/defer stack state for safe mode (issue #1465). * * Single choke point for the detector REST endpoint and the * one-click UI so the stack definition cannot drift between * call sites. Fail-open to all-off on any failure. * * @since 2.3.0 * * @param array $file_optimisation Optional `file_optimisation` slice. * @return array{safe_mode:bool,delay_js:bool,defer_js:bool,combine_css:bool,remove_unused_css:bool,stack_enabled:bool} Stack flags. */staticfunctionget_safe_mode_stack_state(array=array()):array{try{if(empty()&&class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){try{=(array)Util::get_settings();=isset([\'file_optimisation\'])&&is_array([\'file_optimisation\'])?[\'file_optimisation\']:array();}catch(\\Throwable){unset();}}=self::is_safe_mode_active();=!empty([\'delayJS\']);=!empty([\'deferJS\']);=!empty([\'combineCSS\']);=!empty([\'removeUnusedCSS\']);returnarray(\'safe_mode\'=>,\'delay_js\'=>,\'defer_js\'=>,\'combine_css\'=>,\'remove_unused_css\'=>,\'stack_enabled\'=>(||||)&&!,);}catch(\\Throwable){unset();returnarray(\'safe_mode\'=>false,\'delay_js\'=>false,\'defer_js\'=>false,\'combine_css\'=>false,\'remove_unused_css\'=>false,\'stack_enabled\'=>false,);}}/** * Build the one-click safe-mode enable payload (issue #1465). * * Returns the production `file_optimisation` slice with `safeMode` * forced on while every other setting is preserved untouched, so * disabling safe mode later restores the previous configuration * without re-entering settings. Pure function for testability; * persistence lives in the REST handler (per-site wppo_settings). * Fail-open: any failure returns the input unchanged with safeMode on. * * @since 2.3.0 * * @param array $file_optimisation Production slice. * @return array Slice with safeMode enabled. */staticfunctionbuild_safe_mode_enable_payload(array=array()):array{try{[\'safeMode\']=true;return;}catch(\\Throwable){unset();returnarray(\'safeMode\'=>true);}}/** * Whether defer-JS is disabled for a singular page (issue #1098). * * Reads the `_wppo_defer_disabled` post-meta kill-switch. Mirrors * {@see is_delay_disabled_for_page()} with its own blog-scoped * request cache so delay/defer states never cross-contaminate. * Fail-open: any detection failure returns false (defer stays enabled). * * @since 2.2.0 * * @param int $post_id Optional post ID. Defaults to the current post. * @return bool True when defer must be skipped for this page. */staticfunctionis_defer_disabled_for_page(int=0):bool{try{if(function_exists(\'is_singular\')&&!is_singular()&&0===){returnfalse;}if(0===){if(!function_exists(\'get_the_ID\')){returnfalse;}=(int)get_the_ID();}if(<=0){returnfalse;}=0;if(function_exists(\'is_multisite\')&&function_exists(\'get_current_blog_id\')){try{if(is_multisite()){=(int)get_current_blog_id();}}catch(\\Throwable){unset();=0;}}=.\':\'.;if(isset(self::[])){returnself::[];}if(!function_exists(\'get_post_meta\')){returnfalse;}=!empty(get_post_meta(,\'_wppo_defer_disabled\',true));self::[]=;return;}catch(\\Throwable){unset();returnfalse;}}/** * Clear the per-page delay kill-switch request cache and purge that URL only. * * Called when the `_wppo_delay_disabled` meta toggles (metabox save or * programmatic meta write) so the next frontend hit for that URL renders * with the new delay state. Purges only the single post URL\'s static * HTML/CSS sidecars via `Cache::invalidate_single_static_html()` — never * a full-cache wipe. Multisite-safe: per-site post/meta, domain-based * cache paths, no cross-site leakage. Fail-open: any failure is * swallowed so meta saves never fatal. * * @since 2.0.0 * @param int $post_id Post ID whose kill-switch changed. * @return void */staticfunctioninvalidate_delay_kill_switch_cache(int):void{self::invalidate_aggressive_kill_switch_cache();}/** * Clear per-page aggressive-optimisation caches and purge that URL only. * * Unified single-URL purge (issue #1098) for the `_wppo_delay_disabled`, * `_wppo_defer_disabled`, and `_wppo_used_css_disabled` per-page * kill-switches so per-page state survives cache clears: post meta * itself is never stored in the page cache, and toggling any of the * three metas purges only that post URL\'s static HTML/CSS/used-CSS * sidecars via `Cache::invalidate_single_static_html()` — never a * full-cache wipe. Multisite-safe: per-site post/meta, domain-based * cache paths, no cross-site leakage. Fail-open: swallowed. * * @since 2.2.0 * @param int $post_id Post ID whose kill-switch changed. * @return void */staticfunctioninvalidate_aggressive_kill_switch_cache(int):void{try{if(<=0){return;}foreach(array_keys(self::)as){if(str_ends_with((string),\':\'.(string))){unset(self::[]);}}foreach(array_keys(self::)as){if(str_ends_with((string),\':\'.(string))){unset(self::[]);}}if(class_exists(\'PerformanceOptimise\\Inc\\Cache\')){try{=array();if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){=(array)Util::get_settings();}=self::create_cache();if(is_object()&&method_exists(,\'invalidate_single_static_html\')){->invalidate_single_static_html();}}catch(\\Throwable){unset();}}}catch(\\Throwable){unset();}}/** * Handle aggressive-optimisation meta writes for the per-page kill-switches. * * Wired to `added_post_meta` / `updated_post_meta` / `deleted_post_meta` * in `setup_hooks()` so programmatic meta changes (REST, WP-CLI, imports) * purge the single URL just like the metabox save path. Reacts to the * `_wppo_delay_disabled`, `_wppo_defer_disabled`, and * `_wppo_used_css_disabled` keys; everything else is ignored. Fail-open: * detection or purge failures never fatal the meta write. * * @since 2.2.0 * @param mixed $meta_id Meta row ID for added/updated hooks, or an array of IDs for deleted_post_meta (unused, required by hook signature). * @param int $post_id Post ID the meta belongs to. * @param string $meta_key Meta key that was written. * @return void */functionon_aggressive_kill_switch_meta_changed(,,):void{try{=(string);if(\'_wppo_delay_disabled\'!==&&\'_wppo_defer_disabled\'!==&&\'_wppo_used_css_disabled\'!==){return;}self::invalidate_aggressive_kill_switch_cache((int));}catch(\\Throwable){unset();}}/** * Handle `_wppo_delay_disabled` meta writes for the per-page kill-switch. * * Wired to `added_post_meta` / `updated_post_meta` / `deleted_post_meta` * in `setup_hooks()` so programmatic meta changes (REST, WP-CLI, imports) * purge the single URL just like the metabox save path. Only reacts to * the `_wppo_delay_disabled` key; everything else is ignored. Fail-open: * detection or purge failures never fatal the meta write. * * @since 2.0.0 * @param mixed $meta_id Meta row ID for added/updated hooks, or an array of IDs for deleted_post_meta (unused, required by hook signature). * @param int $post_id Post ID the meta belongs to. * @param string $meta_key Meta key that was written. * @return void */functionon_delay_kill_switch_meta_changed(,,):void{->on_aggressive_kill_switch_meta_changed(,,);}/** * Curated third-party Delay-JS denylist (one-click delay, issue #1217). * * Host/keyword fragments that are safe to delay with one click: * analytics, ads, social, chat and video embeds. Payment gateways * (Stripe, PayPal) and consent-management banners (Cookiebot, * OneTrust, TrustArc, Quantcast) are intentionally NOT in this * preset: payment SDKs also run on product pages (express checkout) * and site-wide (fraud detection), and consent banners must stay * eager for GDPR/ePrivacy ordering (consent before trackers). Users * who want them delayed can add them via the extra-denylist * textarea. The user allowlist always wins over this list. * Filterable via `wppo_delay_js_third_party_denylist`. Fail-open: * filter failures fall back to the curated preset. * * Note: no per-request memoization is used on purpose — the filter * call is cheap and caching would go stale on mid-request * add/remove_filter, switch_to_blog, or sequential unit tests. * * Keep-in-sync note: this denylist overlaps ~15 vendor hosts with the * auto-mode preset in get_delay_js_third_party_auto_patterns() * (#1314). The lists are intentionally separate (manual mode pairs * keywords with a generic cross-origin rule; auto mode matches known * vendors only), so adding a vendor may need an edit in both places. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_denylist}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_denylist():array{returnScript_Strategy::get_delay_js_third_party_denylist();}/** * Parse the user-configured third-party allowlist for a settings slice. * * Single shared helper for the Main and Minify\\HTML auto-delay mirrors * so allowlist semantics stay in one place. Reads * `file_optimisation.delayJSThirdPartyAllowlist` (one entry per line) * plus the `wppo_delay_js_third_party_allowlist` filter. Fail-open: * any failure returns an empty list. Memoized per request keyed by the * raw value when no filter is registered; bypassed when a filter is * present so dynamic callbacks always run. * * @since 2.2.0 * @param array $file_opt Effective file_optimisation slice. * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_allowlist_for_slice}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_allowlist_for_slice(array):array{returnScript_Strategy::get_delay_js_third_party_allowlist_for_slice();}/** * Parse the user-configured third-party allowlist (wins over denylist). * * Reads the effective (sandbox-staged) slice so preview renders staged * edits instead of production values on the script_loader_tag path. * Delegates to get_delay_js_third_party_allowlist_for_slice(). * Fail-open: any failure returns an empty list. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_allowlist}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_js_third_party_allowlist():array{return->script_strategy()->get_delay_js_third_party_allowlist();}/** * Whether a script tag/handle is a third-party delay candidate. * * When one-click third-party delay (`delayJSThirdParty`) is on, only * external scripts whose src host differs from the site host — or * whose handle/src matches the curated denylist plus user additions — * are delayed. Inline scripts (no src) are never delayed in this mode. * The user allowlist always wins (returns false). Any detection * failure fails open to false (leave un-delayed). * * Matching semantics: handles use word-boundary matching (consistent * with the rest of delay matching); src/URL matching is substring. * * @since 2.2.0 * @param string $tag Script tag markup. * @param string $handle Script handle. * @return bool True when the script should be delayed in third-party mode. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_third_party_candidate}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_third_party_candidate(string,string):bool{return->script_strategy()->is_delay_third_party_candidate(,);}/** * Whether two script hosts belong to the same site (issue #1217 review). * * Hosts are lowercased, trailing dots trimmed, and a leading `www.` * stripped, then compared equal-or-subdomain in either direction, so a * first-party CDN (`cdn.example.com`), `www` vs apex mismatches, and * apex-vs-subdomain pairs stay eager instead of being misclassified as * third-party. Only genuinely foreign hosts auto-qualify. Fail-open to * false (not same-site) on any error. * * Note: sibling subdomains sharing only a parent (e.g. * `shop.example.com` vs `cdn.example.com`) are conservatively treated * as third-party; add the CDN host to the allowlist in that setup. * * @since 2.2.0 * @param string $a First host. * @param string $b Second host. * @return bool True when both hosts belong to the same site. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_same_site_script_host}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionis_same_site_script_host(string,string):bool{returnScript_Strategy::is_same_site_script_host(,);}/** * Labelled auto third-party vendor categories (issue #1385). * * Single source of truth for the auto detector: analytics, ads, and * social buckets (chat/video/embeds roll into social so every curated * vendor carries exactly one label). The flat pattern list in * get_delay_js_third_party_auto_patterns() merges these buckets, so * the categories can never drift from the matcher. Filterable via * wppo_delay_js_third_party_auto_categories (has_filter-guarded, * fail-open to the curated buckets). * * @since 2.3.0 * @return array<string, string[]> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_auto_categories}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_auto_categories():array{returnScript_Strategy::get_delay_js_third_party_auto_categories();}/** * Label a script src/handle with its auto third-party category (issue #1385). * * Returns analytics, ads, or social for curated vendors, or an empty * string when the input is not an auto candidate (including * WooCommerce fragments plus cart AJAX, which are skipped via * get_delay_js_commerce_exclusions()). Fail-open: any failure * returns an empty string (unlabelled, left eager). * * @since 2.3.0 * * @param string $src_or_handle Script src URL, tag markup, or handle. * @return string Category label or empty string. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_auto_label}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_auto_label(string):string{returnScript_Strategy::get_delay_js_third_party_auto_label();}/** * Reset the auto third-party pattern memo (for tests). * * @since 2.2.0 * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::reset_delay_third_party_auto_cache}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionreset_delay_third_party_auto_cache():void{Script_Strategy::reset_delay_third_party_auto_cache();}/** * Curated known-vendor URL patterns for auto third-party delay (#1314). * * URL-host-oriented fragments (analytics, ads, social, chat, video * embeds, error tracking) matched as substrings against the script * src. Filterable via `wppo_delay_js_third_party_auto_patterns` * (has_filter-guarded; callbacks should merge/append rather than * replace). Fail-open: non-array or throwing callbacks fall back to * the built-in preset; a valid empty array is honored and disables * auto mode (silent no-op by explicit filter choice). Lazily booted: * callers must only invoke this when Delay-JS (and the auto toggle) * is enabled. Memoized per blog id when no filter is registered so * multisite sites with blog-dependent filters never share memoized * patterns; bypassed when a filter is present so dynamic callbacks * always run. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_auto_patterns}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_auto_patterns():array{returnScript_Strategy::get_delay_js_third_party_auto_patterns();}/** * Whether a handle/tag matches the curated auto third-party patterns (#1314). * * Handles use word-boundary matching (consistent with the rest of delay * matching) via a single precompiled alternation; the src extracted * from the tag (or a full tag passed as $tag) uses substring matching * because URLs rarely align on word boundaries. Handle matching uses * the pre-slash segment only (e.g. `linkedin.com` for * `linkedin.com/insight`) because WP handles never contain slashes. * Fail-open to false on any error. The pattern list lazy-boots here, * so callers must gate on the auto toggle first. * * @since 2.2.0 * @param string $handle Script handle (may be empty on buffered paths). * @param string $tag Script tag markup or src URL. * @return bool True on match. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_third_party_auto_pattern}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionmatches_third_party_auto_pattern(string,string):bool{returnScript_Strategy::matches_third_party_auto_pattern(,);}/** * Whether a script tag/handle is an auto third-party delay candidate (#1314). * * Builder and commerce exclusions are unconditional: excluded contexts * (cart/checkout/account, builder previews) never auto-delay. The user * allowlist always wins. Manual exclusions and per-page overrides are * applied by the caller (add_defer_attribute) after this gate, so auto * patterns merge additively and never replace them. Any detection * failure fails open to false (leave un-delayed). * * @since 2.2.0 * @param string $tag Script tag markup. * @param string $handle Script handle. * @return bool True when the script should be delayed in auto mode. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_third_party_auto_candidate}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_third_party_auto_candidate(string,string):bool{return->script_strategy()->is_delay_third_party_auto_candidate(,);}/** * Inject an attribute string into a script open tag (issue #1217 review). * * Case-insensitive single-occurrence insert that handles `<script>`, * `<script `, `<script\\n` (and uppercase `<SCRIPT …>`) variants, so * every delayed tag is stamped even when core emits non-lowercase * markup. Falls back to the original tag when no script open tag is * found or the rewrite fails. * * @since 2.2.0 * @param string $tag Script tag markup. * @param string $insert Attribute string including trailing space, e.g. \'fetchpriority=\"low\" \'. * @return string Tag with the attributes injected. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::inject_delay_script_attr}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctioninject_delay_script_attr(string,string):string{returnScript_Strategy::inject_delay_script_attr(,);}/** * Base Delay JS preset exclusions (always applied, issue #966). * * Safe-by-default: WooCommerce, Elementor, and form plugins are always * excluded so checkout and forms never break. Shared by * get_delay_js_preset_exclusions() and * get_delay_js_protected_exclusions() so the per-page opt-out * protection set cannot drift from the merged preset. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_base_preset_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_base_preset_exclusions():array{returnScript_Strategy::get_delay_js_base_preset_exclusions();}/** * Manual + safe exclusions a per-page preset opt-out must never strip (issue #1308). * * The removal list for an opted-out compat preset is diffed against * this set first, so overlapping strings (e.g. gtag, jquery) that are * also contributed by manual exclusions or safe presets stay eager. * Fail-open: any detection failure returns an empty list (no * protection), degrading to the previous subtract behavior. * * @since 2.2.0 * * @param array $file_opt file_optimisation settings slice. * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_protected_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_protected_exclusions(array):array{returnScript_Strategy::get_delay_js_protected_exclusions();}/** * Get curated delay JS preset exclusions (jquery, recaptcha, stripe, analytics, etc.). * * Safe-by-default: WooCommerce, Elementor, and form plugins are always * excluded so checkout and forms never break. Filterable via * wppo_delay_js_exclusions. Preset prevents breakage on 10% sites. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_js_preset_exclusions():array{return->script_strategy()->get_delay_js_preset_exclusions();}/** * Whether Delay-JS must be skipped for the current request (fail-open safe context). * * Returns true (serve undeferred) on WooCommerce dynamic pages * (cart/checkout/account/endpoints) or when a known form shortcode/block * is present in the current post content. Any detection failure fails * open to safe (no delay) so interactivity is never broken. * * @since 2.0.0 * @return bool True when Delay-JS must be skipped. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_js_safe_context}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_js_safe_context():bool{return->script_strategy()->is_delay_js_safe_context();}/** * Adds fetchpriority to rendered script tags for deferred handles. * * Pre-6.9 fallback only: on WP 6.9+ the native fetchpriority arg passed via * wp_script_add_data() in add_defer_strategy() is rendered by core * (this filter is not registered there via setup_hooks()). Honors the * shared wppo_deferred_fetchpriority filter so a handle can stay * \'high\' or suppress via falsy; \'\' leaves the tag untouched. * Case-insensitive single-occurrence injection handles `<SCRIPT>`, * `<script\\n`, and `<script>` variants via inject_delay_script_attr(). * * @since 1.9.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @return string Modified script tag with fetchpriority. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_fetchpriority_to_deferred}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_fetchpriority_to_deferred(,):string{return->script_strategy()->add_fetchpriority_to_deferred(,);}/** * Reset the per-request font preload dedup guard. * * Wired to `switch_blog` in {@see Main::setup_hooks()} alongside * `Image_Optimisation::clear_runtime_caches()` so the dedup map * cannot leak across sites in `switch_to_blog()` requests; also * called directly in tests. * * @since 2.2.0 * @return void */staticfunctionreset_font_preload_emitted():void{self::=array();self::=array();self::=array();}/** * Reset the per-instance LCP memos on the shared image-optimisation instance. * * Wired to `switch_blog` in {@see Main::setup_hooks()} (issue #1216): the * Image_Optimisation instance is long-lived via Main, so its * memoized LCP URLs would otherwise leak across sites in * `switch_to_blog()` requests. Accepts the switch_blog args so the * hook passes ($new_blog_id, $prev_blog_id) without warnings. * Also called directly in tests. * * @since 2.2.0 * @param int $new_blog_id New blog ID (unused). * @param int $prev_blog_id Previous blog ID (unused). * @return void */staticfunctionreset_image_lcp_memos(=0,=0):void{unset(,);try{=self::get_instance();if(instanceofself&&isset(->image_optimisation)&&->image_optimisationinstanceof\\PerformanceOptimise\\Inc\\Image_Optimisation){->image_optimisation->clear_instance_lcp_memo();}}catch(\\Throwable){unset();}}/** * Extract font file URLs from a CSS string. * * Pure helper (issue #1216): matches `url(...)` values whose path * carries a woff2/woff/ttf extension, drops data:/blob:/javascript: * schemes, trims to 2048 chars, dedups preserving document order * with woff2 preferred within each `@font-face` block (never * reordered across families so a secondary family\'s woff2 cannot * outrank the primary family\'s woff under the cap-2 slice). * Never fatals: any failure returns an empty list. * * @since 2.2.0 * @param string $css CSS text to scan. * @return string[] Ordered unique font URLs. */staticfunctionextract_font_urls_from_css(string):array{try{if(\'\'===trim()){returnarray();}=substr(,0,524288);=array();if(preg_match_all(\'/@font-face\\s*\\{[^}]*\\}/is\',,)&&!empty([0])){=[0];}else{=array();}=array();foreach(as){=substr(,0,524288);if(!preg_match_all(\'/url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)/i\',,)){continue;}=array();foreach([1]as){=trim((string));if(\'\'===||strlen()>2048){continue;}=strtolower(ltrim());if(str_starts_with(,\'data:\')||str_starts_with(,\'blob:\')||str_starts_with(,\'javascript:\')||str_starts_with(,\'vbscript:\')){continue;}=function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH);if(!is_string()||\'\'===||1!==preg_match(\'/\\.(woff2|woff|ttf)(\\?.*)?$/i\',)){continue;}[]=;}=array_values(array_unique());usort(,staticfunction(,){=staticfunction(){=strtolower((string)(function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH)));if(str_ends_with(,\'.woff2\')){return0;}if(str_ends_with(,\'.woff\')){return1;}return2;};return()<=>();});foreach(as){if(!in_array(,,true)){[]=;}}}return;}catch(\\Throwable){unset();returnarray();}}/** * Map a font URL to its preload `type` attribute. * * @since 2.2.0 * @param string $font_url Font URL. * @return string MIME type (possibly empty). */functionfont_type_for_url(string):string{try{=function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH);=is_string()?strtolower(pathinfo(,PATHINFO_EXTENSION)):\'\';switch(){case\'woff2\':return\'font/woff2\';case\'woff\':return\'font/woff\';case\'ttf\':return\'font/ttf\';default:return\'\';}}catch(\\Throwable){unset();return\'\';}}/** * Whether a font candidate URL is same-origin with this site. * * Fail-closed (issue #1216): absolute URLs validate via * `RUM::is_same_origin_url()` when available, else a guarded * home-host comparison; root-relative and bare relative paths are * same-origin by construction. Any failure returns false. * * @since 2.2.0 * @param string $url Candidate URL. * @return bool True when the URL may be preloaded. */functionis_same_origin_font_url(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(0===strpos(,\'/\')&&0!==strpos(,\'//\')){returntrue;}if(false===strpos(,\'://\')&&0!==strpos(,\'//\')){=strtok(,\'/\\\\?#\');if(is_string()&&false!==strpos(,\':\')){returnfalse;}returntrue;}if(class_exists(\'PerformanceOptimise\\Inc\\RUM\')&&method_exists(\'PerformanceOptimise\\Inc\\RUM\',\'is_same_origin_url_strict\')){return\\PerformanceOptimise\\Inc\\RUM::is_same_origin_url_strict();}if(class_exists(\'PerformanceOptimise\\Inc\\RUM\')&&method_exists(\'PerformanceOptimise\\Inc\\RUM\',\'is_same_origin_url\')){return\\PerformanceOptimise\\Inc\\RUM::is_same_origin_url();}if(function_exists(\'wp_parse_url\')&&function_exists(\'home_url\')){=strtolower((string)wp_parse_url(,PHP_URL_HOST));=strtolower((string)wp_parse_url(home_url(),PHP_URL_HOST));return\'\'!==&&===;}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Normalize a font URL for manual-wins dedup. * * Builds on `Util::normalize_url()` (host + path, size-suffix aware) * but re-attaches the truncated query string (issue #1216): font * files versioned via `?v=1` vs `?v=2` are distinct resources and must * not collapse to one tag. Long-lived processes (CLI/cron rendering N * pages with one instance) must call {@see reset_font_preload_emitted()} * between pages or the per-request emitted guard skips page-2 repeats. * * @since 2.2.0 * @param string $url Font URL. * @return string Dedup key. */functionnormalize_font_url(string):string{try{=\'\';if(function_exists(\'wp_parse_url\')){=wp_parse_url(,PHP_URL_QUERY);if(is_string()&&\'\'!==){=\'?\'.substr(,0,256);}}else{=strpos(,\'?\');if(false!==){=\'?\'.substr(substr(,+1),0,256);}}if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'normalize_url\')){=Util::normalize_url();if(\'\'!==){return.;}}}catch(\\Throwable){unset();}returnstrtolower(trim());}/** * Read the manual font preload URL list (resolved to absolute URLs). * * Manual inputs resolve through `resolve_font_url()` with an empty * stylesheet base (issue #1216) — the same resolver auto-discovery * uses — so root-relative refs anchor at `home_url()` and bare * relatives at the home URL, never at `WP_CONTENT_URL`. Shared * resolution keeps the manual-wins normalized-URL dedup comparing * like with like instead of missing across bases. * * @since 2.2.0 * @param array $preload_settings Preload settings tab. * @return string[] Absolute manual font URLs. */functionget_manual_font_urls(array):array{try{if(empty([\'preloadFonts\'])||empty([\'preloadFontsUrls\'])){returnarray();}=Util::process_urls([\'preloadFontsUrls\']);=array();foreach(as){=trim((string));if(\'\'===){continue;}=->resolve_font_url(,\'\');if(\'\'===){continue;}[]=substr(,0,2048);}returnarray_values(array_unique());}catch(\\Throwable){unset();returnarray();}}/** * Resolve a same-origin stylesheet URL to a contained local path. * * Single shared implementation (issue #1216) for * `collect_enqueued_font_css_chunks()` and `font_stylesheet_stamp()` * so a future hardening fix cannot land in one copy only: maps the * URL path under ABSPATH, canonicalizes with realpath (rejecting * `..` escapes), and refuses paths outside ABSPATH before any * file_exists/filesize/file_get_contents probe. Returns \'\' when * unresolvable or outside containment. Never fatals. * * @since 2.2.0 * @param string $abs_src Absolute same-origin stylesheet URL. * @return string Canonical local path, or \'\'. */functionresolve_local_stylesheet_path(string):string{try{=function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH);if(!is_string()||\'\'===){return\'\';}=(defined(\'ABSPATH\')?(string)ABSPATH:\'\').ltrim(,\'/\');if(function_exists(\'wp_normalize_path\')){=wp_normalize_path();}=realpath();if(!is_string()){return\'\';}if(function_exists(\'wp_normalize_path\')){=wp_normalize_path();}=(function_exists(\'wp_normalize_path\')&&defined(\'ABSPATH\'))?wp_normalize_path((string)ABSPATH):(string)(defined(\'ABSPATH\')?ABSPATH:\'\');if(\'\'===||0!==strpos(,rtrim(,\'/\').\'/\')){return\'\';}return;}catch(\\Throwable){unset();return\'\';}}/** * Collect CSS text chunks from enqueued stylesheets (bounded). * * Scans inline `before`/`after` CSS plus same-origin stylesheet file * contents (512 KB per file, 10 handles max). Only queued (actually * printed) handles are scanned so discovery matches the page output. * Each chunk carries its stylesheet base URL so CSS-relative font * refs resolve against the enclosing stylesheet. Fail-open: any * failure returns the chunks collected so far. * * @since 2.2.0 * @return array[] Chunks shaped as array{css: string, base: string}. */functioncollect_enqueued_font_css_chunks():array{=->collect_font_css_chunks_and_hashes();return[\'chunks\'];}/** * Collect CSS chunks plus inline-CSS key hashes in one pass. * * Same scan as `collect_enqueued_font_css_chunks()` but additionally * returns md5 hashes of the scanned inline `before`/`after` CSS so * `get_auto_discovered_font_urls()` builds the transient key without * looping the queue twice per request (issue #1216). The key loop * and the chunk loop previously duplicated stripos/implode/md5 work * on the hot path, including on cache hits. * * @since 2.2.0 * @return array Shaped as array{chunks: array[], inline_hashes: string[]}. */functioncollect_font_css_chunks_and_hashes():array{=array();=array();try{if(!isset([\'wp_styles\'])||!is_object([\'wp_styles\'])){returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}=[\'wp_styles\']->registered??null;if(!is_array()){if(is_object()&&method_exists(,\'getArrayCopy\')){=->getArrayCopy();}else{returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}}=(isset([\'wp_styles\']->queue)&&is_array([\'wp_styles\']->queue))?[\'wp_styles\']->queue:array();if(empty()){returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}=0;=0;foreach(array_slice(,0,10)as){if(>=10){break;}=[]??null;if(!is_object()){continue;}=->extra??array();if(is_array()){foreach(array(\'after\',\'before\')as){if(empty([])){continue;}=is_array([])?implode(\"\\n\",[]):(string)[];if(\'\'!==trim()&&false!==stripos(,\'font-face\')){[]=array(\'css\'=>substr(,0,524288),\'base\'=>\'\',);[]=md5(substr(,0,524288));++;if(>=10){break2;}}}}=is_string(->src??null)?(string)->src:\'\';if(\'\'!==){=strtok(,\'?\');if(!is_string()||1!==preg_match(\'/\\.css$/i\',)){continue;}}if(\'\'===){continue;}=preg_match(\'/^(?:https?:)?\\/\\//i\',)?:Util::cached_content_url();if(!->is_same_origin_font_url()){continue;}=->resolve_local_stylesheet_path();if(\'\'===){continue;}=0;if(file_exists()){=filesize();}if(!is_int()&&!is_float()){continue;}if((int)<=0||(int)>524288){continue;}=false;if(is_object(->filesystem)&&method_exists(->filesystem,\'get_contents\')){=->filesystem->get_contents();}if(!is_string()){=file_get_contents();}if(is_string()&&\'\'!==trim()&&false!==stripos(,\'font-face\')){+=strlen();[]=array(\'css\'=>substr(,0,524288),\'base\'=>,);++;if(>1048576){break;}}}}catch(\\Throwable){unset();}returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}/** * Resolve a font URL found in CSS against its stylesheet base. * * Absolute URLs pass through unchanged. Root-relative refs * (`/fonts/x.woff2`) resolve against `home_url()` and * stylesheet-relative refs (`../fonts/x.woff2`, `fonts/x.woff2`) * resolve against the enclosing stylesheet directory (issue #1216); * inline `<style>` chunks (empty base) resolve root-relative refs * against `home_url()` and bare relatives against the home URL so * no `wp-content`-based guess can emit a 404 preload. Protocol- * relative URLs (`//host/...`) pass through for the same-origin * guard to judge. Never fatals: any failure returns the trimmed * input unchanged. * * @since 2.2.0 * @param string $font_url Font URL as written in CSS. * @param string $base_src Absolute stylesheet URL (or empty for inline CSS). * @return string Resolved absolute-or-relative URL. */functionresolve_font_url(string,string=\'\'):string{try{=trim();if(\'\'===){return\'\';}if(preg_match(\'/^https?:\\/\\//i\',)||0===strpos(,\'//\')){returnsubstr(,0,2048);}=0===strpos(,\'/\');if(&&function_exists(\'home_url\')){=(string)home_url();returnsubstr(->normalize_font_href(rtrim(,\'/\').),0,2048);}if(\'\'!==){=strtok(,\'?#\');if(!is_string()||\'\'===){=;}=rtrim(dirname(),\'/\').\'/\';returnsubstr(->normalize_font_href(.ltrim(,\'/\')),0,2048);}if(function_exists(\'home_url\')){=(string)home_url();returnsubstr(->normalize_font_href(rtrim(,\'/\').\'/\'.ltrim(,\'/\')),0,2048);}returnsubstr(->normalize_font_href(),0,2048);}catch(\\Throwable){unset();returnsubstr(trim(),0,2048);}}/** * Canonicalize a font href by resolving dot-segments. * * Resolves `/./` and `/../` against the directory path so * stylesheet-relative refs (`../fonts/x.woff2`) emit canonical * preload hrefs for dedup, caching, and audit tooling (issue * #1216). Query strings and fragments are preserved. Browsers * resolve uncanonical hrefs identically, so this is purely a * canonicalization step. Never fatals: any failure returns the * input unchanged. * * @since 2.2.0 * @param string $href Absolute or protocol-relative href. * @return string Canonicalized href. */functionnormalize_font_href(string):string{try{=\'\';=strpos(,\'#\');if(false!==){=substr(,);=substr(,0,);}=\'\';=strpos(,\'?\');if(false!==){=substr(,);=substr(,0,);}if(preg_match(\'#^(https?://[^/]+)(/.*)$#i\',,)){return[1].->normalize_font_path([2])..;}if(0===strpos(,\'//\')&&preg_match(\'#^(//[^/]+)(/.*)$#\',,)){return[1].->normalize_font_path([2])..;}if(0===strpos(,\'/\')){return->normalize_font_path()..;}return..;}catch(\\Throwable){unset();return;}}/** * Resolve dot-segments in a URL path. * * @since 2.2.0 * @param string $path URL path starting with `/`. * @return string Normalized path. */functionnormalize_font_path(string):string{=0===strpos(,\'/\');=explode(\'/\',);=array();foreach(as){if(\'\'===||\'.\'===){continue;}if(\'..\'===){if(!empty()){array_pop();}continue;}[]=;}=implode(\'/\',);if(){=\'/\'.;}if(\'\'!==&&\'/\'!==&&str_ends_with(,\'/\')&&!str_ends_with(,\'/\')){.=\'/\';}if(\'\'===&&){=\'/\';}return;}/** * Best-effort file stamp (mtime:size) for a stylesheet src. * * Used only for the auto-font transient key so same-ver CSS edits bust * the 12h cache (issue #1216). Shares * {@see resolve_local_stylesheet_path()} containment with the chunk * collector; unresolvable or non-local files yield \'\' (key falls back * to src|ver). Memoized per request so re-entrant `wp_head` * emissions do not repeat stat syscalls. Never fatals. * * @since 2.2.0 * @param string $src Stylesheet src as registered. * @return string Stamp shaped as \"mtime:size\" or \'\'. */functionfont_stylesheet_stamp(string):string{try{=trim();if(\'\'===){return\'\';}if(isset(self::[])){returnself::[];}=\'\';=preg_match(\'/^(?:https?:)?\\/\\//i\',)?:(class_exists(\'PerformanceOptimise\\Inc\\Util\')?Util::cached_content_url():);if(->is_same_origin_font_url()){=->resolve_local_stylesheet_path();if(\'\'!==){=filemtime();=filesize();if(false!==||false!==){=(false===?\'0\':(string)(int)).\':\'.(false===?\'0\':(string)(int));}}}self::[]=;return;}catch(\\Throwable){unset();return\'\';}}/** * Resolve auto-discovered font preload URLs (capped at 2, manual wins). * * Gated on `preload_settings.autoDiscoverFonts` (off by default). * Candidates come from enqueued stylesheet `@font-face` URLs, * filtered same-origin, minus manual-list overlaps (normalized), then * capped at MAX_AUTO_FONT_PRELOADS. Results are cached in a * blog-aware transient (`Util::transient_key()`, multisite-safe, 12h) * keyed by stylesheet state, plus a per-request in-memory memo so * re-entrant `wp_head` emissions skip the transient round-trip. * Fail-open: any failure returns []. * * Cold-miss cost is bounded (issue #1216): at most 10 queued handles, * 512 KB per file, ~1 MB total before `wp_head` output, with a stat * size probe before every full read. File stamps are memoized per * request and the chunk scan runs once per call (chunks + inline key * hashes collected in a single pass). * * @since 2.2.0 * @param string[] $manual_urls Manual font URLs (win on conflict). * @return string[] Auto font URLs (zero to two items). */functionget_auto_discovered_font_urls(array=array()):array{try{=->get_options()[\'preload_settings\']??array();if(empty([\'autoDiscoverFonts\'])){returnarray();}=array();foreach(as){if(is_string()&&\'\'!==){[->normalize_font_url()]=true;}}=->collect_font_css_chunks_and_hashes();=[\'inline_hashes\'];=\'\';try{=array();=function_exists(\'home_url\')?strtolower((string)home_url()):\'\';if(isset([\'wp_styles\'])&&is_object([\'wp_styles\'])&&isset([\'wp_styles\']->queue)&&is_array([\'wp_styles\']->queue)){=array_slice([\'wp_styles\']->queue,0,10);=[\'wp_styles\']->registered??array();if(!is_array()){=array();}foreach(as){=[]??null;=is_object()?(string)(->src??\'\'):\'\';=is_object()?(string)(->ver??\'\'):\'\';=->font_stylesheet_stamp();[]=(string).\'|\'..\'|\'..\'|\'.;}}sort();=Util::transient_key(\'wppo_auto_fonts_\'.md5(.\'|\'.wp_json_encode().\'|\'.wp_json_encode()));}catch(\\Throwable){unset();=\'\';}if(\'\'!==&&isset(self::[])){return->filter_auto_font_urls(self::[],);}if(\'\'!==&&function_exists(\'get_transient\')){try{=get_transient();if(is_array()){self::[]=;return->filter_auto_font_urls(,);}}catch(\\Throwable){unset();}}=array();foreach([\'chunks\']as){=is_array()?(string)([\'css\']??\'\'):(string);=is_array()?(string)([\'base\']??\'\'):\'\';foreach(self::extract_font_urls_from_css()as){=->resolve_font_url(,);if(!->is_same_origin_font_url()){continue;}=->normalize_font_url();if(isset([])||isset([])){continue;}[]=substr(,0,2048);if(count()>=self::MAX_AUTO_FONT_PRELOADS){break2;}}}=array_values();if(\'\'!==){self::[]=;if(function_exists(\'set_transient\')){try{set_transient(,,defined(\'HOUR_IN_SECONDS\')?12*HOUR_IN_SECONDS:43200);}catch(\\Throwable){unset();}}}return;}catch(\\Throwable){unset();returnarray();}}/** * Filter candidate font URLs to the emission-safe subset (issue #1216). * * Shared by the transient-hit and per-request-memo paths so cached * values are re-validated on every read: a poisoned/stale transient * must not emit cross-origin fonts or shadow the manual list. Trims * to 2048 chars, enforces same-origin, drops manual-list overlaps * (normalized), dedups, and caps at MAX_AUTO_FONT_PRELOADS. * Never fatals: any failure returns []. * * @since 2.2.0 * @param mixed[] $urls Candidate URLs (e.g. from the transient). * @param array $manual_keys Normalized manual-URL keys winning on conflict. * @return string[] Clean auto font URLs (zero to two items). */functionfilter_auto_font_urls(array,array):array{try{=array();foreach(as){=is_string()?substr(trim(),0,2048):\'\';if(\'\'===||!->is_same_origin_font_url()){continue;}=->normalize_font_url();if(\'\'===||isset([])||isset([])){continue;}[]=;if(count()>=self::MAX_AUTO_FONT_PRELOADS){break;}}returnarray_values();}catch(\\Throwable){unset();returnarray();}}/** * Adds preload, prefetch, and preconnect links to optimize resource loading. * * Image preloads delegate to `Image_Optimisation::preload_images()`, * which emits exactly one `<link rel=\"preload\" as=\"image\" * fetchpriority=\"high\">` per URL for the single RUM-field → * PageSpeed LCP candidate (issue #991; Optimization Detective stays * Priority 0), deduped by normalized URL + query + media with a * per-request emitted guard, and excludes that candidate from lazy * load (gated on the LCP toggles, with normalized size-variant * matching). Core 6.9 `fetchpriority` stamping is never * double-applied (the stamp path only fills gaps via * `function_exists()`-guarded core calls). Manual preload-image meta * and the hero fallback remain when no RUM or PageSpeed candidate * resolves (fail-open). * * Runs on `wp_head` priority 1, before core resource-hints at * priority 2. * * @since 1.0.0 */functionadd_preload_prefetch_preconnect(){try{if(function_exists(\'is_admin\')&&is_admin()){return;}if(function_exists(\'is_feed\')&&is_feed()){return;}if(function_exists(\'is_embed\')&&is_embed()){return;}if(function_exists(\'is_preview\')&&is_preview()){return;}}catch(\\Throwable){unset();}=->get_options()[\'preload_settings\']??array();=->get_manual_font_urls(is_array()?:array());foreach(as){=->normalize_font_url((string));if(\'\'!==&&isset(self::[])){continue;}if(\'\'!==){self::[]=true;}Util::generate_preload_link(,\'preload\',\'font\',true,->font_type_for_url((string)));}if(!empty([\'autoDiscoverFonts\'])){try{=->get_auto_discovered_font_urls();}catch(\\Throwable){unset();=array();}=0;foreach(as){if(>=self::MAX_AUTO_FONT_PRELOADS){break;}if(!is_string()||\'\'===trim()){continue;}=->normalize_font_url();if(\'\'===||isset(self::[])){continue;}self::[]=true;Util::generate_preload_link(,\'preload\',\'font\',true,->font_type_for_url());++;}}if(!empty([\'preloadCSS\'])&&!empty([\'preloadCSSUrls\'])){=Util::process_urls([\'preloadCSSUrls\']);foreach(as){=preg_match(\'/^https?:\\/\\//i\',)?:Util::cached_content_url();Util::generate_preload_link(,\'preload\',\'style\');}}->image_optimisation->preload_images();}/** * Adds preconnect/dns-prefetch origins via core\'s resource hints API. * * Core\'s wp_resource_hints() batches, deduplicates, and normalizes * preconnect/dns-prefetch hints, and exposes them through the * `wp_resource_hints` filter for interoperability with other plugins. * Font/CSS/image preload links stay on the raw echo path in * add_preload_prefetch_preconnect() where `as`/`type`/`media` control * is needed. * * Core normalizes preconnect hints to scheme+host and dns-prefetch * hints to protocol-relative `//host`, and emits them on `wp_head` at * priority 2, so they render after the plugin\'s priority-1 preload * links (browser hint order is not significant). * * @since 1.9.0 * * @param array $urls URLs to print for resource hints. * @param string $relation_type The relation type (e.g. \'preconnect\', \'dns-prefetch\'). * @return array Filtered URLs. */functionadd_resource_hints(,){=->get_options()[\'preload_settings\']??array();if(\'preconnect\'===){if(!empty([\'preconnect\'])&&!empty([\'preconnectOrigins\'])){=Util::process_urls([\'preconnectOrigins\']);foreach(as){[]=array(\'href\'=>,\'crossorigin\'=>\'anonymous\',);}}}elseif(\'dns-prefetch\'===){if(!empty([\'prefetchDNS\'])&&!empty([\'dnsPrefetchOrigins\'])){=array_map(staticfunction(){=preg_match(\'#^(?:[a-z][a-z0-9+.-]*:)?//#i\',);return?:\'//\'.;},Util::process_urls([\'dnsPrefetchOrigins\']));=array_merge(,);}}return;}/** * Adds speculation rules for prefetching/prerendering via the WP 6.8+ Speculation Rules API. * * When `wp_get_speculation_rules()` exists (WP 6.8+) the plugin does not emit its own * `<script type=\"speculationrules\">` block. Instead it drives the single core rule set * via the `wp_speculation_rules_configuration` filter so only one document-level rule * is printed (avoids duplicate prefetch/prerender waste when multiple rule sets would append). * * Excludes sensitive/dynamic paths (login, admin, REST API) from all * speculation and pins an explicit configuration whenever the plugin owns * the speculation-rules decision: the user\'s chosen mode/eagerness when * the UI toggle is on, or the legacy `conservative` default when it is * off (so core\'s WP 7.1 cached-site escalation cannot change behavior * behind the user\'s back). * * Effective defaults are `prefetch` + `conservative` unless overridden * via `WP_SPECULATIVE_LOADING_DEFAULT_MODE` / `_EAGERNESS` (WP 7.1, * `wp_get_speculation_rules_default_configuration()`). The * `wp_speculation_rules_configuration` filter (used here) takes precedence * over host constants (see filter_speculation_rules_configuration()). * No auto-elevation to `moderate` is assumed — it must be chosen * explicitly in the UI. * * Host overrides are honored via `WP_SPECULATIVE_LOADING_DEFAULT_*` constants * or environment variables (WP 7.1 #65624); the filter wins over the host. * Mode/eagerness are validated via `WP_Speculation_Rules::is_valid_mode()` * / `is_valid_eagerness()` when the class exists (WP 6.8+), otherwise via * an allowlist fallback. Excludes are merged via `wp_speculation_rules_href_exclude_paths` * (user `speculationExcludeUrls` + WooCommerce cart/checkout/account). * * Backward compatible: on WP <6.8 neither `wp_get_speculation_rules()` * nor `wp_get_speculation_rules_configuration()` exists, * so this method is a no-op and no filter is registered (legacy path). * Fail-open: pre-6.8 output degrades to unoptimised (no speculation * block is printed by this plugin on 6.2-6.7); invalid URLs are * skipped individually and logged-in visitors are always excluded. * * @since 2.0.0 * * @return void */functionadd_speculation_rules(){if(!function_exists(\'wp_get_speculation_rules\')&&!function_exists(\'wp_get_speculation_rules_configuration\')){return;}if(!Wp_Version::is_at_least(\'6.8\',true)){return;}=->get_options()[\'preload_settings\']??array();=!empty([\'enableSpeculationRules\']);add_filter(\'wp_speculation_rules_href_exclude_paths\',function()use(){if(!is_array()){=array();}foreach(->get_speculation_exclude_paths()as){if(!in_array(,,true)){[]=;}}return;});add_filter(\'wp_speculation_rules_configuration\',function()use(,){return->filter_speculation_rules_configuration(,,);});add_filter(\'wp_speculation_rules\',array(,\'filter_speculation_list_rules\'),10);add_action(\'wp_load_speculation_rules\',array(,\'wppo_register_speculation_rules\'));}/** * Canonical speculation-rules href exclusion patterns. * * Merges core safety defaults (auth, admin, REST), generic commerce * paths (cart/checkout/account), WooCommerce dynamic cart/checkout/ * account paths, and user-configured `speculationExcludeUrls`. * Fill-gaps-only: callers dedupe against pre-existing core patterns * so the core ruleset is never duplicated. * * Intentionally narrow: nonce/add-to-cart are query-param * actions (`?_wpnonce=`, `?add-to-cart=`) already * excluded by core\'s `?`-URL handling and by * {@see is_speculation_list_url_valid()}, so no `*substring*` * wildcard is emitted — such wildcards would also block legitimate * slugs (e.g. a post about \"add to cart\"). The `/logout/*` path * prefix guards document-rule `href_matches` for pretty logout * slugs; `?action=logout` list URLs stay covered by the `?`-URL * rejection in {@see is_speculation_list_url_valid()}. * * @since 2.0.0 * * @param array $preload_settings The plugin\'s preload_settings option value. * @return string[] Exclusion patterns (possibly empty, never fatal). */functionget_speculation_exclude_paths(array=array()):array{try{=array(\'/wp-login*\',\'/wp-admin/*\',\'/wp-json/*\',\'/logout/*\',\'/cart/*\',\'/checkout/*\',\'/my-account/*\',\'/account/*\',);if(class_exists(\'PerformanceOptimise\\Inc\\Woo_Detect\')&&method_exists(\'PerformanceOptimise\\Inc\\Woo_Detect\',\'get_woo_excluded_paths\')){try{foreach(Woo_Detect::get_woo_excluded_paths()as){=strtolower(trim((string),\'/\'));if(\'\'===){continue;}=\'/\'..\'/*\';if(!in_array(,,true)){[]=;}}}catch(\\Throwable){unset();}}=!empty([\'speculationExcludeUrls\'])?Util::process_urls([\'speculationExcludeUrls\']):array();foreach(as){if(class_exists(\'WP_URL_Pattern_Prefixer\')&&method_exists(\'WP_URL_Pattern_Prefixer\',\'prefix_path_pattern\')){if(false===strpos(,\'*\')&&isset([0])&&\'/\'===[0]){=\\WP_URL_Pattern_Prefixer::prefix_path_pattern(,\'/\');}}elseif(isset([0])&&\'/\'===[0]&&false===strpos(,\'*\')){=rtrim(,\'/\').\'/*\';}if(!in_array(,,true)){[]=;}}if(function_exists(\'wc_get_checkout_url\')){try{=wc_get_checkout_url();if(){=wp_parse_url(,PHP_URL_PATH);if(&&\'/\'!==){=trailingslashit().\'*\';if(!in_array(,,true)){[]=;}}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_cart_url\')){try{=wc_get_cart_url();if(){=wp_parse_url(,PHP_URL_PATH);if(&&\'/\'!==){=trailingslashit().\'*\';if(!in_array(,,true)){[]=;}}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_page_permalink\')){try{=wc_get_page_permalink(\'myaccount\');if(){=wp_parse_url(,PHP_URL_PATH);if(&&\'/\'!==){=trailingslashit().\'*\';if(!in_array(,,true)){[]=;}}}}catch(\\Throwable){unset();}}if(function_exists(\'apply_filters\')){/** * Filters the speculation-rules href exclusion patterns. * * @since 2.0.0 * @param string[] $excludes Canonical exclusion patterns. * @param array $preload_settings The plugin\'s preload_settings option value. */=apply_filters(\'wppo_speculation_exclusions\',,);if(is_array()){=array_values(array_unique(array_filter(,\'is_string\')));}}return;}catch(\\Throwable){unset();returnarray();}}/** * Whether prerender speculation mode is allowed for the current request. * * Prerender executes page JavaScript speculatively, so it requires * both guardrails: the plugin\'s static cache must be active (never * prerender uncached origin responses) and, when RUM gating is * enabled, real-user field data must qualify via * `AI_Adaptive::get_rum_gated_speculation_state()` (good p75). * Gating explicitly disabled honors the user\'s prerender choice. * Fail-safe: any failure or missing RUM signal means \"not allowed\", * degrading to conservative prefetch (unoptimised, never fatal). * Multisite-safe: per-site options and per-site RUM aggregates only. * * @since 2.2.0 * * @return bool True when prerender may be emitted. */functionis_prerender_allowed():bool{try{if(empty(->get_options()[\'cache_settings\'][\'enableCache\'])){returnfalse;}if(!class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')){returnfalse;}if(!method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'is_speculation_rum_gating_enabled\')||!method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'get_rum_gated_speculation_state\')){returnfalse;}=AI_Adaptive::is_speculation_rum_gating_enabled();if(!){returntrue;}=AI_Adaptive::get_rum_gated_speculation_state();return!empty([\'qualified\']);}catch(\\Throwable){unset();returnfalse;}}/** * Applies the plugin\'s explicit speculation-rules configuration via the * `wp_speculation_rules_configuration` filter. * * WordPress 7.1 escalates the default eagerness from `conservative` to * `moderate` when it detects a caching solution (#64066). This plugin is * a caching solution, so that escalation could change speculative-loading * behavior behind the user\'s back. Whenever the plugin owns the * speculation-rules decision it therefore pins an explicit eagerness: * the user\'s chosen value when the UI toggle is on, or the legacy * `conservative` default when it is off. The explicit * `WP_SPECULATIVE_LOADING_DEFAULT_MODE` / * `WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS` constants or environment * variables introduced in WP 7.1 (#65624) are honored as-is, so hosts * can still pin a different default. The filter wins over the host * override (documented precedence). * * Mode/eagerness are validated via `WP_Speculation_Rules::is_valid_mode()` * / `is_valid_eagerness()` when the class exists (WP 6.8+), otherwise via * an allowlist fallback, with `function_exists`/`class_exists` guards for * backward compat on WP <6.8. * * Excludes are merged via `wp_speculation_rules_href_exclude_paths` * (user `speculationExcludeUrls` + WooCommerce cart/checkout/account via * {@see add_speculation_rules()}). * * Cache awareness: non-cacheable responses (`DONOTCACHEPAGE`, * cart/checkout/account, previews, logged-in visitors) return null so * neither core nor plugin rules prefetch them. * * @since 1.9.0 * @since 2.0.0 Honor `wp_get_speculation_rules_default_configuration()` when available (WP 7.1). * * @param array<string,string>|null $config Filter value (\'auto\' defaults, or null when speculative loading is disabled for the request). * @param array $preload_settings The plugin\'s preload_settings option value. * @param bool $enable_speculation Whether the plugin\'s speculation-rules UI toggle is on. * @return array<string,string>|null */functionfilter_speculation_rules_configuration(,array,bool){if(!is_array()){return;}if(->is_speculation_suppressed_for_visitor()){returnnull;}if(){=[\'speculationMode\']??\'prefetch\';=[\'speculationEagerness\']??\'conservative\';if(class_exists(\'WP_Speculation_Rules\')){if(method_exists(\'WP_Speculation_Rules\',\'is_valid_mode\')){if(!\\WP_Speculation_Rules::is_valid_mode()){=\'prefetch\';}}elseif(!in_array(,array(\'prefetch\',\'prerender\'),true)){=\'prefetch\';}if(method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){if(!\\WP_Speculation_Rules::is_valid_eagerness()){=\'conservative\';}}elseif(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}}else{if(!in_array(,array(\'prefetch\',\'prerender\'),true)){=\'prefetch\';}if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}}if(\'prerender\'===&&!->is_prerender_allowed()){=\'prefetch\';=\'conservative\';}=->maybe_cap_speculation_eagerness();if(\'prerender\'===&&->is_speculation_commerce_or_auth()){=\'prefetch\';}[\'mode\']=;[\'eagerness\']=;return;}=null!==->get_speculation_default_override(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\');if(!empty(->get_options()[\'cache_settings\'][\'enableCache\'])&&\'auto\'===([\'eagerness\']??\'auto\')&&!){[\'eagerness\']=\'conservative\';}return;}/** * RUM-weighted top-URL prefetch cap (issue #1183). * * Reads `preload_settings.speculationTopUrlsLimit` (default 2, * clamped to 1-5 as the footprint guard). Fail-open: any missing or * malformed value returns 2, never fatal. * * @since 2.2.0 * * @return int Capped limit between 1 and 5. */functionget_speculation_top_urls_limit():int{try{=->get_options()[\'preload_settings\'][\'speculationTopUrlsLimit\']??2;=is_numeric()?(int):2;if(<1||>5){return2;}return;}catch(\\Throwable){unset();return2;}}/** * Whether the current request is a commerce/auth context for speculation guardrails. * * Reuses `AI_Adaptive::is_commerce_or_auth_context()` when available * (guarded by class_exists/method_exists for backward compat), with a * conservative local fallback (WooCommerce presence, cart/checkout/ * account conditionals, logged-in visitor, cart cookies). Fail-closed: * any throwable means \"commerce\" so uncertainty suppresses the * highest-risk prerender mode; the outer list builder stays fail-open * (returns empty) for prefetch paths. * * @since 2.2.0 * * @return bool True when eager speculation must be suppressed. */functionis_speculation_commerce_or_auth():bool{try{if(class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')&&method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'is_commerce_or_auth_context\')){return(bool)AI_Adaptive::is_commerce_or_auth_context();}}catch(\\Throwable){unset();}try{if(class_exists(\'WooCommerce\')||function_exists(\'WC\')||function_exists(\'wc_get_checkout_url\')){returntrue;}foreach(array(\'is_cart\',\'is_checkout\',\'is_account_page\')as){if(function_exists()){try{if(call_user_func()){returntrue;}}catch(\\Throwable){unset();}}}if(function_exists(\'is_user_logged_in\')){try{if(->is_speculation_frontend_context()&&is_user_logged_in()){returntrue;}}catch(\\Throwable){unset();}}if((isset([\'woocommerce_items_in_cart\'])&&is_string([\'woocommerce_items_in_cart\'])&&\'\'!==[\'woocommerce_items_in_cart\'])||(isset([\'woocommerce_cart_hash\'])&&is_string([\'woocommerce_cart_hash\'])&&\'\'!==[\'woocommerce_cart_hash\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Whether the current request looks like a frontend visitor visit. * * Mirrors `AI_Adaptive::is_frontend_context()` for the local * commerce/auth fallback: admin, REST, AJAX, cron, and CLI requests * never represent a visitor seeing speculation rules. All probes are * function_exists-guarded so unit tests and minimal installs default * to frontend (true). Fail-open: any throwable means frontend. * * @since 2.2.0 * * @return bool True when the request looks like a frontend visit. */functionis_speculation_frontend_context():bool{try{if(defined(\'WP_CLI\')&&WP_CLI){returnfalse;}if(defined(\'DOING_CRON\')&&DOING_CRON){returnfalse;}if(defined(\'REST_REQUEST\')&&REST_REQUEST){returnfalse;}if(function_exists(\'wp_doing_cron\')){try{if(wp_doing_cron()){returnfalse;}}catch(\\Throwable){unset();}}if(function_exists(\'is_admin\')){try{if(is_admin()){returnfalse;}}catch(\\Throwable){unset();}}if(function_exists(\'wp_doing_ajax\')){try{if(wp_doing_ajax()){returnfalse;}}catch(\\Throwable){unset();}}returntrue;}catch(\\Throwable){unset();returntrue;}}/** * Cap a speculation eagerness value in commerce/auth contexts. * * Prerender-risk guardrail (issue #1183): `eager` becomes `moderate` * when {@see is_speculation_commerce_or_auth()} is true; every other * value passes through untouched. Invalid values fall back to * `conservative`. Manual user settings are never persisted — the cap * applies to the emitted rule only. * * @since 2.2.0 * * @param string $eagerness Raw eagerness value. * @return string Capped eagerness value. */functionmaybe_cap_speculation_eagerness(string):string{try{if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){return\'conservative\';}if(\'eager\'===&&->is_speculation_commerce_or_auth()){return\'moderate\';}return;}catch(\\Throwable){unset();return\'conservative\';}}/** * RUM-weighted top URLs from the learned AI model (issue #1183). * * Reads the field-weighted model (`avgLCP*log(count)` scoring) via * `AI_Adaptive::get_model()` and sanitizes the full `prefetch_urls` * list here — rather than via `AI_Adaptive::get_prefetch_urls()`, * which pre-slices to 2 before validation, so an invalid entry * cannot waste a fill slot. Each URL is re-validated with * {@see is_speculation_list_url_valid()} (same-origin, no commerce/ * admin/query), deduped, and capped at the `speculationTopUrlsLimit` * budget. Empty model, missing class, or any failure returns an empty * array so callers fall back to document-rule-only behavior (fail-open, * never fatal). Multisite-safe: per-site model via per-site options, * same-site host check prevents cross-site leakage. * * @since 2.2.0 * * @param int $limit Maximum URLs to return. * @return string[] Validated absolute model URLs (possibly empty). */functionget_model_weighted_speculation_urls(int=2):array{try{if(<1){returnarray();}if(!class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')||!method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'get_model\')){returnarray();}=AI_Adaptive::get_model();}catch(\\Throwable){unset();returnarray();}if(!is_array()){returnarray();}=[\'prefetch_urls\']??array();if(!is_array()||empty()){returnarray();}try{=array();foreach(as){if(!is_string()||\'\'===){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'===){continue;}if(in_array(,,true)){continue;}if(!->is_speculation_list_url_valid()){continue;}[]=;if(count()>=){break;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Collect high-value same-site URLs for the speculation list rule. * * Source: home URL first, then `performance_audit.high_value_urls`, * then RUM-weighted model top URLs (field-weighted via * {@see get_model_weighted_speculation_urls()}, capped at * `preload_settings.speculationTopUrlsLimit`), then RUM volume * winners via {@see get_rum_top_urls()} (same cap). Each candidate * is normalized via `esc_url_raw(trim())`, deduped, same-site * validated, and capped (keeps the ~1KB footprint). Invalid URLs * are skipped individually (fail-open); an empty array means \"emit * nothing\". * * @since 2.0.0 * @since 2.2.0 Merge RUM-weighted model top URLs within the speculationTopUrlsLimit fill cap. * * @return string[] Validated absolute URLs (possibly empty). */functionget_speculation_list_urls():array{if(function_exists(\'is_admin\')){try{if(is_admin()){returnarray();}}catch(\\Throwable){unset();}}if(function_exists(\'get_option\')){try{=get_option(\'permalink_structure\');if(\'\'===||false===){returnarray();}}catch(\\Throwable){unset();}}=array(Util::cached_home_url(\'/\'));=Util::get_settings();=[\'performance_audit\'][\'high_value_urls\']??array();if(is_string()){=preg_split(\'/[\\r\\n,]+/\',);}if(is_array()){=array_slice(array_values(),0,20);foreach(as){if(is_string()&&\'\'!==trim()){[]=;}}}=->get_speculation_top_urls_limit();=->get_model_weighted_speculation_urls(+count());=array();foreach(as){if(!is_string()){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'!==){[->normalize_speculation_url()]=true;}}=0;foreach(as){if(isset([->normalize_speculation_url()])){continue;}[->normalize_speculation_url()]=true;[]=;++;if(>=){break;}}=-;if(>0){foreach(->get_rum_top_urls()as){[]=;}}=array();=array();=0;foreach(as){if(!is_string()){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'===){continue;}=->normalize_speculation_url();if(isset([])){continue;}if(++>30){break;}if(!->is_speculation_list_url_valid()){continue;}[]=true;[]=;if(count()>=10){break;}}return;}/** * Top RUM (real-visit) URLs by visit volume. * * Reads the `wppo_web_vitals_rum` per-day/per-path aggregates via * `RUM::get_aggregate_readonly()` (per-request memoized, no queue * flush, no transient churn — safe for the frontend hot path), * sums sample counts (`max(lcp.n, ttfb.n, ...)`) per * normalized path across days, and resolves the winners to absolute * same-site URLs. Candidates are validated with * {@see is_speculation_list_url_valid()} (cart/checkout/account, * query strings, cross-site excluded) and capped so home + * high-value + RUM total stays within the 10-URL budget. * Per-request memoized keyed by limit. * * Fail-open: any throwable, missing class, or empty RUM returns an * empty array — never fatal, never white-screen. * * @since 2.0.0 * @since 2.2.0 Accept a fill-budget limit for the RUM-weighted portion. * @since 2.2.0 Read via get_aggregate_readonly() with per-request memo. * * @param int $limit Maximum URLs to return. * @return string[] Validated absolute RUM winner URLs (possibly empty). */functionget_rum_top_urls(int=10):array{if(<1){returnarray();}if(>10){=10;}if(isset(self::[])){returnself::[];}=->compute_rum_top_urls();if(count(self::)>10){self::=array();}self::[]=;return;}/** * Uncached RUM top-URL computation for {@see get_rum_top_urls()}. * * @since 2.2.0 * * @param int $limit Maximum URLs to return. * @return string[] Validated absolute RUM winner URLs (possibly empty). */functioncompute_rum_top_urls(int):array{try{if(!class_exists(\'PerformanceOptimise\\Inc\\RUM\')||!method_exists(\'PerformanceOptimise\\Inc\\RUM\',\'get_aggregate_readonly\')){returnarray();}=RUM::get_aggregate_readonly();}catch(\\Throwable){unset();returnarray();}if(!is_array()||empty()){returnarray();}try{=array();foreach(as){if(!is_array()){continue;}foreach(as=>){if(!is_string()||\'\'===||!is_array()){continue;}if(false!==strpos(,\'?\')||false!==strpos(,\'#\')){continue;}=0;foreach(as=>){if(\'lcpUrls\'===||!is_array()){continue;}=isset([\'n\'])?(int)[\'n\']:0;if(>){=;}}if(<=0){continue;}=class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'normalize_rum_path\')?Util::normalize_rum_path():;if(\'/\'===){continue;}if(!isset([])){[]=0;}[]+=;}}if(empty()){returnarray();}arsort();=array();foreach(array_keys()as){if(\'/\'!==substr(,-1)){.=\'/\';}=Util::cached_home_url();=function_exists(\'esc_url_raw\')?esc_url_raw():;if(!is_string()||\'\'===){continue;}if(in_array(,,true)){continue;}if(!->is_speculation_list_url_valid()){continue;}[]=;if(count()>=){break;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Whether speculation output is suppressed for the current visitor. * * Logged-in users stay excluded (mirrors the `null` config * passthrough in {@see filter_speculation_rules_configuration()}). * Cache-aware: pages served with `DONOTCACHEPAGE` / `no-store` * (cart/checkout/account, previews) never speculate — speculating a * non-cacheable URL wastes origin load and risks broken carts. * Fail-closed: any throwable means \"suppressed\" so uncertainty * disables output (privacy guard must never fail open for * logged-in/DONOTCACHEPAGE visitors). * * @since 2.0.0 * * @return bool True when rules must not be emitted. */functionis_speculation_suppressed_for_visitor():bool{try{if(function_exists(\'is_user_logged_in\')&&is_user_logged_in()){returntrue;}if(defined(\'DONOTCACHEPAGE\')&&DONOTCACHEPAGE){returntrue;}foreach(array(\'is_cart\',\'is_checkout\',\'is_account_page\',\'is_preview\',\'is_customize_preview\')as){if(function_exists()){try{if(call_user_func()){returntrue;}}catch(\\Throwable){unset();}}}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Eager prerender list rule for the home link on singular views. * * Returns a `{\"source\":\"list\"}` rule with `eager` eagerness for the * home URL only when the current view is singular (and the home URL * is present/valid). Commerce/auth contexts degrade to `moderate` * via {@see maybe_cap_speculation_eagerness()} (prefetch only, * never eager). Returns null otherwise (non-singular, no home * link, logged-in visitor, document rules toggled off, or any * failure) — fail-open to \"emit nothing\", never fatal. * * @since 2.0.0 * @since 2.2.0 Cap eager to moderate in commerce/auth contexts. * * @return array<string,mixed>|null The singular rule, or null. */functionget_singular_home_link_rule():?array{try{if(->is_speculation_suppressed_for_visitor()){returnnull;}=->get_options()[\'preload_settings\'][\'speculationDocumentRules\']??true;if(!){returnnull;}if(!function_exists(\'is_singular\')||!is_singular()){returnnull;}=Util::cached_home_url(\'/\');if(!is_string()||\'\'===){returnnull;}=function_exists(\'esc_url_raw\')?esc_url_raw():;if(\'\'===||!->is_speculation_list_url_valid()){returnnull;}returnarray(\'source\'=>\'list\',\'urls\'=>array(),\'eagerness\'=>->maybe_cap_speculation_eagerness(\'eager\'),);}catch(\\Throwable){unset();returnnull;}}/** * Document rule targeting the first post on archive views. * * Reads the first post URL from the main query (`$wp_query->posts` * via `get_permalink()`, all guarded) and emits a * `{\"source\":\"document\"}` rule whose `where` clause pairs an * `href_matches` pattern for that post path with a first-post * `selector_matches`. Returns null when not an archive, when no * first post resolves, for logged-in visitors, when document rules * are toggled off, or on any failure (fail-open, never fatal). * * @since 2.0.0 * * @return array<string,mixed>|null The archive document rule, or null. */functionget_archive_first_post_rule():?array{try{if(->is_speculation_suppressed_for_visitor()){returnnull;}=->get_options()[\'preload_settings\'][\'speculationDocumentRules\']??true;if(!){returnnull;}=(function_exists(\'is_archive\')&&is_archive())||(function_exists(\'is_home\')&&is_home());if(!){returnnull;}=null;global;if(isset(->posts)&&is_array(->posts)&&!empty(->posts)){=->posts[0];if(function_exists(\'get_permalink\')){=get_permalink();if(is_string()&&\'\'!==){=;}}}if(!is_string()||\'\'===){returnnull;}=function_exists(\'esc_url_raw\')?esc_url_raw():;if(\'\'===||!->is_speculation_list_url_valid()){returnnull;}=null;if(function_exists(\'wp_parse_url\')){=wp_parse_url(,PHP_URL_PATH);}if(!is_string()||\'\'===){returnnull;}=rtrim(,\'/\').\'/*\';if(\'/\'===){returnnull;}=->get_options()[\'preload_settings\']??array();=[\'speculationEagerness\']??\'conservative\';if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}=->maybe_cap_speculation_eagerness();returnarray(\'source\'=>\'document\',\'where\'=>array(\'and\'=>array(array(\'href_matches\'=>),array(\'selector_matches\'=>\'main article:first-of-type a, article.post:first-of-type a, .post:first-of-type a\'),),),\'eagerness\'=>,);}catch(\\Throwable){unset();returnnull;}}/** * Validate a single speculation list URL. * * Same-site (host must match home host, preventing multisite * cross-site leakage), http(s) only, and rejects admin, login, * REST, commerce (cart/checkout/account) paths, and any URL * carrying a query string or fragment (mirroring core\'s * `?`-URL exclusion). Same-host different-port URLs and URLs * with userinfo are rejected as cross-origin/unsafe. * * Per-request memoized (URL + home + Woo availability); the same * candidates validated by the list, prerender, and register paths * cost one Woo lookup set per distinct URL. * * @since 2.0.0 * @since 2.2.0 Memoize per-request results. * * @param string $url Candidate absolute URL. * @return bool True when the URL may be prefetched. */functionis_speculation_list_url_valid(string):bool{=wp_parse_url();if(!is_array()||empty([\'host\'])){returnfalse;}=Util::cached_home_url();=.\"\\0\"..\"\\0\".self::speculation_woo_fingerprint();if(array_key_exists(,self::)){returnself::[];}if(count(self::)>200){self::=array();}=->validate_speculation_list_url_uncached(,,);self::[]=;return;}/** * Reset the speculation URL validity + commerce-path memos (for tests). * * @since 2.2.0 * @return void */staticfunctionreset_speculation_url_memo():void{self::=array();self::=null;self::=\'\';self::=array();self::=array();}/** * Fingerprint WooCommerce-function availability for the speculation memos. * * Test fixtures may define wc_get_* after a first resolution; the * fingerprint keeps the cached verdicts keyed on that boundary so a * stale \"no Woo\" verdict is never reused once Woo helpers appear. * * @since 2.2.0 * * @return string \'1\'/\'0\' flags for wc_get_checkout_url, wc_get_cart_url, wc_get_page_permalink. */staticfunctionspeculation_woo_fingerprint():string{return(function_exists(\'wc_get_checkout_url\')?\'1\':\'0\').(function_exists(\'wc_get_cart_url\')?\'1\':\'0\').(function_exists(\'wc_get_page_permalink\')?\'1\':\'0\');}/** * Resolved speculation commerce paths (per-request memoized). * * Base cart/checkout/account prefixes plus WooCommerce dynamic * paths (checkout/cart/myaccount permalinks). Resolved once per * request instead of once per candidate URL. * * @since 2.2.0 * * @return string[] Lowercase path prefixes. */functionget_speculation_commerce_paths():array{=self::speculation_woo_fingerprint();if(null!==self::&&===self::){returnself::;}=array(\'/cart\',\'/checkout\',\'/my-account\',\'/account\');if(function_exists(\'wc_get_checkout_url\')){try{=wc_get_checkout_url();if(){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==&&\'/\'!==){[]=rtrim(,\'/\');}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_cart_url\')){try{=wc_get_cart_url();if(){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==&&\'/\'!==){[]=rtrim(,\'/\');}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_page_permalink\')){try{=wc_get_page_permalink(\'myaccount\');if(){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==&&\'/\'!==){[]=rtrim(,\'/\');}}}catch(\\Throwable){unset();}}self::=array_values(array_unique(array_map(\'strtolower\',)));self::=;returnself::;}/** * Normalize a speculation URL for dedupe/carve-out comparison. * * Lowercases scheme+host, strips default ports, drops trailing-slash * variants, so `https://example.com/post` and * `https://EXAMPLE.com/post/` compare equal (RUM winners restore the * trailing slash while raw high_value_urls input may not carry one). * Query/fragment are preserved as-is: validated URLs never carry * them, and distinct raw inputs must not collapse silently. * * @since 2.2.0 * * @param string $url Candidate URL. * @return string Normalized URL (input unchanged when unparseable). */functionnormalize_speculation_url(string):string{if(isset(self::[])){returnself::[];}=;try{=wp_parse_url();if(is_array()&&!empty([\'host\'])){=strtolower((string)([\'scheme\']??\'\'));=strtolower((string)[\'host\']);=isset([\'port\'])?(int)[\'port\']:null;if((\'http\'===&&80===)||(\'https\'===&&443===)){=null;}=(string)([\'path\']??\'\');if(function_exists(\'untrailingslashit\')){=untrailingslashit();}else{=rtrim(,\'/\');}=(\'\'!==?.\'://\':\'//\')..(null!==&&>0?\':\'.:\'\').;if(isset([\'query\'])&&\'\'!==(string)[\'query\']){.=\'?\'.(string)[\'query\'];}if(isset([\'fragment\'])&&\'\'!==(string)[\'fragment\']){.=\'#\'.(string)[\'fragment\'];}}}catch(\\Throwable){unset();=;}if(count(self::)>200){self::=array();}self::[]=;return;}/** * Uncached speculation list URL validation. * * Same-site (host must match home host, preventing multisite * cross-site leakage), http(s) only, and rejects admin, login, * REST, commerce (cart/checkout/account) paths, and any URL * carrying a query string or fragment (mirroring core\'s * `?`-URL exclusion). Same-host different-port URLs and URLs * with userinfo are rejected as cross-origin/unsafe. * * Called once per distinct URL via the * {@see is_speculation_list_url_valid()} memo. * * @since 2.2.0 * * @param string $url Candidate absolute URL. * @param array<string, mixed> $parts Parsed URL parts. * @param string $home Home URL. * @return bool True when the URL may be prefetched. */functionvalidate_speculation_list_url_uncached(string,array,string):bool{try{=wp_parse_url(,PHP_URL_QUERY);if(is_string()&&\'\'!==){returnfalse;}=wp_parse_url(,PHP_URL_FRAGMENT);if(is_string()&&\'\'!==){returnfalse;}}catch(\\Throwable){unset();if(false!==strpos(,\'?\')||false!==strpos(,\'#\')){returnfalse;}}=strtolower((string)([\'scheme\']??\'\'));if(\'\'!==&&!in_array(,array(\'http\',\'https\'),true)){returnfalse;}=wp_parse_url(,PHP_URL_HOST);if(!is_string()||\'\'===){returnfalse;}if(strtolower([\'host\'])!==strtolower()){returnfalse;}if(isset([\'user\'])||isset([\'pass\'])){returnfalse;}=staticfunction(,):?int{if(null===||\'\'===){returnnull;}=(int);if(<=0){returnnull;}if((\'http\'===&&80===)||(\'https\'===&&443===)){returnnull;}return;};try{=wp_parse_url(,PHP_URL_PORT);}catch(\\Throwable){unset();=null;}=strtolower((string)(wp_parse_url(,PHP_URL_SCHEME)??\'\'));if(([\'port\']??null,)!==(??null,)){returnfalse;}=strtolower((string)([\'path\']??\'/\'));if(false!==strpos(,\'/wp-admin\')||false!==strpos(,\'wp-login.php\')||false!==strpos(,\'/wp-json\')){returnfalse;}=\'\';try{=wp_parse_url(,PHP_URL_QUERY);if(is_string()){=strtolower();}}catch(\\Throwable){unset();}=.\'?\'.;foreach(array(\'nonce\',\'logout\',\'add-to-cart\',\'admin-ajax\')as){if(false!==strpos(,)){returnfalse;}}=->get_speculation_commerce_paths();=rtrim(,\'/\');foreach(as){=strtolower(rtrim((string),\'/\'));if(\'\'===){continue;}if(===||0===strpos(.\'/\',.\'/\')){returnfalse;}}returntrue;}/** * Whether the current request must not receive the high-value prerender list (issue #1243). * * Request-scoped counterpart to {@see is_speculation_commerce_or_auth()}: the * site-wide helper returns true on the mere presence of WooCommerce * (correct for global mode/eagerness guardrails), which would keep the * opt-in prerender list dead on every Woo store. The prerender list is * a per-URL feature whose URL safety already comes from the per-URL * {@see is_speculation_list_url_valid()} commerce-path backstop, so * this probe only inspects the *current request*: cart/checkout/ * account conditionals, a frontend logged-in visitor, and an active * cart session via `woocommerce_items_in_cart` / `woocommerce_cart_hash` * cookies. Fail-closed: any throwable means \"suppressed\". * * @since 2.2.0 * * @return bool True when the prerender list must not be emitted for this request. */functionis_prerender_list_suppressed_for_request():bool{try{foreach(array(\'is_cart\',\'is_checkout\',\'is_account_page\')as){if(function_exists()){try{if(call_user_func()){returntrue;}}catch(\\Throwable){unset();}}}if(function_exists(\'is_user_logged_in\')){try{if(->is_speculation_frontend_context()&&is_user_logged_in()){returntrue;}}catch(\\Throwable){unset();}}if((isset([\'woocommerce_items_in_cart\'])&&is_string([\'woocommerce_items_in_cart\'])&&\'\'!==[\'woocommerce_items_in_cart\'])||(isset([\'woocommerce_cart_hash\'])&&is_string([\'woocommerce_cart_hash\'])&&\'\'!==[\'woocommerce_cart_hash\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * High-value prerender list URLs (issue #1237). * * Builds the high-value URL set (home plus capped RUM top URLs via * {@see get_speculation_list_urls()}, same-site validated with cart, * checkout, account, and query-string URLs excluded) and trims it to * the `speculationTopUrlsLimit` fill cap so the rules JSON stays near * ~0.5 KB. Returns an empty array unless the opt-in * `preload_settings.speculationPrerenderList` toggle is enabled * alongside `enableSpeculationRules`. * * Guards: same-origin enforcement via * {@see is_speculation_list_url_valid()}, request-scoped commerce and * auth exclusion via {@see is_prerender_list_suppressed_for_request()} * (cart/checkout/account conditionals, logged-in visitor, cart * cookies — deliberately not the site-wide * {@see is_speculation_commerce_or_auth()} Woo-presence guard, so the * list still fires on safe pages of Woo stores), logged-in * exclusion via {@see is_speculation_suppressed_for_visitor()}, and * the static-cache + RUM-qualified gate via * {@see is_prerender_allowed()} (matching the document-mode * guardrail: prerender executes page JavaScript, so unqualified * origins get no prerender list). * Fail-open: any empty model, missing toggle, excluded context, or * failure returns an empty array (conservative document-rule-only * behavior), never fatal. Multisite-safe: per-site settings and * model, no cross-site leakage. * * @since 2.2.0 * * @param string[]|null $candidates Optional pre-validated candidates (defaults to get_speculation_list_urls()). * @param array $existing_rules Existing speculation rules used for list-URL dedupe. * @return string[] Validated prerender URLs (possibly empty). */functionget_prerender_list_urls(?array=null,array=array()):array{try{if(empty(->get_options()[\'preload_settings\'][\'enableSpeculationRules\'])){returnarray();}if(empty(->get_options()[\'preload_settings\'][\'speculationPrerenderList\'])){returnarray();}if(!->is_prerender_allowed()){returnarray();}if(->is_speculation_suppressed_for_visitor()){returnarray();}if(->is_prerender_list_suppressed_for_request()){returnarray();}if(null===){=->get_speculation_list_urls();}if(empty()){returnarray();}=->get_speculation_top_urls_limit();=array();=array();foreach(as){if(!is_string()||\'\'===trim()){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'===){continue;}=->normalize_speculation_url();if(isset([])){continue;}if(!->is_speculation_list_url_valid()){continue;}[]=true;[]=;if(count()>=){break;}}if(empty()){returnarray();}returnarray_values(->dedupe_speculation_urls_against_rules(,));}catch(\\Throwable){unset();returnarray();}}/** * Normalize post-filter prerender URLs (issue #1237 follow-up). * * Shared by both merge paths in * {@see wppo_register_speculation_rules()}: keeps strings only, * re-validates every URL with * {@see is_speculation_list_url_valid()} (a filter must not inject * commerce/cross-origin/query URLs), dedupes against existing rules * (normalization-aware), and re-slices to * {@see get_speculation_top_urls_limit()} so a filter returning 20 * URLs cannot defeat the ~0.5 KB footprint guard. * * @since 2.2.0 * * @param array $urls Post-filter candidate URLs. * @param array $existing_rules Existing speculation rules for dedupe. * @return string[] Normalized prerender URLs (possibly empty). */functionnormalize_prerender_urls(array,array):array{=array_values(array_filter(,\'is_string\'));=array_values(array_filter(,array(,\'is_speculation_list_url_valid\')));=->dedupe_speculation_urls_against_rules(,);=->get_speculation_top_urls_limit();returnarray_values(array_slice(,0,));}/** * Validate a post-filter prerender list rule (issue #1237 follow-up). * * Enforces the list-rule schema after the * `wppo_speculation_prerender_list_rule` filter: `source` must stay * `list` (a filter must not morph the rule into a document rule), * `eagerness` must be a known value (invalid values fall back to * `moderate`), and `urls` are re-validated, deduped, and re-sliced * to the top-URL limit. Returns null when the rule must be dropped * (wrong source or no valid URLs left). * * @since 2.2.0 * * @param mixed $rule Post-filter rule candidate. * @param array $existing_rules Existing speculation rules for dedupe. * @return array<string,mixed>|null Validated rule, or null to drop it. */functionvalidate_prerender_rule(,array):?array{if(!is_array()){returnnull;}if(\'list\'!==([\'source\']??\'\')){returnnull;}=[\'eagerness\']??\'moderate\';if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'moderate\';}=[\'urls\']??array();if(!is_array()){returnnull;}=->normalize_prerender_urls(,);if(empty()){returnnull;}[\'source\']=\'list\';[\'eagerness\']=;[\'urls\']=array_values();return;}/** * Validate post-filter speculation rules output (trusted-code-only). * * The `wppo_speculation_*_rules` filters run trusted code, but a * misbehaving callback must not corrupt the emitted * `speculationrules` block: a non-array return falls back to the * pre-filter rules, and non-array entries are dropped. Shape * validation beyond that stays with the rule builders above. * * @since 2.2.0 * * @param mixed $filtered Post-filter rules candidate. * @param array $fallback Pre-filter rules. * @return array Validated rules. */functionvalidate_speculation_rules_output(,array):array{if(!is_array()){return;}=array();foreach(as){if(is_array()){[]=;}}return;}/** * Register the guarded high-value prerender list rule (issue #1237). * * Wires the high-value URL selection ({@see get_prerender_list_urls()}, * home plus capped RUM top URLs) to a dedicated prerender list rule * that only fires for safe same-origin candidates: commerce, auth, * and logged-in contexts stay on conservative prefetch or nothing. * * Dual-path merge into the single core `speculationrules` block on * WP 6.8+: when `$rules` is a `WP_Speculation_Rules` object (the * `wp_load_speculation_rules` action path) the rule is added via * `add_rule( \'prerender\', \'wppo-high-value-prerender\', ... )` with a * `has_rule()` guard so no duplicate is emitted; otherwise (legacy * array path, WP <6.8 or unit-test fixtures) the rule is appended to * the array with dedupe against pre-existing list rules. The WP 6.8+ * object path is additionally guarded by `function_exists` on * `wp_get_speculation_rules_configuration` plus `version_compare`, * so pre-6.8 installs fall back to the legacy plugin-owned output. * Both paths apply the same `wppo_speculation_prerender_list_urls` * and `wppo_speculation_prerender_list_rule` filters with identical * post-filter validation ({@see normalize_prerender_urls()}, * {@see validate_prerender_rule()}). * * Eagerness is pinned to `moderate` by design (not derived from * `speculationEagerness`): `eager` prerender fires on page load and * would speculatively execute JS for every visitor, while * `conservative` waits for hover and defeats prerender\'s * near-instant-navigation purpose for high-value URLs; `moderate` * matches core\'s cached-site default. * * Backward compatible with WP 6.2+ and PHP 8.2+. Fail-open: toggle * off, empty model, commerce context, logged-in visitor, or any * failure returns the input unchanged (current conservative * document-rule-only behavior), never fatal. Multisite-safe: * per-site settings and model, no cross-site leakage. * * @since 2.2.0 * * @param mixed $rules Speculation rules (WP_Speculation_Rules object or legacy rules array). * @param string[]|null $candidate_urls Optional candidate URLs (defaults to the high-value list selection). * @return mixed Updated rules, or the input unchanged. */functionwppo_register_speculation_rules(,?array=null){try{if(is_object()&&method_exists(,\'add_rule\')){if(!function_exists(\'wp_get_speculation_rules_configuration\')&&!function_exists(\'wp_get_speculation_rules\')){return;}if(!Wp_Version::is_at_least(\'6.8\',true)){return;}if(->speculation_prerender_object_added){return;}if(method_exists(,\'has_rule\')&&->has_rule(\'prerender\',\'wppo-high-value-prerender\')){return;}=->get_prerender_list_urls(,array());if(empty()){return;}if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list URLs before the rule is registered. * * @since 2.2.0 * @param string[] $urls Validated prerender URLs (home + capped RUM top URLs). */=apply_filters(\'wppo_speculation_prerender_list_urls\',);if(is_array()){=;}}=->normalize_prerender_urls(,array());if(empty()){return;}=array(\'source\'=>\'list\',\'urls\'=>array_values(),\'eagerness\'=>\'moderate\',);if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list rule before it is registered. * * @since 2.2.0 * @param array $rule_args The prerender list rule arguments. */=apply_filters(\'wppo_speculation_prerender_list_rule\',);if(is_array()){=;}}=->validate_prerender_rule(,array());if(null===){return;}->add_rule(\'prerender\',\'wppo-high-value-prerender\',);->speculation_prerender_object_added=true;return;}if(!is_array()){return;}=->get_prerender_list_urls(,);if(empty()){return;}if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list URLs before the rule is appended. * * @since 2.2.0 * @param string[] $urls Validated prerender URLs (home + capped RUM top URLs). */=apply_filters(\'wppo_speculation_prerender_list_urls\',);if(is_array()){=;}}=->normalize_prerender_urls(,);if(empty()){return;}=array(\'source\'=>\'list\',\'urls\'=>array_values(),\'eagerness\'=>\'moderate\',);if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list rule before it is appended. * * @since 2.2.0 * @param array $rule The prerender list rule. */=apply_filters(\'wppo_speculation_prerender_list_rule\',);if(is_array()){=;}}=->validate_prerender_rule(,);if(null===){return;}[]=;if(function_exists(\'apply_filters\')){/** * Filters the speculation rules after the high-value prerender list rule is appended. * * Trusted-code-only: non-array returns fall back to the * pre-filter rules and non-array entries are dropped * ({@see validate_speculation_rules_output()}). * * @since 2.2.0 * @param array $rules Updated rules. * @param string[] $urls Prerender list URLs that were appended. */=apply_filters(\'wppo_speculation_prerender_list_rules\',,);return->validate_speculation_rules_output(,);}return;}catch(\\Throwable){unset();return;}}/** * Append the high-value list rule to the `wp_speculation_rules` array. * * Runs on WP 6.8+ only (registered inside the `wp_get_speculation_rules` * guard in {@see add_speculation_rules()}). Null/non-array config is * returned untouched; speculation is never auto-enabled. Logged-in * visitors are always excluded (input returned unchanged). * * Emits a single core `speculationrules` block contribution: the * high-value/RUM list rule plus contextual rules — an eager * prerender list rule for the home link on singular views * ({@see get_singular_home_link_rule()}) and a first-post selector * document rule on archive views * ({@see get_archive_first_post_rule()}). When the opt-in * `speculationPrerenderList` toggle is enabled, the safest * high-value URLs are carved out of the generic prefetch list and * emitted as a dedicated guarded prerender list rule via * {@see wppo_register_speculation_rules()}. URLs are deduped across * all emitted entries (and against pre-existing list rules) so no * URL is speculated twice. * * @since 2.0.0 * @since 2.2.0 Carve out the opt-in guarded prerender list via wppo_register_speculation_rules(). * * @param mixed $rules Speculation rules array from core. * @return mixed Updated rules, or the input unchanged. */functionfilter_speculation_list_rules(){if(!is_array()){return;}if(empty(->get_options()[\'preload_settings\'][\'enableSpeculationRules\'])){return;}if(->is_speculation_suppressed_for_visitor()){return;}=->get_singular_home_link_rule();=->get_archive_first_post_rule();=->collect_speculation_list_urls();if(is_array()&&!empty([\'urls\'])&&is_array([\'urls\'])){[\'urls\']=->diff_speculation_urls([\'urls\'],);if(empty([\'urls\'])){=null;}}=array();if(is_array()&&!empty([\'urls\'])&&is_array([\'urls\'])){foreach([\'urls\']as){if(is_string()&&\'\'!==){[]=;}}}=->get_speculation_list_urls();/** * Filters the high-value speculation list URLs. * * @since 2.0.0 * @param string[] $urls Validated list URLs (home + high-value + RUM winners). */=apply_filters(\'wppo_speculation_list_urls\',);if(!is_array()){return;}=array_values(array_filter(,\'is_string\'));=array_values(array_filter(,array(,\'is_speculation_list_url_valid\')));if(!empty()){=->diff_speculation_urls(,);}if(is_array()){=->collect_speculation_document_paths();if(!empty()){=array_values(array_filter(,staticfunction()use(){=wp_parse_url((string),PHP_URL_PATH);if(!is_string()||\'\'===){returntrue;}=rtrim(untrailingslashit(),\'/\');if(\'\'===){=\'/\';}foreach(as){if(===){returnfalse;}}returntrue;}));}}=->dedupe_speculation_urls_against_rules(,);=->get_prerender_list_urls(,);if(!empty()){=->diff_speculation_urls(,);}=->get_options()[\'preload_settings\']??array();=[\'speculationEagerness\']??\'conservative\';if(class_exists(\'WP_Speculation_Rules\')&&method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){if(!\\WP_Speculation_Rules::is_valid_eagerness()){=\'conservative\';}}elseif(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}=->maybe_cap_speculation_eagerness();=array();if(!empty()){[]=array(\'source\'=>\'list\',\'urls\'=>array_values(),\'eagerness\'=>,);}if(is_array()){[]=;}if(is_array()){if(!->has_document_source_rule()){/** * Filters the archive first-post document rule before it is appended. * * @since 2.0.0 * @param array $archive_rule The archive document rule. */=apply_filters(\'wppo_speculation_document_rule\',);if(is_array()){[]=;}}}if(empty()){return;}foreach(as){[]=;}/** * Filters the speculation rules after the high-value list rule is appended. * * Trusted-code-only: a non-array return falls back to the * pre-filter rules and non-array entries are dropped * ({@see validate_speculation_rules_output()}). * * @since 2.0.0 * @param array $rules Updated rules. * @param string[] $urls List URLs that were appended. */=apply_filters(\'wppo_speculation_list_rules\',,);=->validate_speculation_rules_output(,);if(!empty()){=->wppo_register_speculation_rules(,);}return;}/** * Whether any rule in the set uses a document source. * * Shared by the fill-gaps-only document-rule gate in * {@see filter_speculation_list_rules()} so source-checking logic * lives in one place. * * @since 2.0.0 * * @param array $rules Existing speculation rules. * @return bool True when a document-source rule is present. */functionhas_document_source_rule(array):bool{foreach(as){if(is_array()&&\'document\'===([\'source\']??\'\')){returntrue;}}returnfalse;}/** * Collect URLs already covered by list-source rules. * * @since 2.0.0 * * @param array $rules Existing speculation rules. * @return string[] List-source URLs already present. */functioncollect_speculation_list_urls(array):array{=array();foreach(as){if(!is_array()||([\'source\']??\'\')!==\'list\'){continue;}=[\'urls\']??array();if(!is_array()){continue;}foreach(as){if(is_string()&&\'\'!==){[]=;}}}return;}/** * Remove URLs already covered by an existing list-source rule. * * Mirrors `AI_Adaptive::dedupe_against_existing_lists()` so this * method\'s contribution and the priority-20 AI rule can never * re-add the same URL (single block, no duplicates). Comparison is * normalization-aware ({@see normalize_speculation_url()}): a RUM * winner with a restored trailing slash and a raw high-value input * without one count as the same URL. * * @since 2.0.0 * @since 2.2.0 Normalize before comparing. * * @param string[] $urls Candidate list URLs. * @param array $rules Existing speculation rules. * @return string[] Deduped URLs. */functiondedupe_speculation_urls_against_rules(array,array):array{=->collect_speculation_list_urls();return->diff_speculation_urls(,);}/** * Remove URLs present in an exclusion set (normalization-aware). * * Shared by the singular-rule, covered-URL, and prerender carve-outs * in {@see filter_speculation_list_rules()} so trailing-slash * variants compare equal everywhere. Output preserves the original * (non-normalized) URL strings and order. * * @since 2.2.0 * * @param array $urls Candidate URLs (non-strings dropped). * @param array $excluded URLs to remove. * @return string[] Remaining URLs. */functiondiff_speculation_urls(array,array):array{=array();foreach(as){if(is_string()&&\'\'!==){[->normalize_speculation_url()]=true;}}if(empty()){=array();foreach(as){if(is_string()&&\'\'!==){[]=;}}returnarray_values();}=array();foreach(as){if(!is_string()||\'\'===){continue;}=->normalize_speculation_url();if(isset([])){continue;}[]=;[]=true;}returnarray_values();}/** * Collect normalized target paths from a document-source rule. * * Reduces each `href_matches` pattern (e.g. `/first-post/*`) to its * path prefix so generic-list URLs can be compared on the same basis. * * @since 2.0.0 * * @param array $document_rule Document-source rule. * @return string[] Normalized paths (e.g. `/first-post`). */functioncollect_speculation_document_paths(array):array{=array();=[\'where\']??null;if(!is_array()){return;}=[\'and\']??array();if(!is_array()){return;}foreach(as){if(!is_array()||empty([\'href_matches\'])||!is_string([\'href_matches\'])){continue;}=rtrim([\'href_matches\'],\'*\');=rtrim(untrailingslashit(),\'/\');[]=\'\'===?\'/\':;}return;}/** * Read-only lookup of a WP 7.1 speculative-loading default override. * * Mirrors core\'s `wp_get_speculative_loading_override()` precedence * (constant over environment variable) without depending on that private * core function. Reads configuration only; never writes environment * variables or constants. * * Only treats the value as an override when it is one of the values core * accepts for that setting (mirroring the validation in core\'s * `wp_get_speculation_rules_configuration()`). An invalid or empty value * is treated as absent so the plugin\'s conservative pin still applies * instead of silently allowing core\'s cached-site escalation. * * When `wp_get_speculation_rules_default_configuration()` exists (WP 7.1+) * its effective defaults are also considered: if the host pinned a different * default (e.g. `moderate`) the function will reflect it, and this helper * treats that as an override so the plugin\'s `conservative` pin does not * fight the host. The `wp_speculation_rules_configuration` filter (used in * {@see filter_speculation_rules_configuration()}) wins over the host in * any case (documented precedence). Validation uses * `WP_Speculation_Rules::is_valid_mode()` / `is_valid_eagerness()` when available. * * @since 1.9.0 * @since 2.0.0 Honor `wp_get_speculation_rules_default_configuration()` when available. * * @param string $name Override name, e.g. \'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'. * @return string|null The override value, or null when neither the constant nor the environment variable is set to a valid value. */functionget_speculation_default_override(string):?string{if(function_exists(\'wp_get_speculation_rules_default_configuration\')){=wp_get_speculation_rules_default_configuration();if(is_array()){=null;if(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===){=\'mode\';}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===){=\'eagerness\';}if(null!==&&isset([])&&is_string([])&&\'\'!==[]){=[];=true;if(class_exists(\'WP_Speculation_Rules\')){if(\'mode\'===){if(method_exists(\'WP_Speculation_Rules\',\'is_valid_mode\')){=\\WP_Speculation_Rules::is_valid_mode();}else{=in_array(,array(\'prefetch\',\'prerender\'),true);}}elseif(\'eagerness\'===){if(method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){=\\WP_Speculation_Rules::is_valid_eagerness();}else{=in_array(,array(\'conservative\',\'moderate\',\'eager\'),true);}}}elseif(\'mode\'===){=in_array(,array(\'prefetch\',\'prerender\'),true);}else{=in_array(,array(\'conservative\',\'moderate\',\'eager\'),true);}if(){=(\'mode\'===)?\'prefetch\':\'conservative\';if(!==){return;}}}}}=null;if(function_exists(\'getenv\')){=getenv();if(false!==){=;}}if(defined()){=constant();if(is_string()){=;}}if(!is_string()||\'\'===){returnnull;}if(class_exists(\'WP_Speculation_Rules\')){if(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===&&method_exists(\'WP_Speculation_Rules\',\'is_valid_mode\')){if(!\\WP_Speculation_Rules::is_valid_mode()){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===&&method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){if(!\\WP_Speculation_Rules::is_valid_eagerness()){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===){if(!in_array(,array(\'prefetch\',\'prerender\'),true)){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===){if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){returnnull;}}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===){if(!in_array(,array(\'prefetch\',\'prerender\'),true)){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===){if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){returnnull;}}return;}/** * Get handles to exclude from optimization via the CVE guard filter. * * Filter-only, S scope (no persistence, no cron). Default empty (disabled). * When a CVE is known for a handle, site operators can auto-exclude it via * `wppo_cve_guard_handles` (alias `wppo_cve_excluded_handles` for BC) without * touching `wppo_settings`. Merged into minify/defer/delay exclude lists with * `array_unique`; respects the existing `litespeed_can_optm` gate (optimization * disabled there anyway). * * @since 2.0.0 * @return string[] List of handle strings to exclude. */functionget_cve_guard_handles():array{=apply_filters(\'wppo_cve_guard_handles\',array());=apply_filters(\'wppo_cve_excluded_handles\',);if(!is_array()){returnarray();}=array_filter(,\'is_string\');=array_map(\'trim\',);=array_filter();returnarray_values(array_unique());}/** * Whether a style handle is a core block asset owned by core\'s on-demand loader. * * On WP 6.9+ with separate core block assets active, the combined * stylesheet (`wp-block-library`) and every per-block stylesheet * (`wp-block-cover`, `wp-block-group`, ...) are loaded on demand for the * blocks actually present on the page. On pre-6.9 cores the 6.8 classic * on-demand world (`should_load_block_assets_on_demand` / * `wp_should_load_block_assets_on_demand()`, see * {@see is_classic_block_assets_on_demand_active()}) owns the same * `wp-block-*` family when the operator opted in. Rewriting their `src` (minify) or * folding them into the combined file would re-monolithize what core * ships conditionally, so the minify path skips them in either mode — * mirroring `Cache::is_core_block_asset()`. * * @since 2.0.0 * * @param string $handle The registered style handle. * @return bool True when core owns the handle under on-demand mode. */functionis_core_block_asset_skipped():bool{if(->is_core_separate_block_assets_active()){try{returnstr_starts_with((string),\'wp-block-\');}catch(\\Throwable){unset();returnfalse;}}if(->is_classic_block_assets_on_demand_active()){try{returnstr_starts_with((string),\'wp-block-\');}catch(\\Throwable){unset();returnfalse;}}returnfalse;}/** * Whether WP 6.8 classic on-demand block-asset loading is active. * * Covers the pre-6.9 world (`should_load_block_assets_on_demand` filter / * `wp_should_load_block_assets_on_demand()`) that * {@see is_core_separate_block_assets_active()} does not model. Positive * runtime evidence only: the core function wins when present, otherwise * a `has_filter`-guarded `should_load_block_assets_on_demand` read * (which reflects the opt-in registered by * {@see Hook_Registry::register_block_assets_filters()}). The combined monolith escape * hatch (`loadAllCoreBlockAssets` on / `blockAssetsOnDemand` off, see * {@see is_hidden_block_asset_omission_enabled()}) forces false so * operators who opted out keep the legacy monolith. Fail-open: any * throwable, missing API, or absent evidence returns false (legacy path). * * @since 2.2.0 * * @return bool True when classic on-demand block assets are active. */functionis_classic_block_assets_on_demand_active():bool{try{if(Wp_Version::is_global_at_least(\'6.9-alpha\')){returnfalse;}if(!->is_hidden_block_asset_omission_enabled()){returnfalse;}if(function_exists(\'wp_should_load_block_assets_on_demand\')){return(bool)wp_should_load_block_assets_on_demand();}if(function_exists(\'has_filter\')&&has_filter(\'should_load_block_assets_on_demand\')){return(bool)apply_filters(\'should_load_block_assets_on_demand\',false);}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Whether WP 6.9+ core reports separate (on-demand) block-asset loading. * * Shared predicate for {@see is_core_block_asset_skipped()} and * {@see is_core_block_hoisting_active()} so the 6.9-alpha floor + * API-exists + fail-open check cannot drift between the two call sites. * * @since 2.2.0 * * @return bool True when core loads separate core block assets on demand. */functionis_core_separate_block_assets_active():bool{if(!Wp_Version::is_global_at_least(\'6.9-alpha\')){returnfalse;}if(!function_exists(\'wp_should_load_separate_core_block_assets\')){returnfalse;}try{return(bool)wp_should_load_separate_core_block_assets();}catch(\\Throwable){unset();returnfalse;}}/** * Whether the hidden-block-asset omission pass may run. * * Mirrors the opt-out state honored by * {@see Hook_Registry::register_block_assets_filters()}: the omission pass only runs * when on-demand block assets are enabled (`blockAssetsOnDemand` on) and * the combined monolith is not forced (`loadAllCoreBlockAssets` off). * Users who explicitly disabled on-demand assets keep the legacy * monolith untouched. * * @since 2.2.0 * * @return bool True when hidden block assets may be omitted. */functionis_hidden_block_asset_omission_enabled():bool{return!empty(->get_options()[\'file_optimisation\'][\'blockAssetsOnDemand\'])&&empty(->get_options()[\'file_optimisation\'][\'loadAllCoreBlockAssets\']);}/** * Whether core 6.9+ block-asset hoisting is active on this request. * * True only when {@see is_core_separate_block_assets_active()} holds AND * the template-enhancement buffer API exists. Callers use this to yield * to core\'s on-demand hoisting instead of duplicating it (e.g. * {@see omit_hidden_block_assets()} returns early when the * template-enhancement buffer exists). Fail-open: any throwable or * missing API returns false (legacy path unchanged). * * @since 2.2.0 * * @return bool True when core owns on-demand block-asset hoisting. */functionis_core_block_hoisting_active():bool{if(!function_exists(\'wp_should_output_buffer_template_for_enhancement\')){returnfalse;}return->is_core_separate_block_assets_active();}/** * Whether a queued style handle is a core per-block stylesheet. * * The `wp-block-*` prefix alone is not proof of core ownership: * third-party or theme stylesheets may share the prefix without a 1:1 * block-type mapping. A handle only counts as a core per-block asset * when its registered `src` points at core\'s block styles * (`wp-includes` + `block-library` or `/blocks/`). Fail-open: an * unregistered handle or an unverifiable `src` returns false (keep the * asset — degrade to unoptimized, never unstyled). * * @since 2.2.0 * * @param string $handle Queued style handle e.g. \'wp-block-cover\'. * @return bool True when the handle is verifiably a core per-block asset. */functionis_core_per_block_style_handle():bool{try{global;if(!is_object()||!isset(->registered[])){returnfalse;}=(string)(->registered[]->src??\'\');if(\'\'===){returnfalse;}if(false!==strpos(,\'block-library\')){returnfalse!==strpos(,\'wp-includes\');}returnfalse!==strpos(,\'wp-includes\')&&false!==strpos(,\'/blocks/\');}catch(\\Throwable){unset();returnfalse;}}/** * Whether singular post content references block sources outside itself. * * Reusable blocks (`wp:block` refs), patterns (`wp:pattern`), * template parts (`wp:template-part`), shortcode blocks * (`wp:shortcode`, generic `[...]` shortcodes, `do_blocks` output), * and similar markers render stylesheets for blocks absent from the * literal post content, so type-absence cannot prove the asset is * unused. Fail-open: any throwable (or a detected marker) reports * unresolvable (the caller bails and keeps every asset). * * @since 2.2.0 * * @param string $content Singular post content to check. * @return bool True when the content references out-of-content block sources. */functioncontent_has_unresolvable_block_sources():bool{try{=(string);if(\'\'===){returnfalse;}if(false!==strpos(,\'<!-- wp:block \')||false!==strpos(,\'<!-- wp:block/\')||false!==strpos(,\'wp:pattern\')||false!==strpos(,\'wp:template-part\')||false!==strpos(,\'wp:shortcode\')||false!==strpos(,\'do_blocks\')){returntrue;}if(false===strpos(,\'[\')){returnfalse;}if(function_exists(\'get_shortcode_regex\')){try{=(string)get_shortcode_regex();if(\'\'===){returnfalse;}if(1!==preg_match_all(\'/\'..\'/s\',,)||empty([2])){returnfalse;}if(function_exists(\'shortcode_exists\')){foreach([2]as){if(\'\'!==(string)&&shortcode_exists((string))){returntrue;}}returnfalse;}returntrue;}catch(\\Throwable){unset();returntrue;}}return1===preg_match(\'/\\[[a-zA-Z0-9_-]+(?:\\s+[^\\]]*)?\\/?\\]/\',);}catch(\\Throwable){unset();returntrue;}}/** * Whether a block name is a registered block type. * * A `wp-block-<slug>` handle does not guarantee a matching * `core/<slug>` block type exists: core-registered handles without a * 1:1 block-type mapping never match {@see Util::content_has_block()} * and would otherwise always be omitted whenever queued. Fail-open in * the omission direction: when the registry API is unavailable or * throws, the type is treated as known so the legacy omission path is * unchanged; only a positive \"not registered\" answer keeps the asset. * * @since 2.2.0 * * @param string $block_name Block name e.g. \'core/cover\'. * @return bool True when the type is (or may be) registered. */functionis_registered_block_type():bool{try{if(!class_exists(\'WP_Block_Type_Registry\')){returntrue;}if(!method_exists(\'WP_Block_Type_Registry\',\'get_instance\')){returntrue;}=\\WP_Block_Type_Registry::get_instance();if(!is_object()){returntrue;}if(method_exists(,\'is_registered\')){return(bool)->is_registered((string));}if(method_exists(,\'get_registered\')){returnnull!==->get_registered((string));}returntrue;}catch(\\Throwable){unset();returntrue;}}/** * Whether core 6.9+ wants an empty block\'s asset kept via its canonical filter. * * Probes core\'s `enqueue_empty_block_content_assets` filter * (`wp-includes/class-wp-block.php`, `@since 6.9.0`; semantics: * `$enqueue=false` = drop empty-block assets, return `true` = keep * them). See the WP 6.9 frontend-performance field guide. Fail-open: * any doubt returns true (keep the asset — degrade to unoptimized, * never unstyled). On core below 6.9 returns false (no keep-signal) * so the caller falls through to legacy behavior byte-for-byte. * * @since 2.2.0 * * @param string $block_name Block name e.g. \'core/cover\'. * @return bool True when core wants the asset kept. */functionshould_keep_empty_block_asset_via_core_filter():bool{try{if(!Wp_Version::is_global_at_least(\'6.9-alpha\')){returnfalse;}if(!function_exists(\'has_filter\')||!function_exists(\'apply_filters\')){returntrue;}if(!has_filter(\'enqueue_empty_block_content_assets\')){returnfalse;}try{=apply_filters(\'enqueue_empty_block_content_assets\',false,(string));}catch(\\Throwable){unset();returntrue;}return(bool);}catch(\\Throwable){unset();returntrue;}}/** * Whether a queued core block asset should be omitted as hidden. * * A block asset counts as hidden when its block type is absent from the * given singular post content (type-absence definition: blocks present in * markup but never rendered still ship their per-block stylesheet, so * the stylesheet is unused by definition). Hidden assets are omitted by * default; a per-block re-enable is available via the * `wppo_allow_hidden_block_asset` filter (return truthy to keep the * asset for that block). On WP 6.9+ core\'s canonical * `enqueue_empty_block_content_assets` filter is honored first * (return `true` to keep the asset even though empty); either filter * keeping the asset wins. The filters are only applied when a * listener is registered (`has_filter()` guard). Fail-open: missing * content, missing APIs, an unregistered block type, or any throwable * returns false (keep the asset — degrade to unoptimized, never * fatal, never unstyled). * Callers may pass a shared `$presence` map so the content parse in * {@see Util::content_has_block()} runs at most once per block type per * pass instead of once per queued handle. * * @since 2.2.0 * * @param string $block_name Block name e.g. \'core/cover\'. * @param string $handle Queued style handle e.g. \'wp-block-cover\'. * @param string $content Singular post content to check against. * @param array $presence Optional shared presence cache (block_name => bool), updated by reference. * @return bool True when the asset should be dequeued. */functionshould_omit_hidden_block_asset(,,,&=array()):bool{try{if(\'\'===(string)||\'\'===(string)){returnfalse;}if(\'\'===(string)){returnfalse;}if(!->is_registered_block_type((string))){returnfalse;}=(string);if(!array_key_exists(,(array))){[]=Util::content_has_block((string),(string));}if(!empty([])){returnfalse;}if(->should_keep_empty_block_asset_via_core_filter((string))){returnfalse;}=false;if(function_exists(\'has_filter\')&&has_filter(\'wppo_allow_hidden_block_asset\')){try{=apply_filters(\'wppo_allow_hidden_block_asset\',false,(string),(string));}catch(\\Throwable){unset();returnfalse;}}returnempty();}catch(\\Throwable){unset();returnfalse;}}/** * Dequeue per-block stylesheets for blocks absent from the content. * * Singular views only: a single `post_content` is authoritative only * there. Archives, blog-home, search, and other non-singular views bail * out immediately so stylesheets needed by other posts in the loop are * never stripped. Block themes bail out as well: header/footer * template parts, site chrome, and widgets render outside * `post_content`, so type-absence cannot prove the asset is unused * there. Singular gating alone does not protect composite * sources: reusable blocks, patterns, template parts, widgets, and * shortcode/`do_blocks`-injected blocks can render stylesheets for * blocks absent from `post_content`, so the pass additionally bails * out entirely when the content references such out-of-content * sources (see {@see content_has_unresolvable_block_sources()}). * Remaining outside-`post_content` rendering on classic themes is a * known limitation — use the `wppo_allow_hidden_block_asset` filter * to keep those assets. * * Runs on `wp_enqueue_scripts` at PHP_INT_MAX - 2: after core enqueues * but before `minify_queued_styles()` and `Cache::combine_css()`, so * omitted handles never enter the minify/combine pipelines and the * cascade order of the surviving stylesheets is preserved (dequeue * only — nothing is re-enqueued or folded into a monolith). * * Defers to core 6.9 hoisting: when {@see is_core_block_hoisting_active()} * is true and the template-enhancement buffer exists * (`wp_should_output_buffer_template_for_enhancement()`), this returns * immediately and core\'s conditional loading owns the output. The pass * is also skipped when the on-demand opt-out is active * ({@see is_hidden_block_asset_omission_enabled()}). Otherwise the * legacy omission path runs unchanged. * * @since 2.2.0 * * @return void */functionomit_hidden_block_assets():void{try{if(!->is_hidden_block_asset_omission_enabled()){return;}if(!function_exists(\'is_singular\')||!is_singular()){return;}if(function_exists(\'wp_is_block_theme\')){try{if(wp_is_block_theme()){return;}}catch(\\Throwable){unset();return;}}if(->is_core_block_hoisting_active()&&function_exists(\'wp_should_output_buffer_template_for_enhancement\')){try{if(wp_should_output_buffer_template_for_enhancement()){return;}}catch(\\Throwable){unset();}}global;if(!is_object()||empty(->queue)||!is_array(->queue)){return;}if(!function_exists(\'wp_dequeue_style\')){return;}=\'\';if(function_exists(\'get_the_ID\')&&function_exists(\'get_post_field\')){try{=get_the_ID();if(!empty()){=(string)get_post_field(\'post_content\',);}}catch(\\Throwable){unset();return;}}if(\'\'===){return;}if(->content_has_unresolvable_block_sources()){return;}=array();=array();foreach(->queueas){if(!is_string()||0!==strpos(,\'wp-block-\')){continue;}=substr(,strlen(\'wp-block-\'));if(\'\'===||0===strpos(,\'library\')){continue;}if(!->is_core_per_block_style_handle()){continue;}=\'core/\'.;if(!array_key_exists(,)){[]=->should_omit_hidden_block_asset(,,,);}if([]){try{wp_dequeue_style();}catch(\\Throwable){unset();}}}}catch(\\Throwable){unset();}}/** * Rewrites enqueued styles to their minified versions at enqueue time and * registers the on-disk path so core can inline them. * * The inline-styles `path` data mechanism exists since WordPress 5.8 * (`wp_maybe_inline_styles()` / the `styles_inline_size_limit` filter; the * default budget was raised from 20KB to 40KB in 6.9). This runs on * `wp_enqueue_scripts` (before core\'s inline pass at `wp_head` priority 1) * so minified files can opt in to inlining. Falls back to the * `style_loader_tag` rewriting in {@see minify_css()} on older WordPress * versions. * * @since 1.9.0 * @return void */functionminify_queued_styles():void{->minify_policy()->minify_queued_styles();}/** * Rewrites CSS link tags to use minified versions if they exist. * * @since 1.0.0 * * @param string $tag The link tag HTML. * @param string $handle The CSS file\'s handle. * @param string $href The CSS file\'s source URL. * @return string Modified link tag with minified CSS. */functionminify_css(,,){return->minify_policy()->minify_css(,,);}/** * Rewrites script tags to use minified versions if they exist. * * @since 1.0.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @param string $src The script\'s source URL. * @return string Modified script tag with minified JavaScript. */functionminify_js(,,){return->minify_policy()->minify_js(,,);}/** * Sanitizes image info for client exposure — replaces path arrays with counts. * * Prevents filesystem paths from being visible in wppoSettings via View Page Source. * * @since 1.7.0 * * @param array $img_info Raw image info from wppo_img_info option. * @return array Image info with only counts (no file paths). */functionsanitize_image_info_for_client(array):array{=array();foreach(array(\'pending\',\'completed\',\'failed\')as){=[]??array();[]=array(\'webp\'=>is_array([\'webp\']??null)?count([\'webp\']):([\'webp\']??0),\'avif\'=>is_array([\'avif\']??null)?count([\'avif\']):([\'avif\']??0),);}return;}/** * Upgrade auto-purge status for the SPA (issue #1276). * * Returns the last derived-cache purge record plus a safe-mode * preview URL (`?wppo_nocache=1`, bypassing minify). Class and * method-exists guarded + fail-open so localisation never fatals. * Localised (not lazy-fetched) intentionally: the banner needs the * seed on first paint and the SPA refreshes via the read-only * upgrade_purge_status endpoint after cache-clearing actions; the * cost is a single non-autoloaded option read on admin pages. * * @since 2.2.0 * @return array{last_purge:array{reason:string,time:int},safe_preview_url:string} */functionget_upgrade_purge_for_client():array{=array(\'last_purge\'=>array(\'reason\'=>\'\',\'time\'=>0,),\'safe_preview_url\'=>\'\',);try{if(!class_exists(\'PerformanceOptimise\\Inc\\Builder_Purge_Watcher\')){return;}if(method_exists(\'PerformanceOptimise\\Inc\\Builder_Purge_Watcher\',\'get_last_purge\')){=Builder_Purge_Watcher::get_last_purge();if(is_array()){[\'last_purge\']=array(\'reason\'=>isset([\'reason\'])&&is_string([\'reason\'])?[\'reason\']:\'\',\'time\'=>isset([\'time\'])?(int)[\'time\']:0,);}}if(method_exists(\'PerformanceOptimise\\Inc\\Builder_Purge_Watcher\',\'get_safe_preview_url\')){=Builder_Purge_Watcher::get_safe_preview_url();[\'safe_preview_url\']=is_string()?:\'\';}}catch(\\Throwable){unset();}return;}/** * Enqueues the admin bar cache-clearing script and its data. * * Shared between admin and frontend to ensure consistent wppoObject data. * * @since 1.9.0 * * @return void */functionenqueue_admin_bar_script():void{=WPPO_PLUGIN_PATH.\'build/main.asset.php\';=wp_normalize_path(realpath());if(false!==&&0===strpos(,(string)WPPO_PLUGIN_PATH)){=require;}else{=array(\'dependencies\'=>array(),\'version\'=>WPPO_VERSION,);}wp_enqueue_script(\'wppo-admin-bar-script\',WPPO_PLUGIN_URL.\'build/main.js\',[\'dependencies\'],[\'version\'],array(\'in_footer\'=>true,\'fetchpriority\'=>\'low\',));=array(\'apiUrl\'=>get_rest_url(null,\'performance-optimisation/v1\'),\'ajaxUrl\'=>admin_url(\'admin-ajax.php\'),\'nonce\'=>wp_create_nonce(\'wp_rest\'),\'nonce_refresh\'=>wp_create_nonce(\'wppo_nonce_refresh\'),\'translations\'=>array(\'cacheCleared\'=>__(\'Cache cleared successfully.\',\'performance-optimisation\'),\'clearFailed\'=>__(\'Failed to clear cache.\',\'performance-optimisation\'),\'clearRetry\'=>__(\'Failed to clear cache. Please try again.\',\'performance-optimisation\'),\'pageCleared\'=>__(\'Page cache cleared successfully.\',\'performance-optimisation\'),\'pageFailed\'=>__(\'Failed to clear page cache.\',\'performance-optimisation\'),\'pageRetry\'=>__(\'Failed to clear page cache. Please try again.\',\'performance-optimisation\'),\'dismiss\'=>__(\'Dismiss\',\'performance-optimisation\'),),);wp_add_inline_script(\'wppo-admin-bar-script\',\'window.wppoObject = \'.wp_json_encode(,JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP).\';\',\'before\');wp_set_script_translations(\'wppo-admin-bar-script\',\'performance-optimisation\');} $data): }

Direct reference to script-strategy hook state `$exclude_defer_js` (ARCH-005 internal bridge).

ParameterTypeDefaultDescription
$datascript_state_exclude_defer_js(){return->exclude_defer_js;}/** * Direct reference to script-strategy hook state `$exclude_delay_js` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_exclude_delay_js(){return->exclude_delay_js;}/** * Direct reference to script-strategy hook state `$resolved_delay_exclusions` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_resolved_delay_exclusions(){return->resolved_delay_exclusions;}/** * Direct reference to script-strategy hook state `$page_preset_opt_out_remove` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_page_preset_opt_out_remove(){return->page_preset_opt_out_remove;}/** * Direct reference to script-strategy hook state `$delay_js_default_strategy` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_default_strategy(){return->delay_js_default_strategy;}/** * Direct reference to script-strategy hook state `$delay_js_idle_list` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_idle_list(){return->delay_js_idle_list;}/** * Direct reference to script-strategy hook state `$delay_js_viewport_list` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_viewport_list(){return->delay_js_viewport_list;}/** * Direct reference to script-strategy hook state `$delay_js_per_page_interaction` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_per_page_interaction(){return->delay_js_per_page_interaction;}/** * Direct reference to script-strategy hook state `$delay_js_priority` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_js_priority(){return->delay_js_priority;}/** * Direct reference to script-strategy hook state `$delay_disabled_for_page` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_delay_disabled_for_page(){return->delay_disabled_for_page;}/** * Direct reference to script-strategy hook state `$defer_disabled_for_page` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_defer_disabled_for_page(){return->defer_disabled_for_page;}/** * Direct reference to script-strategy hook state `$deferred_handles` (ARCH-005 internal bridge). * * Gives {@see Script_Strategy} the same live request-state access the * relocated bodies had on `Main`. Audit note: only `Script_Strategy` * 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 mixed Reference to the live state. */function&script_state_deferred_handles(){return->deferred_handles;}/** * Logged-in optimisation gate for the script-strategy cluster (ARCH-005 internal bridge). * * Same Tahoe semantics as the direct `$this->should_optimise_for_logged_in()` * call the relocated bodies made on `Main`. Only `Script_Strategy` * calls this. * * @internal * @since 2.4.0 * @return bool True when optimisation applies to the current viewer. */functionscript_should_optimise_for_logged_in():bool{return->should_optimise_for_logged_in();}/** * One-time upgrade for the block-assets toggle on WP 6.9+. * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_block_assets_setting():void{->migrations()->migrate_block_assets_setting(function_exists(\'wp_load_classic_theme_block_styles_on_demand\'));}/** * One-time upgrade core for the block-assets toggle on WP 6.9+. * * Backward-compatibility shim (ARCH-004): delegates to * {@see Settings_Migrations::migrate_block_assets_setting()} so reflective * callers observe identical behavior. * * Retention note: intentionally kept despite looking unreachable — * `tests/php/BlockAssetsMigrationTest.php` invokes it via reflection * and external reflective callers may do the same. Do not remove in * dead-code sweeps. * * @since 2.4.0 Delegates to Settings_Migrations (ARCH-004). * @param bool $loads_separate_core_block_assets_on_demand Whether WP 6.9+ is active. * @return void */functionmigrate_block_assets_setting(bool):void{->migrations()->migrate_block_assets_setting();}/** * One-time backfill for the CCSS inline size cap. * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.0.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ccss_max_size():void{->migrations()->migrate_ccss_max_size();}/** * One-time backfill for the Critical CSS user safelist (issue #1038). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.0.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ccss_safelist():void{->migrations()->migrate_ccss_safelist();}/** * One-time backfill for the RUM-weighted CSS queue keys (issue #1164). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 Also backfills the 25s `ccssGenTimeout` generation budget. * @since 2.3.0 Also backfills the #1388 keys (`ccssInlineBudgetKb`, `ccssCommerceExclude`, `ccssChecksumRegen`). * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_css_queue_defaults():void{->migrations()->migrate_css_queue_defaults();}/** * One-time backfill for the RUM-weighted top-URL prefetch cap (issue #1183). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_speculation_top_urls():void{->migrations()->migrate_speculation_top_urls();}/** * One-time backfill for the high-value prerender list toggle (issue #1237). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_speculation_prerender_list():void{->migrations()->migrate_speculation_prerender_list();}/** * One-time backfill for the RUM beacon sample rate (issue #1214). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_rum_sample_rate():void{->migrations()->migrate_rum_sample_rate();}/** * One-time backfill for the missing-alt autofill toggle and the longest-edge downscale cap (issue #985). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.0.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_image_alt_edge_defaults():void{->migrations()->migrate_image_alt_edge_defaults();}/** * One-time backfill for the unified safe-mode kill switch (issue #1098). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_safe_mode():void{->migrations()->migrate_safe_mode();}/** * Per-request memo for is_elementor_built_page() (issue #1259). * * Both combine_css() and will_combine_css_inline() run the full * builder detection on the same request; without a memo that is 2x * queried-object lookups, up to 4x get_post_meta, and 2x * is_elementor_context() calls. Keyed by resolved post ID * (\'post:{id}\') or by queried object (\'queried:{id}\') when no * explicit post ID was passed. * * @since 2.2.0 * @var array<string,bool> */staticarray=array();/** * Reset the Elementor-built memo (for tests). * * @since 2.2.0 * @return void */staticfunctionreset_elementor_memo():void{self::=array();}/** * Native-only Elementor presence pre-gate (issue #1259). * * Centralizes the cheap class/constant/query-var predicate so * combine_css() and will_combine_css_inline() cannot drift apart. * Zero WP calls: class_exists() with autoload disabled, defined(), * and isset() on $_GET only. Callers run the full guarded * detection only when this returns true. * * Note: a bare `?elementor-preview=1` query var forces a skip on * Elementor-active sites for any visitor appending it. Impact is * performance-only (unoptimized markup served, no data or * cache-write exposure); legit preview links need the bypass even * before per-post builder meta is readable, so this stays lenient * by design. * * Fail direction: detection failure degrades to \"looks like * Elementor\" (true) so the full guarded detection runs and, failing * that, combine is skipped while safe mode is on (perf-only cost * instead of risking broken Elementor layout/FOUC). * * @since 2.2.0 * @return bool True when Elementor looks present on this request. */staticfunctionlooks_like_elementor_request():bool{try{if(class_exists(\'Elementor\\Plugin\',false)||defined(\'ELEMENTOR_VERSION\')){returntrue;}if(isset([\'elementor-preview\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Backfill the additive elementorSafeMode key (issue #1259). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_elementor_safe_mode():void{->migrations()->migrate_elementor_safe_mode();}/** * Whether Elementor-safe mode is active (issue #1259). * * When on (default), combine/inline step aside on Elementor-built * pages. Fail-open to enabled when settings are unreadable so * unknown builder markup degrades to uncombined (never broken). * Every builder call is guarded; non-Elementor sites carry zero * weight beyond two cheap array lookups. * * @since 2.2.0 * * @param array $file_optimisation Optional `file_optimisation` settings slice. * @return bool True when Elementor-safe mode is on. */staticfunctionis_elementor_safe_mode_active(array=array()):bool{try{if(empty()&&class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){try{=(array)Util::get_settings();=isset([\'file_optimisation\'])&&is_array([\'file_optimisation\'])?[\'file_optimisation\']:array();}catch(\\Throwable){unset();}}=!array_key_exists(\'elementorSafeMode\',)||!empty([\'elementorSafeMode\']);if(function_exists(\'has_filter\')&&function_exists(\'apply_filters\')&&has_filter(\'wppo_elementor_safe_mode_enabled\')){try{=(bool)apply_filters(\'wppo_elementor_safe_mode_enabled\',);}catch(\\Throwable){unset();}}return;}catch(\\Throwable){unset();returntrue;}}/** * Whether the current page was built with Elementor (issue #1259). * * Lazy boot: class_exists() / function_exists() guards first so * non-Elementor sites pay nothing. Checks the Elementor plugin class, * version markers, per-post `_elementor_data` / `_elementor_edit_mode` * meta for the resolved post, and `data-elementor-type` is left to * markup-level callers. Detection failure degrades to skip (true) * while safe mode is on (see fail-direction note below). * * Per-request memoized by resolved post ID: combine_css() and * will_combine_css_inline() share one verdict per request. * * Single-post scope: only the resolved post\'s meta is inspected. * Archives, home, loops (queried ID 0), Elementor Theme Builder * archive/header/footer/popup contexts, and posts rendered inside * another loop are not covered — pass an explicit $post_id for those * contexts instead of relying on the queried-object fallback. * Theme Builder templates, translated copies (different IDs/URLs), * and non-builder consumers of builder templates are a known * limitation: purging a template ID purges the template permalink, * not its consumers. Hosts covering those contexts should pass an * explicit post ID or override via the `wppo_is_elementor_page` * filter (return non-null to force a verdict; receives the resolved * post ID as 2nd arg and the raw caller $post_id as 3rd arg for BC). * * Fail direction: detection failure degrades to skip (true) while * Elementor-safe mode is on — uncombined markup costs perf only, * while combining through a detection failure risks broken * Elementor layout/FOUC. With safe mode off, failure returns false * (optimisations run). * * @since 2.2.0 * * @param int|null $post_id Optional post ID (defaults to queried object; * falls back to get_the_ID() only on singular * views so archives/home never inherit a loop * member\'s builder verdict). * @param array $file_opt Optional `file_optimisation` settings slice, * forwarded to is_elementor_safe_mode_active() * in the fail-safe path so a staged * elementorSafeMode=off stays previewable. * @return bool True when this looks like an Elementor-built page. */staticfunctionis_elementor_built_page(?int=null,array=array()):bool{try{=self::resolve_elementor_post_id();if(function_exists(\'has_filter\')&&function_exists(\'apply_filters\')&&has_filter(\'wppo_is_elementor_page\')){try{=apply_filters(\'wppo_is_elementor_page\',null,??,);if(null!==){return(bool);}}catch(\\Throwable){unset();}}=self::is_elementor_safe_mode_active()?\'s1\':\'s0\';=(null!==&&>0?\'post:\'.:\'queried:0\').\':\'.;if(array_key_exists(,self::)){returnself::[];}=self::detect_elementor_built_page(,);self::[]=;return;}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}}/** * Resolve the Elementor post ID from explicit, queried, and loop sources (issue #1259). * * Queried-object ID first; the get_the_ID() loop fallback applies on * singular views only — on archives/home/loop (queried ID 0) * get_the_ID() returns whichever post the loop currently points at, * so inheriting it would skip combine for a whole archive containing * one Elementor post and memoize under a loop-position-dependent key. * Fail-open to null when no ID resolves. * * @since 2.2.0 * * @param int|null $post_id Explicit post ID (or null to resolve). * @return int|null Resolved post ID, or null when unknown. */staticfunctionresolve_elementor_post_id(?int):?int{if(null!==&&>0){return;}if(function_exists(\'get_queried_object_id\')){try{=(int)get_queried_object_id();if(>0){return;}}catch(\\Throwable){unset();}}if(function_exists(\'is_singular\')&&function_exists(\'get_the_ID\')){try{if(is_singular()){=(int)get_the_ID();if(>0){return;}}}catch(\\Throwable){unset();}}returnnull;}/** * Fail-safe verdict for Elementor detection failures (issue #1259). * * Detection failure degrades to skip (true) while safe mode is on — * uncombined markup costs perf only, while combining through a * detection failure risks broken Elementor layout/FOUC. With safe * mode off, failure returns false (optimisations run). * * @since 2.2.0 * * @param array $file_opt Optional `file_optimisation` settings slice. * @return bool Safe-mode-active verdict (true on unreadable settings). */staticfunctionelementor_safe_fallback(array=array()):bool{try{returnself::is_elementor_safe_mode_active();}catch(\\Throwable){unset();returntrue;}}/** * Unmemoized Elementor-built detection (issue #1259). * * Split from is_elementor_built_page() so the memo wrapper stays * trivial. Plugin presence is probed with cheap markers only * (class_exists with autoload disabled, version constants, * builder function markers — zero meta reads), then a SINGLE * `get_post_meta` pair decides the verdict: the tiny * `_elementor_edit_mode` value is checked first and the large * `_elementor_data` blob (100KB–1MB, unserialize + memory spike) * is fetched only on miss, so non-builder pages never pay the * largest meta cost on the combine hot path. The * Critical_CSS::is_elementor_context() precedent is deliberately * not delegated to here: it performs its own meta reads (doubling * memcache/DB payload plus the unserialize cost of the largest * builder meta on the combine hot path) and treats any non-empty * edit mode as a context, while this path requires a strict * `\'builder\' === (string) $edit_mode` comparison so stale or * third-party `_elementor_edit_mode` values never bypass combine. * * The `?elementor-preview` check applies uniformly (not only when * the Critical_CSS class is loaded) so preview URLs behave the * same in full and minimal boots — but only when Elementor looks * active (cheap markers above): a bare preview query var on a * non-Elementor site must not disable combine for any visitor * appending it (perf-only kill-switch otherwise). * * Fail direction: unexpected failure degrades to skip (true) while * safe mode is on (perf-only cost over broken-layout risk). * * @since 2.2.0 * * @param int|null $resolved Resolved post ID (or null when unknown). * @param array $file_opt Optional `file_optimisation` settings slice, * forwarded to is_elementor_safe_mode_active() * in the fail-safe path so a staged * elementorSafeMode=off stays previewable. * @return bool True when this looks like an Elementor-built page. */staticfunctiondetect_elementor_built_page(?int,array=array()):bool{try{=false;try{if(class_exists(\'Elementor\\Plugin\',false)||defined(\'ELEMENTOR_VERSION\')){=true;}elseif(function_exists(\'elementor_pro_load_plugin\')){=true;}}catch(\\Throwable){unset();}if(null!==&&>0&&function_exists(\'get_post_meta\')){try{=get_post_meta(,\'_elementor_edit_mode\',true);if(\'builder\'===(string)){returntrue;}=get_post_meta(,\'_elementor_data\',true);if(!empty()){returntrue;}}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}if(!){returnfalse;}}if(&&isset([\'elementor-preview\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}}/** * Whether combine/inline must be skipped for this request (issue #1259). * * True only when Elementor-safe mode is on AND the current page is * Elementor-built. * * Fail direction: detection failure degrades to skip (true) while * safe mode is on — uncombined markup costs perf only, while * combining through a detection failure risks broken Elementor * layout/FOUC. With safe mode off, failure returns false. * * Single-post scope (inherited from is_elementor_built_page()): pass * an explicit $post_id for archive/loop/Theme Builder contexts where * the queried object is not the Elementor-built post. * * @since 2.2.0 * * @param array $file_optimisation Optional `file_optimisation` settings slice. * @param int|null $post_id Optional post ID forwarded to is_elementor_built_page(). * @return bool True when combine/inline must be skipped. */staticfunctionshould_skip_combine_for_elementor(array=array(),?int=null):bool{try{if(!self::is_elementor_safe_mode_active()){returnfalse;}returnself::is_elementor_built_page(,);}catch(\\Throwable){unset();returnself::elementor_safe_fallback();}}/** * One-time backfill for automatic LCP hero preload + font discovery (issue #1216). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_preload_auto_defaults():void{->migrations()->migrate_preload_auto_defaults();}/** * Backfill the additive Redis outage status flag (issue #1233). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_object_cache_outage_flag():void{->migrations()->migrate_object_cache_outage_flag();}/** * One-time backfill for RUM-segmented speculation auto-tune (issue #1425). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.3.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ai_speculation_autotune():void{->migrations()->migrate_ai_speculation_autotune();}/** * One-time backfill for anomaly detector v2 keys (issue #1313). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.3.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_ai_anomaly_v2():void{->migrations()->migrate_ai_anomaly_v2();}/** * One-time backfill for comment-image hardening (issue #1271). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_comment_image_hardening():void{->migrations()->migrate_comment_image_hardening();}/** * Backfill the additive builder purge watcher keys (issue #1288). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_builder_watcher():void{->migrations()->migrate_builder_watcher();}/** * Backfill the additive auto third-party delay key (issue #1314). * * Facade proxy (ARCH-004): logic lives in {@see Settings_Migrations}; * `Hook_Registry` hook registrations stay byte-identical. * * @since 2.2.0 * @since 2.4.0 Proxied to Settings_Migrations (ARCH-004). * @return void */functionmaybe_migrate_third_party_auto():void{->migrations()->migrate_third_party_auto();}/** * Automatically try to fix WP_CACHE if it is missing or disabled. * * Runs on admin_init. * * @return void */functionmaybe_fix_wp_cache():void{if(defined(\'WP_CACHE\')&&WP_CACHE){return;}if(get_transient(Util::transient_key(\'wppo_wp_cache_fix_checked\'))){return;}=Activate::add_wp_cache_constant();set_transient(Util::transient_key(\'wppo_wp_cache_fix_checked\'),1,HOUR_IN_SECONDS);if(!empty()){=get_transient(Util::transient_key(\'wppo_activation_notices\'));=is_array()?:array();=array_unique(array_merge(,(array)));set_transient(Util::transient_key(\'wppo_activation_notices\'),,30);}}/** * Runs one-time upgrade routines after a plugin update. * * Routine plugin updates never fire register_activation_hook, so this is * triggered on admin_init. Activate::maybe_run_upgrades() exits early once * the stored plugin version has reached the migration floor. * * @return void * @since 1.9.0 */functionmaybe_run_upgrades():void{if(!current_user_can(\'manage_options\')){return;}Activate::maybe_run_upgrades();}/** * Schedule the upgrade routine in the background after a plugin update. * * Fires on upgrader_process_complete when this plugin was updated, giving * sites updated via WP-CLI, background auto-updates, or managed-hosting * pipelines a reliable trigger that does not depend on an admin visit. * * @param object $upgrader The upgrader instance (unused). * @param array $hook_extra Extra arguments passed to the hook. * @return void * @since 1.9.0 */functionmaybe_schedule_upgrade_routine(,):void{if(empty()||!is_array()){return;}=\'performance-optimisation/performance-optimisation.php\';if(!empty([\'plugin\'])&&===[\'plugin\']){Activate::schedule_upgrade_routine();return;}if(!empty([\'plugins\'])&&is_array([\'plugins\'])&&in_array(,[\'plugins\'],true)){Activate::schedule_upgrade_routine();}}/** * Run one-time upgrade routines when the plugin version changes. * * Regenerates the advanced-cache.php drop-in so it honours the * DONOTCACHEPAGE no-cache marker, then clears the full cache once to * remove any pre-existing stale pages the old drop-in would keep serving. * Runs on admin_init and upgrader_process_complete (covering admin-initiated * and CLI updates); the one-time wppo_version gate keeps it idempotent. * * @return void * @since 1.9.0 */functionmaybe_run_version_upgrade():void{if(!current_user_can(\'manage_options\')){return;}=get_option(\'wppo_version\',\'\');if(version_compare((string),WPPO_VERSION,\'>=\')){return;}if(!Advanced_Cache_Handler::create()){return;}if(!Advanced_Cache_Handler::foreign_dropin_present()){Cache::clear_cache();}try{global;if(isset(->options)){=->query(->prepare(\"UPDATE {->options} SET autoload = %s WHERE option_name = %s AND autoload NOT IN (%s, %s)\",\'no\',\'wppo_settings\',\'no\',\'off\'));if(false===){return;}}}catch(\\Throwable){unset();return;}update_option(\'wppo_version\',WPPO_VERSION,false);}/** * Callback for when plugin settings are updated. * * Drops the {@see self::get_options()} memo on the live instance so * same-request post-save reads re-resolve (with backfills) instead of * serving the pre-save snapshot. Covers every save path (REST, CLI, * `Util::save_settings()`) because all of them persist via * `update_option( \'wppo_settings\' )`, which fires this hook. * * @param mixed $old_value The old option value. * @param mixed $value The new option value. * @since 1.2.0 * @since 2.4.0 Invalidates the get_options() memo on the live instance. */staticfunctionon_settings_update(,){Settings_Store::invalidate_resolved_settings();if(null!==self::){self::->refresh_options();}=array(\'cache_settings\',\'file_optimisation\',\'image_optimisation\',\'preload_settings\',\'core_tweaks\');=array(\'database_cleanup\',\'object_cache\',\'performance_audit\');=false;=false;foreach(as){=isset([])?[]:null;=isset([])?[]:null;if(!==){=true;break;}}if(!){foreach(as){=isset([])?[]:null;=isset([])?[]:null;if(!==){=true;break;}}}if(){self::clear_all_cache();Advanced_Cache_Handler::create();}elseif(){Cache::flush_runtime();}=[\'image_optimisation\']??array();=[\'image_optimisation\']??array();if(!==){Img_Converter::invalidate_img_info_cache();}=[\'performance_audit\']??array();=[\'performance_audit\']??array();if(!==){Telemetry::invalidate_audit_cache();}=isset([\'file_optimisation\'][\'enableServerRules\'])?(bool)[\'file_optimisation\'][\'enableServerRules\']:false;=isset([\'file_optimisation\'][\'enableServerRules\'])?(bool)[\'file_optimisation\'][\'enableServerRules\']:false;=isset([\'litespeed_integration\'][\'enableNextGenRewrite\'])?(bool)[\'litespeed_integration\'][\'enableNextGenRewrite\']:false;=isset([\'litespeed_integration\'][\'enableNextGenRewrite\'])?(bool)[\'litespeed_integration\'][\'enableNextGenRewrite\']:false;=!==;=isset([\'image_optimisation\'][\'convertImg\'])?(bool)[\'image_optimisation\'][\'convertImg\']:false;=isset([\'image_optimisation\'][\'convertImg\'])?(bool)[\'image_optimisation\'][\'convertImg\']:false;=!==;=class_exists(\'PerformanceOptimise\\Inc\\Server_Rules\')&&method_exists(\'PerformanceOptimise\\Inc\\Server_Rules\',\'should_skip_htaccess_write\')&&Server_Rules::should_skip_htaccess_write();if(!==){=?true:Htaccess_Handler::update_rules();if(&&&&class_exists(\'PerformanceOptimise\\Inc\\LiteSpeed_Integration\')&&LiteSpeed_Integration::is_litespeed()){Log::add(__(\'Server rules updated on LiteSpeed — restart OpenLiteSpeed if changes do not appear immediately.\',\'performance-optimisation\'));}if(!){[\'file_optimisation\'][\'enableServerRules\']=;remove_action(\'update_option_wppo_settings\',array(__CLASS__,\'on_settings_update\'),10);Settings_Command::save();add_action(\'update_option_wppo_settings\',array(__CLASS__,\'on_settings_update\'),10,2);add_action(\'admin_notices\',array(__CLASS__,\'render_htaccess_failure_notice\'));}}elseif((||)&&){=?true:Htaccess_Handler::update_rules(true);if(&&class_exists(\'PerformanceOptimise\\Inc\\LiteSpeed_Integration\')&&LiteSpeed_Integration::is_litespeed()){Log::add(__(\'Server rules updated on LiteSpeed — restart OpenLiteSpeed if changes do not appear immediately.\',\'performance-optimisation\'));}if(!){add_action(\'admin_notices\',array(__CLASS__,\'render_htaccess_failure_notice\'));}}=[\'file_optimisation\'][\'hostGoogleFontsLocally\']??false;=[\'file_optimisation\'][\'hostGoogleFontsLocally\']??false;=[\'file_optimisation\'][\'fontSubset\']??false;=[\'file_optimisation\'][\'fontSubset\']??false;=[\'file_optimisation\'][\'fontSubsetSubsets\']??\'latin\';=[\'file_optimisation\'][\'fontSubsetSubsets\']??\'latin\';if(!==||!==||!==){Google_Fonts::clear_font_cache();}}/** * Render the admin notice for a failed .htaccess rules update. * * Shared by the enable/disable and next-gen-refresh branches so the * message and ARIA contract cannot drift. `role=\"alert\"` + * `aria-live=\"assertive\"` announce the failure immediately, matching * the React NoticeBanner contract used across the SPA. * * @since 2.0.0 * @return void */staticfunctionrender_htaccess_failure_notice():void{echo\'<div class=\"notice notice-error is-dismissible\" role=\"alert\" aria-live=\"assertive\"><p>\'.esc_html__(\'Performance Optimisation: Failed to update .htaccess rules. Please check file permissions.\',\'performance-optimisation\').\'</p></div>\';}/** * Clear the entire plugin cache. * * Called when structural changes (permalink update, theme switch, etc.) * invalidate all cached pages. * * @since 1.1.0 */staticfunctionclear_all_cache(){Cache::clear_cache();}/** * Auto-purge minify + page cache after any plugin/theme update. * * Hooks `upgrader_process_complete` (priority 20, after the builder * watcher). Only fires for `action=update` + `type=plugin|theme`; * every other upgrade path (core, translation, install) is ignored. * Fail-open: purge failure degrades to the current manual-clear * behavior and is never fatal. * * @since 2.2.0 * @param mixed $upgrader Upgrader instance (unused). * @param mixed $hook_extra Update context (action/type/plugin/plugins/theme/themes). * @return void */staticfunctionon_extension_update(=null,=null):void{unset();try{if(!is_array()){return;}if(\'update\'!==([\'action\']??\'\')){return;}if(!in_array(([\'type\']??\'\'),array(\'plugin\',\'theme\'),true)){return;}=!empty([\'plugin\'])||!empty([\'plugins\'])||!empty([\'theme\'])||!empty([\'themes\'])||!empty([\'bulk\']);if(!){return;}self::clear_all_cache();try{=\'theme\'===([\'type\']??\'\');if(&&class_exists(\'PerformanceOptimise\\Inc\\Used_CSS\')&&method_exists(\'PerformanceOptimise\\Inc\\Used_CSS\',\'request_targeted_regen\')){Used_CSS::request_targeted_regen(\'theme-update\');}}catch(\\Throwable){unset();}}catch(\\Throwable){unset();}}/** * Queue a bounded targeted used-CSS regen after a theme switch (issue #1220). * * Runs alongside clear_all_cache on `switch_theme`. Cooldown-gated * inside Used_CSS::request_targeted_regen(); no-op when * removeUnusedCSS is off or Action Scheduler is unavailable. * Fail-open: never throws. * * @param string $new_name New theme name (unused). * @param mixed $new_theme New theme object (unused). * @param mixed $old_theme Old theme object (unused). * @return void * @since 2.2.0 */staticfunctionon_theme_switch_used_css(=\'\',=null,=null):void{unset(,,);try{if(class_exists(\'PerformanceOptimise\\Inc\\Used_CSS\')&&method_exists(\'PerformanceOptimise\\Inc\\Used_CSS\',\'request_targeted_regen\')){Used_CSS::request_targeted_regen(\'theme-switch\');}}catch(\\Throwable){unset();}}/** * Regenerate the advanced-cache.php drop-in when the home or site URL * changes (domain migration). * * The canonical host is baked into the drop-in at create() time; without * a re-bake every request would mismatch the stale host and silently * run uncached. No cache clear here — the old-domain files are keyed * under a different host directory and simply stop being served. * * @param mixed $old_value Previous option value. * @param mixed $value New option value. * @param string $option Option name. * @return bool True when the drop-in is left in a correct state, false on * filesystem failure. Skipped (unchanged value, or * scheme/path-only change with an identical host) returns true. * @since 2.0.0 */staticfunctionon_site_url_change(=null,=null,=\'\'):bool{if(===){returntrue;}if(function_exists(\'wp_parse_url\')){=Util::normalize_cache_host((string)wp_parse_url((string),PHP_URL_HOST));=Util::normalize_cache_host((string)wp_parse_url((string),PHP_URL_HOST));if(\'\'!==&&===){returntrue;}}if(!Advanced_Cache_Handler::create()){do_action(\'wppo_debug_log\',\'WPPO advanced-cache.php drop-in regeneration failed after \'..\' change\');returnfalse;}returntrue;}/** * Process a single image conversion in the background via Action Scheduler. * * @param array $args { source_path, format } for the image to convert. * @since 1.1.0 */functionprocess_background_image(){if(empty([\'source_path\'])||empty([\'format\'])){return;}=Util::get_settings();=newImg_Converter();=wp_normalize_path([\'source_path\']);=sanitize_text_field([\'format\']);if(method_exists(\'PerformanceOptimise\\Inc\\Img_Converter\',\'is_path_in_allowlist\')&&!Img_Converter::is_path_in_allowlist()){return;}if(file_exists()){->convert_image(,);}}/** * Invalidate cache on save_post, skipping revisions and autosaves. * * @param int $post_id Post ID. * @param \\WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * @since 2.0.0 */functionon_save_post_invalidate_cache(,,){if(wp_is_post_revision()||wp_is_post_autosave()){return;}=null;if(is_object()&&isset(->post_type)){=->post_type;}elseif(function_exists(\'get_post_type\')){=get_post_type();}=null;if(\'product\'===){=\'product\';}elseif(\'product_variation\'===){=0;if(is_object()&&isset(->post_parent)){=(int)->post_parent;}elseif(function_exists(\'wp_get_post_parent_id\')){try{=(int)wp_get_post_parent_id();}catch(\\Throwable){unset();=0;}}if(>0){=\'product\';=;}}elseif(\'shop_order\'===||\'shop_order_placehold\'===||(function_exists(\'wc_get_order_types\')&&in_array(,(array)wc_get_order_types(),true))){=\'order\';}elseif(\'shop_coupon\'===){=\'coupon\';}if(null!==&&->cache&&method_exists(->cache,\'invalidate_woo_object\')){->cache->invalidate_woo_object((int),);if(\'order\'===||\'coupon\'===){return;}if(\'product\'===){return;}}elseif(->cache){->cache->invalidate_dynamic_static_html();}->preload_buffer_coordinator->queue_crawler_warm_after_cache_invalidation((int));}/** * Surgically invalidate cache when a WooCommerce product is updated. * * @param int $product_id Product ID. * @return void * @since 2.0.0 */functionon_woocommerce_product_updated():void{if(!->cache||!method_exists(->cache,\'invalidate_woo_object\')){return;}->cache->invalidate_woo_object((int),\'product\');}/** * Surgically invalidate cache when a WooCommerce order is created/updated. * * Accepts either an order ID or a WC_Order object (hook signatures * differ between `woocommerce_checkout_order_created` and * `woocommerce_update_order` across WC versions). * * @param mixed $order Order ID or WC_Order object. * @return void * @since 2.0.0 */functionon_woocommerce_order_changed():void{if(!->cache||!method_exists(->cache,\'invalidate_woo_object\')){return;}=;if(is_object()&&method_exists(,\'get_id\')){try{=->get_id();}catch(\\Throwable){unset();return;}}=(int);if(<=0){return;}->cache->invalidate_woo_object(,\'order\');}/** * Surgically invalidate cache when a WooCommerce coupon is saved. * * @param mixed $coupon Coupon ID or WC_Coupon object. * @return void * @since 2.0.0 */functionon_woocommerce_coupon_saved():void{if(!->cache||!method_exists(->cache,\'invalidate_woo_object\')){return;}=;if(is_object()&&method_exists(,\'get_id\')){try{=->get_id();}catch(\\Throwable){unset();return;}}=(int);if(<=0){return;}->cache->invalidate_woo_object(,\'coupon\');}/** * Queue used-CSS generation when post content is saved. * * Skips revisions and autosaves, and checks the removeUnusedCSS setting * before enqueueing. Uses atomic unique enqueue (issue #1310) to * prevent duplicate jobs, with the legacy as_has_scheduled_action() * guard as fallback. * * @param int $post_id Post ID. * @param \\WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * @return void * @since 1.9.0 */functionon_save_post_queue_used_css(,,){if(wp_is_post_revision()||wp_is_post_autosave()){return;}if(class_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\')&&method_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\',\'maybe_regen_on_save\')){try{\\PerformanceOptimise\\Inc\\Critical_CSS::maybe_regen_on_save(,);}catch(\\Throwable){unset();}}->preload_buffer_coordinator->queue_used_css_regeneration((int));}/** * Refresh the matching critical-CSS template after an LCP-triggered used-CSS refresh. * * In-repo consumer for the `wppo_ai_css_refresh_queued` action fired by * AI_Adaptive::maybe_queue_css_refresh() (issue #1407): maps the queued * post to its coarse template (`home` for the front page, `page` for * pages, `single` otherwise) and regenerates that single template via * Critical_CSS::regenerate_single(). Fail-open: any failure (unknown * template, missing scheduler, throwable) is swallowed so the used-CSS * job that already queued is never affected. * * @param string $url regressed URL. * @param int $post_id Queued post ID. * @param array $anomaly The firing LCP anomaly. * @return void * @since 2.3.0 */functionon_ai_css_refresh_queued(,,){try{=(int);if(<=0){return;}if(!class_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\')||!method_exists(\'PerformanceOptimise\\Inc\\Critical_CSS\',\'regenerate_single\')){return;}=\'single\';if(is_string()&&\'\'!==&&class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')&&method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'is_homepage_url\')){try{if(AI_Adaptive::is_homepage_url()){=\'home\';}}catch(\\Throwable){unset();}}if(\'home\'!==&&function_exists(\'get_post_type\')){try{if(\'page\'===get_post_type()){=\'page\';}}catch(\\Throwable){unset();}}try{\\PerformanceOptimise\\Inc\\Critical_CSS::regenerate_single();}catch(\\Throwable){unset();}}catch(\\Throwable){unset();}}/** * Process used-CSS when cache is disabled. * * @param string $filtered_output The filtered output from previous callbacks. * @param string $output The raw output buffer content. * @return string The processed output. * @since 1.9.0 */functionprocess_used_css_only(,){return->preload_buffer_coordinator->process_used_css_only(,);}/** * Whether the Server-Timing debug header is enabled. * * Reads the performance_audit.server_timing_enabled setting (default false, off). * Operators may override it via the wppo_server_timing_enabled filter, e.g. to * restrict emission to logged-in administrators with manage_options capability. * * Enabling forces the template-enhancement output buffer via * wp_finalized_template_enhancement_output_buffer registration in * {@see Main::setup_hooks()} (priority 1000 by default), which disables * response streaming / early flush. TTFB increases while TTLB unchanged — * intentional when Server-Timing is active; keep disabled by default and * emit only on cache-miss generation passes. Cached hits served by * advanced-cache.php never boot WordPress so the header never appears there. * Paired with {@see Main::capture_template_start()} / * {@see Main::emit_server_timing_header()}. * * @since 1.9.0 * @since 2.2.0 Streaming tradeoff note and cross-reference. * @return bool True when Server-Timing telemetry is active. */functionserver_timing_enabled():bool{=!empty(->get_options()[\'performance_audit\'][\'server_timing_enabled\']??false);return(bool)apply_filters(\'wppo_server_timing_enabled\',);}/** * Capture the template render start time for Server-Timing telemetry. * * Records microtime(true) at template_redirect:0 before the template is * included, for later duration calculation in * {@see Main::emit_server_timing_header()}. Early bail for admin, AJAX, * REST, or when {@see Main::server_timing_enabled()} is false; stores * the timestamp in {@see Main::$server_timing_template_start}. Paired * with emit_server_timing_header() on * wp_finalized_template_enhancement_output_buffer. * * @since 1.9.0 * @since 2.0.0 Expanded documentation for buffering opt-in context. * @return void */functioncapture_template_start():void{if(!->server_timing_enabled()||is_admin()||wp_doing_ajax()||(defined(\'REST_REQUEST\')&&REST_REQUEST)){return;}->server_timing_template_start=microtime(true);}/** * Emit a Server-Timing response header on live front-end renders (WP 6.9+). * * Hook: wp_finalized_template_enhancement_output_buffer (also wp_send_late_headers * alias) — WP 6.9 canonical late-header spot before flush. WP 6.9 standardised * the former ad-hoc ob_start() at template_redirect / template_include into * wp_should_output_buffer_template_for_enhancement() / * wp_start_template_enhancement_output_buffer() / * wp_finalize_template_enhancement_output_buffer() with filter * wp_template_enhancement_output_buffer and action * wp_finalized_template_enhancement_output_buffer ($final) (Trac #64126 / #63636 * / #43258; Performance Lab #2225/#2515), try/catch wrapped with WP_DEBUG_DISPLAY * on error. * * Performance Lab interop: when the Performance Lab Server-Timing module is * active it owns the Server-Timing header and its default metrics surface as * `wp-before-template`, `wp-template` and `wp-total` (Performance Lab prefixes * every registered metric slug with `wp-`). Emitting our own raw header with * the same metric names would produce duplicate/conflicting entries in * DevTools, so: * * - Performance Lab with output buffering enabled already measures * before-template + template + total from the same underlying timestamps * (timestart / template render window), so our emission is suppressed * entirely and Performance Lab\'s single header carries the data. * - Performance Lab without output buffering sends its header at * template_include (before the template renders) and only carries * `wp-before-template`; the `wp-template` name stays unclaimed, so only * the template render duration is emitted as a distinct appended entry — * the duplicate `wp-before-template` is dropped. When the buffering-state * helper (`perflab_server_timing_use_output_buffer()`) is absent the * buffering mode is unknown and the emission is suppressed instead. * * When Performance Lab is inactive nothing changes: both * `wp-before-template` and `wp-template` are emitted as before. * * Param $output ($final) is the final HTML string passed by Core to the action * (not the filtered value); reserved for future ETag hashing without re-registration * and currently unused. * * Header must be sent via header(\'Server-Timing: ...\', false) before flush; the * second arg false appends to preserve coexisting metrics. Guards headers_sent() * and null === System_Info::get_request_start_microtime() before emitting. Core * wraps this action in try/catch and appends WP_DEBUG_DISPLAY on error, so the * plugin does not add an extra try/catch. * * Streaming tradeoff: registering this action automatically opts into the * template-enhancement buffer (priority 1000 by default), which disables response * streaming / early flush. TTFB increases while TTLB unchanged — intentional when * Server-Timing is enabled; keep disabled by default and emit only on cache-miss * generation passes (advanced-cache.php serves cached pages without booting * WordPress). * * No ETag / 304 computation here — conditional GET (If-Modified-Since / * If-None-Match → 304 with ETag / Last-Modified) is already handled in the * Advanced_Cache_Handler drop-in (advanced-cache.php). * * @param string $output The finalized output buffer content (final HTML string, alias $final). * @return void * @since 2.0.0 Performance Lab Server-Timing interop: defer to the * Performance Lab-owned header (no duplicate/conflicting * metric names); {@see is_pl_server_timing_active()}. */functionemit_server_timing_header(string=\'\'):void{if(!->server_timing_enabled()||is_admin()||wp_doing_ajax()||(defined(\'REST_REQUEST\')&&REST_REQUEST)){return;}if(headers_sent()){return;}=System_Info::get_request_start_microtime();if(null===){return;}=microtime(true);=\'\';if(->server_timing_template_start>){=\'wp-before-template;dur=\'.round((->server_timing_template_start-)*1000,2);}=\'\';if(->server_timing_template_start>0){=(-->server_timing_template_start)*1000;if(>0){=\'wp-template;dur=\'.round(,2);}}if(\'\'===&&\'\'===){return;}if(->is_pl_server_timing_active()){if(!function_exists(\'perflab_server_timing_use_output_buffer\')||perflab_server_timing_use_output_buffer()){return;}if(\'\'===){return;}header(\'Server-Timing: \'.,false);return;}header(\'Server-Timing: \'.implode(\', \',array_filter(array(,))),false);}/** * Whether the Performance Lab Server-Timing module is active. * * Detects the canonical Performance Lab Server-Timing API surface * (`perflab_server_timing_register_metric()` / `perflab_wrap_server_timed_call()`). * Performance Lab is a plugin (not core), so this is a function_exists * gate with no WordPress version check. Used by * {@see emit_server_timing_header()} to defer to the Performance Lab-owned * Server-Timing header instead of emitting a second, conflicting one. * * @since 2.0.0 * @return bool True when the Performance Lab Server-Timing API is present. */functionis_pl_server_timing_active():bool{returnfunction_exists(\'perflab_server_timing_register_metric\')||function_exists(\'perflab_wrap_server_timed_call\');}/** * Start output buffer for used-CSS (legacy path, WP &lt; 6.9). * * Tracked by #829: do not remove until minimum supported WP is raised * to 6.9 (`Requires at least: 6.9`). * * @return void * @since 1.9.0 */functionstart_used_css_buffer(){->preload_buffer_coordinator->start_used_css_buffer();}/** * Start output buffer for LCP image prioritization (legacy path, WP &lt; 6.9). * * Tracked by #829: do not remove until minimum supported WP is raised * to 6.9 (`Requires at least: 6.9`). * * Registers at priority 20, after the cache and used-CSS buffers (default * priority 10), so its inner buffer callback runs first on the raw buffer * and the cache callback then stores the LCP-enhanced HTML. The callback * no-ops when the feature is disabled, the buffer is empty, the request is * non-HTML (feeds, robots, AJAX, REST), or the user is not eligible. * * @return void * @since 1.9.0 */functionstart_lcp_priority_buffer(){->preload_buffer_coordinator->start_lcp_priority_buffer();}/** * Capture and process buffer for used-CSS. * * Wrapped in try/catch so an unexpected failure inside the used-CSS or * Google Fonts pipeline can never throw out of the output-buffer * callback: the callback must always return a string or the buffered * page output would be lost/corrupted when the buffer is closed * (audit #888 finding 12 — balanced buffer lifecycle). * * @param string $buffer The output buffer content. * @return string The processed buffer. * @since 1.9.0 */functionprocess_used_css_capture(){return->preload_buffer_coordinator->process_used_css_capture();}/** * Initialize the admin menu. * * Adds the Performance Optimisation menu to the WordPress admin dashboard. * * @return void * @since 1.0.0 */functioninit_menu():void{add_menu_page(__(\'Performance Optimisation\',\'performance-optimisation\'),__(\'Performance Optimisation\',\'performance-optimisation\'),\'manage_options\',\'performance-optimisation\',array(,\'admin_page\'),\'dashicons-admin-post\',\'2.1\',);}/** * Display the admin page. * * Includes the admin page template for rendering. * * @return void * @since 1.0.0 */functionadmin_page():void{require_onceWPPO_PLUGIN_PATH.\'templates/app.html\';}/** * Add available post types to options. * * Filters out non-public post types and adds the available post types to options. * * @return void * @since 1.0.0 */functionadd_available_post_types_to_options(){=get_post_types(array(\'public\'=>true),\'names\');if(!is_array()){=array();}=array(\'attachment\');=array_keys(array_diff(,));->options[\'image_optimisation\'][\'availablePostTypes\']=;}/** * Extract the active frontend theme\'s primary color. * * Checks block theme (theme.json) first, then classic theme (customizer). * * @since 2.0.0 * @return array{primary?: string, secondary?: string, text?: string} */functionget_frontend_theme_colors():array{=array(\'primary\'=>\'\',\'secondary\'=>\'\',\'text\'=>\'\',);if(function_exists(\'wp_get_global_settings\')){=wp_get_global_settings();=[\'color\'][\'palette\'][\'theme\']??array();foreach(as){=sanitize_title([\'slug\']??\'\');=sanitize_hex_color([\'color\']??\'\');if(!){continue;}if(in_array(,array(\'primary\',\'brand\',\'accent\'),true)){[\'primary\']=;}elseif(in_array(,array(\'secondary\',\'secondary-brand\'),true)){[\'secondary\']=;}elseif(in_array(,array(\'foreground\',\'contrast\',\'body-text\'),true)){[\'text\']=;}}}if(empty([\'primary\'])){=get_theme_mod(\'primary_color\',\'\');if(empty()){=get_theme_mod(\'accent_color\',\'\');}if(!empty()){[\'primary\']=sanitize_hex_color();}}if(empty([\'text\'])){=get_header_textcolor();if(\'blank\'!==&&!empty()){[\'text\']=\'#\'.ltrim(sanitize_hex_color_no_hash(),\'#\');}}returnarray_filter();}/** * Enqueue admin scripts and styles. * * Loads CSS and JavaScript files for the admin dashboard page. * * @return void * @since 1.0.0 */functionadmin_enqueue_scripts():void{=get_current_screen();if(!||\'toplevel_page_performance-optimisation\'!==->base){return;}->enqueue_admin_bar_script();=WPPO_PLUGIN_PATH.\'build/index.asset.php\';=wp_normalize_path(realpath());if(false!==&&0===strpos(,(string)WPPO_PLUGIN_PATH)){=require;}else{=array(\'dependencies\'=>array(),\'version\'=>false,);}wp_enqueue_style(\'performance-optimisation-style\',WPPO_PLUGIN_URL.\'build/style-index.css\',array(),[\'version\'],\'all\');wp_enqueue_script(\'performance-optimisation-script\',WPPO_PLUGIN_URL.\'build/index.js\',[\'dependencies\'],[\'version\'],true);->add_available_post_types_to_options();=Cache::get_cache_stats();=isset([\'size\'])?(string)[\'size\']:__(\'N/A\',\'performance-optimisation\');=Util::cache_salt(\'wppo_cache_last_cleared\');if(function_exists(\'wp_cache_get_salted\')&&function_exists(\'wp_using_ext_object_cache\')&&wp_using_ext_object_cache()){=wp_cache_get_salted(\'wppo_total_js_css\',\'wppo\',);if(false===){=Util::get_js_css_minified_file();wp_cache_set_salted(\'wppo_total_js_css\',,\'wppo\',,15*MINUTE_IN_SECONDS);}}else{=get_transient(Util::transient_key(\'wppo_total_js_css\'));if(false===){=Util::get_js_css_minified_file();set_transient(Util::transient_key(\'wppo_total_js_css\'),,15*MINUTE_IN_SECONDS);}}=0;try{if(class_exists(\'PerformanceOptimise\\Inc\\Cache\')&&method_exists(\'PerformanceOptimise\\Inc\\Cache\',\'get_cache_stats\')){if(function_exists(\'wp_cache_get_salted\')&&function_exists(\'wp_using_ext_object_cache\')&&wp_using_ext_object_cache()){=wp_cache_get_salted(\'wppo_cache_stats\',\'wppo\',);if(is_array()&&isset([\'count\'])){=(int)[\'count\'];}else{=Cache::get_cache_stats();=(int)([\'cached_pages\']??0);}}else{=get_transient(Util::transient_key(\'wppo_cache_count\'));if(false!==&&is_numeric()){=(int);}else{=Cache::get_cache_stats();=(int)([\'cached_pages\']??0);}}}}catch(\\Throwable){unset();=0;}=->get_options();if(isset([\'performance_audit\'][\'pagespeed_api_key\'])){unset([\'performance_audit\'][\'pagespeed_api_key\']);}if(isset([\'object_cache\'][\'password\'])){unset([\'object_cache\'][\'password\']);}=class_exists(\'PerformanceOptimise\\Inc\\Img_Converter\')?Img_Converter::get_img_info():get_option(\'wppo_img_info\',array());=->sanitize_image_info_for_client((array));wp_localize_script(\'performance-optimisation-script\',\'wppoSettings\',array(\'apiUrl\'=>get_rest_url(null,\'performance-optimisation/v1/\'),\'ajaxUrl\'=>admin_url(\'admin-ajax.php\'),\'nonce\'=>wp_create_nonce(\'wp_rest\'),\'nonce_refresh\'=>wp_create_nonce(\'wppo_nonce_refresh\'),\'version\'=>WPPO_VERSION,\'settings\'=>,\'show_welcome\'=>!(bool)get_user_meta(get_current_user_id(),\'wppo_welcome_dismissed\',true),\'image_info\'=>,\'cache_size\'=>,\'cache_count\'=>,\'total_js_css\'=>,\'client_side_media_processing_enabled\'=>function_exists(\'wp_is_client_side_media_processing_enabled\')&&wp_is_client_side_media_processing_enabled(),\'performance_audit\'=>array(\'homeUrl\'=>Util::cached_home_url(\'/\'),\'pagespeedApiKeyConfigured\'=>!empty(->get_options()[\'performance_audit\'][\'pagespeed_api_key\']),\'highValueUrls\'=>->get_options()[\'performance_audit\'][\'high_value_urls\']??array(),\'autoFixEnabled\'=>(bool)(->get_options()[\'performance_audit\'][\'auto_fix_enabled\']??false),\'autoRescan\'=>->get_options()[\'performance_audit\'][\'auto_rescan\']??\'\',),\'themeColors\'=>->get_frontend_theme_colors(),\'userRoles\'=>->get_editable_role_names(),\'speculation_rules\'=>array(\'mode_override\'=>->get_speculation_default_override(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'),\'eagerness_override\'=>->get_speculation_default_override(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'),\'static_cache_active\'=>!empty(->get_options()[\'cache_settings\'][\'enableCache\']),),\'litespeed\'=>class_exists(\'PerformanceOptimise\\Inc\\LiteSpeed_Integration\')?LiteSpeed_Integration::get_info():array(\'detected\'=>false,\'server_type\'=>Server_Rules::get_server_type(),\'lscache_active\'=>false,\'mode\'=>\'auto\',\'effective_mode\'=>\'standalone\',\'wppo_owns_cache\'=>true,\'optimizer_disabled\'=>false,),\'allowedSettingsKeys\'=>Util::ALLOWED_SETTINGS_KEYS,\'presetBundles\'=>array(\'safe\'=>self::get_safe_preset_bundle(),\'aggressive\'=>self::get_aggressive_preset_bundle(),),\'upgradePurge\'=>->get_upgrade_purge_for_client(),),);wp_set_script_translations(\'performance-optimisation-script\',\'performance-optimisation\');}/** * Enqueues scripts for performance optimization. * * @since 1.0.0 */functionenqueue_scripts(){if(is_admin_bar_showing()&&current_user_can(\'manage_options\')){->enqueue_admin_bar_script();}if(->should_optimise_for_logged_in()){=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadImages\']);=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadBackgroundImages\']);=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadVideos\']);=!empty(->get_options()[\'image_optimisation\'][\'enableVideoPlaceholder\'])&&;=!empty(->get_options()[\'file_optimisation\'][\'delayJS\']);=!empty(->get_options()[\'image_optimisation\'][\'lazyLoadNative\']);=(!&&)||||||||;if(){=array();if(){[\'nativeLazy\']=true;}[\'videoPlayerLabel\']=__(\'Video player\',\'performance-optimisation\');if(){=!empty(->get_options()[\'file_optimisation\'][\'delayJSIdleTimeout\'])?absint(->get_options()[\'file_optimisation\'][\'delayJSIdleTimeout\']):3000;=in_array(->delay_js_default_strategy,array(\'interaction\',\'idle\',\'viewport\'),true)?->delay_js_default_strategy:\'interaction\';[\'delayConfig\']=array(\'idleTimeout\'=>,\'defaultStrategy\'=>,);=apply_filters(\'wppo_delay_js_allowed_hosts\',array());if(!empty()){=array();foreach((array)as){if(!is_string()){continue;}=trim();if(\'\'===){continue;}if(\'*\'===){[]=\'*\';continue;}if(0===strpos(,\'//\')){=\'https:\'.;}if(false!==strpos(,\'://\')){=wp_parse_url(,PHP_URL_HOST);if(!is_string()||\'\'===){continue;}=;}=trim();if(0===strpos(,\'[\')){=strpos(,\']\');if(false===){continue;}=substr(,+1);if(\'\'!==&&1!==preg_match(\'/^:\\d+$/\',)){continue;}=substr(,1,-1);}elseif(1===preg_match(\'/^(.*):(\\d+)$/\',,)&&false===strpos([1],\':\')){=[1];}=sanitize_text_field(strtolower(trim(,\'.\')));if(\'\'===){continue;}if(function_exists(\'filter_var\')&&filter_var(,FILTER_VALIDATE_IP)){[]=;continue;}=;if(function_exists(\'idn_to_ascii\')&&defined(\'INTL_IDNA_VARIANT_UTS46\')&&defined(\'IDNA_DEFAULT\')&&1===preg_match(\'/[^\\x00-\\x7F]/\',)){=idn_to_ascii(,IDNA_DEFAULT,INTL_IDNA_VARIANT_UTS46);if(!is_string()||\'\'===){continue;}=strtolower();}if(1!==preg_match(\'/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\',)){continue;}[]=;}if(!empty()){[\'delayConfig\'][\'allowedScriptHosts\']=array_values(array_unique());}}}=self::supports_native_script_fetchpriority();=array(\'in_footer\'=>true,\'fetchpriority\'=>\'low\',);if(){if(function_exists(\'wp_enqueue_script_module\')){wp_enqueue_script_module(\'wppo-lazyload\',WPPO_PLUGIN_URL.\'build/lazyload.js\',array(),WPPO_VERSION,);}elseif(function_exists(\'wp_register_script_module\')){wp_register_script_module(\'wppo-lazyload\',WPPO_PLUGIN_URL.\'build/lazyload.js\',array(),WPPO_VERSION,);if(function_exists(\'wp_enqueue_script_module\')){wp_enqueue_script_module(\'wppo-lazyload\');}}add_filter(\'script_module_data_wppo-lazyload\',staticfunction(array)use(){returnarray_merge(,);});}else{wp_enqueue_script(\'wppo-lazyload\',WPPO_PLUGIN_URL.\'build/lazyload.js\',array(),WPPO_VERSION,array(\'in_footer\'=>true));if(){wp_add_inline_script(\'wppo-lazyload\',\'window.wppoNativeLazy=true;\',\'before\');}if(){=wp_json_encode([\'delayConfig\'],JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP);wp_add_inline_script(\'wppo-lazyload\',\'window.wppoDelayConfig=\'..\';\',\'before\');}}}}}/** * Apply defer optimisations to script modules (WP 6.9+). * * Script modules are already deferred by the browser; the remaining wins * are printing them in the footer and lowering their fetch priority so * critical CSS/images win the network queue. Uses the core API when * available (WP 6.9+ native fetchpriority/in_footer via * WP_Script_Modules::set_in_footer / set_fetchpriority or * wp_register_script_module with fetchpriority/in_footer args) and is a * no-op on older core. Guarded by version_compare and * function_exists/class_exists for backward compat. * * @since 2.0.0 * @return void */functionapply_module_loading_strategies():void{if(empty(->get_options()[\'file_optimisation\'][\'deferJS\'])){return;}if(!->should_optimise_for_logged_in()){return;}if(!self::supports_native_script_fetchpriority()){return;}if(!function_exists(\'wp_script_modules\')){return;}if(!function_exists(\'wp_enqueue_script_module\')){return;}if(!class_exists(\'WP_Script_Modules\')){return;}=wp_script_modules();if(!is_object()){return;}=is_array(->exclude_defer_js)?->exclude_defer_js:array();=(string)(->get_options()[\'file_optimisation\'][\'excludeDeferJS\']??\'\');if(\'\'!==){=array_unique(array_merge(,Util::process_urls()));}=array_unique(array_merge(,self::get_defer_js_preset_exclusions()));if(!in_array(\'wppo-lazyload\',,true)){[]=\'wppo-lazyload\';}foreach(array(\'wp-interactivity\',\'@wordpress/interactivity\',\'@wordpress/interactivity-router\')as){if(!in_array(,,true)){[]=;}}=array();if(method_exists(,\'get_print_queue\')){=(array)->get_print_queue();}=null;=false;if(empty()){=->get_registered_module_ids(,);=true;}=!method_exists(,\'get_registered\');if(&&!){=->read_private_module_store(,\'registered\');}=null;if(){=->read_private_module_store(,\'all\');}foreach(as){if(in_array((string),,true)){continue;}if(method_exists(,\'set_in_footer\')){if(->should_move_deferred_to_footer((string))){->set_in_footer((string),true);}}if(method_exists(,\'set_fetchpriority\')){=->get_module_fetchpriority(,(string),,,);if(is_string()&&\'\'!==trim()&&\'auto\'!==strtolower(trim())){continue;}=->get_filtered_deferred_fetchpriority((string));if(\'\'===){continue;}->set_fetchpriority((string),);}}}/** * Read a script module\'s fetchpriority without touching private state directly. * * Uses the public get_registered() getter when available, otherwise reads * the private $registered store via reflection. Returns null when the * module is unregistered, carries no fetchpriority key, or the store is * unreadable (fail-open: callers treat null as a gap and write \'low\'). * Never accesses $modules->registered directly: that property is private * in core, so isset()/direct reads from outside are always false and * would silently overwrite explicit values in production. * * @since 2.0.0 * * @param object $modules Script modules instance from wp_script_modules(). * @param string $id Module id. * @param mixed $registered_store Optional pre-read $registered store (hoisted by the * caller to avoid per-module reflection; pass-through * even when null so an unreadable store is not re-read). * @param mixed $all_store Optional pre-read $all store used as a fallback * when the id is absent from the registered store * (mirrors get_registered_module_ids()). * @param bool $fallback_ready Hoisted `! method_exists( $modules, \'get_registered\' )` * flag so the callee skips the per-module probe. * @return mixed Fetchpriority value, or null when missing/unreadable. */functionget_module_fetchpriority(object,string,=null,=null,bool=false):mixed{if(!&&method_exists(,\'get_registered\')){=->get_registered();if(is_array()&&array_key_exists(\'fetchpriority\',)){return[\'fetchpriority\'];}if(is_object()&&isset(->fetchpriority)){return->fetchpriority;}returnnull;}=func_num_args()>=3?:->read_private_module_store(,\'registered\');=func_num_args()>=4?:->read_private_module_store(,\'all\');foreach(array(,)as){if(is_array()&&array_key_exists(,)){=[];if(is_array()&&array_key_exists(\'fetchpriority\',)){return[\'fetchpriority\'];}if(is_object()&&isset(->fetchpriority)){return->fetchpriority;}}}returnnull;}/** * Collect registered module ids without touching private state directly. * * Reflection fallback for environments where get_print_queue() is empty or * unavailable; reads the private $registered (then $all) store. Returns an * empty array when the store is unreadable. * * @since 2.0.0 * * @param object $modules Script modules instance from wp_script_modules(). * @param mixed $registered_store Optional out-param receiving the already-read * `registered` store (even when null/unreadable) * so callers can hoist it without re-reflecting. * @return string[] Module ids. */functionget_registered_module_ids(object,&=null):array{=->read_private_module_store(,\'registered\');if(is_array()&&!empty()){returnarray_map(\'strval\',array_keys());}=->read_private_module_store(,\'all\');if(is_array()&&!empty()){returnarray_map(\'strval\',array_keys());}returnarray();}/** * Read a (possibly private) property from the script-modules instance. * * Returns null when the property does not exist or is unreadable instead * of raising. Public properties are read directly; non-public ones go * through reflection (no setAccessible() call: it is deprecated on * PHP 8.5 and a no-op since PHP 8.1, and the plugin requires PHP 8.2+). * * @since 2.0.0 * * @param object $modules Script modules instance. * @param string $property Property name. * @return mixed Property value, or null when unreadable. */functionread_private_module_store(object,string):mixed{try{=new\\ReflectionObject();if(!->hasProperty()){returnnull;}=->getProperty();return->getValue();}catch(\\Throwable){returnnull;}}/** * Dequeues configured WooCommerce CSS and JS handles unless the current URL is excluded. * * Reads `file_optimisation.excludeUrlToKeepJSCSS` and, if the current front-end URL matches any entry * (exact match or prefix match when an entry contains the `(.*)` suffix), preserves scripts/styles. * Otherwise reads `file_optimisation.removeCssJsHandle` and dequeues each entry prefixed with * `style:` (dequeues a style handle) or `script:` (dequeues a script handle). * * @since 1.0.0 * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::remove_woocommerce_scripts}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionremove_woocommerce_scripts(){return->script_strategy()->remove_woocommerce_scripts();}/** * Adds custom settings to the WordPress admin bar. * * Capability-gated: only users with `manage_options` see cache-clear nodes. * The REST handlers behind the nodes also enforce `manage_options` + nonce, * so this is a UI disclosure guard (defence in depth). * * @param \\WP_Admin_Bar $wp_admin_bar The WordPress admin bar object used to add nodes and settings. * * @since 1.0.0 * @since 2.0.0 Added `manage_options` capability check. */functionadd_setting_to_admin_bar(){if(!current_user_can(\'manage_options\')){return;}->add_node(array(\'id\'=>\'wppo_setting\',\'title\'=>__(\'Performance Optimisation\',\'performance-optimisation\'),\'href\'=>admin_url(\'admin.php?page=performance-optimisation\'),\'meta\'=>array(\'class\'=>\'performance-optimisation-setting\',\'title\'=>__(\'Go to Performance Optimisation Setting\',\'performance-optimisation\'),),),);->add_node(array(\'id\'=>\'wppo_clear_all\',\'parent\'=>\'wppo_setting\',\'title\'=>__(\'Clear All Cache\',\'performance-optimisation\'),\'href\'=>\'#\',));if(!is_admin()){=get_the_ID();->add_node(array(\'id\'=>\'wppo_clear_this_page\',\'parent\'=>\'wppo_setting\',\'title\'=>__(\'Clear This Page Cache\',\'performance-optimisation\'),\'href\'=>\'#\',\'meta\'=>array(\'title\'=>__(\'Clear cache for this specific page or post\',\'performance-optimisation\'),\'class\'=>\'page-\'.,),));}}/** * Whether the native WP 6.3+ script loading strategy API can be used. * * Canonical predicate for the defer pipeline (issue #1218): the native * `wp_script_add_data( $handle, \'strategy\', \'defer\' )` path is only * honoured by core since WP 6.3, so both setup_hooks() routing and * add_defer_strategy() fail-open on this helper. Three guards: * version >= 6.3-alpha; `wp_script_add_data()` present (guards a * stripped/missing API — the function itself predates 6.3, so on its * own it cannot detect a backported/filtered version string); and, * when the WP_Scripts class is available, the genuinely-6.3 * `WP_Scripts::get_eligible_loading_strategy()` method (changeset * 56033), which IS absent on pre-6.3 core and therefore catches the * inflated-version case. When the class is unavailable (e.g. very * early load), version + function probes decide. Fail-open: false on * any unreadable version or missing API, in which case callers fall * back to the pre-6.3 script_loader_tag regex. * * @since 2.2.0 * * @return bool True when the native defer strategy path is allowed. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::supports_native_defer_strategy}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionsupports_native_defer_strategy():bool{returnScript_Strategy::supports_native_defer_strategy();}/** * Whether the native WP 6.9+ script fetchpriority API can be used. * * Canonical predicate for the fetchpriority pipeline (issue #1218): * native fetchpriority rendering arrived in WP 6.9 (Trac #61734), so * setup_hooks() fallback routing, add_defer_strategy(), and * apply_module_loading_strategies() all fail-open on this helper * instead of a bare version_compare, which a backported/filtered * version string could defeat. Guards: version >= 6.9-alpha plus the * genuinely-6.9 `WP_Script_Modules::set_fetchpriority()` method when * the class is available; when it is not (e.g. unit-test doubles), * the 6.5+ module functions decide alongside the version gate. * Fail-open: false on any unreadable version or missing API, in which * case callers fall back to the pre-6.9 script_loader_tag regex. * * @since 2.2.0 * * @return bool True when the native fetchpriority path is allowed. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::supports_native_script_fetchpriority}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionsupports_native_script_fetchpriority():bool{returnScript_Strategy::supports_native_script_fetchpriority();}/** * Whether a queued handle is eligible for a deferred loading strategy. * * Mirrors core\'s WP_Scripts::get_eligible_loading_strategy() gate * (changeset 56033, issue #1466) so the native strategy write never * fights core output: core renders a handle blocking when it carries * an inline `after` script, when a blocking queued dependent relies * on it, or when it is a module/import-map script. Core keeps * get_eligible_loading_strategy() private with pre-stamp semantics * (\'\' when no intended strategy is set), so it can neither be called * nor reused here; eligibility is decided by the manual fallback * below, which is the only path that can run against real core. The * existing supports_native_defer_strategy() method_exists probe * already covers capability detection. Fail-open: true on any * unreadable state so output degrades to the current behaviour, never * fatal or white-screen. * * Dependents are judged against the run\'s intended set plus live * state (issue #1466 review): a queued dependent carrying no explicit * strategy counts as deferred when this run intends to defer it, so * the verdict never depends on queue order. Transitive poisoning is * handled by recursing into each deferred dependent with a visited * set (mirroring core\'s $checked param): a dependent that is itself * blocked by a third handle poisons its own dependencies. * * @since 2.3.0 * * @param object $wp_scripts WP_Scripts registry. * @param string $handle Script handle. * @param string[] $intended Handles this run intends to defer (pass * the interactivity guard and exclusion * list). Defaults to empty for direct calls. * @param bool[] $checked Visited handles for the recursion guard * (mirrors core\'s $checked). Callers leave * this at its default; a fresh map is used * per top-level evaluation. * @return bool True when the handle may receive strategy defer. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_defer_eligible_for_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_defer_eligible_for_handle(object,string,array=array(),array&=array()):bool{return->script_strategy()->is_defer_eligible_for_handle(,,,);}/** * Whether the WP 6.9+ core template-enhancement buffer should carry plugin post-processing. * * Canonical predicate for the single-buffer routing (issue #1386): * version >= 6.9-alpha plus the genuinely-6.9 * `wp_should_output_buffer_template_for_enhancement()` API. This is an * availability probe only: it never calls the predicate itself, so * registration-time routing cannot confuse a mid-request opt-out * (filter returning false) with a missing core. When true, * setup_hooks() registers ONLY the `wp_template_enhancement_output_buffer` * filter / `wp_finalized_template_enhancement_output_buffer` action * callbacks and no private `ob_start()` capture; when false (pre-6.9) * only the legacy `template_redirect` captures are registered. * Honoring a runtime `false` (opt-out / no consumers) means degrading * to uncached streaming output, never opening a private buffer — * intentional, since a private capture would stack on core\'s buffer * and re-process HTML; caching resumes when core opts back in. * Fail-open: false on any unreadable version or missing API, in which * case callers fall back to the pre-6.9 legacy path. * * @since 2.3.0 * * @return bool True when the core template-enhancement buffer path is allowed. * Facade proxy (P3-015): logic lives in {@see Preload_Buffer_Coordinator::should_use_core_template_buffer}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). * @since NEXT Proxied to Preload_Buffer_Coordinator (P3-015). */staticfunctionshould_use_core_template_buffer():bool{returnPreload_Buffer_Coordinator::should_use_core_template_buffer();}/** * Resolve the filtered fetchpriority for a deferred handle. * * Shared by the native classic path (add_defer_strategy()), the * pre-6.9 regex fallback (add_fetchpriority_to_deferred()), and the * module path (apply_module_loading_strategies()) so all three honor * the same filter contract. Guarded by function_exists + has_filter * so installs without the filter keep the \'low\' default with no * extra dispatch; fail-open to \'low\' on any throwable and to \'\' * (suppress) when the filter returns a non-listed value. * * @since 2.2.0 * * @param string $handle Script handle or module id. * @return string Validated \'high\'|\'low\'|\'auto\', or \'\' to suppress. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_filtered_deferred_fetchpriority}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_filtered_deferred_fetchpriority(string):string{return->script_strategy()->get_filtered_deferred_fetchpriority();}/** * Whether a deferred handle should be moved to the footer. * * Shared by the native classic path (add_defer_strategy()) and the * module path (apply_module_loading_strategies()) so both honor the * same filter contract. Guarded by function_exists + has_filter; * fail-open to true (move) when the filter is absent or throws. * * @since 2.2.0 * * @param string $handle Script handle or module id. * @return bool True when the handle should be footer-bound. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::should_move_deferred_to_footer}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionshould_move_deferred_to_footer(string):bool{return->script_strategy()->should_move_deferred_to_footer();}/** * Applies defer strategy to non-logged-in users\' scripts using wp_script_add_data. * * Canonical defer path on WP 6.3+ (issue #1218); the pre-6.3 * script_loader_tag regex fallback (add_defer_attribute_legacy()) is * never registered on the same request via setup_hooks(). Iterates * `$wp_scripts->queue` in order with no sorting so dependency chain * order is preserved. Fill-gaps-only for the strategy itself: an * explicit `async` strategy is never rewritten to `defer`. * * On WP 6.9+ deferred handles also receive native fetchpriority/in_footer * args via the Script Loader API (Trac #61734 / #63486) so core renders * them with dependency bumping; the regex fallback * add_fetchpriority_to_deferred() stays disabled on 6.9+ via setup_hooks(). * The footer move uses the \'group\' data key (core maps the in_footer * enqueue arg to group=1; the \'in_footer\' data key itself is never read * for classic scripts) and can be disabled per handle via the * `wppo_deferred_in_footer` filter. * * @since 2.0.0 * * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_defer_strategy}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_defer_strategy():void{->script_strategy()->add_defer_strategy();}/** * Whether a script tag\'s type attribute is executable JavaScript. * * A tag with no `type` attribute is treated as executable because HTML * implies `text/javascript`. Executable JS types (including `module`, * which HTML treats as JavaScript) may be delay-rewritten; every other * type is a data block — `application/json`, `application/ld+json`, * `text/template`, `speculationrules`, and so on — that never executes * and whose `src` must not be moved to `wppo-src`. * * The leading `\\s` in the pattern is what keeps `wppo-type=\"…\"` from * matching: the character before `type` there is `-`, not whitespace. * * @since 2.2.0 * * @param string $tag The script tag markup. * @return bool True when the tag may be delay-rewritten. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_executable_script_type}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_executable_script_type(string):bool{return->script_strategy()->is_executable_script_type();}/** * Adds defer attribute to non-logged-in users\' scripts. * * @since 1.0.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @return string Modified script tag with defer attribute. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_defer_attribute}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_defer_attribute(,):string{return->script_strategy()->add_defer_attribute(,);}/** * Adds the defer attribute to script tags on WordPress < 6.3. * * WordPress 6.3+ natively honours the \'strategy\' script data added via * wp_script_add_data(), so this legacy fallback is only registered on older * core (WP 6.2) where the native strategy is silently ignored. Retained * while the plugin floor is 6.2 (issue #1203: removal deferred until the * minimum supported WP is raised to 6.3; see TODO(#553) in setup_hooks()). * Fill-gaps-only: a tag already carrying `defer`, `async` (core, theme, * or LiteSpeed delay), or `type=\"module\"` is returned untouched so no * `async defer` double-attribute markup is emitted (issue #1218). The * pre-checks are case-insensitive with attribute boundaries and * quote-masking, so DEFER, valued async=\"async\", single-quoted or * spaced type=\'module\', and `async` inside quoted values (ignored) * are all handled. * * @since 1.9.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @return string Modified script tag with the defer attribute. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_defer_attribute_legacy}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_defer_attribute_legacy(,):string{return->script_strategy()->add_defer_attribute_legacy(,);}/** * Check whether a script handle matches a delay pattern using word boundaries. * * The pattern (a handle or URL) is matched literally between `\\b` word * boundaries, preventing partial-word substring false positives such as * `slide` matching the unrelated handle `slider-custom`, while preserving * dash-delimited prefix matches users rely on (`jquery` → `jquery-core`). * URL metacharacters are escaped via preg_quote() so the pattern is matched * literally. Empty patterns are ignored so regex construction stays valid. * * @since 1.9.0 * * @param string $handle The script handle. * @param string $pattern The configured pattern (handle or URL). * @return bool True if the handle matches the pattern. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_delay_pattern}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionmatches_delay_pattern(string,string):bool{return->script_strategy()->matches_delay_pattern(,);}/** * Build (once per request) a combined alternation regex for a pattern list. * * @since 2.0.0 * @param string[] $patterns Pattern list. * @return string Empty string when no usable patterns; otherwise a ready regex. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_patterns_regex}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_patterns_regex(array):string{returnScript_Strategy::get_delay_patterns_regex();}/** * Whether a handle matches any pattern in a list via the precompiled alternation. * * Falls back to per-pattern matching only when the combined regex * fails to compile (extremely long lists). * * @since 2.0.0 * @param string $handle Script handle. * @param string[] $patterns Pattern list. * @return bool True on match. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_any_delay_pattern}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionmatches_any_delay_pattern(string,array):bool{return->script_strategy()->matches_any_delay_pattern(,);}/** * Whether a script handle is excluded from Delay JS. * * Checks exact membership first, then falls back to * matches_delay_pattern() word-boundary matching so builder-handle * variants (e.g. `oxygen-*` via `oxygen`, `et-*` via `et-core-api`) * stay excluded on the external-script path exactly as the inline * path in Minify\\HTML excludes them by substring. * * @since 2.0.0 * * @param string $handle The script\'s registered handle. * @return bool True when the handle must stay un-delayed. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_excluded_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_excluded_handle(string):bool{return->script_strategy()->is_delay_excluded_handle();}/** * Delay-JS exclusions with `wppo_exclude_delay_js` applied on first use. * * `setup_hooks()` applies the filter while the plugin bootstraps, and * plugins load before the active theme. A theme registering an * exclusion from functions.php therefore had no effect on the * handle-level rewrite, which silently leaves its script swapped to * `wppo-src` and never executed — a mobile menu that cannot open, for * instance. Re-applying here (during enqueue, after every plugin and * the theme have loaded) lets those late registrations count. The * result is memoized, so the filter still runs at most once per * request on this path. * * @since 2.2.0 * * @return array<int, string> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_exclusions():array{return->script_strategy()->get_delay_exclusions();}/** * Whether the current request must skip delay-JS rewriting. * * Woo guardrail: cart, checkout, and account pages stay excluded from delay * by default, plus wc-ajax and add-to-cart requests. Intentionally narrower * than Cache::is_woo_excluded(): visitor-scoped cookie signals (cart hash, * wp_woocommerce_session_*) are NOT mirrored — disabling delay site-wide * for every shopper would wipe out the INP win on the homepage/blog, and * mini-cart fragments elsewhere are already protected by the per-handle * Woo exclusions in get_delay_js_preset_exclusions(). Likewise there is no * blanket is_woocommerce() gate: shop/product archives rely on the same * per-handle exclusions (wc-add-to-cart, wc-single-product, …). * Builder guardrail: preview/edit contexts only — rendered frontend output * from builders still gets the INP win. * Fail-open: any detection failure returns true (skip delay) so scripts * stay un-delayed, never fatal. A positive match also returns true. * * The cart/checkout/account path fallback matches the slug as a full * path segment anywhere in the request path (covers subdirectory installs and multisite sub-sites) and * additionally resolves custom/translated slugs via wc_get_page_id() when * WooCommerce is active; on non-Woo installs only the default slugs apply. * * @since 2.0.0 * * @return bool True when delay must be skipped for this request. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_excluded_context}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionis_delay_excluded_context():bool{returnScript_Strategy::is_delay_excluded_context();}/** * Signature of the request inputs that drive the delay-context verdict. * * The verdict depends on the request URI, query string, and query * arguments (plus conditional tags, which a real request does not * change mid-flight). Hashing these lets the memo self-invalidate * when a new logical request reuses the same PHP process. * * @since 2.0.0 * @return string Signature string. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::delay_context_request_signature}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctiondelay_context_request_signature():string{returnScript_Strategy::delay_context_request_signature();}/** * Reset the per-request delay-context memo (for tests). * * @since 2.0.0 * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::reset_delay_context_memo}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionreset_delay_context_memo():void{Script_Strategy::reset_delay_context_memo();}/** * Compute whether the current request must skip delay-JS rewriting. * * @since 2.0.0 * @return bool True when delay must be skipped for this request. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::compute_delay_excluded_context}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctioncompute_delay_excluded_context():bool{returnScript_Strategy::compute_delay_excluded_context();}/** * Whether a request path belongs to a WooCommerce cart/checkout/account page. * * Matches the slug as a full path segment anywhere in the request path * (fail-safe: covers subdirectory/multisite prefixes such as /shop/checkout * and /subsite/cart; a non-Woo page containing the segment is also * treated as dynamic). * Custom/translated slugs are resolved via wc_get_page_id() when * WooCommerce is active; otherwise only the default slugs apply. * * @since 2.0.0 * * @param string $local_path Request path with a leading slash. * @return bool True when the path is a Woo page path. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_woo_page_path}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionmatches_woo_page_path(string):bool{returnScript_Strategy::matches_woo_page_path();}/** * Get the delay strategy for a given script handle. * * Checks idle list, viewport list, and then falls back to default strategy. * * Contract: callers must gate on is_delay_third_party_auto_candidate() * first; this resolver does NOT re-check the allowlist or the * builder/commerce exclusions. Pass the gate verdict via * $is_auto_matched to avoid a second pattern scan; null means * \"not precomputed\" and falls back to matching here. * * @since 1.9.0 * * @param string $handle The script handle. * @param string $tag Optional script tag markup (for auto-mode src matching). * @param bool|null $is_auto_matched Optional precomputed auto-candidate verdict. * @return string The strategy: \'interaction\', \'idle\', or \'viewport\'. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_strategy_for_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_strategy_for_handle(string,string=\'\',?bool=null):string{return->script_strategy()->get_delay_strategy_for_handle(,,);}/** * Get the delay priority for a given script handle. * * @since 1.9.0 * * @param string $handle The script handle. * @return string The priority: \'high\', \'normal\', or \'low\'. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_priority_for_handle}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_priority_for_handle(string):string{return->script_strategy()->get_delay_priority_for_handle();}/** * Apply per-page delay configuration overrides from the Asset Manager metabox. * * Runs at `wp` hook to merge per-page strategy/priority overrides into the * global delay lists before the `script_loader_tag` filter fires. * * @since 1.9.0 * * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::apply_per_page_delay_config}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionapply_per_page_delay_config():void{->script_strategy()->apply_per_page_delay_config();}/** * Curated per-builder Delay JS exclusions (issue #966). * * Builder runtimes must stay un-delayed by default — delaying them * breaks Elementor/Divi/Bricks/WPBakery/Oxygen rendering and the * block-interactivity runtime. Only the exclusion list is shared with * Minify\\HTML so the lists cannot drift; matching semantics differ by * design. The external path matches handles via * is_delay_excluded_handle() (exact, dash/underscore variants, pure * prefixes, word-boundary fallback) while the inline path intentionally * over-matches by substring over attributes+content (fail-open * direction), so over/under-exclusion can still diverge. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_builder_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_builder_exclusions():array{returnScript_Strategy::get_delay_js_builder_exclusions();}/** * Curated commerce Delay JS exclusions (issue #988). * * The jQuery plus cart-fragments/checkout handles stay un-delayed when * the commerce preset is on so carts and checkouts never break. * Filterable via wppo_delay_js_commerce_exclusions. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_commerce_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_commerce_exclusions():array{returnScript_Strategy::get_delay_js_commerce_exclusions();}/** * Curated slider Delay JS exclusions (issue #988). * * Slider runtimes stay un-delayed with the builder preset so hero * sliders keep working. Filterable via wppo_delay_js_slider_exclusions. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_slider_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_slider_exclusions():array{returnScript_Strategy::get_delay_js_slider_exclusions();}/** * Curated first-click interaction Delay JS exclusions (issue #1055). * * Popup/dialog, mobile-menu, and add-to-cart handles must stay * un-delayed so first-click interactions never need a second click. * Filterable via wppo_delay_js_interaction_exclusions. Merged into * the global preset when `delayJSInteractionPreset` is on (default). * Per-site settings only; multisite-safe. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_interaction_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_interaction_exclusions():array{returnScript_Strategy::get_delay_js_interaction_exclusions();}/** * One-click Delay-JS preset levels (issue #1385). * * Single source of truth for the Safe / Balanced / Aggressive * one-click presets. Builder plus commerce presets are forced ON at * every level so carts, checkouts, and builder runtimes never break. * * @since 2.3.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_levels}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_preset_levels():array{returnScript_Strategy::get_delay_js_preset_levels();}/** * Toggle map applied by a one-click Delay-JS preset level (issue #1385). * * Maps a level to the existing exclusion-getter toggles only — no new * delay semantics. Fail-open: unknown levels degrade to the safe map. * Guarded by function_exists/has_filter plus a legacy fallback so the * current delay path is used when the filter API is unavailable. * * @since 2.3.0 * * @param string $level Preset level (safe|balanced|aggressive). * @return array<string, bool> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_level_settings}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_preset_level_settings(string):array{returnScript_Strategy::get_delay_js_preset_level_settings();}/** * Merged exclusion list for a one-click Delay-JS preset level (issue #1385). * * Maps Safe / Balanced / Aggressive to the existing exclusion getters * only (builder, slider, commerce, interaction, consent, analytics, * gallery, jquery plus the always-on base preset). Manual exclusions * and the delayJSThirdPartyAuto patterns are merged by the caller, so * the manual textarea plus filter always win. Fail-open: any failure * degrades to the base preset list (safe direction — pages exclude * more, never delay everything), never fatal. * * @since 2.3.0 * * @param string $level Preset level (safe|balanced|aggressive). * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_level_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_preset_level_exclusions(string):array{returnScript_Strategy::get_delay_js_preset_level_exclusions();}/** * Apply a preset exclusion filter with fail-open guards (issue #1308). * * Shared by the opt-in compat presets so a misbehaving filter callback * degrades to the curated list, never fatal and never white screen. * Guarded by function_exists/has_filter so behaviour is identical with * and without the filter API (WP 6.2+ always provides it). * * @since 2.2.0 * * @param string $filter Filter hook name. * @param string[] $preset Curated preset exclusions. * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::filter_compat_preset_list}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionfilter_compat_preset_list(string,array):array{returnScript_Strategy::filter_compat_preset_list(,);}/** * Curated consent Delay JS exclusions (issue #1308). * * Consent banners and scanners (CookieYes, Cookiebot, Complianz, * Borlabs, OneTrust, …) must stay un-delayed when the consent preset * is on so banners render and scans see the real scripts. Opt-in * (default off); merged additively, never replacing manual excludes. * Filterable via wppo_delay_js_consent_exclusions. Fail-open: any * filter error degrades to the curated list (un-delayed output). * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_consent_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_consent_exclusions():array{returnScript_Strategy::get_delay_js_consent_exclusions();}/** * Curated analytics Delay JS exclusions (issue #1308). * * Analytics beacons (GA4 gtag, Matomo, Plausible, …) must stay * un-delayed when the analytics preset is on so hits are not lost * before interaction. Opt-in (default off); additive merge only. * Filterable via wppo_delay_js_analytics_exclusions. Fail-open to * the curated list on any filter error. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_analytics_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_analytics_exclusions():array{returnScript_Strategy::get_delay_js_analytics_exclusions();}/** * Curated gallery Delay JS exclusions (issue #1308). * * Galleries and lightboxes beyond the slider runtimes (PhotoSwipe, * Fancybox, Envira, FooGallery, …) must stay un-delayed when the * gallery preset is on so they work pre-interaction. Opt-in * (default off); additive merge only. Filterable via * wppo_delay_js_gallery_exclusions. Fail-open to the curated list. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_gallery_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_gallery_exclusions():array{returnScript_Strategy::get_delay_js_gallery_exclusions();}/** * Curated jQuery-legacy Delay JS exclusions (issue #1308). * * Legacy jQuery-dependent widgets on non-Woo sites must stay un-delayed * when the jquery preset is on. The commerce preset already owns the * core jQuery/cart handles for shops; this opt-in preset (default * off) extends cover to jQuery UI/plugins for legacy themes. * Filterable via wppo_delay_js_jquery_exclusions. Fail-open to the * curated list on any filter error. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_jquery_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_jquery_exclusions():array{returnScript_Strategy::get_delay_js_jquery_exclusions();}/** * Compat preset slugs keyed by their settings key (issue #1308). * * Single source of truth for the four opt-in presets: settings key * => preset slug used in per-page opt-out meta. * * @since 2.2.0 * @return array<string, string> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_compat_preset_map}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_compat_preset_map():array{returnScript_Strategy::get_delay_js_compat_preset_map();}/** * Exclusion list for one compat preset slug (issue #1308). * * Lazy-boots only the requested matcher so sites without delay pay * zero cost. Fail-open: unknown slugs return an empty list. * * @since 2.2.0 * * @param string $slug Preset slug (consent|analytics|gallery|jquery). * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_compat_preset_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_compat_preset_exclusions(string):array{returnScript_Strategy::get_delay_js_compat_preset_exclusions();}/** * One-click Safe preset bundle (issue #1442). * * Curated `file_optimisation` settings that enable minify plus defer * plus delay together with the builder, commerce, interaction and * jQuery exclusions pre-applied, so page builders, jQuery widgets and * WooCommerce never break. Returns only pre-existing settings keys: * additive, no schema change, safe-by-default. Consent, analytics and * gallery presets stay off (opt-in), combineCSS stays off (FOUC risk). * Multisite-safe: per-site `wppo_settings` only. * * @since 2.3.0 * @return array<string, mixed> */staticfunctionget_safe_preset_bundle():array{try{returnarray(\'minifyJS\'=>true,\'minifyCSS\'=>true,\'minifyHTML\'=>true,\'deferJS\'=>true,\'delayJS\'=>true,\'delayJSBuilderPreset\'=>true,\'delayJSCommercePreset\'=>true,\'delayJSInteractionPreset\'=>true,\'delayJSJqueryPreset\'=>true,\'delayJSSafeMode\'=>true,\'elementorSafeMode\'=>true,\'delayJSConsentPreset\'=>false,\'delayJSAnalyticsPreset\'=>false,\'delayJSGalleryPreset\'=>false,\'combineCSS\'=>false,);}catch(\\Throwable){unset();returnarray();}}/** * Aggressive preset bundle (issue #1442). * * Same pipelines as the Safe preset but with the builder, commerce, * interaction and jQuery safe presets off plus CSS combining on, for * users who manage exclusions manually. UI-gated behind an explicit * warning with one-click revert via the settings snapshot. Returns * only pre-existing settings keys: additive, no schema change. * * @since 2.3.0 * @return array<string, mixed> */staticfunctionget_aggressive_preset_bundle():array{try{returnarray(\'minifyJS\'=>true,\'minifyCSS\'=>true,\'minifyHTML\'=>true,\'deferJS\'=>true,\'delayJS\'=>true,\'delayJSBuilderPreset\'=>false,\'delayJSCommercePreset\'=>false,\'delayJSInteractionPreset\'=>false,\'delayJSJqueryPreset\'=>false,\'delayJSSafeMode\'=>false,\'elementorSafeMode\'=>false,\'combineCSS\'=>true,);}catch(\\Throwable){unset();returnarray();}}/** * Merge a preset bundle additively into file-optimisation settings (issue #1442). * * Only allowlisted pre-existing keys from the bundle are applied; any * unknown key is skipped so a future bundle can never widen the schema * or persist unexpected values. Fail-open: any failure returns the * input unchanged. * * @since 2.3.0 * @param array<string, mixed> $current Current file_optimisation settings. * @param array<string, mixed> $bundle Preset bundle (e.g. get_safe_preset_bundle()). * @return array<string, mixed> Merged settings. */staticfunctionapply_preset_bundle(array,array):array{try{=array(\'minifyJS\',\'minifyCSS\',\'minifyHTML\',\'deferJS\',\'delayJS\',\'delayJSBuilderPreset\',\'delayJSCommercePreset\',\'delayJSInteractionPreset\',\'delayJSJqueryPreset\',\'delayJSSafeMode\',\'elementorSafeMode\',\'delayJSConsentPreset\',\'delayJSAnalyticsPreset\',\'delayJSGalleryPreset\',\'combineCSS\',);foreach(as=>){if(!is_string()||!in_array(,,true)){continue;}[]=;}return;}catch(\\Throwable){unset();return;}}/** * Whether file-optimisation settings match the Safe preset (issue #1442). * * True when the minify/defer/delay pipelines are on together with all * four safe exclusion presets plus the two safe-mode guards * (`delayJSSafeMode`, `elementorSafeMode`) the bundle applies, and * with `combineCSS` off (the bundle pins it false — FOUC risk — * while Aggressive pins it true), so enabling CSS combining after * applying Safe clears the confirmation instead of overstating * safety. `minifyHTML` is part of the pipeline gate because the * bundle pins it true. Fail-open: any failure returns false. * * @since 2.3.0 * @param array<string, mixed> $file_opt file_optimisation settings slice. * @return bool */staticfunctionis_safe_preset_active(array):bool{try{foreach(array(\'minifyJS\',\'minifyCSS\',\'minifyHTML\',\'deferJS\',\'delayJS\')as){if(empty([])){returnfalse;}}=array(\'delayJSBuilderPreset\',\'delayJSCommercePreset\',\'delayJSInteractionPreset\',\'delayJSJqueryPreset\',\'delayJSSafeMode\',\'elementorSafeMode\',);foreach(as){if(empty([])){returnfalse;}}if(!empty([\'combineCSS\'])){returnfalse;}returntrue;}catch(\\Throwable){unset();returnfalse;}}/** * Per-page disabled compat presets from post meta (issue #1308). * * Reads `_wppo_delay_presets_off` (array of slugs). A page can opt * out of a globally-enabled preset without touching global settings * so exceptions stay surgical. Multisite-safe: per-site post meta, * no cross-site leakage. Fail-open: any detection failure returns * an empty list (no opt-out applied). * * @since 2.2.0 * * @param int $post_id Optional post ID. Defaults to the current post. * @return string[] */staticfunctionget_page_disabled_delay_presets(int=0):array{try{if(0===){if(!function_exists(\'get_the_ID\')){returnarray();}=(int)get_the_ID();}if(<=0){returnarray();}static=array();=0;if(function_exists(\'is_multisite\')&&function_exists(\'get_current_blog_id\')){try{if(is_multisite()){=(int)get_current_blog_id();}}catch(\\Throwable){unset();=0;}}=.\':\'.;if(isset([])){return[];}if(!function_exists(\'get_post_meta\')){returnarray();}=get_post_meta(,\'_wppo_delay_presets_off\',true);if(!is_array()){[]=array();returnarray();}=array(\'consent\',\'analytics\',\'gallery\',\'jquery\');=array();foreach(as){if(!is_scalar()){continue;}=strtolower(trim((string)));if(in_array(,,true)&&!in_array(,,true)){[]=;}}[]=;return;}catch(\\Throwable){unset();returnarray();}}/** * Whether the current URL matches a newline-separated exclusion list (issue #988). * * Each non-empty line is a case-insensitive URL-substring match, or a * regex when wrapped in valid delimiters (e.g. `#...#`). Fail-open: * any detection failure returns false (no exclusion). * * @since 2.0.0 * * @param string $url_list Newline-separated exclusion list. * @return bool True when the current request URL is excluded. */staticfunctionis_url_excluded_by_list(string):bool{try{=trim();if(\'\'===){returnfalse;}=isset([\'REQUEST_URI\'])?wp_unslash([\'REQUEST_URI\']):\'/\';=sanitize_text_field((string));if(function_exists(\'wp_parse_url\')){=(string)wp_parse_url(,PHP_URL_PATH);}else{=strpos(,\'?\');=false===?:substr(,0,);}=strtolower(.\' \'.);if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'process_urls\')){=Util::process_urls();}else{=array_values(array_filter(array_map(\'trim\',explode(\"\\n\",))));}foreach(as){=trim((string));if(\'\'===){continue;}if(strlen()>2&&\'#\'===[0]&&false!==strrpos(,\'#\',1)){=false;set_error_handler(staticfunction(){});try{=false!==preg_match(,\'\');}catch(\\Throwable){unset();=false;}restore_error_handler();if(){set_error_handler(staticfunction(){});try{=preg_match(.\'i\',);}catch(\\Throwable){unset();=false;}restore_error_handler();if(1===){returntrue;}continue;}}if(false!==stripos(,strtolower())){returntrue;}}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Whether used CSS must be skipped for the current URL (issue #988). * * Checks the per-URL `usedCSSExcludeUrls` list plus the `_wppo_used_css_disabled` * per-page kill-switch. Fail-open: any detection failure returns false. * * @since 2.0.0 * @return bool True when used CSS must be skipped. */staticfunctionis_used_css_excluded_for_url():bool{try{=\'\';if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){=Util::get_settings();=isset([\'file_optimisation\'][\'usedCSSExcludeUrls\'])?(string)[\'file_optimisation\'][\'usedCSSExcludeUrls\']:\'\';}if(\'\'!==trim()&&self::is_url_excluded_by_list()){returntrue;}if(function_exists(\'is_singular\')&&function_exists(\'get_the_ID\')&&function_exists(\'get_post_meta\')){try{if(is_singular()){=(int)get_the_ID();if(>0&&!empty(get_post_meta(,\'_wppo_used_css_disabled\',true))){returntrue;}}}catch(\\Throwable){unset();}}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Whether Delay JS is disabled for a singular page (issue #966). * * Reads the `_wppo_delay_disabled` post-meta kill-switch. Fail-open: * any detection failure returns false (delay stays enabled) except * unexpected throwables, which return false as well — callers already * fail open to original scripts on rewrite errors. * * @since 2.0.0 * * @param int $post_id Optional post ID. Defaults to the current post. * @return bool True when delay must be skipped for this page. */staticfunctionis_delay_disabled_for_page(int=0):bool{try{if(function_exists(\'is_singular\')&&!is_singular()&&0===){returnfalse;}if(0===){if(!function_exists(\'get_the_ID\')){returnfalse;}=(int)get_the_ID();}if(<=0){returnfalse;}=0;if(function_exists(\'is_multisite\')&&function_exists(\'get_current_blog_id\')){try{if(is_multisite()){=(int)get_current_blog_id();}}catch(\\Throwable){unset();=0;}}=.\':\'.;if(isset(self::[])){returnself::[];}if(!function_exists(\'get_post_meta\')){returnfalse;}=!empty(get_post_meta(,\'_wppo_delay_disabled\',true));self::[]=;return;}catch(\\Throwable){unset();returnfalse;}}/** * Whether the unified safe-mode kill switch is enabled (issue #1098). * * When on, delay-JS + defer-JS + remove-unused-CSS (and Critical-CSS * stylesheet deferral) are all disabled in one click while the * underlying `delayJS` / `deferJS` / `removeUnusedCSS` settings are * preserved untouched, so turning safe mode back off restores the * previous configuration without re-entering settings (one-click * recovery). Additive `file_optimisation.safeMode` key, defaults to * off. Filterable via `wppo_safe_mode_enabled` (has_filter-guarded, * fail-open to the stored setting). Multisite-safe: per-site settings. * * @since 2.2.0 * * @return bool True when aggressive optimisations must be skipped. */functionis_safe_mode_enabled():bool{returnself::is_safe_mode_active(->get_options()[\'file_optimisation\']??array());}/** * Whether the current request is a valid sandbox asset-preview request. * * Delegates to Sandbox_Preview::is_preview_request() (admin-only query * param + nonce). Fail-open to false when the controller is missing. * * @since 2.2.0 * * @return bool True when experimental preview output may render. */staticfunctionis_sandbox_preview_active():bool{try{if(class_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\')&&method_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\',\'is_preview_request\')){return(bool)Sandbox_Preview::is_preview_request();}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Effective file_optimisation slice for this request. * * Visitors get production unchanged; admin preview requests get * production overlaid with staged sandbox values. Fail-open to * production on any error. * * @since 2.2.0 * * @param array $file_optimisation Production slice. * @return array Effective slice. */staticfunctionget_effective_file_optimisation(array=array()):array{try{if(class_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\')&&method_exists(\'PerformanceOptimise\\Inc\\Sandbox_Preview\',\'get_effective_file_optimisation\')){returnSandbox_Preview::get_effective_file_optimisation();}return;}catch(\\Throwable){unset();return;}}/** * Static safe-mode predicate shared by Main / Used_CSS / Critical_CSS. * * Reads `file_optimisation.safeMode` from the passed settings (or from * `Util::get_settings()` when empty) so static buffer callbacks that * have no Main instance can gate identically. Any failure fails open * to disabled (optimisations run) except an explicit stored `true`. * * @since 2.2.0 * * @param array $file_optimisation Optional `file_optimisation` settings slice. * @return bool True when safe mode is on. */staticfunctionis_safe_mode_active(array=array()):bool{try{if(empty()&&class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){try{=(array)Util::get_settings();=isset([\'file_optimisation\'])&&is_array([\'file_optimisation\'])?[\'file_optimisation\']:array();}catch(\\Throwable){unset();}}=!empty([\'safeMode\']);if(function_exists(\'has_filter\')&&function_exists(\'apply_filters\')&&has_filter(\'wppo_safe_mode_enabled\')){try{=apply_filters(\'wppo_safe_mode_enabled\',);return(bool);}catch(\\Throwable){unset();return;}}return;}catch(\\Throwable){unset();returnfalse;}}/** * Whether aggressive optimisations must be bypassed for this request. * * Shared nocache bypass (issue #1098) for delay + defer + used-CSS + * Critical-CSS deferral: `?nocache` / `?wppo_nocache` query args and * preview contexts (`is_preview()` when available, * function_exists-guarded for WP 6.2+ compat). `DONOTCACHEPAGE` is * intentionally NOT checked here: it gates page-cache storage (and * Used_CSS::process_buffer() keeps its own pre-existing explicit * check), while delay/defer rewriting still applies on such pages. * Any detection failure fails open to bypassed (unoptimised output, * never fatal). Multisite-safe: request-local only. * * @since 2.2.0 * * @return bool True when optimisations must be skipped for this request. */staticfunctionis_aggressive_bypass_active():bool{try{if(function_exists(\'is_preview\')){try{if(is_preview()){returntrue;}}catch(\\Throwable){unset();}}if(isset([\'nocache\'])||isset([\'wppo_nocache\'])){returntrue;}if(!empty([\'QUERY_STRING\'])&&function_exists(\'wp_unslash\')){=(string)wp_unslash([\'QUERY_STRING\']);if(preg_match(\'/(?:^|&)(nocache|wppo_nocache)(?:=|&|$)/i\',)){returntrue;}}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Curated defer-JS preset exclusions (issue #1098). * * Built-in jQuery + Elementor/Divi + WooCommerce handles stay un-deferred by * default so carts, checkouts, and builders never break. Filterable * via `wppo_defer_js_preset_exclusions` (has_filter-guarded, fail-open * to the built-in preset). Merged with user `excludeDeferJS` via * array_unique by callers. Per-site settings only; multisite-safe. * * @since 2.2.0 * * @return string[] */staticfunctionget_defer_js_preset_exclusions():array{=array(\'jquery\',\'jquery-core\',\'jquery-migrate\',\'elementor-frontend\',\'elementor-pro-frontend\',\'elementor-common\',\'et-core-api\',\'divi-custom-script\',\'wc-cart-fragments\',\'wc-checkout\',\'woocommerce\',\'wc-add-to-cart\',\'add-to-cart\',\'cart-fragments\',\'wc-blocks\',\'wc-store\',\'wp-interactivity\',\'@wordpress/interactivity\',\'@wordpress/interactivity-router\',);/** * Filters defer-JS preset exclusions. * * @since 2.2.0 * @param string[] $preset Defer preset exclusions. */if(!function_exists(\'has_filter\')||!function_exists(\'apply_filters\')||!has_filter(\'wppo_defer_js_preset_exclusions\')){return;}try{=apply_filters(\'wppo_defer_js_preset_exclusions\',);}catch(\\Throwable){unset();return;}if(!is_array()){return;}returnarray_values(array_unique(array_filter(array_map(staticfunction():string{returnis_string()||is_numeric()?(string):\'\';},),staticfunction():bool{return\'\'!==;})));}/** * Fragile-handle map for the auto-exclude detector (issue #1465). * * Ordered jQuery first, then cart fragments, then builders so the * detector names the most breakage-prone handle first. Each entry * maps a lowercase handle fragment to its exclude field(s) and a * short human-readable reason. Filterable via * `wppo_fragile_handle_map` (has_filter-guarded, fail-open to the * built-in map). Multisite-safe: static data only. * * @since 2.3.0 * * @return array<string,array{fields:string[],reason:string}> Fragment => meta. */staticfunctionget_fragile_handle_map():array{=array(\'jquery-core\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'jQuery core — deferring or delaying breaks dependent scripts.\',),\'jquery-migrate\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'jQuery Migrate — deferring or delaying breaks dependent scripts.\',),\'jquery\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'jQuery — deferring or delaying breaks dependent scripts.\',),\'wc-cart-fragments\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce cart fragments — delaying breaks the mini-cart AJAX refresh.\',),\'cart-fragments\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce cart fragments — delaying breaks the mini-cart AJAX refresh.\',),\'wc-checkout\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce checkout — deferring breaks payment and validation scripts.\',),\'wc-add-to-cart\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce add-to-cart — delaying breaks shop interactions.\',),\'wc-blocks\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce Blocks — deferring breaks Store API interactivity.\',),\'wc-store\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce Store API — deferring breaks cart and checkout blocks.\',),\'woocommerce\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'WooCommerce — deferring breaks cart and checkout flows.\',),\'elementor-frontend\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Elementor frontend runtime — delaying breaks builder layout and widgets.\',),\'elementor-pro-frontend\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Elementor Pro runtime — delaying breaks builder widgets.\',),\'et-core-api\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Divi builder runtime — delaying breaks builder layout.\',),\'divi-custom-script\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Divi custom script — delaying breaks builder layout.\',),\'kadence\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Kadence runtime — delaying breaks blocks and layout.\',),\'wp-interactivity\'=>array(\'fields\'=>array(\'excludeDeferJS\',\'excludeDelayJS\'),\'reason\'=>\'Block interactivity runtime — deferring breaks interactive blocks.\',),);if(!function_exists(\'has_filter\')||!function_exists(\'apply_filters\')||!has_filter(\'wppo_fragile_handle_map\')){return;}try{=apply_filters(\'wppo_fragile_handle_map\',);}catch(\\Throwable){unset();return;}if(!is_array()){return;}=array();foreach(as=>){if(!is_string()&&!is_numeric()){continue;}=trim((string));if(\'\'===||!is_array()){continue;}=strtolower();if(isset([])){continue;}=array(\'excludeDeferJS\',\'excludeDelayJS\');=array();if(isset([\'fields\'])&&is_array([\'fields\'])){foreach([\'fields\']as){if(is_string()||is_numeric()){=trim((string));if(in_array(,,true)){[]=;}}}=array_values(array_unique());}if(empty()){=array(\'excludeDeferJS\',\'excludeDelayJS\');}=isset([\'reason\'])&&is_string([\'reason\'])?[\'reason\']:\'\';[]=array(\'fields\'=>,\'reason\'=>,);}return!empty()?:;}/** * Map enqueued handles to fragile-handle exclude suggestions (issue #1465). * * Case-insensitive substring match against {@see get_fragile_handle_map()}, * jQuery/cart/builders first via map order. Returns at most 20 * suggestions, deduped by handle. Fail-open: any failure returns an * empty array (unoptimised guidance only, never fatal). * * @since 2.3.0 * * @param string[] $handles Enqueued script/style handles. * @return array<int,array{handle:string,fields:string[],reason:string}> Suggestions. */staticfunctiondetect_fragile_handles(array):array{try{=self::get_fragile_handle_map();if(empty()||empty()){returnarray();}=array();=array();foreach(as){if(!is_string()&&!is_numeric()){continue;}=(string);if(\'\'===||isset([strtolower()])){continue;}=strtolower();foreach(as=>){=strtolower((string));if(\'\'===){continue;}if(false!==strpos(,)){=isset([\'fields\'])&&is_array([\'fields\'])?array_values([\'fields\']):array(\'excludeDeferJS\',\'excludeDelayJS\');=isset([\'reason\'])&&is_string([\'reason\'])?[\'reason\']:\'\';[]=array(\'handle\'=>,\'fields\'=>,\'reason\'=>,);[strtolower()]=true;break;}}if(count()>=20){break;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Current minify/combine/defer stack state for safe mode (issue #1465). * * Single choke point for the detector REST endpoint and the * one-click UI so the stack definition cannot drift between * call sites. Fail-open to all-off on any failure. * * @since 2.3.0 * * @param array $file_optimisation Optional `file_optimisation` slice. * @return array{safe_mode:bool,delay_js:bool,defer_js:bool,combine_css:bool,remove_unused_css:bool,stack_enabled:bool} Stack flags. */staticfunctionget_safe_mode_stack_state(array=array()):array{try{if(empty()&&class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){try{=(array)Util::get_settings();=isset([\'file_optimisation\'])&&is_array([\'file_optimisation\'])?[\'file_optimisation\']:array();}catch(\\Throwable){unset();}}=self::is_safe_mode_active();=!empty([\'delayJS\']);=!empty([\'deferJS\']);=!empty([\'combineCSS\']);=!empty([\'removeUnusedCSS\']);returnarray(\'safe_mode\'=>,\'delay_js\'=>,\'defer_js\'=>,\'combine_css\'=>,\'remove_unused_css\'=>,\'stack_enabled\'=>(||||)&&!,);}catch(\\Throwable){unset();returnarray(\'safe_mode\'=>false,\'delay_js\'=>false,\'defer_js\'=>false,\'combine_css\'=>false,\'remove_unused_css\'=>false,\'stack_enabled\'=>false,);}}/** * Build the one-click safe-mode enable payload (issue #1465). * * Returns the production `file_optimisation` slice with `safeMode` * forced on while every other setting is preserved untouched, so * disabling safe mode later restores the previous configuration * without re-entering settings. Pure function for testability; * persistence lives in the REST handler (per-site wppo_settings). * Fail-open: any failure returns the input unchanged with safeMode on. * * @since 2.3.0 * * @param array $file_optimisation Production slice. * @return array Slice with safeMode enabled. */staticfunctionbuild_safe_mode_enable_payload(array=array()):array{try{[\'safeMode\']=true;return;}catch(\\Throwable){unset();returnarray(\'safeMode\'=>true);}}/** * Whether defer-JS is disabled for a singular page (issue #1098). * * Reads the `_wppo_defer_disabled` post-meta kill-switch. Mirrors * {@see is_delay_disabled_for_page()} with its own blog-scoped * request cache so delay/defer states never cross-contaminate. * Fail-open: any detection failure returns false (defer stays enabled). * * @since 2.2.0 * * @param int $post_id Optional post ID. Defaults to the current post. * @return bool True when defer must be skipped for this page. */staticfunctionis_defer_disabled_for_page(int=0):bool{try{if(function_exists(\'is_singular\')&&!is_singular()&&0===){returnfalse;}if(0===){if(!function_exists(\'get_the_ID\')){returnfalse;}=(int)get_the_ID();}if(<=0){returnfalse;}=0;if(function_exists(\'is_multisite\')&&function_exists(\'get_current_blog_id\')){try{if(is_multisite()){=(int)get_current_blog_id();}}catch(\\Throwable){unset();=0;}}=.\':\'.;if(isset(self::[])){returnself::[];}if(!function_exists(\'get_post_meta\')){returnfalse;}=!empty(get_post_meta(,\'_wppo_defer_disabled\',true));self::[]=;return;}catch(\\Throwable){unset();returnfalse;}}/** * Clear the per-page delay kill-switch request cache and purge that URL only. * * Called when the `_wppo_delay_disabled` meta toggles (metabox save or * programmatic meta write) so the next frontend hit for that URL renders * with the new delay state. Purges only the single post URL\'s static * HTML/CSS sidecars via `Cache::invalidate_single_static_html()` — never * a full-cache wipe. Multisite-safe: per-site post/meta, domain-based * cache paths, no cross-site leakage. Fail-open: any failure is * swallowed so meta saves never fatal. * * @since 2.0.0 * @param int $post_id Post ID whose kill-switch changed. * @return void */staticfunctioninvalidate_delay_kill_switch_cache(int):void{self::invalidate_aggressive_kill_switch_cache();}/** * Clear per-page aggressive-optimisation caches and purge that URL only. * * Unified single-URL purge (issue #1098) for the `_wppo_delay_disabled`, * `_wppo_defer_disabled`, and `_wppo_used_css_disabled` per-page * kill-switches so per-page state survives cache clears: post meta * itself is never stored in the page cache, and toggling any of the * three metas purges only that post URL\'s static HTML/CSS/used-CSS * sidecars via `Cache::invalidate_single_static_html()` — never a * full-cache wipe. Multisite-safe: per-site post/meta, domain-based * cache paths, no cross-site leakage. Fail-open: swallowed. * * @since 2.2.0 * @param int $post_id Post ID whose kill-switch changed. * @return void */staticfunctioninvalidate_aggressive_kill_switch_cache(int):void{try{if(<=0){return;}foreach(array_keys(self::)as){if(str_ends_with((string),\':\'.(string))){unset(self::[]);}}foreach(array_keys(self::)as){if(str_ends_with((string),\':\'.(string))){unset(self::[]);}}if(class_exists(\'PerformanceOptimise\\Inc\\Cache\')){try{=array();if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'get_settings\')){=(array)Util::get_settings();}=self::create_cache();if(is_object()&&method_exists(,\'invalidate_single_static_html\')){->invalidate_single_static_html();}}catch(\\Throwable){unset();}}}catch(\\Throwable){unset();}}/** * Handle aggressive-optimisation meta writes for the per-page kill-switches. * * Wired to `added_post_meta` / `updated_post_meta` / `deleted_post_meta` * in `setup_hooks()` so programmatic meta changes (REST, WP-CLI, imports) * purge the single URL just like the metabox save path. Reacts to the * `_wppo_delay_disabled`, `_wppo_defer_disabled`, and * `_wppo_used_css_disabled` keys; everything else is ignored. Fail-open: * detection or purge failures never fatal the meta write. * * @since 2.2.0 * @param mixed $meta_id Meta row ID for added/updated hooks, or an array of IDs for deleted_post_meta (unused, required by hook signature). * @param int $post_id Post ID the meta belongs to. * @param string $meta_key Meta key that was written. * @return void */functionon_aggressive_kill_switch_meta_changed(,,):void{try{=(string);if(\'_wppo_delay_disabled\'!==&&\'_wppo_defer_disabled\'!==&&\'_wppo_used_css_disabled\'!==){return;}self::invalidate_aggressive_kill_switch_cache((int));}catch(\\Throwable){unset();}}/** * Handle `_wppo_delay_disabled` meta writes for the per-page kill-switch. * * Wired to `added_post_meta` / `updated_post_meta` / `deleted_post_meta` * in `setup_hooks()` so programmatic meta changes (REST, WP-CLI, imports) * purge the single URL just like the metabox save path. Only reacts to * the `_wppo_delay_disabled` key; everything else is ignored. Fail-open: * detection or purge failures never fatal the meta write. * * @since 2.0.0 * @param mixed $meta_id Meta row ID for added/updated hooks, or an array of IDs for deleted_post_meta (unused, required by hook signature). * @param int $post_id Post ID the meta belongs to. * @param string $meta_key Meta key that was written. * @return void */functionon_delay_kill_switch_meta_changed(,,):void{->on_aggressive_kill_switch_meta_changed(,,);}/** * Curated third-party Delay-JS denylist (one-click delay, issue #1217). * * Host/keyword fragments that are safe to delay with one click: * analytics, ads, social, chat and video embeds. Payment gateways * (Stripe, PayPal) and consent-management banners (Cookiebot, * OneTrust, TrustArc, Quantcast) are intentionally NOT in this * preset: payment SDKs also run on product pages (express checkout) * and site-wide (fraud detection), and consent banners must stay * eager for GDPR/ePrivacy ordering (consent before trackers). Users * who want them delayed can add them via the extra-denylist * textarea. The user allowlist always wins over this list. * Filterable via `wppo_delay_js_third_party_denylist`. Fail-open: * filter failures fall back to the curated preset. * * Note: no per-request memoization is used on purpose — the filter * call is cheap and caching would go stale on mid-request * add/remove_filter, switch_to_blog, or sequential unit tests. * * Keep-in-sync note: this denylist overlaps ~15 vendor hosts with the * auto-mode preset in get_delay_js_third_party_auto_patterns() * (#1314). The lists are intentionally separate (manual mode pairs * keywords with a generic cross-origin rule; auto mode matches known * vendors only), so adding a vendor may need an edit in both places. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_denylist}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_denylist():array{returnScript_Strategy::get_delay_js_third_party_denylist();}/** * Parse the user-configured third-party allowlist for a settings slice. * * Single shared helper for the Main and Minify\\HTML auto-delay mirrors * so allowlist semantics stay in one place. Reads * `file_optimisation.delayJSThirdPartyAllowlist` (one entry per line) * plus the `wppo_delay_js_third_party_allowlist` filter. Fail-open: * any failure returns an empty list. Memoized per request keyed by the * raw value when no filter is registered; bypassed when a filter is * present so dynamic callbacks always run. * * @since 2.2.0 * @param array $file_opt Effective file_optimisation slice. * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_allowlist_for_slice}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_allowlist_for_slice(array):array{returnScript_Strategy::get_delay_js_third_party_allowlist_for_slice();}/** * Parse the user-configured third-party allowlist (wins over denylist). * * Reads the effective (sandbox-staged) slice so preview renders staged * edits instead of production values on the script_loader_tag path. * Delegates to get_delay_js_third_party_allowlist_for_slice(). * Fail-open: any failure returns an empty list. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_allowlist}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_js_third_party_allowlist():array{return->script_strategy()->get_delay_js_third_party_allowlist();}/** * Whether a script tag/handle is a third-party delay candidate. * * When one-click third-party delay (`delayJSThirdParty`) is on, only * external scripts whose src host differs from the site host — or * whose handle/src matches the curated denylist plus user additions — * are delayed. Inline scripts (no src) are never delayed in this mode. * The user allowlist always wins (returns false). Any detection * failure fails open to false (leave un-delayed). * * Matching semantics: handles use word-boundary matching (consistent * with the rest of delay matching); src/URL matching is substring. * * @since 2.2.0 * @param string $tag Script tag markup. * @param string $handle Script handle. * @return bool True when the script should be delayed in third-party mode. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_third_party_candidate}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_third_party_candidate(string,string):bool{return->script_strategy()->is_delay_third_party_candidate(,);}/** * Whether two script hosts belong to the same site (issue #1217 review). * * Hosts are lowercased, trailing dots trimmed, and a leading `www.` * stripped, then compared equal-or-subdomain in either direction, so a * first-party CDN (`cdn.example.com`), `www` vs apex mismatches, and * apex-vs-subdomain pairs stay eager instead of being misclassified as * third-party. Only genuinely foreign hosts auto-qualify. Fail-open to * false (not same-site) on any error. * * Note: sibling subdomains sharing only a parent (e.g. * `shop.example.com` vs `cdn.example.com`) are conservatively treated * as third-party; add the CDN host to the allowlist in that setup. * * @since 2.2.0 * @param string $a First host. * @param string $b Second host. * @return bool True when both hosts belong to the same site. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_same_site_script_host}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionis_same_site_script_host(string,string):bool{returnScript_Strategy::is_same_site_script_host(,);}/** * Labelled auto third-party vendor categories (issue #1385). * * Single source of truth for the auto detector: analytics, ads, and * social buckets (chat/video/embeds roll into social so every curated * vendor carries exactly one label). The flat pattern list in * get_delay_js_third_party_auto_patterns() merges these buckets, so * the categories can never drift from the matcher. Filterable via * wppo_delay_js_third_party_auto_categories (has_filter-guarded, * fail-open to the curated buckets). * * @since 2.3.0 * @return array<string, string[]> * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_auto_categories}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_auto_categories():array{returnScript_Strategy::get_delay_js_third_party_auto_categories();}/** * Label a script src/handle with its auto third-party category (issue #1385). * * Returns analytics, ads, or social for curated vendors, or an empty * string when the input is not an auto candidate (including * WooCommerce fragments plus cart AJAX, which are skipped via * get_delay_js_commerce_exclusions()). Fail-open: any failure * returns an empty string (unlabelled, left eager). * * @since 2.3.0 * * @param string $src_or_handle Script src URL, tag markup, or handle. * @return string Category label or empty string. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_auto_label}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_auto_label(string):string{returnScript_Strategy::get_delay_js_third_party_auto_label();}/** * Reset the auto third-party pattern memo (for tests). * * @since 2.2.0 * @return void * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::reset_delay_third_party_auto_cache}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionreset_delay_third_party_auto_cache():void{Script_Strategy::reset_delay_third_party_auto_cache();}/** * Curated known-vendor URL patterns for auto third-party delay (#1314). * * URL-host-oriented fragments (analytics, ads, social, chat, video * embeds, error tracking) matched as substrings against the script * src. Filterable via `wppo_delay_js_third_party_auto_patterns` * (has_filter-guarded; callbacks should merge/append rather than * replace). Fail-open: non-array or throwing callbacks fall back to * the built-in preset; a valid empty array is honored and disables * auto mode (silent no-op by explicit filter choice). Lazily booted: * callers must only invoke this when Delay-JS (and the auto toggle) * is enabled. Memoized per blog id when no filter is registered so * multisite sites with blog-dependent filters never share memoized * patterns; bypassed when a filter is present so dynamic callbacks * always run. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_third_party_auto_patterns}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_third_party_auto_patterns():array{returnScript_Strategy::get_delay_js_third_party_auto_patterns();}/** * Whether a handle/tag matches the curated auto third-party patterns (#1314). * * Handles use word-boundary matching (consistent with the rest of delay * matching) via a single precompiled alternation; the src extracted * from the tag (or a full tag passed as $tag) uses substring matching * because URLs rarely align on word boundaries. Handle matching uses * the pre-slash segment only (e.g. `linkedin.com` for * `linkedin.com/insight`) because WP handles never contain slashes. * Fail-open to false on any error. The pattern list lazy-boots here, * so callers must gate on the auto toggle first. * * @since 2.2.0 * @param string $handle Script handle (may be empty on buffered paths). * @param string $tag Script tag markup or src URL. * @return bool True on match. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::matches_third_party_auto_pattern}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionmatches_third_party_auto_pattern(string,string):bool{returnScript_Strategy::matches_third_party_auto_pattern(,);}/** * Whether a script tag/handle is an auto third-party delay candidate (#1314). * * Builder and commerce exclusions are unconditional: excluded contexts * (cart/checkout/account, builder previews) never auto-delay. The user * allowlist always wins. Manual exclusions and per-page overrides are * applied by the caller (add_defer_attribute) after this gate, so auto * patterns merge additively and never replace them. Any detection * failure fails open to false (leave un-delayed). * * @since 2.2.0 * @param string $tag Script tag markup. * @param string $handle Script handle. * @return bool True when the script should be delayed in auto mode. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_third_party_auto_candidate}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_third_party_auto_candidate(string,string):bool{return->script_strategy()->is_delay_third_party_auto_candidate(,);}/** * Inject an attribute string into a script open tag (issue #1217 review). * * Case-insensitive single-occurrence insert that handles `<script>`, * `<script `, `<script\\n` (and uppercase `<SCRIPT …>`) variants, so * every delayed tag is stamped even when core emits non-lowercase * markup. Falls back to the original tag when no script open tag is * found or the rewrite fails. * * @since 2.2.0 * @param string $tag Script tag markup. * @param string $insert Attribute string including trailing space, e.g. \'fetchpriority=\"low\" \'. * @return string Tag with the attributes injected. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::inject_delay_script_attr}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctioninject_delay_script_attr(string,string):string{returnScript_Strategy::inject_delay_script_attr(,);}/** * Base Delay JS preset exclusions (always applied, issue #966). * * Safe-by-default: WooCommerce, Elementor, and form plugins are always * excluded so checkout and forms never break. Shared by * get_delay_js_preset_exclusions() and * get_delay_js_protected_exclusions() so the per-page opt-out * protection set cannot drift from the merged preset. * * @since 2.2.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_base_preset_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_base_preset_exclusions():array{returnScript_Strategy::get_delay_js_base_preset_exclusions();}/** * Manual + safe exclusions a per-page preset opt-out must never strip (issue #1308). * * The removal list for an opted-out compat preset is diffed against * this set first, so overlapping strings (e.g. gtag, jquery) that are * also contributed by manual exclusions or safe presets stay eager. * Fail-open: any detection failure returns an empty list (no * protection), degrading to the previous subtract behavior. * * @since 2.2.0 * * @param array $file_opt file_optimisation settings slice. * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_protected_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */staticfunctionget_delay_js_protected_exclusions(array):array{returnScript_Strategy::get_delay_js_protected_exclusions();}/** * Get curated delay JS preset exclusions (jquery, recaptcha, stripe, analytics, etc.). * * Safe-by-default: WooCommerce, Elementor, and form plugins are always * excluded so checkout and forms never break. Filterable via * wppo_delay_js_exclusions. Preset prevents breakage on 10% sites. * * @since 2.0.0 * @return string[] * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::get_delay_js_preset_exclusions}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionget_delay_js_preset_exclusions():array{return->script_strategy()->get_delay_js_preset_exclusions();}/** * Whether Delay-JS must be skipped for the current request (fail-open safe context). * * Returns true (serve undeferred) on WooCommerce dynamic pages * (cart/checkout/account/endpoints) or when a known form shortcode/block * is present in the current post content. Any detection failure fails * open to safe (no delay) so interactivity is never broken. * * @since 2.0.0 * @return bool True when Delay-JS must be skipped. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::is_delay_js_safe_context}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionis_delay_js_safe_context():bool{return->script_strategy()->is_delay_js_safe_context();}/** * Adds fetchpriority to rendered script tags for deferred handles. * * Pre-6.9 fallback only: on WP 6.9+ the native fetchpriority arg passed via * wp_script_add_data() in add_defer_strategy() is rendered by core * (this filter is not registered there via setup_hooks()). Honors the * shared wppo_deferred_fetchpriority filter so a handle can stay * \'high\' or suppress via falsy; \'\' leaves the tag untouched. * Case-insensitive single-occurrence injection handles `<SCRIPT>`, * `<script\\n`, and `<script>` variants via inject_delay_script_attr(). * * @since 1.9.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @return string Modified script tag with fetchpriority. * Facade proxy (ARCH-005): logic lives in {@see Script_Strategy::add_fetchpriority_to_deferred}. * @since 2.4.0 Proxied to Script_Strategy (ARCH-005). */functionadd_fetchpriority_to_deferred(,):string{return->script_strategy()->add_fetchpriority_to_deferred(,);}/** * Reset the per-request font preload dedup guard. * * Wired to `switch_blog` in {@see Main::setup_hooks()} alongside * `Image_Optimisation::clear_runtime_caches()` so the dedup map * cannot leak across sites in `switch_to_blog()` requests; also * called directly in tests. * * @since 2.2.0 * @return void */staticfunctionreset_font_preload_emitted():void{self::=array();self::=array();self::=array();}/** * Reset the per-instance LCP memos on the shared image-optimisation instance. * * Wired to `switch_blog` in {@see Main::setup_hooks()} (issue #1216): the * Image_Optimisation instance is long-lived via Main, so its * memoized LCP URLs would otherwise leak across sites in * `switch_to_blog()` requests. Accepts the switch_blog args so the * hook passes ($new_blog_id, $prev_blog_id) without warnings. * Also called directly in tests. * * @since 2.2.0 * @param int $new_blog_id New blog ID (unused). * @param int $prev_blog_id Previous blog ID (unused). * @return void */staticfunctionreset_image_lcp_memos(=0,=0):void{unset(,);try{=self::get_instance();if(instanceofself&&isset(->image_optimisation)&&->image_optimisationinstanceof\\PerformanceOptimise\\Inc\\Image_Optimisation){->image_optimisation->clear_instance_lcp_memo();}}catch(\\Throwable){unset();}}/** * Extract font file URLs from a CSS string. * * Pure helper (issue #1216): matches `url(...)` values whose path * carries a woff2/woff/ttf extension, drops data:/blob:/javascript: * schemes, trims to 2048 chars, dedups preserving document order * with woff2 preferred within each `@font-face` block (never * reordered across families so a secondary family\'s woff2 cannot * outrank the primary family\'s woff under the cap-2 slice). * Never fatals: any failure returns an empty list. * * @since 2.2.0 * @param string $css CSS text to scan. * @return string[] Ordered unique font URLs. */staticfunctionextract_font_urls_from_css(string):array{try{if(\'\'===trim()){returnarray();}=substr(,0,524288);=array();if(preg_match_all(\'/@font-face\\s*\\{[^}]*\\}/is\',,)&&!empty([0])){=[0];}else{=array();}=array();foreach(as){=substr(,0,524288);if(!preg_match_all(\'/url\\(\\s*[\\\'\"]?([^\\\'\")]+)[\\\'\"]?\\s*\\)/i\',,)){continue;}=array();foreach([1]as){=trim((string));if(\'\'===||strlen()>2048){continue;}=strtolower(ltrim());if(str_starts_with(,\'data:\')||str_starts_with(,\'blob:\')||str_starts_with(,\'javascript:\')||str_starts_with(,\'vbscript:\')){continue;}=function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH);if(!is_string()||\'\'===||1!==preg_match(\'/\\.(woff2|woff|ttf)(\\?.*)?$/i\',)){continue;}[]=;}=array_values(array_unique());usort(,staticfunction(,){=staticfunction(){=strtolower((string)(function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH)));if(str_ends_with(,\'.woff2\')){return0;}if(str_ends_with(,\'.woff\')){return1;}return2;};return()<=>();});foreach(as){if(!in_array(,,true)){[]=;}}}return;}catch(\\Throwable){unset();returnarray();}}/** * Map a font URL to its preload `type` attribute. * * @since 2.2.0 * @param string $font_url Font URL. * @return string MIME type (possibly empty). */functionfont_type_for_url(string):string{try{=function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH);=is_string()?strtolower(pathinfo(,PATHINFO_EXTENSION)):\'\';switch(){case\'woff2\':return\'font/woff2\';case\'woff\':return\'font/woff\';case\'ttf\':return\'font/ttf\';default:return\'\';}}catch(\\Throwable){unset();return\'\';}}/** * Whether a font candidate URL is same-origin with this site. * * Fail-closed (issue #1216): absolute URLs validate via * `RUM::is_same_origin_url()` when available, else a guarded * home-host comparison; root-relative and bare relative paths are * same-origin by construction. Any failure returns false. * * @since 2.2.0 * @param string $url Candidate URL. * @return bool True when the URL may be preloaded. */functionis_same_origin_font_url(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(0===strpos(,\'/\')&&0!==strpos(,\'//\')){returntrue;}if(false===strpos(,\'://\')&&0!==strpos(,\'//\')){=strtok(,\'/\\\\?#\');if(is_string()&&false!==strpos(,\':\')){returnfalse;}returntrue;}if(class_exists(\'PerformanceOptimise\\Inc\\RUM\')&&method_exists(\'PerformanceOptimise\\Inc\\RUM\',\'is_same_origin_url_strict\')){return\\PerformanceOptimise\\Inc\\RUM::is_same_origin_url_strict();}if(class_exists(\'PerformanceOptimise\\Inc\\RUM\')&&method_exists(\'PerformanceOptimise\\Inc\\RUM\',\'is_same_origin_url\')){return\\PerformanceOptimise\\Inc\\RUM::is_same_origin_url();}if(function_exists(\'wp_parse_url\')&&function_exists(\'home_url\')){=strtolower((string)wp_parse_url(,PHP_URL_HOST));=strtolower((string)wp_parse_url(home_url(),PHP_URL_HOST));return\'\'!==&&===;}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Normalize a font URL for manual-wins dedup. * * Builds on `Util::normalize_url()` (host + path, size-suffix aware) * but re-attaches the truncated query string (issue #1216): font * files versioned via `?v=1` vs `?v=2` are distinct resources and must * not collapse to one tag. Long-lived processes (CLI/cron rendering N * pages with one instance) must call {@see reset_font_preload_emitted()} * between pages or the per-request emitted guard skips page-2 repeats. * * @since 2.2.0 * @param string $url Font URL. * @return string Dedup key. */functionnormalize_font_url(string):string{try{=\'\';if(function_exists(\'wp_parse_url\')){=wp_parse_url(,PHP_URL_QUERY);if(is_string()&&\'\'!==){=\'?\'.substr(,0,256);}}else{=strpos(,\'?\');if(false!==){=\'?\'.substr(substr(,+1),0,256);}}if(class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'normalize_url\')){=Util::normalize_url();if(\'\'!==){return.;}}}catch(\\Throwable){unset();}returnstrtolower(trim());}/** * Read the manual font preload URL list (resolved to absolute URLs). * * Manual inputs resolve through `resolve_font_url()` with an empty * stylesheet base (issue #1216) — the same resolver auto-discovery * uses — so root-relative refs anchor at `home_url()` and bare * relatives at the home URL, never at `WP_CONTENT_URL`. Shared * resolution keeps the manual-wins normalized-URL dedup comparing * like with like instead of missing across bases. * * @since 2.2.0 * @param array $preload_settings Preload settings tab. * @return string[] Absolute manual font URLs. */functionget_manual_font_urls(array):array{try{if(empty([\'preloadFonts\'])||empty([\'preloadFontsUrls\'])){returnarray();}=Util::process_urls([\'preloadFontsUrls\']);=array();foreach(as){=trim((string));if(\'\'===){continue;}=->resolve_font_url(,\'\');if(\'\'===){continue;}[]=substr(,0,2048);}returnarray_values(array_unique());}catch(\\Throwable){unset();returnarray();}}/** * Resolve a same-origin stylesheet URL to a contained local path. * * Single shared implementation (issue #1216) for * `collect_enqueued_font_css_chunks()` and `font_stylesheet_stamp()` * so a future hardening fix cannot land in one copy only: maps the * URL path under ABSPATH, canonicalizes with realpath (rejecting * `..` escapes), and refuses paths outside ABSPATH before any * file_exists/filesize/file_get_contents probe. Returns \'\' when * unresolvable or outside containment. Never fatals. * * @since 2.2.0 * @param string $abs_src Absolute same-origin stylesheet URL. * @return string Canonical local path, or \'\'. */functionresolve_local_stylesheet_path(string):string{try{=function_exists(\'wp_parse_url\')?wp_parse_url(,PHP_URL_PATH):parse_url(,PHP_URL_PATH);if(!is_string()||\'\'===){return\'\';}=(defined(\'ABSPATH\')?(string)ABSPATH:\'\').ltrim(,\'/\');if(function_exists(\'wp_normalize_path\')){=wp_normalize_path();}=realpath();if(!is_string()){return\'\';}if(function_exists(\'wp_normalize_path\')){=wp_normalize_path();}=(function_exists(\'wp_normalize_path\')&&defined(\'ABSPATH\'))?wp_normalize_path((string)ABSPATH):(string)(defined(\'ABSPATH\')?ABSPATH:\'\');if(\'\'===||0!==strpos(,rtrim(,\'/\').\'/\')){return\'\';}return;}catch(\\Throwable){unset();return\'\';}}/** * Collect CSS text chunks from enqueued stylesheets (bounded). * * Scans inline `before`/`after` CSS plus same-origin stylesheet file * contents (512 KB per file, 10 handles max). Only queued (actually * printed) handles are scanned so discovery matches the page output. * Each chunk carries its stylesheet base URL so CSS-relative font * refs resolve against the enclosing stylesheet. Fail-open: any * failure returns the chunks collected so far. * * @since 2.2.0 * @return array[] Chunks shaped as array{css: string, base: string}. */functioncollect_enqueued_font_css_chunks():array{=->collect_font_css_chunks_and_hashes();return[\'chunks\'];}/** * Collect CSS chunks plus inline-CSS key hashes in one pass. * * Same scan as `collect_enqueued_font_css_chunks()` but additionally * returns md5 hashes of the scanned inline `before`/`after` CSS so * `get_auto_discovered_font_urls()` builds the transient key without * looping the queue twice per request (issue #1216). The key loop * and the chunk loop previously duplicated stripos/implode/md5 work * on the hot path, including on cache hits. * * @since 2.2.0 * @return array Shaped as array{chunks: array[], inline_hashes: string[]}. */functioncollect_font_css_chunks_and_hashes():array{=array();=array();try{if(!isset([\'wp_styles\'])||!is_object([\'wp_styles\'])){returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}=[\'wp_styles\']->registered??null;if(!is_array()){if(is_object()&&method_exists(,\'getArrayCopy\')){=->getArrayCopy();}else{returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}}=(isset([\'wp_styles\']->queue)&&is_array([\'wp_styles\']->queue))?[\'wp_styles\']->queue:array();if(empty()){returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}=0;=0;foreach(array_slice(,0,10)as){if(>=10){break;}=[]??null;if(!is_object()){continue;}=->extra??array();if(is_array()){foreach(array(\'after\',\'before\')as){if(empty([])){continue;}=is_array([])?implode(\"\\n\",[]):(string)[];if(\'\'!==trim()&&false!==stripos(,\'font-face\')){[]=array(\'css\'=>substr(,0,524288),\'base\'=>\'\',);[]=md5(substr(,0,524288));++;if(>=10){break2;}}}}=is_string(->src??null)?(string)->src:\'\';if(\'\'!==){=strtok(,\'?\');if(!is_string()||1!==preg_match(\'/\\.css$/i\',)){continue;}}if(\'\'===){continue;}=preg_match(\'/^(?:https?:)?\\/\\//i\',)?:Util::cached_content_url();if(!->is_same_origin_font_url()){continue;}=->resolve_local_stylesheet_path();if(\'\'===){continue;}=0;if(file_exists()){=filesize();}if(!is_int()&&!is_float()){continue;}if((int)<=0||(int)>524288){continue;}=false;if(is_object(->filesystem)&&method_exists(->filesystem,\'get_contents\')){=->filesystem->get_contents();}if(!is_string()){=file_get_contents();}if(is_string()&&\'\'!==trim()&&false!==stripos(,\'font-face\')){+=strlen();[]=array(\'css\'=>substr(,0,524288),\'base\'=>,);++;if(>1048576){break;}}}}catch(\\Throwable){unset();}returnarray(\'chunks\'=>,\'inline_hashes\'=>,);}/** * Resolve a font URL found in CSS against its stylesheet base. * * Absolute URLs pass through unchanged. Root-relative refs * (`/fonts/x.woff2`) resolve against `home_url()` and * stylesheet-relative refs (`../fonts/x.woff2`, `fonts/x.woff2`) * resolve against the enclosing stylesheet directory (issue #1216); * inline `<style>` chunks (empty base) resolve root-relative refs * against `home_url()` and bare relatives against the home URL so * no `wp-content`-based guess can emit a 404 preload. Protocol- * relative URLs (`//host/...`) pass through for the same-origin * guard to judge. Never fatals: any failure returns the trimmed * input unchanged. * * @since 2.2.0 * @param string $font_url Font URL as written in CSS. * @param string $base_src Absolute stylesheet URL (or empty for inline CSS). * @return string Resolved absolute-or-relative URL. */functionresolve_font_url(string,string=\'\'):string{try{=trim();if(\'\'===){return\'\';}if(preg_match(\'/^https?:\\/\\//i\',)||0===strpos(,\'//\')){returnsubstr(,0,2048);}=0===strpos(,\'/\');if(&&function_exists(\'home_url\')){=(string)home_url();returnsubstr(->normalize_font_href(rtrim(,\'/\').),0,2048);}if(\'\'!==){=strtok(,\'?#\');if(!is_string()||\'\'===){=;}=rtrim(dirname(),\'/\').\'/\';returnsubstr(->normalize_font_href(.ltrim(,\'/\')),0,2048);}if(function_exists(\'home_url\')){=(string)home_url();returnsubstr(->normalize_font_href(rtrim(,\'/\').\'/\'.ltrim(,\'/\')),0,2048);}returnsubstr(->normalize_font_href(),0,2048);}catch(\\Throwable){unset();returnsubstr(trim(),0,2048);}}/** * Canonicalize a font href by resolving dot-segments. * * Resolves `/./` and `/../` against the directory path so * stylesheet-relative refs (`../fonts/x.woff2`) emit canonical * preload hrefs for dedup, caching, and audit tooling (issue * #1216). Query strings and fragments are preserved. Browsers * resolve uncanonical hrefs identically, so this is purely a * canonicalization step. Never fatals: any failure returns the * input unchanged. * * @since 2.2.0 * @param string $href Absolute or protocol-relative href. * @return string Canonicalized href. */functionnormalize_font_href(string):string{try{=\'\';=strpos(,\'#\');if(false!==){=substr(,);=substr(,0,);}=\'\';=strpos(,\'?\');if(false!==){=substr(,);=substr(,0,);}if(preg_match(\'#^(https?://[^/]+)(/.*)$#i\',,)){return[1].->normalize_font_path([2])..;}if(0===strpos(,\'//\')&&preg_match(\'#^(//[^/]+)(/.*)$#\',,)){return[1].->normalize_font_path([2])..;}if(0===strpos(,\'/\')){return->normalize_font_path()..;}return..;}catch(\\Throwable){unset();return;}}/** * Resolve dot-segments in a URL path. * * @since 2.2.0 * @param string $path URL path starting with `/`. * @return string Normalized path. */functionnormalize_font_path(string):string{=0===strpos(,\'/\');=explode(\'/\',);=array();foreach(as){if(\'\'===||\'.\'===){continue;}if(\'..\'===){if(!empty()){array_pop();}continue;}[]=;}=implode(\'/\',);if(){=\'/\'.;}if(\'\'!==&&\'/\'!==&&str_ends_with(,\'/\')&&!str_ends_with(,\'/\')){.=\'/\';}if(\'\'===&&){=\'/\';}return;}/** * Best-effort file stamp (mtime:size) for a stylesheet src. * * Used only for the auto-font transient key so same-ver CSS edits bust * the 12h cache (issue #1216). Shares * {@see resolve_local_stylesheet_path()} containment with the chunk * collector; unresolvable or non-local files yield \'\' (key falls back * to src|ver). Memoized per request so re-entrant `wp_head` * emissions do not repeat stat syscalls. Never fatals. * * @since 2.2.0 * @param string $src Stylesheet src as registered. * @return string Stamp shaped as \"mtime:size\" or \'\'. */functionfont_stylesheet_stamp(string):string{try{=trim();if(\'\'===){return\'\';}if(isset(self::[])){returnself::[];}=\'\';=preg_match(\'/^(?:https?:)?\\/\\//i\',)?:(class_exists(\'PerformanceOptimise\\Inc\\Util\')?Util::cached_content_url():);if(->is_same_origin_font_url()){=->resolve_local_stylesheet_path();if(\'\'!==){=filemtime();=filesize();if(false!==||false!==){=(false===?\'0\':(string)(int)).\':\'.(false===?\'0\':(string)(int));}}}self::[]=;return;}catch(\\Throwable){unset();return\'\';}}/** * Resolve auto-discovered font preload URLs (capped at 2, manual wins). * * Gated on `preload_settings.autoDiscoverFonts` (off by default). * Candidates come from enqueued stylesheet `@font-face` URLs, * filtered same-origin, minus manual-list overlaps (normalized), then * capped at MAX_AUTO_FONT_PRELOADS. Results are cached in a * blog-aware transient (`Util::transient_key()`, multisite-safe, 12h) * keyed by stylesheet state, plus a per-request in-memory memo so * re-entrant `wp_head` emissions skip the transient round-trip. * Fail-open: any failure returns []. * * Cold-miss cost is bounded (issue #1216): at most 10 queued handles, * 512 KB per file, ~1 MB total before `wp_head` output, with a stat * size probe before every full read. File stamps are memoized per * request and the chunk scan runs once per call (chunks + inline key * hashes collected in a single pass). * * @since 2.2.0 * @param string[] $manual_urls Manual font URLs (win on conflict). * @return string[] Auto font URLs (zero to two items). */functionget_auto_discovered_font_urls(array=array()):array{try{=->get_options()[\'preload_settings\']??array();if(empty([\'autoDiscoverFonts\'])){returnarray();}=array();foreach(as){if(is_string()&&\'\'!==){[->normalize_font_url()]=true;}}=->collect_font_css_chunks_and_hashes();=[\'inline_hashes\'];=\'\';try{=array();=function_exists(\'home_url\')?strtolower((string)home_url()):\'\';if(isset([\'wp_styles\'])&&is_object([\'wp_styles\'])&&isset([\'wp_styles\']->queue)&&is_array([\'wp_styles\']->queue)){=array_slice([\'wp_styles\']->queue,0,10);=[\'wp_styles\']->registered??array();if(!is_array()){=array();}foreach(as){=[]??null;=is_object()?(string)(->src??\'\'):\'\';=is_object()?(string)(->ver??\'\'):\'\';=->font_stylesheet_stamp();[]=(string).\'|\'..\'|\'..\'|\'.;}}sort();=Util::transient_key(\'wppo_auto_fonts_\'.md5(.\'|\'.wp_json_encode().\'|\'.wp_json_encode()));}catch(\\Throwable){unset();=\'\';}if(\'\'!==&&isset(self::[])){return->filter_auto_font_urls(self::[],);}if(\'\'!==&&function_exists(\'get_transient\')){try{=get_transient();if(is_array()){self::[]=;return->filter_auto_font_urls(,);}}catch(\\Throwable){unset();}}=array();foreach([\'chunks\']as){=is_array()?(string)([\'css\']??\'\'):(string);=is_array()?(string)([\'base\']??\'\'):\'\';foreach(self::extract_font_urls_from_css()as){=->resolve_font_url(,);if(!->is_same_origin_font_url()){continue;}=->normalize_font_url();if(isset([])||isset([])){continue;}[]=substr(,0,2048);if(count()>=self::MAX_AUTO_FONT_PRELOADS){break2;}}}=array_values();if(\'\'!==){self::[]=;if(function_exists(\'set_transient\')){try{set_transient(,,defined(\'HOUR_IN_SECONDS\')?12*HOUR_IN_SECONDS:43200);}catch(\\Throwable){unset();}}}return;}catch(\\Throwable){unset();returnarray();}}/** * Filter candidate font URLs to the emission-safe subset (issue #1216). * * Shared by the transient-hit and per-request-memo paths so cached * values are re-validated on every read: a poisoned/stale transient * must not emit cross-origin fonts or shadow the manual list. Trims * to 2048 chars, enforces same-origin, drops manual-list overlaps * (normalized), dedups, and caps at MAX_AUTO_FONT_PRELOADS. * Never fatals: any failure returns []. * * @since 2.2.0 * @param mixed[] $urls Candidate URLs (e.g. from the transient). * @param array $manual_keys Normalized manual-URL keys winning on conflict. * @return string[] Clean auto font URLs (zero to two items). */functionfilter_auto_font_urls(array,array):array{try{=array();foreach(as){=is_string()?substr(trim(),0,2048):\'\';if(\'\'===||!->is_same_origin_font_url()){continue;}=->normalize_font_url();if(\'\'===||isset([])||isset([])){continue;}[]=;if(count()>=self::MAX_AUTO_FONT_PRELOADS){break;}}returnarray_values();}catch(\\Throwable){unset();returnarray();}}/** * Adds preload, prefetch, and preconnect links to optimize resource loading. * * Image preloads delegate to `Image_Optimisation::preload_images()`, * which emits exactly one `<link rel=\"preload\" as=\"image\" * fetchpriority=\"high\">` per URL for the single RUM-field → * PageSpeed LCP candidate (issue #991; Optimization Detective stays * Priority 0), deduped by normalized URL + query + media with a * per-request emitted guard, and excludes that candidate from lazy * load (gated on the LCP toggles, with normalized size-variant * matching). Core 6.9 `fetchpriority` stamping is never * double-applied (the stamp path only fills gaps via * `function_exists()`-guarded core calls). Manual preload-image meta * and the hero fallback remain when no RUM or PageSpeed candidate * resolves (fail-open). * * Runs on `wp_head` priority 1, before core resource-hints at * priority 2. * * @since 1.0.0 */functionadd_preload_prefetch_preconnect(){try{if(function_exists(\'is_admin\')&&is_admin()){return;}if(function_exists(\'is_feed\')&&is_feed()){return;}if(function_exists(\'is_embed\')&&is_embed()){return;}if(function_exists(\'is_preview\')&&is_preview()){return;}}catch(\\Throwable){unset();}=->get_options()[\'preload_settings\']??array();=->get_manual_font_urls(is_array()?:array());foreach(as){=->normalize_font_url((string));if(\'\'!==&&isset(self::[])){continue;}if(\'\'!==){self::[]=true;}Util::generate_preload_link(,\'preload\',\'font\',true,->font_type_for_url((string)));}if(!empty([\'autoDiscoverFonts\'])){try{=->get_auto_discovered_font_urls();}catch(\\Throwable){unset();=array();}=0;foreach(as){if(>=self::MAX_AUTO_FONT_PRELOADS){break;}if(!is_string()||\'\'===trim()){continue;}=->normalize_font_url();if(\'\'===||isset(self::[])){continue;}self::[]=true;Util::generate_preload_link(,\'preload\',\'font\',true,->font_type_for_url());++;}}if(!empty([\'preloadCSS\'])&&!empty([\'preloadCSSUrls\'])){=Util::process_urls([\'preloadCSSUrls\']);foreach(as){=preg_match(\'/^https?:\\/\\//i\',)?:Util::cached_content_url();Util::generate_preload_link(,\'preload\',\'style\');}}->image_optimisation->preload_images();}/** * Adds preconnect/dns-prefetch origins via core\'s resource hints API. * * Core\'s wp_resource_hints() batches, deduplicates, and normalizes * preconnect/dns-prefetch hints, and exposes them through the * `wp_resource_hints` filter for interoperability with other plugins. * Font/CSS/image preload links stay on the raw echo path in * add_preload_prefetch_preconnect() where `as`/`type`/`media` control * is needed. * * Core normalizes preconnect hints to scheme+host and dns-prefetch * hints to protocol-relative `//host`, and emits them on `wp_head` at * priority 2, so they render after the plugin\'s priority-1 preload * links (browser hint order is not significant). * * @since 1.9.0 * * @param array $urls URLs to print for resource hints. * @param string $relation_type The relation type (e.g. \'preconnect\', \'dns-prefetch\'). * @return array Filtered URLs. */functionadd_resource_hints(,){=->get_options()[\'preload_settings\']??array();if(\'preconnect\'===){if(!empty([\'preconnect\'])&&!empty([\'preconnectOrigins\'])){=Util::process_urls([\'preconnectOrigins\']);foreach(as){[]=array(\'href\'=>,\'crossorigin\'=>\'anonymous\',);}}}elseif(\'dns-prefetch\'===){if(!empty([\'prefetchDNS\'])&&!empty([\'dnsPrefetchOrigins\'])){=array_map(staticfunction(){=preg_match(\'#^(?:[a-z][a-z0-9+.-]*:)?//#i\',);return?:\'//\'.;},Util::process_urls([\'dnsPrefetchOrigins\']));=array_merge(,);}}return;}/** * Adds speculation rules for prefetching/prerendering via the WP 6.8+ Speculation Rules API. * * When `wp_get_speculation_rules()` exists (WP 6.8+) the plugin does not emit its own * `<script type=\"speculationrules\">` block. Instead it drives the single core rule set * via the `wp_speculation_rules_configuration` filter so only one document-level rule * is printed (avoids duplicate prefetch/prerender waste when multiple rule sets would append). * * Excludes sensitive/dynamic paths (login, admin, REST API) from all * speculation and pins an explicit configuration whenever the plugin owns * the speculation-rules decision: the user\'s chosen mode/eagerness when * the UI toggle is on, or the legacy `conservative` default when it is * off (so core\'s WP 7.1 cached-site escalation cannot change behavior * behind the user\'s back). * * Effective defaults are `prefetch` + `conservative` unless overridden * via `WP_SPECULATIVE_LOADING_DEFAULT_MODE` / `_EAGERNESS` (WP 7.1, * `wp_get_speculation_rules_default_configuration()`). The * `wp_speculation_rules_configuration` filter (used here) takes precedence * over host constants (see filter_speculation_rules_configuration()). * No auto-elevation to `moderate` is assumed — it must be chosen * explicitly in the UI. * * Host overrides are honored via `WP_SPECULATIVE_LOADING_DEFAULT_*` constants * or environment variables (WP 7.1 #65624); the filter wins over the host. * Mode/eagerness are validated via `WP_Speculation_Rules::is_valid_mode()` * / `is_valid_eagerness()` when the class exists (WP 6.8+), otherwise via * an allowlist fallback. Excludes are merged via `wp_speculation_rules_href_exclude_paths` * (user `speculationExcludeUrls` + WooCommerce cart/checkout/account). * * Backward compatible: on WP <6.8 neither `wp_get_speculation_rules()` * nor `wp_get_speculation_rules_configuration()` exists, * so this method is a no-op and no filter is registered (legacy path). * Fail-open: pre-6.8 output degrades to unoptimised (no speculation * block is printed by this plugin on 6.2-6.7); invalid URLs are * skipped individually and logged-in visitors are always excluded. * * @since 2.0.0 * * @return void */functionadd_speculation_rules(){if(!function_exists(\'wp_get_speculation_rules\')&&!function_exists(\'wp_get_speculation_rules_configuration\')){return;}if(!Wp_Version::is_at_least(\'6.8\',true)){return;}=->get_options()[\'preload_settings\']??array();=!empty([\'enableSpeculationRules\']);add_filter(\'wp_speculation_rules_href_exclude_paths\',function()use(){if(!is_array()){=array();}foreach(->get_speculation_exclude_paths()as){if(!in_array(,,true)){[]=;}}return;});add_filter(\'wp_speculation_rules_configuration\',function()use(,){return->filter_speculation_rules_configuration(,,);});add_filter(\'wp_speculation_rules\',array(,\'filter_speculation_list_rules\'),10);add_action(\'wp_load_speculation_rules\',array(,\'wppo_register_speculation_rules\'));}/** * Canonical speculation-rules href exclusion patterns. * * Merges core safety defaults (auth, admin, REST), generic commerce * paths (cart/checkout/account), WooCommerce dynamic cart/checkout/ * account paths, and user-configured `speculationExcludeUrls`. * Fill-gaps-only: callers dedupe against pre-existing core patterns * so the core ruleset is never duplicated. * * Intentionally narrow: nonce/add-to-cart are query-param * actions (`?_wpnonce=`, `?add-to-cart=`) already * excluded by core\'s `?`-URL handling and by * {@see is_speculation_list_url_valid()}, so no `*substring*` * wildcard is emitted — such wildcards would also block legitimate * slugs (e.g. a post about \"add to cart\"). The `/logout/*` path * prefix guards document-rule `href_matches` for pretty logout * slugs; `?action=logout` list URLs stay covered by the `?`-URL * rejection in {@see is_speculation_list_url_valid()}. * * @since 2.0.0 * * @param array $preload_settings The plugin\'s preload_settings option value. * @return string[] Exclusion patterns (possibly empty, never fatal). */functionget_speculation_exclude_paths(array=array()):array{try{=array(\'/wp-login*\',\'/wp-admin/*\',\'/wp-json/*\',\'/logout/*\',\'/cart/*\',\'/checkout/*\',\'/my-account/*\',\'/account/*\',);if(class_exists(\'PerformanceOptimise\\Inc\\Woo_Detect\')&&method_exists(\'PerformanceOptimise\\Inc\\Woo_Detect\',\'get_woo_excluded_paths\')){try{foreach(Woo_Detect::get_woo_excluded_paths()as){=strtolower(trim((string),\'/\'));if(\'\'===){continue;}=\'/\'..\'/*\';if(!in_array(,,true)){[]=;}}}catch(\\Throwable){unset();}}=!empty([\'speculationExcludeUrls\'])?Util::process_urls([\'speculationExcludeUrls\']):array();foreach(as){if(class_exists(\'WP_URL_Pattern_Prefixer\')&&method_exists(\'WP_URL_Pattern_Prefixer\',\'prefix_path_pattern\')){if(false===strpos(,\'*\')&&isset([0])&&\'/\'===[0]){=\\WP_URL_Pattern_Prefixer::prefix_path_pattern(,\'/\');}}elseif(isset([0])&&\'/\'===[0]&&false===strpos(,\'*\')){=rtrim(,\'/\').\'/*\';}if(!in_array(,,true)){[]=;}}if(function_exists(\'wc_get_checkout_url\')){try{=wc_get_checkout_url();if(){=wp_parse_url(,PHP_URL_PATH);if(&&\'/\'!==){=trailingslashit().\'*\';if(!in_array(,,true)){[]=;}}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_cart_url\')){try{=wc_get_cart_url();if(){=wp_parse_url(,PHP_URL_PATH);if(&&\'/\'!==){=trailingslashit().\'*\';if(!in_array(,,true)){[]=;}}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_page_permalink\')){try{=wc_get_page_permalink(\'myaccount\');if(){=wp_parse_url(,PHP_URL_PATH);if(&&\'/\'!==){=trailingslashit().\'*\';if(!in_array(,,true)){[]=;}}}}catch(\\Throwable){unset();}}if(function_exists(\'apply_filters\')){/** * Filters the speculation-rules href exclusion patterns. * * @since 2.0.0 * @param string[] $excludes Canonical exclusion patterns. * @param array $preload_settings The plugin\'s preload_settings option value. */=apply_filters(\'wppo_speculation_exclusions\',,);if(is_array()){=array_values(array_unique(array_filter(,\'is_string\')));}}return;}catch(\\Throwable){unset();returnarray();}}/** * Whether prerender speculation mode is allowed for the current request. * * Prerender executes page JavaScript speculatively, so it requires * both guardrails: the plugin\'s static cache must be active (never * prerender uncached origin responses) and, when RUM gating is * enabled, real-user field data must qualify via * `AI_Adaptive::get_rum_gated_speculation_state()` (good p75). * Gating explicitly disabled honors the user\'s prerender choice. * Fail-safe: any failure or missing RUM signal means \"not allowed\", * degrading to conservative prefetch (unoptimised, never fatal). * Multisite-safe: per-site options and per-site RUM aggregates only. * * @since 2.2.0 * * @return bool True when prerender may be emitted. */functionis_prerender_allowed():bool{try{if(empty(->get_options()[\'cache_settings\'][\'enableCache\'])){returnfalse;}if(!class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')){returnfalse;}if(!method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'is_speculation_rum_gating_enabled\')||!method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'get_rum_gated_speculation_state\')){returnfalse;}=AI_Adaptive::is_speculation_rum_gating_enabled();if(!){returntrue;}=AI_Adaptive::get_rum_gated_speculation_state();return!empty([\'qualified\']);}catch(\\Throwable){unset();returnfalse;}}/** * Applies the plugin\'s explicit speculation-rules configuration via the * `wp_speculation_rules_configuration` filter. * * WordPress 7.1 escalates the default eagerness from `conservative` to * `moderate` when it detects a caching solution (#64066). This plugin is * a caching solution, so that escalation could change speculative-loading * behavior behind the user\'s back. Whenever the plugin owns the * speculation-rules decision it therefore pins an explicit eagerness: * the user\'s chosen value when the UI toggle is on, or the legacy * `conservative` default when it is off. The explicit * `WP_SPECULATIVE_LOADING_DEFAULT_MODE` / * `WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS` constants or environment * variables introduced in WP 7.1 (#65624) are honored as-is, so hosts * can still pin a different default. The filter wins over the host * override (documented precedence). * * Mode/eagerness are validated via `WP_Speculation_Rules::is_valid_mode()` * / `is_valid_eagerness()` when the class exists (WP 6.8+), otherwise via * an allowlist fallback, with `function_exists`/`class_exists` guards for * backward compat on WP <6.8. * * Excludes are merged via `wp_speculation_rules_href_exclude_paths` * (user `speculationExcludeUrls` + WooCommerce cart/checkout/account via * {@see add_speculation_rules()}). * * Cache awareness: non-cacheable responses (`DONOTCACHEPAGE`, * cart/checkout/account, previews, logged-in visitors) return null so * neither core nor plugin rules prefetch them. * * @since 1.9.0 * @since 2.0.0 Honor `wp_get_speculation_rules_default_configuration()` when available (WP 7.1). * * @param array<string,string>|null $config Filter value (\'auto\' defaults, or null when speculative loading is disabled for the request). * @param array $preload_settings The plugin\'s preload_settings option value. * @param bool $enable_speculation Whether the plugin\'s speculation-rules UI toggle is on. * @return array<string,string>|null */functionfilter_speculation_rules_configuration(,array,bool){if(!is_array()){return;}if(->is_speculation_suppressed_for_visitor()){returnnull;}if(){=[\'speculationMode\']??\'prefetch\';=[\'speculationEagerness\']??\'conservative\';if(class_exists(\'WP_Speculation_Rules\')){if(method_exists(\'WP_Speculation_Rules\',\'is_valid_mode\')){if(!\\WP_Speculation_Rules::is_valid_mode()){=\'prefetch\';}}elseif(!in_array(,array(\'prefetch\',\'prerender\'),true)){=\'prefetch\';}if(method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){if(!\\WP_Speculation_Rules::is_valid_eagerness()){=\'conservative\';}}elseif(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}}else{if(!in_array(,array(\'prefetch\',\'prerender\'),true)){=\'prefetch\';}if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}}if(\'prerender\'===&&!->is_prerender_allowed()){=\'prefetch\';=\'conservative\';}=->maybe_cap_speculation_eagerness();if(\'prerender\'===&&->is_speculation_commerce_or_auth()){=\'prefetch\';}[\'mode\']=;[\'eagerness\']=;return;}=null!==->get_speculation_default_override(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\');if(!empty(->get_options()[\'cache_settings\'][\'enableCache\'])&&\'auto\'===([\'eagerness\']??\'auto\')&&!){[\'eagerness\']=\'conservative\';}return;}/** * RUM-weighted top-URL prefetch cap (issue #1183). * * Reads `preload_settings.speculationTopUrlsLimit` (default 2, * clamped to 1-5 as the footprint guard). Fail-open: any missing or * malformed value returns 2, never fatal. * * @since 2.2.0 * * @return int Capped limit between 1 and 5. */functionget_speculation_top_urls_limit():int{try{=->get_options()[\'preload_settings\'][\'speculationTopUrlsLimit\']??2;=is_numeric()?(int):2;if(<1||>5){return2;}return;}catch(\\Throwable){unset();return2;}}/** * Whether the current request is a commerce/auth context for speculation guardrails. * * Reuses `AI_Adaptive::is_commerce_or_auth_context()` when available * (guarded by class_exists/method_exists for backward compat), with a * conservative local fallback (WooCommerce presence, cart/checkout/ * account conditionals, logged-in visitor, cart cookies). Fail-closed: * any throwable means \"commerce\" so uncertainty suppresses the * highest-risk prerender mode; the outer list builder stays fail-open * (returns empty) for prefetch paths. * * @since 2.2.0 * * @return bool True when eager speculation must be suppressed. */functionis_speculation_commerce_or_auth():bool{try{if(class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')&&method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'is_commerce_or_auth_context\')){return(bool)AI_Adaptive::is_commerce_or_auth_context();}}catch(\\Throwable){unset();}try{if(class_exists(\'WooCommerce\')||function_exists(\'WC\')||function_exists(\'wc_get_checkout_url\')){returntrue;}foreach(array(\'is_cart\',\'is_checkout\',\'is_account_page\')as){if(function_exists()){try{if(call_user_func()){returntrue;}}catch(\\Throwable){unset();}}}if(function_exists(\'is_user_logged_in\')){try{if(->is_speculation_frontend_context()&&is_user_logged_in()){returntrue;}}catch(\\Throwable){unset();}}if((isset([\'woocommerce_items_in_cart\'])&&is_string([\'woocommerce_items_in_cart\'])&&\'\'!==[\'woocommerce_items_in_cart\'])||(isset([\'woocommerce_cart_hash\'])&&is_string([\'woocommerce_cart_hash\'])&&\'\'!==[\'woocommerce_cart_hash\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Whether the current request looks like a frontend visitor visit. * * Mirrors `AI_Adaptive::is_frontend_context()` for the local * commerce/auth fallback: admin, REST, AJAX, cron, and CLI requests * never represent a visitor seeing speculation rules. All probes are * function_exists-guarded so unit tests and minimal installs default * to frontend (true). Fail-open: any throwable means frontend. * * @since 2.2.0 * * @return bool True when the request looks like a frontend visit. */functionis_speculation_frontend_context():bool{try{if(defined(\'WP_CLI\')&&WP_CLI){returnfalse;}if(defined(\'DOING_CRON\')&&DOING_CRON){returnfalse;}if(defined(\'REST_REQUEST\')&&REST_REQUEST){returnfalse;}if(function_exists(\'wp_doing_cron\')){try{if(wp_doing_cron()){returnfalse;}}catch(\\Throwable){unset();}}if(function_exists(\'is_admin\')){try{if(is_admin()){returnfalse;}}catch(\\Throwable){unset();}}if(function_exists(\'wp_doing_ajax\')){try{if(wp_doing_ajax()){returnfalse;}}catch(\\Throwable){unset();}}returntrue;}catch(\\Throwable){unset();returntrue;}}/** * Cap a speculation eagerness value in commerce/auth contexts. * * Prerender-risk guardrail (issue #1183): `eager` becomes `moderate` * when {@see is_speculation_commerce_or_auth()} is true; every other * value passes through untouched. Invalid values fall back to * `conservative`. Manual user settings are never persisted — the cap * applies to the emitted rule only. * * @since 2.2.0 * * @param string $eagerness Raw eagerness value. * @return string Capped eagerness value. */functionmaybe_cap_speculation_eagerness(string):string{try{if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){return\'conservative\';}if(\'eager\'===&&->is_speculation_commerce_or_auth()){return\'moderate\';}return;}catch(\\Throwable){unset();return\'conservative\';}}/** * RUM-weighted top URLs from the learned AI model (issue #1183). * * Reads the field-weighted model (`avgLCP*log(count)` scoring) via * `AI_Adaptive::get_model()` and sanitizes the full `prefetch_urls` * list here — rather than via `AI_Adaptive::get_prefetch_urls()`, * which pre-slices to 2 before validation, so an invalid entry * cannot waste a fill slot. Each URL is re-validated with * {@see is_speculation_list_url_valid()} (same-origin, no commerce/ * admin/query), deduped, and capped at the `speculationTopUrlsLimit` * budget. Empty model, missing class, or any failure returns an empty * array so callers fall back to document-rule-only behavior (fail-open, * never fatal). Multisite-safe: per-site model via per-site options, * same-site host check prevents cross-site leakage. * * @since 2.2.0 * * @param int $limit Maximum URLs to return. * @return string[] Validated absolute model URLs (possibly empty). */functionget_model_weighted_speculation_urls(int=2):array{try{if(<1){returnarray();}if(!class_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\')||!method_exists(\'PerformanceOptimise\\Inc\\AI_Adaptive\',\'get_model\')){returnarray();}=AI_Adaptive::get_model();}catch(\\Throwable){unset();returnarray();}if(!is_array()){returnarray();}=[\'prefetch_urls\']??array();if(!is_array()||empty()){returnarray();}try{=array();foreach(as){if(!is_string()||\'\'===){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'===){continue;}if(in_array(,,true)){continue;}if(!->is_speculation_list_url_valid()){continue;}[]=;if(count()>=){break;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Collect high-value same-site URLs for the speculation list rule. * * Source: home URL first, then `performance_audit.high_value_urls`, * then RUM-weighted model top URLs (field-weighted via * {@see get_model_weighted_speculation_urls()}, capped at * `preload_settings.speculationTopUrlsLimit`), then RUM volume * winners via {@see get_rum_top_urls()} (same cap). Each candidate * is normalized via `esc_url_raw(trim())`, deduped, same-site * validated, and capped (keeps the ~1KB footprint). Invalid URLs * are skipped individually (fail-open); an empty array means \"emit * nothing\". * * @since 2.0.0 * @since 2.2.0 Merge RUM-weighted model top URLs within the speculationTopUrlsLimit fill cap. * * @return string[] Validated absolute URLs (possibly empty). */functionget_speculation_list_urls():array{if(function_exists(\'is_admin\')){try{if(is_admin()){returnarray();}}catch(\\Throwable){unset();}}if(function_exists(\'get_option\')){try{=get_option(\'permalink_structure\');if(\'\'===||false===){returnarray();}}catch(\\Throwable){unset();}}=array(Util::cached_home_url(\'/\'));=Util::get_settings();=[\'performance_audit\'][\'high_value_urls\']??array();if(is_string()){=preg_split(\'/[\\r\\n,]+/\',);}if(is_array()){=array_slice(array_values(),0,20);foreach(as){if(is_string()&&\'\'!==trim()){[]=;}}}=->get_speculation_top_urls_limit();=->get_model_weighted_speculation_urls(+count());=array();foreach(as){if(!is_string()){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'!==){[->normalize_speculation_url()]=true;}}=0;foreach(as){if(isset([->normalize_speculation_url()])){continue;}[->normalize_speculation_url()]=true;[]=;++;if(>=){break;}}=-;if(>0){foreach(->get_rum_top_urls()as){[]=;}}=array();=array();=0;foreach(as){if(!is_string()){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'===){continue;}=->normalize_speculation_url();if(isset([])){continue;}if(++>30){break;}if(!->is_speculation_list_url_valid()){continue;}[]=true;[]=;if(count()>=10){break;}}return;}/** * Top RUM (real-visit) URLs by visit volume. * * Reads the `wppo_web_vitals_rum` per-day/per-path aggregates via * `RUM::get_aggregate_readonly()` (per-request memoized, no queue * flush, no transient churn — safe for the frontend hot path), * sums sample counts (`max(lcp.n, ttfb.n, ...)`) per * normalized path across days, and resolves the winners to absolute * same-site URLs. Candidates are validated with * {@see is_speculation_list_url_valid()} (cart/checkout/account, * query strings, cross-site excluded) and capped so home + * high-value + RUM total stays within the 10-URL budget. * Per-request memoized keyed by limit. * * Fail-open: any throwable, missing class, or empty RUM returns an * empty array — never fatal, never white-screen. * * @since 2.0.0 * @since 2.2.0 Accept a fill-budget limit for the RUM-weighted portion. * @since 2.2.0 Read via get_aggregate_readonly() with per-request memo. * * @param int $limit Maximum URLs to return. * @return string[] Validated absolute RUM winner URLs (possibly empty). */functionget_rum_top_urls(int=10):array{if(<1){returnarray();}if(>10){=10;}if(isset(self::[])){returnself::[];}=->compute_rum_top_urls();if(count(self::)>10){self::=array();}self::[]=;return;}/** * Uncached RUM top-URL computation for {@see get_rum_top_urls()}. * * @since 2.2.0 * * @param int $limit Maximum URLs to return. * @return string[] Validated absolute RUM winner URLs (possibly empty). */functioncompute_rum_top_urls(int):array{try{if(!class_exists(\'PerformanceOptimise\\Inc\\RUM\')||!method_exists(\'PerformanceOptimise\\Inc\\RUM\',\'get_aggregate_readonly\')){returnarray();}=RUM::get_aggregate_readonly();}catch(\\Throwable){unset();returnarray();}if(!is_array()||empty()){returnarray();}try{=array();foreach(as){if(!is_array()){continue;}foreach(as=>){if(!is_string()||\'\'===||!is_array()){continue;}if(false!==strpos(,\'?\')||false!==strpos(,\'#\')){continue;}=0;foreach(as=>){if(\'lcpUrls\'===||!is_array()){continue;}=isset([\'n\'])?(int)[\'n\']:0;if(>){=;}}if(<=0){continue;}=class_exists(\'PerformanceOptimise\\Inc\\Util\')&&method_exists(\'PerformanceOptimise\\Inc\\Util\',\'normalize_rum_path\')?Util::normalize_rum_path():;if(\'/\'===){continue;}if(!isset([])){[]=0;}[]+=;}}if(empty()){returnarray();}arsort();=array();foreach(array_keys()as){if(\'/\'!==substr(,-1)){.=\'/\';}=Util::cached_home_url();=function_exists(\'esc_url_raw\')?esc_url_raw():;if(!is_string()||\'\'===){continue;}if(in_array(,,true)){continue;}if(!->is_speculation_list_url_valid()){continue;}[]=;if(count()>=){break;}}return;}catch(\\Throwable){unset();returnarray();}}/** * Whether speculation output is suppressed for the current visitor. * * Logged-in users stay excluded (mirrors the `null` config * passthrough in {@see filter_speculation_rules_configuration()}). * Cache-aware: pages served with `DONOTCACHEPAGE` / `no-store` * (cart/checkout/account, previews) never speculate — speculating a * non-cacheable URL wastes origin load and risks broken carts. * Fail-closed: any throwable means \"suppressed\" so uncertainty * disables output (privacy guard must never fail open for * logged-in/DONOTCACHEPAGE visitors). * * @since 2.0.0 * * @return bool True when rules must not be emitted. */functionis_speculation_suppressed_for_visitor():bool{try{if(function_exists(\'is_user_logged_in\')&&is_user_logged_in()){returntrue;}if(defined(\'DONOTCACHEPAGE\')&&DONOTCACHEPAGE){returntrue;}foreach(array(\'is_cart\',\'is_checkout\',\'is_account_page\',\'is_preview\',\'is_customize_preview\')as){if(function_exists()){try{if(call_user_func()){returntrue;}}catch(\\Throwable){unset();}}}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * Eager prerender list rule for the home link on singular views. * * Returns a `{\"source\":\"list\"}` rule with `eager` eagerness for the * home URL only when the current view is singular (and the home URL * is present/valid). Commerce/auth contexts degrade to `moderate` * via {@see maybe_cap_speculation_eagerness()} (prefetch only, * never eager). Returns null otherwise (non-singular, no home * link, logged-in visitor, document rules toggled off, or any * failure) — fail-open to \"emit nothing\", never fatal. * * @since 2.0.0 * @since 2.2.0 Cap eager to moderate in commerce/auth contexts. * * @return array<string,mixed>|null The singular rule, or null. */functionget_singular_home_link_rule():?array{try{if(->is_speculation_suppressed_for_visitor()){returnnull;}=->get_options()[\'preload_settings\'][\'speculationDocumentRules\']??true;if(!){returnnull;}if(!function_exists(\'is_singular\')||!is_singular()){returnnull;}=Util::cached_home_url(\'/\');if(!is_string()||\'\'===){returnnull;}=function_exists(\'esc_url_raw\')?esc_url_raw():;if(\'\'===||!->is_speculation_list_url_valid()){returnnull;}returnarray(\'source\'=>\'list\',\'urls\'=>array(),\'eagerness\'=>->maybe_cap_speculation_eagerness(\'eager\'),);}catch(\\Throwable){unset();returnnull;}}/** * Document rule targeting the first post on archive views. * * Reads the first post URL from the main query (`$wp_query->posts` * via `get_permalink()`, all guarded) and emits a * `{\"source\":\"document\"}` rule whose `where` clause pairs an * `href_matches` pattern for that post path with a first-post * `selector_matches`. Returns null when not an archive, when no * first post resolves, for logged-in visitors, when document rules * are toggled off, or on any failure (fail-open, never fatal). * * @since 2.0.0 * * @return array<string,mixed>|null The archive document rule, or null. */functionget_archive_first_post_rule():?array{try{if(->is_speculation_suppressed_for_visitor()){returnnull;}=->get_options()[\'preload_settings\'][\'speculationDocumentRules\']??true;if(!){returnnull;}=(function_exists(\'is_archive\')&&is_archive())||(function_exists(\'is_home\')&&is_home());if(!){returnnull;}=null;global;if(isset(->posts)&&is_array(->posts)&&!empty(->posts)){=->posts[0];if(function_exists(\'get_permalink\')){=get_permalink();if(is_string()&&\'\'!==){=;}}}if(!is_string()||\'\'===){returnnull;}=function_exists(\'esc_url_raw\')?esc_url_raw():;if(\'\'===||!->is_speculation_list_url_valid()){returnnull;}=null;if(function_exists(\'wp_parse_url\')){=wp_parse_url(,PHP_URL_PATH);}if(!is_string()||\'\'===){returnnull;}=rtrim(,\'/\').\'/*\';if(\'/\'===){returnnull;}=->get_options()[\'preload_settings\']??array();=[\'speculationEagerness\']??\'conservative\';if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}=->maybe_cap_speculation_eagerness();returnarray(\'source\'=>\'document\',\'where\'=>array(\'and\'=>array(array(\'href_matches\'=>),array(\'selector_matches\'=>\'main article:first-of-type a, article.post:first-of-type a, .post:first-of-type a\'),),),\'eagerness\'=>,);}catch(\\Throwable){unset();returnnull;}}/** * Validate a single speculation list URL. * * Same-site (host must match home host, preventing multisite * cross-site leakage), http(s) only, and rejects admin, login, * REST, commerce (cart/checkout/account) paths, and any URL * carrying a query string or fragment (mirroring core\'s * `?`-URL exclusion). Same-host different-port URLs and URLs * with userinfo are rejected as cross-origin/unsafe. * * Per-request memoized (URL + home + Woo availability); the same * candidates validated by the list, prerender, and register paths * cost one Woo lookup set per distinct URL. * * @since 2.0.0 * @since 2.2.0 Memoize per-request results. * * @param string $url Candidate absolute URL. * @return bool True when the URL may be prefetched. */functionis_speculation_list_url_valid(string):bool{=wp_parse_url();if(!is_array()||empty([\'host\'])){returnfalse;}=Util::cached_home_url();=.\"\\0\"..\"\\0\".self::speculation_woo_fingerprint();if(array_key_exists(,self::)){returnself::[];}if(count(self::)>200){self::=array();}=->validate_speculation_list_url_uncached(,,);self::[]=;return;}/** * Reset the speculation URL validity + commerce-path memos (for tests). * * @since 2.2.0 * @return void */staticfunctionreset_speculation_url_memo():void{self::=array();self::=null;self::=\'\';self::=array();self::=array();}/** * Fingerprint WooCommerce-function availability for the speculation memos. * * Test fixtures may define wc_get_* after a first resolution; the * fingerprint keeps the cached verdicts keyed on that boundary so a * stale \"no Woo\" verdict is never reused once Woo helpers appear. * * @since 2.2.0 * * @return string \'1\'/\'0\' flags for wc_get_checkout_url, wc_get_cart_url, wc_get_page_permalink. */staticfunctionspeculation_woo_fingerprint():string{return(function_exists(\'wc_get_checkout_url\')?\'1\':\'0\').(function_exists(\'wc_get_cart_url\')?\'1\':\'0\').(function_exists(\'wc_get_page_permalink\')?\'1\':\'0\');}/** * Resolved speculation commerce paths (per-request memoized). * * Base cart/checkout/account prefixes plus WooCommerce dynamic * paths (checkout/cart/myaccount permalinks). Resolved once per * request instead of once per candidate URL. * * @since 2.2.0 * * @return string[] Lowercase path prefixes. */functionget_speculation_commerce_paths():array{=self::speculation_woo_fingerprint();if(null!==self::&&===self::){returnself::;}=array(\'/cart\',\'/checkout\',\'/my-account\',\'/account\');if(function_exists(\'wc_get_checkout_url\')){try{=wc_get_checkout_url();if(){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==&&\'/\'!==){[]=rtrim(,\'/\');}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_cart_url\')){try{=wc_get_cart_url();if(){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==&&\'/\'!==){[]=rtrim(,\'/\');}}}catch(\\Throwable){unset();}}if(function_exists(\'wc_get_page_permalink\')){try{=wc_get_page_permalink(\'myaccount\');if(){=wp_parse_url(,PHP_URL_PATH);if(is_string()&&\'\'!==&&\'/\'!==){[]=rtrim(,\'/\');}}}catch(\\Throwable){unset();}}self::=array_values(array_unique(array_map(\'strtolower\',)));self::=;returnself::;}/** * Normalize a speculation URL for dedupe/carve-out comparison. * * Lowercases scheme+host, strips default ports, drops trailing-slash * variants, so `https://example.com/post` and * `https://EXAMPLE.com/post/` compare equal (RUM winners restore the * trailing slash while raw high_value_urls input may not carry one). * Query/fragment are preserved as-is: validated URLs never carry * them, and distinct raw inputs must not collapse silently. * * @since 2.2.0 * * @param string $url Candidate URL. * @return string Normalized URL (input unchanged when unparseable). */functionnormalize_speculation_url(string):string{if(isset(self::[])){returnself::[];}=;try{=wp_parse_url();if(is_array()&&!empty([\'host\'])){=strtolower((string)([\'scheme\']??\'\'));=strtolower((string)[\'host\']);=isset([\'port\'])?(int)[\'port\']:null;if((\'http\'===&&80===)||(\'https\'===&&443===)){=null;}=(string)([\'path\']??\'\');if(function_exists(\'untrailingslashit\')){=untrailingslashit();}else{=rtrim(,\'/\');}=(\'\'!==?.\'://\':\'//\')..(null!==&&>0?\':\'.:\'\').;if(isset([\'query\'])&&\'\'!==(string)[\'query\']){.=\'?\'.(string)[\'query\'];}if(isset([\'fragment\'])&&\'\'!==(string)[\'fragment\']){.=\'#\'.(string)[\'fragment\'];}}}catch(\\Throwable){unset();=;}if(count(self::)>200){self::=array();}self::[]=;return;}/** * Uncached speculation list URL validation. * * Same-site (host must match home host, preventing multisite * cross-site leakage), http(s) only, and rejects admin, login, * REST, commerce (cart/checkout/account) paths, and any URL * carrying a query string or fragment (mirroring core\'s * `?`-URL exclusion). Same-host different-port URLs and URLs * with userinfo are rejected as cross-origin/unsafe. * * Called once per distinct URL via the * {@see is_speculation_list_url_valid()} memo. * * @since 2.2.0 * * @param string $url Candidate absolute URL. * @param array<string, mixed> $parts Parsed URL parts. * @param string $home Home URL. * @return bool True when the URL may be prefetched. */functionvalidate_speculation_list_url_uncached(string,array,string):bool{try{=wp_parse_url(,PHP_URL_QUERY);if(is_string()&&\'\'!==){returnfalse;}=wp_parse_url(,PHP_URL_FRAGMENT);if(is_string()&&\'\'!==){returnfalse;}}catch(\\Throwable){unset();if(false!==strpos(,\'?\')||false!==strpos(,\'#\')){returnfalse;}}=strtolower((string)([\'scheme\']??\'\'));if(\'\'!==&&!in_array(,array(\'http\',\'https\'),true)){returnfalse;}=wp_parse_url(,PHP_URL_HOST);if(!is_string()||\'\'===){returnfalse;}if(strtolower([\'host\'])!==strtolower()){returnfalse;}if(isset([\'user\'])||isset([\'pass\'])){returnfalse;}=staticfunction(,):?int{if(null===||\'\'===){returnnull;}=(int);if(<=0){returnnull;}if((\'http\'===&&80===)||(\'https\'===&&443===)){returnnull;}return;};try{=wp_parse_url(,PHP_URL_PORT);}catch(\\Throwable){unset();=null;}=strtolower((string)(wp_parse_url(,PHP_URL_SCHEME)??\'\'));if(([\'port\']??null,)!==(??null,)){returnfalse;}=strtolower((string)([\'path\']??\'/\'));if(false!==strpos(,\'/wp-admin\')||false!==strpos(,\'wp-login.php\')||false!==strpos(,\'/wp-json\')){returnfalse;}=\'\';try{=wp_parse_url(,PHP_URL_QUERY);if(is_string()){=strtolower();}}catch(\\Throwable){unset();}=.\'?\'.;foreach(array(\'nonce\',\'logout\',\'add-to-cart\',\'admin-ajax\')as){if(false!==strpos(,)){returnfalse;}}=->get_speculation_commerce_paths();=rtrim(,\'/\');foreach(as){=strtolower(rtrim((string),\'/\'));if(\'\'===){continue;}if(===||0===strpos(.\'/\',.\'/\')){returnfalse;}}returntrue;}/** * Whether the current request must not receive the high-value prerender list (issue #1243). * * Request-scoped counterpart to {@see is_speculation_commerce_or_auth()}: the * site-wide helper returns true on the mere presence of WooCommerce * (correct for global mode/eagerness guardrails), which would keep the * opt-in prerender list dead on every Woo store. The prerender list is * a per-URL feature whose URL safety already comes from the per-URL * {@see is_speculation_list_url_valid()} commerce-path backstop, so * this probe only inspects the *current request*: cart/checkout/ * account conditionals, a frontend logged-in visitor, and an active * cart session via `woocommerce_items_in_cart` / `woocommerce_cart_hash` * cookies. Fail-closed: any throwable means \"suppressed\". * * @since 2.2.0 * * @return bool True when the prerender list must not be emitted for this request. */functionis_prerender_list_suppressed_for_request():bool{try{foreach(array(\'is_cart\',\'is_checkout\',\'is_account_page\')as){if(function_exists()){try{if(call_user_func()){returntrue;}}catch(\\Throwable){unset();}}}if(function_exists(\'is_user_logged_in\')){try{if(->is_speculation_frontend_context()&&is_user_logged_in()){returntrue;}}catch(\\Throwable){unset();}}if((isset([\'woocommerce_items_in_cart\'])&&is_string([\'woocommerce_items_in_cart\'])&&\'\'!==[\'woocommerce_items_in_cart\'])||(isset([\'woocommerce_cart_hash\'])&&is_string([\'woocommerce_cart_hash\'])&&\'\'!==[\'woocommerce_cart_hash\'])){returntrue;}returnfalse;}catch(\\Throwable){unset();returntrue;}}/** * High-value prerender list URLs (issue #1237). * * Builds the high-value URL set (home plus capped RUM top URLs via * {@see get_speculation_list_urls()}, same-site validated with cart, * checkout, account, and query-string URLs excluded) and trims it to * the `speculationTopUrlsLimit` fill cap so the rules JSON stays near * ~0.5 KB. Returns an empty array unless the opt-in * `preload_settings.speculationPrerenderList` toggle is enabled * alongside `enableSpeculationRules`. * * Guards: same-origin enforcement via * {@see is_speculation_list_url_valid()}, request-scoped commerce and * auth exclusion via {@see is_prerender_list_suppressed_for_request()} * (cart/checkout/account conditionals, logged-in visitor, cart * cookies — deliberately not the site-wide * {@see is_speculation_commerce_or_auth()} Woo-presence guard, so the * list still fires on safe pages of Woo stores), logged-in * exclusion via {@see is_speculation_suppressed_for_visitor()}, and * the static-cache + RUM-qualified gate via * {@see is_prerender_allowed()} (matching the document-mode * guardrail: prerender executes page JavaScript, so unqualified * origins get no prerender list). * Fail-open: any empty model, missing toggle, excluded context, or * failure returns an empty array (conservative document-rule-only * behavior), never fatal. Multisite-safe: per-site settings and * model, no cross-site leakage. * * @since 2.2.0 * * @param string[]|null $candidates Optional pre-validated candidates (defaults to get_speculation_list_urls()). * @param array $existing_rules Existing speculation rules used for list-URL dedupe. * @return string[] Validated prerender URLs (possibly empty). */functionget_prerender_list_urls(?array=null,array=array()):array{try{if(empty(->get_options()[\'preload_settings\'][\'enableSpeculationRules\'])){returnarray();}if(empty(->get_options()[\'preload_settings\'][\'speculationPrerenderList\'])){returnarray();}if(!->is_prerender_allowed()){returnarray();}if(->is_speculation_suppressed_for_visitor()){returnarray();}if(->is_prerender_list_suppressed_for_request()){returnarray();}if(null===){=->get_speculation_list_urls();}if(empty()){returnarray();}=->get_speculation_top_urls_limit();=array();=array();foreach(as){if(!is_string()||\'\'===trim()){continue;}=function_exists(\'esc_url_raw\')?esc_url_raw(trim()):trim();if(\'\'===){continue;}=->normalize_speculation_url();if(isset([])){continue;}if(!->is_speculation_list_url_valid()){continue;}[]=true;[]=;if(count()>=){break;}}if(empty()){returnarray();}returnarray_values(->dedupe_speculation_urls_against_rules(,));}catch(\\Throwable){unset();returnarray();}}/** * Normalize post-filter prerender URLs (issue #1237 follow-up). * * Shared by both merge paths in * {@see wppo_register_speculation_rules()}: keeps strings only, * re-validates every URL with * {@see is_speculation_list_url_valid()} (a filter must not inject * commerce/cross-origin/query URLs), dedupes against existing rules * (normalization-aware), and re-slices to * {@see get_speculation_top_urls_limit()} so a filter returning 20 * URLs cannot defeat the ~0.5 KB footprint guard. * * @since 2.2.0 * * @param array $urls Post-filter candidate URLs. * @param array $existing_rules Existing speculation rules for dedupe. * @return string[] Normalized prerender URLs (possibly empty). */functionnormalize_prerender_urls(array,array):array{=array_values(array_filter(,\'is_string\'));=array_values(array_filter(,array(,\'is_speculation_list_url_valid\')));=->dedupe_speculation_urls_against_rules(,);=->get_speculation_top_urls_limit();returnarray_values(array_slice(,0,));}/** * Validate a post-filter prerender list rule (issue #1237 follow-up). * * Enforces the list-rule schema after the * `wppo_speculation_prerender_list_rule` filter: `source` must stay * `list` (a filter must not morph the rule into a document rule), * `eagerness` must be a known value (invalid values fall back to * `moderate`), and `urls` are re-validated, deduped, and re-sliced * to the top-URL limit. Returns null when the rule must be dropped * (wrong source or no valid URLs left). * * @since 2.2.0 * * @param mixed $rule Post-filter rule candidate. * @param array $existing_rules Existing speculation rules for dedupe. * @return array<string,mixed>|null Validated rule, or null to drop it. */functionvalidate_prerender_rule(,array):?array{if(!is_array()){returnnull;}if(\'list\'!==([\'source\']??\'\')){returnnull;}=[\'eagerness\']??\'moderate\';if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'moderate\';}=[\'urls\']??array();if(!is_array()){returnnull;}=->normalize_prerender_urls(,);if(empty()){returnnull;}[\'source\']=\'list\';[\'eagerness\']=;[\'urls\']=array_values();return;}/** * Validate post-filter speculation rules output (trusted-code-only). * * The `wppo_speculation_*_rules` filters run trusted code, but a * misbehaving callback must not corrupt the emitted * `speculationrules` block: a non-array return falls back to the * pre-filter rules, and non-array entries are dropped. Shape * validation beyond that stays with the rule builders above. * * @since 2.2.0 * * @param mixed $filtered Post-filter rules candidate. * @param array $fallback Pre-filter rules. * @return array Validated rules. */functionvalidate_speculation_rules_output(,array):array{if(!is_array()){return;}=array();foreach(as){if(is_array()){[]=;}}return;}/** * Register the guarded high-value prerender list rule (issue #1237). * * Wires the high-value URL selection ({@see get_prerender_list_urls()}, * home plus capped RUM top URLs) to a dedicated prerender list rule * that only fires for safe same-origin candidates: commerce, auth, * and logged-in contexts stay on conservative prefetch or nothing. * * Dual-path merge into the single core `speculationrules` block on * WP 6.8+: when `$rules` is a `WP_Speculation_Rules` object (the * `wp_load_speculation_rules` action path) the rule is added via * `add_rule( \'prerender\', \'wppo-high-value-prerender\', ... )` with a * `has_rule()` guard so no duplicate is emitted; otherwise (legacy * array path, WP <6.8 or unit-test fixtures) the rule is appended to * the array with dedupe against pre-existing list rules. The WP 6.8+ * object path is additionally guarded by `function_exists` on * `wp_get_speculation_rules_configuration` plus `version_compare`, * so pre-6.8 installs fall back to the legacy plugin-owned output. * Both paths apply the same `wppo_speculation_prerender_list_urls` * and `wppo_speculation_prerender_list_rule` filters with identical * post-filter validation ({@see normalize_prerender_urls()}, * {@see validate_prerender_rule()}). * * Eagerness is pinned to `moderate` by design (not derived from * `speculationEagerness`): `eager` prerender fires on page load and * would speculatively execute JS for every visitor, while * `conservative` waits for hover and defeats prerender\'s * near-instant-navigation purpose for high-value URLs; `moderate` * matches core\'s cached-site default. * * Backward compatible with WP 6.2+ and PHP 8.2+. Fail-open: toggle * off, empty model, commerce context, logged-in visitor, or any * failure returns the input unchanged (current conservative * document-rule-only behavior), never fatal. Multisite-safe: * per-site settings and model, no cross-site leakage. * * @since 2.2.0 * * @param mixed $rules Speculation rules (WP_Speculation_Rules object or legacy rules array). * @param string[]|null $candidate_urls Optional candidate URLs (defaults to the high-value list selection). * @return mixed Updated rules, or the input unchanged. */functionwppo_register_speculation_rules(,?array=null){try{if(is_object()&&method_exists(,\'add_rule\')){if(!function_exists(\'wp_get_speculation_rules_configuration\')&&!function_exists(\'wp_get_speculation_rules\')){return;}if(!Wp_Version::is_at_least(\'6.8\',true)){return;}if(->speculation_prerender_object_added){return;}if(method_exists(,\'has_rule\')&&->has_rule(\'prerender\',\'wppo-high-value-prerender\')){return;}=->get_prerender_list_urls(,array());if(empty()){return;}if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list URLs before the rule is registered. * * @since 2.2.0 * @param string[] $urls Validated prerender URLs (home + capped RUM top URLs). */=apply_filters(\'wppo_speculation_prerender_list_urls\',);if(is_array()){=;}}=->normalize_prerender_urls(,array());if(empty()){return;}=array(\'source\'=>\'list\',\'urls\'=>array_values(),\'eagerness\'=>\'moderate\',);if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list rule before it is registered. * * @since 2.2.0 * @param array $rule_args The prerender list rule arguments. */=apply_filters(\'wppo_speculation_prerender_list_rule\',);if(is_array()){=;}}=->validate_prerender_rule(,array());if(null===){return;}->add_rule(\'prerender\',\'wppo-high-value-prerender\',);->speculation_prerender_object_added=true;return;}if(!is_array()){return;}=->get_prerender_list_urls(,);if(empty()){return;}if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list URLs before the rule is appended. * * @since 2.2.0 * @param string[] $urls Validated prerender URLs (home + capped RUM top URLs). */=apply_filters(\'wppo_speculation_prerender_list_urls\',);if(is_array()){=;}}=->normalize_prerender_urls(,);if(empty()){return;}=array(\'source\'=>\'list\',\'urls\'=>array_values(),\'eagerness\'=>\'moderate\',);if(function_exists(\'apply_filters\')){/** * Filters the high-value prerender list rule before it is appended. * * @since 2.2.0 * @param array $rule The prerender list rule. */=apply_filters(\'wppo_speculation_prerender_list_rule\',);if(is_array()){=;}}=->validate_prerender_rule(,);if(null===){return;}[]=;if(function_exists(\'apply_filters\')){/** * Filters the speculation rules after the high-value prerender list rule is appended. * * Trusted-code-only: non-array returns fall back to the * pre-filter rules and non-array entries are dropped * ({@see validate_speculation_rules_output()}). * * @since 2.2.0 * @param array $rules Updated rules. * @param string[] $urls Prerender list URLs that were appended. */=apply_filters(\'wppo_speculation_prerender_list_rules\',,);return->validate_speculation_rules_output(,);}return;}catch(\\Throwable){unset();return;}}/** * Append the high-value list rule to the `wp_speculation_rules` array. * * Runs on WP 6.8+ only (registered inside the `wp_get_speculation_rules` * guard in {@see add_speculation_rules()}). Null/non-array config is * returned untouched; speculation is never auto-enabled. Logged-in * visitors are always excluded (input returned unchanged). * * Emits a single core `speculationrules` block contribution: the * high-value/RUM list rule plus contextual rules — an eager * prerender list rule for the home link on singular views * ({@see get_singular_home_link_rule()}) and a first-post selector * document rule on archive views * ({@see get_archive_first_post_rule()}). When the opt-in * `speculationPrerenderList` toggle is enabled, the safest * high-value URLs are carved out of the generic prefetch list and * emitted as a dedicated guarded prerender list rule via * {@see wppo_register_speculation_rules()}. URLs are deduped across * all emitted entries (and against pre-existing list rules) so no * URL is speculated twice. * * @since 2.0.0 * @since 2.2.0 Carve out the opt-in guarded prerender list via wppo_register_speculation_rules(). * * @param mixed $rules Speculation rules array from core. * @return mixed Updated rules, or the input unchanged. */functionfilter_speculation_list_rules(){if(!is_array()){return;}if(empty(->get_options()[\'preload_settings\'][\'enableSpeculationRules\'])){return;}if(->is_speculation_suppressed_for_visitor()){return;}=->get_singular_home_link_rule();=->get_archive_first_post_rule();=->collect_speculation_list_urls();if(is_array()&&!empty([\'urls\'])&&is_array([\'urls\'])){[\'urls\']=->diff_speculation_urls([\'urls\'],);if(empty([\'urls\'])){=null;}}=array();if(is_array()&&!empty([\'urls\'])&&is_array([\'urls\'])){foreach([\'urls\']as){if(is_string()&&\'\'!==){[]=;}}}=->get_speculation_list_urls();/** * Filters the high-value speculation list URLs. * * @since 2.0.0 * @param string[] $urls Validated list URLs (home + high-value + RUM winners). */=apply_filters(\'wppo_speculation_list_urls\',);if(!is_array()){return;}=array_values(array_filter(,\'is_string\'));=array_values(array_filter(,array(,\'is_speculation_list_url_valid\')));if(!empty()){=->diff_speculation_urls(,);}if(is_array()){=->collect_speculation_document_paths();if(!empty()){=array_values(array_filter(,staticfunction()use(){=wp_parse_url((string),PHP_URL_PATH);if(!is_string()||\'\'===){returntrue;}=rtrim(untrailingslashit(),\'/\');if(\'\'===){=\'/\';}foreach(as){if(===){returnfalse;}}returntrue;}));}}=->dedupe_speculation_urls_against_rules(,);=->get_prerender_list_urls(,);if(!empty()){=->diff_speculation_urls(,);}=->get_options()[\'preload_settings\']??array();=[\'speculationEagerness\']??\'conservative\';if(class_exists(\'WP_Speculation_Rules\')&&method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){if(!\\WP_Speculation_Rules::is_valid_eagerness()){=\'conservative\';}}elseif(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){=\'conservative\';}=->maybe_cap_speculation_eagerness();=array();if(!empty()){[]=array(\'source\'=>\'list\',\'urls\'=>array_values(),\'eagerness\'=>,);}if(is_array()){[]=;}if(is_array()){if(!->has_document_source_rule()){/** * Filters the archive first-post document rule before it is appended. * * @since 2.0.0 * @param array $archive_rule The archive document rule. */=apply_filters(\'wppo_speculation_document_rule\',);if(is_array()){[]=;}}}if(empty()){return;}foreach(as){[]=;}/** * Filters the speculation rules after the high-value list rule is appended. * * Trusted-code-only: a non-array return falls back to the * pre-filter rules and non-array entries are dropped * ({@see validate_speculation_rules_output()}). * * @since 2.0.0 * @param array $rules Updated rules. * @param string[] $urls List URLs that were appended. */=apply_filters(\'wppo_speculation_list_rules\',,);=->validate_speculation_rules_output(,);if(!empty()){=->wppo_register_speculation_rules(,);}return;}/** * Whether any rule in the set uses a document source. * * Shared by the fill-gaps-only document-rule gate in * {@see filter_speculation_list_rules()} so source-checking logic * lives in one place. * * @since 2.0.0 * * @param array $rules Existing speculation rules. * @return bool True when a document-source rule is present. */functionhas_document_source_rule(array):bool{foreach(as){if(is_array()&&\'document\'===([\'source\']??\'\')){returntrue;}}returnfalse;}/** * Collect URLs already covered by list-source rules. * * @since 2.0.0 * * @param array $rules Existing speculation rules. * @return string[] List-source URLs already present. */functioncollect_speculation_list_urls(array):array{=array();foreach(as){if(!is_array()||([\'source\']??\'\')!==\'list\'){continue;}=[\'urls\']??array();if(!is_array()){continue;}foreach(as){if(is_string()&&\'\'!==){[]=;}}}return;}/** * Remove URLs already covered by an existing list-source rule. * * Mirrors `AI_Adaptive::dedupe_against_existing_lists()` so this * method\'s contribution and the priority-20 AI rule can never * re-add the same URL (single block, no duplicates). Comparison is * normalization-aware ({@see normalize_speculation_url()}): a RUM * winner with a restored trailing slash and a raw high-value input * without one count as the same URL. * * @since 2.0.0 * @since 2.2.0 Normalize before comparing. * * @param string[] $urls Candidate list URLs. * @param array $rules Existing speculation rules. * @return string[] Deduped URLs. */functiondedupe_speculation_urls_against_rules(array,array):array{=->collect_speculation_list_urls();return->diff_speculation_urls(,);}/** * Remove URLs present in an exclusion set (normalization-aware). * * Shared by the singular-rule, covered-URL, and prerender carve-outs * in {@see filter_speculation_list_rules()} so trailing-slash * variants compare equal everywhere. Output preserves the original * (non-normalized) URL strings and order. * * @since 2.2.0 * * @param array $urls Candidate URLs (non-strings dropped). * @param array $excluded URLs to remove. * @return string[] Remaining URLs. */functiondiff_speculation_urls(array,array):array{=array();foreach(as){if(is_string()&&\'\'!==){[->normalize_speculation_url()]=true;}}if(empty()){=array();foreach(as){if(is_string()&&\'\'!==){[]=;}}returnarray_values();}=array();foreach(as){if(!is_string()||\'\'===){continue;}=->normalize_speculation_url();if(isset([])){continue;}[]=;[]=true;}returnarray_values();}/** * Collect normalized target paths from a document-source rule. * * Reduces each `href_matches` pattern (e.g. `/first-post/*`) to its * path prefix so generic-list URLs can be compared on the same basis. * * @since 2.0.0 * * @param array $document_rule Document-source rule. * @return string[] Normalized paths (e.g. `/first-post`). */functioncollect_speculation_document_paths(array):array{=array();=[\'where\']??null;if(!is_array()){return;}=[\'and\']??array();if(!is_array()){return;}foreach(as){if(!is_array()||empty([\'href_matches\'])||!is_string([\'href_matches\'])){continue;}=rtrim([\'href_matches\'],\'*\');=rtrim(untrailingslashit(),\'/\');[]=\'\'===?\'/\':;}return;}/** * Read-only lookup of a WP 7.1 speculative-loading default override. * * Mirrors core\'s `wp_get_speculative_loading_override()` precedence * (constant over environment variable) without depending on that private * core function. Reads configuration only; never writes environment * variables or constants. * * Only treats the value as an override when it is one of the values core * accepts for that setting (mirroring the validation in core\'s * `wp_get_speculation_rules_configuration()`). An invalid or empty value * is treated as absent so the plugin\'s conservative pin still applies * instead of silently allowing core\'s cached-site escalation. * * When `wp_get_speculation_rules_default_configuration()` exists (WP 7.1+) * its effective defaults are also considered: if the host pinned a different * default (e.g. `moderate`) the function will reflect it, and this helper * treats that as an override so the plugin\'s `conservative` pin does not * fight the host. The `wp_speculation_rules_configuration` filter (used in * {@see filter_speculation_rules_configuration()}) wins over the host in * any case (documented precedence). Validation uses * `WP_Speculation_Rules::is_valid_mode()` / `is_valid_eagerness()` when available. * * @since 1.9.0 * @since 2.0.0 Honor `wp_get_speculation_rules_default_configuration()` when available. * * @param string $name Override name, e.g. \'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'. * @return string|null The override value, or null when neither the constant nor the environment variable is set to a valid value. */functionget_speculation_default_override(string):?string{if(function_exists(\'wp_get_speculation_rules_default_configuration\')){=wp_get_speculation_rules_default_configuration();if(is_array()){=null;if(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===){=\'mode\';}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===){=\'eagerness\';}if(null!==&&isset([])&&is_string([])&&\'\'!==[]){=[];=true;if(class_exists(\'WP_Speculation_Rules\')){if(\'mode\'===){if(method_exists(\'WP_Speculation_Rules\',\'is_valid_mode\')){=\\WP_Speculation_Rules::is_valid_mode();}else{=in_array(,array(\'prefetch\',\'prerender\'),true);}}elseif(\'eagerness\'===){if(method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){=\\WP_Speculation_Rules::is_valid_eagerness();}else{=in_array(,array(\'conservative\',\'moderate\',\'eager\'),true);}}}elseif(\'mode\'===){=in_array(,array(\'prefetch\',\'prerender\'),true);}else{=in_array(,array(\'conservative\',\'moderate\',\'eager\'),true);}if(){=(\'mode\'===)?\'prefetch\':\'conservative\';if(!==){return;}}}}}=null;if(function_exists(\'getenv\')){=getenv();if(false!==){=;}}if(defined()){=constant();if(is_string()){=;}}if(!is_string()||\'\'===){returnnull;}if(class_exists(\'WP_Speculation_Rules\')){if(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===&&method_exists(\'WP_Speculation_Rules\',\'is_valid_mode\')){if(!\\WP_Speculation_Rules::is_valid_mode()){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===&&method_exists(\'WP_Speculation_Rules\',\'is_valid_eagerness\')){if(!\\WP_Speculation_Rules::is_valid_eagerness()){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===){if(!in_array(,array(\'prefetch\',\'prerender\'),true)){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===){if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){returnnull;}}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_MODE\'===){if(!in_array(,array(\'prefetch\',\'prerender\'),true)){returnnull;}}elseif(\'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS\'===){if(!in_array(,array(\'conservative\',\'moderate\',\'eager\'),true)){returnnull;}}return;}/** * Get handles to exclude from optimization via the CVE guard filter. * * Filter-only, S scope (no persistence, no cron). Default empty (disabled). * When a CVE is known for a handle, site operators can auto-exclude it via * `wppo_cve_guard_handles` (alias `wppo_cve_excluded_handles` for BC) without * touching `wppo_settings`. Merged into minify/defer/delay exclude lists with * `array_unique`; respects the existing `litespeed_can_optm` gate (optimization * disabled there anyway). * * @since 2.0.0 * @return string[] List of handle strings to exclude. */functionget_cve_guard_handles():array{=apply_filters(\'wppo_cve_guard_handles\',array());=apply_filters(\'wppo_cve_excluded_handles\',);if(!is_array()){returnarray();}=array_filter(,\'is_string\');=array_map(\'trim\',);=array_filter();returnarray_values(array_unique());}/** * Whether a style handle is a core block asset owned by core\'s on-demand loader. * * On WP 6.9+ with separate core block assets active, the combined * stylesheet (`wp-block-library`) and every per-block stylesheet * (`wp-block-cover`, `wp-block-group`, ...) are loaded on demand for the * blocks actually present on the page. On pre-6.9 cores the 6.8 classic * on-demand world (`should_load_block_assets_on_demand` / * `wp_should_load_block_assets_on_demand()`, see * {@see is_classic_block_assets_on_demand_active()}) owns the same * `wp-block-*` family when the operator opted in. Rewriting their `src` (minify) or * folding them into the combined file would re-monolithize what core * ships conditionally, so the minify path skips them in either mode — * mirroring `Cache::is_core_block_asset()`. * * @since 2.0.0 * * @param string $handle The registered style handle. * @return bool True when core owns the handle under on-demand mode. */functionis_core_block_asset_skipped():bool{if(->is_core_separate_block_assets_active()){try{returnstr_starts_with((string),\'wp-block-\');}catch(\\Throwable){unset();returnfalse;}}if(->is_classic_block_assets_on_demand_active()){try{returnstr_starts_with((string),\'wp-block-\');}catch(\\Throwable){unset();returnfalse;}}returnfalse;}/** * Whether WP 6.8 classic on-demand block-asset loading is active. * * Covers the pre-6.9 world (`should_load_block_assets_on_demand` filter / * `wp_should_load_block_assets_on_demand()`) that * {@see is_core_separate_block_assets_active()} does not model. Positive * runtime evidence only: the core function wins when present, otherwise * a `has_filter`-guarded `should_load_block_assets_on_demand` read * (which reflects the opt-in registered by * {@see Hook_Registry::register_block_assets_filters()}). The combined monolith escape * hatch (`loadAllCoreBlockAssets` on / `blockAssetsOnDemand` off, see * {@see is_hidden_block_asset_omission_enabled()}) forces false so * operators who opted out keep the legacy monolith. Fail-open: any * throwable, missing API, or absent evidence returns false (legacy path). * * @since 2.2.0 * * @return bool True when classic on-demand block assets are active. */functionis_classic_block_assets_on_demand_active():bool{try{if(Wp_Version::is_global_at_least(\'6.9-alpha\')){returnfalse;}if(!->is_hidden_block_asset_omission_enabled()){returnfalse;}if(function_exists(\'wp_should_load_block_assets_on_demand\')){return(bool)wp_should_load_block_assets_on_demand();}if(function_exists(\'has_filter\')&&has_filter(\'should_load_block_assets_on_demand\')){return(bool)apply_filters(\'should_load_block_assets_on_demand\',false);}returnfalse;}catch(\\Throwable){unset();returnfalse;}}/** * Whether WP 6.9+ core reports separate (on-demand) block-asset loading. * * Shared predicate for {@see is_core_block_asset_skipped()} and * {@see is_core_block_hoisting_active()} so the 6.9-alpha floor + * API-exists + fail-open check cannot drift between the two call sites. * * @since 2.2.0 * * @return bool True when core loads separate core block assets on demand. */functionis_core_separate_block_assets_active():bool{if(!Wp_Version::is_global_at_least(\'6.9-alpha\')){returnfalse;}if(!function_exists(\'wp_should_load_separate_core_block_assets\')){returnfalse;}try{return(bool)wp_should_load_separate_core_block_assets();}catch(\\Throwable){unset();returnfalse;}}/** * Whether the hidden-block-asset omission pass may run. * * Mirrors the opt-out state honored by * {@see Hook_Registry::register_block_assets_filters()}: the omission pass only runs * when on-demand block assets are enabled (`blockAssetsOnDemand` on) and * the combined monolith is not forced (`loadAllCoreBlockAssets` off). * Users who explicitly disabled on-demand assets keep the legacy * monolith untouched. * * @since 2.2.0 * * @return bool True when hidden block assets may be omitted. */functionis_hidden_block_asset_omission_enabled():bool{return!empty(->get_options()[\'file_optimisation\'][\'blockAssetsOnDemand\'])&&empty(->get_options()[\'file_optimisation\'][\'loadAllCoreBlockAssets\']);}/** * Whether core 6.9+ block-asset hoisting is active on this request. * * True only when {@see is_core_separate_block_assets_active()} holds AND * the template-enhancement buffer API exists. Callers use this to yield * to core\'s on-demand hoisting instead of duplicating it (e.g. * {@see omit_hidden_block_assets()} returns early when the * template-enhancement buffer exists). Fail-open: any throwable or * missing API returns false (legacy path unchanged). * * @since 2.2.0 * * @return bool True when core owns on-demand block-asset hoisting. */functionis_core_block_hoisting_active():bool{if(!function_exists(\'wp_should_output_buffer_template_for_enhancement\')){returnfalse;}return->is_core_separate_block_assets_active();}/** * Whether a queued style handle is a core per-block stylesheet. * * The `wp-block-*` prefix alone is not proof of core ownership: * third-party or theme stylesheets may share the prefix without a 1:1 * block-type mapping. A handle only counts as a core per-block asset * when its registered `src` points at core\'s block styles * (`wp-includes` + `block-library` or `/blocks/`). Fail-open: an * unregistered handle or an unverifiable `src` returns false (keep the * asset — degrade to unoptimized, never unstyled). * * @since 2.2.0 * * @param string $handle Queued style handle e.g. \'wp-block-cover\'. * @return bool True when the handle is verifiably a core per-block asset. */functionis_core_per_block_style_handle():bool{try{global;if(!is_object()||!isset(->registered[])){returnfalse;}=(string)(->registered[]->src??\'\');if(\'\'===){returnfalse;}if(false!==strpos(,\'block-library\')){returnfalse!==strpos(,\'wp-includes\');}returnfalse!==strpos(,\'wp-includes\')&&false!==strpos(,\'/blocks/\');}catch(\\Throwable){unset();returnfalse;}}/** * Whether singular post content references block sources outside itself. * * Reusable blocks (`wp:block` refs), patterns (`wp:pattern`), * template parts (`wp:template-part`), shortcode blocks * (`wp:shortcode`, generic `[...]` shortcodes, `do_blocks` output), * and similar markers render stylesheets for blocks absent from the * literal post content, so type-absence cannot prove the asset is * unused. Fail-open: any throwable (or a detected marker) reports * unresolvable (the caller bails and keeps every asset). * * @since 2.2.0 * * @param string $content Singular post content to check. * @return bool True when the content references out-of-content block sources. */functioncontent_has_unresolvable_block_sources():bool{try{=(string);if(\'\'===){returnfalse;}if(false!==strpos(,\'<!-- wp:block \')||false!==strpos(,\'<!-- wp:block/\')||false!==strpos(,\'wp:pattern\')||false!==strpos(,\'wp:template-part\')||false!==strpos(,\'wp:shortcode\')||false!==strpos(,\'do_blocks\')){returntrue;}if(false===strpos(,\'[\')){returnfalse;}if(function_exists(\'get_shortcode_regex\')){try{=(string)get_shortcode_regex();if(\'\'===){returnfalse;}if(1!==preg_match_all(\'/\'..\'/s\',,)||empty([2])){returnfalse;}if(function_exists(\'shortcode_exists\')){foreach([2]as){if(\'\'!==(string)&&shortcode_exists((string))){returntrue;}}returnfalse;}returntrue;}catch(\\Throwable){unset();returntrue;}}return1===preg_match(\'/\\[[a-zA-Z0-9_-]+(?:\\s+[^\\]]*)?\\/?\\]/\',);}catch(\\Throwable){unset();returntrue;}}/** * Whether a block name is a registered block type. * * A `wp-block-<slug>` handle does not guarantee a matching * `core/<slug>` block type exists: core-registered handles without a * 1:1 block-type mapping never match {@see Util::content_has_block()} * and would otherwise always be omitted whenever queued. Fail-open in * the omission direction: when the registry API is unavailable or * throws, the type is treated as known so the legacy omission path is * unchanged; only a positive \"not registered\" answer keeps the asset. * * @since 2.2.0 * * @param string $block_name Block name e.g. \'core/cover\'. * @return bool True when the type is (or may be) registered. */functionis_registered_block_type():bool{try{if(!class_exists(\'WP_Block_Type_Registry\')){returntrue;}if(!method_exists(\'WP_Block_Type_Registry\',\'get_instance\')){returntrue;}=\\WP_Block_Type_Registry::get_instance();if(!is_object()){returntrue;}if(method_exists(,\'is_registered\')){return(bool)->is_registered((string));}if(method_exists(,\'get_registered\')){returnnull!==->get_registered((string));}returntrue;}catch(\\Throwable){unset();returntrue;}}/** * Whether core 6.9+ wants an empty block\'s asset kept via its canonical filter. * * Probes core\'s `enqueue_empty_block_content_assets` filter * (`wp-includes/class-wp-block.php`, `@since 6.9.0`; semantics: * `$enqueue=false` = drop empty-block assets, return `true` = keep * them). See the WP 6.9 frontend-performance field guide. Fail-open: * any doubt returns true (keep the asset — degrade to unoptimized, * never unstyled). On core below 6.9 returns false (no keep-signal) * so the caller falls through to legacy behavior byte-for-byte. * * @since 2.2.0 * * @param string $block_name Block name e.g. \'core/cover\'. * @return bool True when core wants the asset kept. */functionshould_keep_empty_block_asset_via_core_filter():bool{try{if(!Wp_Version::is_global_at_least(\'6.9-alpha\')){returnfalse;}if(!function_exists(\'has_filter\')||!function_exists(\'apply_filters\')){returntrue;}if(!has_filter(\'enqueue_empty_block_content_assets\')){returnfalse;}try{=apply_filters(\'enqueue_empty_block_content_assets\',false,(string));}catch(\\Throwable){unset();returntrue;}return(bool);}catch(\\Throwable){unset();returntrue;}}/** * Whether a queued core block asset should be omitted as hidden. * * A block asset counts as hidden when its block type is absent from the * given singular post content (type-absence definition: blocks present in * markup but never rendered still ship their per-block stylesheet, so * the stylesheet is unused by definition). Hidden assets are omitted by * default; a per-block re-enable is available via the * `wppo_allow_hidden_block_asset` filter (return truthy to keep the * asset for that block). On WP 6.9+ core\'s canonical * `enqueue_empty_block_content_assets` filter is honored first * (return `true` to keep the asset even though empty); either filter * keeping the asset wins. The filters are only applied when a * listener is registered (`has_filter()` guard). Fail-open: missing * content, missing APIs, an unregistered block type, or any throwable * returns false (keep the asset — degrade to unoptimized, never * fatal, never unstyled). * Callers may pass a shared `$presence` map so the content parse in * {@see Util::content_has_block()} runs at most once per block type per * pass instead of once per queued handle. * * @since 2.2.0 * * @param string $block_name Block name e.g. \'core/cover\'. * @param string $handle Queued style handle e.g. \'wp-block-cover\'. * @param string $content Singular post content to check against. * @param array $presence Optional shared presence cache (block_name => bool), updated by reference. * @return bool True when the asset should be dequeued. */functionshould_omit_hidden_block_asset(,,,&=array()):bool{try{if(\'\'===(string)||\'\'===(string)){returnfalse;}if(\'\'===(string)){returnfalse;}if(!->is_registered_block_type((string))){returnfalse;}=(string);if(!array_key_exists(,(array))){[]=Util::content_has_block((string),(string));}if(!empty([])){returnfalse;}if(->should_keep_empty_block_asset_via_core_filter((string))){returnfalse;}=false;if(function_exists(\'has_filter\')&&has_filter(\'wppo_allow_hidden_block_asset\')){try{=apply_filters(\'wppo_allow_hidden_block_asset\',false,(string),(string));}catch(\\Throwable){unset();returnfalse;}}returnempty();}catch(\\Throwable){unset();returnfalse;}}/** * Dequeue per-block stylesheets for blocks absent from the content. * * Singular views only: a single `post_content` is authoritative only * there. Archives, blog-home, search, and other non-singular views bail * out immediately so stylesheets needed by other posts in the loop are * never stripped. Block themes bail out as well: header/footer * template parts, site chrome, and widgets render outside * `post_content`, so type-absence cannot prove the asset is unused * there. Singular gating alone does not protect composite * sources: reusable blocks, patterns, template parts, widgets, and * shortcode/`do_blocks`-injected blocks can render stylesheets for * blocks absent from `post_content`, so the pass additionally bails * out entirely when the content references such out-of-content * sources (see {@see content_has_unresolvable_block_sources()}). * Remaining outside-`post_content` rendering on classic themes is a * known limitation — use the `wppo_allow_hidden_block_asset` filter * to keep those assets. * * Runs on `wp_enqueue_scripts` at PHP_INT_MAX - 2: after core enqueues * but before `minify_queued_styles()` and `Cache::combine_css()`, so * omitted handles never enter the minify/combine pipelines and the * cascade order of the surviving stylesheets is preserved (dequeue * only — nothing is re-enqueued or folded into a monolith). * * Defers to core 6.9 hoisting: when {@see is_core_block_hoisting_active()} * is true and the template-enhancement buffer exists * (`wp_should_output_buffer_template_for_enhancement()`), this returns * immediately and core\'s conditional loading owns the output. The pass * is also skipped when the on-demand opt-out is active * ({@see is_hidden_block_asset_omission_enabled()}). Otherwise the * legacy omission path runs unchanged. * * @since 2.2.0 * * @return void */functionomit_hidden_block_assets():void{try{if(!->is_hidden_block_asset_omission_enabled()){return;}if(!function_exists(\'is_singular\')||!is_singular()){return;}if(function_exists(\'wp_is_block_theme\')){try{if(wp_is_block_theme()){return;}}catch(\\Throwable){unset();return;}}if(->is_core_block_hoisting_active()&&function_exists(\'wp_should_output_buffer_template_for_enhancement\')){try{if(wp_should_output_buffer_template_for_enhancement()){return;}}catch(\\Throwable){unset();}}global;if(!is_object()||empty(->queue)||!is_array(->queue)){return;}if(!function_exists(\'wp_dequeue_style\')){return;}=\'\';if(function_exists(\'get_the_ID\')&&function_exists(\'get_post_field\')){try{=get_the_ID();if(!empty()){=(string)get_post_field(\'post_content\',);}}catch(\\Throwable){unset();return;}}if(\'\'===){return;}if(->content_has_unresolvable_block_sources()){return;}=array();=array();foreach(->queueas){if(!is_string()||0!==strpos(,\'wp-block-\')){continue;}=substr(,strlen(\'wp-block-\'));if(\'\'===||0===strpos(,\'library\')){continue;}if(!->is_core_per_block_style_handle()){continue;}=\'core/\'.;if(!array_key_exists(,)){[]=->should_omit_hidden_block_asset(,,,);}if([]){try{wp_dequeue_style();}catch(\\Throwable){unset();}}}}catch(\\Throwable){unset();}}/** * Rewrites enqueued styles to their minified versions at enqueue time and * registers the on-disk path so core can inline them. * * The inline-styles `path` data mechanism exists since WordPress 5.8 * (`wp_maybe_inline_styles()` / the `styles_inline_size_limit` filter; the * default budget was raised from 20KB to 40KB in 6.9). This runs on * `wp_enqueue_scripts` (before core\'s inline pass at `wp_head` priority 1) * so minified files can opt in to inlining. Falls back to the * `style_loader_tag` rewriting in {@see minify_css()} on older WordPress * versions. * * @since 1.9.0 * @return void */functionminify_queued_styles():void{->minify_policy()->minify_queued_styles();}/** * Rewrites CSS link tags to use minified versions if they exist. * * @since 1.0.0 * * @param string $tag The link tag HTML. * @param string $handle The CSS file\'s handle. * @param string $href The CSS file\'s source URL. * @return string Modified link tag with minified CSS. */functionminify_css(,,){return->minify_policy()->minify_css(,,);}/** * Rewrites script tags to use minified versions if they exist. * * @since 1.0.0 * * @param string $tag The script tag HTML. * @param string $handle The script\'s registered handle. * @param string $src The script\'s source URL. * @return string Modified script tag with minified JavaScript. */functionminify_js(,,){return->minify_policy()->minify_js(,,);}/** * Sanitizes image info for client exposure — replaces path arrays with counts. * * Prevents filesystem paths from being visible in wppoSettings via View Page Source. * * @since 1.7.0 * * @param array $img_info Raw image info from wppo_img_info option. * @return array Image info with only counts (no file paths). */functionsanitize_image_info_for_client(array):array{=array();foreach(array(\'pending\',\'completed\',\'failed\')as){=[]??array();[]=array(\'webp\'=>is_array([\'webp\']??null)?count([\'webp\']):([\'webp\']??0),\'avif\'=>is_array([\'avif\']??null)?count([\'avif\']):([\'avif\']??0),);}return;}/** * Upgrade auto-purge status for the SPA (issue #1276). * * Returns the last derived-cache purge record plus a safe-mode * preview URL (`?wppo_nocache=1`, bypassing minify). Class and * method-exists guarded + fail-open so localisation never fatals. * Localised (not lazy-fetched) intentionally: the banner needs the * seed on first paint and the SPA refreshes via the read-only * upgrade_purge_status endpoint after cache-clearing actions; the * cost is a single non-autoloaded option read on admin pages. * * @since 2.2.0 * @return array{last_purge:array{reason:string,time:int},safe_preview_url:string} */functionget_upgrade_purge_for_client():array{=array(\'last_purge\'=>array(\'reason\'=>\'\',\'time\'=>0,),\'safe_preview_url\'=>\'\',);try{if(!class_exists(\'PerformanceOptimise\\Inc\\Builder_Purge_Watcher\')){return;}if(method_exists(\'PerformanceOptimise\\Inc\\Builder_Purge_Watcher\',\'get_last_purge\')){=Builder_Purge_Watcher::get_last_purge();if(is_array()){[\'last_purge\']=array(\'reason\'=>isset([\'reason\'])&&is_string([\'reason\'])?[\'reason\']:\'\',\'time\'=>isset([\'time\'])?(int)[\'time\']:0,);}}if(method_exists(\'PerformanceOptimise\\Inc\\Builder_Purge_Watcher\',\'get_safe_preview_url\')){=Builder_Purge_Watcher::get_safe_preview_url();[\'safe_preview_url\']=is_string()?:\'\';}}catch(\\Throwable){unset();}return;}/** * Enqueues the admin bar cache-clearing script and its data. * * Shared between admin and frontend to ensure consistent wppoObject data. * * @since 1.9.0 * * @return void */functionenqueue_admin_bar_script():void{=WPPO_PLUGIN_PATH.\'build/main.asset.php\';=wp_normalize_path(realpath());if(false!==&&0===strpos(,(string)WPPO_PLUGIN_PATH)){=require;}else{=array(\'dependencies\'=>array(),\'version\'=>WPPO_VERSION,);}wp_enqueue_script(\'wppo-admin-bar-script\',WPPO_PLUGIN_URL.\'build/main.js\',[\'dependencies\'],[\'version\'],array(\'in_footer\'=>true,\'fetchpriority\'=>\'low\',));=array(\'apiUrl\'=>get_rest_url(null,\'performance-optimisation/v1\'),\'ajaxUrl\'=>admin_url(\'admin-ajax.php\'),\'nonce\'=>wp_create_nonce(\'wp_rest\'),\'nonce_refresh\'=>wp_create_nonce(\'wppo_nonce_refresh\'),\'translations\'=>array(\'cacheCleared\'=>__(\'Cache cleared successfully.\',\'performance-optimisation\'),\'clearFailed\'=>__(\'Failed to clear cache.\',\'performance-optimisation\'),\'clearRetry\'=>__(\'Failed to clear cache. Please try again.\',\'performance-optimisation\'),\'pageCleared\'=>__(\'Page cache cleared successfully.\',\'performance-optimisation\'),\'pageFailed\'=>__(\'Failed to clear page cache.\',\'performance-optimisation\'),\'pageRetry\'=>__(\'Failed to clear page cache. Please try again.\',\'performance-optimisation\'),\'dismiss\'=>__(\'Dismiss\',\'performance-optimisation\'),),);wp_add_inline_script(\'wppo-admin-bar-script\',\'window.wppoObject = \'.wp_json_encode(,JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP).\';\',\'before\');wp_set_script_translations(\'wppo-admin-bar-script\',\'performance-optimisation\');}——

Return: mixed — Reference to the live state.

Tags: @internal · @since 2.4.0

Source: includes/Core/class-main.php, line 1250

Hooks

Hooks referenced in includes/Core/class-main.php:

HookTypeLineNotes
wppo_exclude_defer_jsfilter1015—
wppo_exclude_defer_jsfilter1020—
wppo_exclude_delay_jsfilter1063—
wppo_elementor_safe_mode_enabledfilter1707—
wppo_is_elementor_pagefilter1772—
wppo_debug_logaction2506—
wppo_server_timing_enabledfilter2809—
wppo_delay_js_allowed_hostsfilter3389—
wppo_safe_mode_enabledfilter5143—
wppo_defer_js_preset_exclusionsfilter5252—
wppo_fragile_handle_mapfilter5362—
wppo_speculation_exclusionsfilter7283@param ×2
wppo_speculation_prerender_list_urlsfilter8824@param ×1
wppo_speculation_prerender_list_rulefilter8850@param ×1
wppo_speculation_prerender_list_urlsfilter8884@param ×1
wppo_speculation_prerender_list_rulefilter8906@param ×1
wppo_speculation_prerender_list_rulesfilter8929@param ×2
wppo_speculation_list_urlsfilter9015@param ×1
wppo_speculation_document_rulefilter9113@param ×1
wppo_speculation_list_rulesfilter9139@param ×2
wppo_cve_guard_handlesfilter9446—
wppo_cve_excluded_handlesfilter9447—
wppo_allow_hidden_block_assetfilter9861—