and ).)*?) to keep the match inside one block with a single lazy .*? # (cheap, no per-char lookahead) and test only that bounded block for the # facade signature. The signature check runs on one small block at a time, # so neither pattern can strain the JIT stack. if (!is_string($html) || stripos($html, '#is', static function ($m) { # Facade reset: "img,span{ … position:absolute … }" inside this block. if (preg_match('#\bimg\s*,\s*span\s*\{[^}]*position\s*:\s*absolute[^}]*\}#is', $m[0])) { return ''; } return $m[0]; }, $html ); # Belt-and-suspenders: if PCRE still fails for any reason, keep the input. return $out === null ? $html : $out; } /** * Apply OTTO's canonical via string replacement (reliable fallback). * * Why the DOM path fails: do_header_replacements() sets the canonical with a * DOM attribute edit ($link->href = …). That edit works in isolation, but * insert_header_html() runs *earlier* in the same pipeline and reassigns * $head->outertext to a raw string (to inject OTTO's schema). In SimpleHtmlDom, * assigning ->outertext freezes that node —
now serializes as that * literal string and no longer re-renders from its child node tree. The * canonical is captured inside that frozen string with its OLD href, so * the later $link->href edit is never reflected in the output and the SEO * plugin's (Yoast/Rank Math/AIOSEO) canonical always wins. This is the same * head-freeze that forced title/meta onto string-replacement fallbacks; * canonical was the one case that never got one. * * This pass runs on the final serialized HTML (after the freeze), so it is * immune to the ordering problem. It guarantees exactly one canonical — * OTTO's: it removes every existing and inserts OTTO's * recommended value (marked data-otto="true") right after . Manual * canonicals (_metasync_canonical_url / meta_canonical) still take priority, * matching the DOM path's protection. * * @param string $html Serialized HTML. * @param array $replacement_data OTTO suggestions. * @return string */ private function apply_canonical_via_string($html, $replacement_data) { if (!is_string($html) || empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])) { return $html; } # Find OTTO's canonical recommendation. $canonical = ''; foreach ($replacement_data['header_replacements'] as $item) { if (($item['type'] ?? '') === 'link' && ($item['rel'] ?? '') === 'canonical') { $canonical = $item['recommended_value'] ?? $item['value'] ?? ''; break; } } if (empty($canonical) || !is_string($canonical)) { return $html; } # Validate OTTO's own suggestion too: if the platform payload # carries a corrupted/non-URL canonical, leave the document untouched. $canonical = Metasync_Canonical_Sanitizer::sanitize($canonical); if ($canonical === '') { return $html; } # Respect a manually-set canonical (same protection as the DOM path). # Validated: a legacy row corrupted to "Array" must not count # as a manual canonical — that would suppress OTTO's correct value. if (function_exists('is_singular') && is_singular()) { $post_id = function_exists('get_the_ID') ? get_the_ID() : 0; if ($post_id) { $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true)); if ($custom === '') { $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true)); } if ($custom !== '') { return $html; # manual canonical wins — leave the document untouched } } } # Only proceed if there is a to place the tag in (never end up with zero canonical). if (!preg_match('#]*>#i', $html)) { return $html; } $tag = ''; # Remove every existing canonical link (bounded per-tag pattern; null-safe). $stripped = preg_replace('#]*\brel=(["\'])canonical\1[^>]*>\s*#i', '', $html); if (is_string($stripped)) { $html = $stripped; } # Insert OTTO's canonical right after (callback avoids $/\ interpolation from the URL). $inserted = preg_replace_callback('#(]*>)#i', function ($m) use ($tag) { return $m[1] . "\n" . $tag; }, $html, 1); if (is_string($inserted)) { $html = $inserted; } return $html; } /** * Keep the charset declaration within the first 1024 bytes of . * * OTTO injects meta tags + JSON-LD schema at the top of , which can push * the theme's past the 1024-byte limit that browsers * enforce for in-document charset detection (HTML spec). When that happens — * and a cached/CDN response is served without an HTTP charset header — the * document falls back to the locale encoding (Windows-1252). External * stylesheets that declare no @charset of their own (e.g. a theme rule like * `content:"\2713"` written as a raw UTF-8 ✓) then inherit that wrong encoding * and render as mojibake ("âœ"" instead of "✓"). * * We guarantee a as the first child of whenever * one isn't already present within the first 1024 bytes. Harmless when a valid * early charset already exists (we skip), and a duplicate later declaration is * ignored by the browser (first one wins). * * @param string $html * @return string */ private function ensure_early_charset_meta($html) { if (!preg_match('/]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) { return $html; } $inner_start = $m[0][1] + strlen($m[0][0]); # Already declared early enough? Leave it alone (also covers AMP, which # requires charset as the first child). $window = substr($html, $inner_start, 1024); if (preg_match('/]*charset/i', $window)) { return $html; } return substr($html, 0, $inner_start) . '' . substr($html, $inner_start); } # function __construct($otto_uuid){ # set the site uuid using the provided string $this->site_uuid = $otto_uuid; # Use endpoint manager if available, otherwise fallback to production if (class_exists('Metasync_Endpoint_Manager')) { $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS'); } else { $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details'; } # laod the simple html dom parser with UTF-8 charset to handle special characters $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false); } /** * Check Route Method * @param route : The route to check * @param path : The path of the html file to save */ function process_route($route, $file_path){ # Construct the full endpoint URL with query parameters $url_with_params = add_query_arg( [ 'url' => $route, 'uuid' => $this->site_uuid, ], $this->otto_end_point ); # PERFORMANCE FIX: Add timeout to prevent blocking $args = array( 'timeout' => 5, // 5 second max timeout (allow time for redirects) 'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301) 'user-agent' => 'MetaSync-OTTO-SSR/2.0', 'sslverify' => true ); # Perform the GET request with timeout $response = wp_remote_get($url_with_params, $args); # Check for errors if (is_wp_error($response)) { error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message()); return false; } # get the response body $body = wp_remote_retrieve_body($response); # Get the response code $response_code = wp_remote_retrieve_response_code($response); # if no change data skip if (empty($body) || $response_code !== 200){ error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code); return false; } # set the html file path $this->html_file = $file_path; # load change data $change_data = json_decode($body, true); # Process with the fetched data return $this->process_route_with_data($route, $change_data, $file_path); } /** * Process route with pre-fetched suggestions data * OPTION 1: Used when data comes from transient cache * @param route : The route to check * @param change_data : Pre-fetched OTTO suggestions data * @param path : The path of the html file to save */ function process_route_with_data($route, $change_data, $file_path){ if (empty($change_data) || !is_array($change_data)) { return false; } # set the html file path $this->html_file = $file_path; # Analyze what Otto is providing and store for conditional SEO blocking $has_otto_title = false; $otto_description_tags = []; // Track specific description tags Otto provides if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) { foreach ($change_data['header_replacements'] as $item) { if (!empty($item['type'])) { # Check if Otto has title if ($item['type'] == 'title' && !empty($item['recommended_value'])) { $has_otto_title = true; } # Check if Otto has description - track specific tag types if ($item['type'] == 'meta') { # Check for meta[name=description] if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) { $otto_description_tags[] = 'meta[name=description]'; } # Check for meta[property=og:description] if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) { $otto_description_tags[] = 'meta[property=og:description]'; } # Check for meta[name=twitter:description] if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) { $otto_description_tags[] = 'meta[name=twitter:description]'; } } } } } # Check header_html_insertion for description - must have non-empty content value if (!empty($change_data['header_html_insertion'])) { if (preg_match('/]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) { $otto_description_tags[] = 'meta[name=description]'; } } # Remove duplicates $otto_description_tags = array_unique($otto_description_tags); # Store blocking flags to pass to handle_route_html # This will be added to the internal fetch URL as parameters $change_data['_otto_blocking'] = array( 'block_title' => $has_otto_title, 'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove ); # Process the route with the suggestions data return $this->handle_route_html($route, $change_data); } # function to get tag attributes function get_tag_attributes($tag){ # Extract existing attributes of the tag $attributes = []; # set the tag attributes $tag_attributes = []; # check that the tag attributes if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){ return ''; } # get the tag attributes $tag_attributes = $tag->getAllAttributes(); # loop all attributes foreach ($tag_attributes as $key => $value) { if ($value == 1) { # Handle boolean attributes $attributes[] = htmlspecialchars($key, ENT_QUOTES); } else { # Handle attributes with values $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"'; } } # Convert attributes array to a string $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : ''; # return the attributes string return $attributes_string; } function handle_route_html($route, $replacement_data){ # Detect if current page uses Brizy and disable SG Cache if so # Using global function defined in otto_pixel.php if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) { metasync_otto_disable_sg_cache_for_brizy(); } # lablel the Otto Route # label otto requests to avoid loops // $request_body = add_query_arg( // [ // 'is_otto_page_fetch' => 1 // ], // $route // ); # Add blocking flags as URL parameters (no database writes!) $url_params = ['is_otto_page_fetch' => 1]; # Add blocking flags if available if (!empty($replacement_data['_otto_blocking'])) { $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0'; # For HTTP fetch path, check if any description tags need blocking $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? []; $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0'; } $request_body = add_query_arg($url_params, $route); # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.) # and loopback requests fail, try using localhost instead $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route); # set cookie header var $cookie_header = ''; # loop cookies to set header foreach ($_COOKIE as $name => $value) { # handle array values by converting to string $cookie_value = is_array($value) ? serialize($value) : $value; # add cookie to header # $cookie_header .= $name . '=' . $value . '; '; $cookie_header .= $name . '=' . $cookie_value . '; '; } # trim the string $cookie_header = rtrim($cookie_header, '; '); # Allow timeout customization for slow tunnel environments $fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5); $args = array( 'sslverify' => false, // Disabled for localhost/tunnel environments 'timeout' => $fetch_timeout, // Configurable timeout for tunnels 'redirection' => 5, 'httpversion' => '1.1', 'headers' => array( 'Cookie' => $cookie_header, 'Cache-Control' => 'no-cache, no-store, must-revalidate', 'Pragma' => 'no-cache', 'X-OTTO-Internal-Fetch' => '1', 'User-Agent' => 'MetaSync-OTTO-SSR/3.0', 'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels ) ); # get the associateed route html $route_html = wp_remote_get($request_body, $args); # Check for timeout or connection errors if (is_wp_error($route_html)) { $error_msg = $route_html->get_error_message(); error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route); return false; } # get body $html_body = wp_remote_retrieve_body($route_html); # Get the response code $response_code = wp_remote_retrieve_response_code($route_html); # check not empty if(empty($html_body) || $response_code !== 200){ error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route); return false; } # Skip DOM processing on oversized documents to avoid fatal OOM. if (class_exists('Metasync_Otto_Render_Strategy') && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html_body)) ) { Metasync_Otto_Render_Strategy::log_oversized_skip('handle_route_html', strlen($html_body)); return false; } # Remove XML declaration $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body); # / Save ALL original