PluginProbe ʕ •ᴥ•ʔ
GPTranslate – Multilingual AI Translation for WordPress: Automatically Translate Websites / 2.33.7
GPTranslate – Multilingual AI Translation for WordPress: Automatically Translate Websites v2.33.7
2.33.7 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 5 days ago
class-gptranslate-optimizer-compat.php
885 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 $handles = self::$protected_handles;
469 $css_handles = [ 'gptranslate-bootstrap-style', 'gptranslate-dynamic-css' ];
470 $js_paths = self::$protected_js_patterns;
471 $markers = self::$protected_inline_js_markers;
472
473 // JS combination: sgo_javascript_combine_exclude takes WP script handles (converted
474 // internally to relative URLs for exact-match exclusion). Belt-and-suspenders: also
475 // add sgo_javascript_combine_excluded_internal_paths which does a strpos() match
476 // against the full src URL, catching edge cases where the handle lookup may differ.
477 add_filter( 'sgo_javascript_combine_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $handles ) ) );
478 add_filter( 'sgo_javascript_combine_excluded_internal_paths', static fn( $l ) => array_unique( array_merge( (array) $l, $js_paths ) ) );
479 add_filter( 'sgo_javascript_combine_excluded_inline_content', static fn( $l ) => array_unique( array_merge( (array) $l, $markers ) ) );
480
481 // JS minification and async: both filters take WP script handles (in_array check).
482 add_filter( 'sgo_js_minify_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $handles ) ) );
483 add_filter( 'sgo_js_async_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $handles ) ) );
484
485 // CSS combination and minification: both filters take WP style handles.
486 add_filter( 'sgo_css_combine_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $css_handles ) ) );
487 add_filter( 'sgo_css_minify_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, $css_handles ) ) );
488 }
489
490 private static function register_hummingbird() {
491 $handles = self::$protected_handles;
492 add_filter( 'wphb_minify_resource', static function ( $minify, $handle, $type ) use ( $handles ) {
493 if ( $type === 'scripts' && in_array( $handle, $handles, true ) ) {
494 return false;
495 }
496 return $minify;
497 }, 10, 3 );
498 }
499
500 private static function register_flyingpress() {
501 $js = self::$protected_js_patterns;
502 $css = self::$protected_css_patterns;
503
504 add_filter( 'flying_press_exclude_js_defer', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
505 add_filter( 'flying_press_exclude_js_delay', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
506 add_filter( 'flying_press_exclude_js_minify', static fn( $l ) => array_unique( array_merge( (array) $l, $js ) ) );
507 add_filter( 'flying_press_exclude_css_minify', static fn( $l ) => array_unique( array_merge( (array) $l, $css ) ) );
508 }
509
510 private static function register_nitropack() {
511 // data-nitro-exclude attribute is added by add_protection_attributes()
512 // and add_inline_protection_attributes().
513 add_filter( 'nitropack_js_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
514 add_filter( 'nitropack_css_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_css_patterns ) ) );
515 }
516
517 private static function register_perfmatters() {
518 add_filter( 'perfmatters_delay_js_exclusions', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
519 }
520
521 private static function register_swift() {
522 add_filter( 'swift_performance_exclude_js', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
523 add_filter( 'swift_performance_exclude_css', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_css_patterns ) ) );
524 }
525
526 private static function register_wp_optimize() {
527 // WP-Optimize reads exclusions from the 'exclude_js' and 'exclude_css' keys
528 // inside the 'wpo_minify_config' option. Filters are unreliable because of
529 // internal caching, so we write our patterns DIRECTLY into the saved config.
530 // This runs once: subsequent loads skip because the patterns are already there.
531 $option_name = is_multisite() ? 'wpo_minify_config' : 'wpo_minify_config';
532 $getter = is_multisite() ? 'get_site_option' : 'get_option';
533 $setter = is_multisite() ? 'update_site_option' : 'update_option';
534
535 $config = call_user_func( $getter, $option_name, [] );
536 if ( ! is_array( $config ) ) {
537 $config = [];
538 }
539
540 $changed = false;
541
542 // --- JS exclusions ---
543 $exclude_js = isset( $config['exclude_js'] ) ? $config['exclude_js'] : '';
544 foreach ( self::$protected_js_patterns as $pattern ) {
545 if ( stripos( $exclude_js, $pattern ) === false ) {
546 $exclude_js .= ( ! empty( $exclude_js ) ? "\n" : '' ) . $pattern;
547 $changed = true;
548 }
549 }
550 $config['exclude_js'] = $exclude_js;
551
552 // --- CSS exclusions ---
553 $exclude_css = isset( $config['exclude_css'] ) ? $config['exclude_css'] : '';
554 foreach ( self::$protected_css_patterns as $pattern ) {
555 if ( stripos( $exclude_css, $pattern ) === false ) {
556 $exclude_css .= ( ! empty( $exclude_css ) ? "\n" : '' ) . $pattern;
557 $changed = true;
558 }
559 }
560 $config['exclude_css'] = $exclude_css;
561
562 // Only write to DB if we actually added something new.
563 if ( $changed ) {
564 call_user_func( $setter, $option_name, $config );
565 // Purge WP-Optimize minify cache so new exclusions take effect immediately.
566 if ( class_exists( 'WP_Optimize_Minify_Cache_Functions' ) ) {
567 WP_Optimize_Minify_Cache_Functions::reset();
568 }
569 }
570 }
571
572 private static function register_breeze() {
573 add_filter( 'breeze_get_option', static function ( $value, $option_name ) {
574 $js_opts = [ 'breeze-js-excl', 'breeze-defer-js-excl' ];
575 $css_opts = [ 'breeze-css-excl' ];
576
577 if ( in_array( $option_name, $js_opts, true ) ) {
578 if ( ! is_array( $value ) ) {
579 $value = array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
580 }
581 return array_unique( array_merge( $value, self::$protected_js_patterns ) );
582 }
583 if ( in_array( $option_name, $css_opts, true ) ) {
584 if ( ! is_array( $value ) ) {
585 $value = array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
586 }
587 return array_unique( array_merge( $value, self::$protected_css_patterns ) );
588 }
589 return $value;
590 }, 10, 2 );
591 }
592
593 private static function register_powered_cache() {
594 add_filter( 'powered_cache_js_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
595 add_filter( 'powered_cache_css_exclude_list', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_css_patterns ) ) );
596 }
597
598 private static function register_wpspeedoflight() {
599 add_filter( 'wpsol_js_exclude', static fn( $l ) => array_unique( array_merge( (array) $l, self::$protected_js_patterns ) ) );
600 }
601
602 private static function register_wpspeed() {
603 // WPSpeed / FastCache honour data-no-optimize / data-no-minify attributes
604 // added by add_protection_attributes() and add_inline_protection_attributes().
605 // No additional PHP filter needed.
606 }
607
608 // -------------------------------------------------------------------------
609 // HTML attribute protection – EXTERNAL scripts (script_loader_tag)
610 // -------------------------------------------------------------------------
611
612 /**
613 * Inject protective data-attributes into every GPTranslate external <script> tag.
614 *
615 * data-cfasync="false" → Cloudflare Rocket Loader: skip this script
616 * data-no-rocketlazyload="1" → WP Rocket: do not delay this script
617 * data-no-defer="1" → generic: do not defer
618 * data-no-minify="1" → generic / WP Fastest Cache: do not minify
619 * data-no-optimize="1" → WPSpeed, FastCache, output-buffer parsers
620 * data-nitro-exclude → NitroPack: exclude from optimisation
621 *
622 * Only registered when at least one optimizer is detected (see init()).
623 * Runs at priority 20, after the existing GPTranslate filter at priority 10.
624 *
625 * @param string $tag Full <script …></script> HTML.
626 * @param string $handle Registered script handle.
627 * @return string
628 */
629 public static function add_protection_attributes( $tag, $handle ) {
630 if ( ! in_array( $handle, self::$protected_handles, true ) ) {
631 return $tag;
632 }
633
634 // Idempotent – skip if already marked.
635 if ( strpos( $tag, 'data-cfasync' ) !== false ) {
636 return $tag;
637 }
638
639 $attrs = 'data-cfasync="false" data-no-rocketlazyload="1" data-no-defer="1" data-no-minify="1" data-no-optimize="1" data-nitro-exclude';
640
641 return preg_replace( '/<script\b/', '<script ' . $attrs, $tag, 1 );
642 }
643
644 // -------------------------------------------------------------------------
645 // HTML attribute protection – INLINE scripts (wp_inline_script_tag)
646 // -------------------------------------------------------------------------
647
648 /**
649 * Inject protective data-attributes into GPTranslate inline <script> blocks.
650 *
651 * WordPress outputs inline scripts added via wp_add_inline_script() as
652 * separate <script id="{handle}-js-{position}">…</script> tags.
653 * This filter (available since WP 6.3) lets us add attributes to those tags.
654 *
655 * Optimizers like WP Rocket can change these to
656 * <script type="text/rocketlazyloadscript">
657 * which delays execution and causes "variable is not defined" errors in the
658 * main GPTranslate JS. Adding the protective attributes prevents this.
659 *
660 * data-cfasync="false" → Cloudflare Rocket Loader
661 * data-no-rocketlazyload="1" → WP Rocket Delay JS: skip inline block
662 * data-no-defer="1" → generic
663 * data-no-minify="1" → generic / WP Fastest Cache
664 * data-no-optimize="1" → WPSpeed, FastCache, output-buffer parsers
665 * data-nitro-exclude → NitroPack
666 *
667 * @param string $tag Full <script …>…</script> HTML for the inline block.
668 * @param string $handle Parent script handle (e.g. 'gptranslate-main').
669 * @param string $code The inline JavaScript code (unused here).
670 * @param string $position 'before' or 'after' (unused here).
671 * @return string
672 */
673 public static function add_inline_protection_attributes( $tag, $handle, $code, $position ) {
674 if ( ! in_array( $handle, self::$protected_inline_handles, true ) ) {
675 return $tag;
676 }
677
678 // Idempotent – skip if already marked.
679 if ( strpos( $tag, 'data-cfasync' ) !== false ) {
680 return $tag;
681 }
682
683 $attrs = 'data-cfasync="false" data-no-rocketlazyload="1" data-no-defer="1" data-no-minify="1" data-no-optimize="1" data-nitro-exclude';
684
685 return preg_replace( '/<script\b/', '<script ' . $attrs, $tag, 1 );
686 }
687
688 // -------------------------------------------------------------------------
689 // Output-buffer fix for inline scripts: backup duplication in <head>
690 // -------------------------------------------------------------------------
691
692 /**
693 * Output-buffer callback: duplicates GPTranslate inline scripts in <head> for backup.
694 *
695 * Strategy: If any optimizer has modified inline script tags (e.g., WP Rocket
696 * converting type="text/javascript" to type="text/rocketlazyloadscript"), inject
697 * a backup copy of the original scripts just after the <head> tag opening.
698 *
699 * This ensures at least one copy always executes correctly, even if optimizers
700 * mangle the originals.
701 *
702 * Algorithm:
703 * 1. Fast-path: if "gptranslate" is not in the HTML, return immediately.
704 * 2. Extract all <script id="gptranslate-*-js-after">…</script> blocks.
705 * 3. If found, inject them just after <head> opening tag (wrapped in a comment).
706 * 4. Return the modified HTML.
707 *
708 * @param string $html The full HTML of the page after all other processing.
709 * @return string The HTML with backup inline scripts injected into <head>.
710 */
711 public static function fix_inline_scripts_in_buffer( $html ) {
712 // Fast-path: nothing to do if our scripts are not present.
713 if ( empty( $html ) || strpos( $html, 'gptranslate' ) === false ) {
714 return $html;
715 }
716
717 // Build the pattern from protected inline handles.
718 // The tag ids WordPress generates are "{handle}-js-after".
719 $ids = array_map(
720 static fn( $h ) => $h . '-js-after',
721 self::$protected_inline_handles
722 );
723
724 $ids_regex = implode(
725 '|',
726 array_map( static fn( $id ) => preg_quote( $id, '/' ), $ids )
727 );
728
729 // Extract all GPTranslate inline script blocks.
730 // Pattern matches regardless of whether an optimizer has modified the type attribute.
731 $inline_scripts = [];
732 preg_match_all(
733 '/<script\b[^>]*?\bid="(?:' . $ids_regex . ')"[^>]*?>(.*?)<\/script>/is',
734 $html,
735 $matches,
736 PREG_OFFSET_CAPTURE
737 );
738
739 // Inline script backup: only if we found any inline scripts to copy
740 if ( ! empty( $matches[0] ) ) {
741 foreach ( $matches[0] as $key => $match ) {
742 $full_tag = $match[0];
743 $script_content = $matches[1][ $key ][0];
744 // Extract the id attribute to preserve it in backup
745 if ( preg_match( '/\bid=["\']([^"\']*)["\']/', $full_tag, $id_match ) ) {
746 $inline_scripts[] = '<script type="text/javascript" id="' . esc_attr( $id_match[1] ) . '-backup">' . $script_content . '</script>';
747 }
748 }
749
750 if ( ! empty( $inline_scripts ) ) {
751 $backup_html = implode( "\n", $inline_scripts );
752 $injection = "\n<!-- GPTranslate inline scripts optimizer compatibility -->\n" . $backup_html . "\n";
753 $html = preg_replace_callback(
754 '/<head\b[^>]*>/i',
755 static function ( $m ) use ( $injection ) {
756 return $m[0] . $injection;
757 },
758 $html,
759 1
760 );
761 }
762 }
763
764 // Fix WP Rocket lazyrender on GPTranslate wrapper: remove data-wpr-lazyrender attribute
765 // WP Rocket adds data-wpr-lazyrender="1" which triggers content-visibility: auto via CSS,
766 // deferring rendering until viewport entry. But GPTranslate float switcher must be
767 // visible immediately (floating widget, not viewport-dependent).
768 $html = preg_replace_callback(
769 '/<div\b[^>]*\bid="gpt-wrapper"[^>]*>/i',
770 static function ( $m ) {
771 return preg_replace( '/\s*data-wpr-lazyrender="[^"]*"/', '', $m[0] );
772 },
773 $html,
774 1
775 );
776
777 // WP-Optimize fix: REMOVE minified bundles that contain GPTranslate code and
778 // RE-INJECT the original unmodified script files.
779 // WP-Optimize bundles gptranslate.js (ES6 module) into its minified files,
780 // breaking the import statements. We detect these corrupted bundles by name
781 // pattern and replace them with the original script tags.
782 $html = self::fix_wpo_bundles( $html );
783
784 return $html;
785 }
786
787 /**
788 * Remove WP-Optimize minified bundles that contain GPTranslate code and
789 * re-inject the original GPTranslate script files.
790 *
791 * WP-Optimize creates files like "wpo-minify-gptranslate-jsonrepair-*.min.js"
792 * which break ES6 module imports. This method:
793 * 1. Finds all <script> tags pointing to wpo-minify files with "gptranslate" in the name
794 * 2. REMOVES them from the HTML
795 * 3. Injects the original GPTranslate script files (captured in init_late) before </body>
796 *
797 * @param string $html The HTML buffer.
798 * @return string Modified HTML.
799 */
800 private static function fix_wpo_bundles( $html ) {
801 // Quick check: skip if no WP-Optimize bundles or no captured URLs
802 if ( strpos( $html, 'wpo-minify' ) === false ) {
803 return $html;
804 }
805
806 // Find and REMOVE <script> tags pointing to WP-Optimize bundles that contain GPTranslate
807 // Pattern: <script ... src="...wpo-minify...gptranslate...min.js..."></script>
808 $removed = false;
809 $html = preg_replace_callback(
810 '/<script\b[^>]*\bsrc="[^"]*wpo-minify[^"]*"[^>]*>\s*<\/script>/i',
811 static function ( $m ) use ( &$removed ) {
812 // Only remove bundles that contain "gptranslate" in the filename
813 if ( stripos( $m[0], 'gptranslate' ) !== false ) {
814 $removed = true;
815 return '<!-- GPTranslate: removed corrupted WP-Optimize bundle -->';
816 }
817 return $m[0]; // Leave non-GPTranslate bundles untouched
818 },
819 $html
820 );
821
822 // If we removed any bundles, re-inject the original GPTranslate script files
823 if ( $removed && ! empty( self::$original_script_tags ) ) {
824 $injection = "\n<!-- GPTranslate: original scripts re-injected (WP-Optimize compatibility) -->\n";
825 foreach ( self::$original_script_tags as $handle => $info ) {
826 $src = $info['src'];
827 $type = $info['type'];
828 $strategy = $info['strategy'];
829 $extra_attrs = isset( $info['extra_attrs'] ) ? $info['extra_attrs'] : '';
830 // Build the script tag with all original attributes
831 $tag = '<script';
832 if ( ! empty( $type ) ) {
833 $tag .= ' type="' . esc_attr( $type ) . '"';
834 }
835 $tag .= ' src="' . esc_attr( $src ) . '"';
836 $tag .= ' id="' . esc_attr( $handle ) . '-js"';
837 if ( 'defer' === $strategy ) {
838 $tag .= ' defer';
839 } elseif ( 'async' === $strategy ) {
840 $tag .= ' async';
841 }
842 $tag .= $extra_attrs;
843 $tag .= '></script>';
844 $injection .= $tag . "\n";
845 }
846
847 // Inject before </body>
848 if ( preg_match( '/<\/body\s*>/i', $html ) ) {
849 $html = preg_replace( '/(<\/body\s*>)/i', $injection . '$1', $html, 1 );
850 }
851 }
852
853 return $html;
854 }
855
856 // -------------------------------------------------------------------------
857 // Public accessors (useful for debugging / third-party code)
858 // -------------------------------------------------------------------------
859
860 /** @return array<string,string> */
861 public static function get_detected_optimizers() {
862 return self::$detected_optimizers;
863 }
864
865 /** @return string[] */
866 public static function get_protected_handles() {
867 return self::$protected_handles;
868 }
869
870 /** @return string[] */
871 public static function get_protected_inline_handles() {
872 return self::$protected_inline_handles;
873 }
874
875 /** @return string[] */
876 public static function get_protected_js_patterns() {
877 return self::$protected_js_patterns;
878 }
879
880 /** @return string[] */
881 public static function get_protected_inline_js_markers() {
882 return self::$protected_inline_js_markers;
883 }
884 }
885