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

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