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

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