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

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

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