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

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

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