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

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

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