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

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

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