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

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