| 1 |
<?php |
| 2 |
/** |
| 3 |
* OTTO Render Strategy Manager |
| 4 |
* Implements hybrid approach: Output Buffer (fast) with wp_remote_get fallback |
| 5 |
* |
| 6 |
* Features: |
| 7 |
* - Automatic method selection based on environment |
| 8 |
* - Output buffer approach (eliminates internal HTTP request) |
| 9 |
* - Fallback to wp_remote_get for problematic environments |
| 10 |
* - Response headers indicating method used |
| 11 |
* - Detection of caching plugins and managed hosts |
| 12 |
* |
| 13 |
* @since 2.6.0 |
| 14 |
*/ |
| 15 |
|
| 16 |
if (!defined('ABSPATH')) { |
| 17 |
exit; |
| 18 |
} |
| 19 |
|
| 20 |
class Metasync_Otto_Render_Strategy { |
| 21 |
|
| 22 |
/** |
| 23 |
* Render method constants |
| 24 |
*/ |
| 25 |
const METHOD_BUFFER = 'buffer'; |
| 26 |
const METHOD_HTTP = 'http'; |
| 27 |
const METHOD_NONE = 'none'; |
| 28 |
const METHOD_WP_ROCKET = 'rocket_buffer'; |
| 29 |
|
| 30 |
/** |
| 31 |
* Memory amplification factor for SimpleHtmlDom. |
| 32 |
* Parsing a page into a SimpleHtmlDom node tree costs ~18x the raw HTML |
| 33 |
* size, and the save()/load() reload cycle adds another ~40% transient |
| 34 |
* peak. We budget 25x to keep a safety margin before the PHP memory limit. |
| 35 |
*/ |
| 36 |
const DOM_MEMORY_AMPLIFICATION = 25; |
| 37 |
|
| 38 |
/** |
| 39 |
* Absolute hard cap (bytes) on documents OTTO will DOM-parse, |
| 40 |
* regardless of available memory. Backstop against pathological pages. |
| 41 |
* Default 16 MB; override with the METASYNC_OTTO_MAX_HTML_BYTES constant |
| 42 |
* or the 'metasync_otto_max_html_bytes' filter. |
| 43 |
*/ |
| 44 |
const ABSOLUTE_MAX_DOCUMENT_BYTES = 16777216; |
| 45 |
|
| 46 |
/** |
| 47 |
* Current render method being used |
| 48 |
*/ |
| 49 |
private static $current_method = null; |
| 50 |
|
| 51 |
/** |
| 52 |
* Buffer level when OTTO started |
| 53 |
*/ |
| 54 |
private static $buffer_start_level = null; |
| 55 |
|
| 56 |
/** |
| 57 |
* Flag to track if buffer is active |
| 58 |
*/ |
| 59 |
private static $buffer_active = false; |
| 60 |
|
| 61 |
/** |
| 62 |
* OTTO suggestions data (stored for buffer approach) |
| 63 |
*/ |
| 64 |
private static $pending_suggestions = null; |
| 65 |
|
| 66 |
/** |
| 67 |
* Current route (stored for buffer approach) |
| 68 |
*/ |
| 69 |
private static $pending_route = null; |
| 70 |
|
| 71 |
/** |
| 72 |
* OTTO HTML processor instance |
| 73 |
*/ |
| 74 |
private static $otto_html = null; |
| 75 |
|
| 76 |
/** |
| 77 |
* Blocking flags for SEO plugins |
| 78 |
*/ |
| 79 |
private static $blocking_flags = null; |
| 80 |
|
| 81 |
/** |
| 82 |
* Determine the best render method for current environment |
| 83 |
* |
| 84 |
* @return string METHOD_BUFFER or METHOD_HTTP |
| 85 |
*/ |
| 86 |
public static function determine_method() { |
| 87 |
# Skip buffer for internal fetch requests (avoid infinite loop) |
| 88 |
if (!empty($_GET['is_otto_page_fetch'])) { |
| 89 |
return self::METHOD_NONE; |
| 90 |
} |
| 91 |
|
| 92 |
# Skip for admin, AJAX, REST API |
| 93 |
if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) { |
| 94 |
return self::METHOD_NONE; |
| 95 |
} |
| 96 |
|
| 97 |
# Check for known problematic configurations that need HTTP method |
| 98 |
if (self::should_use_http_method()) { |
| 99 |
return self::METHOD_HTTP; |
| 100 |
} |
| 101 |
|
| 102 |
# Check if output buffering is available and safe to use |
| 103 |
if (self::is_buffer_available()) { |
| 104 |
return self::METHOD_BUFFER; |
| 105 |
} |
| 106 |
|
| 107 |
# Fallback to HTTP method |
| 108 |
return self::METHOD_HTTP; |
| 109 |
} |
| 110 |
|
| 111 |
/** |
| 112 |
* Check if output buffering is available and safe |
| 113 |
* |
| 114 |
* @return bool |
| 115 |
*/ |
| 116 |
private static function is_buffer_available() { |
| 117 |
# Check if output buffering is enabled |
| 118 |
if (!function_exists('ob_start') || !function_exists('ob_get_clean')) { |
| 119 |
return false; |
| 120 |
} |
| 121 |
|
| 122 |
# Check buffer nesting level (too many nested buffers = risky) |
| 123 |
$current_level = ob_get_level(); |
| 124 |
if ($current_level > 5) { |
| 125 |
return false; |
| 126 |
} |
| 127 |
|
| 128 |
# Check if headers already sent (can't use buffer properly) |
| 129 |
if (headers_sent($file, $line)) { |
| 130 |
return false; |
| 131 |
} |
| 132 |
|
| 133 |
# Check memory limit (need enough memory to buffer page) |
| 134 |
$memory_limit = self::get_memory_limit_bytes(); |
| 135 |
$memory_used = memory_get_usage(true); |
| 136 |
$memory_available = $memory_limit - $memory_used; |
| 137 |
|
| 138 |
# Need at least 16MB available for buffering |
| 139 |
if ($memory_available < 16 * 1024 * 1024) { |
| 140 |
return false; |
| 141 |
} |
| 142 |
|
| 143 |
return true; |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Check if HTTP method should be forced |
| 148 |
* |
| 149 |
* @return bool True if HTTP method should be used |
| 150 |
*/ |
| 151 |
private static function should_use_http_method() { |
| 152 |
# Force HTTP method via constant (for debugging/testing) |
| 153 |
if (defined('METASYNC_OTTO_FORCE_HTTP') && METASYNC_OTTO_FORCE_HTTP) { |
| 154 |
return true; |
| 155 |
} |
| 156 |
|
| 157 |
# Check for known caching plugins that may conflict with output buffering |
| 158 |
$conflicting_plugins = [ |
| 159 |
# Heavy output buffering plugins |
| 160 |
'WP_Rocket' => class_exists('WP_Rocket'), |
| 161 |
'W3TC' => defined('W3TC'), |
| 162 |
'LiteSpeed_Cache' => defined('LSCWP_V'), |
| 163 |
|
| 164 |
# SiteGround Optimizer — its Parser starts a nested ob_start() with callback |
| 165 |
# that flushes before wp_footer completes, causing Divi's late-injected |
| 166 |
# module-design CSS (<style id="et-builder-module-design-*">) to be lost. |
| 167 |
# Affects all themes that defer inline CSS output to wp_footer (Divi 5, etc.). |
| 168 |
'SG_CachePress' => defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser'), |
| 169 |
|
| 170 |
# Page builders with aggressive buffering |
| 171 |
'Brizy_Editor' => class_exists('Brizy_Editor'), |
| 172 |
'Oxygen_Builder' => defined('CT_VERSION'), # Oxygen manipulates wp_current_filter causing CSS loading issues in PHP 8.3+ |
| 173 |
|
| 174 |
# Optimization plugins |
| 175 |
'Autoptimize' => class_exists('autoptimizeMain'), |
| 176 |
]; |
| 177 |
|
| 178 |
foreach ($conflicting_plugins as $name => $is_active) { |
| 179 |
if ($is_active) { |
| 180 |
# OXYGEN BUILDER: Always use HTTP method due to wp_current_filter manipulation |
| 181 |
# Oxygen manipulates $wp_current_filter during wp_head which breaks CSS loading in PHP 8.3+ |
| 182 |
# HTTP method bypasses the buffer and avoids the conflict entirely |
| 183 |
if ($name === 'Oxygen_Builder') { |
| 184 |
return true; |
| 185 |
} |
| 186 |
|
| 187 |
# SITEGROUND OPTIMIZER: Use HTTP method — buffer method + SimpleHtmlDom |
| 188 |
# corrupts Divi module numbering (et_pb_blog_0 → et_pb_blog_1), breaking |
| 189 |
# AJAX pagination. HTTP method preserves numbering; Divi CSS re-injection |
| 190 |
# is handled in render_via_http() via et_core_page_resource_get API. |
| 191 |
if ($name === 'SG_CachePress') { |
| 192 |
return true; |
| 193 |
} |
| 194 |
|
| 195 |
# For logged-in users, buffer is usually safe even with these plugins |
| 196 |
if (is_user_logged_in()) { |
| 197 |
continue; |
| 198 |
} |
| 199 |
|
| 200 |
# For non-logged-in users with caching plugins, check if page caching is active |
| 201 |
if ($name === 'WP_Rocket') { |
| 202 |
# Get WP Rocket compatibility configuration |
| 203 |
$metasync_options = get_option('metasync_options', []); |
| 204 |
$wp_rocket_compat_mode = $metasync_options['general']['otto_wp_rocket_compat'] ?? 'auto'; |
| 205 |
|
| 206 |
# Force HTTP if user selected it |
| 207 |
if ($wp_rocket_compat_mode === 'http') { |
| 208 |
return true; # Use HTTP method for maximum compatibility |
| 209 |
} |
| 210 |
|
| 211 |
# Force buffer if user selected it |
| 212 |
if ($wp_rocket_compat_mode === 'buffer') { |
| 213 |
continue; # Use buffer method for speed |
| 214 |
} |
| 215 |
|
| 216 |
# For auto mode, use buffer so WP Rocket can continue optimizing the page |
| 217 |
if ($wp_rocket_compat_mode === 'auto') { |
| 218 |
continue; |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
# Default: use HTTP method for safety with caching plugins |
| 223 |
// Removed logging to reduce noise - uncomment for debugging |
| 224 |
|
| 225 |
# Actually, let's try buffer first for most cases |
| 226 |
# Only force HTTP for specific known issues |
| 227 |
} |
| 228 |
} |
| 229 |
|
| 230 |
# Check for managed hosts that may have issues |
| 231 |
# WP Engine detection |
| 232 |
if (defined('WPE_APIKEY') || getenv('IS_WPE')) { |
| 233 |
# WP Engine serves cached pages at edge, but for uncached requests, buffer works |
| 234 |
if (!is_user_logged_in() && !defined('DONOTCACHEPAGE')) { |
| 235 |
# This might be a cached request that WP Engine serves from edge |
| 236 |
# However, if we're here, PHP is executing, so buffer should work |
| 237 |
} |
| 238 |
} |
| 239 |
|
| 240 |
# Kinsta detection |
| 241 |
if (defined('KINSTAMU_VERSION') || getenv('KINSTA_CACHE_ZONE')) { |
| 242 |
# Similar to WP Engine - if PHP is executing, buffer should work |
| 243 |
} |
| 244 |
|
| 245 |
# Cloudways Varnish |
| 246 |
if (isset($_SERVER['HTTP_X_VARNISH'])) { |
| 247 |
# Varnish is in front, but if we're here, request reached PHP |
| 248 |
} |
| 249 |
|
| 250 |
# Flywheel detection |
| 251 |
if (defined('FLYWHEEL_CONFIG_DIR')) { |
| 252 |
# Flywheel caching similar to WP Engine |
| 253 |
} |
| 254 |
|
| 255 |
# No known conflicts detected |
| 256 |
return false; |
| 257 |
} |
| 258 |
|
| 259 |
/** |
| 260 |
* Get memory limit in bytes |
| 261 |
* |
| 262 |
* @return int Memory limit in bytes |
| 263 |
*/ |
| 264 |
private static function get_memory_limit_bytes() { |
| 265 |
$limit = ini_get('memory_limit'); |
| 266 |
|
| 267 |
if ($limit === '-1') { |
| 268 |
return PHP_INT_MAX; # Unlimited |
| 269 |
} |
| 270 |
|
| 271 |
$unit = strtolower(substr($limit, -1)); |
| 272 |
$value = (int) $limit; |
| 273 |
|
| 274 |
switch ($unit) { |
| 275 |
case 'g': |
| 276 |
$value *= 1024 * 1024 * 1024; |
| 277 |
break; |
| 278 |
case 'm': |
| 279 |
$value *= 1024 * 1024; |
| 280 |
break; |
| 281 |
case 'k': |
| 282 |
$value *= 1024; |
| 283 |
break; |
| 284 |
} |
| 285 |
|
| 286 |
return $value; |
| 287 |
} |
| 288 |
|
| 289 |
/** |
| 290 |
* Resolve the configurable absolute document-size cap (bytes). |
| 291 |
* |
| 292 |
* @return int Maximum document size in bytes (0 disables the absolute cap) |
| 293 |
*/ |
| 294 |
public static function get_max_document_bytes() { |
| 295 |
$cap = defined('METASYNC_OTTO_MAX_HTML_BYTES') |
| 296 |
? (int) METASYNC_OTTO_MAX_HTML_BYTES |
| 297 |
: self::ABSOLUTE_MAX_DOCUMENT_BYTES; |
| 298 |
|
| 299 |
# Allow per-site tuning via filter (e.g. higher cap on large-memory hosts) |
| 300 |
if (function_exists('apply_filters')) { |
| 301 |
$cap = (int) apply_filters('metasync_otto_max_html_bytes', $cap); |
| 302 |
} |
| 303 |
|
| 304 |
return max(0, $cap); |
| 305 |
} |
| 306 |
|
| 307 |
/** |
| 308 |
* Decide whether a document is safe to DOM-parse without risking |
| 309 |
* a fatal out-of-memory error. |
| 310 |
* |
| 311 |
* Combines a configurable absolute byte cap with a dynamic check against |
| 312 |
* the actual memory headroom (memory_limit minus current usage), budgeting |
| 313 |
* DOM_MEMORY_AMPLIFICATION times the HTML size for the parse + reload cycle. |
| 314 |
* |
| 315 |
* @param int $html_length Length of the raw HTML in bytes |
| 316 |
* @return bool True if processing is safe, false if it should be skipped |
| 317 |
*/ |
| 318 |
public static function is_document_processable($html_length) { |
| 319 |
$html_length = (int) $html_length; |
| 320 |
|
| 321 |
if ($html_length <= 0) { |
| 322 |
return false; |
| 323 |
} |
| 324 |
|
| 325 |
# 1. Absolute configurable cap (backstop against pathological pages) |
| 326 |
$max = self::get_max_document_bytes(); |
| 327 |
if ($max > 0 && $html_length > $max) { |
| 328 |
return false; |
| 329 |
} |
| 330 |
|
| 331 |
# 2. Dynamic memory budget — adapts to the host's memory_limit |
| 332 |
$limit = self::get_memory_limit_bytes(); |
| 333 |
if ($limit === PHP_INT_MAX) { |
| 334 |
return true; # Unlimited memory_limit (-1) |
| 335 |
} |
| 336 |
|
| 337 |
$available = $limit - memory_get_usage(true); |
| 338 |
$needed = $html_length * self::DOM_MEMORY_AMPLIFICATION; |
| 339 |
|
| 340 |
return $needed < $available; |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Log a skipped oversized document once per request (warning, not fatal). |
| 345 |
* |
| 346 |
* @param string $context Where the skip happened (for the log line) |
| 347 |
* @param int $html_length Raw HTML length in bytes |
| 348 |
*/ |
| 349 |
public static function log_oversized_skip($context, $html_length) { |
| 350 |
$route = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown'; |
| 351 |
error_log(sprintf( |
| 352 |
'[MetaSync OTTO] Skipped DOM processing on %s (%s): document %s exceeds safe memory budget (limit %s, used %s, cap %s).', |
| 353 |
$route, |
| 354 |
$context, |
| 355 |
size_format($html_length), |
| 356 |
ini_get('memory_limit'), |
| 357 |
size_format(memory_get_usage(true)), |
| 358 |
size_format(self::get_max_document_bytes()) |
| 359 |
)); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* Start output buffer for OTTO processing |
| 364 |
* Called early in WordPress lifecycle (template_redirect) |
| 365 |
* |
| 366 |
* @param array $suggestions OTTO suggestions data |
| 367 |
* @param string $route Current page route |
| 368 |
* @param Metasync_otto_html $otto_html OTTO HTML processor |
| 369 |
* @param array $blocking_flags SEO plugin blocking flags |
| 370 |
* @return bool True if buffer started successfully |
| 371 |
*/ |
| 372 |
public static function start_buffer($suggestions, $route, $otto_html, $blocking_flags = []) { |
| 373 |
if (self::$buffer_active) { |
| 374 |
return false; # Already buffering |
| 375 |
} |
| 376 |
|
| 377 |
# Store data for later processing |
| 378 |
self::$pending_suggestions = $suggestions; |
| 379 |
self::$pending_route = $route; |
| 380 |
self::$otto_html = $otto_html; |
| 381 |
self::$blocking_flags = $blocking_flags; |
| 382 |
self::$buffer_start_level = ob_get_level(); |
| 383 |
self::$current_method = self::METHOD_BUFFER; |
| 384 |
|
| 385 |
# Start output buffering with our callback |
| 386 |
$started = ob_start([__CLASS__, 'process_buffer']); |
| 387 |
|
| 388 |
if ($started) { |
| 389 |
self::$buffer_active = true; |
| 390 |
|
| 391 |
# Register shutdown handler to ensure buffer is processed |
| 392 |
register_shutdown_function([__CLASS__, 'shutdown_handler']); |
| 393 |
|
| 394 |
return true; |
| 395 |
} |
| 396 |
|
| 397 |
# Buffer failed to start, mark for HTTP fallback |
| 398 |
self::$current_method = self::METHOD_HTTP; |
| 399 |
return false; |
| 400 |
} |
| 401 |
|
| 402 |
/** |
| 403 |
* Process the captured output buffer |
| 404 |
* This is the callback for ob_start() |
| 405 |
* |
| 406 |
* CRITICAL: This callback MUST return the HTML string (original or modified) |
| 407 |
* If this fails, WordPress will show a blank page. Always return $html as fallback. |
| 408 |
* |
| 409 |
* @param string $html The captured HTML |
| 410 |
* @return string Modified HTML |
| 411 |
*/ |
| 412 |
public static function process_buffer($html) { |
| 413 |
# SAFETY FIRST: If anything goes wrong, always return original HTML |
| 414 |
# Never throw or cause errors - this would break the page |
| 415 |
|
| 416 |
|
| 417 |
try { |
| 418 |
# If HTML is empty or too short, WordPress likely crashed before generating output. |
| 419 |
# Override the cache headers OTTO already sent so downstream caches (WP Engine, |
| 420 |
# Kinsta, Cloudflare, etc.) don't store and serve this broken response. |
| 421 |
if (empty($html) || strlen($html) < 100) { |
| 422 |
if (!headers_sent()) { |
| 423 |
header('Cache-Control: no-store, no-cache, private'); |
| 424 |
header_remove('Surrogate-Key'); |
| 425 |
header_remove('Cache-Tag'); |
| 426 |
header_remove('Edge-Cache-Tag'); |
| 427 |
} |
| 428 |
return $html; |
| 429 |
} |
| 430 |
|
| 431 |
# Skip processing if no suggestions (page is fine, just no OTTO modifications needed) |
| 432 |
if (empty(self::$pending_suggestions)) { |
| 433 |
return $html; |
| 434 |
} |
| 435 |
|
| 436 |
# Skip if this doesn't look like HTML (error page, JSON response, etc.) |
| 437 |
if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) { |
| 438 |
return $html; |
| 439 |
} |
| 440 |
|
| 441 |
# Skip if this looks like an error page (WordPress fatal error) |
| 442 |
if (stripos($html, 'Fatal error') !== false || stripos($html, 'Parse error') !== false) { |
| 443 |
if (!headers_sent()) { |
| 444 |
header('Cache-Control: no-store, no-cache, private'); |
| 445 |
header_remove('Surrogate-Key'); |
| 446 |
header_remove('Cache-Tag'); |
| 447 |
header_remove('Edge-Cache-Tag'); |
| 448 |
} |
| 449 |
return $html; |
| 450 |
} |
| 451 |
|
| 452 |
|
| 453 |
# Process the HTML with OTTO modifications |
| 454 |
$modified_html = self::apply_otto_modifications($html); |
| 455 |
|
| 456 |
|
| 457 |
if ($modified_html !== false && !empty($modified_html)) { |
| 458 |
# Validate the modified HTML isn't corrupted |
| 459 |
if (strlen($modified_html) > strlen($html) * 0.5) { |
| 460 |
# Modified HTML is at least 50% of original size - seems valid |
| 461 |
|
| 462 |
# DEBUG: Check if title was actually changed in the HTML |
| 463 |
if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $modified_html, $matches)) { |
| 464 |
} |
| 465 |
|
| 466 |
return $modified_html; |
| 467 |
} else { |
| 468 |
} |
| 469 |
} else { |
| 470 |
} |
| 471 |
} catch (Exception $e) { |
| 472 |
error_log('[MetaSync OTTO] Render exception on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 473 |
} catch (Error $e) { |
| 474 |
error_log('[MetaSync OTTO] Render error on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 475 |
} |
| 476 |
|
| 477 |
# Return original HTML on any failure - NEVER return empty string |
| 478 |
return $html; |
| 479 |
} |
| 480 |
|
| 481 |
/** |
| 482 |
* Apply OTTO modifications to HTML |
| 483 |
* |
| 484 |
* @param string $html Original HTML |
| 485 |
* @return string|false Modified HTML or false on failure |
| 486 |
*/ |
| 487 |
private static function apply_otto_modifications($html) { |
| 488 |
# Validate prerequisites |
| 489 |
if (!self::$otto_html || !self::$pending_suggestions) { |
| 490 |
return false; |
| 491 |
} |
| 492 |
|
| 493 |
# Verify the OTTO HTML processor has the required method |
| 494 |
if (!method_exists(self::$otto_html, 'process_html_directly')) { |
| 495 |
return false; |
| 496 |
} |
| 497 |
|
| 498 |
try { |
| 499 |
# Add blocking flags to suggestions |
| 500 |
$suggestions = self::$pending_suggestions; |
| 501 |
if (!empty(self::$blocking_flags)) { |
| 502 |
$suggestions['_otto_blocking'] = self::$blocking_flags; |
| 503 |
} |
| 504 |
|
| 505 |
# Process HTML directly (no HTTP request needed!) |
| 506 |
$result = self::$otto_html->process_html_directly($html, $suggestions); |
| 507 |
|
| 508 |
if ($result) { |
| 509 |
# Result is now an HTML string (not DOM object) |
| 510 |
# Check if it's a string or still a DOM object for backwards compatibility |
| 511 |
$result_string = is_string($result) ? $result : $result->__toString(); |
| 512 |
|
| 513 |
# Apply any post-processing fixes |
| 514 |
$result_string = self::apply_post_processing($result_string); |
| 515 |
|
| 516 |
return $result_string; |
| 517 |
} |
| 518 |
} catch (Exception $e) { |
| 519 |
error_log('[MetaSync OTTO] Modification exception on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 520 |
} catch (Error $e) { |
| 521 |
error_log('[MetaSync OTTO] Modification error on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 522 |
} |
| 523 |
|
| 524 |
return false; |
| 525 |
} |
| 526 |
|
| 527 |
/** |
| 528 |
* Apply post-processing fixes to modified HTML |
| 529 |
* |
| 530 |
* @param string $html Modified HTML |
| 531 |
* @return string Processed HTML |
| 532 |
*/ |
| 533 |
private static function apply_post_processing($html) { |
| 534 |
# Fix for sliding headline layouts |
| 535 |
if (strpos($html, 'pix-sliding-headline-2') !== false || strpos($html, 'pix-intro-sliding-text') !== false) { |
| 536 |
$html = preg_replace( |
| 537 |
'#(</span></span>)(<span\s+class=["\'][^"\']*slide-in-container[^"\']*["\'][^>]*>)#i', |
| 538 |
'$1 $2', |
| 539 |
$html |
| 540 |
); |
| 541 |
} |
| 542 |
|
| 543 |
# Fix for divi-pixel Timeline compatibility |
| 544 |
# jQuery's .data() method auto-parses JSON, but Timeline script tries to JSON.parse it again |
| 545 |
# This causes "[object Object]" is not valid JSON errors |
| 546 |
# Solution: Inject a script that patches jQuery's .data() to return strings for data-config |
| 547 |
if (strpos($html, 'dipi_timeline_item_custom_classes') !== false && strpos($html, 'Timeline.min.js') !== false) { |
| 548 |
# Create a script that ensures data-config is read as a string, not auto-parsed object |
| 549 |
$fix_script = '<script type="text/javascript">' . "\n" . |
| 550 |
'(function() {' . "\n" . |
| 551 |
' if (typeof jQuery !== "undefined") {' . "\n" . |
| 552 |
' // Patch jQuery.data() to return string for data-config on timeline items' . "\n" . |
| 553 |
' var originalData = jQuery.fn.data;' . "\n" . |
| 554 |
' jQuery.fn.data = function(key, value) {' . "\n" . |
| 555 |
' // If reading "config" from timeline items, return raw string from attribute' . "\n" . |
| 556 |
' if (key === "config" && value === undefined && this.length > 0) {' . "\n" . |
| 557 |
' var $first = jQuery(this[0]);' . "\n" . |
| 558 |
' if ($first.hasClass("dipi_timeline_item_custom_classes")) {' . "\n" . |
| 559 |
' var attrValue = $first.attr("data-config");' . "\n" . |
| 560 |
' if (attrValue) {' . "\n" . |
| 561 |
' // Decode HTML entities and return as string' . "\n" . |
| 562 |
' var tempDiv = document.createElement("div");' . "\n" . |
| 563 |
' tempDiv.innerHTML = attrValue;' . "\n" . |
| 564 |
' return tempDiv.textContent || tempDiv.innerText || attrValue;' . "\n" . |
| 565 |
' }' . "\n" . |
| 566 |
' }' . "\n" . |
| 567 |
' }' . "\n" . |
| 568 |
' // For all other cases, use original jQuery.data()' . "\n" . |
| 569 |
' return originalData.apply(this, arguments);' . "\n" . |
| 570 |
' };' . "\n" . |
| 571 |
' }' . "\n" . |
| 572 |
'})();' . "\n" . |
| 573 |
'</script>'; |
| 574 |
|
| 575 |
# Insert before closing body tag (before Timeline script runs) |
| 576 |
$html = preg_replace( |
| 577 |
'/(<\/body>)/i', |
| 578 |
$fix_script . "\n" . '$1', |
| 579 |
$html, |
| 580 |
1 |
| 581 |
); |
| 582 |
} |
| 583 |
|
| 584 |
# Re-inject Divi's module-design CSS if missing from buffer output. |
| 585 |
# SG Optimizer's parser strips late-injected CSS from the buffer. We read it |
| 586 |
# from Divi's page resource manager (available in the current WP context) and |
| 587 |
# inject it before </body>. This preserves correct module numbering (buffer |
| 588 |
# approach) while ensuring the CSS is present. |
| 589 |
if (strpos($html, 'et-builder-module-design') === false |
| 590 |
&& function_exists('et_core_page_resource_get') |
| 591 |
) { |
| 592 |
$post_id = get_the_ID(); |
| 593 |
if ($post_id) { |
| 594 |
$resource_slug = et_theme_builder_decorate_page_resource_slug($post_id, 'module-design'); |
| 595 |
$manager = et_core_page_resource_get('builder', $resource_slug, $post_id, 40); |
| 596 |
if ($manager && method_exists($manager, 'get_data')) { |
| 597 |
$css_data = $manager->get_data('inline'); |
| 598 |
if (!empty($css_data)) { |
| 599 |
$style_tag = '<style id="et-builder-' . esc_attr($resource_slug) . '-cached-inline-styles">' |
| 600 |
. wp_strip_all_tags($css_data) . '</style>'; |
| 601 |
$html = str_replace('</body>', $style_tag . "\n" . '</body>', $html); |
| 602 |
} |
| 603 |
} |
| 604 |
} |
| 605 |
} |
| 606 |
|
| 607 |
return $html; |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Shutdown handler to ensure buffer is properly processed |
| 612 |
*/ |
| 613 |
public static function shutdown_handler() { |
| 614 |
if (!self::$buffer_active) { |
| 615 |
return; |
| 616 |
} |
| 617 |
|
| 618 |
# Get any remaining buffered content |
| 619 |
$remaining_levels = ob_get_level() - self::$buffer_start_level; |
| 620 |
|
| 621 |
while ($remaining_levels > 0) { |
| 622 |
ob_end_flush(); |
| 623 |
$remaining_levels--; |
| 624 |
} |
| 625 |
|
| 626 |
self::$buffer_active = false; |
| 627 |
} |
| 628 |
|
| 629 |
/** |
| 630 |
* Get the current render method being used |
| 631 |
* |
| 632 |
* @return string Current method (buffer, http, none) |
| 633 |
*/ |
| 634 |
public static function get_current_method() { |
| 635 |
return self::$current_method ?: self::METHOD_NONE; |
| 636 |
} |
| 637 |
|
| 638 |
/** |
| 639 |
* Set the current render method (for HTTP fallback) |
| 640 |
* |
| 641 |
* @param string $method The method being used |
| 642 |
*/ |
| 643 |
public static function set_current_method($method) { |
| 644 |
self::$current_method = $method; |
| 645 |
} |
| 646 |
|
| 647 |
/** |
| 648 |
* Send response headers indicating render method and status |
| 649 |
* |
| 650 |
* @param string $cache_status Cache status (HIT, MISS, etc.) |
| 651 |
*/ |
| 652 |
public static function send_headers($cache_status = '') { |
| 653 |
if (headers_sent()) { |
| 654 |
return; |
| 655 |
} |
| 656 |
|
| 657 |
# OTTO Render Method header |
| 658 |
$method = self::get_current_method(); |
| 659 |
if ($method === self::METHOD_BUFFER) { |
| 660 |
$method_label = 'BUFFER'; |
| 661 |
} elseif ($method === self::METHOD_WP_ROCKET) { |
| 662 |
$method_label = 'WP_ROCKET_BUFFER'; // WP Rocket caches OTTO-modified HTML |
| 663 |
} else { |
| 664 |
$method_label = 'HTTP'; |
| 665 |
} |
| 666 |
header('X-MetaSync-OTTO-Method: ' . $method_label); |
| 667 |
|
| 668 |
# OTTO Cache Status header |
| 669 |
if (!empty($cache_status)) { |
| 670 |
header('X-MetaSync-OTTO-Cache: ' . $cache_status); |
| 671 |
} |
| 672 |
|
| 673 |
# OTTO Processed indicator |
| 674 |
header('X-MetaSync-OTTO-Processed: true'); |
| 675 |
|
| 676 |
# Check if WP Rocket is active |
| 677 |
$wp_rocket_active = class_exists('WP_Rocket'); |
| 678 |
|
| 679 |
# Add compatibility indicator |
| 680 |
if ($wp_rocket_active) { |
| 681 |
header('X-MetaSync-OTTO-WPRocket: Compatible'); |
| 682 |
} |
| 683 |
|
| 684 |
# Browser cache header only — let hosting (Kinsta, WP Engine, etc.) manage CDN TTL natively. |
| 685 |
# Do NOT set s-maxage: it overrides hosting-level CDN purges (kinsta_cache_purge_full, |
| 686 |
# WpeCommon::purge_varnish_cache) and prevents OTTO updates from appearing after a cache clear. |
| 687 |
# Only set if WP Rocket is NOT active (let WP Rocket control cache headers) |
| 688 |
if (!is_user_logged_in() && !$wp_rocket_active) { |
| 689 |
$cache_duration = 3600; // 1 hour for browsers |
| 690 |
|
| 691 |
# only the HTTP fallback path must force `private`. That path serves an |
| 692 |
# internally-fetched copy of the page (handle_route_html) and keeps ONLY the body — |
| 693 |
# the response `Set-Cookie` (e.g. PHPSESSID from Formidable etc.) is discarded — so the |
| 694 |
# host/CDN cannot auto-skip caching form/session pages, and a shared copy would re-leak |
| 695 |
# nonces across visitors (the regression). |
| 696 |
# |
| 697 |
# On the BUFFER path the visitor's own request renders the page, so its real `Set-Cookie` |
| 698 |
# IS on the response; the host/CDN already auto-skips caching any response that sets a |
| 699 |
# cookie, so form/session pages stay protected without us forcing `private`. Forcing it |
| 700 |
# there blocked host/CDN caching site-wide → uncached PHP render on every hit → CPU spikes |
| 701 |
# and slow first-load. So we let those pages be cached normally (no `private`). |
| 702 |
if (self::get_current_method() === self::METHOD_HTTP) { |
| 703 |
header('Cache-Control: private, max-age=' . $cache_duration); |
| 704 |
} |
| 705 |
header('Vary: Accept-Encoding'); |
| 706 |
} |
| 707 |
|
| 708 |
# Cache-tag headers for CDN purge-by-tag (Phase 4). |
| 709 |
# Only emitted on OTTO-processed singular pages when cache_tags_enabled is on. |
| 710 |
if (is_singular()) { |
| 711 |
$post_id = get_queried_object_id(); |
| 712 |
if ($post_id > 0) { |
| 713 |
$edge_options = class_exists('Metasync_Edge_Cache_Settings') |
| 714 |
? Metasync_Edge_Cache_Settings::get_settings() |
| 715 |
: get_option('metasync_edge_cache_options', array()); |
| 716 |
if (!empty($edge_options['cache_tags_enabled'])) { |
| 717 |
$tag = 'metasync-post-' . $post_id; |
| 718 |
header('Cache-Tag: ' . $tag); // Cloudflare |
| 719 |
header('Surrogate-Key: ' . $tag); // Fastly / Pantheon |
| 720 |
header('Edge-Cache-Tag: ' . $tag); // Akamai |
| 721 |
} |
| 722 |
} |
| 723 |
} |
| 724 |
|
| 725 |
} |
| 726 |
|
| 727 |
/** |
| 728 |
* Check if buffer method is currently active |
| 729 |
* |
| 730 |
* @return bool |
| 731 |
*/ |
| 732 |
public static function is_buffer_active() { |
| 733 |
return self::$buffer_active; |
| 734 |
} |
| 735 |
|
| 736 |
/** |
| 737 |
* Cancel buffer processing (for fallback to HTTP) |
| 738 |
*/ |
| 739 |
public static function cancel_buffer() { |
| 740 |
if (self::$buffer_active) { |
| 741 |
# End our buffer without processing |
| 742 |
if (ob_get_level() > self::$buffer_start_level) { |
| 743 |
ob_end_flush(); |
| 744 |
} |
| 745 |
self::$buffer_active = false; |
| 746 |
self::$current_method = self::METHOD_HTTP; |
| 747 |
} |
| 748 |
} |
| 749 |
|
| 750 |
/** |
| 751 |
* Get diagnostic information about current environment |
| 752 |
* |
| 753 |
* @return array Diagnostic data |
| 754 |
*/ |
| 755 |
public static function get_diagnostics() { |
| 756 |
return [ |
| 757 |
'php_version' => PHP_VERSION, |
| 758 |
'wp_version' => get_bloginfo('version'), |
| 759 |
'memory_limit' => ini_get('memory_limit'), |
| 760 |
'memory_used' => size_format(memory_get_usage(true)), |
| 761 |
'buffer_level' => ob_get_level(), |
| 762 |
'headers_sent' => headers_sent(), |
| 763 |
'is_admin' => is_admin(), |
| 764 |
'is_ajax' => wp_doing_ajax(), |
| 765 |
'is_logged_in' => is_user_logged_in(), |
| 766 |
'current_method' => self::get_current_method(), |
| 767 |
'buffer_active' => self::$buffer_active, |
| 768 |
'detected_plugins' => [ |
| 769 |
'wp_rocket' => class_exists('WP_Rocket'), |
| 770 |
'w3tc' => defined('W3TC'), |
| 771 |
'litespeed' => defined('LSCWP_V'), |
| 772 |
'autoptimize' => class_exists('autoptimizeMain'), |
| 773 |
'brizy' => class_exists('Brizy_Editor'), |
| 774 |
], |
| 775 |
'detected_hosts' => [ |
| 776 |
'wp_engine' => defined('WPE_APIKEY') || getenv('IS_WPE'), |
| 777 |
'kinsta' => defined('KINSTAMU_VERSION') || getenv('KINSTA_CACHE_ZONE'), |
| 778 |
'cloudways' => isset($_SERVER['HTTP_X_VARNISH']), |
| 779 |
'flywheel' => defined('FLYWHEEL_CONFIG_DIR'), |
| 780 |
], |
| 781 |
]; |
| 782 |
} |
| 783 |
} |
| 784 |
|
| 785 |
|