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

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

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