PluginProbe ʕ •ᴥ•ʔ
GPTranslate – Multilingual AI Translation for WordPress: Automatically Translate Websites / 2.28
GPTranslate – Multilingual AI Translation for WordPress: Automatically Translate Websites v2.28
2.33.6 2.33.5 2.33.2 2.32.10 2.33 2.33.1 2.32.6 2.32.7 2.32.8 trunk 2.10.3 2.10.4 2.10.5 2.10.6 2.11 2.12 2.13 2.14 2.14.1 2.15 2.15.1 2.16.1 2.16.2 2.17 2.18 2.18.1 2.18.2 2.19 2.20 2.21 2.22 2.23 2.24 2.25 2.25.1 2.25.2 2.26 2.27 2.27.10 2.27.5 2.28 2.28.1 2.29 2.30 2.31 2.32 2.32.5
gptranslate / includes / class-gptranslate-optimizer-compat.php
gptranslate / includes Last commit date
class-gptranslate-optimizer-compat.php 2 months ago
class-gptranslate-optimizer-compat.php
874 lines
1 <?php
2 /**
3 * GPTranslate Optimizer Compatibility
4 *
5 * Behaviour
6 * ──────────
7 * • Detects active JS/CSS optimizer plugins at boot time.
8 * • If NO optimizer is found → nothing happens, zero impact on existing code.
9 * • If one or more optimizers ARE found → exclusion rules are registered
10 * automatically so they leave GPTranslate scripts untouched.
11 * • Protects BOTH external script files AND inline scripts (wp_add_inline_script).
12 *
13 * No settings, no admin notices, no user interaction required.
14 * Full backwards-compatibility: sites without optimizers are completely unaffected.
15 * Only runs on frontend, never in wp-admin.
16 *
17 * Supported optimizers
18 * ─────────────────────
19 * WP Rocket, LiteSpeed Cache, Autoptimize, W3 Total Cache, WP Fastest Cache,
20 * SG Optimizer, Hummingbird, FlyingPress, NitroPack, perfmatters,
21 * Swift Performance (lite), WP-Optimize, Breeze (Cloudways),
22 * Powered Cache, WP Speed of Light, WPSpeed, FastCache.
23 *
24 * @package GPTranslate
25 * @since 2.27.5
26 */
27
28 if ( ! defined( 'ABSPATH' ) ) {
29 exit;
30 }
31
32 class GPTranslate_Optimizer_Compat {
33
34 // -------------------------------------------------------------------------
35 // Protected asset lists
36 // -------------------------------------------------------------------------
37
38 /**
39 * WordPress script handles for EXTERNAL files that must NOT be optimised.
40 * These are processed via the script_loader_tag filter.
41 * Filterable via `gptranslate_optimizer_protected_handles`.
42 *
43 * @var string[]
44 */
45 private static $protected_handles = [
46 'gptranslate-main',
47 'gptranslate-jsonrepair',
48 'gptranslate-bstoast',
49 'gptranslate-responsivevoice',
50 'gptranslate-toast-dismiss',
51 ];
52
53 /**
54 * Parent handles that have INLINE scripts attached via wp_add_inline_script().
55 * The wp_inline_script_tag filter uses these handles to protect inline blocks.
56 * WordPress outputs them as <script id="{handle}-js-{position}">…</script>.
57 *
58 * @var string[]
59 */
60 private static $protected_inline_handles = [
61 'gptranslate-main-inline',
62 'gptranslate-js-specs',
63 'gptranslate-js-language-strings',
64 'gptranslate-js-word-leafones-excluded-language',
65 'gptranslate-toast-dismiss',
66 ];
67
68 /**
69 * Original script info captured in init_late() before optimizers process them.
70 * Used by the output buffer to replace corrupted minified bundles.
71 * Each entry: [ 'src' => url, 'type' => 'module'|'', 'attrs' => extra attributes string ]
72 *
73 * @var array<string,array>
74 */
75 private static $original_script_tags = [];
76
77 /**
78 * Partial URL patterns for GPTranslate JS files.
79 * Filterable via `gptranslate_optimizer_protected_js_patterns`.
80 *
81 * @var string[]
82 */
83 private static $protected_js_patterns = [
84 'plugins/gptranslate',
85 'gptranslate.js',
86 'jsonrepair',
87 'toast.min.js',
88 'responsivevoice.js',
89 ];
90
91 /**
92 * Partial URL patterns for GPTranslate CSS files.
93 * Filterable via `gptranslate_optimizer_protected_css_patterns`.
94 *
95 * @var string[]
96 */
97 private static $protected_css_patterns = [
98 'plugins/gptranslate',
99 ];
100
101 /**
102 * Content markers used to identify GPTranslate inline scripts.
103 *
104 * Optimizers that scan inline script CONTENT to decide whether to skip them
105 * (e.g. WP Rocket's rocket_excluded_inline_js_content, LiteSpeed's
106 * litespeed_optimize_js_inline_excludes, Autoptimize's noptimize filter)
107 * will match against these strings.
108 *
109 * Strategy: we use TWO layers of markers so that at least one always matches.
110 *
111 * Layer 1 – sourceURL identifiers
112 * WordPress automatically appends //# sourceURL=<id> to every inline script
113 * for debugging. These are the most reliable markers because they are unique
114 * to GPTranslate and appear in EVERY inline block regardless of configuration.
115 *
116 * Layer 2 – JS variable names
117 * Fallback for environments that strip the sourceURL comment during output.
118 *
119 * @var string[]
120 */
121 private static $protected_inline_js_markers = [
122 // Layer 1: sourceURL comment identifiers (added by WordPress to inline scripts)
123 'gptranslate-main-inline-js-after',
124 'gptranslate-js-specs-js-after',
125 'gptranslate-js-language-strings-js-after',
126 'gptranslate-js-word-leafones-excluded-language-js-after',
127 'gptranslate-toast-dismiss-js-after',
128 // Layer 2: distinctive JS variable names present in the inline scripts
129 'gptServerSideLink',
130 'gptServerSideLightLink',
131 'gptApiKey',
132 'gptAjaxSecret',
133 'gptLiveSite',
134 'gptStorage',
135 'gptranslateSettings',
136 'PLG_GPTRANSLATE_TRANSLATING',
137 'chatgptApiKey',
138 'chatgptWordsLeafnodesExcludedByLanguage',
139 ];
140
141 /**
142 * Known optimizer plugins [plugin-basename => display-name].
143 *
144 * @var array<string,string>
145 */
146 private static $known_optimizers = [
147 'wp-rocket/wp-rocket.php' => 'WP Rocket',
148 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
149 'autoptimize/autoptimize.php' => 'Autoptimize',
150 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
151 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache',
152 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
153 'sg-optimizer/sg-optimizer.php' => 'SG Optimizer',
154 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird',
155 'wp-hummingbird/wp-hummingbird.php' => 'Hummingbird',
156 'flying-press/flying-press.php' => 'FlyingPress',
157 'nitropack/main.php' => 'NitroPack',
158 'perfmatters/perfmatters.php' => 'perfmatters',
159 'asset-cleanup/asset-cleanup.php' => 'Asset CleanUp',
160 'wp-asset-clean-up/wpacu.php' => 'Asset CleanUp',
161 'swift-performance/performance.php' => 'Swift Performance',
162 'swift-performance-lite/performance.php' => 'Swift Performance Lite',
163 'cachify/cachify.php' => 'Cachify',
164 'comet-cache/comet-cache.php' => 'Comet Cache',
165 'wp-optimize/wp-optimize.php' => 'WP-Optimize',
166 'breeze/breeze.php' => 'Breeze (Cloudways)',
167 'powered-cache/powered-cache.php' => 'Powered Cache',
168 'seraphinite-accelerator/seraphinite-accelerator.php' => 'Seraphinite Accelerator',
169 'wp-speed-of-light/wp-speed-of-light.php' => 'WP Speed of Light',
170 'wpspeed/wpspeed.php' => 'WPSpeed',
171 'fastcache-by-host-it/fastcache.php' => 'FastCache',
172 ];
173
174 /** @var array<string,string> Optimizer plugins currently active on this site. */
175 private static $detected_optimizers = [];
176
177 // -------------------------------------------------------------------------
178 // Bootstrap
179 // -------------------------------------------------------------------------
180
181 /**
182 * PHASE 1 – Early init: detect optimizers, register PHP-side exclusion filters,
183 * and start output buffer for inline script backup duplication.
184 *
185 * Called DIRECTLY at plugin load time (plugins_loaded), not inside any hook,
186 * so that PHP-side exclusion filters (rocket_excluded_inline_js_content, etc.)
187 * are in place before optimizers read them during their own boot.
188 *
189 * Also activates an output buffer that will detect if an optimizer modifies
190 * GPTranslate inline scripts (e.g., WP Rocket changing type to "text/rocketlazyloadscript")
191 * and injects a backup copy into <head> just after the opening tag, ensuring
192 * at least one copy always executes correctly.
193 *
194 * Skips wp-admin, WP-CLI, AJAX, XMLRPC and cron contexts where no HTML
195 * page is being rendered.
196 */
197 public static function init_early() {
198 // Never run in admin, CLI, background or non-HTML contexts.
199 if ( is_admin() ) {
200 return;
201 }
202 if ( ( defined( 'WP_CLI' ) && WP_CLI ) ||
203 ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ||
204 ( defined( 'DOING_CRON' ) && DOING_CRON ) ||
205 ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
206 return;
207 }
208
209 // Allow external code to extend the protected lists.
210 self::$protected_handles = apply_filters( 'gptranslate_optimizer_protected_handles', self::$protected_handles );
211 self::$protected_inline_handles = apply_filters( 'gptranslate_optimizer_protected_inline_handles', self::$protected_inline_handles );
212 self::$protected_js_patterns = apply_filters( 'gptranslate_optimizer_protected_js_patterns', self::$protected_js_patterns );
213 self::$protected_css_patterns = apply_filters( 'gptranslate_optimizer_protected_css_patterns', self::$protected_css_patterns );
214
215 // Detect active optimizers.
216 self::$detected_optimizers = self::detect_active_optimizers();
217
218 // No optimizer active → nothing to do, zero impact on the site.
219 if ( empty( self::$detected_optimizers ) ) {
220 return;
221 }
222
223 // Register all PHP-side optimizer exclusion filters.
224 self::register_all_exclusions();
225
226 // Start the outermost output buffer RIGHT NOW (plugins_loaded time).
227 // All optimizer ob_start() calls happen later and are nested inside ours.
228 // The callback fires at PHP termination with the fully-processed HTML.
229 ob_start( [ __CLASS__, 'fix_inline_scripts_in_buffer' ] );
230 }
231
232 /**
233 * PHASE 2 – Late init: register HTML tag-manipulation filters.
234 *
235 * Hooked into wp_enqueue_scripts at priority 999 so it runs AFTER
236 * GPTranslate's own enqueue logic (page inclusions/exclusions, etc.).
237 * Bails immediately if gptranslate-main is not enqueued on this page,
238 * keeping zero overhead on pages where GPTranslate is inactive.
239 */
240 public static function init_late() {
241 if ( is_admin() ) {
242 return;
243 }
244
245 // Scripts not enqueued on this page → no HTML to protect.
246 if ( ! wp_script_is( 'gptranslate-main', 'enqueued' ) ) {
247 return;
248 }
249
250 // No optimizer detected in Phase 1 → nothing to do.
251 if ( empty( self::$detected_optimizers ) ) {
252 return;
253 }
254
255 // Capture original script info BEFORE any optimizer can bundle/modify them.
256 // These are used by the output buffer to replace corrupted optimizer bundles.
257 // NOTE: type="module" and defer are added via script_loader_tag filter in
258 // gptranslate.php, NOT via wp_script_add_data(), so we must hard-code them.
259 $module_handles = [ 'gptranslate-main', 'gptranslate-jsonrepair', 'gptranslate-bstoast' ];
260 $defer_handles = [ 'gptranslate-responsivevoice' ];
261
262 global $wp_scripts;
263 foreach ( self::$protected_handles as $handle ) {
264 if ( wp_script_is( $handle, 'enqueued' ) && isset( $wp_scripts->registered[ $handle ] ) ) {
265 $reg = $wp_scripts->registered[ $handle ];
266 $src = $reg->src;
267 if ( ! empty( $src ) ) {
268 // Build the full URL with version query string
269 $ver = $reg->ver ? $reg->ver : get_bloginfo( 'version' );
270 $src_versioned = add_query_arg( 'ver', $ver, $src );
271 // Determine type and loading strategy from known handle lists
272 $type = in_array( $handle, $module_handles, true ) ? 'module' : '';
273 $strategy = in_array( $handle, $defer_handles, true ) ? 'defer' : '';
274 // For gptranslate-main, capture data-gt-* attributes
275 $extra_attrs = '';
276 if ( 'gptranslate-main' === $handle ) {
277 $raw_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
278 $raw_host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
279 $orig_url = esc_url_raw( wp_unslash( $raw_uri ) );
280 $orig_domain = preg_replace( '/[^a-z0-9.-]/i', '', sanitize_text_field( wp_unslash( $raw_host ) ) );
281 $extra_attrs = ' data-gt-orig-url="' . esc_attr( $orig_url ) . '"'
282 . ' data-gt-orig-domain="' . esc_attr( $orig_domain ) . '"'
283 . ' data-gt-widget-id="1"';
284 }
285 self::$original_script_tags[ $handle ] = [
286 'src' => $src_versioned,
287 'type' => $type,
288 'strategy' => $strategy,
289 'extra_attrs' => $extra_attrs,
290 ];
291 }
292 }
293 }
294
295 // Protect EXTERNAL script file tags via script_loader_tag.
296 add_filter( 'script_loader_tag', [ __CLASS__, 'add_protection_attributes' ], 20, 2 );
297
298 // Protect INLINE script blocks via wp_inline_script_tag (WP 5.7+).
299 // Safe to register unconditionally: silently ignored on older WP versions.
300 add_filter( 'wp_inline_script_tag', [ __CLASS__, 'add_inline_protection_attributes' ], 20, 4 );
301 }
302
303 // -------------------------------------------------------------------------
304 // Detection
305 // -------------------------------------------------------------------------
306
307 /**
308 * Return the subset of $known_optimizers that are currently active.
309 *
310 * @return array<string,string>
311 */
312 private static function detect_active_optimizers() {
313 $active = (array) get_option( 'active_plugins', [] );
314
315 if ( is_multisite() ) {
316 $network = array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) );
317 $active = array_merge( $active, $network );
318 }
319
320 $detected = [];
321 foreach ( self::$known_optimizers as $basename => $name ) {
322 if ( in_array( $basename, $active, true ) ) {
323 $detected[ $basename ] = $name;
324 }
325 }
326
327 return $detected;
328 }
329
330 // -------------------------------------------------------------------------
331 // Exclusion registration – dispatcher
332 // -------------------------------------------------------------------------
333
334 private static function register_all_exclusions() {
335 $active = array_keys( self::$detected_optimizers );
336
337 $map = [
338 'wp-rocket/wp-rocket.php' => 'register_wp_rocket',
339 'litespeed-cache/litespeed-cache.php' => 'register_litespeed',
340 'autoptimize/autoptimize.php' => 'register_autoptimize',
341 'w3-total-cache/w3-total-cache.php' => 'register_w3tc',
342 'wp-fastest-cache/wpFastestCache.php' => 'register_wp_fastest_cache',
343 'sg-cachepress/sg-cachepress.php' => 'register_sgo',
344 'sg-optimizer/sg-optimizer.php' => 'register_sgo',
345 'hummingbird-performance/wp-hummingbird.php' => 'register_hummingbird',
346 'wp-hummingbird/wp-hummingbird.php' => 'register_hummingbird',
347 'flying-press/flying-press.php' => 'register_flyingpress',
348 'nitropack/main.php' => 'register_nitropack',
349 'perfmatters/perfmatters.php' => 'register_perfmatters',
350 'swift-performance/performance.php' => 'register_swift',
351 'swift-performance-lite/performance.php' => 'register_swift',
352 'wp-optimize/wp-optimize.php' => 'register_wp_optimize',
353 'breeze/breeze.php' => 'register_breeze',
354 'powered-cache/powered-cache.php' => 'register_powered_cache',
355 'wp-speed-of-light/wp-speed-of-light.php' => 'register_wpspeedoflight',
356 'wpspeed/wpspeed.php' => 'register_wpspeed',
357 'fastcache-by-host-it/fastcache.php' => 'register_wpspeed',
358 ];
359
360 $already_called = [];
361 foreach ( $map as $basename => $method ) {
362 if ( in_array( $basename, $active, true ) && ! in_array( $method, $already_called, true ) ) {
363 self::$method();
364 $already_called[] = $method;
365 }
366 }
367 }
368
369 // -------------------------------------------------------------------------
370 // Per-optimizer exclusion methods
371 // -------------------------------------------------------------------------
372
373 private static function register_wp_rocket() {
374 $js = self::$protected_js_patterns;
375 $css = self::$protected_css_patterns;
376 $markers = self::$protected_inline_js_markers;
377
378 // Build the list of inline script tag IDs from the protected inline handles.
379 // WP Rocket's Delay JS scanner processes the full HTML output and matches
380 // patterns against the entire <script> tag, including the id="…" attribute.
381 // Adding these IDs to rocket_delay_js_exclusions prevents the scanner from
382 // converting inline GPTranslate scripts to type="text/rocketlazyloadscript".
383 $inline_tag_ids = array_map(
384 static fn( $h ) => $h . '-js-after',
385 self::$protected_inline_handles
386 );
387
388 // External file exclusions.
389 add_filter( 'rocket_exclude_js', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
390 add_filter( 'rocket_exclude_defer_js', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
391 add_filter( 'rocket_exclude_async_js', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
392 add_filter( 'rocket_exclude_css', static fn( $l ) => array_unique( array_merge( (array) $l, $css ) ) );
393
394 // Delay JS exclusions: covers both external files (by src pattern) AND
395 // inline script tags (by id attribute pattern, matched in full HTML).
396 add_filter( 'rocket_delay_js_exclusions', static fn( $l ) => array_unique( array_merge( (array) $l, $js, $inline_tag_ids ) ) );
397
398 // Content-based inline exclusions (combine/minify features).
399 // rocket_excluded_inline_js_content matches against the CONTENT of inline
400 // scripts. We supply both the //# sourceURL identifiers (Layer 1) and
401 // the JS variable names (Layer 2) so at least one always matches.
402 add_filter( 'rocket_excluded_inline_js_content', static fn( $l ) => array_unique( array_merge( (array) $l, $markers ) ) );
403 }
404
405 private static function register_litespeed() {
406 $js = self::$protected_js_patterns;
407 $css = self::$protected_css_patterns;
408 $markers = self::$protected_inline_js_markers;
409
410 // litespeed_optimize_js_excludes handles BOTH external JS files (matched against src URL)
411 // AND inline scripts (matched against inline script content) - see optimize.cls.php line 908 vs 939.
412 // litespeed_optimize_js_inline_excludes does NOT exist in LiteSpeed Cache.
413 add_filter( 'litespeed_optimize_js_excludes', static fn( $l ) => array_unique( array_merge( (array) $l, $js, $markers ) ) );
414 add_filter( 'litespeed_optimize_js_defer_excludes', static fn( $l ) => array_unique( array_merge( (array) $l, $js, $markers ) ) );
415 add_filter( 'litespeed_optimize_css_excludes', static fn( $l ) => array_unique( array_merge( (array) $l, $css ) ) );
416 add_filter( 'litespeed_localres_url_excludes', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
417 }
418
419 private static function register_autoptimize() {
420 $js_str = implode( ',', self::$protected_js_patterns );
421 $css_str = implode( ',', self::$protected_css_patterns );
422
423 add_filter( 'autoptimize_filter_js_exclude', static fn( $x ) => $x . ',' . $js_str );
424 add_filter( 'autoptimize_filter_css_exclude', static fn( $x ) => $x . ',' . $css_str );
425
426 // For inline scripts: if the tag contains any known marker, skip optimisation.
427 add_filter( 'autoptimize_filter_js_noptimize', static function ( $nopt, $tag ) {
428 foreach ( self::$protected_inline_js_markers as $marker ) {
429 if ( strpos( $tag, $marker ) !== false ) {
430 return true;
431 }
432 }
433 return $nopt;
434 }, 10, 2 );
435 }
436
437 private static function register_w3tc() {
438 add_filter( 'w3tc_minify_js_do_tag_minification', static function ( $do, $tag ) {
439 foreach ( self::$protected_js_patterns as $p ) {
440 if ( strpos( $tag, $p ) !== false ) {
441 return false;
442 }
443 }
444 // Also protect inline blocks by their content markers.
445 foreach ( self::$protected_inline_js_markers as $marker ) {
446 if ( strpos( $tag, $marker ) !== false ) {
447 return false;
448 }
449 }
450 return $do;
451 }, 10, 3 );
452
453 add_filter( 'w3tc_minify_css_do_tag_minification', static function ( $do, $tag ) {
454 foreach ( self::$protected_css_patterns as $p ) {
455 if ( strpos( $tag, $p ) !== false ) {
456 return false;
457 }
458 }
459 return $do;
460 }, 10, 3 );
461 }
462
463 private static function register_wp_fastest_cache() {
464 add_filter( 'wpfc_js_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
465 }
466
467 private static function register_sgo() {
468 $js = self::$protected_js_patterns;
469 $css = self::$protected_css_patterns;
470 $markers = self::$protected_inline_js_markers;
471
472 add_filter( 'sgo_javascript_combine_excluded_inline_scripts', static fn( $l ) => array_unique( array_merge( (array) $l, $markers ) ) );
473 add_filter( 'sgo_js_minify_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
474 add_filter( 'sgo_js_async_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
475 add_filter( 'sgo_css_combine_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $css ) ) );
476 add_filter( 'sgo_css_minify_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $css ) ) );
477 }
478
479 private static function register_hummingbird() {
480 $handles = self::$protected_handles;
481 add_filter( 'wphb_minify_resource', static function ( $minify, $handle, $type ) use ( $handles ) {
482 if ( $type === 'scripts' && in_array( $handle, $handles, true ) ) {
483 return false;
484 }
485 return $minify;
486 }, 10, 3 );
487 }
488
489 private static function register_flyingpress() {
490 $js = self::$protected_js_patterns;
491 $css = self::$protected_css_patterns;
492
493 add_filter( 'flying_press_exclude_js_defer', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
494 add_filter( 'flying_press_exclude_js_delay', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
495 add_filter( 'flying_press_exclude_js_minify', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
496 add_filter( 'flying_press_exclude_css_minify', static fn( $l ) => array_unique( array_merge( (array) $l, $css ) ) );
497 }
498
499 private static function register_nitropack() {
500 // data-nitro-exclude attribute is added by add_protection_attributes()
501 // and add_inline_protection_attributes().
502 add_filter( 'nitropack_js_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
503 add_filter( 'nitropack_css_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_css_patterns ) ) );
504 }
505
506 private static function register_perfmatters() {
507 add_filter( 'perfmatters_delay_js_exclusions', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
508 }
509
510 private static function register_swift() {
511 add_filter( 'swift_performance_exclude_js', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
512 add_filter( 'swift_performance_exclude_css', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_css_patterns ) ) );
513 }
514
515 private static function register_wp_optimize() {
516 // WP-Optimize reads exclusions from the 'exclude_js' and 'exclude_css' keys
517 // inside the 'wpo_minify_config' option. Filters are unreliable because of
518 // internal caching, so we write our patterns DIRECTLY into the saved config.
519 // This runs once: subsequent loads skip because the patterns are already there.
520 $option_name = is_multisite() ? 'wpo_minify_config' : 'wpo_minify_config';
521 $getter = is_multisite() ? 'get_site_option' : 'get_option';
522 $setter = is_multisite() ? 'update_site_option' : 'update_option';
523
524 $config = call_user_func( $getter, $option_name, [] );
525 if ( ! is_array( $config ) ) {
526 $config = [];
527 }
528
529 $changed = false;
530
531 // --- JS exclusions ---
532 $exclude_js = isset( $config['exclude_js'] ) ? $config['exclude_js'] : '';
533 foreach ( self::$protected_js_patterns as $pattern ) {
534 if ( stripos( $exclude_js, $pattern ) === false ) {
535 $exclude_js .= ( ! empty( $exclude_js ) ? "\n" : '' ) . $pattern;
536 $changed = true;
537 }
538 }
539 $config['exclude_js'] = $exclude_js;
540
541 // --- CSS exclusions ---
542 $exclude_css = isset( $config['exclude_css'] ) ? $config['exclude_css'] : '';
543 foreach ( self::$protected_css_patterns as $pattern ) {
544 if ( stripos( $exclude_css, $pattern ) === false ) {
545 $exclude_css .= ( ! empty( $exclude_css ) ? "\n" : '' ) . $pattern;
546 $changed = true;
547 }
548 }
549 $config['exclude_css'] = $exclude_css;
550
551 // Only write to DB if we actually added something new.
552 if ( $changed ) {
553 call_user_func( $setter, $option_name, $config );
554 // Purge WP-Optimize minify cache so new exclusions take effect immediately.
555 if ( class_exists( 'WP_Optimize_Minify_Cache_Functions' ) ) {
556 WP_Optimize_Minify_Cache_Functions::reset();
557 }
558 }
559 }
560
561 private static function register_breeze() {
562 add_filter( 'breeze_get_option', static function ( $value, $option_name ) {
563 $js_opts = [ 'breeze-js-excl', 'breeze-defer-js-excl' ];
564 $css_opts = [ 'breeze-css-excl' ];
565
566 if ( in_array( $option_name, $js_opts, true ) ) {
567 if ( ! is_array( $value ) ) {
568 $value = array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
569 }
570 return array_unique( array_merge( $value, self::$protected_js_patterns ) );
571 }
572 if ( in_array( $option_name, $css_opts, true ) ) {
573 if ( ! is_array( $value ) ) {
574 $value = array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
575 }
576 return array_unique( array_merge( $value, self::$protected_css_patterns ) );
577 }
578 return $value;
579 }, 10, 2 );
580 }
581
582 private static function register_powered_cache() {
583 add_filter( 'powered_cache_js_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
584 add_filter( 'powered_cache_css_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_css_patterns ) ) );
585 }
586
587 private static function register_wpspeedoflight() {
588 add_filter( 'wpsol_js_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
589 }
590
591 private static function register_wpspeed() {
592 // WPSpeed / FastCache honour data-no-optimize / data-no-minify attributes
593 // added by add_protection_attributes() and add_inline_protection_attributes().
594 // No additional PHP filter needed.
595 }
596
597 // -------------------------------------------------------------------------
598 // HTML attribute protection – EXTERNAL scripts (script_loader_tag)
599 // -------------------------------------------------------------------------
600
601 /**
602 * Inject protective data-attributes into every GPTranslate external <script> tag.
603 *
604 * data-cfasync="false" → Cloudflare Rocket Loader: skip this script
605 * data-no-rocketlazyload="1" → WP Rocket: do not delay this script
606 * data-no-defer="1" → generic: do not defer
607 * data-no-minify="1" → generic / WP Fastest Cache: do not minify
608 * data-no-optimize="1" → WPSpeed, FastCache, output-buffer parsers
609 * data-nitro-exclude → NitroPack: exclude from optimisation
610 *
611 * Only registered when at least one optimizer is detected (see init()).
612 * Runs at priority 20, after the existing GPTranslate filter at priority 10.
613 *
614 * @param string $tag Full <script …></script> HTML.
615 * @param string $handle Registered script handle.
616 * @return string
617 */
618 public static function add_protection_attributes( $tag, $handle ) {
619 if ( ! in_array( $handle, self::$protected_handles, true ) ) {
620 return $tag;
621 }
622
623 // Idempotent – skip if already marked.
624 if ( strpos( $tag, 'data-cfasync' ) !== false ) {
625 return $tag;
626 }
627
628 $attrs = 'data-cfasync="false" data-no-rocketlazyload="1" data-no-defer="1" data-no-minify="1" data-no-optimize="1" data-nitro-exclude';
629
630 return preg_replace( '/<script\b/', '<script ' . $attrs, $tag, 1 );
631 }
632
633 // -------------------------------------------------------------------------
634 // HTML attribute protection – INLINE scripts (wp_inline_script_tag)
635 // -------------------------------------------------------------------------
636
637 /**
638 * Inject protective data-attributes into GPTranslate inline <script> blocks.
639 *
640 * WordPress outputs inline scripts added via wp_add_inline_script() as
641 * separate <script id="{handle}-js-{position}">…</script> tags.
642 * This filter (available since WP 6.3) lets us add attributes to those tags.
643 *
644 * Optimizers like WP Rocket can change these to
645 * <script type="text/rocketlazyloadscript">
646 * which delays execution and causes "variable is not defined" errors in the
647 * main GPTranslate JS. Adding the protective attributes prevents this.
648 *
649 * data-cfasync="false" → Cloudflare Rocket Loader
650 * data-no-rocketlazyload="1" → WP Rocket Delay JS: skip inline block
651 * data-no-defer="1" → generic
652 * data-no-minify="1" → generic / WP Fastest Cache
653 * data-no-optimize="1" → WPSpeed, FastCache, output-buffer parsers
654 * data-nitro-exclude → NitroPack
655 *
656 * @param string $tag Full <script …>…</script> HTML for the inline block.
657 * @param string $handle Parent script handle (e.g. 'gptranslate-main').
658 * @param string $code The inline JavaScript code (unused here).
659 * @param string $position 'before' or 'after' (unused here).
660 * @return string
661 */
662 public static function add_inline_protection_attributes( $tag, $handle, $code, $position ) {
663 if ( ! in_array( $handle, self::$protected_inline_handles, true ) ) {
664 return $tag;
665 }
666
667 // Idempotent – skip if already marked.
668 if ( strpos( $tag, 'data-cfasync' ) !== false ) {
669 return $tag;
670 }
671
672 $attrs = 'data-cfasync="false" data-no-rocketlazyload="1" data-no-defer="1" data-no-minify="1" data-no-optimize="1" data-nitro-exclude';
673
674 return preg_replace( '/<script\b/', '<script ' . $attrs, $tag, 1 );
675 }
676
677 // -------------------------------------------------------------------------
678 // Output-buffer fix for inline scripts: backup duplication in <head>
679 // -------------------------------------------------------------------------
680
681 /**
682 * Output-buffer callback: duplicates GPTranslate inline scripts in <head> for backup.
683 *
684 * Strategy: If any optimizer has modified inline script tags (e.g., WP Rocket
685 * converting type="text/javascript" to type="text/rocketlazyloadscript"), inject
686 * a backup copy of the original scripts just after the <head> tag opening.
687 *
688 * This ensures at least one copy always executes correctly, even if optimizers
689 * mangle the originals.
690 *
691 * Algorithm:
692 * 1. Fast-path: if "gptranslate" is not in the HTML, return immediately.
693 * 2. Extract all <script id="gptranslate-*-js-after">…</script> blocks.
694 * 3. If found, inject them just after <head> opening tag (wrapped in a comment).
695 * 4. Return the modified HTML.
696 *
697 * @param string $html The full HTML of the page after all other processing.
698 * @return string The HTML with backup inline scripts injected into <head>.
699 */
700 public static function fix_inline_scripts_in_buffer( $html ) {
701 // Fast-path: nothing to do if our scripts are not present.
702 if ( empty( $html ) || strpos( $html, 'gptranslate' ) === false ) {
703 return $html;
704 }
705
706 // Build the pattern from protected inline handles.
707 // The tag ids WordPress generates are "{handle}-js-after".
708 $ids = array_map(
709 static fn( $h ) => $h . '-js-after',
710 self::$protected_inline_handles
711 );
712
713 $ids_regex = implode(
714 '|',
715 array_map( static fn( $id ) => preg_quote( $id, '/' ), $ids )
716 );
717
718 // Extract all GPTranslate inline script blocks.
719 // Pattern matches regardless of whether an optimizer has modified the type attribute.
720 $inline_scripts = [];
721 preg_match_all(
722 '/<script\b[^>]*?\bid="(?:' . $ids_regex . ')"[^>]*?>(.*?)<\/script>/is',
723 $html,
724 $matches,
725 PREG_OFFSET_CAPTURE
726 );
727
728 // Inline script backup: only if we found any inline scripts to copy
729 if ( ! empty( $matches[0] ) ) {
730 foreach ( $matches[0] as $key => $match ) {
731 $full_tag = $match[0];
732 $script_content = $matches[1][ $key ][0];
733 // Extract the id attribute to preserve it in backup
734 if ( preg_match( '/\bid=["\']([^"\']*)["\']/', $full_tag, $id_match ) ) {
735 $inline_scripts[] = '<script type="text/javascript" id="' . esc_attr( $id_match[1] ) . '-backup">' . $script_content . '</script>';
736 }
737 }
738
739 if ( ! empty( $inline_scripts ) ) {
740 $backup_html = implode( "\n", $inline_scripts );
741 $injection = "\n<!-- GPTranslate inline scripts optimizer compatibility -->\n" . $backup_html . "\n";
742 $html = preg_replace_callback(
743 '/<head\b[^>]*>/i',
744 static function ( $m ) use ( $injection ) {
745 return $m[0] . $injection;
746 },
747 $html,
748 1
749 );
750 }
751 }
752
753 // Fix WP Rocket lazyrender on GPTranslate wrapper: remove data-wpr-lazyrender attribute
754 // WP Rocket adds data-wpr-lazyrender="1" which triggers content-visibility: auto via CSS,
755 // deferring rendering until viewport entry. But GPTranslate float switcher must be
756 // visible immediately (floating widget, not viewport-dependent).
757 $html = preg_replace_callback(
758 '/<div\b[^>]*\bid="gpt-wrapper"[^>]*>/i',
759 static function ( $m ) {
760 return preg_replace( '/\s*data-wpr-lazyrender="[^"]*"/', '', $m[0] );
761 },
762 $html,
763 1
764 );
765
766 // WP-Optimize fix: REMOVE minified bundles that contain GPTranslate code and
767 // RE-INJECT the original unmodified script files.
768 // WP-Optimize bundles gptranslate.js (ES6 module) into its minified files,
769 // breaking the import statements. We detect these corrupted bundles by name
770 // pattern and replace them with the original script tags.
771 $html = self::fix_wpo_bundles( $html );
772
773 return $html;
774 }
775
776 /**
777 * Remove WP-Optimize minified bundles that contain GPTranslate code and
778 * re-inject the original GPTranslate script files.
779 *
780 * WP-Optimize creates files like "wpo-minify-gptranslate-jsonrepair-*.min.js"
781 * which break ES6 module imports. This method:
782 * 1. Finds all <script> tags pointing to wpo-minify files with "gptranslate" in the name
783 * 2. REMOVES them from the HTML
784 * 3. Injects the original GPTranslate script files (captured in init_late) before </body>
785 *
786 * @param string $html The HTML buffer.
787 * @return string Modified HTML.
788 */
789 private static function fix_wpo_bundles( $html ) {
790 // Quick check: skip if no WP-Optimize bundles or no captured URLs
791 if ( strpos( $html, 'wpo-minify' ) === false ) {
792 return $html;
793 }
794
795 // Find and REMOVE <script> tags pointing to WP-Optimize bundles that contain GPTranslate
796 // Pattern: <script ... src="...wpo-minify...gptranslate...min.js..."></script>
797 $removed = false;
798 $html = preg_replace_callback(
799 '/<script\b[^>]*\bsrc="[^"]*wpo-minify[^"]*"[^>]*>\s*<\/script>/i',
800 static function ( $m ) use ( &$removed ) {
801 // Only remove bundles that contain "gptranslate" in the filename
802 if ( stripos( $m[0], 'gptranslate' ) !== false ) {
803 $removed = true;
804 return '<!-- GPTranslate: removed corrupted WP-Optimize bundle -->';
805 }
806 return $m[0]; // Leave non-GPTranslate bundles untouched
807 },
808 $html
809 );
810
811 // If we removed any bundles, re-inject the original GPTranslate script files
812 if ( $removed && ! empty( self::$original_script_tags ) ) {
813 $injection = "\n<!-- GPTranslate: original scripts re-injected (WP-Optimize compatibility) -->\n";
814 foreach ( self::$original_script_tags as $handle => $info ) {
815 $src = $info['src'];
816 $type = $info['type'];
817 $strategy = $info['strategy'];
818 $extra_attrs = isset( $info['extra_attrs'] ) ? $info['extra_attrs'] : '';
819 // Build the script tag with all original attributes
820 $tag = '<script';
821 if ( ! empty( $type ) ) {
822 $tag .= ' type="' . esc_attr( $type ) . '"';
823 }
824 $tag .= ' src="' . esc_attr( $src ) . '"';
825 $tag .= ' id="' . esc_attr( $handle ) . '-js"';
826 if ( 'defer' === $strategy ) {
827 $tag .= ' defer';
828 } elseif ( 'async' === $strategy ) {
829 $tag .= ' async';
830 }
831 $tag .= $extra_attrs;
832 $tag .= '></script>';
833 $injection .= $tag . "\n";
834 }
835
836 // Inject before </body>
837 if ( preg_match( '/<\/body\s*>/i', $html ) ) {
838 $html = preg_replace( '/(<\/body\s*>)/i', $injection . '$1', $html, 1 );
839 }
840 }
841
842 return $html;
843 }
844
845 // -------------------------------------------------------------------------
846 // Public accessors (useful for debugging / third-party code)
847 // -------------------------------------------------------------------------
848
849 /** @return array<string,string> */
850 public static function get_detected_optimizers() {
851 return self::$detected_optimizers;
852 }
853
854 /** @return string[] */
855 public static function get_protected_handles() {
856 return self::$protected_handles;
857 }
858
859 /** @return string[] */
860 public static function get_protected_inline_handles() {
861 return self::$protected_inline_handles;
862 }
863
864 /** @return string[] */
865 public static function get_protected_js_patterns() {
866 return self::$protected_js_patterns;
867 }
868
869 /** @return string[] */
870 public static function get_protected_inline_js_markers() {
871 return self::$protected_inline_js_markers;
872 }
873 }
874