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

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