PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
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.10, at otto/Otto_html_class.php

2,527 lines 102.4 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 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
781 # Single pass with hash map lookup instead of nested loop
782 foreach($images AS $key => $image){
783 # Get image src
784 $image_src = $image->src;
785
786 if (empty($image_src)) {
787 continue;
788 }
789
790 # Hash map lookup O(1) instead of loop O(n)
791 if (isset($image_data[$image_src])) {
792 # Set alt text - Note: This may not persist in all cases
793 # Manual string replacement in process_html_directly() ensures it's applied
794 $new_alt = htmlspecialchars($image_data[$image_src], ENT_QUOTES, 'UTF-8');
795
796 # Get current img tag HTML and update alt attribute
797 $current_html = $image->outertext;
798
799 # Remove existing alt attribute (if any)
800 $updated_html = preg_replace('/\s+alt=(["\'])[^"\']*\1/', '', $current_html);
801
802 # Insert new alt attribute after the opening <img
803 $updated_html = preg_replace('/^<img\s/', '<img alt="' . str_replace('$', '\\$', $new_alt) . '" ', $updated_html);
804
805 # Update the element
806 $image->outertext = $updated_html;
807
808 $multi_view_attr = $image->getAttribute('data-et-multi-view');
809 if (!empty($multi_view_attr)) {
810 $this->update_divi_multi_view_alt($image, $image_data[$image_src]);
811 }
812 }
813 }
814 }
815
816 /**
817 * Update Divi's multi-view data attribute with alt text
818 * Divi stores image attributes in a JSON structure within data-et-multi-view
819 *
820 * @param object $image The image DOM element
821 * @param string $alt_text The alt text to set
822 */
823 private function update_divi_multi_view_alt($image, $alt_text) {
824 $multi_view_attr = $image->getAttribute('data-et-multi-view');
825
826 if (!empty($multi_view_attr)) {
827 try {
828 # Decode the JSON
829 $multi_view_data = json_decode($multi_view_attr, true);
830
831 if ($multi_view_data && isset($multi_view_data['schema']['attrs'])) {
832 # Update alt in desktop view
833 if (isset($multi_view_data['schema']['attrs']['desktop'])) {
834 $multi_view_data['schema']['attrs']['desktop']['alt'] = $alt_text;
835 }
836
837 # Update alt in other views if they exist (phone, tablet, etc.)
838 foreach ($multi_view_data['schema']['attrs'] as $view => $attrs) {
839 if (isset($attrs['alt'])) {
840 $multi_view_data['schema']['attrs'][$view]['alt'] = $alt_text;
841 }
842 }
843
844 # Encode back to JSON and update the attribute
845 $updated_json = json_encode($multi_view_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
846 $image->setAttribute('data-et-multi-view', $updated_json);
847
848 # error_log('MetaSync OTTO DEBUG: Updated Divi multi-view alt text');
849 }
850 } catch (Exception $e) {
851 # If JSON decode fails, just log and continue
852 # error_log('MetaSync OTTO DEBUG: Failed to update Divi multi-view - ' . $e->getMessage());
853 }
854 }
855 }
856
857 /**
858 * Apply image alt text via string replacement on raw HTML.
859 * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites).
860 *
861 * @param string $html The HTML to process
862 * @param array $replacement_data The replacement data containing body_substitutions.images
863 * @return string Modified HTML with alt text applied
864 */
865 private function apply_image_alt_text_via_string($html, $replacement_data) {
866 if (empty($replacement_data['body_substitutions']['images']) || !is_array($replacement_data['body_substitutions']['images'])) {
867 return $html;
868 }
869
870 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
871 if (empty($alt_text) || strpos($html, $image_url) === false) {
872 continue;
873 }
874
875 $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
876 $escaped_url = preg_quote($image_url, '/');
877 $img_pattern = '/<img[^>]*src=["\']' . $escaped_url . '["\'][^>]*>/i';
878
879 if (preg_match_all($img_pattern, $html, $img_matches)) {
880 foreach ($img_matches[0] as $original_img) {
881 if (strpos($original_img, $escaped_alt) !== false) {
882 continue;
883 }
884
885 # Remove ALL existing alt attributes
886 $new_img = preg_replace('/\s+alt\s*=\s*(["\'])[^"\']*\1/i', '', $original_img);
887 $new_img = preg_replace('/<img\s+alt\s*=\s*(["\'])[^"\']*\1\s*/i', '<img ', $new_img);
888
889 # Add single alt attribute after <img
890 $new_img = preg_replace('/^<img\s*/i', '<img alt="' . str_replace('$', '\\$', $escaped_alt) . '" ', $new_img);
891
892 # Update data-et-multi-view JSON if present (Divi theme)
893 if (strpos($new_img, 'data-et-multi-view') !== false) {
894 $new_img = preg_replace_callback(
895 '/data-et-multi-view="([^"]+)"/i',
896 function($mv_matches) use ($alt_text) {
897 $json_str = html_entity_decode($mv_matches[1], ENT_QUOTES, 'UTF-8');
898 $json_data = json_decode($json_str, true);
899
900 if ($json_data && isset($json_data['schema']['attrs'])) {
901 foreach ($json_data['schema']['attrs'] as &$attrs) {
902 if (array_key_exists('alt', $attrs)) {
903 $attrs['alt'] = $alt_text;
904 }
905 }
906 unset($attrs);
907 $new_json = json_encode($json_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
908 return 'data-et-multi-view="' . str_replace('"', '&quot;', $new_json) . '"';
909 }
910 return $mv_matches[0];
911 },
912 $new_img
913 );
914 }
915
916 $html = str_replace($original_img, $new_img, $html);
917 }
918 }
919 }
920
921 return $html;
922 }
923
924 # heading substitutions
925 function do_heading_body_substitutions($heading_data){
926
927 # loop all data
928 foreach($heading_data AS $okey => $heading){
929
930 # find all occurences of the heading type
931 $occurences = $this->dom->find($heading['type']);
932
933 # loop all occurences
934 foreach ($occurences as $ikey => $heading_old) {
935
936 # get the header text
937 $text = $heading_old->text();
938
939 # check matching text
940 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
941
942 # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
943 $outer = $heading_old->outertext;
944 $open_end = strpos($outer, '>');
945 if ($open_end !== false) {
946 $open_tag = substr($outer, 0, $open_end + 1);
947 $close_tag = '</' . $heading['type'] . '>';
948 $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
949 }
950
951 }
952 }
953
954 }
955
956 # save and reload the dom
957 $this->save_reload();
958 }
959
960 # link replacements
961 function do_link_body_substitutions($swap_data){
962
963 # find all links in the body
964 $links = $this->dom->find('a');
965
966 # loop all links check if we have them in swap data
967 foreach ($links as $key => $link) {
968
969 # check if link matches
970 if(!empty($swap_data[$link->href])){
971
972 # replace the link
973 $link->href = $swap_data[$link->href];
974 }
975
976 }
977 }
978
979 /**
980 * Add rel="nofollow" attribute to external links
981 * Works for both buffer and HTTP rendering methods
982 * Only processes links that don't already have rel="nofollow"
983 */
984 function add_nofollow_to_external_links(){
985
986 # find all links in the body
987 $links = $this->dom->find('a');
988
989 # get home URL for comparison
990 $home_url = rtrim(home_url(), '/');
991 $home_url_lower = strtolower($home_url);
992
993 # loop through all links
994 foreach ($links as $key => $link) {
995
996 # get the href attribute
997 $href = $link->href ?? '';
998
999 # skip if href is empty
1000 if (empty($href)) {
1001 continue;
1002 }
1003
1004 # check if link is external
1005 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
1006 # get existing rel attribute
1007 $existing_rel = $link->rel ?? '';
1008
1009 # check if nofollow already exists
1010 if (empty($existing_rel)) {
1011 # no rel attribute, add nofollow
1012 $link->rel = 'nofollow';
1013 } elseif (strpos($existing_rel, 'nofollow') === false) {
1014 # rel exists but nofollow is not present, add it
1015 $link->rel = trim($existing_rel . ' nofollow');
1016 }
1017 # if nofollow already exists, do nothing
1018 }
1019 }
1020
1021 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
1022 # This avoids redundant DOM save/reload operations and improves performance
1023 }
1024
1025 /**
1026 * Check if a URL is external
1027 *
1028 * @param string $url The URL to check
1029 * @param string $home_url The home URL without trailing slash
1030 * @param string $home_url_lower Lowercase version of home URL
1031 * @return bool True if external, false if internal
1032 */
1033 private function is_external_link($url, $home_url, $home_url_lower) {
1034 # Empty or anchor-only links are internal
1035 if (empty($url) || $url === '#' || strpos($url, '#') === 0) {
1036 return false;
1037 }
1038
1039 # Relative URLs (starting with /) are internal
1040 if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
1041 return false;
1042 }
1043
1044 # Check if URL starts with home URL (case-insensitive)
1045 $url_lower = strtolower($url);
1046 if (strpos($url_lower, $home_url_lower) === 0) {
1047 return false;
1048 }
1049
1050 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1051 if (strpos($url, '//') === 0) {
1052 $parsed_home = parse_url($home_url);
1053 $parsed_link = parse_url($url);
1054
1055 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1056 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1057 return false;
1058 }
1059 }
1060 }
1061
1062 # All other URLs are considered external
1063 return true;
1064 }
1065
1066 /**
1067 * Add target="_blank" attribute to external links
1068 * Works for both buffer and HTTP rendering methods
1069 * Only processes links that don't already have a target attribute
1070 */
1071 function add_target_blank_to_external_links(){
1072
1073 # find all links in the body
1074 $links = $this->dom->find('a');
1075
1076 # get home URL for comparison
1077 $home_url = rtrim(home_url(), '/');
1078 $home_url_lower = strtolower($home_url);
1079
1080 # loop through all links
1081 foreach ($links as $key => $link) {
1082
1083 # get the href attribute
1084 $href = $link->href ?? '';
1085
1086 # skip if href is empty or already has target attribute
1087 if (empty($href) || !empty($link->target)) {
1088 continue;
1089 }
1090
1091 # check if link is external
1092 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
1093 # add target="_blank" attribute
1094 $link->target = '_blank';
1095
1096 # add rel="noopener noreferrer" for security
1097 $existing_rel = $link->rel ?? '';
1098 if (empty($existing_rel)) {
1099 $link->rel = 'noopener noreferrer';
1100 } elseif (strpos($existing_rel, 'noopener') === false) {
1101 $link->rel = trim($existing_rel . ' noopener noreferrer');
1102 }
1103 }
1104 }
1105
1106 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
1107 # This avoids redundant DOM save/reload operations and improves performance
1108 }
1109
1110 # body bottom html replacement code
1111 function do_body_bottom_html($insert_data){
1112
1113 # check that data is availbale
1114 if(empty($insert_data['body_bottom_html_insertion'])){
1115 return;
1116 }
1117
1118 # OPTIMIZED: Use cached body element
1119 $body = $this->get_cached_element('body');
1120
1121 # set the link property if not empty
1122 if(empty($body->outertext)){
1123 return;
1124 }
1125
1126
1127 # get the tag attributes
1128 $attributes_string = $this->get_tag_attributes($body);
1129
1130 # now do the actual html replacements
1131 $body->outertext = '<body' . $attributes_string . '>' . $body->innertext . $insert_data['body_bottom_html_insertion'].'</body>';
1132
1133 # save the document
1134 $this->save_reload();
1135 }
1136
1137 # body top html replacement
1138 function do_body_top_html($insert_data){
1139
1140 # check that data is availbale
1141 if(empty($insert_data['body_top_html_insertion'])){
1142 return;
1143 }
1144
1145 # OPTIMIZED: Use cached body element
1146 $body = $this->get_cached_element('body');
1147
1148 # get the tag attributes
1149 $attributes_string = $this->get_tag_attributes($body);
1150
1151 # now do the actual html replacements
1152 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1153 }
1154
1155 # this function does the header replacements
1156 function do_header_replacements($replacement_data){
1157
1158 # check that we have header replacements
1159 if(empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])){
1160 return;
1161 }
1162
1163 # Check for custom SEO values from MetaSync SEO Sidebar
1164 # Custom values take absolute priority over OTTO suggestions
1165 $custom_seo_title = '';
1166 $custom_seo_description = '';
1167
1168 if (function_exists('is_singular') && is_singular()) {
1169 $post_id = get_the_ID();
1170 if ($post_id) {
1171 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
1172 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1173 }
1174 }
1175
1176 # now lets do the replacement work
1177 foreach($replacement_data['header_replacements'] AS $key => $data){
1178
1179 # skip cases where type is not specified
1180 if(empty($data['type'])){
1181 continue;
1182 }
1183
1184 # handle title - skip if custom SEO title exists or no value
1185 if($data['type'] == 'title'){
1186 if (!empty($custom_seo_title)) {
1187 # Skip title replacement - custom SEO title takes priority
1188 continue;
1189 }
1190
1191 # Skip if OTTO has no title value
1192 if (empty(trim($data['recommended_value'] ?? $data['value'] ?? ''))) {
1193 continue;
1194 }
1195
1196 # handle the title logic
1197 $this->replace_title($data);
1198
1199 #
1200 continue;
1201 }
1202
1203 # handle canonical links
1204 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1205
1206 # Protect manually-set canonical from OTTO override
1207 if (function_exists('is_singular') && is_singular()) {
1208 $post_id = get_the_ID();
1209 if ($post_id) {
1210 $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1211 if (empty($custom_canonical)) {
1212 $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1213 // Handle legacy array values
1214 if (is_array($custom_canonical)) {
1215 $custom_canonical = reset($custom_canonical) ?: '';
1216 }
1217 }
1218 if (!empty($custom_canonical)) {
1219 # Manual canonical takes priority — skip OTTO override
1220 continue;
1221 }
1222 }
1223 }
1224
1225 # find the cannonical dom element
1226 $link = $this->dom->find('link[rel="canonical"]', 0);
1227
1228 # set the link property if not empty
1229 if(!empty($link->href)){
1230 $link->href = $data['recommended_value'] ?? $link->href;
1231 }
1232
1233 #
1234 continue;
1235 }
1236
1237 # work on other elemenets not titlte
1238 $this->handle_meta_element($data);
1239 }
1240 }
1241
1242 # function to handle meta elements other than title
1243 function handle_meta_element($data){
1244
1245 # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
1246 # with empty content when OTTO has no recommendation for this meta field
1247 $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
1248 if (empty(trim($recommended_value))) {
1249 return;
1250 }
1251
1252 # Check if this is a description meta tag and if custom description exists
1253 # Custom values take absolute priority over OTTO suggestions
1254 $name = $data['name'] ?? false;
1255 $property = $data['property'] ?? false;
1256
1257 # Protect manually-set robots meta from OTTO override
1258 # If the user set noindex via Common Robots Meta or meta_robots, honour it
1259 if (!empty($name) && $name === 'robots') {
1260 if (function_exists('is_singular') && is_singular()) {
1261 $post_id = get_the_ID();
1262 if ($post_id) {
1263 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
1264 if (!empty($manual_robots) && stripos($manual_robots, 'noindex') !== false) {
1265 # Manual noindex takes priority — skip OTTO override
1266 return;
1267 }
1268 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1269 if (is_array($common_robots) && !empty($common_robots['noindex'])) {
1270 # Common Robots Meta has noindex checked — skip OTTO override
1271 return;
1272 }
1273 }
1274 }
1275 }
1276
1277 # Check for custom SEO description
1278 if (!empty($name) && $name === 'description') {
1279 if (function_exists('is_singular') && is_singular()) {
1280 $post_id = get_the_ID();
1281 if ($post_id) {
1282 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1283 if (!empty($custom_seo_description)) {
1284 # Custom description exists, skip OTTO's suggestion
1285 return;
1286 }
1287 }
1288 }
1289 }
1290
1291 # extract property value
1292 $property = $data['property'] ?? false;
1293
1294 # extract name value
1295 $name = $data['name'] ?? false;
1296
1297 # set the selector
1298 $meta_selector = '';
1299
1300 # extend selector
1301 if(!empty($name)){
1302 $meta_selector .= 'meta[name="' . trim($name) . '"]';
1303 }
1304
1305 # extent if property is defined
1306 if(!empty($property)){
1307 if (!empty($meta_selector)) {
1308 $meta_selector .= ',';
1309 }
1310 $meta_selector .= 'meta[property="' . trim($property) . '"]';
1311 }
1312
1313 # find the meta gat in the dom
1314 $meta_tag = $this->dom->find($meta_selector, 0);
1315
1316 # if tag not exists add it
1317 if(empty($meta_tag)){
1318 if($data['type'] == 'meta'){
1319
1320 # get the attribute
1321 $attribute = $property ? 'property' : 'name';
1322
1323 # call the create metatag function
1324 $result = $this->create_metatag($attribute, $data);
1325 }
1326
1327 # return after creation
1328 return;
1329 }
1330
1331 # CRITICAL FIX: Clear cache and get fresh reference
1332 # Preserve 'imgs' key for later use by handle_images()
1333 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1334 $this->cached_elements = [];
1335 if ($preserved_imgs !== null) {
1336 $this->cached_elements['imgs'] = $preserved_imgs;
1337 }
1338
1339 # Get fresh meta tag reference using same selector
1340 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
1341
1342 if ($meta_tag_fresh) {
1343 # Use outertext for replacement
1344 $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8');
1345
1346 # Determine attribute name
1347 $attr_name = !empty($data['name']) ? 'name' : 'property';
1348 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
1349
1350 # Build new meta tag
1351 $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1352 }
1353
1354
1355 }
1356
1357 # function to handle the page title
1358 function replace_title($title_data){
1359
1360 # find the title
1361 $title = $this->dom->find('title', 0) ?? false;
1362
1363 # if none
1364 if($title === false){
1365 return $this->create_title($title_data);
1366 }
1367
1368 # CRITICAL FIX: Clear element cache and get fresh reference
1369 # Preserve 'imgs' key for later use by handle_images()
1370 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1371 $this->cached_elements = [];
1372 if ($preserved_imgs !== null) {
1373 $this->cached_elements['imgs'] = $preserved_imgs;
1374 }
1375
1376 # Get fresh title element reference
1377 $title_fresh = $this->dom->find('title', 0);
1378
1379 if ($title_fresh) {
1380 # Use outertext for replacement
1381 $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
1382 $title_fresh->outertext = '<title>' . $new_value . '</title>';
1383 }
1384
1385 # Don't save_reload here - will happen at the end
1386 # $this->save_reload();
1387 }
1388
1389 # Function to create a title when it's missing
1390 function create_title($title_data) {
1391
1392 # Find the <head> tag
1393 $head = $this->dom->find('head', 0);
1394
1395 # Construct the <title> tag HTML
1396 $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';
1397
1398 if (empty($head)) {
1399 return false;
1400 }
1401
1402 # Extract existing attributes of the <head> tag
1403 $attributes = [];
1404
1405 # get the tag attributes
1406 $tag_attributes = $head->getAllAttributes();
1407
1408 # loop all attributes
1409 foreach ($tag_attributes as $key => $value) {
1410
1411 if ($value == 1) {
1412
1413 # Handle boolean attributes
1414 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1415 } else {
1416
1417 # Handle attributes with values
1418 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1419 }
1420 }
1421
1422 # Convert attributes array to a string
1423 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1424
1425 # Rebuild the <head> tag, inserting the <title> at the beginning
1426 $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';
1427
1428 # save and reload DOM
1429 $this->save_reload();
1430 }
1431
1432 # function to create meta tag if none existss
1433 function create_metatag($attribute, $data){
1434
1435 # Find the <head> tag
1436 $head = $this->dom->find('head', 0);
1437
1438 # Construct the meta tag HTML (no spaces around = for standard HTML and regex compatibility)
1439 $meta_tag = '<meta '.$attribute.'="'.htmlspecialchars($data[$attribute], ENT_QUOTES, 'UTF-8').'" content="'.htmlspecialchars($data['recommended_value'], ENT_QUOTES, 'UTF-8').'">';
1440
1441 if (empty($head)) {
1442 return false;
1443 }
1444
1445 # Extract existing attributes of the <head> tag
1446 $attributes = [];
1447
1448 # get the tag attributes
1449 $tag_attributes = $head->getAllAttributes();
1450
1451 # loop all attributes
1452 foreach ($tag_attributes as $key => $value) {
1453
1454 if ($value == 1) {
1455
1456 # Handle boolean attributes
1457 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1458 } else {
1459
1460 # Handle attributes with values
1461 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1462 }
1463 }
1464
1465 # Convert attributes array to a string
1466 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1467
1468 # Rebuild the <head> tag, inserting the <title> at the beginning
1469 $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';
1470
1471 # save and reload DOM
1472 $this->save_reload();
1473 }
1474
1475 /**
1476 * Remove duplicate <title> tags from HTML, keeping the first (OTTO's) value.
1477 *
1478 * OTTO's title replacement is always applied first (limit=1 or DOM manipulation),
1479 * so the first <title> tag holds the authoritative value. If SEO plugin conflicts
1480 * produce additional <title> tags, this strips all and re-inserts one.
1481 *
1482 * @param string $html Full HTML document.
1483 * @return string HTML with at most one <title> tag.
1484 */
1485 private function deduplicate_title_tags($html) {
1486 $title_count = preg_match_all('/<title[^>]*>.*?<\/title>/is', $html, $title_matches);
1487 if ($title_count <= 1) {
1488 return $html;
1489 }
1490
1491 # Capture the first title's inner text (OTTO's replacement)
1492 preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $first_title);
1493 $authoritative_title = isset($first_title[1]) ? $first_title[1] : '';
1494
1495 # Strip all <title> tags
1496 $html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html);
1497
1498 # Re-insert a single <title> after <head> using preg_replace_callback to prevent
1499 # backreference injection when title contains $ followed by digits (e.g. "$50 off")
1500 $title_tag = '<title>' . $authoritative_title . '</title>';
1501 $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1502 return $m[1] . $title_tag;
1503 }, $html, 1);
1504
1505 return $html;
1506 }
1507
1508 /**
1509 * Remove duplicate OG and Twitter meta tags from HTML after OTTO processing.
1510 *
1511 * OTTO injects its tags with `data-otto-pixel` or `data-otto` attributes.
1512 * Legacy MetaSync output and third-party SEO plugins may also emit the same
1513 * OG/Twitter properties. This method runs at the buffer level — after all
1514 * sources have written their tags — and keeps only the OTTO version when
1515 * duplicates exist.
1516 *
1517 * Strategy per property (e.g. og:description):
1518 * - If OTTO tag exists (has data-otto marker) → remove all non-OTTO duplicates
1519 * - If no OTTO tag exists → keep the first occurrence, remove the rest
1520 *
1521 * @param string $html Full HTML document.
1522 * @return string HTML with at most one tag per OG/Twitter property.
1523 */
1524 private function deduplicate_og_twitter_tags($html) {
1525 # OG properties to deduplicate
1526 $og_properties = [
1527 'og:title', 'og:description', 'og:url', 'og:type',
1528 'og:locale', 'og:site_name', 'og:image',
1529 ];
1530
1531 foreach ($og_properties as $prop) {
1532 $html = $this->deduplicate_meta_by_attr($html, 'property', $prop);
1533 }
1534
1535 # Twitter names to deduplicate
1536 $twitter_names = [
1537 'twitter:title', 'twitter:description', 'twitter:card',
1538 'twitter:image', 'twitter:site',
1539 ];
1540
1541 foreach ($twitter_names as $name) {
1542 $html = $this->deduplicate_meta_by_attr($html, 'name', $name);
1543 }
1544
1545 return $html;
1546 }
1547
1548 /**
1549 * Deduplicate meta tags by a specific attribute (property= or name=).
1550 *
1551 * When duplicates exist and one carries a data-otto marker, keep only
1552 * the OTTO version. Otherwise keep the first occurrence.
1553 *
1554 * @param string $html Full HTML.
1555 * @param string $attr_name Attribute name: 'property' or 'name'.
1556 * @param string $attr_val Attribute value: e.g. 'og:title' or 'twitter:description'.
1557 * @return string
1558 */
1559 private function deduplicate_meta_by_attr($html, $attr_name, $attr_val) {
1560 $escaped = preg_quote($attr_val, '/');
1561 # Match all <meta> tags with this attribute value (both attr orderings)
1562 $pattern = '/<meta\s[^>]*' . preg_quote($attr_name, '/') . '\s*=\s*["\']' . $escaped . '["\'][^>]*\/?>/i';
1563
1564 if (preg_match_all($pattern, $html, $matches) <= 1) {
1565 return $html; # 0 or 1 — nothing to deduplicate
1566 }
1567
1568 $all_tags = $matches[0];
1569
1570 # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
1571 $otto_tag = null;
1572 foreach ($all_tags as $tag) {
1573 if (stripos($tag, 'data-otto') !== false) {
1574 $otto_tag = $tag;
1575 break;
1576 }
1577 }
1578
1579 # Determine the keeper: OTTO tag if present, otherwise the first tag
1580 $keeper = $otto_tag ?: $all_tags[0];
1581
1582 # Remove all occurrences, then re-insert the keeper at the first position
1583 $first_replaced = false;
1584 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1585 if (!$first_replaced) {
1586 $first_replaced = true;
1587 return $keeper;
1588 }
1589 return ''; # Remove subsequent duplicates
1590 }, $html);
1591
1592 return $html;
1593 }
1594
1595 /**
1596 * Remove duplicate <link rel="canonical"> tags from HTML.
1597 *
1598 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1599 * SEO output (or WordPress core) has already emitted one, keep only the
1600 * OTTO version (identified by data-otto marker). If no OTTO tag exists,
1601 * keep the first occurrence.
1602 *
1603 * @param string $html Full HTML document.
1604 * @return string HTML with at most one canonical tag.
1605 */
1606 private function deduplicate_canonical_tags($html) {
1607 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
1608
1609 if (preg_match_all($pattern, $html, $matches) <= 1) {
1610 return $html;
1611 }
1612
1613 $all_tags = $matches[0];
1614
1615 $otto_tag = null;
1616 foreach ($all_tags as $tag) {
1617 if (stripos($tag, 'data-otto') !== false) {
1618 $otto_tag = $tag;
1619 break;
1620 }
1621 }
1622
1623 $keeper = $otto_tag ?: $all_tags[0];
1624
1625 $first_replaced = false;
1626 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1627 if (!$first_replaced) {
1628 $first_replaced = true;
1629 return $keeper;
1630 }
1631 return '';
1632 }, $html);
1633
1634 return $html;
1635 }
1636
1637 /**
1638 * Deduplicate JSON-LD schema blocks.
1639 *
1640 * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1641 * blocks, keep OTTO's version for any @type that appears in both.
1642 * Third-party blocks whose @type is not covered by OTTO are preserved.
1643 *
1644 * @param string $html Full HTML.
1645 * @return string
1646 */
1647 private function deduplicate_schema_tags($html) {
1648 // Find all JSON-LD script blocks
1649 $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1650 if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
1651 return $html;
1652 }
1653
1654 $otto_by_type = []; // @type => decoded JSON object
1655 $third_by_type = []; // @type => decoded JSON object
1656 $otto_graph = []; // entries from OTTO @graph blocks
1657 $third_graph = []; // entries from third-party @graph blocks
1658
1659 foreach ($matches as $m) {
1660 $attrs = $m[1];
1661 $json_str = $m[3];
1662 $decoded = json_decode($json_str, true);
1663 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1664 continue; // skip unparseable blocks — leave them in place
1665 }
1666 $is_otto = stripos($attrs, 'data-otto') !== false;
1667
1668 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1669 foreach ($decoded['@graph'] as $entry) {
1670 if (!isset($entry['@type'])) continue;
1671 // JSON-LD allows @type to be a string OR an array of strings.
1672 // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1673 // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1674 $type = is_array($entry['@type'])
1675 ? (string) reset($entry['@type'])
1676 : (string) $entry['@type'];
1677 if ($type === '') continue;
1678 if ($is_otto) {
1679 $otto_graph[$type] = $entry;
1680 } else {
1681 $third_graph[$type] = $entry;
1682 }
1683 }
1684 } elseif (isset($decoded['@type'])) {
1685 $type = is_array($decoded['@type'])
1686 ? (string) reset($decoded['@type'])
1687 : (string) $decoded['@type'];
1688 if ($type === '') continue;
1689 if ($is_otto) {
1690 $otto_by_type[$type] = $decoded;
1691 } else {
1692 $third_by_type[$type] = $decoded;
1693 }
1694 }
1695 }
1696
1697 // If OTTO provided no schema at all, nothing to deduplicate
1698 if (empty($otto_by_type) && empty($otto_graph)) {
1699 return $html;
1700 }
1701
1702 // Remove all JSON-LD blocks from HTML
1703 $html = preg_replace($pattern, '', $html);
1704
1705 // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
1706 $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
1707 $rebuilt = '';
1708 foreach ($kept as $decoded) {
1709 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1710 wp_json_encode($decoded) . "</script>\n";
1711 }
1712
1713 // Re-insert merged @graph block (if any entries exist)
1714 $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
1715 if (!empty($merged_graph)) {
1716 $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
1717 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1718 wp_json_encode($graph_obj) . "</script>\n";
1719 }
1720
1721 // Re-inject before </head>
1722 if (!empty($rebuilt)) {
1723 $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
1724 return $rebuilt . $m[1];
1725 }, $html, 1);
1726 }
1727
1728 return $html;
1729 }
1730
1731 # function to detect if current page is an AMP page
1732 function is_amp_page(){
1733
1734 # Check if URL path contains /amp/
1735 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1736 if (strpos($current_url, '/amp/') !== false) {
1737 return true;
1738 }
1739
1740 # Check if URL ends with /amp
1741 if (preg_match('/\/amp\/?$/', $current_url)) {
1742 return true;
1743 }
1744
1745 # Check if amp=1 query parameter is present
1746 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
1747 return true;
1748 }
1749
1750 # Check for other common AMP query parameters
1751 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
1752 return true;
1753 }
1754
1755 return false;
1756 }
1757
1758 # this function insterts header html to the dom
1759 function insert_header_html($data){
1760
1761 # check that we have the header html
1762 if(empty($data['header_html_insertion'])){
1763 #
1764 return;
1765 }
1766
1767 # append the/ html at the start of the header
1768 $head = $this->dom->find('head', 0);
1769
1770 if ($head) {
1771
1772 # Check if this is an AMP page - if so, don't add metasync_optimized attribute
1773 $is_amp_page = $this->is_amp_page();
1774
1775 # Append the new HTML at the start of the <head> tag
1776 # For AMP pages: use clean <head> tag without metasync_optimized attribute
1777 # For non-AMP pages: add metasync_optimized attribute to <head> tag
1778 if ($is_amp_page) {
1779 $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1780 } else {
1781 $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1782 }
1783
1784 }
1785
1786 # save and reload DOM
1787 $this->save_reload();
1788 }
1789
1790 # function to forcefully remove metasync_optimized attribute from head on AMP pages
1791 function cleanup_amp_metasync_attribute(){
1792
1793 # Only proceed if this is an AMP page
1794 if (!$this->is_amp_page()) {
1795 return;
1796 }
1797
1798 # Find the head tag
1799 $head = $this->dom->find('head', 0);
1800
1801 if (!$head) {
1802 return;
1803 }
1804
1805 # Check if head has metasync_optimized attribute
1806 $head_html = $head->outertext;
1807
1808 # If metasync_optimized attribute is found, remove it
1809 if (strpos($head_html, 'metasync_optimized') !== false) {
1810
1811 # Remove the metasync_optimized attribute from the head tag
1812 # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
1813 $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
1814
1815 # Update the head element
1816 $head->outertext = $cleaned_head_html;
1817
1818 }
1819 }
1820
1821 /**
1822 * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1823 * Reduces 8-10 full DOM traversals to 1 initial traversal
1824 * Call this once after loading HTML, before processing
1825 */
1826 private function cache_elements() {
1827 if (!$this->dom) {
1828 return;
1829 }
1830
1831 # Cache all commonly accessed elements in one pass
1832 $this->cached_elements = [
1833 'html' => $this->dom->find('html', 0),
1834 'head' => $this->dom->find('head', 0),
1835 'body' => $this->dom->find('body', 0),
1836 'footer' => $this->dom->find('footer', 0),
1837 'title' => $this->dom->find('title', 0),
1838 'imgs' => $this->dom->find('img'),
1839 'links' => $this->dom->find('a'),
1840 'canonical' => $this->dom->find('link[rel="canonical"]', 0),
1841 ];
1842 }
1843
1844 /**
1845 * Get cached element by key, with fallback to DOM find
1846 * @param string $key Element key from cache
1847 * @param mixed $fallback Fallback value if not cached
1848 * @return mixed Cached element or fallback
1849 */
1850 private function get_cached_element($key, $fallback = null) {
1851 return $this->cached_elements[$key] ?? $fallback;
1852 }
1853
1854 # this function saves are reloads the dom for modifications to avoid conflict
1855 function save_reload(){
1856
1857 # PERFORMANCE OPTIMIZATION: Skip reload if deferred
1858 # This reduces multiple serialize/deserialize cycles to just one final reload
1859 if ($this->deferred_reload) {
1860 return;
1861 }
1862
1863 # Cleanup metasync_optimized attribute on AMP pages before saving
1864 $this->cleanup_amp_metasync_attribute();
1865
1866 # DISABLED: Cache file creation temporarily disabled
1867
1868 # if(file_put_contents($this->html_file, $this->dom)){
1869
1870 # load the modified file to the DOM
1871 # $this->dom = new HtmlDocument($this->html_file );
1872 # }
1873
1874 # this code is to be replaced in future
1875 # reson for adding is to prevent caching logged in user pages
1876 # why not just skip saving? it broke the DOM Library
1877 # check user is logged in clear the file
1878
1879 # if(is_user_logged_in()) {
1880 # unlink($this->html_file);
1881 # }
1882
1883 # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
1884 # This prevents DOM breaking while avoiding cache file creation
1885 if($this->dom){
1886 # Get current DOM as HTML string
1887 $current_html = $this->dom->save();
1888
1889 # Reload DOM from the HTML string to refresh internal state
1890 # This replaces the file save/reload cycle that SimpleHtmlDOM expects
1891 $current_html = $this->sanitize_text_less_than($current_html);
1892 $this->dom->load($current_html, true, false);
1893
1894 # Force UTF-8 charset after reload to preserve emojis
1895 $this->dom->_charset = 'UTF-8';
1896 $this->dom->_target_charset = 'UTF-8';
1897 }
1898
1899 }
1900
1901 /**
1902 * Process HTML directly without HTTP request (for buffer approach)
1903 * This is the FAST path - eliminates the internal wp_remote_get call
1904 *
1905 * CRITICAL: This method is called from the output buffer callback.
1906 * If it fails, we must return false so the original HTML can be used.
1907 *
1908 * @param string $html The raw HTML captured from output buffer
1909 * @param array $replacement_data OTTO suggestions/replacement data
1910 * @return HtmlDocument|false Modified DOM or false on failure
1911 * @since 2.6.0
1912 */
1913 function process_html_directly($html, $replacement_data) {
1914 try {
1915 # Validate inputs
1916 if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
1917 return false;
1918 }
1919
1920 # Validate HTML is actual HTML content
1921 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
1922 return false;
1923 }
1924
1925 # Remove XML declaration if present
1926 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
1927
1928 # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
1929 $original_style_blocks = $this->capture_style_blocks($html);
1930
1931 # Escape bare < in text content before DOM parsing
1932 $html = $this->sanitize_text_less_than($html);
1933
1934 # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
1935 $html = $this->fix_malformed_self_closing_tags($html);
1936
1937 # Load HTML into DOM
1938 $this->dom->load($html, true, false);
1939
1940 # Force UTF-8 charset to preserve emojis and special characters
1941 $this->dom->_charset = 'UTF-8';
1942 $this->dom->_target_charset = 'UTF-8';
1943
1944 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1945 # This eliminates 8-10 full DOM traversals per page
1946 $this->cache_elements();
1947
1948 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
1949 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
1950 $this->deferred_reload = true;
1951
1952 # Apply blocking flags if available (for SEO plugin coordination)
1953 if (!empty($replacement_data['_otto_blocking'])) {
1954 $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
1955 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
1956
1957 # Remove SEO plugin meta tags if OTTO is providing them
1958 if ($block_title || !empty($block_description_tags)) {
1959 $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
1960 }
1961 }
1962
1963 # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)
1964
1965 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
1966 # Some API versions return 'value' instead of 'recommended_value'
1967 if (!empty($replacement_data['header_replacements'])) {
1968 foreach ($replacement_data['header_replacements'] as &$item) {
1969 if (isset($item['value']) && !isset($item['recommended_value'])) {
1970 $item['recommended_value'] = $item['value'];
1971 }
1972 }
1973 unset($item); // Break reference
1974 }
1975
1976 # 1. Header HTML insertion
1977 $this->insert_header_html($replacement_data);
1978
1979 # 2. Header replacements (title, meta, canonical)
1980 $this->do_header_replacements($replacement_data);
1981
1982 # 3. Body replacements (top, bottom, substitutions)
1983 $this->do_body_replacements($replacement_data);
1984
1985 # 4. Footer HTML insertion
1986 $this->do_footer_html_insertion($replacement_data);
1987
1988 # 5. Final cleanup for AMP pages
1989 $this->cleanup_amp_metasync_attribute();
1990
1991 # CRITICAL FIX: Clear cached elements and force DOM to refresh
1992 $this->deferred_reload = false;
1993 $this->cached_elements = []; // Clear our custom cache
1994
1995 # Clear SimpleHtmlDom's internal cache
1996 if (method_exists($this->dom, 'clear')) {
1997 $this->dom->clear();
1998 }
1999
2000
2001 # Try getting HTML via root element instead of save()
2002 $root = $this->dom->root;
2003 if ($root && isset($root->outertext)) {
2004 $result_html = $root->outertext;
2005 } else {
2006 # Fallback to save() method
2007 $result_html = $this->dom->save();
2008 }
2009
2010 # DEBUG: Check if DOM changes persisted
2011 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2012 }
2013
2014 # Apply header replacements manually via string replacement
2015 if (!empty($replacement_data['header_replacements'])) {
2016
2017 foreach ($replacement_data['header_replacements'] as $idx => $item) {
2018 $type = $item['type'] ?? '';
2019 $value = $item['recommended_value'] ?? $item['value'] ?? '';
2020
2021
2022 if (empty($value)) {
2023 continue;
2024 }
2025
2026 if ($type === 'title') {
2027 # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
2028 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2029 $title_tag = '<title>' . $new_value . '</title>';
2030 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2031 return $title_tag;
2032 }, $result_html, 1);
2033 if ($replaced === $result_html && strpos($result_html, '<title') === false) {
2034 # No <title> in document (common when Yoast is blocked) — insert after <head>
2035 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
2036 return $m[1] . "\n" . $title_tag;
2037 }, $result_html, 1);
2038 } else {
2039 $result_html = $replaced;
2040 }
2041 } elseif ($type === 'meta') {
2042 $name = $item['name'] ?? '';
2043 $property = $item['property'] ?? '';
2044 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2045
2046 if (!empty($name)) {
2047 # MEMORY OPTIMIZED: Count meta tags without storing all matches
2048 # preg_match_all requires $matches, so we pass it but unset immediately
2049 # Use \s*=\s* to match attributes with or without spaces around =
2050 $before_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);
2051
2052 # Free memory immediately after use
2053 unset($before_matches);
2054
2055 # IMPROVED FIX: Remove ALL existing meta tags with this name
2056 # This pattern matches ANY meta tag with name="X" regardless of:
2057 # - Attribute order (name before/after content)
2058 # - Additional attributes (id, class, data-*, etc.)
2059 # - Quote style (single or double quotes)
2060 # - Whitespace variations
2061 # The pattern uses [^>]* to match ANY characters until the closing >
2062 $removed_count = preg_replace_callback(
2063 '/<meta\s+[^>]*name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
2064 function($match) {
2065 return ''; // Remove the tag
2066 },
2067 $result_html,
2068 -1, // Remove all occurrences
2069 $total_removed
2070 );
2071
2072 # Update the HTML with removed tags
2073 if ($total_removed > 0) {
2074 $result_html = $removed_count;
2075 }
2076
2077
2078 # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
2079 $after_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
2080 unset($after_matches);
2081
2082 # Now insert ONE new meta tag at the TOP of <head>
2083 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
2084 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
2085 return $m[1] . "\n" . $replacement;
2086 }, $result_html, 1);
2087 } elseif (!empty($property)) {
2088 # Replace meta property tag
2089 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
2090 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
2091
2092 if (preg_match($pattern, $result_html)) {
2093 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
2094 return $replacement;
2095 }, $result_html, 1);
2096 } else {
2097 # Meta tag doesn't exist, insert it in head
2098 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
2099 return $m[1] . "\n" . $replacement;
2100 }, $result_html, 1);
2101 }
2102 }
2103 } elseif ($type === 'h1' || $type === 'heading') {
2104 # Replace first H1 tag, preserving attributes
2105 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
2106 $result_html = preg_replace_callback(
2107 '/<h1([^>]*)>.*?<\/h1>/is',
2108 function ($m) use ($new_value) {
2109 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
2110 },
2111 $result_html,
2112 1
2113 );
2114 }
2115 }
2116 }
2117
2118 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
2119 if (!empty($replacement_data['header_html_insertion'])) {
2120 $header_html_check = trim($replacement_data['header_html_insertion']);
2121 if (strpos($result_html, $header_html_check) === false) {
2122 $header_html_insertion = preg_replace(
2123 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
2124 '<script$1type=$2application/ld+json$2 data-otto="true"',
2125 $replacement_data['header_html_insertion']
2126 );
2127 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
2128 # Insert before </head>
2129 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
2130 }
2131 }
2132
2133 # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
2134 if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
2135 $result_html = preg_replace(
2136 '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
2137 '',
2138 $result_html
2139 );
2140 $first = true;
2141 $result_html = preg_replace_callback(
2142 '/<title[^>]*>.*?<\/title>\s*/is',
2143 function ($m) use (&$first) {
2144 if ($first) {
2145 $first = false;
2146 return $m[0];
2147 }
2148 return '';
2149 },
2150 $result_html
2151 );
2152 }
2153
2154 # Apply body top HTML insertion — only if DOM insertion didn't already apply it
2155 if (!empty($replacement_data['body_top_html_insertion'])) {
2156 $body_top_check = trim($replacement_data['body_top_html_insertion']);
2157 if (strpos($result_html, $body_top_check) === false) {
2158 $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
2159 # Insert after <body>
2160 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
2161 }
2162 }
2163
2164 # Apply body bottom HTML insertion — only if DOM insertion didn't already apply it
2165 if (!empty($replacement_data['body_bottom_html_insertion'])) {
2166 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
2167 if (strpos($result_html, $body_bottom_check) === false) {
2168 $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
2169 # Insert before </body>
2170 $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
2171 }
2172 }
2173
2174 # Apply footer HTML insertion
2175 if (!empty($replacement_data['footer_html_insertion'])) {
2176 $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
2177 # Insert before </html>
2178 $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
2179 }
2180
2181 # CRITICAL FIX: Apply image alt text manually via string replacement
2182 # DOM changes don't persist, must use string replacement
2183 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
2184
2185 # String-based heading fallback for body_substitutions
2186 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2187 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
2188 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
2189 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
2190 continue;
2191 }
2192
2193 $heading_type = preg_quote($heading['type'], '/');
2194 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
2195 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
2196
2197 $result_html = preg_replace_callback(
2198 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2199 function ($m) use ($current_value, $recommended_value) {
2200 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2201 if ($inner_text === $current_value) {
2202 return $m[1] . $recommended_value . $m[3];
2203 }
2204 return $m[0];
2205 },
2206 $result_html,
2207 -1
2208 );
2209 }
2210 }
2211
2212 # DEBUG: Check what we're returning
2213 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2214 }
2215
2216 # FINAL VERIFICATION: Count meta descriptions in returned HTML
2217 $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
2218 if ($final_meta_count > 0) {
2219 foreach ($final_meta_matches[0] as $idx => $meta) {
2220 }
2221 }
2222
2223 if ($final_meta_count > 1) {
2224 }
2225
2226 # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
2227 # Only runs when OTTO has actually inserted its own description (data-otto marker present)
2228 # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace
2229
2230 $removal_count = 0;
2231 $otto_has_description = (bool) preg_match('/<meta[^>]*name\s*=\s*["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
2232 if ($otto_has_description) {
2233 $result_html = preg_replace_callback(
2234 '/<meta\s+([^>]*name\s*=\s*["\']description["\'][^>]*)>/i',
2235 function($match) use (&$removal_count) {
2236 # Keep only if it has data-otto="true"
2237 if (stripos($match[1], 'data-otto') !== false) {
2238 return $match[0]; // Keep OTTO's meta tag
2239 }
2240 # Remove any other meta description
2241 $removal_count++;
2242 return '';
2243 },
2244 $result_html
2245 );
2246 }
2247
2248 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2249 $result_html = $this->deduplicate_title_tags($result_html);
2250 $result_html = $this->deduplicate_og_twitter_tags($result_html);
2251 $result_html = $this->deduplicate_schema_tags($result_html);
2252 $result_html = $this->deduplicate_canonical_tags($result_html);
2253
2254 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2255 # This ensures memory is released immediately, especially important for high-traffic sites
2256 unset($final_meta_matches, $matches);
2257
2258 # Clear SimpleHtmlDom internal cache to free memory
2259 # Note: We don't unset $this->dom as the object may be reused
2260 if ($this->dom && method_exists($this->dom, 'clear')) {
2261 $this->dom->clear();
2262 }
2263
2264 # Clear element cache array
2265 $this->cached_elements = [];
2266
2267 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2268 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2269 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
2270 }
2271
2272 $result_html = $this->restore_case_sensitive_attributes($result_html);
2273
2274 # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
2275 $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
2276
2277 # WP-315: Fix Divi 5 shortcode framework class renumbering
2278 $result_html = $this->fix_divi_class_renumbering($result_html);
2279
2280 # WP-315: Clean OTTO internal fetch params from HTML output
2281 $result_html = $this->clean_otto_fetch_params($result_html);
2282
2283 return $result_html;
2284
2285 } catch (Exception $e) {
2286 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
2287 return false;
2288 } catch (Error $e) {
2289 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
2290 return false;
2291 }
2292 }
2293
2294 /**
2295 * Remove conflicting SEO plugin meta tags from DOM
2296 * Called when OTTO is providing its own title/description
2297 *
2298 * @param bool $remove_title Remove title-related tags
2299 * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
2300 * @since 2.6.0
2301 */
2302 private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
2303 if (!$this->dom) {
2304 return;
2305 }
2306
2307 # Find head element
2308 $head = $this->dom->find('head', 0);
2309 if (!$head) {
2310 return;
2311 }
2312
2313 # Check for custom SEO values from MetaSync SEO Sidebar
2314 # Custom values take absolute priority over OTTO suggestions
2315 $has_custom_title = false;
2316 $has_custom_description = false;
2317
2318 if (function_exists('is_singular') && is_singular()) {
2319 $post_id = get_the_ID();
2320 if ($post_id) {
2321 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
2322 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
2323 $has_custom_title = !empty($custom_seo_title);
2324 $has_custom_description = !empty($custom_seo_description);
2325 }
2326 }
2327
2328 # Handle description tags - only remove specific tags that Otto is providing
2329 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
2330 # Remove only the specific description tags that Otto is providing
2331 foreach ($remove_description_tags as $selector) {
2332 $tags = $this->dom->find($selector);
2333 foreach ($tags as $tag) {
2334 # Remove the tag
2335 $tag->outertext = '';
2336 }
2337 }
2338 }
2339
2340 if ($remove_title && !$has_custom_title) {
2341 # Remove Open Graph and Twitter title tags (keep main <title>)
2342 $title_selectors = [
2343 'meta[property=og:title]',
2344 'meta[name=twitter:title]',
2345 ];
2346
2347 foreach ($title_selectors as $selector) {
2348 $tags = $this->dom->find($selector);
2349 foreach ($tags as $tag) {
2350 $tag->outertext = ''; # Remove the tag
2351 }
2352 }
2353 }
2354
2355 # Save changes
2356 $this->save_reload();
2357 }
2358
2359 /**
2360 * Clean OTTO internal fetch query parameters from rendered HTML (WP-315).
2361 *
2362 * @param string $html The rendered HTML
2363 * @return string HTML with OTTO fetch params removed
2364 */
2365 private function clean_otto_fetch_params($html) {
2366 $patterns = [
2367 '/[?&]is_otto_page_fetch=1/',
2368 '/[?&]otto_block_title=1/',
2369 '/[?&]otto_block_desc=1/',
2370 '/[?&]amp;is_otto_page_fetch=1/',
2371 '/[?&]amp;otto_block_title=1/',
2372 '/[?&]amp;otto_block_desc=1/',
2373 ];
2374 $html = preg_replace($patterns, '', $html);
2375 $html = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $html);
2376 return $html;
2377 }
2378
2379 /**
2380 * Fix Divi 5 shortcode framework class renumbering (WP-315)
2381 *
2382 * When Divi 5's shortcode framework loads during the internal HTTP fetch,
2383 * it uses shared counters across all template parts (header + page + footer),
2384 * causing page content element classes to be offset (et_pb_section_0 becomes
2385 * et_pb_section_4, etc.). The cached CSS references 0-based numbering.
2386 *
2387 * Detects the offset per element type and remaps back to 0-based.
2388 *
2389 * @param string $html The processed HTML string
2390 * @return string HTML with corrected Divi class numbering
2391 */
2392 private function fix_divi_class_renumbering($html) {
2393 # Only run on Divi sites
2394 if (!defined('ET_CORE_VERSION')) {
2395 return $html;
2396 }
2397
2398 # Only apply to Divi pages with template builder
2399 if (strpos($html, 'et-l et-l--post') === false) {
2400 return $html;
2401 }
2402
2403 # Locate the page content area (between et-l--post and et-l--footer)
2404 $page_start = strpos($html, 'et-l et-l--post');
2405 if ($page_start === false) {
2406 return $html;
2407 }
2408
2409 $footer_start = strpos($html, 'et-l et-l--footer', $page_start);
2410 if ($footer_start === false) {
2411 $footer_start = strlen($html);
2412 }
2413
2414 $page_content = substr($html, $page_start, $footer_start - $page_start);
2415
2416
2417 # Divi element types that get renumbered by the shortcode framework.
2418 # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
2419 # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
2420 # wp_remote_get includes the Theme Builder header template in Divi's counter.
2421 # If the blog module class doesn't match between page 1 (OTTO-processed) and
2422 # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results (WP-315).
2423 $types = [
2424 'section', 'row', 'column', 'text', 'blurb', 'toggle',
2425 'button', 'image', 'group_carousel',
2426 'blog', 'portfolio', 'filterable_portfolio', 'shop',
2427 'heading', 'divider', 'code', 'icon', 'contact_form_7',
2428 ];
2429
2430 $offsets = [];
2431
2432 foreach ($types as $type) {
2433 # Find the FIRST numbered instance in page content (not _tb_ suffixed)
2434 if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)(?!_tb_)/', $page_content, $m)) {
2435 $first_num = (int) $m[1];
2436 if ($first_num > 0) {
2437 $offsets[$type] = $first_num;
2438 }
2439 }
2440 }
2441
2442 if (empty($offsets)) {
2443 return $html;
2444 }
2445
2446 # Remap each element type back to 0-based numbering
2447 foreach ($offsets as $etype => $offset) {
2448 $escaped = preg_quote($etype, '/');
2449 $html = preg_replace_callback(
2450 '/et_pb_' . $escaped . '_(\d+)(?!_tb_)/',
2451 function ($m) use ($etype, $offset) {
2452 $num = (int) $m[1];
2453 if ($num >= $offset) {
2454 return 'et_pb_' . $etype . '_' . ($num - $offset);
2455 }
2456 return $m[0];
2457 },
2458 $html
2459 );
2460 }
2461
2462 # Remove duplicate JS variable declarations from the shortcode framework
2463 foreach (['et_pb_custom', 'et_frontend_scripts', 'et_builder_utils_params'] as $var_name) {
2464 $needle = 'var ' . $var_name . ' = ';
2465 $first = strpos($html, $needle);
2466 if ($first !== false) {
2467 $second = strpos($html, $needle, $first + strlen($needle));
2468 if ($second !== false) {
2469 $end = strpos($html, ";\n", $second);
2470 if ($end !== false) {
2471 $html = substr($html, 0, $second) . substr($html, $end + 2);
2472 }
2473 }
2474 }
2475 }
2476
2477 # Deduplicate animation data (shortcode framework creates duplicate entries)
2478 if (preg_match('/var diviElementAnimationData = (\[.*?\]);/s', $html, $am)) {
2479 $data = json_decode($am[1], true);
2480 if (is_array($data)) {
2481 $seen = [];
2482 $unique = [];
2483 foreach (array_reverse($data) as $e) {
2484 $k = $e['class'] ?? '';
2485 if ($k !== '' && !isset($seen[$k])) {
2486 $seen[$k] = true;
2487 array_unshift($unique, $e);
2488 }
2489 }
2490 if (count($unique) < count($data)) {
2491 $html = str_replace(
2492 $am[0],
2493 'var diviElementAnimationData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
2494 $html
2495 );
2496 }
2497 }
2498 }
2499
2500 # Deduplicate multiview data
2501 if (preg_match('/var diviElementMultiViewData = (\[.*?\]);/s', $html, $mv)) {
2502 $data = json_decode($mv[1], true);
2503 if (is_array($data)) {
2504 $seen = [];
2505 $unique = [];
2506 foreach (array_reverse($data) as $e) {
2507 $k = ($e['selector'] ?? '') . '|' . ($e['action'] ?? '');
2508 if ($k !== '|' && !isset($seen[$k])) {
2509 $seen[$k] = true;
2510 array_unshift($unique, $e);
2511 }
2512 }
2513 if (count($unique) < count($data)) {
2514 $html = str_replace(
2515 $mv[0],
2516 'var diviElementMultiViewData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
2517 $html
2518 );
2519 }
2520 }
2521 }
2522
2523 return $html;
2524 }
2525
2526
2527 }