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

2,216 lines 89.6 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 * Restore case-sensitive SVG and HTML5 attributes that SimpleHtmlDom lowercases.
67 * The DOM parser's $lowercase=true flag (required for find() queries) lowercases
68 * all attribute names, breaking case-sensitive attributes like viewBox.
69 * Applied on final HTML output after all DOM processing is complete.
70 */
71 private function restore_case_sensitive_attributes($html) {
72 static $map = [
73 ' viewbox=' => ' viewBox=',
74 ' preserveaspectratio=' => ' preserveAspectRatio=',
75 ' controlslist=' => ' controlsList=',
76 ];
77 return str_ireplace(array_keys($map), array_values($map), $html);
78 }
79
80 #
81 function __construct($otto_uuid){
82
83 # set the site uuid using the provided string
84 $this->site_uuid = $otto_uuid;
85
86 # Use endpoint manager if available, otherwise fallback to production
87 if (class_exists('Metasync_Endpoint_Manager')) {
88 $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
89 } else {
90 $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details';
91 }
92
93 # laod the simple html dom parser with UTF-8 charset to handle special characters
94 $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
95 }
96
97 /**
98 * Check Route Method
99 * @param route : The route to check
100 * @param path : The path of the html file to save
101 */
102 function process_route($route, $file_path){
103
104 # Construct the full endpoint URL with query parameters
105 $url_with_params = add_query_arg(
106 [
107 'url' => $route,
108 'uuid' => $this->site_uuid,
109 ],
110 $this->otto_end_point
111 );
112
113 # PERFORMANCE FIX: Add timeout to prevent blocking
114 $args = array(
115 'timeout' => 5, // 5 second max timeout (allow time for redirects)
116 'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301)
117 'user-agent' => 'MetaSync-OTTO-SSR/2.0',
118 'sslverify' => true
119 );
120
121 # Perform the GET request with timeout
122 $response = wp_remote_get($url_with_params, $args);
123
124 # Check for errors
125 if (is_wp_error($response)) {
126 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
127 return false;
128 }
129
130 # get the response body
131 $body = wp_remote_retrieve_body($response);
132
133 # Get the response code
134 $response_code = wp_remote_retrieve_response_code($response);
135
136 # if no change data skip
137 if (empty($body) || $response_code !== 200){
138 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
139 return false;
140 }
141
142 # set the html file path
143 $this->html_file = $file_path;
144
145 # load change data
146 $change_data = json_decode($body, true);
147
148 # Process with the fetched data
149 return $this->process_route_with_data($route, $change_data, $file_path);
150 }
151
152 /**
153 * Process route with pre-fetched suggestions data
154 * OPTION 1: Used when data comes from transient cache
155 * @param route : The route to check
156 * @param change_data : Pre-fetched OTTO suggestions data
157 * @param path : The path of the html file to save
158 */
159 function process_route_with_data($route, $change_data, $file_path){
160
161 if (empty($change_data) || !is_array($change_data)) {
162 return false;
163 }
164
165 # set the html file path
166 $this->html_file = $file_path;
167
168 # Analyze what Otto is providing and store for conditional SEO blocking
169 $has_otto_title = false;
170 $otto_description_tags = []; // Track specific description tags Otto provides
171
172 if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) {
173 foreach ($change_data['header_replacements'] as $item) {
174 if (!empty($item['type'])) {
175 # Check if Otto has title
176 if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
177 $has_otto_title = true;
178 }
179 # Check if Otto has description - track specific tag types
180 if ($item['type'] == 'meta') {
181 # Check for meta[name=description]
182 if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
183 $otto_description_tags[] = 'meta[name=description]';
184 }
185 # Check for meta[property=og:description]
186 if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
187 $otto_description_tags[] = 'meta[property=og:description]';
188 }
189 # Check for meta[name=twitter:description]
190 if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
191 $otto_description_tags[] = 'meta[name=twitter:description]';
192 }
193 }
194 }
195 }
196 }
197
198 # Check header_html_insertion for description - must have non-empty content value
199 if (!empty($change_data['header_html_insertion'])) {
200 if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) {
201 $otto_description_tags[] = 'meta[name=description]';
202 }
203 }
204
205 # Remove duplicates
206 $otto_description_tags = array_unique($otto_description_tags);
207
208 # Store blocking flags to pass to handle_route_html
209 # This will be added to the internal fetch URL as parameters
210 $change_data['_otto_blocking'] = array(
211 'block_title' => $has_otto_title,
212 'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove
213 );
214
215 # Process the route with the suggestions data
216 return $this->handle_route_html($route, $change_data);
217
218 }
219
220 # function to get tag attributes
221 function get_tag_attributes($tag){
222
223 # Extract existing attributes of the <body> tag
224 $attributes = [];
225
226
227 # set the tag attributes
228 $tag_attributes = [];
229
230
231 # check that the tag attributes
232 if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){
233 return '';
234 }
235
236 # get the tag attributes
237 $tag_attributes = $tag->getAllAttributes();
238
239 # loop all attributes
240 foreach ($tag_attributes as $key => $value) {
241
242 if ($value == 1) {
243
244 # Handle boolean attributes
245 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
246 } else {
247
248 # Handle attributes with values
249 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
250 }
251 }
252
253 # Convert attributes array to a string
254 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
255
256 # return the attributes string
257 return $attributes_string;
258 }
259
260 function handle_route_html($route, $replacement_data){
261
262 # Detect if current page uses Brizy and disable SG Cache if so
263 # Using global function defined in otto_pixel.php
264 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
265 metasync_otto_disable_sg_cache_for_brizy();
266 }
267
268 # lablel the Otto Route
269 # label otto requests to avoid loops
270 // $request_body = add_query_arg(
271 // [
272 // 'is_otto_page_fetch' => 1
273 // ],
274 // $route
275 // );
276 # Add blocking flags as URL parameters (no database writes!)
277 $url_params = ['is_otto_page_fetch' => 1];
278
279 # Add blocking flags if available
280 if (!empty($replacement_data['_otto_blocking'])) {
281 $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0';
282 # For HTTP fetch path, check if any description tags need blocking
283 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
284 $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0';
285 }
286
287 $request_body = add_query_arg($url_params, $route);
288
289 # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.)
290 # and loopback requests fail, try using localhost instead
291 $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route);
292 # set cookie header var
293 $cookie_header = '';
294
295 # loop cookies to set header
296 foreach ($_COOKIE as $name => $value) {
297
298 # handle array values by converting to string
299 $cookie_value = is_array($value) ? serialize($value) : $value;
300
301 # add cookie to header
302 # $cookie_header .= $name . '=' . $value . '; ';
303 $cookie_header .= $name . '=' . $cookie_value . '; ';
304 }
305
306 # trim the string
307 $cookie_header = rtrim($cookie_header, '; ');
308
309 # Allow timeout customization for slow tunnel environments
310 $fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5);
311
312 $args = array(
313 'sslverify' => false, // Disabled for localhost/tunnel environments
314 'timeout' => $fetch_timeout, // Configurable timeout for tunnels
315 'redirection' => 5,
316 'httpversion' => '1.1',
317 'headers' => array(
318 'Cookie' => $cookie_header,
319 'Cache-Control' => 'no-cache, no-store, must-revalidate',
320 'Pragma' => 'no-cache',
321 'X-OTTO-Internal-Fetch' => '1',
322 'User-Agent' => 'MetaSync-OTTO-SSR/3.0',
323 'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels
324 )
325 );
326
327 # get the associateed route html
328 $route_html = wp_remote_get($request_body, $args);
329
330 # Check for timeout or connection errors
331 if (is_wp_error($route_html)) {
332 $error_msg = $route_html->get_error_message();
333 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
334 return false;
335 }
336
337 # get body
338 $html_body = wp_remote_retrieve_body($route_html);
339
340 # Get the response code
341 $response_code = wp_remote_retrieve_response_code($route_html);
342
343
344 # check not empty
345 if(empty($html_body) || $response_code !== 200){
346 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
347 return false;
348 }
349
350 # Remove XML declaration
351 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
352
353 # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
354 $html_body = $this->sanitize_text_less_than($html_body);
355
356 # now that the html is not empty
357 # load it into the simple html dom
358 $this->dom->load($html_body, true, false);
359
360 # Force UTF-8 charset to preserve emojis and special characters
361 # This overrides any charset detection from HTML meta tags
362 $this->dom->_charset = 'UTF-8';
363 $this->dom->_target_charset = 'UTF-8';
364
365 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
366 # This eliminates 8-10 full DOM traversals per page
367 $this->cache_elements();
368
369 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
370 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
371 $this->deferred_reload = true;
372
373 # now lets do the magic
374
375 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
376 # Some API versions return 'value' instead of 'recommended_value'
377 if (!empty($replacement_data['header_replacements'])) {
378 foreach ($replacement_data['header_replacements'] as &$item) {
379 if (isset($item['value']) && !isset($item['recommended_value'])) {
380 $item['recommended_value'] = $item['value'];
381 }
382 }
383 unset($item); // Break reference
384 }
385
386 # start the header html insertion
387 $this->insert_header_html($replacement_data);
388
389 # now we do the header replacements
390 $this->do_header_replacements($replacement_data);
391
392 # now do the body replacements
393 $this->do_body_replacements($replacement_data);
394
395 # now do the footer insertions
396 $this->do_footer_html_insertion($replacement_data);
397
398 # final cleanup: ensure metasync_optimized attribute is removed from AMP pages
399 $this->cleanup_amp_metasync_attribute();
400
401 # CRITICAL FIX: SimpleHtmlDom save() doesn't persist outertext/innertext changes
402 # Use the same manual string replacement approach as process_html_directly
403 $this->deferred_reload = false;
404
405 # Get the HTML as string
406 $result_html = $this->dom->save();
407
408 # Apply manual replacements (same logic as process_html_directly)
409
410 # Apply header replacements manually
411 if (!empty($replacement_data['header_replacements'])) {
412 foreach ($replacement_data['header_replacements'] as $item) {
413 $type = $item['type'] ?? '';
414 $value = $item['recommended_value'] ?? $item['value'] ?? '';
415
416 if (empty($value)) continue;
417
418 if ($type === 'title') {
419 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
420 $title_tag = '<title>' . $new_value . '</title>';
421 $result_html = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
422 return $title_tag;
423 }, $result_html, 1);
424 } elseif ($type === 'meta') {
425 $name = $item['name'] ?? '';
426 $property = $item['property'] ?? '';
427 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
428
429 if (!empty($name)) {
430 $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';
431 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
432 $count = 0;
433 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
434 return $replacement;
435 }, $result_html, -1, $count);
436
437 if ($count === 0) {
438 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
439 return $m[1] . "\n" . $replacement;
440 }, $result_html, 1);
441 }
442 } elseif (!empty($property)) {
443 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
444 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
445 $count = 0;
446 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
447 return $replacement;
448 }, $result_html, -1, $count);
449
450 if ($count === 0) {
451 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
452 return $m[1] . "\n" . $replacement;
453 }, $result_html, 1);
454 }
455 }
456 } elseif ($type === 'h1' || $type === 'heading') {
457 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
458 $result_html = preg_replace_callback(
459 '/<h1([^>]*)>.*?<\/h1>/is',
460 function ($m) use ($new_value) {
461 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
462 },
463 $result_html,
464 1
465 );
466 }
467 }
468 }
469
470 # String-based heading fallback for body_substitutions
471 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
472 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
473 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
474 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
475 continue;
476 }
477
478 $heading_type = preg_quote($heading['type'], '/');
479 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
480 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
481
482 $result_html = preg_replace_callback(
483 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
484 function ($m) use ($current_value, $recommended_value) {
485 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
486 if ($inner_text === $current_value) {
487 return $m[1] . $recommended_value . $m[3];
488 }
489 return $m[0];
490 },
491 $result_html,
492 -1
493 );
494 }
495 }
496
497 # CRITICAL FIX: Apply image alt text manually via string replacement
498 # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path
499 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
500
501 # Apply insertions — only if DOM insertion didn't already apply it
502 if (!empty($replacement_data['header_html_insertion'])) {
503 $header_html_check = trim($replacement_data['header_html_insertion']);
504 if (strpos($result_html, $header_html_check) === false) {
505 $header_html_insertion = preg_replace(
506 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
507 '<script$1type=$2application/ld+json$2 data-otto="true"',
508 $replacement_data['header_html_insertion']
509 );
510 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
511 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
512 }
513 }
514 if (!empty($replacement_data['body_top_html_insertion'])) {
515 $body_top_check = trim($replacement_data['body_top_html_insertion']);
516 if (strpos($result_html, $body_top_check) === false) {
517 $safe_body_top = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
518 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $safe_body_top, $result_html, 1);
519 }
520 }
521 if (!empty($replacement_data['body_bottom_html_insertion'])) {
522 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
523 if (strpos($result_html, $body_bottom_check) === false) {
524 $safe_body_bottom = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
525 $result_html = preg_replace('/(<\/body>)/i', $safe_body_bottom . "\n" . '$1', $result_html, 1);
526 }
527 }
528 if (!empty($replacement_data['footer_html_insertion'])) {
529 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
530 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
531 }
532
533 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
534 $result_html = $this->deduplicate_title_tags($result_html);
535 $result_html = $this->deduplicate_og_twitter_tags($result_html);
536 $result_html = $this->deduplicate_schema_tags($result_html);
537 $result_html = $this->deduplicate_canonical_tags($result_html);
538
539 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
540 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
541 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
542 }
543
544 $result_html = $this->restore_case_sensitive_attributes($result_html);
545
546 return $result_html;
547 }
548
549 # do the footer html insertion
550 function do_footer_html_insertion($replacement_data){
551
552 # check that we have footer html
553 if(empty($replacement_data['footer_html_insertion'])){
554 return;
555 }
556
557 # OPTIMIZED: Use cached element instead of DOM traversal
558 $footer = $this->get_cached_element('footer');
559
560 # check that footer is object
561 if(!is_object($footer) || !isset($footer->innertext, $footer->outertext)){
562 return;
563 }
564
565 # get the tag attributes
566 $attributes_string = $this->get_tag_attributes($footer);
567
568 # now do the actual html replacements
569 $footer->outertext = '<footer' . $attributes_string . '>' . $footer->innertext . $replacement_data['footer_html_insertion'].'</footer>';
570
571 # save the document
572 $this->save_reload();
573 }
574
575 # do body replacements
576 function do_body_replacements($replacement_data){
577
578 # start body top html replacements
579 $this->do_body_top_html($replacement_data);
580
581 # start the body bottom html replacements
582 $this->do_body_bottom_html($replacement_data);
583
584 # do the body substitutions
585 $this->do_body_substitutions($replacement_data);
586
587 # Check if the feature is enabled in general settings (Post/Page Editor Settings)
588 $general_settings = get_option('metasync_options')['general'] ?? [];
589 if (!empty($general_settings['open_external_links']) && $general_settings['open_external_links'] == '1') {
590 $this->add_target_blank_to_external_links();
591 }
592
593 # Check if the feature is enabled in seo_controls settings (Indexation Control)
594 $seo_controls = get_option('metasync_options')['seo_controls'] ?? [];
595 if (!empty($seo_controls['add_nofollow_to_external_links']) && $seo_controls['add_nofollow_to_external_links'] === 'true') {
596 $this->add_nofollow_to_external_links();
597 }
598 }
599
600 # body substitutions data
601 function do_body_substitutions($replacement_data){
602
603 # check that we have an array of substitutions
604 if(empty($replacement_data['body_substitutions']) || !is_array($replacement_data['body_substitutions'])){
605 return;
606 }
607
608 # now work on different substitution keys
609 foreach ($replacement_data['body_substitutions'] as $key => $value) {
610
611 # check key categories
612 if($key == 'images'){
613 # do image replacements
614 $this->handle_images($value);
615 }
616 elseif($key == 'headings'){
617 # do heading repalcements
618 $this->do_heading_body_substitutions($value);
619 }
620 elseif($key == 'links'){
621 # do link replacements
622 $this->do_link_body_substitutions($value);
623 }
624
625 }
626
627 # save the document
628 $this->save_reload();
629
630 }
631
632 /**
633 * START BODY SUBSTITUTION FUNCTIONS
634 * @see do_body_substitutions();
635 */
636
637 # image substitions
638 function handle_images($image_data){
639
640 if (empty($image_data) || !is_array($image_data)) {
641 return;
642 }
643
644 # OPTIMIZED: Use cached images instead of DOM traversal
645 $images = $this->get_cached_element('imgs', []);
646
647 if (empty($images)) {
648 return;
649 }
650
651 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
652 # Single pass with hash map lookup instead of nested loop
653 foreach($images AS $key => $image){
654 # Get image src
655 $image_src = $image->src;
656
657 if (empty($image_src)) {
658 continue;
659 }
660
661 # Hash map lookup O(1) instead of loop O(n)
662 if (isset($image_data[$image_src])) {
663 # Set alt text - Note: This may not persist in all cases
664 # Manual string replacement in process_html_directly() ensures it's applied
665 $new_alt = htmlspecialchars($image_data[$image_src], ENT_QUOTES, 'UTF-8');
666
667 # Get current img tag HTML and update alt attribute
668 $current_html = $image->outertext;
669
670 # Remove existing alt attribute (if any)
671 $updated_html = preg_replace('/\s+alt=(["\'])[^"\']*\1/', '', $current_html);
672
673 # Insert new alt attribute after the opening <img
674 $updated_html = preg_replace('/^<img\s/', '<img alt="' . str_replace('$', '\\$', $new_alt) . '" ', $updated_html);
675
676 # Update the element
677 $image->outertext = $updated_html;
678
679 $multi_view_attr = $image->getAttribute('data-et-multi-view');
680 if (!empty($multi_view_attr)) {
681 $this->update_divi_multi_view_alt($image, $image_data[$image_src]);
682 }
683 }
684 }
685 }
686
687 /**
688 * Update Divi's multi-view data attribute with alt text
689 * Divi stores image attributes in a JSON structure within data-et-multi-view
690 *
691 * @param object $image The image DOM element
692 * @param string $alt_text The alt text to set
693 */
694 private function update_divi_multi_view_alt($image, $alt_text) {
695 $multi_view_attr = $image->getAttribute('data-et-multi-view');
696
697 if (!empty($multi_view_attr)) {
698 try {
699 # Decode the JSON
700 $multi_view_data = json_decode($multi_view_attr, true);
701
702 if ($multi_view_data && isset($multi_view_data['schema']['attrs'])) {
703 # Update alt in desktop view
704 if (isset($multi_view_data['schema']['attrs']['desktop'])) {
705 $multi_view_data['schema']['attrs']['desktop']['alt'] = $alt_text;
706 }
707
708 # Update alt in other views if they exist (phone, tablet, etc.)
709 foreach ($multi_view_data['schema']['attrs'] as $view => $attrs) {
710 if (isset($attrs['alt'])) {
711 $multi_view_data['schema']['attrs'][$view]['alt'] = $alt_text;
712 }
713 }
714
715 # Encode back to JSON and update the attribute
716 $updated_json = json_encode($multi_view_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
717 $image->setAttribute('data-et-multi-view', $updated_json);
718
719 # error_log('MetaSync OTTO DEBUG: Updated Divi multi-view alt text');
720 }
721 } catch (Exception $e) {
722 # If JSON decode fails, just log and continue
723 # error_log('MetaSync OTTO DEBUG: Failed to update Divi multi-view - ' . $e->getMessage());
724 }
725 }
726 }
727
728 /**
729 * Apply image alt text via string replacement on raw HTML.
730 * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites).
731 *
732 * @param string $html The HTML to process
733 * @param array $replacement_data The replacement data containing body_substitutions.images
734 * @return string Modified HTML with alt text applied
735 */
736 private function apply_image_alt_text_via_string($html, $replacement_data) {
737 if (empty($replacement_data['body_substitutions']['images']) || !is_array($replacement_data['body_substitutions']['images'])) {
738 return $html;
739 }
740
741 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
742 if (empty($alt_text) || strpos($html, $image_url) === false) {
743 continue;
744 }
745
746 $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
747 $escaped_url = preg_quote($image_url, '/');
748 $img_pattern = '/<img[^>]*src=["\']' . $escaped_url . '["\'][^>]*>/i';
749
750 if (preg_match_all($img_pattern, $html, $img_matches)) {
751 foreach ($img_matches[0] as $original_img) {
752 if (strpos($original_img, $escaped_alt) !== false) {
753 continue;
754 }
755
756 # Remove ALL existing alt attributes
757 $new_img = preg_replace('/\s+alt\s*=\s*(["\'])[^"\']*\1/i', '', $original_img);
758 $new_img = preg_replace('/<img\s+alt\s*=\s*(["\'])[^"\']*\1\s*/i', '<img ', $new_img);
759
760 # Add single alt attribute after <img
761 $new_img = preg_replace('/^<img\s*/i', '<img alt="' . str_replace('$', '\\$', $escaped_alt) . '" ', $new_img);
762
763 # Update data-et-multi-view JSON if present (Divi theme)
764 if (strpos($new_img, 'data-et-multi-view') !== false) {
765 $new_img = preg_replace_callback(
766 '/data-et-multi-view="([^"]+)"/i',
767 function($mv_matches) use ($alt_text) {
768 $json_str = html_entity_decode($mv_matches[1], ENT_QUOTES, 'UTF-8');
769 $json_data = json_decode($json_str, true);
770
771 if ($json_data && isset($json_data['schema']['attrs'])) {
772 foreach ($json_data['schema']['attrs'] as &$attrs) {
773 if (array_key_exists('alt', $attrs)) {
774 $attrs['alt'] = $alt_text;
775 }
776 }
777 unset($attrs);
778 $new_json = json_encode($json_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
779 return 'data-et-multi-view="' . str_replace('"', '&quot;', $new_json) . '"';
780 }
781 return $mv_matches[0];
782 },
783 $new_img
784 );
785 }
786
787 $html = str_replace($original_img, $new_img, $html);
788 }
789 }
790 }
791
792 return $html;
793 }
794
795 # heading substitutions
796 function do_heading_body_substitutions($heading_data){
797
798 # loop all data
799 foreach($heading_data AS $okey => $heading){
800
801 # find all occurences of the heading type
802 $occurences = $this->dom->find($heading['type']);
803
804 # loop all occurences
805 foreach ($occurences as $ikey => $heading_old) {
806
807 # get the header text
808 $text = $heading_old->text();
809
810 # check matching text
811 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
812
813 # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
814 $outer = $heading_old->outertext;
815 $open_end = strpos($outer, '>');
816 if ($open_end !== false) {
817 $open_tag = substr($outer, 0, $open_end + 1);
818 $close_tag = '</' . $heading['type'] . '>';
819 $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
820 }
821
822 }
823 }
824
825 }
826
827 # save and reload the dom
828 $this->save_reload();
829 }
830
831 # link replacements
832 function do_link_body_substitutions($swap_data){
833
834 # find all links in the body
835 $links = $this->dom->find('a');
836
837 # loop all links check if we have them in swap data
838 foreach ($links as $key => $link) {
839
840 # check if link matches
841 if(!empty($swap_data[$link->href])){
842
843 # replace the link
844 $link->href = $swap_data[$link->href];
845 }
846
847 }
848 }
849
850 /**
851 * Add rel="nofollow" attribute to external links
852 * Works for both buffer and HTTP rendering methods
853 * Only processes links that don't already have rel="nofollow"
854 */
855 function add_nofollow_to_external_links(){
856
857 # find all links in the body
858 $links = $this->dom->find('a');
859
860 # get home URL for comparison
861 $home_url = rtrim(home_url(), '/');
862 $home_url_lower = strtolower($home_url);
863
864 # loop through all links
865 foreach ($links as $key => $link) {
866
867 # get the href attribute
868 $href = $link->href ?? '';
869
870 # skip if href is empty
871 if (empty($href)) {
872 continue;
873 }
874
875 # check if link is external
876 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
877 # get existing rel attribute
878 $existing_rel = $link->rel ?? '';
879
880 # check if nofollow already exists
881 if (empty($existing_rel)) {
882 # no rel attribute, add nofollow
883 $link->rel = 'nofollow';
884 } elseif (strpos($existing_rel, 'nofollow') === false) {
885 # rel exists but nofollow is not present, add it
886 $link->rel = trim($existing_rel . ' nofollow');
887 }
888 # if nofollow already exists, do nothing
889 }
890 }
891
892 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
893 # This avoids redundant DOM save/reload operations and improves performance
894 }
895
896 /**
897 * Check if a URL is external
898 *
899 * @param string $url The URL to check
900 * @param string $home_url The home URL without trailing slash
901 * @param string $home_url_lower Lowercase version of home URL
902 * @return bool True if external, false if internal
903 */
904 private function is_external_link($url, $home_url, $home_url_lower) {
905 # Empty or anchor-only links are internal
906 if (empty($url) || $url === '#' || strpos($url, '#') === 0) {
907 return false;
908 }
909
910 # Relative URLs (starting with /) are internal
911 if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
912 return false;
913 }
914
915 # Check if URL starts with home URL (case-insensitive)
916 $url_lower = strtolower($url);
917 if (strpos($url_lower, $home_url_lower) === 0) {
918 return false;
919 }
920
921 # If it's a protocol-relative URL (//example.com), check if it matches our domain
922 if (strpos($url, '//') === 0) {
923 $parsed_home = parse_url($home_url);
924 $parsed_link = parse_url($url);
925
926 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
927 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
928 return false;
929 }
930 }
931 }
932
933 # All other URLs are considered external
934 return true;
935 }
936
937 /**
938 * Add target="_blank" attribute to external links
939 * Works for both buffer and HTTP rendering methods
940 * Only processes links that don't already have a target attribute
941 */
942 function add_target_blank_to_external_links(){
943
944 # find all links in the body
945 $links = $this->dom->find('a');
946
947 # get home URL for comparison
948 $home_url = rtrim(home_url(), '/');
949 $home_url_lower = strtolower($home_url);
950
951 # loop through all links
952 foreach ($links as $key => $link) {
953
954 # get the href attribute
955 $href = $link->href ?? '';
956
957 # skip if href is empty or already has target attribute
958 if (empty($href) || !empty($link->target)) {
959 continue;
960 }
961
962 # check if link is external
963 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
964 # add target="_blank" attribute
965 $link->target = '_blank';
966
967 # add rel="noopener noreferrer" for security
968 $existing_rel = $link->rel ?? '';
969 if (empty($existing_rel)) {
970 $link->rel = 'noopener noreferrer';
971 } elseif (strpos($existing_rel, 'noopener') === false) {
972 $link->rel = trim($existing_rel . ' noopener noreferrer');
973 }
974 }
975 }
976
977 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
978 # This avoids redundant DOM save/reload operations and improves performance
979 }
980
981 # body bottom html replacement code
982 function do_body_bottom_html($insert_data){
983
984 # check that data is availbale
985 if(empty($insert_data['body_bottom_html_insertion'])){
986 return;
987 }
988
989 # OPTIMIZED: Use cached body element
990 $body = $this->get_cached_element('body');
991
992 # set the link property if not empty
993 if(empty($body->outertext)){
994 return;
995 }
996
997
998 # get the tag attributes
999 $attributes_string = $this->get_tag_attributes($body);
1000
1001 # now do the actual html replacements
1002 $body->outertext = '<body' . $attributes_string . '>' . $body->innertext . $insert_data['body_bottom_html_insertion'].'</body>';
1003
1004 # save the document
1005 $this->save_reload();
1006 }
1007
1008 # body top html replacement
1009 function do_body_top_html($insert_data){
1010
1011 # check that data is availbale
1012 if(empty($insert_data['body_top_html_insertion'])){
1013 return;
1014 }
1015
1016 # OPTIMIZED: Use cached body element
1017 $body = $this->get_cached_element('body');
1018
1019 # get the tag attributes
1020 $attributes_string = $this->get_tag_attributes($body);
1021
1022 # now do the actual html replacements
1023 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1024 }
1025
1026 # this function does the header replacements
1027 function do_header_replacements($replacement_data){
1028
1029 # check that we have header replacements
1030 if(empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])){
1031 return;
1032 }
1033
1034 # Check for custom SEO values from MetaSync SEO Sidebar
1035 # Custom values take absolute priority over OTTO suggestions
1036 $custom_seo_title = '';
1037 $custom_seo_description = '';
1038
1039 if (function_exists('is_singular') && is_singular()) {
1040 $post_id = get_the_ID();
1041 if ($post_id) {
1042 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
1043 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1044 }
1045 }
1046
1047 # now lets do the replacement work
1048 foreach($replacement_data['header_replacements'] AS $key => $data){
1049
1050 # skip cases where type is not specified
1051 if(empty($data['type'])){
1052 continue;
1053 }
1054
1055 # handle title - skip if custom SEO title exists or no value
1056 if($data['type'] == 'title'){
1057 if (!empty($custom_seo_title)) {
1058 # Skip title replacement - custom SEO title takes priority
1059 continue;
1060 }
1061
1062 # Skip if OTTO has no title value
1063 if (empty(trim($data['recommended_value'] ?? $data['value'] ?? ''))) {
1064 continue;
1065 }
1066
1067 # handle the title logic
1068 $this->replace_title($data);
1069
1070 #
1071 continue;
1072 }
1073
1074 # handle canonical links
1075 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1076
1077 # Protect manually-set canonical from OTTO override
1078 if (function_exists('is_singular') && is_singular()) {
1079 $post_id = get_the_ID();
1080 if ($post_id) {
1081 $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1082 if (empty($custom_canonical)) {
1083 $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1084 // Handle legacy array values
1085 if (is_array($custom_canonical)) {
1086 $custom_canonical = reset($custom_canonical) ?: '';
1087 }
1088 }
1089 if (!empty($custom_canonical)) {
1090 # Manual canonical takes priority — skip OTTO override
1091 continue;
1092 }
1093 }
1094 }
1095
1096 # find the cannonical dom element
1097 $link = $this->dom->find('link[rel="canonical"]', 0);
1098
1099 # set the link property if not empty
1100 if(!empty($link->href)){
1101 $link->href = $data['recommended_value'] ?? $link->href;
1102 }
1103
1104 #
1105 continue;
1106 }
1107
1108 # work on other elemenets not titlte
1109 $this->handle_meta_element($data);
1110 }
1111 }
1112
1113 # function to handle meta elements other than title
1114 function handle_meta_element($data){
1115
1116 # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
1117 # with empty content when OTTO has no recommendation for this meta field
1118 $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
1119 if (empty(trim($recommended_value))) {
1120 return;
1121 }
1122
1123 # Check if this is a description meta tag and if custom description exists
1124 # Custom values take absolute priority over OTTO suggestions
1125 $name = $data['name'] ?? false;
1126 $property = $data['property'] ?? false;
1127
1128 # Protect manually-set robots meta from OTTO override
1129 # If the user set noindex via Common Robots Meta or meta_robots, honour it
1130 if (!empty($name) && $name === 'robots') {
1131 if (function_exists('is_singular') && is_singular()) {
1132 $post_id = get_the_ID();
1133 if ($post_id) {
1134 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
1135 if (!empty($manual_robots) && stripos($manual_robots, 'noindex') !== false) {
1136 # Manual noindex takes priority — skip OTTO override
1137 return;
1138 }
1139 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1140 if (is_array($common_robots) && !empty($common_robots['noindex'])) {
1141 # Common Robots Meta has noindex checked — skip OTTO override
1142 return;
1143 }
1144 }
1145 }
1146 }
1147
1148 # Check for custom SEO description
1149 if (!empty($name) && $name === 'description') {
1150 if (function_exists('is_singular') && is_singular()) {
1151 $post_id = get_the_ID();
1152 if ($post_id) {
1153 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1154 if (!empty($custom_seo_description)) {
1155 # Custom description exists, skip OTTO's suggestion
1156 return;
1157 }
1158 }
1159 }
1160 }
1161
1162 # extract property value
1163 $property = $data['property'] ?? false;
1164
1165 # extract name value
1166 $name = $data['name'] ?? false;
1167
1168 # set the selector
1169 $meta_selector = '';
1170
1171 # extend selector
1172 if(!empty($name)){
1173 $meta_selector .= 'meta[name="' . trim($name) . '"]';
1174 }
1175
1176 # extent if property is defined
1177 if(!empty($property)){
1178 if (!empty($meta_selector)) {
1179 $meta_selector .= ',';
1180 }
1181 $meta_selector .= 'meta[property="' . trim($property) . '"]';
1182 }
1183
1184 # find the meta gat in the dom
1185 $meta_tag = $this->dom->find($meta_selector, 0);
1186
1187 # if tag not exists add it
1188 if(empty($meta_tag)){
1189 if($data['type'] == 'meta'){
1190
1191 # get the attribute
1192 $attribute = $property ? 'property' : 'name';
1193
1194 # call the create metatag function
1195 $result = $this->create_metatag($attribute, $data);
1196 }
1197
1198 # return after creation
1199 return;
1200 }
1201
1202 # CRITICAL FIX: Clear cache and get fresh reference
1203 # Preserve 'imgs' key for later use by handle_images()
1204 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1205 $this->cached_elements = [];
1206 if ($preserved_imgs !== null) {
1207 $this->cached_elements['imgs'] = $preserved_imgs;
1208 }
1209
1210 # Get fresh meta tag reference using same selector
1211 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
1212
1213 if ($meta_tag_fresh) {
1214 # Use outertext for replacement
1215 $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8');
1216
1217 # Determine attribute name
1218 $attr_name = !empty($data['name']) ? 'name' : 'property';
1219 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
1220
1221 # Build new meta tag
1222 $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1223 }
1224
1225
1226 }
1227
1228 # function to handle the page title
1229 function replace_title($title_data){
1230
1231 # find the title
1232 $title = $this->dom->find('title', 0) ?? false;
1233
1234 # if none
1235 if($title === false){
1236 return $this->create_title($title_data);
1237 }
1238
1239 # CRITICAL FIX: Clear element cache and get fresh reference
1240 # Preserve 'imgs' key for later use by handle_images()
1241 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1242 $this->cached_elements = [];
1243 if ($preserved_imgs !== null) {
1244 $this->cached_elements['imgs'] = $preserved_imgs;
1245 }
1246
1247 # Get fresh title element reference
1248 $title_fresh = $this->dom->find('title', 0);
1249
1250 if ($title_fresh) {
1251 # Use outertext for replacement
1252 $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
1253 $title_fresh->outertext = '<title>' . $new_value . '</title>';
1254 }
1255
1256 # Don't save_reload here - will happen at the end
1257 # $this->save_reload();
1258 }
1259
1260 # Function to create a title when it's missing
1261 function create_title($title_data) {
1262
1263 # Find the <head> tag
1264 $head = $this->dom->find('head', 0);
1265
1266 # Construct the <title> tag HTML
1267 $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';
1268
1269 if (empty($head)) {
1270 return false;
1271 }
1272
1273 # Extract existing attributes of the <head> tag
1274 $attributes = [];
1275
1276 # get the tag attributes
1277 $tag_attributes = $head->getAllAttributes();
1278
1279 # loop all attributes
1280 foreach ($tag_attributes as $key => $value) {
1281
1282 if ($value == 1) {
1283
1284 # Handle boolean attributes
1285 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1286 } else {
1287
1288 # Handle attributes with values
1289 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1290 }
1291 }
1292
1293 # Convert attributes array to a string
1294 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1295
1296 # Rebuild the <head> tag, inserting the <title> at the beginning
1297 $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';
1298
1299 # save and reload DOM
1300 $this->save_reload();
1301 }
1302
1303 # function to create meta tag if none existss
1304 function create_metatag($attribute, $data){
1305
1306 # Find the <head> tag
1307 $head = $this->dom->find('head', 0);
1308
1309 # Construct the meta tag HTML (no spaces around = for standard HTML and regex compatibility)
1310 $meta_tag = '<meta '.$attribute.'="'.htmlspecialchars($data[$attribute], ENT_QUOTES, 'UTF-8').'" content="'.htmlspecialchars($data['recommended_value'], ENT_QUOTES, 'UTF-8').'">';
1311
1312 if (empty($head)) {
1313 return false;
1314 }
1315
1316 # Extract existing attributes of the <head> tag
1317 $attributes = [];
1318
1319 # get the tag attributes
1320 $tag_attributes = $head->getAllAttributes();
1321
1322 # loop all attributes
1323 foreach ($tag_attributes as $key => $value) {
1324
1325 if ($value == 1) {
1326
1327 # Handle boolean attributes
1328 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1329 } else {
1330
1331 # Handle attributes with values
1332 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1333 }
1334 }
1335
1336 # Convert attributes array to a string
1337 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1338
1339 # Rebuild the <head> tag, inserting the <title> at the beginning
1340 $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';
1341
1342 # save and reload DOM
1343 $this->save_reload();
1344 }
1345
1346 /**
1347 * Remove duplicate <title> tags from HTML, keeping the first (OTTO's) value.
1348 *
1349 * OTTO's title replacement is always applied first (limit=1 or DOM manipulation),
1350 * so the first <title> tag holds the authoritative value. If SEO plugin conflicts
1351 * produce additional <title> tags, this strips all and re-inserts one.
1352 *
1353 * @param string $html Full HTML document.
1354 * @return string HTML with at most one <title> tag.
1355 */
1356 private function deduplicate_title_tags($html) {
1357 $title_count = preg_match_all('/<title[^>]*>.*?<\/title>/is', $html, $title_matches);
1358 if ($title_count <= 1) {
1359 return $html;
1360 }
1361
1362 # Capture the first title's inner text (OTTO's replacement)
1363 preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $first_title);
1364 $authoritative_title = isset($first_title[1]) ? $first_title[1] : '';
1365
1366 # Strip all <title> tags
1367 $html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html);
1368
1369 # Re-insert a single <title> after <head> using preg_replace_callback to prevent
1370 # backreference injection when title contains $ followed by digits (e.g. "$50 off")
1371 $title_tag = '<title>' . $authoritative_title . '</title>';
1372 $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1373 return $m[1] . $title_tag;
1374 }, $html, 1);
1375
1376 return $html;
1377 }
1378
1379 /**
1380 * Remove duplicate OG and Twitter meta tags from HTML after OTTO processing.
1381 *
1382 * OTTO injects its tags with `data-otto-pixel` or `data-otto` attributes.
1383 * Legacy MetaSync output and third-party SEO plugins may also emit the same
1384 * OG/Twitter properties. This method runs at the buffer level — after all
1385 * sources have written their tags — and keeps only the OTTO version when
1386 * duplicates exist.
1387 *
1388 * Strategy per property (e.g. og:description):
1389 * - If OTTO tag exists (has data-otto marker) → remove all non-OTTO duplicates
1390 * - If no OTTO tag exists → keep the first occurrence, remove the rest
1391 *
1392 * @param string $html Full HTML document.
1393 * @return string HTML with at most one tag per OG/Twitter property.
1394 */
1395 private function deduplicate_og_twitter_tags($html) {
1396 # OG properties to deduplicate
1397 $og_properties = [
1398 'og:title', 'og:description', 'og:url', 'og:type',
1399 'og:locale', 'og:site_name', 'og:image',
1400 ];
1401
1402 foreach ($og_properties as $prop) {
1403 $html = $this->deduplicate_meta_by_attr($html, 'property', $prop);
1404 }
1405
1406 # Twitter names to deduplicate
1407 $twitter_names = [
1408 'twitter:title', 'twitter:description', 'twitter:card',
1409 'twitter:image', 'twitter:site',
1410 ];
1411
1412 foreach ($twitter_names as $name) {
1413 $html = $this->deduplicate_meta_by_attr($html, 'name', $name);
1414 }
1415
1416 return $html;
1417 }
1418
1419 /**
1420 * Deduplicate meta tags by a specific attribute (property= or name=).
1421 *
1422 * When duplicates exist and one carries a data-otto marker, keep only
1423 * the OTTO version. Otherwise keep the first occurrence.
1424 *
1425 * @param string $html Full HTML.
1426 * @param string $attr_name Attribute name: 'property' or 'name'.
1427 * @param string $attr_val Attribute value: e.g. 'og:title' or 'twitter:description'.
1428 * @return string
1429 */
1430 private function deduplicate_meta_by_attr($html, $attr_name, $attr_val) {
1431 $escaped = preg_quote($attr_val, '/');
1432 # Match all <meta> tags with this attribute value (both attr orderings)
1433 $pattern = '/<meta\s[^>]*' . preg_quote($attr_name, '/') . '\s*=\s*["\']' . $escaped . '["\'][^>]*\/?>/i';
1434
1435 if (preg_match_all($pattern, $html, $matches) <= 1) {
1436 return $html; # 0 or 1 — nothing to deduplicate
1437 }
1438
1439 $all_tags = $matches[0];
1440
1441 # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
1442 $otto_tag = null;
1443 foreach ($all_tags as $tag) {
1444 if (stripos($tag, 'data-otto') !== false) {
1445 $otto_tag = $tag;
1446 break;
1447 }
1448 }
1449
1450 # Determine the keeper: OTTO tag if present, otherwise the first tag
1451 $keeper = $otto_tag ?: $all_tags[0];
1452
1453 # Remove all occurrences, then re-insert the keeper at the first position
1454 $first_replaced = false;
1455 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1456 if (!$first_replaced) {
1457 $first_replaced = true;
1458 return $keeper;
1459 }
1460 return ''; # Remove subsequent duplicates
1461 }, $html);
1462
1463 return $html;
1464 }
1465
1466 /**
1467 * Remove duplicate <link rel="canonical"> tags from HTML.
1468 *
1469 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1470 * SEO output (or WordPress core) has already emitted one, keep only the
1471 * OTTO version (identified by data-otto marker). If no OTTO tag exists,
1472 * keep the first occurrence.
1473 *
1474 * @param string $html Full HTML document.
1475 * @return string HTML with at most one canonical tag.
1476 */
1477 private function deduplicate_canonical_tags($html) {
1478 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
1479
1480 if (preg_match_all($pattern, $html, $matches) <= 1) {
1481 return $html;
1482 }
1483
1484 $all_tags = $matches[0];
1485
1486 $otto_tag = null;
1487 foreach ($all_tags as $tag) {
1488 if (stripos($tag, 'data-otto') !== false) {
1489 $otto_tag = $tag;
1490 break;
1491 }
1492 }
1493
1494 $keeper = $otto_tag ?: $all_tags[0];
1495
1496 $first_replaced = false;
1497 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1498 if (!$first_replaced) {
1499 $first_replaced = true;
1500 return $keeper;
1501 }
1502 return '';
1503 }, $html);
1504
1505 return $html;
1506 }
1507
1508 /**
1509 * Deduplicate JSON-LD schema blocks.
1510 *
1511 * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1512 * blocks, keep OTTO's version for any @type that appears in both.
1513 * Third-party blocks whose @type is not covered by OTTO are preserved.
1514 *
1515 * @param string $html Full HTML.
1516 * @return string
1517 */
1518 private function deduplicate_schema_tags($html) {
1519 // Find all JSON-LD script blocks
1520 $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1521 if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
1522 return $html;
1523 }
1524
1525 $otto_by_type = []; // @type => decoded JSON object
1526 $third_by_type = []; // @type => decoded JSON object
1527 $otto_graph = []; // entries from OTTO @graph blocks
1528 $third_graph = []; // entries from third-party @graph blocks
1529
1530 foreach ($matches as $m) {
1531 $attrs = $m[1];
1532 $json_str = $m[3];
1533 $decoded = json_decode($json_str, true);
1534 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1535 continue; // skip unparseable blocks — leave them in place
1536 }
1537 $is_otto = stripos($attrs, 'data-otto') !== false;
1538
1539 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1540 foreach ($decoded['@graph'] as $entry) {
1541 if (!isset($entry['@type'])) continue;
1542 // JSON-LD allows @type to be a string OR an array of strings.
1543 // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1544 // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1545 $type = is_array($entry['@type'])
1546 ? (string) reset($entry['@type'])
1547 : (string) $entry['@type'];
1548 if ($type === '') continue;
1549 if ($is_otto) {
1550 $otto_graph[$type] = $entry;
1551 } else {
1552 $third_graph[$type] = $entry;
1553 }
1554 }
1555 } elseif (isset($decoded['@type'])) {
1556 $type = is_array($decoded['@type'])
1557 ? (string) reset($decoded['@type'])
1558 : (string) $decoded['@type'];
1559 if ($type === '') continue;
1560 if ($is_otto) {
1561 $otto_by_type[$type] = $decoded;
1562 } else {
1563 $third_by_type[$type] = $decoded;
1564 }
1565 }
1566 }
1567
1568 // If OTTO provided no schema at all, nothing to deduplicate
1569 if (empty($otto_by_type) && empty($otto_graph)) {
1570 return $html;
1571 }
1572
1573 // Remove all JSON-LD blocks from HTML
1574 $html = preg_replace($pattern, '', $html);
1575
1576 // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
1577 $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
1578 $rebuilt = '';
1579 foreach ($kept as $decoded) {
1580 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1581 wp_json_encode($decoded) . "</script>\n";
1582 }
1583
1584 // Re-insert merged @graph block (if any entries exist)
1585 $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
1586 if (!empty($merged_graph)) {
1587 $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
1588 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1589 wp_json_encode($graph_obj) . "</script>\n";
1590 }
1591
1592 // Re-inject before </head>
1593 if (!empty($rebuilt)) {
1594 $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
1595 return $rebuilt . $m[1];
1596 }, $html, 1);
1597 }
1598
1599 return $html;
1600 }
1601
1602 # function to detect if current page is an AMP page
1603 function is_amp_page(){
1604
1605 # Check if URL path contains /amp/
1606 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1607 if (strpos($current_url, '/amp/') !== false) {
1608 return true;
1609 }
1610
1611 # Check if URL ends with /amp
1612 if (preg_match('/\/amp\/?$/', $current_url)) {
1613 return true;
1614 }
1615
1616 # Check if amp=1 query parameter is present
1617 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
1618 return true;
1619 }
1620
1621 # Check for other common AMP query parameters
1622 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
1623 return true;
1624 }
1625
1626 return false;
1627 }
1628
1629 # this function insterts header html to the dom
1630 function insert_header_html($data){
1631
1632 # check that we have the header html
1633 if(empty($data['header_html_insertion'])){
1634 #
1635 return;
1636 }
1637
1638 # append the/ html at the start of the header
1639 $head = $this->dom->find('head', 0);
1640
1641 if ($head) {
1642
1643 # Check if this is an AMP page - if so, don't add metasync_optimized attribute
1644 $is_amp_page = $this->is_amp_page();
1645
1646 # Append the new HTML at the start of the <head> tag
1647 # For AMP pages: use clean <head> tag without metasync_optimized attribute
1648 # For non-AMP pages: add metasync_optimized attribute to <head> tag
1649 if ($is_amp_page) {
1650 $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1651 } else {
1652 $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1653 }
1654
1655 }
1656
1657 # save and reload DOM
1658 $this->save_reload();
1659 }
1660
1661 # function to forcefully remove metasync_optimized attribute from head on AMP pages
1662 function cleanup_amp_metasync_attribute(){
1663
1664 # Only proceed if this is an AMP page
1665 if (!$this->is_amp_page()) {
1666 return;
1667 }
1668
1669 # Find the head tag
1670 $head = $this->dom->find('head', 0);
1671
1672 if (!$head) {
1673 return;
1674 }
1675
1676 # Check if head has metasync_optimized attribute
1677 $head_html = $head->outertext;
1678
1679 # If metasync_optimized attribute is found, remove it
1680 if (strpos($head_html, 'metasync_optimized') !== false) {
1681
1682 # Remove the metasync_optimized attribute from the head tag
1683 # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
1684 $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
1685
1686 # Update the head element
1687 $head->outertext = $cleaned_head_html;
1688
1689 }
1690 }
1691
1692 /**
1693 * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1694 * Reduces 8-10 full DOM traversals to 1 initial traversal
1695 * Call this once after loading HTML, before processing
1696 */
1697 private function cache_elements() {
1698 if (!$this->dom) {
1699 return;
1700 }
1701
1702 # Cache all commonly accessed elements in one pass
1703 $this->cached_elements = [
1704 'html' => $this->dom->find('html', 0),
1705 'head' => $this->dom->find('head', 0),
1706 'body' => $this->dom->find('body', 0),
1707 'footer' => $this->dom->find('footer', 0),
1708 'title' => $this->dom->find('title', 0),
1709 'imgs' => $this->dom->find('img'),
1710 'links' => $this->dom->find('a'),
1711 'canonical' => $this->dom->find('link[rel="canonical"]', 0),
1712 ];
1713 }
1714
1715 /**
1716 * Get cached element by key, with fallback to DOM find
1717 * @param string $key Element key from cache
1718 * @param mixed $fallback Fallback value if not cached
1719 * @return mixed Cached element or fallback
1720 */
1721 private function get_cached_element($key, $fallback = null) {
1722 return $this->cached_elements[$key] ?? $fallback;
1723 }
1724
1725 # this function saves are reloads the dom for modifications to avoid conflict
1726 function save_reload(){
1727
1728 # PERFORMANCE OPTIMIZATION: Skip reload if deferred
1729 # This reduces multiple serialize/deserialize cycles to just one final reload
1730 if ($this->deferred_reload) {
1731 return;
1732 }
1733
1734 # Cleanup metasync_optimized attribute on AMP pages before saving
1735 $this->cleanup_amp_metasync_attribute();
1736
1737 # DISABLED: Cache file creation temporarily disabled
1738
1739 # if(file_put_contents($this->html_file, $this->dom)){
1740
1741 # load the modified file to the DOM
1742 # $this->dom = new HtmlDocument($this->html_file );
1743 # }
1744
1745 # this code is to be replaced in future
1746 # reson for adding is to prevent caching logged in user pages
1747 # why not just skip saving? it broke the DOM Library
1748 # check user is logged in clear the file
1749
1750 # if(is_user_logged_in()) {
1751 # unlink($this->html_file);
1752 # }
1753
1754 # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
1755 # This prevents DOM breaking while avoiding cache file creation
1756 if($this->dom){
1757 # Get current DOM as HTML string
1758 $current_html = $this->dom->save();
1759
1760 # Reload DOM from the HTML string to refresh internal state
1761 # This replaces the file save/reload cycle that SimpleHtmlDOM expects
1762 $current_html = $this->sanitize_text_less_than($current_html);
1763 $this->dom->load($current_html, true, false);
1764
1765 # Force UTF-8 charset after reload to preserve emojis
1766 $this->dom->_charset = 'UTF-8';
1767 $this->dom->_target_charset = 'UTF-8';
1768 }
1769
1770 }
1771
1772 /**
1773 * Process HTML directly without HTTP request (for buffer approach)
1774 * This is the FAST path - eliminates the internal wp_remote_get call
1775 *
1776 * CRITICAL: This method is called from the output buffer callback.
1777 * If it fails, we must return false so the original HTML can be used.
1778 *
1779 * @param string $html The raw HTML captured from output buffer
1780 * @param array $replacement_data OTTO suggestions/replacement data
1781 * @return HtmlDocument|false Modified DOM or false on failure
1782 * @since 2.6.0
1783 */
1784 function process_html_directly($html, $replacement_data) {
1785 try {
1786 # Validate inputs
1787 if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
1788 return false;
1789 }
1790
1791 # Validate HTML is actual HTML content
1792 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
1793 return false;
1794 }
1795
1796 # Remove XML declaration if present
1797 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
1798
1799 # Escape bare < in text content before DOM parsing
1800 $html = $this->sanitize_text_less_than($html);
1801
1802 # Load HTML into DOM
1803 $this->dom->load($html, true, false);
1804
1805 # Force UTF-8 charset to preserve emojis and special characters
1806 $this->dom->_charset = 'UTF-8';
1807 $this->dom->_target_charset = 'UTF-8';
1808
1809 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1810 # This eliminates 8-10 full DOM traversals per page
1811 $this->cache_elements();
1812
1813 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
1814 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
1815 $this->deferred_reload = true;
1816
1817 # Apply blocking flags if available (for SEO plugin coordination)
1818 if (!empty($replacement_data['_otto_blocking'])) {
1819 $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
1820 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
1821
1822 # Remove SEO plugin meta tags if OTTO is providing them
1823 if ($block_title || !empty($block_description_tags)) {
1824 $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
1825 }
1826 }
1827
1828 # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)
1829
1830 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
1831 # Some API versions return 'value' instead of 'recommended_value'
1832 if (!empty($replacement_data['header_replacements'])) {
1833 foreach ($replacement_data['header_replacements'] as &$item) {
1834 if (isset($item['value']) && !isset($item['recommended_value'])) {
1835 $item['recommended_value'] = $item['value'];
1836 }
1837 }
1838 unset($item); // Break reference
1839 }
1840
1841 # 1. Header HTML insertion
1842 $this->insert_header_html($replacement_data);
1843
1844 # 2. Header replacements (title, meta, canonical)
1845 $this->do_header_replacements($replacement_data);
1846
1847 # 3. Body replacements (top, bottom, substitutions)
1848 $this->do_body_replacements($replacement_data);
1849
1850 # 4. Footer HTML insertion
1851 $this->do_footer_html_insertion($replacement_data);
1852
1853 # 5. Final cleanup for AMP pages
1854 $this->cleanup_amp_metasync_attribute();
1855
1856 # CRITICAL FIX: Clear cached elements and force DOM to refresh
1857 $this->deferred_reload = false;
1858 $this->cached_elements = []; // Clear our custom cache
1859
1860 # Clear SimpleHtmlDom's internal cache
1861 if (method_exists($this->dom, 'clear')) {
1862 $this->dom->clear();
1863 }
1864
1865
1866 # Try getting HTML via root element instead of save()
1867 $root = $this->dom->root;
1868 if ($root && isset($root->outertext)) {
1869 $result_html = $root->outertext;
1870 } else {
1871 # Fallback to save() method
1872 $result_html = $this->dom->save();
1873 }
1874
1875 # DEBUG: Check if DOM changes persisted
1876 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
1877 }
1878
1879 # Apply header replacements manually via string replacement
1880 if (!empty($replacement_data['header_replacements'])) {
1881
1882 foreach ($replacement_data['header_replacements'] as $idx => $item) {
1883 $type = $item['type'] ?? '';
1884 $value = $item['recommended_value'] ?? $item['value'] ?? '';
1885
1886
1887 if (empty($value)) {
1888 continue;
1889 }
1890
1891 if ($type === 'title') {
1892 # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
1893 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1894 $title_tag = '<title>' . $new_value . '</title>';
1895 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
1896 return $title_tag;
1897 }, $result_html, 1);
1898 if ($replaced === $result_html && strpos($result_html, '<title') === false) {
1899 # No <title> in document (common when Yoast is blocked) — insert after <head>
1900 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1901 return $m[1] . "\n" . $title_tag;
1902 }, $result_html, 1);
1903 } else {
1904 $result_html = $replaced;
1905 }
1906 } elseif ($type === 'meta') {
1907 $name = $item['name'] ?? '';
1908 $property = $item['property'] ?? '';
1909 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1910
1911 if (!empty($name)) {
1912 # MEMORY OPTIMIZED: Count meta tags without storing all matches
1913 # preg_match_all requires $matches, so we pass it but unset immediately
1914 # Use \s*=\s* to match attributes with or without spaces around =
1915 $before_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);
1916
1917 # Free memory immediately after use
1918 unset($before_matches);
1919
1920 # IMPROVED FIX: Remove ALL existing meta tags with this name
1921 # This pattern matches ANY meta tag with name="X" regardless of:
1922 # - Attribute order (name before/after content)
1923 # - Additional attributes (id, class, data-*, etc.)
1924 # - Quote style (single or double quotes)
1925 # - Whitespace variations
1926 # The pattern uses [^>]* to match ANY characters until the closing >
1927 $removed_count = preg_replace_callback(
1928 '/<meta\s+[^>]*name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
1929 function($match) {
1930 return ''; // Remove the tag
1931 },
1932 $result_html,
1933 -1, // Remove all occurrences
1934 $total_removed
1935 );
1936
1937 # Update the HTML with removed tags
1938 if ($total_removed > 0) {
1939 $result_html = $removed_count;
1940 }
1941
1942
1943 # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
1944 $after_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
1945 unset($after_matches);
1946
1947 # Now insert ONE new meta tag at the TOP of <head>
1948 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
1949 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
1950 return $m[1] . "\n" . $replacement;
1951 }, $result_html, 1);
1952 } elseif (!empty($property)) {
1953 # Replace meta property tag
1954 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
1955 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1956
1957 if (preg_match($pattern, $result_html)) {
1958 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
1959 return $replacement;
1960 }, $result_html, 1);
1961 } else {
1962 # Meta tag doesn't exist, insert it in head
1963 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
1964 return $m[1] . "\n" . $replacement;
1965 }, $result_html, 1);
1966 }
1967 }
1968 } elseif ($type === 'h1' || $type === 'heading') {
1969 # Replace first H1 tag, preserving attributes
1970 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1971 $result_html = preg_replace_callback(
1972 '/<h1([^>]*)>.*?<\/h1>/is',
1973 function ($m) use ($new_value) {
1974 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
1975 },
1976 $result_html,
1977 1
1978 );
1979 }
1980 }
1981 }
1982
1983 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
1984 if (!empty($replacement_data['header_html_insertion'])) {
1985 $header_html_check = trim($replacement_data['header_html_insertion']);
1986 if (strpos($result_html, $header_html_check) === false) {
1987 $header_html_insertion = preg_replace(
1988 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
1989 '<script$1type=$2application/ld+json$2 data-otto="true"',
1990 $replacement_data['header_html_insertion']
1991 );
1992 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
1993 # Insert before </head>
1994 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
1995 }
1996 }
1997
1998 # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
1999 if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
2000 $result_html = preg_replace(
2001 '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
2002 '',
2003 $result_html
2004 );
2005 $first = true;
2006 $result_html = preg_replace_callback(
2007 '/<title[^>]*>.*?<\/title>\s*/is',
2008 function ($m) use (&$first) {
2009 if ($first) {
2010 $first = false;
2011 return $m[0];
2012 }
2013 return '';
2014 },
2015 $result_html
2016 );
2017 }
2018
2019 # Apply body top HTML insertion — only if DOM insertion didn't already apply it
2020 if (!empty($replacement_data['body_top_html_insertion'])) {
2021 $body_top_check = trim($replacement_data['body_top_html_insertion']);
2022 if (strpos($result_html, $body_top_check) === false) {
2023 $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
2024 # Insert after <body>
2025 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
2026 }
2027 }
2028
2029 # Apply body bottom HTML insertion — only if DOM insertion didn't already apply it
2030 if (!empty($replacement_data['body_bottom_html_insertion'])) {
2031 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
2032 if (strpos($result_html, $body_bottom_check) === false) {
2033 $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
2034 # Insert before </body>
2035 $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
2036 }
2037 }
2038
2039 # Apply footer HTML insertion
2040 if (!empty($replacement_data['footer_html_insertion'])) {
2041 $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
2042 # Insert before </html>
2043 $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
2044 }
2045
2046 # CRITICAL FIX: Apply image alt text manually via string replacement
2047 # DOM changes don't persist, must use string replacement
2048 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
2049
2050 # String-based heading fallback for body_substitutions
2051 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2052 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
2053 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
2054 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
2055 continue;
2056 }
2057
2058 $heading_type = preg_quote($heading['type'], '/');
2059 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
2060 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
2061
2062 $result_html = preg_replace_callback(
2063 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2064 function ($m) use ($current_value, $recommended_value) {
2065 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2066 if ($inner_text === $current_value) {
2067 return $m[1] . $recommended_value . $m[3];
2068 }
2069 return $m[0];
2070 },
2071 $result_html,
2072 -1
2073 );
2074 }
2075 }
2076
2077 # DEBUG: Check what we're returning
2078 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2079 }
2080
2081 # FINAL VERIFICATION: Count meta descriptions in returned HTML
2082 $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
2083 if ($final_meta_count > 0) {
2084 foreach ($final_meta_matches[0] as $idx => $meta) {
2085 }
2086 }
2087
2088 if ($final_meta_count > 1) {
2089 }
2090
2091 # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
2092 # Only runs when OTTO has actually inserted its own description (data-otto marker present)
2093 # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace
2094
2095 $removal_count = 0;
2096 $otto_has_description = (bool) preg_match('/<meta[^>]*name\s*=\s*["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
2097 if ($otto_has_description) {
2098 $result_html = preg_replace_callback(
2099 '/<meta\s+([^>]*name\s*=\s*["\']description["\'][^>]*)>/i',
2100 function($match) use (&$removal_count) {
2101 # Keep only if it has data-otto="true"
2102 if (stripos($match[1], 'data-otto') !== false) {
2103 return $match[0]; // Keep OTTO's meta tag
2104 }
2105 # Remove any other meta description
2106 $removal_count++;
2107 return '';
2108 },
2109 $result_html
2110 );
2111 }
2112
2113 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2114 $result_html = $this->deduplicate_title_tags($result_html);
2115 $result_html = $this->deduplicate_og_twitter_tags($result_html);
2116 $result_html = $this->deduplicate_schema_tags($result_html);
2117 $result_html = $this->deduplicate_canonical_tags($result_html);
2118
2119 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2120 # This ensures memory is released immediately, especially important for high-traffic sites
2121 unset($final_meta_matches, $matches);
2122
2123 # Clear SimpleHtmlDom internal cache to free memory
2124 # Note: We don't unset $this->dom as the object may be reused
2125 if ($this->dom && method_exists($this->dom, 'clear')) {
2126 $this->dom->clear();
2127 }
2128
2129 # Clear element cache array
2130 $this->cached_elements = [];
2131
2132 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2133 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2134 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
2135 }
2136
2137 $result_html = $this->restore_case_sensitive_attributes($result_html);
2138
2139 return $result_html;
2140
2141 } catch (Exception $e) {
2142 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
2143 return false;
2144 } catch (Error $e) {
2145 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
2146 return false;
2147 }
2148 }
2149
2150 /**
2151 * Remove conflicting SEO plugin meta tags from DOM
2152 * Called when OTTO is providing its own title/description
2153 *
2154 * @param bool $remove_title Remove title-related tags
2155 * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
2156 * @since 2.6.0
2157 */
2158 private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
2159 if (!$this->dom) {
2160 return;
2161 }
2162
2163 # Find head element
2164 $head = $this->dom->find('head', 0);
2165 if (!$head) {
2166 return;
2167 }
2168
2169 # Check for custom SEO values from MetaSync SEO Sidebar
2170 # Custom values take absolute priority over OTTO suggestions
2171 $has_custom_title = false;
2172 $has_custom_description = false;
2173
2174 if (function_exists('is_singular') && is_singular()) {
2175 $post_id = get_the_ID();
2176 if ($post_id) {
2177 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
2178 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
2179 $has_custom_title = !empty($custom_seo_title);
2180 $has_custom_description = !empty($custom_seo_description);
2181 }
2182 }
2183
2184 # Handle description tags - only remove specific tags that Otto is providing
2185 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
2186 # Remove only the specific description tags that Otto is providing
2187 foreach ($remove_description_tags as $selector) {
2188 $tags = $this->dom->find($selector);
2189 foreach ($tags as $tag) {
2190 # Remove the tag
2191 $tag->outertext = '';
2192 }
2193 }
2194 }
2195
2196 if ($remove_title && !$has_custom_title) {
2197 # Remove Open Graph and Twitter title tags (keep main <title>)
2198 $title_selectors = [
2199 'meta[property=og:title]',
2200 'meta[name=twitter:title]',
2201 ];
2202
2203 foreach ($title_selectors as $selector) {
2204 $tags = $this->dom->find($selector);
2205 foreach ($tags as $tag) {
2206 $tag->outertext = ''; # Remove the tag
2207 }
2208 }
2209 }
2210
2211 # Save changes
2212 $this->save_reload();
2213 }
2214
2215
2216 }