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

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