| @@ -14,9 +14,8 @@ | ||
| 14 | 14 | |
| 15 | 15 | class Metasync_Smart_Lazy_Loader { |
| 16 | 16 | |
| 17 | 17 | private array $settings; |
| 18 | - private int $img_counter = 0; | |
| 19 | 18 | |
| 20 | 19 | public function __construct(array $settings) { |
| 21 | 20 | $this->settings = $settings; |
| 22 | 21 | |
| @@ -33,9 +32,9 @@ | ||
| 33 | 32 | /** |
| 34 | 33 | * Process HTML content to add/manage lazy loading attributes. |
| 35 | 34 | */ |
| 36 | 35 | public function process_content(string $content): string { |
| 37 | - if (empty($content) || is_admin() || is_feed()) { | |
| 36 | + if (empty($content) || is_admin() || is_feed() || $this->is_amp_request()) { | |
| 38 | 37 | return $content; |
| 39 | 38 | } |
| 40 | 39 | |
| 41 | 40 | $content = $this->process_images($content); |
| @@ -53,11 +52,17 @@ | ||
| 53 | 52 | private function process_images(string $content): string { |
| 54 | 53 | $skip = (int) ($this->settings['lcp_skip_count'] ?? 2); |
| 55 | 54 | $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? ''))); |
| 56 | 55 | |
| 57 | - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) use ($skip, $exclude_classes) { | |
| 56 | + // The LCP budget is scoped to a single content block (the same | |
| 57 | + // semantics as core's wp_omit_loading_attr_threshold): an | |
| 58 | + // instance-wide counter shared across the_content, thumbnails, and | |
| 59 | + // widgets counted tags in filter-execution order, not document | |
| 60 | + // order, so the hero could still end up lazy-loaded. | |
| 61 | + $position = 0; | |
| 62 | + | |
| 63 | + return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) use ($skip, $exclude_classes, &$position) { | |
| 58 | 64 | $tag = $matches[0]; |
| 59 | - $this->img_counter++; | |
| 60 | 65 | |
| 61 | 66 | // Already has a loading attribute - don't touch it |
| 62 | 67 | if (preg_match('/\sloading\s*=/i', $tag)) { |
| 63 | 68 | return $tag; |
| @@ -72,10 +77,14 @@ | ||
| 72 | 77 | } |
| 73 | 78 | } |
| 74 | 79 | } |
| 75 | 80 | |
| 81 | + // Only images this filter actually modifies consume the budget; | |
| 82 | + // already-lazy and excluded tags above must not. | |
| 83 | + $position++; | |
| 84 | + | |
| 76 | 85 | // Skip first N images to protect LCP |
| 77 | - if ($this->img_counter <= $skip) { | |
| 86 | + if ($position <= $skip) { | |
| 78 | 87 | return $this->inject_attribute($tag, 'loading', 'eager'); |
| 79 | 88 | } |
| 80 | 89 | |
| 81 | 90 | // Add loading="lazy" and decoding="async" for below-fold images |
| @@ -89,9 +98,15 @@ | ||
| 89 | 98 | /** |
| 90 | 99 | * Add loading="lazy" to <iframe> tags. |
| 91 | 100 | */ |
| 92 | 101 | private function process_iframes(string $content): string { |
| 93 | - return preg_replace_callback('/<iframe\s[^>]*>/i', function ($matches) { | |
| 102 | + // Same skip semantics as images: the first N iframes (typically | |
| 103 | + // above-fold embeds — a hero map or video) stay eager instead of | |
| 104 | + // being lazy-loaded unconditionally. | |
| 105 | + $skip = (int) ($this->settings['lcp_skip_count'] ?? 2); | |
| 106 | + $position = 0; | |
| 107 | + | |
| 108 | + return preg_replace_callback('/<iframe\s[^>]*>/i', function ($matches) use ($skip, &$position) { | |
| 94 | 109 | $tag = $matches[0]; |
| 95 | 110 | |
| 96 | 111 | if (preg_match('/\sloading\s*=/i', $tag)) { |
| 97 | 112 | return $tag; |
| @@ -96,10 +111,35 @@ | ||
| 96 | 111 | if (preg_match('/\sloading\s*=/i', $tag)) { |
| 97 | 112 | return $tag; |
| 98 | 113 | } |
| 99 | 114 | |
| 115 | + $position++; | |
| 116 | + if ($position <= $skip) { | |
| 117 | + return $this->inject_attribute($tag, 'loading', 'eager'); | |
| 118 | + } | |
| 119 | + | |
| 100 | 120 | return $this->inject_attribute($tag, 'loading', 'lazy'); |
| 101 | 121 | }, $content); |
| 122 | + } | |
| 123 | + | |
| 124 | + /** | |
| 125 | + * True when the current request renders an AMP page. The AMP runtime | |
| 126 | + * manages its own loading strategy and rejects foreign attributes, so | |
| 127 | + * this filter is skipped there. The helper indirection keeps static | |
| 128 | + * analysis from assuming the AMP plugin's functions always exist. | |
| 129 | + */ | |
| 130 | + private function is_amp_request(): bool { | |
| 131 | + if (self::func_is_available('amp_is_request')) { | |
| 132 | + return (bool) amp_is_request(); | |
| 133 | + } | |
| 134 | + if (self::func_is_available('is_amp_endpoint')) { | |
| 135 | + return (bool) is_amp_endpoint(); | |
| 136 | + } | |
| 137 | + return false; | |
| 138 | + } | |
| 139 | + | |
| 140 | + private static function func_is_available(string $function): bool { | |
| 141 | + return function_exists($function); | |
| 102 | 142 | } |
| 103 | 143 | |
| 104 | 144 | /** |
| 105 | 145 | * Inject an HTML attribute into a tag if not already present. |