PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.11
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.11
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / Otto_html_class.php

Otto_html_class.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.11, at otto/Otto_html_class.php

2,702 lines 109.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 # uses the simple html dom library
9 use simplehtmldom\HtmlWeb;
10 use simplehtmldom\HtmlDocument;
11
12 /**
13 * This Class Handles
14 */
15
16 Class Metasync_otto_html{
17
18 # html dom
19 private $dom;
20
21 # the file path to save to
22 private $html_file;
23
24 # uuid of site
25 private $site_uuid;
26
27 # the otto endpoint url
28 private $otto_end_point;
29
30 # PERFORMANCE OPTIMIZATION: Defer DOM reload until final save
31 # Reduces 6-10 serialize/deserialize cycles to just 1
32 private $deferred_reload = false;
33
34 # PERFORMANCE OPTIMIZATION: Cache commonly accessed DOM elements
35 # Eliminates 8-10 full DOM traversals per page
36 private $cached_elements = [];
37
38 /**
39 * Escape bare < characters in text content that SimpleHtmlDom misinterprets as HTML tags.
40 * Example: "<4 microns" gets parsed as a tag, corrupting the DOM and breaking page layout.
41 * Only targets < followed by digits (never valid HTML tag starts).
42 * Protects <script> and <style> blocks where < appears in code.
43 */
44 private function sanitize_text_less_than($html) {
45 $protected = [];
46 $html = preg_replace_callback(
47 '/<(script|style)([\s>])(.*?)<\/\1>/si',
48 function($match) use (&$protected) {
49 $placeholder = '<!--METASYNC_PROTECTED_' . count($protected) . '-->';
50 $protected[$placeholder] = $match[0];
51 return $placeholder;
52 },
53 $html
54 );
55
56 $html = preg_replace('/<(?=\d)/', '&lt;', $html);
57
58 if (!empty($protected)) {
59 $html = str_replace(array_keys($protected), array_values($protected), $html);
60 }
61
62 return $html;
63 }
64
65 /**
66 * WP-355: Fix malformed self-closing non-void HTML tags before DOM parsing.
67 *
68 * Some themes (e.g. Bootstrap Component Blox) output malformed tags like:
69 * <ul/ class="sub-menu dropdown-menu" />
70 * SimpleHtmlDom interprets <ul/ as a void element, strips all attributes,
71 * and re-serializes as bare <ul> — breaking Bootstrap dropdown menus.
72 *
73 * This normalizes such tags into proper opening tags:
74 * <ul class="sub-menu dropdown-menu">
75 *
76 * @param string $html Raw HTML before DOM parsing.
77 * @return string HTML with malformed self-closing non-void tags fixed.
78 */
79 private function fix_malformed_self_closing_tags($html) {
80 # Non-void elements that must never be self-closing
81 $tags = 'ul|ol|li|div|span|a|p|h[1-6]|section|header|footer|nav|main|article|aside'
82 . '|table|thead|tbody|tfoot|tr|td|th|caption|colgroup'
83 . '|form|fieldset|legend|label|select|option|optgroup|textarea|button'
84 . '|figure|figcaption|details|summary|blockquote|pre|code|dl|dt|dd'
85 . '|video|audio|canvas|iframe|object|embed|map|picture|source';
86
87 # Pattern 1: <tag/ attr...> or <tag/ attr... /> — slash after tag name
88 # e.g. <ul/ class="sub-menu dropdown-menu" />
89 $html = preg_replace(
90 '#<(' . $tags . ')/(\s[^>]*?)\s*/>#si',
91 '<$1$2>',
92 $html
93 );
94
95 # Pattern 2: <tag/ attr...> without trailing slash
96 # e.g. <ul/ class="sub-menu">
97 $html = preg_replace(
98 '#<(' . $tags . ')/(\s)#si',
99 '<$1$2',
100 $html
101 );
102
103 # Pattern 3: <tag attr... /> — proper self-closing syntax on non-void element
104 # e.g. <ul class="sub-menu" />
105 $html = preg_replace(
106 '#<(' . $tags . ')(\s[^>]*?)\s*/>#si',
107 '<$1$2>',
108 $html
109 );
110
111 return $html;
112 }
113
114 /**
115 * Restore case-sensitive SVG and HTML5 attributes that SimpleHtmlDom lowercases.
116 * The DOM parser's $lowercase=true flag (required for find() queries) lowercases
117 * all attribute names, breaking case-sensitive attributes like viewBox.
118 * Applied on final HTML output after all DOM processing is complete.
119 */
120 private function restore_case_sensitive_attributes($html) {
121 static $map = [
122 ' viewbox=' => ' viewBox=',
123 ' preserveaspectratio=' => ' preserveAspectRatio=',
124 ' controlslist=' => ' controlsList=',
125 ];
126 return str_ireplace(array_keys($map), array_values($map), $html);
127 }
128
129 /**
130 * WP-355 / WP-315: Capture ALL <style> blocks before DOM processing.
131 *
132 * SimpleHtmlDom can lose or corrupt <style> blocks during parse/serialize,
133 * especially those without an id attribute (common in custom themes).
134 * This captures every <style> block so we can re-inject any that are lost.
135 *
136 * @param string $html Raw HTML before DOM parsing.
137 * @return array Keyed by id (or content hash for anonymous styles) → full <style> tag.
138 */
139 private function capture_style_blocks($html) {
140 $blocks = [];
141 if (preg_match_all('/<style[^>]*>.*?<\/style>/si', $html, $matches)) {
142 foreach ($matches[0] as $style_tag) {
143 if (preg_match('/id=["\']([^"\']+)["\']/', $style_tag, $id_match)) {
144 $key = $id_match[1];
145 } else {
146 $key = 'anon_' . md5($style_tag);
147 }
148 $blocks[$key] = $style_tag;
149 }
150 }
151 return $blocks;
152 }
153
154 /**
155 * WP-355 / WP-315: Re-inject any <style> blocks lost during DOM processing.
156 *
157 * Compares the captured blocks against the final HTML and re-injects any
158 * that disappeared. Named blocks (with id) are checked by id; anonymous
159 * blocks are checked by exact content match.
160 *
161 * @param string $html Processed HTML after DOM modifications.
162 * @param array $original_blocks Blocks returned by capture_style_blocks().
163 * @return string HTML with lost style blocks restored.
164 */
165 private function restore_lost_style_blocks($html, $original_blocks) {
166 if (empty($original_blocks)) {
167 return $html;
168 }
169
170 foreach ($original_blocks as $key => $style_tag) {
171 if (strpos($key, 'anon_') === 0) {
172 # Anonymous block — check if exact content is still present
173 if (strpos($html, $style_tag) === false) {
174 $html = str_replace('</head>', $style_tag . "\n" . '</head>', $html);
175 }
176 } else {
177 # Named block — check if its id is still in the document
178 if (strpos($html, 'id="' . $key . '"') === false
179 && strpos($html, "id='" . $key . "'") === false) {
180 $html = str_replace('</head>', $style_tag . "\n" . '</head>', $html);
181 }
182 }
183 }
184
185 return $html;
186 }
187
188 #
189 function __construct($otto_uuid){
190
191 # set the site uuid using the provided string
192 $this->site_uuid = $otto_uuid;
193
194 # Use endpoint manager if available, otherwise fallback to production
195 if (class_exists('Metasync_Endpoint_Manager')) {
196 $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
197 } else {
198 $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details';
199 }
200
201 # laod the simple html dom parser with UTF-8 charset to handle special characters
202 $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
203 }
204
205 /**
206 * Check Route Method
207 * @param route : The route to check
208 * @param path : The path of the html file to save
209 */
210 function process_route($route, $file_path){
211
212 # Construct the full endpoint URL with query parameters
213 $url_with_params = add_query_arg(
214 [
215 'url' => $route,
216 'uuid' => $this->site_uuid,
217 ],
218 $this->otto_end_point
219 );
220
221 # PERFORMANCE FIX: Add timeout to prevent blocking
222 $args = array(
223 'timeout' => 5, // 5 second max timeout (allow time for redirects)
224 'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301)
225 'user-agent' => 'MetaSync-OTTO-SSR/2.0',
226 'sslverify' => true
227 );
228
229 # Perform the GET request with timeout
230 $response = wp_remote_get($url_with_params, $args);
231
232 # Check for errors
233 if (is_wp_error($response)) {
234 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
235 return false;
236 }
237
238 # get the response body
239 $body = wp_remote_retrieve_body($response);
240
241 # Get the response code
242 $response_code = wp_remote_retrieve_response_code($response);
243
244 # if no change data skip
245 if (empty($body) || $response_code !== 200){
246 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
247 return false;
248 }
249
250 # set the html file path
251 $this->html_file = $file_path;
252
253 # load change data
254 $change_data = json_decode($body, true);
255
256 # Process with the fetched data
257 return $this->process_route_with_data($route, $change_data, $file_path);
258 }
259
260 /**
261 * Process route with pre-fetched suggestions data
262 * OPTION 1: Used when data comes from transient cache
263 * @param route : The route to check
264 * @param change_data : Pre-fetched OTTO suggestions data
265 * @param path : The path of the html file to save
266 */
267 function process_route_with_data($route, $change_data, $file_path){
268
269 if (empty($change_data) || !is_array($change_data)) {
270 return false;
271 }
272
273 # set the html file path
274 $this->html_file = $file_path;
275
276 # Analyze what Otto is providing and store for conditional SEO blocking
277 $has_otto_title = false;
278 $otto_description_tags = []; // Track specific description tags Otto provides
279
280 if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) {
281 foreach ($change_data['header_replacements'] as $item) {
282 if (!empty($item['type'])) {
283 # Check if Otto has title
284 if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
285 $has_otto_title = true;
286 }
287 # Check if Otto has description - track specific tag types
288 if ($item['type'] == 'meta') {
289 # Check for meta[name=description]
290 if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
291 $otto_description_tags[] = 'meta[name=description]';
292 }
293 # Check for meta[property=og:description]
294 if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
295 $otto_description_tags[] = 'meta[property=og:description]';
296 }
297 # Check for meta[name=twitter:description]
298 if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
299 $otto_description_tags[] = 'meta[name=twitter:description]';
300 }
301 }
302 }
303 }
304 }
305
306 # Check header_html_insertion for description - must have non-empty content value
307 if (!empty($change_data['header_html_insertion'])) {
308 if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) {
309 $otto_description_tags[] = 'meta[name=description]';
310 }
311 }
312
313 # Remove duplicates
314 $otto_description_tags = array_unique($otto_description_tags);
315
316 # Store blocking flags to pass to handle_route_html
317 # This will be added to the internal fetch URL as parameters
318 $change_data['_otto_blocking'] = array(
319 'block_title' => $has_otto_title,
320 'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove
321 );
322
323 # Process the route with the suggestions data
324 return $this->handle_route_html($route, $change_data);
325
326 }
327
328 # function to get tag attributes
329 function get_tag_attributes($tag){
330
331 # Extract existing attributes of the <body> tag
332 $attributes = [];
333
334
335 # set the tag attributes
336 $tag_attributes = [];
337
338
339 # check that the tag attributes
340 if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){
341 return '';
342 }
343
344 # get the tag attributes
345 $tag_attributes = $tag->getAllAttributes();
346
347 # loop all attributes
348 foreach ($tag_attributes as $key => $value) {
349
350 if ($value == 1) {
351
352 # Handle boolean attributes
353 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
354 } else {
355
356 # Handle attributes with values
357 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
358 }
359 }
360
361 # Convert attributes array to a string
362 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
363
364 # return the attributes string
365 return $attributes_string;
366 }
367
368 function handle_route_html($route, $replacement_data){
369
370 # Detect if current page uses Brizy and disable SG Cache if so
371 # Using global function defined in otto_pixel.php
372 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
373 metasync_otto_disable_sg_cache_for_brizy();
374 }
375
376 # lablel the Otto Route
377 # label otto requests to avoid loops
378 // $request_body = add_query_arg(
379 // [
380 // 'is_otto_page_fetch' => 1
381 // ],
382 // $route
383 // );
384 # Add blocking flags as URL parameters (no database writes!)
385 $url_params = ['is_otto_page_fetch' => 1];
386
387 # Add blocking flags if available
388 if (!empty($replacement_data['_otto_blocking'])) {
389 $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0';
390 # For HTTP fetch path, check if any description tags need blocking
391 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
392 $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0';
393 }
394
395 $request_body = add_query_arg($url_params, $route);
396
397 # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.)
398 # and loopback requests fail, try using localhost instead
399 $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route);
400 # set cookie header var
401 $cookie_header = '';
402
403 # loop cookies to set header
404 foreach ($_COOKIE as $name => $value) {
405
406 # handle array values by converting to string
407 $cookie_value = is_array($value) ? serialize($value) : $value;
408
409 # add cookie to header
410 # $cookie_header .= $name . '=' . $value . '; ';
411 $cookie_header .= $name . '=' . $cookie_value . '; ';
412 }
413
414 # trim the string
415 $cookie_header = rtrim($cookie_header, '; ');
416
417 # Allow timeout customization for slow tunnel environments
418 $fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5);
419
420 $args = array(
421 'sslverify' => false, // Disabled for localhost/tunnel environments
422 'timeout' => $fetch_timeout, // Configurable timeout for tunnels
423 'redirection' => 5,
424 'httpversion' => '1.1',
425 'headers' => array(
426 'Cookie' => $cookie_header,
427 'Cache-Control' => 'no-cache, no-store, must-revalidate',
428 'Pragma' => 'no-cache',
429 'X-OTTO-Internal-Fetch' => '1',
430 'User-Agent' => 'MetaSync-OTTO-SSR/3.0',
431 'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels
432 )
433 );
434
435 # get the associateed route html
436 $route_html = wp_remote_get($request_body, $args);
437
438 # Check for timeout or connection errors
439 if (is_wp_error($route_html)) {
440 $error_msg = $route_html->get_error_message();
441 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
442 return false;
443 }
444
445 # get body
446 $html_body = wp_remote_retrieve_body($route_html);
447
448 # Get the response code
449 $response_code = wp_remote_retrieve_response_code($route_html);
450
451
452 # check not empty
453 if(empty($html_body) || $response_code !== 200){
454 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
455 return false;
456 }
457
458 # Remove XML declaration
459 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
460
461 # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
462 # SimpleHtmlDom can lose or corrupt style blocks during parse/serialize —
463 # not only Divi's id-tagged ones but also anonymous <style> blocks used by
464 # custom themes for nav collapse CSS, responsive breakpoints, etc.
465 $original_style_blocks = $this->capture_style_blocks($html_body);
466
467 # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
468 $html_body = $this->sanitize_text_less_than($html_body);
469
470 # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
471 # before DOM parsing — SimpleHtmlDom strips attributes from these.
472 $html_body = $this->fix_malformed_self_closing_tags($html_body);
473
474 # now that the html is not empty
475 # load it into the simple html dom
476 $this->dom->load($html_body, true, false);
477
478 # Force UTF-8 charset to preserve emojis and special characters
479 # This overrides any charset detection from HTML meta tags
480 $this->dom->_charset = 'UTF-8';
481 $this->dom->_target_charset = 'UTF-8';
482
483 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
484 # This eliminates 8-10 full DOM traversals per page
485 $this->cache_elements();
486
487 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
488 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
489 $this->deferred_reload = true;
490
491 # now lets do the magic
492
493 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
494 # Some API versions return 'value' instead of 'recommended_value'
495 if (!empty($replacement_data['header_replacements'])) {
496 foreach ($replacement_data['header_replacements'] as &$item) {
497 if (isset($item['value']) && !isset($item['recommended_value'])) {
498 $item['recommended_value'] = $item['value'];
499 }
500 }
501 unset($item); // Break reference
502 }
503
504 # start the header html insertion
505 $this->insert_header_html($replacement_data);
506
507 # now we do the header replacements
508 $this->do_header_replacements($replacement_data);
509
510 # now do the body replacements
511 $this->do_body_replacements($replacement_data);
512
513 # now do the footer insertions
514 $this->do_footer_html_insertion($replacement_data);
515
516 # final cleanup: ensure metasync_optimized attribute is removed from AMP pages
517 $this->cleanup_amp_metasync_attribute();
518
519 # CRITICAL FIX: SimpleHtmlDom save() doesn't persist outertext/innertext changes
520 # Use the same manual string replacement approach as process_html_directly
521 $this->deferred_reload = false;
522
523 # Get the HTML as string
524 $result_html = $this->dom->save();
525
526 # Apply manual replacements (same logic as process_html_directly)
527
528 # Apply header replacements manually
529 if (!empty($replacement_data['header_replacements'])) {
530 foreach ($replacement_data['header_replacements'] as $item) {
531 $type = $item['type'] ?? '';
532 $value = $item['recommended_value'] ?? $item['value'] ?? '';
533
534 if (empty($value)) continue;
535
536 if ($type === 'title') {
537 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
538 $title_tag = '<title>' . $new_value . '</title>';
539 $result_html = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
540 return $title_tag;
541 }, $result_html, 1);
542 } elseif ($type === 'meta') {
543 $name = $item['name'] ?? '';
544 $property = $item['property'] ?? '';
545 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
546
547 if (!empty($name)) {
548 $pattern = '/<meta\s+(?:name\s*=\s*["\']' . preg_quote($name, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']|content\s*=\s*["\'][^"\']*["\']\s+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'])\s*\/?>/i';
549 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
550 $count = 0;
551 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
552 return $replacement;
553 }, $result_html, -1, $count);
554
555 if ($count === 0) {
556 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
557 return $m[1] . "\n" . $replacement;
558 }, $result_html, 1);
559 }
560 } elseif (!empty($property)) {
561 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
562 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
563 $count = 0;
564 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
565 return $replacement;
566 }, $result_html, -1, $count);
567
568 if ($count === 0) {
569 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
570 return $m[1] . "\n" . $replacement;
571 }, $result_html, 1);
572 }
573 }
574 } elseif ($type === 'h1' || $type === 'heading') {
575 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
576 $result_html = preg_replace_callback(
577 '/<h1([^>]*)>.*?<\/h1>/is',
578 function ($m) use ($new_value) {
579 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
580 },
581 $result_html,
582 1
583 );
584 }
585 }
586 }
587
588 # String-based heading fallback for body_substitutions
589 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
590 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
591 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
592 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
593 continue;
594 }
595
596 $heading_type = preg_quote($heading['type'], '/');
597 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
598 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
599
600 $result_html = preg_replace_callback(
601 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
602 function ($m) use ($current_value, $recommended_value) {
603 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
604 if ($inner_text === $current_value) {
605 return $m[1] . $recommended_value . $m[3];
606 }
607 return $m[0];
608 },
609 $result_html,
610 -1
611 );
612 }
613 }
614
615 # CRITICAL FIX: Apply image alt text manually via string replacement
616 # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path
617 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
618
619 # Apply insertions — only if DOM insertion didn't already apply it
620 if (!empty($replacement_data['header_html_insertion'])) {
621 $header_html_check = trim($replacement_data['header_html_insertion']);
622 if (strpos($result_html, $header_html_check) === false) {
623 $header_html_insertion = preg_replace(
624 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
625 '<script$1type=$2application/ld+json$2 data-otto="true"',
626 $replacement_data['header_html_insertion']
627 );
628 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
629 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
630 }
631 }
632 if (!empty($replacement_data['body_top_html_insertion'])) {
633 $body_top_check = trim($replacement_data['body_top_html_insertion']);
634 if (strpos($result_html, $body_top_check) === false) {
635 $safe_body_top = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
636 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $safe_body_top, $result_html, 1);
637 }
638 }
639 if (!empty($replacement_data['body_bottom_html_insertion'])) {
640 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
641 if (strpos($result_html, $body_bottom_check) === false) {
642 $safe_body_bottom = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
643 $result_html = preg_replace('/(<\/body>)/i', $safe_body_bottom . "\n" . '$1', $result_html, 1);
644 }
645 }
646 if (!empty($replacement_data['footer_html_insertion'])) {
647 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
648 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
649 }
650
651 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
652 $result_html = $this->deduplicate_title_tags($result_html);
653 $result_html = $this->deduplicate_og_twitter_tags($result_html);
654 $result_html = $this->deduplicate_schema_tags($result_html);
655 $result_html = $this->deduplicate_canonical_tags($result_html);
656
657 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
658 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
659 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
660 }
661
662 $result_html = $this->restore_case_sensitive_attributes($result_html);
663
664 # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
665 $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
666
667 # WP-315: Fix Divi 5 shortcode framework class renumbering (HTTP path)
668 $result_html = $this->fix_divi_class_renumbering($result_html);
669
670 # WP-315: Clean OTTO internal fetch params from HTML output.
671 # The HTTP render uses wp_remote_get with ?is_otto_page_fetch=1&otto_block_title=1&otto_block_desc=1
672 # These leak into form action URLs, canonical links, etc. in the rendered HTML.
673 $result_html = $this->clean_otto_fetch_params($result_html);
674
675 return $result_html;
676 }
677
678 # do the footer html insertion
679 function do_footer_html_insertion($replacement_data){
680
681 # check that we have footer html
682 if(empty($replacement_data['footer_html_insertion'])){
683 return;
684 }
685
686 # OPTIMIZED: Use cached element instead of DOM traversal
687 $footer = $this->get_cached_element('footer');
688
689 # check that footer is object
690 if(!is_object($footer) || !isset($footer->innertext, $footer->outertext)){
691 return;
692 }
693
694 # get the tag attributes
695 $attributes_string = $this->get_tag_attributes($footer);
696
697 # now do the actual html replacements
698 $footer->outertext = '<footer' . $attributes_string . '>' . $footer->innertext . $replacement_data['footer_html_insertion'].'</footer>';
699
700 # save the document
701 $this->save_reload();
702 }
703
704 # do body replacements
705 function do_body_replacements($replacement_data){
706
707 # start body top html replacements
708 $this->do_body_top_html($replacement_data);
709
710 # start the body bottom html replacements
711 $this->do_body_bottom_html($replacement_data);
712
713 # do the body substitutions
714 $this->do_body_substitutions($replacement_data);
715
716 # Check if the feature is enabled in general settings (Post/Page Editor Settings)
717 $general_settings = get_option('metasync_options')['general'] ?? [];
718 if (!empty($general_settings['open_external_links']) && $general_settings['open_external_links'] == '1') {
719 $this->add_target_blank_to_external_links();
720 }
721
722 # Check if the feature is enabled in seo_controls settings (Indexation Control)
723 $seo_controls = get_option('metasync_options')['seo_controls'] ?? [];
724 if (!empty($seo_controls['add_nofollow_to_external_links']) && $seo_controls['add_nofollow_to_external_links'] === 'true') {
725 $this->add_nofollow_to_external_links();
726 }
727 }
728
729 # body substitutions data
730 function do_body_substitutions($replacement_data){
731
732 # check that we have an array of substitutions
733 if(empty($replacement_data['body_substitutions']) || !is_array($replacement_data['body_substitutions'])){
734 return;
735 }
736
737 # now work on different substitution keys
738 foreach ($replacement_data['body_substitutions'] as $key => $value) {
739
740 # check key categories
741 if($key == 'images'){
742 # do image replacements
743 $this->handle_images($value);
744 }
745 elseif($key == 'headings'){
746 # do heading repalcements
747 $this->do_heading_body_substitutions($value);
748 }
749 elseif($key == 'links'){
750 # do link replacements
751 $this->do_link_body_substitutions($value);
752 }
753
754 }
755
756 # save the document
757 $this->save_reload();
758
759 }
760
761 /**
762 * START BODY SUBSTITUTION FUNCTIONS
763 * @see do_body_substitutions();
764 */
765
766 # image substitions
767 function handle_images($image_data){
768
769 if (empty($image_data) || !is_array($image_data)) {
770 return;
771 }
772
773 # OPTIMIZED: Use cached images instead of DOM traversal
774 $images = $this->get_cached_element('imgs', []);
775
776 if (empty($images)) {
777 return;
778 }
779
780 # WP-425: index suggestions by URL path as well, so images authored with
781 # relative srcs still match OTTO's absolute-URL keys (and vice versa).
782 $alt_lookup = $this->build_image_alt_lookup($image_data);
783
784 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
785 # Single pass with hash map lookup instead of nested loop
786 foreach($images AS $key => $image){
787 # Get image src
788 $image_src = $image->src;
789
790 if (empty($image_src)) {
791 continue;
792 }
793
794 # Hash map lookup O(1) instead of loop O(n)
795 $alt_text = $this->lookup_image_alt($alt_lookup, $image_src);
796 if ($alt_text !== null) {
797 # Set alt text - Note: This may not persist in all cases
798 # Manual string replacement in process_html_directly() ensures it's applied
799 $new_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
800
801 # Get current img tag HTML and update alt attribute
802 $current_html = $image->outertext;
803
804 # Remove existing alt attribute (if any)
805 $updated_html = preg_replace('/\s+alt=(["\'])[^"\']*\1/', '', $current_html);
806
807 # Insert new alt attribute after the opening <img
808 $updated_html = preg_replace('/^<img\s/', '<img alt="' . str_replace('$', '\\$', $new_alt) . '" ', $updated_html);
809
810 # Update the element
811 $image->outertext = $updated_html;
812
813 $multi_view_attr = $image->getAttribute('data-et-multi-view');
814 if (!empty($multi_view_attr)) {
815 $this->update_divi_multi_view_alt($image, $alt_text);
816 }
817 }
818 }
819 }
820
821 # WP-425: memoized site host — home_url() runs its filter chain on every
822 # call and these helpers run per suggestion and per img.
823 private ?string $site_host_cache = null;
824
825 /**
826 * Get this site's host (lowercase) for same-host URL comparisons.
827 *
828 * @return string
829 */
830 private function get_site_host() {
831 if ($this->site_host_cache === null) {
832 $host = wp_parse_url(home_url(), PHP_URL_HOST);
833 $this->site_host_cache = is_string($host) ? strtolower($host) : '';
834 }
835 return $this->site_host_cache;
836 }
837
838 /**
839 * WP-425: Candidate lookup keys for an image URL — the URL itself plus,
840 * for relative URLs or absolute/protocol-relative URLs on this site's
841 * host, the bare URL path. URLs on a different host only ever match
842 * exactly, so a same path on a foreign host cannot produce a false match.
843 *
844 * @param string $url Image URL (absolute, protocol-relative, or relative)
845 * @return array Candidate keys, original URL first
846 */
847 private function get_image_url_variants($url) {
848 $variants = [$url];
849
850 if (!is_string($url) || $url === '') {
851 return $variants;
852 }
853
854 $host = wp_parse_url($url, PHP_URL_HOST);
855 if (!empty($host) && strtolower($host) !== $this->get_site_host()) {
856 # Foreign host: exact match only
857 return $variants;
858 }
859
860 $path = wp_parse_url($url, PHP_URL_PATH);
861 if (is_string($path) && $path !== '' && $path !== $url) {
862 $variants[] = $path;
863 }
864
865 return $variants;
866 }
867
868 /**
869 * WP-425: Build the image alt lookup keyed by every URL variant of each
870 * OTTO suggestion, so relative img srcs match absolute suggestion URLs.
871 *
872 * @param array $image_data OTTO body_substitutions.images map (url => alt)
873 * @return array Expanded lookup (url-or-path => alt)
874 */
875 private function build_image_alt_lookup($image_data) {
876 $lookup = [];
877
878 foreach ($image_data as $image_url => $alt_text) {
879 foreach ($this->get_image_url_variants($image_url) as $variant) {
880 # First suggestion wins when two keys collapse to the same
881 # path (e.g. a relative and an absolute key for one image);
882 # exact-src matches still take priority in lookup_image_alt().
883 if (!isset($lookup[$variant])) {
884 $lookup[$variant] = $alt_text;
885 }
886 }
887 }
888
889 return $lookup;
890 }
891
892 /**
893 * WP-425: Resolve the OTTO alt text for an img src, trying the exact src
894 * first and then its same-host path variant.
895 *
896 * @param array $lookup Expanded lookup from build_image_alt_lookup()
897 * @param string $image_src The img element's src attribute
898 * @return string|null Alt text, or null when no suggestion matches
899 */
900 private function lookup_image_alt($lookup, $image_src) {
901 foreach ($this->get_image_url_variants($image_src) as $variant) {
902 if (isset($lookup[$variant])) {
903 return $lookup[$variant];
904 }
905 }
906
907 return null;
908 }
909
910 /**
911 * Update Divi's multi-view data attribute with alt text
912 * Divi stores image attributes in a JSON structure within data-et-multi-view
913 *
914 * @param object $image The image DOM element
915 * @param string $alt_text The alt text to set
916 */
917 private function update_divi_multi_view_alt($image, $alt_text) {
918 $multi_view_attr = $image->getAttribute('data-et-multi-view');
919
920 if (!empty($multi_view_attr)) {
921 try {
922 # Decode the JSON
923 $multi_view_data = json_decode($multi_view_attr, true);
924
925 if ($multi_view_data && isset($multi_view_data['schema']['attrs'])) {
926 # Update alt in desktop view
927 if (isset($multi_view_data['schema']['attrs']['desktop'])) {
928 $multi_view_data['schema']['attrs']['desktop']['alt'] = $alt_text;
929 }
930
931 # Update alt in other views if they exist (phone, tablet, etc.)
932 foreach ($multi_view_data['schema']['attrs'] as $view => $attrs) {
933 if (isset($attrs['alt'])) {
934 $multi_view_data['schema']['attrs'][$view]['alt'] = $alt_text;
935 }
936 }
937
938 # Encode back to JSON and update the attribute
939 $updated_json = json_encode($multi_view_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
940 $image->setAttribute('data-et-multi-view', $updated_json);
941
942 # error_log('MetaSync OTTO DEBUG: Updated Divi multi-view alt text');
943 }
944 } catch (Exception $e) {
945 # If JSON decode fails, just log and continue
946 # error_log('MetaSync OTTO DEBUG: Failed to update Divi multi-view - ' . $e->getMessage());
947 }
948 }
949 }
950
951 /**
952 * Apply image alt text via string replacement on raw HTML.
953 * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites).
954 *
955 * Known limitation (pre-existing): the `<img[^>]*src=` pattern can match a
956 * `data-src`/`data-lazy-src` attribute that ends with the target URL before
957 * the real `src`, mis-attributing the alt on lazy-loaded markup. Tracked as
958 * a follow-up to WP-425.
959 *
960 * @param string $html The HTML to process
961 * @param array $replacement_data The replacement data containing body_substitutions.images
962 * @return string Modified HTML with alt text applied
963 */
964 private function apply_image_alt_text_via_string($html, $replacement_data) {
965 if (empty($replacement_data['body_substitutions']['images']) || !is_array($replacement_data['body_substitutions']['images'])) {
966 return $html;
967 }
968
969 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
970 # WP-425: prefilter and match on the same-host path variant too, so
971 # relative img srcs match OTTO's absolute suggestion URLs.
972 $src_pattern = $this->get_image_src_pattern($image_url);
973
974 if (empty($alt_text) || $src_pattern === null || strpos($html, $src_pattern['needle']) === false) {
975 continue;
976 }
977
978 $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
979 $img_pattern = '/<img[^>]*src=["\']' . $src_pattern['pattern'] . '["\'][^>]*>/i';
980
981 if (preg_match_all($img_pattern, $html, $img_matches)) {
982 foreach ($img_matches[0] as $original_img) {
983 if (strpos($original_img, $escaped_alt) !== false) {
984 continue;
985 }
986
987 # Remove ALL existing alt attributes
988 $new_img = preg_replace('/\s+alt\s*=\s*(["\'])[^"\']*\1/i', '', $original_img);
989 $new_img = preg_replace('/<img\s+alt\s*=\s*(["\'])[^"\']*\1\s*/i', '<img ', $new_img);
990
991 # Add single alt attribute after <img
992 $new_img = preg_replace('/^<img\s*/i', '<img alt="' . str_replace('$', '\\$', $escaped_alt) . '" ', $new_img);
993
994 # Update data-et-multi-view JSON if present (Divi theme)
995 if (strpos($new_img, 'data-et-multi-view') !== false) {
996 $new_img = preg_replace_callback(
997 '/data-et-multi-view="([^"]+)"/i',
998 function($mv_matches) use ($alt_text) {
999 $json_str = html_entity_decode($mv_matches[1], ENT_QUOTES, 'UTF-8');
1000 $json_data = json_decode($json_str, true);
1001
1002 if ($json_data && isset($json_data['schema']['attrs'])) {
1003 foreach ($json_data['schema']['attrs'] as &$attrs) {
1004 if (array_key_exists('alt', $attrs)) {
1005 $attrs['alt'] = $alt_text;
1006 }
1007 }
1008 unset($attrs);
1009 $new_json = json_encode($json_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1010 return 'data-et-multi-view="' . str_replace('"', '&quot;', $new_json) . '"';
1011 }
1012 return $mv_matches[0];
1013 },
1014 $new_img
1015 );
1016 }
1017
1018 $html = str_replace($original_img, $new_img, $html);
1019 }
1020 }
1021 }
1022
1023 return $html;
1024 }
1025
1026 /**
1027 * WP-425: Build the src match for an OTTO image suggestion URL.
1028 *
1029 * For relative URLs or absolute/protocol-relative URLs on this site's
1030 * host, the regex matches the URL path with an optional scheme+host
1031 * prefix — so a relative img src matches an absolute suggestion URL and
1032 * vice versa. URLs on a foreign host keep the original exact match, so a
1033 * same path on a different host cannot produce a false positive.
1034 *
1035 * @param string $image_url OTTO suggestion URL
1036 * @return array|null ['needle' => strpos prefilter string,
1037 * 'pattern' => regex fragment ('/' delimiter)] or null
1038 */
1039 private function get_image_src_pattern($image_url) {
1040 if (!is_string($image_url) || $image_url === '') {
1041 return null;
1042 }
1043
1044 $host = wp_parse_url($image_url, PHP_URL_HOST);
1045 $site_host = $this->get_site_host();
1046
1047 # Foreign host: exact match only
1048 if (!empty($host) && strtolower($host) !== $site_host) {
1049 return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')];
1050 }
1051
1052 $path = wp_parse_url($image_url, PHP_URL_PATH);
1053 if (!is_string($path) || $path === '') {
1054 return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')];
1055 }
1056
1057 $prefix = $site_host !== '' ? '(?:(?:https?:)?\/\/' . preg_quote($site_host, '/') . ')?' : '';
1058
1059 # Allow an optional query/fragment after the path (e.g. WordPress
1060 # `?ver=` cache-busters) so src="/up/a.jpg?ver=2" still matches.
1061 return ['needle' => $path, 'pattern' => $prefix . preg_quote($path, '/') . '(?:[?#][^"\']*)?'];
1062 }
1063
1064 # heading substitutions
1065 function do_heading_body_substitutions($heading_data){
1066
1067 # loop all data
1068 foreach($heading_data AS $okey => $heading){
1069
1070 # find all occurences of the heading type
1071 $occurences = $this->dom->find($heading['type']);
1072
1073 # loop all occurences
1074 foreach ($occurences as $ikey => $heading_old) {
1075
1076 # get the header text
1077 $text = $heading_old->text();
1078
1079 # check matching text
1080 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
1081
1082 # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
1083 $outer = $heading_old->outertext;
1084 $open_end = strpos($outer, '>');
1085 if ($open_end !== false) {
1086 $open_tag = substr($outer, 0, $open_end + 1);
1087 $close_tag = '</' . $heading['type'] . '>';
1088 $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
1089 }
1090
1091 }
1092 }
1093
1094 }
1095
1096 # save and reload the dom
1097 $this->save_reload();
1098 }
1099
1100 # link replacements
1101 function do_link_body_substitutions($swap_data){
1102
1103 # find all links in the body
1104 $links = $this->dom->find('a');
1105
1106 # loop all links check if we have them in swap data
1107 foreach ($links as $key => $link) {
1108
1109 # check if link matches
1110 if(!empty($swap_data[$link->href])){
1111
1112 # replace the link
1113 $link->href = $swap_data[$link->href];
1114 }
1115
1116 }
1117 }
1118
1119 /**
1120 * Add rel="nofollow" attribute to external links
1121 * Works for both buffer and HTTP rendering methods
1122 * Only processes links that don't already have rel="nofollow"
1123 */
1124 function add_nofollow_to_external_links(){
1125
1126 # find all links in the body
1127 $links = $this->dom->find('a');
1128
1129 # get home URL for comparison
1130 $home_url = rtrim(home_url(), '/');
1131 $home_url_lower = strtolower($home_url);
1132
1133 # loop through all links
1134 foreach ($links as $key => $link) {
1135
1136 # get the href attribute
1137 $href = $link->href ?? '';
1138
1139 # skip if href is empty
1140 if (empty($href)) {
1141 continue;
1142 }
1143
1144 # check if link is external
1145 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
1146 # get existing rel attribute
1147 $existing_rel = $link->rel ?? '';
1148
1149 # check if nofollow already exists
1150 if (empty($existing_rel)) {
1151 # no rel attribute, add nofollow
1152 $link->rel = 'nofollow';
1153 } elseif (strpos($existing_rel, 'nofollow') === false) {
1154 # rel exists but nofollow is not present, add it
1155 $link->rel = trim($existing_rel . ' nofollow');
1156 }
1157 # if nofollow already exists, do nothing
1158 }
1159 }
1160
1161 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
1162 # This avoids redundant DOM save/reload operations and improves performance
1163 }
1164
1165 /**
1166 * Check if a URL is external
1167 *
1168 * @param string $url The URL to check
1169 * @param string $home_url The home URL without trailing slash
1170 * @param string $home_url_lower Lowercase version of home URL
1171 * @return bool True if external, false if internal
1172 */
1173 private function is_external_link($url, $home_url, $home_url_lower) {
1174 # Empty or anchor-only links are internal
1175 if (empty($url) || $url === '#' || strpos($url, '#') === 0) {
1176 return false;
1177 }
1178
1179 # Relative URLs (starting with /) are internal
1180 if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
1181 return false;
1182 }
1183
1184 # Check if URL starts with home URL (case-insensitive)
1185 $url_lower = strtolower($url);
1186 if (strpos($url_lower, $home_url_lower) === 0) {
1187 return false;
1188 }
1189
1190 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1191 if (strpos($url, '//') === 0) {
1192 $parsed_home = parse_url($home_url);
1193 $parsed_link = parse_url($url);
1194
1195 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1196 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1197 return false;
1198 }
1199 }
1200 }
1201
1202 # All other URLs are considered external
1203 return true;
1204 }
1205
1206 /**
1207 * Add target="_blank" attribute to external links
1208 * Works for both buffer and HTTP rendering methods
1209 * Only processes links that don't already have a target attribute
1210 */
1211 function add_target_blank_to_external_links(){
1212
1213 # find all links in the body
1214 $links = $this->dom->find('a');
1215
1216 # get home URL for comparison
1217 $home_url = rtrim(home_url(), '/');
1218 $home_url_lower = strtolower($home_url);
1219
1220 # loop through all links
1221 foreach ($links as $key => $link) {
1222
1223 # get the href attribute
1224 $href = $link->href ?? '';
1225
1226 # skip if href is empty or already has target attribute
1227 if (empty($href) || !empty($link->target)) {
1228 continue;
1229 }
1230
1231 # check if link is external
1232 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
1233 # add target="_blank" attribute
1234 $link->target = '_blank';
1235
1236 # add rel="noopener noreferrer" for security
1237 $existing_rel = $link->rel ?? '';
1238 if (empty($existing_rel)) {
1239 $link->rel = 'noopener noreferrer';
1240 } elseif (strpos($existing_rel, 'noopener') === false) {
1241 $link->rel = trim($existing_rel . ' noopener noreferrer');
1242 }
1243 }
1244 }
1245
1246 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
1247 # This avoids redundant DOM save/reload operations and improves performance
1248 }
1249
1250 # body bottom html replacement code
1251 function do_body_bottom_html($insert_data){
1252
1253 # check that data is availbale
1254 if(empty($insert_data['body_bottom_html_insertion'])){
1255 return;
1256 }
1257
1258 # OPTIMIZED: Use cached body element
1259 $body = $this->get_cached_element('body');
1260
1261 # set the link property if not empty
1262 if(empty($body->outertext)){
1263 return;
1264 }
1265
1266
1267 # get the tag attributes
1268 $attributes_string = $this->get_tag_attributes($body);
1269
1270 # now do the actual html replacements
1271 $body->outertext = '<body' . $attributes_string . '>' . $body->innertext . $insert_data['body_bottom_html_insertion'].'</body>';
1272
1273 # save the document
1274 $this->save_reload();
1275 }
1276
1277 # body top html replacement
1278 function do_body_top_html($insert_data){
1279
1280 # check that data is availbale
1281 if(empty($insert_data['body_top_html_insertion'])){
1282 return;
1283 }
1284
1285 # OPTIMIZED: Use cached body element
1286 $body = $this->get_cached_element('body');
1287
1288 # get the tag attributes
1289 $attributes_string = $this->get_tag_attributes($body);
1290
1291 # now do the actual html replacements
1292 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1293 }
1294
1295 # this function does the header replacements
1296 function do_header_replacements($replacement_data){
1297
1298 # check that we have header replacements
1299 if(empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])){
1300 return;
1301 }
1302
1303 # Check for custom SEO values from MetaSync SEO Sidebar
1304 # Custom values take absolute priority over OTTO suggestions
1305 $custom_seo_title = '';
1306 $custom_seo_description = '';
1307
1308 if (function_exists('is_singular') && is_singular()) {
1309 $post_id = get_the_ID();
1310 if ($post_id) {
1311 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
1312 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1313 }
1314 }
1315
1316 # now lets do the replacement work
1317 foreach($replacement_data['header_replacements'] AS $key => $data){
1318
1319 # skip cases where type is not specified
1320 if(empty($data['type'])){
1321 continue;
1322 }
1323
1324 # handle title - skip if custom SEO title exists or no value
1325 if($data['type'] == 'title'){
1326 if (!empty($custom_seo_title)) {
1327 # Skip title replacement - custom SEO title takes priority
1328 continue;
1329 }
1330
1331 # Skip if OTTO has no title value
1332 if (empty(trim($data['recommended_value'] ?? $data['value'] ?? ''))) {
1333 continue;
1334 }
1335
1336 # handle the title logic
1337 $this->replace_title($data);
1338
1339 #
1340 continue;
1341 }
1342
1343 # handle canonical links
1344 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1345
1346 # Protect manually-set canonical from OTTO override
1347 if (function_exists('is_singular') && is_singular()) {
1348 $post_id = get_the_ID();
1349 if ($post_id) {
1350 $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1351 if (empty($custom_canonical)) {
1352 $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1353 // Handle legacy array values
1354 if (is_array($custom_canonical)) {
1355 $custom_canonical = reset($custom_canonical) ?: '';
1356 }
1357 }
1358 if (!empty($custom_canonical)) {
1359 # Manual canonical takes priority — skip OTTO override
1360 continue;
1361 }
1362 }
1363 }
1364
1365 # find the cannonical dom element
1366 $link = $this->dom->find('link[rel="canonical"]', 0);
1367
1368 # set the link property if not empty
1369 if(!empty($link->href)){
1370 $link->href = $data['recommended_value'] ?? $link->href;
1371 }
1372
1373 #
1374 continue;
1375 }
1376
1377 # work on other elemenets not titlte
1378 $this->handle_meta_element($data);
1379 }
1380 }
1381
1382 # function to handle meta elements other than title
1383 function handle_meta_element($data){
1384
1385 # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
1386 # with empty content when OTTO has no recommendation for this meta field
1387 $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
1388 if (empty(trim($recommended_value))) {
1389 return;
1390 }
1391
1392 # Check if this is a description meta tag and if custom description exists
1393 # Custom values take absolute priority over OTTO suggestions
1394 $name = $data['name'] ?? false;
1395 $property = $data['property'] ?? false;
1396
1397 # Protect manually-set robots meta from OTTO override
1398 # If the user set noindex via Common Robots Meta or meta_robots, honour it
1399 if (!empty($name) && $name === 'robots') {
1400 if (function_exists('is_singular') && is_singular()) {
1401 $post_id = get_the_ID();
1402 if ($post_id) {
1403 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
1404 if (!empty($manual_robots) && stripos($manual_robots, 'noindex') !== false) {
1405 # Manual noindex takes priority — skip OTTO override
1406 return;
1407 }
1408 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1409 if (is_array($common_robots) && !empty($common_robots['noindex'])) {
1410 # Common Robots Meta has noindex checked — skip OTTO override
1411 return;
1412 }
1413 }
1414 }
1415 }
1416
1417 # Check for custom SEO description
1418 if (!empty($name) && $name === 'description') {
1419 if (function_exists('is_singular') && is_singular()) {
1420 $post_id = get_the_ID();
1421 if ($post_id) {
1422 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1423 if (!empty($custom_seo_description)) {
1424 # Custom description exists, skip OTTO's suggestion
1425 return;
1426 }
1427 }
1428 }
1429 }
1430
1431 # extract property value
1432 $property = $data['property'] ?? false;
1433
1434 # extract name value
1435 $name = $data['name'] ?? false;
1436
1437 # set the selector
1438 $meta_selector = '';
1439
1440 # extend selector
1441 if(!empty($name)){
1442 $meta_selector .= 'meta[name="' . trim($name) . '"]';
1443 }
1444
1445 # extent if property is defined
1446 if(!empty($property)){
1447 if (!empty($meta_selector)) {
1448 $meta_selector .= ',';
1449 }
1450 $meta_selector .= 'meta[property="' . trim($property) . '"]';
1451 }
1452
1453 # find the meta gat in the dom
1454 $meta_tag = $this->dom->find($meta_selector, 0);
1455
1456 # if tag not exists add it
1457 if(empty($meta_tag)){
1458 if($data['type'] == 'meta'){
1459
1460 # get the attribute
1461 $attribute = $property ? 'property' : 'name';
1462
1463 # call the create metatag function
1464 $result = $this->create_metatag($attribute, $data);
1465 }
1466
1467 # return after creation
1468 return;
1469 }
1470
1471 # CRITICAL FIX: Clear cache and get fresh reference
1472 # Preserve 'imgs' key for later use by handle_images()
1473 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1474 $this->cached_elements = [];
1475 if ($preserved_imgs !== null) {
1476 $this->cached_elements['imgs'] = $preserved_imgs;
1477 }
1478
1479 # Get fresh meta tag reference using same selector
1480 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
1481
1482 if ($meta_tag_fresh) {
1483 # Use outertext for replacement
1484 $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8');
1485
1486 # Determine attribute name
1487 $attr_name = !empty($data['name']) ? 'name' : 'property';
1488 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
1489
1490 # Build new meta tag
1491 $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1492 }
1493
1494
1495 }
1496
1497 # function to handle the page title
1498 function replace_title($title_data){
1499
1500 # find the title
1501 $title = $this->dom->find('title', 0) ?? false;
1502
1503 # if none
1504 if($title === false){
1505 return $this->create_title($title_data);
1506 }
1507
1508 # CRITICAL FIX: Clear element cache and get fresh reference
1509 # Preserve 'imgs' key for later use by handle_images()
1510 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1511 $this->cached_elements = [];
1512 if ($preserved_imgs !== null) {
1513 $this->cached_elements['imgs'] = $preserved_imgs;
1514 }
1515
1516 # Get fresh title element reference
1517 $title_fresh = $this->dom->find('title', 0);
1518
1519 if ($title_fresh) {
1520 # Use outertext for replacement
1521 $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
1522 $title_fresh->outertext = '<title>' . $new_value . '</title>';
1523 }
1524
1525 # Don't save_reload here - will happen at the end
1526 # $this->save_reload();
1527 }
1528
1529 # Function to create a title when it's missing
1530 function create_title($title_data) {
1531
1532 # Find the <head> tag
1533 $head = $this->dom->find('head', 0);
1534
1535 # Construct the <title> tag HTML
1536 $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';
1537
1538 if (empty($head)) {
1539 return false;
1540 }
1541
1542 # Extract existing attributes of the <head> tag
1543 $attributes = [];
1544
1545 # get the tag attributes
1546 $tag_attributes = $head->getAllAttributes();
1547
1548 # loop all attributes
1549 foreach ($tag_attributes as $key => $value) {
1550
1551 if ($value == 1) {
1552
1553 # Handle boolean attributes
1554 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1555 } else {
1556
1557 # Handle attributes with values
1558 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1559 }
1560 }
1561
1562 # Convert attributes array to a string
1563 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1564
1565 # Rebuild the <head> tag, inserting the <title> at the beginning
1566 $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';
1567
1568 # save and reload DOM
1569 $this->save_reload();
1570 }
1571
1572 # function to create meta tag if none existss
1573 function create_metatag($attribute, $data){
1574
1575 # Find the <head> tag
1576 $head = $this->dom->find('head', 0);
1577
1578 # Construct the meta tag HTML (no spaces around = for standard HTML and regex compatibility)
1579 $meta_tag = '<meta '.$attribute.'="'.htmlspecialchars($data[$attribute], ENT_QUOTES, 'UTF-8').'" content="'.htmlspecialchars($data['recommended_value'], ENT_QUOTES, 'UTF-8').'">';
1580
1581 if (empty($head)) {
1582 return false;
1583 }
1584
1585 # Extract existing attributes of the <head> tag
1586 $attributes = [];
1587
1588 # get the tag attributes
1589 $tag_attributes = $head->getAllAttributes();
1590
1591 # loop all attributes
1592 foreach ($tag_attributes as $key => $value) {
1593
1594 if ($value == 1) {
1595
1596 # Handle boolean attributes
1597 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1598 } else {
1599
1600 # Handle attributes with values
1601 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1602 }
1603 }
1604
1605 # Convert attributes array to a string
1606 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1607
1608 # Rebuild the <head> tag, inserting the <title> at the beginning
1609 $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';
1610
1611 # save and reload DOM
1612 $this->save_reload();
1613 }
1614
1615 /**
1616 * Remove duplicate <title> tags from HTML, keeping the first (OTTO's) value.
1617 *
1618 * OTTO's title replacement is always applied first (limit=1 or DOM manipulation),
1619 * so the first <title> tag holds the authoritative value. If SEO plugin conflicts
1620 * produce additional <title> tags, this strips all and re-inserts one.
1621 *
1622 * @param string $html Full HTML document.
1623 * @return string HTML with at most one <title> tag.
1624 */
1625 private function deduplicate_title_tags($html) {
1626 $title_count = preg_match_all('/<title[^>]*>.*?<\/title>/is', $html, $title_matches);
1627 if ($title_count <= 1) {
1628 return $html;
1629 }
1630
1631 # Capture the first title's inner text (OTTO's replacement)
1632 preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $first_title);
1633 $authoritative_title = isset($first_title[1]) ? $first_title[1] : '';
1634
1635 # Strip all <title> tags
1636 $html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html);
1637
1638 # Re-insert a single <title> after <head> using preg_replace_callback to prevent
1639 # backreference injection when title contains $ followed by digits (e.g. "$50 off")
1640 $title_tag = '<title>' . $authoritative_title . '</title>';
1641 $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1642 return $m[1] . $title_tag;
1643 }, $html, 1);
1644
1645 return $html;
1646 }
1647
1648 /**
1649 * Remove duplicate OG and Twitter meta tags from HTML after OTTO processing.
1650 *
1651 * OTTO injects its tags with `data-otto-pixel` or `data-otto` attributes.
1652 * Legacy MetaSync output and third-party SEO plugins may also emit the same
1653 * OG/Twitter properties. This method runs at the buffer level — after all
1654 * sources have written their tags — and keeps only the OTTO version when
1655 * duplicates exist.
1656 *
1657 * Strategy per property (e.g. og:description):
1658 * - If OTTO tag exists (has data-otto marker) → remove all non-OTTO duplicates
1659 * - If no OTTO tag exists → keep the first occurrence, remove the rest
1660 *
1661 * @param string $html Full HTML document.
1662 * @return string HTML with at most one tag per OG/Twitter property.
1663 */
1664 private function deduplicate_og_twitter_tags($html) {
1665 # OG properties to deduplicate
1666 $og_properties = [
1667 'og:title', 'og:description', 'og:url', 'og:type',
1668 'og:locale', 'og:site_name', 'og:image',
1669 ];
1670
1671 foreach ($og_properties as $prop) {
1672 $html = $this->deduplicate_meta_by_attr($html, 'property', $prop);
1673 }
1674
1675 # Twitter names to deduplicate
1676 $twitter_names = [
1677 'twitter:title', 'twitter:description', 'twitter:card',
1678 'twitter:image', 'twitter:site',
1679 ];
1680
1681 foreach ($twitter_names as $name) {
1682 $html = $this->deduplicate_meta_by_attr($html, 'name', $name);
1683 }
1684
1685 return $html;
1686 }
1687
1688 /**
1689 * Deduplicate meta tags by a specific attribute (property= or name=).
1690 *
1691 * When duplicates exist and one carries a data-otto marker, keep only
1692 * the OTTO version. Otherwise keep the first occurrence.
1693 *
1694 * @param string $html Full HTML.
1695 * @param string $attr_name Attribute name: 'property' or 'name'.
1696 * @param string $attr_val Attribute value: e.g. 'og:title' or 'twitter:description'.
1697 * @return string
1698 */
1699 private function deduplicate_meta_by_attr($html, $attr_name, $attr_val) {
1700 $escaped = preg_quote($attr_val, '/');
1701 # Match all <meta> tags with this attribute value (both attr orderings)
1702 $pattern = '/<meta\s[^>]*' . preg_quote($attr_name, '/') . '\s*=\s*["\']' . $escaped . '["\'][^>]*\/?>/i';
1703
1704 if (preg_match_all($pattern, $html, $matches) <= 1) {
1705 return $html; # 0 or 1 — nothing to deduplicate
1706 }
1707
1708 $all_tags = $matches[0];
1709
1710 # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
1711 $otto_tag = null;
1712 foreach ($all_tags as $tag) {
1713 if (stripos($tag, 'data-otto') !== false) {
1714 $otto_tag = $tag;
1715 break;
1716 }
1717 }
1718
1719 # Determine the keeper: OTTO tag if present, otherwise the first tag
1720 $keeper = $otto_tag ?: $all_tags[0];
1721
1722 # Remove all occurrences, then re-insert the keeper at the first position
1723 $first_replaced = false;
1724 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1725 if (!$first_replaced) {
1726 $first_replaced = true;
1727 return $keeper;
1728 }
1729 return ''; # Remove subsequent duplicates
1730 }, $html);
1731
1732 return $html;
1733 }
1734
1735 /**
1736 * Remove duplicate <link rel="canonical"> tags from HTML.
1737 *
1738 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1739 * SEO output (or WordPress core) has already emitted one, keep only the
1740 * OTTO version (identified by data-otto marker). If no OTTO tag exists,
1741 * keep the first occurrence.
1742 *
1743 * @param string $html Full HTML document.
1744 * @return string HTML with at most one canonical tag.
1745 */
1746 private function deduplicate_canonical_tags($html) {
1747 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
1748
1749 if (preg_match_all($pattern, $html, $matches) <= 1) {
1750 return $html;
1751 }
1752
1753 $all_tags = $matches[0];
1754
1755 $otto_tag = null;
1756 foreach ($all_tags as $tag) {
1757 if (stripos($tag, 'data-otto') !== false) {
1758 $otto_tag = $tag;
1759 break;
1760 }
1761 }
1762
1763 $keeper = $otto_tag ?: $all_tags[0];
1764
1765 $first_replaced = false;
1766 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1767 if (!$first_replaced) {
1768 $first_replaced = true;
1769 return $keeper;
1770 }
1771 return '';
1772 }, $html);
1773
1774 return $html;
1775 }
1776
1777 /**
1778 * Deduplicate JSON-LD schema blocks.
1779 *
1780 * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1781 * blocks, keep OTTO's version for any @type that appears in both.
1782 * Third-party blocks whose @type is not covered by OTTO are preserved.
1783 *
1784 * @param string $html Full HTML.
1785 * @return string
1786 */
1787 private function deduplicate_schema_tags($html) {
1788 // Find all JSON-LD script blocks
1789 $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1790 if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
1791 return $html;
1792 }
1793
1794 $otto_by_type = []; // @type => decoded JSON object
1795 $third_by_type = []; // @type => decoded JSON object
1796 $otto_graph = []; // entries from OTTO @graph blocks
1797 $third_graph = []; // entries from third-party @graph blocks
1798
1799 foreach ($matches as $m) {
1800 $attrs = $m[1];
1801 $json_str = $m[3];
1802 $decoded = json_decode($json_str, true);
1803 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1804 continue; // skip unparseable blocks — leave them in place
1805 }
1806 $is_otto = stripos($attrs, 'data-otto') !== false;
1807
1808 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1809 foreach ($decoded['@graph'] as $entry) {
1810 if (!isset($entry['@type'])) continue;
1811 // JSON-LD allows @type to be a string OR an array of strings.
1812 // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1813 // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1814 $type = is_array($entry['@type'])
1815 ? (string) reset($entry['@type'])
1816 : (string) $entry['@type'];
1817 if ($type === '') continue;
1818 if ($is_otto) {
1819 $otto_graph[$type] = $entry;
1820 } else {
1821 $third_graph[$type] = $entry;
1822 }
1823 }
1824 } elseif (isset($decoded['@type'])) {
1825 $type = is_array($decoded['@type'])
1826 ? (string) reset($decoded['@type'])
1827 : (string) $decoded['@type'];
1828 if ($type === '') continue;
1829 if ($is_otto) {
1830 $otto_by_type[$type] = $decoded;
1831 } else {
1832 $third_by_type[$type] = $decoded;
1833 }
1834 }
1835 }
1836
1837 // If OTTO provided no schema at all, nothing to deduplicate
1838 if (empty($otto_by_type) && empty($otto_graph)) {
1839 return $html;
1840 }
1841
1842 // Remove all JSON-LD blocks from HTML
1843 $html = preg_replace($pattern, '', $html);
1844
1845 // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
1846 $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
1847 $rebuilt = '';
1848 foreach ($kept as $decoded) {
1849 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1850 wp_json_encode($decoded) . "</script>\n";
1851 }
1852
1853 // Re-insert merged @graph block (if any entries exist)
1854 $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
1855 if (!empty($merged_graph)) {
1856 $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
1857 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1858 wp_json_encode($graph_obj) . "</script>\n";
1859 }
1860
1861 // Re-inject before </head>
1862 if (!empty($rebuilt)) {
1863 $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
1864 return $rebuilt . $m[1];
1865 }, $html, 1);
1866 }
1867
1868 return $html;
1869 }
1870
1871 # function to detect if current page is an AMP page
1872 function is_amp_page(){
1873
1874 # Check if URL path contains /amp/
1875 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1876 if (strpos($current_url, '/amp/') !== false) {
1877 return true;
1878 }
1879
1880 # Check if URL ends with /amp
1881 if (preg_match('/\/amp\/?$/', $current_url)) {
1882 return true;
1883 }
1884
1885 # Check if amp=1 query parameter is present
1886 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
1887 return true;
1888 }
1889
1890 # Check for other common AMP query parameters
1891 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
1892 return true;
1893 }
1894
1895 return false;
1896 }
1897
1898 # this function insterts header html to the dom
1899 function insert_header_html($data){
1900
1901 # check that we have the header html
1902 if(empty($data['header_html_insertion'])){
1903 #
1904 return;
1905 }
1906
1907 # append the/ html at the start of the header
1908 $head = $this->dom->find('head', 0);
1909
1910 if ($head) {
1911
1912 # Check if this is an AMP page - if so, don't add metasync_optimized attribute
1913 $is_amp_page = $this->is_amp_page();
1914
1915 # Append the new HTML at the start of the <head> tag
1916 # For AMP pages: use clean <head> tag without metasync_optimized attribute
1917 # For non-AMP pages: add metasync_optimized attribute to <head> tag
1918 if ($is_amp_page) {
1919 $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1920 } else {
1921 $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1922 }
1923
1924 }
1925
1926 # save and reload DOM
1927 $this->save_reload();
1928 }
1929
1930 # function to forcefully remove metasync_optimized attribute from head on AMP pages
1931 function cleanup_amp_metasync_attribute(){
1932
1933 # Only proceed if this is an AMP page
1934 if (!$this->is_amp_page()) {
1935 return;
1936 }
1937
1938 # Find the head tag
1939 $head = $this->dom->find('head', 0);
1940
1941 if (!$head) {
1942 return;
1943 }
1944
1945 # Check if head has metasync_optimized attribute
1946 $head_html = $head->outertext;
1947
1948 # If metasync_optimized attribute is found, remove it
1949 if (strpos($head_html, 'metasync_optimized') !== false) {
1950
1951 # Remove the metasync_optimized attribute from the head tag
1952 # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
1953 $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
1954
1955 # Update the head element
1956 $head->outertext = $cleaned_head_html;
1957
1958 }
1959 }
1960
1961 /**
1962 * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1963 * Reduces 8-10 full DOM traversals to 1 initial traversal
1964 * Call this once after loading HTML, before processing
1965 */
1966 private function cache_elements() {
1967 if (!$this->dom) {
1968 return;
1969 }
1970
1971 # Cache all commonly accessed elements in one pass
1972 $this->cached_elements = [
1973 'html' => $this->dom->find('html', 0),
1974 'head' => $this->dom->find('head', 0),
1975 'body' => $this->dom->find('body', 0),
1976 'footer' => $this->dom->find('footer', 0),
1977 'title' => $this->dom->find('title', 0),
1978 'imgs' => $this->dom->find('img'),
1979 'links' => $this->dom->find('a'),
1980 'canonical' => $this->dom->find('link[rel="canonical"]', 0),
1981 ];
1982 }
1983
1984 /**
1985 * Get cached element by key, with fallback to DOM find
1986 * @param string $key Element key from cache
1987 * @param mixed $fallback Fallback value if not cached
1988 * @return mixed Cached element or fallback
1989 */
1990 private function get_cached_element($key, $fallback = null) {
1991 return $this->cached_elements[$key] ?? $fallback;
1992 }
1993
1994 # this function saves are reloads the dom for modifications to avoid conflict
1995 function save_reload(){
1996
1997 # PERFORMANCE OPTIMIZATION: Skip reload if deferred
1998 # This reduces multiple serialize/deserialize cycles to just one final reload
1999 if ($this->deferred_reload) {
2000 return;
2001 }
2002
2003 # Cleanup metasync_optimized attribute on AMP pages before saving
2004 $this->cleanup_amp_metasync_attribute();
2005
2006 # DISABLED: Cache file creation temporarily disabled
2007
2008 # if(file_put_contents($this->html_file, $this->dom)){
2009
2010 # load the modified file to the DOM
2011 # $this->dom = new HtmlDocument($this->html_file );
2012 # }
2013
2014 # this code is to be replaced in future
2015 # reson for adding is to prevent caching logged in user pages
2016 # why not just skip saving? it broke the DOM Library
2017 # check user is logged in clear the file
2018
2019 # if(is_user_logged_in()) {
2020 # unlink($this->html_file);
2021 # }
2022
2023 # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
2024 # This prevents DOM breaking while avoiding cache file creation
2025 if($this->dom){
2026 # Get current DOM as HTML string
2027 $current_html = $this->dom->save();
2028
2029 # Reload DOM from the HTML string to refresh internal state
2030 # This replaces the file save/reload cycle that SimpleHtmlDOM expects
2031 $current_html = $this->sanitize_text_less_than($current_html);
2032 $this->dom->load($current_html, true, false);
2033
2034 # Force UTF-8 charset after reload to preserve emojis
2035 $this->dom->_charset = 'UTF-8';
2036 $this->dom->_target_charset = 'UTF-8';
2037 }
2038
2039 }
2040
2041 /**
2042 * Process HTML directly without HTTP request (for buffer approach)
2043 * This is the FAST path - eliminates the internal wp_remote_get call
2044 *
2045 * CRITICAL: This method is called from the output buffer callback.
2046 * If it fails, we must return false so the original HTML can be used.
2047 *
2048 * @param string $html The raw HTML captured from output buffer
2049 * @param array $replacement_data OTTO suggestions/replacement data
2050 * @return HtmlDocument|false Modified DOM or false on failure
2051 * @since 2.6.0
2052 */
2053 function process_html_directly($html, $replacement_data) {
2054 try {
2055 # Validate inputs
2056 if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
2057 return false;
2058 }
2059
2060 # Validate HTML is actual HTML content
2061 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
2062 return false;
2063 }
2064
2065 # Remove XML declaration if present
2066 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
2067
2068 # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
2069 $original_style_blocks = $this->capture_style_blocks($html);
2070
2071 # Escape bare < in text content before DOM parsing
2072 $html = $this->sanitize_text_less_than($html);
2073
2074 # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
2075 $html = $this->fix_malformed_self_closing_tags($html);
2076
2077 # Load HTML into DOM
2078 $this->dom->load($html, true, false);
2079
2080 # Force UTF-8 charset to preserve emojis and special characters
2081 $this->dom->_charset = 'UTF-8';
2082 $this->dom->_target_charset = 'UTF-8';
2083
2084 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
2085 # This eliminates 8-10 full DOM traversals per page
2086 $this->cache_elements();
2087
2088 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
2089 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
2090 $this->deferred_reload = true;
2091
2092 # Apply blocking flags if available (for SEO plugin coordination)
2093 if (!empty($replacement_data['_otto_blocking'])) {
2094 $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
2095 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
2096
2097 # Remove SEO plugin meta tags if OTTO is providing them
2098 if ($block_title || !empty($block_description_tags)) {
2099 $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
2100 }
2101 }
2102
2103 # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)
2104
2105 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
2106 # Some API versions return 'value' instead of 'recommended_value'
2107 if (!empty($replacement_data['header_replacements'])) {
2108 foreach ($replacement_data['header_replacements'] as &$item) {
2109 if (isset($item['value']) && !isset($item['recommended_value'])) {
2110 $item['recommended_value'] = $item['value'];
2111 }
2112 }
2113 unset($item); // Break reference
2114 }
2115
2116 # 1. Header HTML insertion
2117 $this->insert_header_html($replacement_data);
2118
2119 # 2. Header replacements (title, meta, canonical)
2120 $this->do_header_replacements($replacement_data);
2121
2122 # 3. Body replacements (top, bottom, substitutions)
2123 $this->do_body_replacements($replacement_data);
2124
2125 # 4. Footer HTML insertion
2126 $this->do_footer_html_insertion($replacement_data);
2127
2128 # 5. Final cleanup for AMP pages
2129 $this->cleanup_amp_metasync_attribute();
2130
2131 # CRITICAL FIX: Clear cached elements and force DOM to refresh
2132 $this->deferred_reload = false;
2133 $this->cached_elements = []; // Clear our custom cache
2134
2135 # Clear SimpleHtmlDom's internal cache
2136 if (method_exists($this->dom, 'clear')) {
2137 $this->dom->clear();
2138 }
2139
2140
2141 # Try getting HTML via root element instead of save()
2142 $root = $this->dom->root;
2143 if ($root && isset($root->outertext)) {
2144 $result_html = $root->outertext;
2145 } else {
2146 # Fallback to save() method
2147 $result_html = $this->dom->save();
2148 }
2149
2150 # DEBUG: Check if DOM changes persisted
2151 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2152 }
2153
2154 # Apply header replacements manually via string replacement
2155 if (!empty($replacement_data['header_replacements'])) {
2156
2157 foreach ($replacement_data['header_replacements'] as $idx => $item) {
2158 $type = $item['type'] ?? '';
2159 $value = $item['recommended_value'] ?? $item['value'] ?? '';
2160
2161
2162 if (empty($value)) {
2163 continue;
2164 }
2165
2166 if ($type === 'title') {
2167 # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
2168 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2169 $title_tag = '<title>' . $new_value . '</title>';
2170 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2171 return $title_tag;
2172 }, $result_html, 1);
2173 if ($replaced === $result_html && strpos($result_html, '<title') === false) {
2174 # No <title> in document (common when Yoast is blocked) — insert after <head>
2175 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
2176 return $m[1] . "\n" . $title_tag;
2177 }, $result_html, 1);
2178 } else {
2179 $result_html = $replaced;
2180 }
2181 } elseif ($type === 'meta') {
2182 $name = $item['name'] ?? '';
2183 $property = $item['property'] ?? '';
2184 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2185
2186 if (!empty($name)) {
2187 # MEMORY OPTIMIZED: Count meta tags without storing all matches
2188 # preg_match_all requires $matches, so we pass it but unset immediately
2189 # Use \s*=\s* to match attributes with or without spaces around =
2190 $before_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);
2191
2192 # Free memory immediately after use
2193 unset($before_matches);
2194
2195 # IMPROVED FIX: Remove ALL existing meta tags with this name
2196 # This pattern matches ANY meta tag with name="X" regardless of:
2197 # - Attribute order (name before/after content)
2198 # - Additional attributes (id, class, data-*, etc.)
2199 # - Quote style (single or double quotes)
2200 # - Whitespace variations
2201 # The pattern uses [^>]* to match ANY characters until the closing >
2202 $removed_count = preg_replace_callback(
2203 '/<meta\s+[^>]*name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
2204 function($match) {
2205 return ''; // Remove the tag
2206 },
2207 $result_html,
2208 -1, // Remove all occurrences
2209 $total_removed
2210 );
2211
2212 # Update the HTML with removed tags
2213 if ($total_removed > 0) {
2214 $result_html = $removed_count;
2215 }
2216
2217
2218 # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
2219 $after_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
2220 unset($after_matches);
2221
2222 # Now insert ONE new meta tag at the TOP of <head>
2223 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
2224 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
2225 return $m[1] . "\n" . $replacement;
2226 }, $result_html, 1);
2227 } elseif (!empty($property)) {
2228 # Replace meta property tag
2229 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
2230 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
2231
2232 if (preg_match($pattern, $result_html)) {
2233 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
2234 return $replacement;
2235 }, $result_html, 1);
2236 } else {
2237 # Meta tag doesn't exist, insert it in head
2238 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
2239 return $m[1] . "\n" . $replacement;
2240 }, $result_html, 1);
2241 }
2242 }
2243 } elseif ($type === 'h1' || $type === 'heading') {
2244 # Replace first H1 tag, preserving attributes
2245 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2246 $result_html = preg_replace_callback(
2247 '/<h1([^>]*)>.*?<\/h1>/is',
2248 function ($m) use ($new_value) {
2249 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
2250 },
2251 $result_html,
2252 1
2253 );
2254 }
2255 }
2256 }
2257
2258 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
2259 if (!empty($replacement_data['header_html_insertion'])) {
2260 $header_html_check = trim($replacement_data['header_html_insertion']);
2261 if (strpos($result_html, $header_html_check) === false) {
2262 $header_html_insertion = preg_replace(
2263 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
2264 '<script$1type=$2application/ld+json$2 data-otto="true"',
2265 $replacement_data['header_html_insertion']
2266 );
2267 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
2268 # Insert before </head>
2269 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
2270 }
2271 }
2272
2273 # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
2274 if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
2275 $result_html = preg_replace(
2276 '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
2277 '',
2278 $result_html
2279 );
2280 $first = true;
2281 $result_html = preg_replace_callback(
2282 '/<title[^>]*>.*?<\/title>\s*/is',
2283 function ($m) use (&$first) {
2284 if ($first) {
2285 $first = false;
2286 return $m[0];
2287 }
2288 return '';
2289 },
2290 $result_html
2291 );
2292 }
2293
2294 # Apply body top HTML insertion — only if DOM insertion didn't already apply it
2295 if (!empty($replacement_data['body_top_html_insertion'])) {
2296 $body_top_check = trim($replacement_data['body_top_html_insertion']);
2297 if (strpos($result_html, $body_top_check) === false) {
2298 $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
2299 # Insert after <body>
2300 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
2301 }
2302 }
2303
2304 # Apply body bottom HTML insertion — only if DOM insertion didn't already apply it
2305 if (!empty($replacement_data['body_bottom_html_insertion'])) {
2306 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
2307 if (strpos($result_html, $body_bottom_check) === false) {
2308 $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
2309 # Insert before </body>
2310 $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
2311 }
2312 }
2313
2314 # Apply footer HTML insertion
2315 if (!empty($replacement_data['footer_html_insertion'])) {
2316 $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
2317 # Insert before </html>
2318 $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
2319 }
2320
2321 # CRITICAL FIX: Apply image alt text manually via string replacement
2322 # DOM changes don't persist, must use string replacement
2323 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
2324
2325 # String-based heading fallback for body_substitutions
2326 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2327 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
2328 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
2329 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
2330 continue;
2331 }
2332
2333 $heading_type = preg_quote($heading['type'], '/');
2334 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
2335 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
2336
2337 $result_html = preg_replace_callback(
2338 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2339 function ($m) use ($current_value, $recommended_value) {
2340 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2341 if ($inner_text === $current_value) {
2342 return $m[1] . $recommended_value . $m[3];
2343 }
2344 return $m[0];
2345 },
2346 $result_html,
2347 -1
2348 );
2349 }
2350 }
2351
2352 # DEBUG: Check what we're returning
2353 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2354 }
2355
2356 # FINAL VERIFICATION: Count meta descriptions in returned HTML
2357 $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
2358 if ($final_meta_count > 0) {
2359 foreach ($final_meta_matches[0] as $idx => $meta) {
2360 }
2361 }
2362
2363 if ($final_meta_count > 1) {
2364 }
2365
2366 # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
2367 # Only runs when OTTO has actually inserted its own description (data-otto marker present)
2368 # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace
2369
2370 $removal_count = 0;
2371 $otto_has_description = (bool) preg_match('/<meta[^>]*name\s*=\s*["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
2372 if ($otto_has_description) {
2373 $result_html = preg_replace_callback(
2374 '/<meta\s+([^>]*name\s*=\s*["\']description["\'][^>]*)>/i',
2375 function($match) use (&$removal_count) {
2376 # Keep only if it has data-otto="true"
2377 if (stripos($match[1], 'data-otto') !== false) {
2378 return $match[0]; // Keep OTTO's meta tag
2379 }
2380 # Remove any other meta description
2381 $removal_count++;
2382 return '';
2383 },
2384 $result_html
2385 );
2386 }
2387
2388 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2389 $result_html = $this->deduplicate_title_tags($result_html);
2390 $result_html = $this->deduplicate_og_twitter_tags($result_html);
2391 $result_html = $this->deduplicate_schema_tags($result_html);
2392 $result_html = $this->deduplicate_canonical_tags($result_html);
2393
2394 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2395 # This ensures memory is released immediately, especially important for high-traffic sites
2396 unset($final_meta_matches, $matches);
2397
2398 # Clear SimpleHtmlDom internal cache to free memory
2399 # Note: We don't unset $this->dom as the object may be reused
2400 if ($this->dom && method_exists($this->dom, 'clear')) {
2401 $this->dom->clear();
2402 }
2403
2404 # Clear element cache array
2405 $this->cached_elements = [];
2406
2407 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2408 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2409 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
2410 }
2411
2412 $result_html = $this->restore_case_sensitive_attributes($result_html);
2413
2414 # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
2415 $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
2416
2417 # WP-315: Fix Divi 5 shortcode framework class renumbering
2418 $result_html = $this->fix_divi_class_renumbering($result_html);
2419
2420 # WP-315: Clean OTTO internal fetch params from HTML output
2421 $result_html = $this->clean_otto_fetch_params($result_html);
2422
2423 return $result_html;
2424
2425 } catch (Exception $e) {
2426 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
2427 return false;
2428 } catch (Error $e) {
2429 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
2430 return false;
2431 }
2432 }
2433
2434 /**
2435 * Remove conflicting SEO plugin meta tags from DOM
2436 * Called when OTTO is providing its own title/description
2437 *
2438 * @param bool $remove_title Remove title-related tags
2439 * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
2440 * @since 2.6.0
2441 */
2442 private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
2443 if (!$this->dom) {
2444 return;
2445 }
2446
2447 # Find head element
2448 $head = $this->dom->find('head', 0);
2449 if (!$head) {
2450 return;
2451 }
2452
2453 # Check for custom SEO values from MetaSync SEO Sidebar
2454 # Custom values take absolute priority over OTTO suggestions
2455 $has_custom_title = false;
2456 $has_custom_description = false;
2457
2458 if (function_exists('is_singular') && is_singular()) {
2459 $post_id = get_the_ID();
2460 if ($post_id) {
2461 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
2462 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
2463 $has_custom_title = !empty($custom_seo_title);
2464 $has_custom_description = !empty($custom_seo_description);
2465 }
2466 }
2467
2468 # Handle description tags - only remove specific tags that Otto is providing
2469 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
2470 # Remove only the specific description tags that Otto is providing
2471 foreach ($remove_description_tags as $selector) {
2472 $tags = $this->dom->find($selector);
2473 foreach ($tags as $tag) {
2474 # Remove the tag
2475 $tag->outertext = '';
2476 }
2477 }
2478 }
2479
2480 if ($remove_title && !$has_custom_title) {
2481 # Remove Open Graph and Twitter title tags (keep main <title>)
2482 $title_selectors = [
2483 'meta[property=og:title]',
2484 'meta[name=twitter:title]',
2485 ];
2486
2487 foreach ($title_selectors as $selector) {
2488 $tags = $this->dom->find($selector);
2489 foreach ($tags as $tag) {
2490 $tag->outertext = ''; # Remove the tag
2491 }
2492 }
2493 }
2494
2495 # Save changes
2496 $this->save_reload();
2497 }
2498
2499 /**
2500 * Clean OTTO internal fetch query parameters from rendered HTML (WP-315).
2501 *
2502 * @param string $html The rendered HTML
2503 * @return string HTML with OTTO fetch params removed
2504 */
2505 private function clean_otto_fetch_params($html) {
2506 $patterns = [
2507 '/[?&]is_otto_page_fetch=1/',
2508 '/[?&]otto_block_title=1/',
2509 '/[?&]otto_block_desc=1/',
2510 '/[?&]amp;is_otto_page_fetch=1/',
2511 '/[?&]amp;otto_block_title=1/',
2512 '/[?&]amp;otto_block_desc=1/',
2513 ];
2514 $html = preg_replace($patterns, '', $html);
2515
2516 # WP-398: Protect <script>/<style> bodies before the URL cleanup below.
2517 # The href/src/action matcher can otherwise match JS that looks like an
2518 # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the
2519 # ?? inside it. Swap each block for a placeholder comment, run the cleanup,
2520 # then restore byte-for-byte.
2521 $protected = [];
2522 $html = preg_replace_callback(
2523 '/<(script|style)(\b[^>]*)>([\s\S]*?)<\/\1>/i',
2524 function ($m) use (&$protected) {
2525 $key = '<!--METASYNC_FETCHCLEAN_' . count($protected) . '-->';
2526 $protected[$key] = $m[0];
2527 return $key;
2528 },
2529 $html
2530 );
2531
2532 # WP-398: Normalize leftover query-string separators (?? / ?& / ?#) created
2533 # by the param removal above — but ONLY inside URL attributes (href/src/action).
2534 # The previous implementation ran str_replace('??','?') over the ENTIRE document,
2535 # which corrupted inline JavaScript nullish operators (?? / ??=) and any literal
2536 # "??" in page text — breaking Slider Revolution and other modern inline scripts.
2537 $html = preg_replace_callback(
2538 '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is',
2539 function ($m) {
2540 $url = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $m[4]);
2541 return $m[1] . $m[2] . $m[3] . $url . $m[3];
2542 },
2543 $html
2544 );
2545
2546 # Restore protected <script>/<style> blocks byte-for-byte.
2547 if (!empty($protected)) {
2548 $html = str_replace(array_keys($protected), array_values($protected), $html);
2549 }
2550
2551 return $html;
2552 }
2553
2554 /**
2555 * Fix Divi 5 shortcode framework class renumbering (WP-315)
2556 *
2557 * When Divi 5's shortcode framework loads during the internal HTTP fetch,
2558 * it uses shared counters across all template parts (header + page + footer),
2559 * causing page content element classes to be offset (et_pb_section_0 becomes
2560 * et_pb_section_4, etc.). The cached CSS references 0-based numbering.
2561 *
2562 * Detects the offset per element type and remaps back to 0-based.
2563 *
2564 * @param string $html The processed HTML string
2565 * @return string HTML with corrected Divi class numbering
2566 */
2567 private function fix_divi_class_renumbering($html) {
2568 # Only run on Divi sites
2569 if (!defined('ET_CORE_VERSION')) {
2570 return $html;
2571 }
2572
2573 # Only apply to Divi pages with template builder
2574 if (strpos($html, 'et-l et-l--post') === false) {
2575 return $html;
2576 }
2577
2578 # Locate the page content area (between et-l--post and et-l--footer)
2579 $page_start = strpos($html, 'et-l et-l--post');
2580 if ($page_start === false) {
2581 return $html;
2582 }
2583
2584 $footer_start = strpos($html, 'et-l et-l--footer', $page_start);
2585 if ($footer_start === false) {
2586 $footer_start = strlen($html);
2587 }
2588
2589 $page_content = substr($html, $page_start, $footer_start - $page_start);
2590
2591
2592 # Divi element types that get renumbered by the shortcode framework.
2593 # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
2594 # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
2595 # wp_remote_get includes the Theme Builder header template in Divi's counter.
2596 # If the blog module class doesn't match between page 1 (OTTO-processed) and
2597 # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results (WP-315).
2598 $types = [
2599 'section', 'row', 'column', 'text', 'blurb', 'toggle',
2600 'button', 'image', 'group_carousel',
2601 'blog', 'portfolio', 'filterable_portfolio', 'shop',
2602 'heading', 'divider', 'code', 'icon', 'contact_form_7',
2603 ];
2604
2605 $offsets = [];
2606
2607 foreach ($types as $type) {
2608 # Find the FIRST numbered instance in page content (not _tb_ suffixed)
2609 if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)(?!_tb_)/', $page_content, $m)) {
2610 $first_num = (int) $m[1];
2611 if ($first_num > 0) {
2612 $offsets[$type] = $first_num;
2613 }
2614 }
2615 }
2616
2617 if (empty($offsets)) {
2618 return $html;
2619 }
2620
2621 # Remap each element type back to 0-based numbering
2622 foreach ($offsets as $etype => $offset) {
2623 $escaped = preg_quote($etype, '/');
2624 $html = preg_replace_callback(
2625 '/et_pb_' . $escaped . '_(\d+)(?!_tb_)/',
2626 function ($m) use ($etype, $offset) {
2627 $num = (int) $m[1];
2628 if ($num >= $offset) {
2629 return 'et_pb_' . $etype . '_' . ($num - $offset);
2630 }
2631 return $m[0];
2632 },
2633 $html
2634 );
2635 }
2636
2637 # Remove duplicate JS variable declarations from the shortcode framework
2638 foreach (['et_pb_custom', 'et_frontend_scripts', 'et_builder_utils_params'] as $var_name) {
2639 $needle = 'var ' . $var_name . ' = ';
2640 $first = strpos($html, $needle);
2641 if ($first !== false) {
2642 $second = strpos($html, $needle, $first + strlen($needle));
2643 if ($second !== false) {
2644 $end = strpos($html, ";\n", $second);
2645 if ($end !== false) {
2646 $html = substr($html, 0, $second) . substr($html, $end + 2);
2647 }
2648 }
2649 }
2650 }
2651
2652 # Deduplicate animation data (shortcode framework creates duplicate entries)
2653 if (preg_match('/var diviElementAnimationData = (\[.*?\]);/s', $html, $am)) {
2654 $data = json_decode($am[1], true);
2655 if (is_array($data)) {
2656 $seen = [];
2657 $unique = [];
2658 foreach (array_reverse($data) as $e) {
2659 $k = $e['class'] ?? '';
2660 if ($k !== '' && !isset($seen[$k])) {
2661 $seen[$k] = true;
2662 array_unshift($unique, $e);
2663 }
2664 }
2665 if (count($unique) < count($data)) {
2666 $html = str_replace(
2667 $am[0],
2668 'var diviElementAnimationData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
2669 $html
2670 );
2671 }
2672 }
2673 }
2674
2675 # Deduplicate multiview data
2676 if (preg_match('/var diviElementMultiViewData = (\[.*?\]);/s', $html, $mv)) {
2677 $data = json_decode($mv[1], true);
2678 if (is_array($data)) {
2679 $seen = [];
2680 $unique = [];
2681 foreach (array_reverse($data) as $e) {
2682 $k = ($e['selector'] ?? '') . '|' . ($e['action'] ?? '');
2683 if ($k !== '|' && !isset($seen[$k])) {
2684 $seen[$k] = true;
2685 array_unshift($unique, $e);
2686 }
2687 }
2688 if (count($unique) < count($data)) {
2689 $html = str_replace(
2690 $mv[0],
2691 'var diviElementMultiViewData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
2692 $html
2693 );
2694 }
2695 }
2696 }
2697
2698 return $html;
2699 }
2700
2701
2702 }