PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / media-optimization / class-smart-lazy-loader.php

class-smart-lazy-loader.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at media-optimization/class-smart-lazy-loader.php

155 lines 5.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Smart_Lazy_Loader
4 * Adds loading="lazy" to images and iframes while protecting LCP elements.
5 *
6 * @package Search Atlas SEO
7 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
8 * @since 2.6.0
9 */
10
11 if (!defined('ABSPATH')) {
12 exit;
13 }
14
15 class Metasync_Smart_Lazy_Loader {
16
17 private array $settings;
18
19 public function __construct(array $settings) {
20 $this->settings = $settings;
21
22 // Tell WordPress to skip lazy loading for the first N images (WP 5.9+)
23 $skip = (int) ($settings['lcp_skip_count'] ?? 2);
24 add_filter('wp_omit_loading_attr_threshold', fn() => $skip);
25
26 // For broader control (including non-WP images and iframes), filter content
27 add_filter('the_content', [$this, 'process_content'], 25);
28 add_filter('post_thumbnail_html', [$this, 'process_content'], 25);
29 add_filter('widget_text', [$this, 'process_content'], 25);
30 }
31
32 /**
33 * Process HTML content to add/manage lazy loading attributes.
34 */
35 public function process_content(string $content): string {
36 if (empty($content) || is_admin() || is_feed() || $this->is_amp_request()) {
37 return $content;
38 }
39
40 $content = $this->process_images($content);
41
42 if (!empty($this->settings['lazy_load_iframes'])) {
43 $content = $this->process_iframes($content);
44 }
45
46 return $content;
47 }
48
49 /**
50 * Add loading="lazy" to <img> tags, skipping the first N for LCP.
51 */
52 private function process_images(string $content): string {
53 $skip = (int) ($this->settings['lcp_skip_count'] ?? 2);
54 $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? '')));
55
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) {
64 $tag = $matches[0];
65
66 // Already has a loading attribute - don't touch it
67 if (preg_match('/\sloading\s*=/i', $tag)) {
68 return $tag;
69 }
70
71 // Check excluded classes
72 if (!empty($exclude_classes) && preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) {
73 $classes = explode(' ', $class_match[1]);
74 foreach ($exclude_classes as $excluded) {
75 if (in_array($excluded, $classes, true)) {
76 return $tag;
77 }
78 }
79 }
80
81 // Only images this filter actually modifies consume the budget;
82 // already-lazy and excluded tags above must not.
83 $position++;
84
85 // Skip first N images to protect LCP
86 if ($position <= $skip) {
87 return $this->inject_attribute($tag, 'loading', 'eager');
88 }
89
90 // Add loading="lazy" and decoding="async" for below-fold images
91 $tag = $this->inject_attribute($tag, 'loading', 'lazy');
92 $tag = $this->inject_attribute($tag, 'decoding', 'async');
93
94 return $tag;
95 }, $content);
96 }
97
98 /**
99 * Add loading="lazy" to <iframe> tags.
100 */
101 private function process_iframes(string $content): string {
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) {
109 $tag = $matches[0];
110
111 if (preg_match('/\sloading\s*=/i', $tag)) {
112 return $tag;
113 }
114
115 $position++;
116 if ($position <= $skip) {
117 return $this->inject_attribute($tag, 'loading', 'eager');
118 }
119
120 return $this->inject_attribute($tag, 'loading', 'lazy');
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);
142 }
143
144 /**
145 * Inject an HTML attribute into a tag if not already present.
146 */
147 private function inject_attribute(string $tag, string $attr, string $value): string {
148 if (preg_match('/\s' . preg_quote($attr, '/') . '\s*=/i', $tag)) {
149 return $tag;
150 }
151
152 return preg_replace('/\s*\/?>$/', sprintf(' %s="%s"$0', esc_attr($attr), esc_attr($value)), $tag, 1);
153 }
154 }
155