PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.12
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.12
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / otto / Otto_html_class.php

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

2,708 lines 109.7 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 (is_object($recommended_value) || is_array($recommended_value)) {
1389 return;
1390 }
1391 if (!is_string($recommended_value)) {
1392 $recommended_value = (string) $recommended_value;
1393 }
1394 if (empty(trim($recommended_value))) {
1395 return;
1396 }
1397
1398 # Check if this is a description meta tag and if custom description exists
1399 # Custom values take absolute priority over OTTO suggestions
1400 $name = $data['name'] ?? false;
1401 $property = $data['property'] ?? false;
1402
1403 # Protect manually-set robots meta from OTTO override
1404 # If the user set noindex via Common Robots Meta or meta_robots, honour it
1405 if (!empty($name) && $name === 'robots') {
1406 if (function_exists('is_singular') && is_singular()) {
1407 $post_id = get_the_ID();
1408 if ($post_id) {
1409 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
1410 if (!empty($manual_robots) && stripos($manual_robots, 'noindex') !== false) {
1411 # Manual noindex takes priority — skip OTTO override
1412 return;
1413 }
1414 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1415 if (is_array($common_robots) && !empty($common_robots['noindex'])) {
1416 # Common Robots Meta has noindex checked — skip OTTO override
1417 return;
1418 }
1419 }
1420 }
1421 }
1422
1423 # Check for custom SEO description
1424 if (!empty($name) && $name === 'description') {
1425 if (function_exists('is_singular') && is_singular()) {
1426 $post_id = get_the_ID();
1427 if ($post_id) {
1428 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1429 if (!empty($custom_seo_description)) {
1430 # Custom description exists, skip OTTO's suggestion
1431 return;
1432 }
1433 }
1434 }
1435 }
1436
1437 # extract property value
1438 $property = $data['property'] ?? false;
1439
1440 # extract name value
1441 $name = $data['name'] ?? false;
1442
1443 # set the selector
1444 $meta_selector = '';
1445
1446 # extend selector
1447 if(!empty($name)){
1448 $meta_selector .= 'meta[name="' . trim($name) . '"]';
1449 }
1450
1451 # extent if property is defined
1452 if(!empty($property)){
1453 if (!empty($meta_selector)) {
1454 $meta_selector .= ',';
1455 }
1456 $meta_selector .= 'meta[property="' . trim($property) . '"]';
1457 }
1458
1459 # find the meta gat in the dom
1460 $meta_tag = $this->dom->find($meta_selector, 0);
1461
1462 # if tag not exists add it
1463 if(empty($meta_tag)){
1464 if($data['type'] == 'meta'){
1465
1466 # get the attribute
1467 $attribute = $property ? 'property' : 'name';
1468
1469 # call the create metatag function
1470 $result = $this->create_metatag($attribute, $data);
1471 }
1472
1473 # return after creation
1474 return;
1475 }
1476
1477 # CRITICAL FIX: Clear cache and get fresh reference
1478 # Preserve 'imgs' key for later use by handle_images()
1479 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1480 $this->cached_elements = [];
1481 if ($preserved_imgs !== null) {
1482 $this->cached_elements['imgs'] = $preserved_imgs;
1483 }
1484
1485 # Get fresh meta tag reference using same selector
1486 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
1487
1488 if ($meta_tag_fresh) {
1489 # Use outertext for replacement
1490 $new_value = htmlspecialchars($recommended_value, ENT_QUOTES, 'UTF-8');
1491
1492 # Determine attribute name
1493 $attr_name = !empty($data['name']) ? 'name' : 'property';
1494 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
1495
1496 # Build new meta tag
1497 $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1498 }
1499
1500
1501 }
1502
1503 # function to handle the page title
1504 function replace_title($title_data){
1505
1506 # find the title
1507 $title = $this->dom->find('title', 0) ?? false;
1508
1509 # if none
1510 if($title === false){
1511 return $this->create_title($title_data);
1512 }
1513
1514 # CRITICAL FIX: Clear element cache and get fresh reference
1515 # Preserve 'imgs' key for later use by handle_images()
1516 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1517 $this->cached_elements = [];
1518 if ($preserved_imgs !== null) {
1519 $this->cached_elements['imgs'] = $preserved_imgs;
1520 }
1521
1522 # Get fresh title element reference
1523 $title_fresh = $this->dom->find('title', 0);
1524
1525 if ($title_fresh) {
1526 # Use outertext for replacement
1527 $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
1528 $title_fresh->outertext = '<title>' . $new_value . '</title>';
1529 }
1530
1531 # Don't save_reload here - will happen at the end
1532 # $this->save_reload();
1533 }
1534
1535 # Function to create a title when it's missing
1536 function create_title($title_data) {
1537
1538 # Find the <head> tag
1539 $head = $this->dom->find('head', 0);
1540
1541 # Construct the <title> tag HTML
1542 $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';
1543
1544 if (empty($head)) {
1545 return false;
1546 }
1547
1548 # Extract existing attributes of the <head> tag
1549 $attributes = [];
1550
1551 # get the tag attributes
1552 $tag_attributes = $head->getAllAttributes();
1553
1554 # loop all attributes
1555 foreach ($tag_attributes as $key => $value) {
1556
1557 if ($value == 1) {
1558
1559 # Handle boolean attributes
1560 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1561 } else {
1562
1563 # Handle attributes with values
1564 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1565 }
1566 }
1567
1568 # Convert attributes array to a string
1569 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1570
1571 # Rebuild the <head> tag, inserting the <title> at the beginning
1572 $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';
1573
1574 # save and reload DOM
1575 $this->save_reload();
1576 }
1577
1578 # function to create meta tag if none existss
1579 function create_metatag($attribute, $data){
1580
1581 # Find the <head> tag
1582 $head = $this->dom->find('head', 0);
1583
1584 # Construct the meta tag HTML (no spaces around = for standard HTML and regex compatibility)
1585 $meta_tag = '<meta '.$attribute.'="'.htmlspecialchars($data[$attribute], ENT_QUOTES, 'UTF-8').'" content="'.htmlspecialchars($data['recommended_value'], ENT_QUOTES, 'UTF-8').'">';
1586
1587 if (empty($head)) {
1588 return false;
1589 }
1590
1591 # Extract existing attributes of the <head> tag
1592 $attributes = [];
1593
1594 # get the tag attributes
1595 $tag_attributes = $head->getAllAttributes();
1596
1597 # loop all attributes
1598 foreach ($tag_attributes as $key => $value) {
1599
1600 if ($value == 1) {
1601
1602 # Handle boolean attributes
1603 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1604 } else {
1605
1606 # Handle attributes with values
1607 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1608 }
1609 }
1610
1611 # Convert attributes array to a string
1612 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1613
1614 # Rebuild the <head> tag, inserting the <title> at the beginning
1615 $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';
1616
1617 # save and reload DOM
1618 $this->save_reload();
1619 }
1620
1621 /**
1622 * Remove duplicate <title> tags from HTML, keeping the first (OTTO's) value.
1623 *
1624 * OTTO's title replacement is always applied first (limit=1 or DOM manipulation),
1625 * so the first <title> tag holds the authoritative value. If SEO plugin conflicts
1626 * produce additional <title> tags, this strips all and re-inserts one.
1627 *
1628 * @param string $html Full HTML document.
1629 * @return string HTML with at most one <title> tag.
1630 */
1631 private function deduplicate_title_tags($html) {
1632 $title_count = preg_match_all('/<title[^>]*>.*?<\/title>/is', $html, $title_matches);
1633 if ($title_count <= 1) {
1634 return $html;
1635 }
1636
1637 # Capture the first title's inner text (OTTO's replacement)
1638 preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $first_title);
1639 $authoritative_title = isset($first_title[1]) ? $first_title[1] : '';
1640
1641 # Strip all <title> tags
1642 $html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html);
1643
1644 # Re-insert a single <title> after <head> using preg_replace_callback to prevent
1645 # backreference injection when title contains $ followed by digits (e.g. "$50 off")
1646 $title_tag = '<title>' . $authoritative_title . '</title>';
1647 $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1648 return $m[1] . $title_tag;
1649 }, $html, 1);
1650
1651 return $html;
1652 }
1653
1654 /**
1655 * Remove duplicate OG and Twitter meta tags from HTML after OTTO processing.
1656 *
1657 * OTTO injects its tags with `data-otto-pixel` or `data-otto` attributes.
1658 * Legacy MetaSync output and third-party SEO plugins may also emit the same
1659 * OG/Twitter properties. This method runs at the buffer level — after all
1660 * sources have written their tags — and keeps only the OTTO version when
1661 * duplicates exist.
1662 *
1663 * Strategy per property (e.g. og:description):
1664 * - If OTTO tag exists (has data-otto marker) → remove all non-OTTO duplicates
1665 * - If no OTTO tag exists → keep the first occurrence, remove the rest
1666 *
1667 * @param string $html Full HTML document.
1668 * @return string HTML with at most one tag per OG/Twitter property.
1669 */
1670 private function deduplicate_og_twitter_tags($html) {
1671 # OG properties to deduplicate
1672 $og_properties = [
1673 'og:title', 'og:description', 'og:url', 'og:type',
1674 'og:locale', 'og:site_name', 'og:image',
1675 ];
1676
1677 foreach ($og_properties as $prop) {
1678 $html = $this->deduplicate_meta_by_attr($html, 'property', $prop);
1679 }
1680
1681 # Twitter names to deduplicate
1682 $twitter_names = [
1683 'twitter:title', 'twitter:description', 'twitter:card',
1684 'twitter:image', 'twitter:site',
1685 ];
1686
1687 foreach ($twitter_names as $name) {
1688 $html = $this->deduplicate_meta_by_attr($html, 'name', $name);
1689 }
1690
1691 return $html;
1692 }
1693
1694 /**
1695 * Deduplicate meta tags by a specific attribute (property= or name=).
1696 *
1697 * When duplicates exist and one carries a data-otto marker, keep only
1698 * the OTTO version. Otherwise keep the first occurrence.
1699 *
1700 * @param string $html Full HTML.
1701 * @param string $attr_name Attribute name: 'property' or 'name'.
1702 * @param string $attr_val Attribute value: e.g. 'og:title' or 'twitter:description'.
1703 * @return string
1704 */
1705 private function deduplicate_meta_by_attr($html, $attr_name, $attr_val) {
1706 $escaped = preg_quote($attr_val, '/');
1707 # Match all <meta> tags with this attribute value (both attr orderings)
1708 $pattern = '/<meta\s[^>]*' . preg_quote($attr_name, '/') . '\s*=\s*["\']' . $escaped . '["\'][^>]*\/?>/i';
1709
1710 if (preg_match_all($pattern, $html, $matches) <= 1) {
1711 return $html; # 0 or 1 — nothing to deduplicate
1712 }
1713
1714 $all_tags = $matches[0];
1715
1716 # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
1717 $otto_tag = null;
1718 foreach ($all_tags as $tag) {
1719 if (stripos($tag, 'data-otto') !== false) {
1720 $otto_tag = $tag;
1721 break;
1722 }
1723 }
1724
1725 # Determine the keeper: OTTO tag if present, otherwise the first tag
1726 $keeper = $otto_tag ?: $all_tags[0];
1727
1728 # Remove all occurrences, then re-insert the keeper at the first position
1729 $first_replaced = false;
1730 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1731 if (!$first_replaced) {
1732 $first_replaced = true;
1733 return $keeper;
1734 }
1735 return ''; # Remove subsequent duplicates
1736 }, $html);
1737
1738 return $html;
1739 }
1740
1741 /**
1742 * Remove duplicate <link rel="canonical"> tags from HTML.
1743 *
1744 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1745 * SEO output (or WordPress core) has already emitted one, keep only the
1746 * OTTO version (identified by data-otto marker). If no OTTO tag exists,
1747 * keep the first occurrence.
1748 *
1749 * @param string $html Full HTML document.
1750 * @return string HTML with at most one canonical tag.
1751 */
1752 private function deduplicate_canonical_tags($html) {
1753 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
1754
1755 if (preg_match_all($pattern, $html, $matches) <= 1) {
1756 return $html;
1757 }
1758
1759 $all_tags = $matches[0];
1760
1761 $otto_tag = null;
1762 foreach ($all_tags as $tag) {
1763 if (stripos($tag, 'data-otto') !== false) {
1764 $otto_tag = $tag;
1765 break;
1766 }
1767 }
1768
1769 $keeper = $otto_tag ?: $all_tags[0];
1770
1771 $first_replaced = false;
1772 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1773 if (!$first_replaced) {
1774 $first_replaced = true;
1775 return $keeper;
1776 }
1777 return '';
1778 }, $html);
1779
1780 return $html;
1781 }
1782
1783 /**
1784 * Deduplicate JSON-LD schema blocks.
1785 *
1786 * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1787 * blocks, keep OTTO's version for any @type that appears in both.
1788 * Third-party blocks whose @type is not covered by OTTO are preserved.
1789 *
1790 * @param string $html Full HTML.
1791 * @return string
1792 */
1793 private function deduplicate_schema_tags($html) {
1794 // Find all JSON-LD script blocks
1795 $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1796 if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
1797 return $html;
1798 }
1799
1800 $otto_by_type = []; // @type => decoded JSON object
1801 $third_by_type = []; // @type => decoded JSON object
1802 $otto_graph = []; // entries from OTTO @graph blocks
1803 $third_graph = []; // entries from third-party @graph blocks
1804
1805 foreach ($matches as $m) {
1806 $attrs = $m[1];
1807 $json_str = $m[3];
1808 $decoded = json_decode($json_str, true);
1809 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1810 continue; // skip unparseable blocks — leave them in place
1811 }
1812 $is_otto = stripos($attrs, 'data-otto') !== false;
1813
1814 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1815 foreach ($decoded['@graph'] as $entry) {
1816 if (!isset($entry['@type'])) continue;
1817 // JSON-LD allows @type to be a string OR an array of strings.
1818 // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1819 // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1820 $type = is_array($entry['@type'])
1821 ? (string) reset($entry['@type'])
1822 : (string) $entry['@type'];
1823 if ($type === '') continue;
1824 if ($is_otto) {
1825 $otto_graph[$type] = $entry;
1826 } else {
1827 $third_graph[$type] = $entry;
1828 }
1829 }
1830 } elseif (isset($decoded['@type'])) {
1831 $type = is_array($decoded['@type'])
1832 ? (string) reset($decoded['@type'])
1833 : (string) $decoded['@type'];
1834 if ($type === '') continue;
1835 if ($is_otto) {
1836 $otto_by_type[$type] = $decoded;
1837 } else {
1838 $third_by_type[$type] = $decoded;
1839 }
1840 }
1841 }
1842
1843 // If OTTO provided no schema at all, nothing to deduplicate
1844 if (empty($otto_by_type) && empty($otto_graph)) {
1845 return $html;
1846 }
1847
1848 // Remove all JSON-LD blocks from HTML
1849 $html = preg_replace($pattern, '', $html);
1850
1851 // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
1852 $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
1853 $rebuilt = '';
1854 foreach ($kept as $decoded) {
1855 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1856 wp_json_encode($decoded) . "</script>\n";
1857 }
1858
1859 // Re-insert merged @graph block (if any entries exist)
1860 $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
1861 if (!empty($merged_graph)) {
1862 $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
1863 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1864 wp_json_encode($graph_obj) . "</script>\n";
1865 }
1866
1867 // Re-inject before </head>
1868 if (!empty($rebuilt)) {
1869 $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
1870 return $rebuilt . $m[1];
1871 }, $html, 1);
1872 }
1873
1874 return $html;
1875 }
1876
1877 # function to detect if current page is an AMP page
1878 function is_amp_page(){
1879
1880 # Check if URL path contains /amp/
1881 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1882 if (strpos($current_url, '/amp/') !== false) {
1883 return true;
1884 }
1885
1886 # Check if URL ends with /amp
1887 if (preg_match('/\/amp\/?$/', $current_url)) {
1888 return true;
1889 }
1890
1891 # Check if amp=1 query parameter is present
1892 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
1893 return true;
1894 }
1895
1896 # Check for other common AMP query parameters
1897 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
1898 return true;
1899 }
1900
1901 return false;
1902 }
1903
1904 # this function insterts header html to the dom
1905 function insert_header_html($data){
1906
1907 # check that we have the header html
1908 if(empty($data['header_html_insertion'])){
1909 #
1910 return;
1911 }
1912
1913 # append the/ html at the start of the header
1914 $head = $this->dom->find('head', 0);
1915
1916 if ($head) {
1917
1918 # Check if this is an AMP page - if so, don't add metasync_optimized attribute
1919 $is_amp_page = $this->is_amp_page();
1920
1921 # Append the new HTML at the start of the <head> tag
1922 # For AMP pages: use clean <head> tag without metasync_optimized attribute
1923 # For non-AMP pages: add metasync_optimized attribute to <head> tag
1924 if ($is_amp_page) {
1925 $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1926 } else {
1927 $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1928 }
1929
1930 }
1931
1932 # save and reload DOM
1933 $this->save_reload();
1934 }
1935
1936 # function to forcefully remove metasync_optimized attribute from head on AMP pages
1937 function cleanup_amp_metasync_attribute(){
1938
1939 # Only proceed if this is an AMP page
1940 if (!$this->is_amp_page()) {
1941 return;
1942 }
1943
1944 # Find the head tag
1945 $head = $this->dom->find('head', 0);
1946
1947 if (!$head) {
1948 return;
1949 }
1950
1951 # Check if head has metasync_optimized attribute
1952 $head_html = $head->outertext;
1953
1954 # If metasync_optimized attribute is found, remove it
1955 if (strpos($head_html, 'metasync_optimized') !== false) {
1956
1957 # Remove the metasync_optimized attribute from the head tag
1958 # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
1959 $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
1960
1961 # Update the head element
1962 $head->outertext = $cleaned_head_html;
1963
1964 }
1965 }
1966
1967 /**
1968 * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1969 * Reduces 8-10 full DOM traversals to 1 initial traversal
1970 * Call this once after loading HTML, before processing
1971 */
1972 private function cache_elements() {
1973 if (!$this->dom) {
1974 return;
1975 }
1976
1977 # Cache all commonly accessed elements in one pass
1978 $this->cached_elements = [
1979 'html' => $this->dom->find('html', 0),
1980 'head' => $this->dom->find('head', 0),
1981 'body' => $this->dom->find('body', 0),
1982 'footer' => $this->dom->find('footer', 0),
1983 'title' => $this->dom->find('title', 0),
1984 'imgs' => $this->dom->find('img'),
1985 'links' => $this->dom->find('a'),
1986 'canonical' => $this->dom->find('link[rel="canonical"]', 0),
1987 ];
1988 }
1989
1990 /**
1991 * Get cached element by key, with fallback to DOM find
1992 * @param string $key Element key from cache
1993 * @param mixed $fallback Fallback value if not cached
1994 * @return mixed Cached element or fallback
1995 */
1996 private function get_cached_element($key, $fallback = null) {
1997 return $this->cached_elements[$key] ?? $fallback;
1998 }
1999
2000 # this function saves are reloads the dom for modifications to avoid conflict
2001 function save_reload(){
2002
2003 # PERFORMANCE OPTIMIZATION: Skip reload if deferred
2004 # This reduces multiple serialize/deserialize cycles to just one final reload
2005 if ($this->deferred_reload) {
2006 return;
2007 }
2008
2009 # Cleanup metasync_optimized attribute on AMP pages before saving
2010 $this->cleanup_amp_metasync_attribute();
2011
2012 # DISABLED: Cache file creation temporarily disabled
2013
2014 # if(file_put_contents($this->html_file, $this->dom)){
2015
2016 # load the modified file to the DOM
2017 # $this->dom = new HtmlDocument($this->html_file );
2018 # }
2019
2020 # this code is to be replaced in future
2021 # reson for adding is to prevent caching logged in user pages
2022 # why not just skip saving? it broke the DOM Library
2023 # check user is logged in clear the file
2024
2025 # if(is_user_logged_in()) {
2026 # unlink($this->html_file);
2027 # }
2028
2029 # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
2030 # This prevents DOM breaking while avoiding cache file creation
2031 if($this->dom){
2032 # Get current DOM as HTML string
2033 $current_html = $this->dom->save();
2034
2035 # Reload DOM from the HTML string to refresh internal state
2036 # This replaces the file save/reload cycle that SimpleHtmlDOM expects
2037 $current_html = $this->sanitize_text_less_than($current_html);
2038 $this->dom->load($current_html, true, false);
2039
2040 # Force UTF-8 charset after reload to preserve emojis
2041 $this->dom->_charset = 'UTF-8';
2042 $this->dom->_target_charset = 'UTF-8';
2043 }
2044
2045 }
2046
2047 /**
2048 * Process HTML directly without HTTP request (for buffer approach)
2049 * This is the FAST path - eliminates the internal wp_remote_get call
2050 *
2051 * CRITICAL: This method is called from the output buffer callback.
2052 * If it fails, we must return false so the original HTML can be used.
2053 *
2054 * @param string $html The raw HTML captured from output buffer
2055 * @param array $replacement_data OTTO suggestions/replacement data
2056 * @return HtmlDocument|false Modified DOM or false on failure
2057 * @since 2.6.0
2058 */
2059 function process_html_directly($html, $replacement_data) {
2060 try {
2061 # Validate inputs
2062 if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
2063 return false;
2064 }
2065
2066 # Validate HTML is actual HTML content
2067 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
2068 return false;
2069 }
2070
2071 # Remove XML declaration if present
2072 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
2073
2074 # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
2075 $original_style_blocks = $this->capture_style_blocks($html);
2076
2077 # Escape bare < in text content before DOM parsing
2078 $html = $this->sanitize_text_less_than($html);
2079
2080 # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
2081 $html = $this->fix_malformed_self_closing_tags($html);
2082
2083 # Load HTML into DOM
2084 $this->dom->load($html, true, false);
2085
2086 # Force UTF-8 charset to preserve emojis and special characters
2087 $this->dom->_charset = 'UTF-8';
2088 $this->dom->_target_charset = 'UTF-8';
2089
2090 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
2091 # This eliminates 8-10 full DOM traversals per page
2092 $this->cache_elements();
2093
2094 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
2095 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
2096 $this->deferred_reload = true;
2097
2098 # Apply blocking flags if available (for SEO plugin coordination)
2099 if (!empty($replacement_data['_otto_blocking'])) {
2100 $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
2101 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
2102
2103 # Remove SEO plugin meta tags if OTTO is providing them
2104 if ($block_title || !empty($block_description_tags)) {
2105 $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
2106 }
2107 }
2108
2109 # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)
2110
2111 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
2112 # Some API versions return 'value' instead of 'recommended_value'
2113 if (!empty($replacement_data['header_replacements'])) {
2114 foreach ($replacement_data['header_replacements'] as &$item) {
2115 if (isset($item['value']) && !isset($item['recommended_value'])) {
2116 $item['recommended_value'] = $item['value'];
2117 }
2118 }
2119 unset($item); // Break reference
2120 }
2121
2122 # 1. Header HTML insertion
2123 $this->insert_header_html($replacement_data);
2124
2125 # 2. Header replacements (title, meta, canonical)
2126 $this->do_header_replacements($replacement_data);
2127
2128 # 3. Body replacements (top, bottom, substitutions)
2129 $this->do_body_replacements($replacement_data);
2130
2131 # 4. Footer HTML insertion
2132 $this->do_footer_html_insertion($replacement_data);
2133
2134 # 5. Final cleanup for AMP pages
2135 $this->cleanup_amp_metasync_attribute();
2136
2137 # CRITICAL FIX: Clear cached elements and force DOM to refresh
2138 $this->deferred_reload = false;
2139 $this->cached_elements = []; // Clear our custom cache
2140
2141 # Clear SimpleHtmlDom's internal cache
2142 if (method_exists($this->dom, 'clear')) {
2143 $this->dom->clear();
2144 }
2145
2146
2147 # Try getting HTML via root element instead of save()
2148 $root = $this->dom->root;
2149 if ($root && isset($root->outertext)) {
2150 $result_html = $root->outertext;
2151 } else {
2152 # Fallback to save() method
2153 $result_html = $this->dom->save();
2154 }
2155
2156 # DEBUG: Check if DOM changes persisted
2157 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2158 }
2159
2160 # Apply header replacements manually via string replacement
2161 if (!empty($replacement_data['header_replacements'])) {
2162
2163 foreach ($replacement_data['header_replacements'] as $idx => $item) {
2164 $type = $item['type'] ?? '';
2165 $value = $item['recommended_value'] ?? $item['value'] ?? '';
2166
2167
2168 if (empty($value)) {
2169 continue;
2170 }
2171
2172 if ($type === 'title') {
2173 # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
2174 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2175 $title_tag = '<title>' . $new_value . '</title>';
2176 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2177 return $title_tag;
2178 }, $result_html, 1);
2179 if ($replaced === $result_html && strpos($result_html, '<title') === false) {
2180 # No <title> in document (common when Yoast is blocked) — insert after <head>
2181 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
2182 return $m[1] . "\n" . $title_tag;
2183 }, $result_html, 1);
2184 } else {
2185 $result_html = $replaced;
2186 }
2187 } elseif ($type === 'meta') {
2188 $name = $item['name'] ?? '';
2189 $property = $item['property'] ?? '';
2190 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2191
2192 if (!empty($name)) {
2193 # MEMORY OPTIMIZED: Count meta tags without storing all matches
2194 # preg_match_all requires $matches, so we pass it but unset immediately
2195 # Use \s*=\s* to match attributes with or without spaces around =
2196 $before_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);
2197
2198 # Free memory immediately after use
2199 unset($before_matches);
2200
2201 # IMPROVED FIX: Remove ALL existing meta tags with this name
2202 # This pattern matches ANY meta tag with name="X" regardless of:
2203 # - Attribute order (name before/after content)
2204 # - Additional attributes (id, class, data-*, etc.)
2205 # - Quote style (single or double quotes)
2206 # - Whitespace variations
2207 # The pattern uses [^>]* to match ANY characters until the closing >
2208 $removed_count = preg_replace_callback(
2209 '/<meta\s+[^>]*name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
2210 function($match) {
2211 return ''; // Remove the tag
2212 },
2213 $result_html,
2214 -1, // Remove all occurrences
2215 $total_removed
2216 );
2217
2218 # Update the HTML with removed tags
2219 if ($total_removed > 0) {
2220 $result_html = $removed_count;
2221 }
2222
2223
2224 # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
2225 $after_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
2226 unset($after_matches);
2227
2228 # Now insert ONE new meta tag at the TOP of <head>
2229 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
2230 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
2231 return $m[1] . "\n" . $replacement;
2232 }, $result_html, 1);
2233 } elseif (!empty($property)) {
2234 # Replace meta property tag
2235 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
2236 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
2237
2238 if (preg_match($pattern, $result_html)) {
2239 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
2240 return $replacement;
2241 }, $result_html, 1);
2242 } else {
2243 # Meta tag doesn't exist, insert it in head
2244 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
2245 return $m[1] . "\n" . $replacement;
2246 }, $result_html, 1);
2247 }
2248 }
2249 } elseif ($type === 'h1' || $type === 'heading') {
2250 # Replace first H1 tag, preserving attributes
2251 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2252 $result_html = preg_replace_callback(
2253 '/<h1([^>]*)>.*?<\/h1>/is',
2254 function ($m) use ($new_value) {
2255 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
2256 },
2257 $result_html,
2258 1
2259 );
2260 }
2261 }
2262 }
2263
2264 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
2265 if (!empty($replacement_data['header_html_insertion'])) {
2266 $header_html_check = trim($replacement_data['header_html_insertion']);
2267 if (strpos($result_html, $header_html_check) === false) {
2268 $header_html_insertion = preg_replace(
2269 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
2270 '<script$1type=$2application/ld+json$2 data-otto="true"',
2271 $replacement_data['header_html_insertion']
2272 );
2273 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
2274 # Insert before </head>
2275 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
2276 }
2277 }
2278
2279 # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
2280 if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
2281 $result_html = preg_replace(
2282 '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
2283 '',
2284 $result_html
2285 );
2286 $first = true;
2287 $result_html = preg_replace_callback(
2288 '/<title[^>]*>.*?<\/title>\s*/is',
2289 function ($m) use (&$first) {
2290 if ($first) {
2291 $first = false;
2292 return $m[0];
2293 }
2294 return '';
2295 },
2296 $result_html
2297 );
2298 }
2299
2300 # Apply body top HTML insertion — only if DOM insertion didn't already apply it
2301 if (!empty($replacement_data['body_top_html_insertion'])) {
2302 $body_top_check = trim($replacement_data['body_top_html_insertion']);
2303 if (strpos($result_html, $body_top_check) === false) {
2304 $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
2305 # Insert after <body>
2306 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
2307 }
2308 }
2309
2310 # Apply body bottom HTML insertion — only if DOM insertion didn't already apply it
2311 if (!empty($replacement_data['body_bottom_html_insertion'])) {
2312 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
2313 if (strpos($result_html, $body_bottom_check) === false) {
2314 $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
2315 # Insert before </body>
2316 $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
2317 }
2318 }
2319
2320 # Apply footer HTML insertion
2321 if (!empty($replacement_data['footer_html_insertion'])) {
2322 $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
2323 # Insert before </html>
2324 $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
2325 }
2326
2327 # CRITICAL FIX: Apply image alt text manually via string replacement
2328 # DOM changes don't persist, must use string replacement
2329 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
2330
2331 # String-based heading fallback for body_substitutions
2332 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2333 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
2334 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
2335 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
2336 continue;
2337 }
2338
2339 $heading_type = preg_quote($heading['type'], '/');
2340 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
2341 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
2342
2343 $result_html = preg_replace_callback(
2344 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2345 function ($m) use ($current_value, $recommended_value) {
2346 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2347 if ($inner_text === $current_value) {
2348 return $m[1] . $recommended_value . $m[3];
2349 }
2350 return $m[0];
2351 },
2352 $result_html,
2353 -1
2354 );
2355 }
2356 }
2357
2358 # DEBUG: Check what we're returning
2359 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2360 }
2361
2362 # FINAL VERIFICATION: Count meta descriptions in returned HTML
2363 $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
2364 if ($final_meta_count > 0) {
2365 foreach ($final_meta_matches[0] as $idx => $meta) {
2366 }
2367 }
2368
2369 if ($final_meta_count > 1) {
2370 }
2371
2372 # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
2373 # Only runs when OTTO has actually inserted its own description (data-otto marker present)
2374 # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace
2375
2376 $removal_count = 0;
2377 $otto_has_description = (bool) preg_match('/<meta[^>]*name\s*=\s*["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
2378 if ($otto_has_description) {
2379 $result_html = preg_replace_callback(
2380 '/<meta\s+([^>]*name\s*=\s*["\']description["\'][^>]*)>/i',
2381 function($match) use (&$removal_count) {
2382 # Keep only if it has data-otto="true"
2383 if (stripos($match[1], 'data-otto') !== false) {
2384 return $match[0]; // Keep OTTO's meta tag
2385 }
2386 # Remove any other meta description
2387 $removal_count++;
2388 return '';
2389 },
2390 $result_html
2391 );
2392 }
2393
2394 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2395 $result_html = $this->deduplicate_title_tags($result_html);
2396 $result_html = $this->deduplicate_og_twitter_tags($result_html);
2397 $result_html = $this->deduplicate_schema_tags($result_html);
2398 $result_html = $this->deduplicate_canonical_tags($result_html);
2399
2400 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2401 # This ensures memory is released immediately, especially important for high-traffic sites
2402 unset($final_meta_matches, $matches);
2403
2404 # Clear SimpleHtmlDom internal cache to free memory
2405 # Note: We don't unset $this->dom as the object may be reused
2406 if ($this->dom && method_exists($this->dom, 'clear')) {
2407 $this->dom->clear();
2408 }
2409
2410 # Clear element cache array
2411 $this->cached_elements = [];
2412
2413 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2414 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2415 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
2416 }
2417
2418 $result_html = $this->restore_case_sensitive_attributes($result_html);
2419
2420 # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
2421 $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
2422
2423 # WP-315: Fix Divi 5 shortcode framework class renumbering
2424 $result_html = $this->fix_divi_class_renumbering($result_html);
2425
2426 # WP-315: Clean OTTO internal fetch params from HTML output
2427 $result_html = $this->clean_otto_fetch_params($result_html);
2428
2429 return $result_html;
2430
2431 } catch (Exception $e) {
2432 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
2433 return false;
2434 } catch (Error $e) {
2435 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
2436 return false;
2437 }
2438 }
2439
2440 /**
2441 * Remove conflicting SEO plugin meta tags from DOM
2442 * Called when OTTO is providing its own title/description
2443 *
2444 * @param bool $remove_title Remove title-related tags
2445 * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
2446 * @since 2.6.0
2447 */
2448 private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
2449 if (!$this->dom) {
2450 return;
2451 }
2452
2453 # Find head element
2454 $head = $this->dom->find('head', 0);
2455 if (!$head) {
2456 return;
2457 }
2458
2459 # Check for custom SEO values from MetaSync SEO Sidebar
2460 # Custom values take absolute priority over OTTO suggestions
2461 $has_custom_title = false;
2462 $has_custom_description = false;
2463
2464 if (function_exists('is_singular') && is_singular()) {
2465 $post_id = get_the_ID();
2466 if ($post_id) {
2467 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
2468 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
2469 $has_custom_title = !empty($custom_seo_title);
2470 $has_custom_description = !empty($custom_seo_description);
2471 }
2472 }
2473
2474 # Handle description tags - only remove specific tags that Otto is providing
2475 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
2476 # Remove only the specific description tags that Otto is providing
2477 foreach ($remove_description_tags as $selector) {
2478 $tags = $this->dom->find($selector);
2479 foreach ($tags as $tag) {
2480 # Remove the tag
2481 $tag->outertext = '';
2482 }
2483 }
2484 }
2485
2486 if ($remove_title && !$has_custom_title) {
2487 # Remove Open Graph and Twitter title tags (keep main <title>)
2488 $title_selectors = [
2489 'meta[property=og:title]',
2490 'meta[name=twitter:title]',
2491 ];
2492
2493 foreach ($title_selectors as $selector) {
2494 $tags = $this->dom->find($selector);
2495 foreach ($tags as $tag) {
2496 $tag->outertext = ''; # Remove the tag
2497 }
2498 }
2499 }
2500
2501 # Save changes
2502 $this->save_reload();
2503 }
2504
2505 /**
2506 * Clean OTTO internal fetch query parameters from rendered HTML (WP-315).
2507 *
2508 * @param string $html The rendered HTML
2509 * @return string HTML with OTTO fetch params removed
2510 */
2511 private function clean_otto_fetch_params($html) {
2512 $patterns = [
2513 '/[?&]is_otto_page_fetch=1/',
2514 '/[?&]otto_block_title=1/',
2515 '/[?&]otto_block_desc=1/',
2516 '/[?&]amp;is_otto_page_fetch=1/',
2517 '/[?&]amp;otto_block_title=1/',
2518 '/[?&]amp;otto_block_desc=1/',
2519 ];
2520 $html = preg_replace($patterns, '', $html);
2521
2522 # WP-398: Protect <script>/<style> bodies before the URL cleanup below.
2523 # The href/src/action matcher can otherwise match JS that looks like an
2524 # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the
2525 # ?? inside it. Swap each block for a placeholder comment, run the cleanup,
2526 # then restore byte-for-byte.
2527 $protected = [];
2528 $html = preg_replace_callback(
2529 '/<(script|style)(\b[^>]*)>([\s\S]*?)<\/\1>/i',
2530 function ($m) use (&$protected) {
2531 $key = '<!--METASYNC_FETCHCLEAN_' . count($protected) . '-->';
2532 $protected[$key] = $m[0];
2533 return $key;
2534 },
2535 $html
2536 );
2537
2538 # WP-398: Normalize leftover query-string separators (?? / ?& / ?#) created
2539 # by the param removal above — but ONLY inside URL attributes (href/src/action).
2540 # The previous implementation ran str_replace('??','?') over the ENTIRE document,
2541 # which corrupted inline JavaScript nullish operators (?? / ??=) and any literal
2542 # "??" in page text — breaking Slider Revolution and other modern inline scripts.
2543 $html = preg_replace_callback(
2544 '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is',
2545 function ($m) {
2546 $url = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $m[4]);
2547 return $m[1] . $m[2] . $m[3] . $url . $m[3];
2548 },
2549 $html
2550 );
2551
2552 # Restore protected <script>/<style> blocks byte-for-byte.
2553 if (!empty($protected)) {
2554 $html = str_replace(array_keys($protected), array_values($protected), $html);
2555 }
2556
2557 return $html;
2558 }
2559
2560 /**
2561 * Fix Divi 5 shortcode framework class renumbering (WP-315)
2562 *
2563 * When Divi 5's shortcode framework loads during the internal HTTP fetch,
2564 * it uses shared counters across all template parts (header + page + footer),
2565 * causing page content element classes to be offset (et_pb_section_0 becomes
2566 * et_pb_section_4, etc.). The cached CSS references 0-based numbering.
2567 *
2568 * Detects the offset per element type and remaps back to 0-based.
2569 *
2570 * @param string $html The processed HTML string
2571 * @return string HTML with corrected Divi class numbering
2572 */
2573 private function fix_divi_class_renumbering($html) {
2574 # Only run on Divi sites
2575 if (!defined('ET_CORE_VERSION')) {
2576 return $html;
2577 }
2578
2579 # Only apply to Divi pages with template builder
2580 if (strpos($html, 'et-l et-l--post') === false) {
2581 return $html;
2582 }
2583
2584 # Locate the page content area (between et-l--post and et-l--footer)
2585 $page_start = strpos($html, 'et-l et-l--post');
2586 if ($page_start === false) {
2587 return $html;
2588 }
2589
2590 $footer_start = strpos($html, 'et-l et-l--footer', $page_start);
2591 if ($footer_start === false) {
2592 $footer_start = strlen($html);
2593 }
2594
2595 $page_content = substr($html, $page_start, $footer_start - $page_start);
2596
2597
2598 # Divi element types that get renumbered by the shortcode framework.
2599 # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
2600 # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
2601 # wp_remote_get includes the Theme Builder header template in Divi's counter.
2602 # If the blog module class doesn't match between page 1 (OTTO-processed) and
2603 # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results (WP-315).
2604 $types = [
2605 'section', 'row', 'column', 'text', 'blurb', 'toggle',
2606 'button', 'image', 'group_carousel',
2607 'blog', 'portfolio', 'filterable_portfolio', 'shop',
2608 'heading', 'divider', 'code', 'icon', 'contact_form_7',
2609 ];
2610
2611 $offsets = [];
2612
2613 foreach ($types as $type) {
2614 # Find the FIRST numbered instance in page content (not _tb_ suffixed)
2615 if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)(?!_tb_)/', $page_content, $m)) {
2616 $first_num = (int) $m[1];
2617 if ($first_num > 0) {
2618 $offsets[$type] = $first_num;
2619 }
2620 }
2621 }
2622
2623 if (empty($offsets)) {
2624 return $html;
2625 }
2626
2627 # Remap each element type back to 0-based numbering
2628 foreach ($offsets as $etype => $offset) {
2629 $escaped = preg_quote($etype, '/');
2630 $html = preg_replace_callback(
2631 '/et_pb_' . $escaped . '_(\d+)(?!_tb_)/',
2632 function ($m) use ($etype, $offset) {
2633 $num = (int) $m[1];
2634 if ($num >= $offset) {
2635 return 'et_pb_' . $etype . '_' . ($num - $offset);
2636 }
2637 return $m[0];
2638 },
2639 $html
2640 );
2641 }
2642
2643 # Remove duplicate JS variable declarations from the shortcode framework
2644 foreach (['et_pb_custom', 'et_frontend_scripts', 'et_builder_utils_params'] as $var_name) {
2645 $needle = 'var ' . $var_name . ' = ';
2646 $first = strpos($html, $needle);
2647 if ($first !== false) {
2648 $second = strpos($html, $needle, $first + strlen($needle));
2649 if ($second !== false) {
2650 $end = strpos($html, ";\n", $second);
2651 if ($end !== false) {
2652 $html = substr($html, 0, $second) . substr($html, $end + 2);
2653 }
2654 }
2655 }
2656 }
2657
2658 # Deduplicate animation data (shortcode framework creates duplicate entries)
2659 if (preg_match('/var diviElementAnimationData = (\[.*?\]);/s', $html, $am)) {
2660 $data = json_decode($am[1], true);
2661 if (is_array($data)) {
2662 $seen = [];
2663 $unique = [];
2664 foreach (array_reverse($data) as $e) {
2665 $k = $e['class'] ?? '';
2666 if ($k !== '' && !isset($seen[$k])) {
2667 $seen[$k] = true;
2668 array_unshift($unique, $e);
2669 }
2670 }
2671 if (count($unique) < count($data)) {
2672 $html = str_replace(
2673 $am[0],
2674 'var diviElementAnimationData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
2675 $html
2676 );
2677 }
2678 }
2679 }
2680
2681 # Deduplicate multiview data
2682 if (preg_match('/var diviElementMultiViewData = (\[.*?\]);/s', $html, $mv)) {
2683 $data = json_decode($mv[1], true);
2684 if (is_array($data)) {
2685 $seen = [];
2686 $unique = [];
2687 foreach (array_reverse($data) as $e) {
2688 $k = ($e['selector'] ?? '') . '|' . ($e['action'] ?? '');
2689 if ($k !== '|' && !isset($seen[$k])) {
2690 $seen[$k] = true;
2691 array_unshift($unique, $e);
2692 }
2693 }
2694 if (count($unique) < count($data)) {
2695 $html = str_replace(
2696 $mv[0],
2697 'var diviElementMultiViewData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
2698 $html
2699 );
2700 }
2701 }
2702 }
2703
2704 return $html;
2705 }
2706
2707
2708 }