PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | otto/Otto_html_class.php +1730 -211 2.6.13 → 2.7.1 View file →
@@ -14,8 +14,13 @@
14 14 */
15 15
16 16 Class Metasync_otto_html{
17 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 +
18 23 # html dom
19 24 private $dom;
20 25
21 26 # the file path to save to
@@ -62,9 +67,9 @@
62 67 return $html;
63 68 }
64 69
65 70 /**
66 - * WP-355: Fix malformed self-closing non-void HTML tags before DOM parsing.
71 + * Fix malformed self-closing non-void HTML tags before DOM parsing.
67 72 *
68 73 * Some themes (e.g. Bootstrap Component Blox) output malformed tags like:
69 74 * <ul/ class="sub-menu dropdown-menu" />
70 75 * SimpleHtmlDom interprets <ul/ as a void element, strips all attributes,
@@ -126,10 +131,197 @@
126 131 return str_ireplace(array_keys($map), array_values($map), $html);
127 132 }
128 133
129 134 /**
130 - * WP-355 / WP-315: Capture ALL <style> blocks before DOM processing.
135 + * Quote unquoted attribute values before DOM parsing.
131 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 + *
132 324 * SimpleHtmlDom can lose or corrupt <style> blocks during parse/serialize,
133 325 * especially those without an id attribute (common in custom themes).
134 326 * This captures every <style> block so we can re-inject any that are lost.
135 327 *
@@ -151,9 +343,9 @@
151 343 return $blocks;
152 344 }
153 345
154 346 /**
155 - * WP-355 / WP-315: Re-inject any <style> blocks lost during DOM processing.
347 + * / Re-inject any <style> blocks lost during DOM processing.
156 348 *
157 349 * Compares the captured blocks against the final HTML and re-injects any
158 350 * that disappeared. Named blocks (with id) are checked by id; anonymous
159 351 * blocks are checked by exact content match.
@@ -185,10 +377,68 @@
185 377 return $html;
186 378 }
187 379
188 380 /**
189 - * WP-465: Protect entity-encoded `srcdoc` attribute values before DOM parsing.
381 + * Build the new inner HTML for a heading whose text OTTO is replacing,
382 + * preserving the author's inline formatting.
190 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 + *
191 441 * Lazy-loaded YouTube/video facades embed a full HTML document inside an
192 442 * iframe's `srcdoc` attribute, entity-encoded:
193 443 * <iframe srcdoc="&lt;style&gt;*{overflow:hidden}...&lt;/style&gt;&lt;a&gt;..."></iframe>
194 444 * SimpleHtmlDom decodes that value and re-emits the inner <style>/<a>/<img>
@@ -217,9 +467,9 @@
217 467 );
218 468 }
219 469
220 470 /**
221 - * WP-465: Restore srcdoc values tokenized by protect_srcdoc_attributes().
471 + * Restore srcdoc values tokenized by protect_srcdoc_attributes().
222 472 * Tokens are unique and never altered by SimpleHtmlDom, so a plain
223 473 * str_replace on the final output is safe.
224 474 *
225 475 * @param string $html Processed HTML.
@@ -233,9 +483,9 @@
233 483 return str_replace(array_keys($store), array_values($store), $html);
234 484 }
235 485
236 486 /**
237 - * WP-465: Safety net — strip any lazy-video FACADE <style> that leaked into the
487 + * Safety net — strip any lazy-video FACADE <style> that leaked into the
238 488 * document as a real stylesheet.
239 489 *
240 490 * Lazy YouTube/video facades carry this exact CSS reset inside their iframe
241 491 * srcdoc: *{...overflow:hidden} html,body{height:100%} img,span{position:absolute;width:100%;...}
@@ -250,19 +500,212 @@
250 500 *
251 501 * @param string $html
252 502 * @return string
253 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 +
254 537 private function strip_hoisted_facade_styles($html) {
255 - return preg_replace(
256 - '#<style\b[^>]*>(?:(?!</style>).)*?\bimg\s*,\s*span\s*\{[^}]*position\s*:\s*absolute[^}]*\}(?:(?!</style>).)*?</style>#is',
257 - '',
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 + },
258 564 $html
259 565 );
566 +
567 + # Belt-and-suspenders: if PCRE still fails for any reason, keep the input.
568 + return $out === null ? $html : $out;
260 569 }
261 570
262 571 /**
263 - * WP-465: Keep the charset declaration within the first 1024 bytes of <head>.
572 + * Mark the <head> as OTTO-processed without altering the <head> tag itself.
264 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 + *
265 708 * OTTO injects meta tags + JSON-LD schema at the top of <head>, which can push
266 709 * the theme's <meta charset="utf-8"> past the 1024-byte limit that browsers
267 710 * enforce for in-document charset detection (HTML spec). When that happens —
268 711 * and a cached/CDN response is served without an HTTP charset header — the
@@ -342,9 +785,9 @@
342 785 $response = wp_remote_get($url_with_params, $args);
343 786
344 787 # Check for errors
345 788 if (is_wp_error($response)) {
346 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
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
347 790 return false;
348 791 }
349 792
350 793 # get the response body
@@ -354,9 +797,9 @@
354 797 $response_code = wp_remote_retrieve_response_code($response);
355 798
356 799 # if no change data skip
357 800 if (empty($body) || $response_code !== 200){
358 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
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
359 802 return false;
360 803 }
361 804
362 805 # set the html file path
@@ -549,9 +992,11 @@
549 992
550 993 # Check for timeout or connection errors
551 994 if (is_wp_error($route_html)) {
552 995 $error_msg = $route_html->get_error_message();
553 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
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 + }
554 999 return false;
555 1000 }
556 1001
557 1002 # get body
@@ -559,25 +1004,72 @@
559 1004
560 1005 # Get the response code
561 1006 $response_code = wp_remote_retrieve_response_code($route_html);
562 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 + }
563 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 +
564 1046 # check not empty
565 1047 if(empty($html_body) || $response_code !== 200){
566 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
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 + }
567 1051 return false;
568 1052 }
569 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 +
570 1062 # Remove XML declaration
571 1063 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
572 1064
573 - # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
1065 + # / Save ALL original <style> blocks before DOM processing.
574 1066 # SimpleHtmlDom can lose or corrupt style blocks during parse/serialize —
575 1067 # not only Divi's id-tagged ones but also anonymous <style> blocks used by
576 1068 # custom themes for nav collapse CSS, responsive breakpoints, etc.
577 1069 $original_style_blocks = $this->capture_style_blocks($html_body);
578 1070
579 - # WP-465: Shield entity-encoded srcdoc values (lazy YouTube/video facades)
1071 + # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
580 1072 # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
581 1073 $srcdoc_store = array();
582 1074 $html_body = $this->protect_srcdoc_attributes($html_body, $srcdoc_store);
583 1075
@@ -583,12 +1075,16 @@
583 1075
584 1076 # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
585 1077 $html_body = $this->sanitize_text_less_than($html_body);
586 1078
587 - # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
1079 + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
588 1080 # before DOM parsing — SimpleHtmlDom strips attributes from these.
589 1081 $html_body = $this->fix_malformed_self_closing_tags($html_body);
590 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 +
591 1087 # now that the html is not empty
592 1088 # load it into the simple html dom
593 1089 $this->dom->load($html_body, true, false);
594 1090
@@ -716,13 +1212,19 @@
716 1212
717 1213 $result_html = preg_replace_callback(
718 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
719 1215 function ($m) use ($current_value, $recommended_value) {
720 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
721 - if ($inner_text === $current_value) {
722 - return $m[1] . $recommended_value . $m[3];
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];
723 1219 }
724 - return $m[0];
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];
725 1227 },
726 1228 $result_html,
727 1229 -1
728 1230 );
@@ -730,19 +1232,20 @@
730 1232 }
731 1233
732 1234 # CRITICAL FIX: Apply image alt text manually via string replacement
733 1235 # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path
734 - $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
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');
735 1237
736 1238 # Apply insertions — only if DOM insertion didn't already apply it
737 1239 if (!empty($replacement_data['header_html_insertion'])) {
738 - $header_html_check = trim($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);
739 1247 if (strpos($result_html, $header_html_check) === false) {
740 - $header_html_insertion = preg_replace(
741 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
742 - '<script$1type=$2application/ld+json$2 data-otto="true"',
743 - $replacement_data['header_html_insertion']
744 - );
745 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
746 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
747 1250 }
748 1251 }
@@ -764,44 +1267,69 @@
764 1267 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
765 1268 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
766 1269 }
767 1270
768 - # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
769 - $result_html = $this->deduplicate_title_tags($result_html);
770 - $result_html = $this->deduplicate_og_twitter_tags($result_html);
771 - $result_html = $this->deduplicate_schema_tags($result_html);
772 - $result_html = $this->deduplicate_canonical_tags($result_html);
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);
773 1273
774 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
775 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
776 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
1274 + # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
1275 + $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
1276 + $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
1277 + $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
1278 + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
1279 + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
1280 + $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
1281 + $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
1282 +
1283 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
1284 + if (!$this->is_amp_page()) {
1285 + $result_html = $this->mark_head_optimized($result_html);
777 1286 }
778 1287
779 - $result_html = $this->restore_case_sensitive_attributes($result_html);
1288 + $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
780 1289
781 - # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
782 - $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
1290 + # / Re-inject any <style> blocks lost during processing.
1291 + $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');
783 1292
784 - # WP-315: Fix Divi 5 shortcode framework class renumbering (HTTP path)
785 - $result_html = $this->fix_divi_class_renumbering($result_html);
1293 + # Fix Divi 5 shortcode framework class renumbering (HTTP path)
1294 + $result_html = $this->otto_guard_html($this->fix_divi_class_renumbering($result_html), $result_html, 'fix_divi_class_renumbering');
786 1295
787 - # WP-315: Clean OTTO internal fetch params from HTML output.
1296 + # Clean OTTO internal fetch params from HTML output.
788 1297 # The HTTP render uses wp_remote_get with ?is_otto_page_fetch=1&otto_block_title=1&otto_block_desc=1
789 1298 # These leak into form action URLs, canonical links, etc. in the rendered HTML.
790 - $result_html = $this->clean_otto_fetch_params($result_html);
1299 + $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');
791 1300
792 - # WP-465: Remove any lazy-video facade <style> hoisted into the page
1301 + # Remove any lazy-video facade <style> hoisted into the page
793 1302 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
1303 + $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');
1304 +
1305 + # Restore the original encoded srcdoc values (undo tokenization).
1306 + $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');
1307 +
1308 + # Keep <meta charset> within the first 1024 bytes so external CSS
1309 + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
1310 + $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');
1311 +
1312 + # undo double-encoded numeric/hex character references produced by the
1313 + # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
1314 + $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
1315 +
1316 + # restore literal '&' query-string separators the serializer escaped to
1317 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
1318 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
1319 +
1320 + # Remove any lazy-video facade <style> hoisted into the page
1321 + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
794 1322 $result_html = $this->strip_hoisted_facade_styles($result_html);
795 1323
796 - # WP-465: Restore the original encoded srcdoc values (undo tokenization).
1324 + # Restore the original encoded srcdoc values (undo tokenization).
797 1325 $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);
798 1326
799 - # WP-465: Keep <meta charset> within the first 1024 bytes so external CSS
1327 + # Keep <meta charset> within the first 1024 bytes so external CSS
800 1328 # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
801 1329 $result_html = $this->ensure_early_charset_meta($result_html);
802 1330
803 - # WP-471: undo double-encoded numeric/hex character references produced by the
1331 + # undo double-encoded numeric/hex character references produced by the
804 1332 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
805 1333 $result_html = $this->repair_double_encoded_entities($result_html);
806 1334
807 1335 return $result_html;
@@ -807,10 +1335,113 @@
807 1335 return $result_html;
808 1336 }
809 1337
810 1338 /**
811 - * WP-471: Repair double-encoded character references in serialized HTML.
1339 + * Read the final URL from the WordPress HTTP response when Requests has
1340 + * followed one or more redirects. Older HTTP transports may not expose it;
1341 + * in that case the empty result leaves the existing fail-open behavior.
812 1342 *
1343 + * @param mixed $response wp_remote_get() response.
1344 + * @return string Final URL, or an empty string when unavailable.
1345 + */
1346 + private function get_effective_fetch_url($response)
1347 + {
1348 + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
1349 + return '';
1350 + }
1351 +
1352 + $http_response = $response['http_response'];
1353 + if (!method_exists($http_response, 'get_response_object')) {
1354 + return '';
1355 + }
1356 +
1357 + $requests_response = $http_response->get_response_object();
1358 + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
1359 + return '';
1360 + }
1361 +
1362 + return $requests_response->url;
1363 + }
1364 +
1365 + /**
1366 + * Determine whether a followed response still represents the requested
1367 + * route. The internal fetch may use a rewritten host for tunnel support,
1368 + * so both the original and transport hosts are accepted; the path and
1369 + * non-OTTO query parameters must remain identical.
1370 + *
1371 + * @param string $effective_url Final URL reported by Requests.
1372 + * @param string $route Original route supplied to OTTO.
1373 + * @param string $fetch_url URL actually passed to wp_remote_get().
1374 + * @return bool True when no meaningful redirect changed the route.
1375 + */
1376 + private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1377 + {
1378 + $effective = wp_parse_url($effective_url);
1379 + $requested = wp_parse_url($route);
1380 + $transport = wp_parse_url($fetch_url);
1381 + if (!is_array($effective) || !is_array($requested)) {
1382 + return true;
1383 + }
1384 +
1385 + $effective_path = isset($effective['path']) ? $effective['path'] : '/';
1386 + $requested_path = isset($requested['path']) ? $requested['path'] : '/';
1387 + if ($effective_path !== $requested_path) {
1388 + return false;
1389 + }
1390 +
1391 + $allowed_hosts = [];
1392 + foreach ([$requested, $transport] as $parts) {
1393 + if (!empty($parts['host'])) {
1394 + $host = strtolower($parts['host']);
1395 + if (!empty($parts['port'])) {
1396 + $host .= ':' . $parts['port'];
1397 + }
1398 + $allowed_hosts[] = $host;
1399 + }
1400 + }
1401 + if (!empty($effective['host'])) {
1402 + $effective_host = strtolower($effective['host']);
1403 + if (!empty($effective['port'])) {
1404 + $effective_host .= ':' . $effective['port'];
1405 + }
1406 + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
1407 + return false;
1408 + }
1409 + }
1410 +
1411 + return $this->without_otto_fetch_params($effective['query'] ?? '')
1412 + === $this->without_otto_fetch_params($requested['query'] ?? '');
1413 + }
1414 +
1415 + /**
1416 + * Remove parameters used only to mark OTTO's internal request.
1417 + *
1418 + * @param string $query Query string without the leading '?'.
1419 + * @return string Query with OTTO markers removed.
1420 + */
1421 + private function without_otto_fetch_params($query)
1422 + {
1423 + $query = (string) $query;
1424 + if ($query === '') {
1425 + return '';
1426 + }
1427 +
1428 + $parts = [];
1429 + foreach (explode('&', $query) as $part) {
1430 + $name = strstr($part, '=', true);
1431 + $name = $name === false ? $part : $name;
1432 + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
1433 + continue;
1434 + }
1435 + $parts[] = $part;
1436 + }
1437 +
1438 + return implode('&', $parts);
1439 + }
1440 +
1441 + /**
1442 + * Repair double-encoded character references in serialized HTML.
1443 + *
813 1444 * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
814 1445 * $this->dom->save() and $root->outertext) runs every attribute value through
815 1446 * htmlentities(), which escapes the leading '&' of numeric/hex character
816 1447 * references. Theme icon attributes such as data-x-icon-s="&#xf3c5"
@@ -831,8 +1462,118 @@
831 1462 }
832 1463 return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
833 1464 }
834 1465
1466 + /**
1467 + * Restore literal '&' query-string separators inside URL attributes.
1468 + *
1469 + * HtmlNode::makeup() serialises every attribute through htmlentities(), so a
1470 + * separator that arrived as a bare '&' - or as '&#038;', which HtmlDocument
1471 + * decodes to '&' when it parses the attribute - is written back as '&amp;'.
1472 + * Both forms decode to '&' in a conforming HTML parser, so the browser is
1473 + * unaffected. A consumer that reads the raw attribute WITHOUT decoding
1474 + * entities sees a different URL, and Cloudflare Fonts is one of those: it
1475 + * refetches fonts.googleapis.com/css2?family=A&amp;family=B, Google treats
1476 + * only the first 'family=' as a real parameter (the rest arrive named
1477 + * 'amp;family'), and every font after the first is dropped from the response.
1478 + * The page keeps its font-family declarations but loses the @font-face rules,
1479 + * so text silently falls back to a system font.
1480 + *
1481 + * Rewrites only a '&amp;' that is immediately followed by a parameter name
1482 + * and '=', and only inside the listed URL attributes. A '&amp;' elsewhere is
1483 + * left escaped, and the mandatory '=' in the lookahead (with ';' excluded
1484 + * from the name class) means no ambiguous ampersand ('&' + alphanumerics +
1485 + * ';') is ever produced.
1486 + *
1487 + * All three delimiter forms the serializer can emit are handled: double
1488 + * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and
1489 + * emits no delimiter for a value with no whitespace, which is common on
1490 + * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted
1491 + * value may legitimately contain an apostrophe.
1492 + *
1493 + * The attribute list stops at attributes whose value is always a URL.
1494 + * 'content' is deliberately excluded because it also carries prose
1495 + * (<meta name="description" content="A &amp; B">), where unescaping would
1496 + * corrupt visible text. Attributes read only via JavaScript getAttribute()
1497 + * do not need it either - the DOM decodes entities for them; only consumers
1498 + * that scan raw HTML are affected.
1499 + *
1500 + * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded
1501 + * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes
1502 + * there would edit a JS string literal, or turn "&amp;copy=2" in a textarea
1503 + * into "(c)=2" once the browser decodes it.
1504 + *
1505 + * Plain negated character classes only - no tempered-greedy lookaheads, which
1506 + * blow the PCRE JIT stack on pages with very large inline blocks.
1507 + *
1508 + * @param string $html Serialized HTML from save()/outertext.
1509 + * @return string
1510 + */
1511 + private function repair_query_string_ampersands($html) {
1512 + # strpos guard: skip the regex entirely when there is nothing to repair
1513 + if (strpos($html, '&amp;') === false) {
1514 + return $html;
1515 + }
1516 +
1517 + # Shield verbatim / RCDATA regions and comments before touching anything.
1518 + # Masking also shrinks large inline blocks to a token, so the attribute
1519 + # pass never walks them.
1520 + $shielded = array();
1521 + $masked = preg_replace_callback(
1522 + '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is',
1523 + function ($m) use (&$shielded) {
1524 + $token = '<!--METASYNC_QSA_' . count($shielded) . '-->';
1525 + $shielded[$token] = $m[0];
1526 + return $token;
1527 + },
1528 + $html
1529 + );
1530 +
1531 + # preg_* returns null on a PCRE failure; keep the original document.
1532 + if ($masked === null) {
1533 + return $html;
1534 + }
1535 +
1536 + $repaired = preg_replace_callback(
1537 + '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i',
1538 + function ($m) {
1539 + # Pick the delimiter branch that matched. An empty value carries
1540 + # no query string, so the ambiguity between an empty quoted value
1541 + # and an unset group cannot change the outcome.
1542 + if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones)
1543 + $quote = '';
1544 + $value = $m[4];
1545 + } elseif (isset($m[3]) && $m[3] !== '') {
1546 + $quote = "'";
1547 + $value = $m[3];
1548 + } else {
1549 + $quote = '"';
1550 + $value = isset($m[2]) ? $m[2] : '';
1551 + }
1552 +
1553 + # Only a value carrying a query string can hold separators.
1554 + if (strpos($value, '?') === false || strpos($value, '&amp;') === false) {
1555 + return $m[0];
1556 + }
1557 +
1558 + $fixed = preg_replace('/&amp;(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value);
1559 +
1560 + if ($fixed === null) {
1561 + return $m[0];
1562 + }
1563 +
1564 + return $m[1] . $quote . $fixed . $quote;
1565 + },
1566 + $masked
1567 + );
1568 +
1569 + if ($repaired === null) {
1570 + return $html;
1571 + }
1572 +
1573 + return empty($shielded) ? $repaired : strtr($repaired, $shielded);
1574 + }
1575 +
835 1576 # do the footer html insertion
836 1577 function do_footer_html_insertion($replacement_data){
837 1578
838 1579 # check that we have footer html
@@ -933,9 +1674,9 @@
933 1674 if (empty($images)) {
934 1675 return;
935 1676 }
936 1677
937 - # WP-425: index suggestions by URL path as well, so images authored with
1678 + # index suggestions by URL path as well, so images authored with
938 1679 # relative srcs still match OTTO's absolute-URL keys (and vice versa).
939 1680 $alt_lookup = $this->build_image_alt_lookup($image_data);
940 1681
941 1682 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
@@ -974,9 +1715,9 @@
974 1715 }
975 1716 }
976 1717 }
977 1718
978 - # WP-425: memoized site host — home_url() runs its filter chain on every
1719 + # memoized site host — home_url() runs its filter chain on every
979 1720 # call and these helpers run per suggestion and per img.
980 1721 private ?string $site_host_cache = null;
981 1722
982 1723 /**
@@ -992,9 +1733,9 @@
992 1733 return $this->site_host_cache;
993 1734 }
994 1735
995 1736 /**
996 - * WP-425: Candidate lookup keys for an image URL — the URL itself plus,
1737 + * Candidate lookup keys for an image URL — the URL itself plus,
997 1738 * for relative URLs or absolute/protocol-relative URLs on this site's
998 1739 * host, the bare URL path. URLs on a different host only ever match
999 1740 * exactly, so a same path on a foreign host cannot produce a false match.
1000 1741 *
@@ -1022,9 +1763,9 @@
1022 1763 return $variants;
1023 1764 }
1024 1765
1025 1766 /**
1026 - * WP-425: Build the image alt lookup keyed by every URL variant of each
1767 + * Build the image alt lookup keyed by every URL variant of each
1027 1768 * OTTO suggestion, so relative img srcs match absolute suggestion URLs.
1028 1769 *
1029 1770 * @param array $image_data OTTO body_substitutions.images map (url => alt)
1030 1771 * @return array Expanded lookup (url-or-path => alt)
@@ -1046,9 +1787,9 @@
1046 1787 return $lookup;
1047 1788 }
1048 1789
1049 1790 /**
1050 - * WP-425: Resolve the OTTO alt text for an img src, trying the exact src
1791 + * Resolve the OTTO alt text for an img src, trying the exact src
1051 1792 * first and then its same-host path variant.
1052 1793 *
1053 1794 * @param array $lookup Expanded lookup from build_image_alt_lookup()
1054 1795 * @param string $image_src The img element's src attribute
@@ -1111,9 +1852,9 @@
1111 1852 *
1112 1853 * Known limitation (pre-existing): the `<img[^>]*src=` pattern can match a
1113 1854 * `data-src`/`data-lazy-src` attribute that ends with the target URL before
1114 1855 * the real `src`, mis-attributing the alt on lazy-loaded markup. Tracked as
1115 - * a follow-up to WP-425.
1856 + * a follow-up to.
1116 1857 *
1117 1858 * @param string $html The HTML to process
1118 1859 * @param array $replacement_data The replacement data containing body_substitutions.images
1119 1860 * @return string Modified HTML with alt text applied
@@ -1123,9 +1864,9 @@
1123 1864 return $html;
1124 1865 }
1125 1866
1126 1867 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
1127 - # WP-425: prefilter and match on the same-host path variant too, so
1868 + # prefilter and match on the same-host path variant too, so
1128 1869 # relative img srcs match OTTO's absolute suggestion URLs.
1129 1870 $src_pattern = $this->get_image_src_pattern($image_url);
1130 1871
1131 1872 if (empty($alt_text) || $src_pattern === null || strpos($html, $src_pattern['needle']) === false) {
@@ -1180,9 +1921,9 @@
1180 1921 return $html;
1181 1922 }
1182 1923
1183 1924 /**
1184 - * WP-425: Build the src match for an OTTO image suggestion URL.
1925 + * Build the src match for an OTTO image suggestion URL.
1185 1926 *
1186 1927 * For relative URLs or absolute/protocol-relative URLs on this site's
1187 1928 * host, the regex matches the URL path with an optional scheme+host
1188 1929 * prefix — so a relative img src matches an absolute suggestion URL and
@@ -1188,9 +1929,10 @@
1188 1929 * prefix — so a relative img src matches an absolute suggestion URL and
1189 1930 * vice versa. URLs on a foreign host keep the original exact match, so a
1190 1931 * same path on a different host cannot produce a false positive.
1191 1932 *
1192 - * @param string $image_url OTTO suggestion URL
1933 + * @param string|int $image_url OTTO suggestion URL (may arrive as an int
1934 + * when it originates from an array key that PHP coerced)
1193 1935 * @return array|null ['needle' => strpos prefilter string,
1194 1936 * 'pattern' => regex fragment ('/' delimiter)] or null
1195 1937 */
1196 1938 private function get_image_src_pattern($image_url) {
@@ -1235,15 +1977,28 @@
1235 1977
1236 1978 # check matching text
1237 1979 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
1238 1980
1239 - # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
1981 + # Rebuild the inner HTML, keeping the author's inline
1982 + # formatting. Returns null when the markup can't be preserved
1983 + # unambiguously — leave that heading alone rather than strip
1984 + # its <span style>/<em>/<br> and restyle the page.
1985 + $new_inner = self::build_heading_inner_html(
1986 + $heading_old->innertext,
1987 + $heading['recommended_value']
1988 + );
1989 +
1990 + if ($new_inner === null) {
1991 + continue;
1992 + }
1993 +
1994 + # replace the tag output, preserving the opening tag's attributes
1240 1995 $outer = $heading_old->outertext;
1241 1996 $open_end = strpos($outer, '>');
1242 1997 if ($open_end !== false) {
1243 1998 $open_tag = substr($outer, 0, $open_end + 1);
1244 1999 $close_tag = '</' . $heading['type'] . '>';
1245 - $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
2000 + $heading_old->outertext = $open_tag . $new_inner . $close_tag;
1246 2001 }
1247 2002
1248 2003 }
1249 2004 }
@@ -1345,10 +2100,10 @@
1345 2100 }
1346 2101
1347 2102 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1348 2103 if (strpos($url, '//') === 0) {
1349 - $parsed_home = parse_url($home_url);
1350 - $parsed_link = parse_url($url);
2104 + $parsed_home = wp_parse_url($home_url);
2105 + $parsed_link = wp_parse_url($url);
1351 2106
1352 2107 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1353 2108 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1354 2109 return false;
@@ -1448,8 +2203,152 @@
1448 2203 # now do the actual html replacements
1449 2204 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1450 2205 }
1451 2206
2207 + /**
2208 + * Resolve the current singular post id reliably inside the OTTO output
2209 + * buffer / direct-render context.
2210 + *
2211 + * get_the_ID() depends on the global $post/loop state, which is not reliable
2212 + * once WordPress has finished rendering and the buffer/shutdown pass runs.
2213 + * get_queried_object_id() reads the parsed main query and stays correct there
2214 + * (same approach as apply_metabox_og_precedence()). Restricted to
2215 + * singular views: on archives/home the queried object id can be a term or
2216 + * user id, which must not be read as post meta.
2217 + *
2218 + * @return int Post id, or 0 when not applicable.
2219 + */
2220 + private function get_buffer_post_id() {
2221 + if (!function_exists('is_singular') || !is_singular()) {
2222 + return 0;
2223 + }
2224 + $post_id = function_exists('get_queried_object_id') ? get_queried_object_id() : 0;
2225 + return $post_id ? (int) $post_id : 0;
2226 + }
2227 +
2228 + /**
2229 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2230 + *
2231 + * @param string $html
2232 + * @param string $attr "name" or "property"
2233 + * @param string $val e.g. "description", "og:description"
2234 + * @param string $content_escaped Already HTML-escaped content value
2235 + * @param string $marker Provenance marker for the winning tier
2236 + * @return string
2237 + */
2238 + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
2239 + $marker_attribute = $marker === 'otto'
2240 + ? ' data-metasync-otto="true"'
2241 + : ' data-metasync-seo="custom"';
2242 + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
2243 + $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2244 + $count = 0;
2245 + $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2246 + return $tag;
2247 + }, $html, 1, $count);
2248 + if ($count > 0) {
2249 + return $new;
2250 + }
2251 + # None present — insert after <head>.
2252 + return preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($tag) {
2253 + return $m[1] . "\n" . $tag;
2254 + }, $html, 1);
2255 + }
2256 +
2257 + /**
2258 + * Make sure the precedence resolver is loaded before calling it.
2259 + *
2260 + * @return bool
2261 + */
2262 + private static function precedence_available() {
2263 + if (class_exists('Metasync_Seo_Precedence')) {
2264 + return true;
2265 + }
2266 +
2267 + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
2268 + if (is_readable($file)) {
2269 + require_once $file;
2270 + }
2271 +
2272 + return class_exists('Metasync_Seo_Precedence');
2273 + }
2274 +
2275 + /**
2276 + * Enforce the resolved SEO title/description as the FINAL word over OTTO.
2277 + *
2278 + * The resolver owns the global custom-versus-OTTO order. This final buffer
2279 + * pass must use it too: otherwise the setting works through the sidebar
2280 + * and third-party filters but OTTO's later string pass puts the custom
2281 + * value back on the page.
2282 + *
2283 + * OG and Twitter descriptions remain outside this method deliberately;
2284 + * social precedence is a separate follow-up scope.
2285 + *
2286 + * @param string $html Full HTML document (post-dedup).
2287 + * @return string
2288 + */
2289 + private function apply_custom_seo_precedence($html) {
2290 + if ($html === '') {
2291 + return $html;
2292 + }
2293 + $post_id = $this->get_buffer_post_id();
2294 + if (!$post_id) {
2295 + return $html;
2296 + }
2297 +
2298 + # This runs inside the output buffer, where an unresolvable class is a
2299 + # fatal that takes the whole page down — otto_guard_html() only catches
2300 + # a NULL return, not a thrown Error. A partial update can leave newer
2301 + # PHP files beside an older committed autoload classmap, so resolve the
2302 + # file directly before trusting the class, and leave the document
2303 + # untouched if it is genuinely absent. Same reasoning as
2304 + # Metasync_SEO_Conflict_Handler::precedence_available().
2305 + if (!self::precedence_available()) {
2306 + return $html;
2307 + }
2308 +
2309 + # Title — replace the resolved page title into the (single, post-dedup) <title>.
2310 + $resolved_title = Metasync_Seo_Precedence::resolve(
2311 + $post_id,
2312 + Metasync_Seo_Precedence::FIELD_TITLE
2313 + );
2314 + if (!empty($resolved_title['value'])) {
2315 + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
2316 + $count = 0;
2317 + $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2318 + return $title_tag;
2319 + }, $html, 1, $count);
2320 + if ($count > 0) {
2321 + $html = $replaced;
2322 + } elseif (stripos($html, '<title') === false) {
2323 + $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
2324 + return $m[1] . "\n" . $title_tag;
2325 + }, $html, 1);
2326 + }
2327 + }
2328 +
2329 + # Description — replace the resolved page description into the SEO
2330 + # <meta name="description"> only. OG/Twitter description is social
2331 + # precedence and remains outside this setting's scope.
2332 + $resolved_description = Metasync_Seo_Precedence::resolve(
2333 + $post_id,
2334 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2335 + );
2336 + if (!empty($resolved_description['value'])) {
2337 + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
2338 + $marker = Metasync_Seo_Precedence::is_otto_value(
2339 + $post_id,
2340 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2341 + $resolved_description
2342 + )
2343 + ? 'otto'
2344 + : 'custom';
2345 + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
2346 + }
2347 +
2348 + return $html;
2349 + }
2350 +
1452 2351 # this function does the header replacements
1453 2352 function do_header_replacements($replacement_data){
1454 2353
1455 2354 # check that we have header replacements
@@ -1499,21 +2398,19 @@
1499 2398
1500 2399 # handle canonical links
1501 2400 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1502 2401
1503 - # Protect manually-set canonical from OTTO override
2402 + # Protect manually-set canonical from OTTO override.
2403 + # Validated: a legacy row corrupted to "Array" must not
2404 + # count as a manual canonical and block OTTO's correct value.
1504 2405 if (function_exists('is_singular') && is_singular()) {
1505 2406 $post_id = get_the_ID();
1506 2407 if ($post_id) {
1507 - $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1508 - if (empty($custom_canonical)) {
1509 - $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1510 - // Handle legacy array values
1511 - if (is_array($custom_canonical)) {
1512 - $custom_canonical = reset($custom_canonical) ?: '';
1513 - }
2408 + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true));
2409 + if ($custom_canonical === '') {
2410 + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true));
1514 2411 }
1515 - if (!empty($custom_canonical)) {
2412 + if ($custom_canonical !== '') {
1516 2413 # Manual canonical takes priority — skip OTTO override
1517 2414 continue;
1518 2415 }
1519 2416 }
@@ -1521,11 +2418,15 @@
1521 2418
1522 2419 # find the cannonical dom element
1523 2420 $link = $this->dom->find('link[rel="canonical"]', 0);
1524 2421
1525 - # set the link property if not empty
2422 + # set the link property if not empty — only to a validated URL
2423 + # never inject a corrupted/non-URL suggestion.
1526 2424 if(!empty($link->href)){
1527 - $link->href = $data['recommended_value'] ?? $link->href;
2425 + $otto_canonical = Metasync_Canonical_Sanitizer::sanitize($data['recommended_value'] ?? '');
2426 + if ($otto_canonical !== '') {
2427 + $link->href = $otto_canonical;
2428 + }
1528 2429 }
1529 2430
1530 2431 #
1531 2432 continue;
@@ -1848,8 +2749,221 @@
1848 2749 return $html;
1849 2750 }
1850 2751
1851 2752 /**
2753 + * Read one of the meta box's social title/description keys with the "Auto Draft"
2754 + * placeholder collapsed to ''.
2755 + *
2756 + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
2757 + * place. Falls back to a raw read when that class — or that method on it — is
2758 + * unavailable: this buffer filter runs on the front end, and a partially updated
2759 + * install can pair a newer otto/ file with an older includes/ one, where calling
2760 + * a method the loaded class doesn't define would fatal the page rather than
2761 + * degrade.
2762 + *
2763 + * @param int $post_id
2764 + * @param string $key
2765 + * @return string
2766 + */
2767 + private function social_meta($post_id, $key) {
2768 + // PHPStan resolves the literal class-string against the current source and so
2769 + // sees the method as always present; the check is deliberate runtime
2770 + // version-skew defence. Suppressed the same way otto_pixel.php does.
2771 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2772 + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
2773 + return Metasync_OpenGraph::get_social_meta($post_id, $key);
2774 + }
2775 + return (string) get_post_meta($post_id, $key, true);
2776 + }
2777 +
2778 + /**
2779 + * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
2780 + *
2781 + * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
2782 + * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
2783 + * default fills the gap. Mirrors how the custom SEO sidebar values take priority
2784 + * over OTTO (see do_header_replacements()).
2785 + *
2786 + * Runs *after* deduplicate_og_twitter_tags(), and only within <head>. For a field the
2787 + * user explicitly set it removes any existing tag (OTTO's) and re-adds the user's
2788 + * value; for the rest it only adds a tag when absent — so OTTO stays authoritative
2789 + * where the user left a field blank and no duplicate is produced. OTTO never emits an
2790 + * og:image, so this also surfaces a user-set social image on an OTTO-managed page.
2791 + *
2792 + * Only applies to singular posts/pages: the meta box values are per-post, and on
2793 + * archives/home get_queried_object_id() can return a term or user id.
2794 + *
2795 + * @param string $html Full HTML document (post-dedup).
2796 + * @return string
2797 + */
2798 + private function apply_metabox_og_precedence($html) {
2799 + if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
2800 + return $html;
2801 + }
2802 + # Per-post OG only applies to singular posts/pages. On archives/home the queried
2803 + # object id can be a term or user id, which must not be read as post meta.
2804 + if (!is_singular()) {
2805 + return $html;
2806 + }
2807 + $post_id = get_queried_object_id();
2808 + if (!$post_id) {
2809 + return $html;
2810 + }
2811 +
2812 + # Respect the meta box opt-out (only an explicit '0' disables it)
2813 + if (get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
2814 + return $html;
2815 + }
2816 +
2817 + # Explicit (user-typed) meta box values. A non-empty value means the user set
2818 + # this field themselves and it must take priority over OTTO.
2819 + #
2820 + # The four social title/description keys are read through
2821 + # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
2822 + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
2823 + # brand-new post reads as a deliberate override here (it differs from the real
2824 + # title) and would beat OTTO's correct og:title.
2825 + $og_title_set = $this->social_meta($post_id, '_metasync_og_title');
2826 + $og_desc_set = $this->social_meta($post_id, '_metasync_og_description');
2827 + $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2828 + $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
2829 + $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true);
2830 + $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', true);
2831 + $tw_title_set = $this->social_meta($post_id, '_metasync_twitter_title');
2832 + $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description');
2833 + $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2834 + $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2835 +
2836 + # Default OG values the meta box pre-fills (post title / generated excerpt /
2837 + # featured image). The meta box PERSISTS these defaults on save, so a non-empty
2838 + # _metasync_og_* value alone does not prove the user customized it — a field is a
2839 + # genuine override only when its stored value differs from this default. Reuse the
2840 + # emitter's own resolver so the defaults match exactly (falling back to empty
2841 + # defaults, i.e. treat nothing as customized, if the instance is unavailable).
2842 + $defaults = ['title' => '', 'description' => '', 'image' => ''];
2843 + if (class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::get_instance()) {
2844 + $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
2845 + }
2846 +
2847 + $title_is_custom = ($og_title_set !== '' && $og_title_set !== $defaults['title']);
2848 + $desc_is_custom = ($og_desc_set !== '' && $og_desc_set !== $defaults['description']);
2849 + $image_is_custom = ($og_image_set !== '' && $og_image_set !== $defaults['image']);
2850 +
2851 + # Resolved values (explicit value first, then the meta box default).
2852 + $og_title = $og_title_set !== '' ? $og_title_set : $defaults['title'];
2853 + $og_desc = $og_desc_set !== '' ? $og_desc_set : $defaults['description'];
2854 + $og_image = $og_image_set !== '' ? $og_image_set : $defaults['image'];
2855 + $og_type = $og_type_set ?: 'article';
2856 + $site_name = get_bloginfo('name');
2857 +
2858 + # Twitter inherits the OG value when its own field is blank; a user-customized
2859 + # og:image therefore also overrides OTTO's twitter:image (the meta box treats them
2860 + # as one). A twitter field is custom if its own value differs from the default OR
2861 + # the OG field it inherits was customized.
2862 + $tw_card = $tw_card_set ?: 'summary_large_image';
2863 + $tw_title = $tw_title_set !== '' ? $tw_title_set : $og_title;
2864 + $tw_desc = $tw_desc_set !== '' ? $tw_desc_set : $og_desc;
2865 + $tw_image = $tw_image_set !== '' ? $tw_image_set : $og_image;
2866 +
2867 + $tw_title_is_custom = ($tw_title_set !== '' && $tw_title_set !== $defaults['title']) || $title_is_custom;
2868 + $tw_desc_is_custom = ($tw_desc_set !== '' && $tw_desc_set !== $defaults['description']) || $desc_is_custom;
2869 + $tw_image_is_custom = ($tw_image_set !== '' && $tw_image_set !== $defaults['image']) || $image_is_custom;
2870 +
2871 + # twitter:site: per-post value, else the site-wide handle from Social Meta settings.
2872 + # twitter:site / twitter:image:alt are not auto-defaulted by the meta box, so a
2873 + # non-empty per-post value is itself the override signal.
2874 + $tw_site = $tw_site_set;
2875 + if ($tw_site === '' && class_exists('Metasync')) {
2876 + $social = Metasync::get_option('social_meta');
2877 + if (is_array($social) && !empty($social['twitter_username'])) {
2878 + $tw_site = '@' . ltrim($social['twitter_username'], '@');
2879 + }
2880 + }
2881 +
2882 + # og:type / twitter:card are dropdowns that always carry a value, so they override
2883 + # OTTO only when the user picked a non-default option. og:url is auto-populated with
2884 + # the permalink on save, so it is fill-only (never an override) to avoid clobbering
2885 + # OTTO's canonical URL with a stale/auto value. og:site_name is a site default.
2886 + $og_type_override = ($og_type_set !== '' && $og_type_set !== 'article');
2887 + $tw_card_override = ($tw_card_set !== '' && $tw_card_set !== 'summary_large_image');
2888 +
2889 + # [attr, property/name, value, is_url, is_override]
2890 + $tags = [
2891 + ['property', 'og:title', $og_title, false, $title_is_custom],
2892 + ['property', 'og:description', $og_desc, false, $desc_is_custom],
2893 + ['property', 'og:image', $og_image, true, $image_is_custom],
2894 + ['property', 'og:url', get_permalink($post_id), true, false],
2895 + ['property', 'og:type', $og_type, false, $og_type_override],
2896 + ['property', 'og:site_name', $site_name, false, false],
2897 + ['name', 'twitter:card', $tw_card, false, $tw_card_override],
2898 + ['name', 'twitter:site', $tw_site, false, $tw_site_set !== ''],
2899 + ['name', 'twitter:title', $tw_title, false, $tw_title_is_custom],
2900 + ['name', 'twitter:description', $tw_desc, false, $tw_desc_is_custom],
2901 + ['name', 'twitter:image', $tw_image, true, $tw_image_is_custom],
2902 + ['name', 'twitter:image:alt', $tw_image_alt_set, false, $tw_image_alt_set !== ''],
2903 + ];
2904 +
2905 + # Operate only within <head> so meta tags in the body are never touched.
2906 + if (!preg_match('/(<head\b[^>]*>)(.*?)(<\/head>)/is', $html, $hm)) {
2907 + return $html;
2908 + }
2909 + $head = $hm[2];
2910 +
2911 + # 1) Remove existing head tags for user-overridden properties so the user's value wins.
2912 + foreach ($tags as $t) {
2913 + list($attr, $key, $val, $is_url, $is_override) = $t;
2914 + if ($is_override && $val !== '' && $val !== null) {
2915 + $stripped = preg_replace(
2916 + '/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1[^>]*>\s*/i',
2917 + '',
2918 + $head
2919 + );
2920 + if ($stripped !== null) {
2921 + $head = $stripped;
2922 + }
2923 + }
2924 + }
2925 +
2926 + # 2) Build additions: overrides (removed above) + gap-fills for anything still absent.
2927 + #
2928 + # When a third-party SEO plugin is active the conflict handler may have suppressed
2929 + # that plugin's og:title / twitter:title so MetaSync can supply the replacement
2930 + # (Metasync_SEO_Sidebar::output_seo_meta_description). Gap-filling those two tags
2931 + # here would inject the meta-box post-title default over that replacement, so skip
2932 + # them in that case. User-customized overrides are unaffected — they were already
2933 + # applied above.
2934 + $seo_plugin_active = class_exists('Metasync_SEO_Conflict_Handler')
2935 + && Metasync_SEO_Conflict_Handler::get_instance()->has_active_seo_plugin();
2936 +
2937 + $additions = '';
2938 + foreach ($tags as $t) {
2939 + list($attr, $key, $val, $is_url, $is_override) = $t;
2940 + if ($val === '' || $val === null) {
2941 + continue;
2942 + }
2943 + if (!$is_override && $seo_plugin_active && ($key === 'og:title' || $key === 'twitter:title')) {
2944 + continue; # never gap-fill the post title over a third-party plugin's own title
2945 + }
2946 + if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) {
2947 + continue; # OTTO (or another source) already provides it and the user did not override
2948 + }
2949 + $content = $is_url ? esc_url($val) : esc_attr($val);
2950 + if ($content === '') {
2951 + continue;
2952 + }
2953 + $marker = $is_override ? 'override' : 'fill';
2954 + $additions .= '<meta ' . $attr . '="' . $key . '" content="' . $content . '" data-metasync-og="' . $marker . '">' . "\n";
2955 + }
2956 +
2957 + # Reassemble the head block (str_replace avoids backreference issues from $ in values).
2958 + if ($head === $hm[2] && $additions === '') {
2959 + return $html; # nothing removed, nothing added
2960 + }
2961 + $new_head_block = $hm[1] . $head . $additions . $hm[3];
2962 + return str_replace($hm[0], $new_head_block, $html);
2963 + }
2964 +
2965 + /**
1852 2966 * Deduplicate meta tags by a specific attribute (property= or name=).
1853 2967 *
1854 2968 * When duplicates exist and one carries a data-otto marker, keep only
1855 2969 * the OTTO version. Otherwise keep the first occurrence.
@@ -1869,19 +2983,27 @@
1869 2983 }
1870 2984
1871 2985 $all_tags = $matches[0];
1872 2986
1873 - # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
2987 + # Choose the keeper by precedence: custom sidebar → OTTO → first.
2988 + # A user-set SEO sidebar value (data-metasync-seo="custom") must win over
2989 + # OTTO, matching deduplicate_description_tags() and the sidebar's documented
2990 + # "custom always wins over OTTO" intent. Without this, the generic keeper
2991 + # (OTTO-or-first) drops the sidebar's og:/twitter: description in favor of
2992 + # OTTO's injected tag.
2993 + $custom_tag = null;
1874 2994 $otto_tag = null;
1875 2995 foreach ($all_tags as $tag) {
1876 - if (stripos($tag, 'data-otto') !== false) {
2996 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
2997 + $custom_tag = $tag;
2998 + }
2999 + if ($otto_tag === null && stripos($tag, 'data-otto') !== false) {
1877 3000 $otto_tag = $tag;
1878 - break;
1879 3001 }
1880 3002 }
1881 3003
1882 - # Determine the keeper: OTTO tag if present, otherwise the first tag
1883 - $keeper = $otto_tag ?: $all_tags[0];
3004 + # Determine the keeper: custom sidebar tag, else OTTO tag, else the first tag
3005 + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]);
1884 3006
1885 3007 # Remove all occurrences, then re-insert the keeper at the first position
1886 3008 $first_replaced = false;
1887 3009 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
@@ -1895,8 +3017,81 @@
1895 3017 return $html;
1896 3018 }
1897 3019
1898 3020 /**
3021 + * Deduplicate <meta name="description"> tags after OTTO processing.
3022 + *
3023 + * Unlike title, og, twitter, and canonical tags, the plain name="description"
3024 + * tag had NO dedup pass, so a page could end up with 2-3 copies when several
3025 + * subsystems each emit one:
3026 + * - OTTO backend payload (header_html_insertion) → data-otto-pixel="dynamic-seo"
3027 + * (spliced in additively before </head>, never replacing an existing tag)
3028 + * - MetaSync's persisted-meta wp_head hook → data-metasync-otto="true"
3029 + * - MetaSync SEO sidebar custom value → data-metasync-seo="custom"
3030 + *
3031 + * This runs at the buffer level — after every source has written its tag —
3032 + * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented
3033 + * "custom always wins over OTTO" intent):
3034 + * 1. custom sidebar (data-metasync-seo)
3035 + * 2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto)
3036 + * 3. first occurrence
3037 + *
3038 + * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches
3039 + * substring "data-otto", which misses "data-metasync-otto"; this method checks
3040 + * both OTTO markers explicitly, so either OTTO source is recognized.
3041 + *
3042 + * @param string $html Full HTML document.
3043 + * @return string HTML with at most one <meta name="description">.
3044 + */
3045 + private function deduplicate_description_tags($html) {
3046 + if (!is_string($html) || $html === '') {
3047 + return $html;
3048 + }
3049 +
3050 + # Match <meta ... name="description" ...> in either attribute order.
3051 + # [^>]* is bounded to a single tag; name="twitter:description" is NOT matched
3052 + # because the opening quote must be immediately followed by "description".
3053 + $pattern = '/<meta\s[^>]*name\s*=\s*["\']description["\'][^>]*\/?>/i';
3054 +
3055 + if (preg_match_all($pattern, $html, $matches) <= 1) {
3056 + return $html; # 0 or 1 — nothing to deduplicate
3057 + }
3058 +
3059 + $all_tags = $matches[0];
3060 +
3061 + # Choose the keeper by precedence: custom sidebar → OTTO → first.
3062 + $custom_tag = null;
3063 + $otto_tag = null;
3064 + foreach ($all_tags as $tag) {
3065 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3066 + $custom_tag = $tag;
3067 + }
3068 + if ($otto_tag === null && (
3069 + stripos($tag, 'data-otto-pixel') !== false ||
3070 + stripos($tag, 'data-metasync-otto') !== false ||
3071 + stripos($tag, 'data-otto') !== false
3072 + )) {
3073 + $otto_tag = $tag;
3074 + }
3075 + }
3076 + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]);
3077 +
3078 + # Remove all occurrences, re-inserting the keeper at the first position.
3079 + # Callback form avoids backreference injection when the description content
3080 + # contains $ followed by digits (e.g. "$50 off").
3081 + $first_replaced = false;
3082 + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
3083 + if (!$first_replaced) {
3084 + $first_replaced = true;
3085 + return $keeper;
3086 + }
3087 + return ''; # Remove subsequent duplicates
3088 + }, $html);
3089 +
3090 + return $html;
3091 + }
3092 +
3093 + /**
1899 3094 * Remove duplicate <link rel="canonical"> tags from HTML.
1900 3095 *
1901 3096 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1902 3097 * SEO output (or WordPress core) has already emitted one, keep only the
@@ -1937,96 +3132,301 @@
1937 3132 return $html;
1938 3133 }
1939 3134
1940 3135 /**
3136 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3137 + *
3138 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3139 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3140 + * Collapsing such a node to its first token hides it from a plain-typed
3141 + * duplicate, so every token is returned and indexed separately.
3142 + *
3143 + * @param mixed $raw The raw `@type` value.
3144 + * @return string[] Unique lowercased tokens, possibly empty.
3145 + */
3146 + private function schema_type_tokens($raw) {
3147 + $tokens = [];
3148 +
3149 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3150 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3151 + continue;
3152 + }
3153 + $type = strtolower(trim((string) $type));
3154 + if ($type === '') {
3155 + continue;
3156 + }
3157 + $tokens[$type] = true;
3158 + }
3159 +
3160 + return array_keys($tokens);
3161 + }
3162 +
3163 + /**
1941 3164 * Deduplicate JSON-LD schema blocks.
1942 3165 *
1943 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1944 - * blocks, keep OTTO's version for any @type that appears in both.
1945 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3166 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3167 + * token the page already carries. Collisions resolve strictly in OTTO's
3168 + * favour:
1946 3169 *
3170 + * - an OTTO node (marked `data-otto`) displaces every colliding
3171 + * third-party node, whichever appeared first in the document;
3172 + * - two OTTO nodes describing the same entity keep the first;
3173 + * - two third-party nodes never evict each other, even when they share a
3174 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3175 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3176 + * output this pass has no mandate to fold.
3177 + *
3178 + * When OTTO contributed no node at all the document comes back untouched —
3179 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3180 + * into the original document, so a block that loses nothing keeps its exact
3181 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3182 + * and nothing is ever relocated.
3183 + *
3184 + * Blocks are left exactly where they are when their JSON does not parse,
3185 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3186 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3187 + * it in a comment must stay disabled, not be re-published by this pass.
3188 + *
1947 3189 * @param string $html Full HTML.
1948 3190 * @return string
1949 3191 */
1950 3192 private function deduplicate_schema_tags($html) {
1951 - // Find all JSON-LD script blocks
1952 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1953 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3193 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3194 + // capturing only the part before `type=` would hide a `data-otto` marker
3195 + // that sits after it, which is where every insertion path puts it. The
3196 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3197 + // is recognised too.
3198 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3199 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
1954 3200 return $html;
1955 3201 }
1956 3202
1957 - $otto_by_type = []; // @type => decoded JSON object
1958 - $third_by_type = []; // @type => decoded JSON object
1959 - $otto_graph = []; // entries from OTTO @graph blocks
1960 - $third_graph = []; // entries from third-party @graph blocks
3203 + // Byte ranges this pass must not reach into. A commented-out (or
3204 + // <noscript>-wrapped) schema block is deliberately disabled output.
3205 + $masked = [];
3206 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3207 + foreach ($comments as $comment) {
3208 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3209 + }
3210 + }
1961 3211
3212 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3213 + // `data-otto`. A substring test would also catch a hypothetical
3214 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3215 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3216 +
3217 + // Blocks able to take part, and their entities in document order. An
3218 + // entity is one addressable node: a flat block's document, one
3219 + // `@graph` entry, or one node of a top-level array.
3220 + $blocks = [];
3221 + $entities = [];
3222 +
1962 3223 foreach ($matches as $m) {
1963 - $attrs = $m[1];
1964 - $json_str = $m[3];
3224 + $start = $m[0][1];
3225 + foreach ($masked as $range) {
3226 + if ($start >= $range[0] && $start < $range[1]) {
3227 + continue 2;
3228 + }
3229 + }
3230 +
3231 + $attrs = $m[1][0];
3232 + $json_str = $m[3][0];
1965 3233 $decoded = json_decode($json_str, true);
1966 3234 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1967 - continue; // skip unparseable blocks — leave them in place
3235 + continue; // unparseable — leave the block in place
1968 3236 }
1969 - $is_otto = stripos($attrs, 'data-otto') !== false;
1970 3237
3238 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3239 + $end = $start + strlen($m[0][0]);
3240 +
3241 + // A trailing newline swallowed on full removal keeps the output tidy.
3242 + $end_ws = $end;
3243 + if (substr($html, $end, 2) === "\r\n") {
3244 + $end_ws = $end + 2;
3245 + } elseif (substr($html, $end, 1) === "\n") {
3246 + $end_ws = $end + 1;
3247 + }
3248 +
3249 + $register = function (array $node) use (&$entities, $is_otto) {
3250 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3251 + // @type tokens for the type fold. Entries with neither stay in
3252 + // their block but cannot collide, so they keep entity === null.
3253 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3254 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3255 + if ($types === [] && $id === '') {
3256 + return null;
3257 + }
3258 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3259 + return count($entities) - 1;
3260 + };
3261 +
3262 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3263 +
1971 3264 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1972 - foreach ($decoded['@graph'] as $entry) {
1973 - if (!isset($entry['@type'])) continue;
1974 - // JSON-LD allows @type to be a string OR an array of strings.
1975 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1976 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1977 - $type = is_array($entry['@type'])
1978 - ? (string) reset($entry['@type'])
1979 - : (string) $entry['@type'];
1980 - if ($type === '') continue;
1981 - if ($is_otto) {
1982 - $otto_graph[$type] = $entry;
1983 - } else {
1984 - $third_graph[$type] = $entry;
1985 - }
3265 + foreach ($decoded['@graph'] as $node) {
3266 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
1986 3267 }
3268 + $block['shape'] = 'graph';
1987 3269 } elseif (isset($decoded['@type'])) {
1988 - $type = is_array($decoded['@type'])
1989 - ? (string) reset($decoded['@type'])
1990 - : (string) $decoded['@type'];
1991 - if ($type === '') continue;
1992 - if ($is_otto) {
1993 - $otto_by_type[$type] = $decoded;
3270 + $block['shape'] = 'flat';
3271 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3272 + } elseif (isset($decoded[0])) {
3273 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3274 + // persistence path already folds this shape; the render path
3275 + // used to skip it entirely, leaving its duplicates standing.
3276 + $block['shape'] = 'list';
3277 + foreach ($decoded as $node) {
3278 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3279 + }
3280 + } else {
3281 + continue; // neither @type nor @graph nor array — nothing to key on
3282 + }
3283 +
3284 + $blocks[] = $block;
3285 + }
3286 +
3287 + foreach ($entities as $entity) {
3288 + if ($entity['otto']) {
3289 + $otto_present = true;
3290 + break;
3291 + }
3292 + }
3293 + if (empty($otto_present)) {
3294 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3295 + }
3296 +
3297 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3298 + // node can displace a whole set of third-party duplicates at once.
3299 + /** @var array<string, int> $by_id */
3300 + $by_id = []; // '@id:...' => entity index
3301 + /** @var array<string, int[]> $by_type */
3302 + $by_type = []; // type token => entity index[]
3303 +
3304 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3305 + foreach ($entities[$i]['types'] as $token) {
3306 + $holders = $by_type[$token] ?? [];
3307 + $holders = array_values(array_diff($holders, [$i]));
3308 + if ($holders === []) {
3309 + unset($by_type[$token]);
1994 3310 } else {
1995 - $third_by_type[$type] = $decoded;
3311 + $by_type[$token] = $holders;
1996 3312 }
1997 3313 }
3314 + $id = $entities[$i]['id'];
3315 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3316 + unset($by_id[$id]);
3317 + }
3318 + };
3319 +
3320 + foreach ($entities as $i => $entity) {
3321 + $alive = true;
3322 +
3323 + // The same real-world entity twice, whatever @type each copy carries.
3324 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3325 + $occupant = $by_id[$entity['id']];
3326 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3327 + $entities[$occupant]['alive'] = false;
3328 + $release($occupant);
3329 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3330 + } else {
3331 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3332 + }
3333 + }
3334 +
3335 + if ($alive) {
3336 + foreach ($entity['types'] as $token) {
3337 + $holders = $by_type[$token] ?? [];
3338 + if ($holders === []) {
3339 + continue;
3340 + }
3341 + $otto_holder = false;
3342 + foreach ($holders as $holder) {
3343 + if ($entities[$holder]['otto']) {
3344 + $otto_holder = true;
3345 + break;
3346 + }
3347 + }
3348 + if ($otto_holder) {
3349 + $alive = false; // an OTTO node already holds this token
3350 + break;
3351 + }
3352 + if ($entity['otto']) {
3353 + // OTTO displaces every third-party holder of the token and
3354 + // releases ALL their claims, not just this one — otherwise
3355 + // tokens the evicted node held alone would stay blocked and
3356 + // later, legitimate nodes of that type would lose.
3357 + foreach ($holders as $holder) {
3358 + $entities[$holder]['alive'] = false;
3359 + $release($holder);
3360 + }
3361 + }
3362 + // Third party meeting third-party holders: both stand.
3363 + }
3364 + }
3365 +
3366 + if ($alive) {
3367 + foreach ($entity['types'] as $token) {
3368 + $by_type[$token][] = $i;
3369 + }
3370 + if ($entity['id'] !== '') {
3371 + $by_id[$entity['id']] = $i;
3372 + }
3373 + } else {
3374 + $entities[$i]['alive'] = false;
3375 + }
1998 3376 }
1999 3377
2000 - // If OTTO provided no schema at all, nothing to deduplicate
2001 - if (empty($otto_by_type) && empty($otto_graph)) {
2002 - return $html;
3378 + $any_evicted = false;
3379 + foreach ($entities as $entity) {
3380 + if (!$entity['alive']) {
3381 + $any_evicted = true;
3382 + break;
3383 + }
2003 3384 }
3385 + if (!$any_evicted) {
3386 + return $html; // no collision — every original byte stays
3387 + }
2004 3388
2005 - // Remove all JSON-LD blocks from HTML
2006 - $html = preg_replace($pattern, '', $html);
3389 + // Rebuild only the blocks that lost something, back-to-front so the
3390 + // recorded offsets of earlier blocks stay valid.
3391 + $edits = [];
3392 + foreach ($blocks as $block) {
3393 + $kept = [];
3394 + $lost = 0;
3395 + foreach ($block['items'] as $item) {
3396 + if ($item['e'] === null || $entities[$item['e']]['alive']) {
3397 + $kept[] = $item['node'];
3398 + } else {
3399 + $lost++;
3400 + }
3401 + }
3402 + if ($lost === 0) {
3403 + continue;
3404 + }
3405 + if ($kept === []) {
3406 + $edits[] = [$block['start'], $block['end_ws'], ''];
3407 + continue;
3408 + }
2007 3409
2008 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
2009 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
2010 - $rebuilt = '';
2011 - foreach ($kept as $decoded) {
2012 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2013 - wp_json_encode($decoded) . "</script>\n";
2014 - }
3410 + if ($block['shape'] === 'list') {
3411 + $payload = $kept;
3412 + } else { // graph — a flat block that lost its only entity is removed above
3413 + $payload = $block['decoded'];
3414 + $payload['@graph'] = $kept;
3415 + }
2015 3416
2016 - // Re-insert merged @graph block (if any entries exist)
2017 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
2018 - if (!empty($merged_graph)) {
2019 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
2020 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2021 - wp_json_encode($graph_obj) . "</script>\n";
3417 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3418 + if (!is_string($json) || $json === '') {
3419 + continue; // never drop a block whose payload failed to re-encode
3420 + }
3421 +
3422 + // The original attribute string is reused verbatim so class, id and
3423 + // the data-otto marker itself survive the rebuild.
3424 + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
2022 3425 }
2023 3426
2024 - // Re-inject before </head>
2025 - if (!empty($rebuilt)) {
2026 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
2027 - return $rebuilt . $m[1];
2028 - }, $html, 1);
3427 + foreach (array_reverse($edits) as $edit) {
3428 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
2029 3429 }
2030 3430
2031 3431 return $html;
2032 3432 }
@@ -2045,14 +3445,14 @@
2045 3445 return true;
2046 3446 }
2047 3447
2048 3448 # Check if amp=1 query parameter is present
2049 - if (isset($_GET['amp']) && $_GET['amp'] == '1') {
3449 + if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2050 3450 return true;
2051 3451 }
2052 3452
2053 3453 # Check for other common AMP query parameters
2054 - if (isset($_GET['amp']) && !empty($_GET['amp'])) {
3454 + if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2055 3455 return true;
2056 3456 }
2057 3457
2058 3458 return false;
@@ -2057,8 +3457,32 @@
2057 3457
2058 3458 return false;
2059 3459 }
2060 3460
3461 + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
3462 + # tell them apart from a third-party SEO plugin's blocks. Every insertion path
3463 + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
3464 + # bytes they insert are identical and the "already applied" checks still match.
3465 + private function stamp_otto_json_ld($html){
3466 +
3467 + $stamped = preg_replace_callback(
3468 + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
3469 + function ($m) {
3470 + # idempotent: a block already carrying a data-otto attribute —
3471 + # anywhere in the tag, not just before `type` — is left alone,
3472 + # never given a second one
3473 + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
3474 + return $m[0];
3475 + }
3476 + return '<script' . $m[1] . ' data-otto="true">';
3477 + },
3478 + $html
3479 + );
3480 +
3481 + # preg_replace returns null on a PCRE failure — never hand back less than we got
3482 + return is_string($stamped) ? $stamped : $html;
3483 + }
3484 +
2061 3485 # this function insterts header html to the dom
2062 3486 function insert_header_html($data){
2063 3487
2064 3488 # check that we have the header html
@@ -2071,20 +3495,27 @@
2071 3495 $head = $this->dom->find('head', 0);
2072 3496
2073 3497 if ($head) {
2074 3498
2075 - # Check if this is an AMP page - if so, don't add metasync_optimized attribute
3499 + # Check if this is an AMP page - if so, skip the processed marker
2076 3500 $is_amp_page = $this->is_amp_page();
2077 3501
2078 - # Append the new HTML at the start of the <head> tag
2079 - # For AMP pages: use clean <head> tag without metasync_optimized attribute
2080 - # For non-AMP pages: add metasync_optimized attribute to <head> tag
2081 - if ($is_amp_page) {
2082 - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
2083 - } else {
2084 - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
2085 - }
3502 + # Append the new HTML at the start of the <head> tag. The tag itself
3503 + # stays a literal '<head>' either way, so plugins that inject with a
3504 + # literal str_replace('<head>', ...) still match the real tag —
3505 + # see mark_head_optimized() for what breaks when it does not.
3506 + $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
2086 3507
3508 + # Escape JSON-LD payload closers so a `</script>` inside a JSON
3509 + # string value cannot terminate the element early (XSS/page corruption).
3510 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3511 + # fallbacks do, so the deduper can attribute them.
3512 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3513 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3514 + );
3515 +
3516 + $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3517 +
2087 3518 }
2088 3519
2089 3520 # save and reload DOM
2090 3521 $this->save_reload();
@@ -2201,8 +3632,35 @@
2201 3632
2202 3633 }
2203 3634
2204 3635 /**
3636 + * Explicitly free the SimpleHtmlDom node tree and reclaim memory.
3637 + *
3638 + * SimpleHtmlDom nodes hold circular parent/child references, so simply
3639 + * dropping the document does not free the tree until request shutdown.
3640 + * HtmlNode::clear() breaks those cycles; we then drop the document, force a
3641 + * GC pass, and re-instantiate a fresh empty parser so the instance stays
3642 + * reusable for any subsequent route on the same request.
3643 + */
3644 + function free_dom(){
3645 + if (isset($this->dom)) {
3646 + # Break the node tree's parent/child cycles so GC can reclaim it now.
3647 + if (isset($this->dom->root) && is_object($this->dom->root)
3648 + && method_exists($this->dom->root, 'clear')) {
3649 + $this->dom->root->clear();
3650 + }
3651 + unset($this->dom);
3652 + }
3653 +
3654 + # Fresh, empty parser — mirrors the constructor so the object is reusable.
3655 + $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
3656 +
3657 + if (function_exists('gc_collect_cycles')) {
3658 + gc_collect_cycles();
3659 + }
3660 + }
3661 +
3662 + /**
2205 3663 * Process HTML directly without HTTP request (for buffer approach)
2206 3664 * This is the FAST path - eliminates the internal wp_remote_get call
2207 3665 *
2208 3666 * CRITICAL: This method is called from the output buffer callback.
@@ -2224,15 +3682,25 @@
2224 3682 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
2225 3683 return false;
2226 3684 }
2227 3685
3686 + # Skip DOM processing on oversized documents to avoid fatal OOM.
3687 + # Returning false makes the buffer callback serve the original HTML
3688 + # unmodified — the page still renders, just without OTTO changes.
3689 + if (class_exists('Metasync_Otto_Render_Strategy')
3690 + && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html))
3691 + ) {
3692 + Metasync_Otto_Render_Strategy::log_oversized_skip('process_html_directly', strlen($html));
3693 + return false;
3694 + }
3695 +
2228 3696 # Remove XML declaration if present
2229 3697 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
2230 3698
2231 - # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
3699 + # / Save ALL original <style> blocks before DOM processing.
2232 3700 $original_style_blocks = $this->capture_style_blocks($html);
2233 3701
2234 - # WP-465: Shield entity-encoded srcdoc values (lazy YouTube/video facades)
3702 + # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
2235 3703 # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
2236 3704 $srcdoc_store = array();
2237 3705 $html = $this->protect_srcdoc_attributes($html, $srcdoc_store);
2238 3706
@@ -2238,11 +3706,15 @@
2238 3706
2239 3707 # Escape bare < in text content before DOM parsing
2240 3708 $html = $this->sanitize_text_less_than($html);
2241 3709
2242 - # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
3710 + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
2243 3711 $html = $this->fix_malformed_self_closing_tags($html);
2244 3712
3713 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
3714 + # SimpleHtmlDom swallows every following attribute into such a value.
3715 + $html = $this->quote_unquoted_attribute_values($html);
3716 +
2245 3717 # Load HTML into DOM
2246 3718 $this->dom->load($html, true, false);
2247 3719
2248 3720 # Force UTF-8 charset to preserve emojis and special characters
@@ -2424,15 +3896,16 @@
2424 3896 }
2425 3897
2426 3898 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
2427 3899 if (!empty($replacement_data['header_html_insertion'])) {
2428 - $header_html_check = trim($replacement_data['header_html_insertion']);
3900 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
3901 + # path inserts the same bytes, so the already-applied check must compare
3902 + # against the stamped form or it would insert a second copy.
3903 + $header_html_insertion = $this->stamp_otto_json_ld(
3904 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3905 + );
3906 + $header_html_check = trim($header_html_insertion);
2429 3907 if (strpos($result_html, $header_html_check) === false) {
2430 - $header_html_insertion = preg_replace(
2431 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
2432 - '<script$1type=$2application/ld+json$2 data-otto="true"',
2433 - $replacement_data['header_html_insertion']
2434 - );
2435 3908 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
2436 3909 # Insert before </head>
2437 3910 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
2438 3911 }
@@ -2487,9 +3960,9 @@
2487 3960 }
2488 3961
2489 3962 # CRITICAL FIX: Apply image alt text manually via string replacement
2490 3963 # DOM changes don't persist, must use string replacement
2491 - $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
3964 + $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');
2492 3965
2493 3966 # String-based heading fallback for body_substitutions
2494 3967 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2495 3968 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
@@ -2504,13 +3977,19 @@
2504 3977
2505 3978 $result_html = preg_replace_callback(
2506 3979 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2507 3980 function ($m) use ($current_value, $recommended_value) {
2508 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2509 - if ($inner_text === $current_value) {
2510 - return $m[1] . $recommended_value . $m[3];
3981 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3982 + if ($inner_text !== $current_value) {
3983 + return $m[0];
2511 3984 }
2512 - return $m[0];
3985 + # Keep the author's inline formatting; skip when it can't
3986 + # be preserved rather than flattening the heading.
3987 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
3988 + if ($new_inner === null) {
3989 + return $m[0];
3990 + }
3991 + return $m[1] . $new_inner . $m[3];
2513 3992 },
2514 3993 $result_html,
2515 3994 -1
2516 3995 );
@@ -2552,38 +4031,45 @@
2552 4031 $result_html
2553 4032 );
2554 4033 }
2555 4034
2556 - # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2557 - $result_html = $this->deduplicate_title_tags($result_html);
2558 - $result_html = $this->deduplicate_og_twitter_tags($result_html);
2559 - $result_html = $this->deduplicate_schema_tags($result_html);
2560 - $result_html = $this->deduplicate_canonical_tags($result_html);
4035 + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
4036 + $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);
2561 4037
4038 + # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
4039 + $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
4040 + $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
4041 + $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
4042 + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
4043 + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
4044 + $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
4045 + $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
4046 +
2562 4047 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2563 4048 # This ensures memory is released immediately, especially important for high-traffic sites
2564 4049 unset($final_meta_matches, $matches);
2565 4050
2566 - # Clear SimpleHtmlDom internal cache to free memory
2567 - # Note: We don't unset $this->dom as the object may be reused
2568 - if ($this->dom && method_exists($this->dom, 'clear')) {
2569 - $this->dom->clear();
2570 - }
4051 + # Explicitly free the SimpleHtmlDom node tree now that the
4052 + # result has been serialized to a string. The bundled HtmlDocument has
4053 + # no clear() method, so the previous method_exists() guard was a no-op
4054 + # and the (circular-referenced) node tree lingered until object
4055 + # destruction. free_dom() breaks those references and reclaims memory.
4056 + $this->free_dom();
2571 4057
2572 4058 # Clear element cache array
2573 4059 $this->cached_elements = [];
2574 4060
2575 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2576 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2577 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
4061 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
4062 + if (!$this->is_amp_page()) {
4063 + $result_html = $this->mark_head_optimized($result_html);
2578 4064 }
2579 4065
2580 - $result_html = $this->restore_case_sensitive_attributes($result_html);
4066 + $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
2581 4067
2582 - # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
2583 - $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
4068 + # / Re-inject any <style> blocks lost during processing.
4069 + $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');
2584 4070
2585 - # WP-480: Do NOT renumber Divi classes on the buffer/in-place path.
4071 + # Do NOT renumber Divi classes on the buffer/in-place path.
2586 4072 # fix_divi_class_renumbering() exists to undo the index offset that
2587 4073 # Divi's shortcode framework introduces ONLY during the internal
2588 4074 # wp_remote_get fetch (handle_route_html), where the Theme Builder
2589 4075 # header is counted twice. process_html_directly() processes the
@@ -2593,23 +4079,42 @@
2593 4079 # colliding page modules with header/global modules of the same
2594 4080 # number and collapsing layouts (Divi hero/slider) on Divi sites.
2595 4081 # The renumber stays only on the HTTP-fetch path (handle_route_html).
2596 4082
2597 - # WP-315: Clean OTTO internal fetch params from HTML output
2598 - $result_html = $this->clean_otto_fetch_params($result_html);
4083 + # Clean OTTO internal fetch params from HTML output
4084 + $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');
2599 4085
2600 - # WP-465: Remove any lazy-video facade <style> hoisted into the page
4086 + # Remove any lazy-video facade <style> hoisted into the page
2601 4087 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
4088 + $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');
4089 +
4090 + # Restore the original encoded srcdoc values (undo tokenization).
4091 + $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');
4092 +
4093 + # Keep <meta charset> within the first 1024 bytes so external CSS
4094 + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
4095 + $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');
4096 +
4097 + # undo double-encoded numeric/hex character references produced by the
4098 + # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
4099 + $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
4100 +
4101 + # restore literal '&' query-string separators the serializer escaped to
4102 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
4103 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
4104 +
4105 + # Remove any lazy-video facade <style> hoisted into the page
4106 + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
2602 4107 $result_html = $this->strip_hoisted_facade_styles($result_html);
2603 4108
2604 - # WP-465: Restore the original encoded srcdoc values (undo tokenization).
4109 + # Restore the original encoded srcdoc values (undo tokenization).
2605 4110 $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);
2606 4111
2607 - # WP-465: Keep <meta charset> within the first 1024 bytes so external CSS
4112 + # Keep <meta charset> within the first 1024 bytes so external CSS
2608 4113 # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
2609 4114 $result_html = $this->ensure_early_charset_meta($result_html);
2610 4115
2611 - # WP-471: undo double-encoded numeric/hex character references produced by the
4116 + # undo double-encoded numeric/hex character references produced by the
2612 4117 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
2613 4118 $result_html = $this->repair_double_encoded_entities($result_html);
2614 4119
2615 4120 return $result_html;
@@ -2614,12 +4119,26 @@
2614 4119
2615 4120 return $result_html;
2616 4121
2617 4122 } catch (Exception $e) {
2618 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
4123 + if (function_exists('metasync_otto_report_render_failure')) {
4124 + metasync_otto_report_render_failure(
4125 + 'MODIFICATION_EXCEPTION',
4126 + 'OTTO rewrite threw: ' . $e->getMessage(),
4127 + ['exception' => get_class($e)],
4128 + 'error'
4129 + );
4130 + }
2619 4131 return false;
2620 4132 } catch (Error $e) {
2621 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
4133 + if (function_exists('metasync_otto_report_render_failure')) {
4134 + metasync_otto_report_render_failure(
4135 + 'MODIFICATION_ERROR',
4136 + 'OTTO rewrite errored: ' . $e->getMessage(),
4137 + ['error' => get_class($e)],
4138 + 'error'
4139 + );
4140 + }
2622 4141 return false;
2623 4142 }
2624 4143 }
2625 4144
@@ -2668,29 +4187,21 @@
2668 4187 }
2669 4188 }
2670 4189 }
2671 4190
2672 - if ($remove_title && !$has_custom_title) {
2673 - # Remove Open Graph and Twitter title tags (keep main <title>)
2674 - $title_selectors = [
2675 - 'meta[property=og:title]',
2676 - 'meta[name=twitter:title]',
2677 - ];
4191 + # Preserve third-party og:title/twitter:title tags here. They used to be
4192 + # stripped whenever $remove_title was set, but that also removes them
4193 + # when no custom title exists to replace them, leaving pages with no
4194 + # og:title/twitter:title at all. OTTO's own title/OG dedup and
4195 + # precedence passes already reconcile duplicates, so this branch no
4196 + # longer needs to delete those tags.
2678 4197
2679 - foreach ($title_selectors as $selector) {
2680 - $tags = $this->dom->find($selector);
2681 - foreach ($tags as $tag) {
2682 - $tag->outertext = ''; # Remove the tag
2683 - }
2684 - }
2685 - }
2686 -
2687 4198 # Save changes
2688 4199 $this->save_reload();
2689 4200 }
2690 4201
2691 4202 /**
2692 - * Clean OTTO internal fetch query parameters from rendered HTML (WP-315).
4203 + * Clean OTTO internal fetch query parameters from rendered HTML.
2693 4204 *
2694 4205 * @param string $html The rendered HTML
2695 4206 * @return string HTML with OTTO fetch params removed
2696 4207 */
@@ -2704,9 +4215,9 @@
2704 4215 '/[?&]amp;otto_block_desc=1/',
2705 4216 ];
2706 4217 $html = preg_replace($patterns, '', $html);
2707 4218
2708 - # WP-398: Protect <script>/<style> bodies before the URL cleanup below.
4219 + # Protect <script>/<style> bodies before the URL cleanup below.
2709 4220 # The href/src/action matcher can otherwise match JS that looks like an
2710 4221 # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the
2711 4222 # ?? inside it. Swap each block for a placeholder comment, run the cleanup,
2712 4223 # then restore byte-for-byte.
@@ -2720,17 +4231,25 @@
2720 4231 },
2721 4232 $html
2722 4233 );
2723 4234
2724 - # WP-398: Normalize leftover query-string separators (?? / ?& / ?#) created
2725 - # by the param removal above — but ONLY inside URL attributes (href/src/action).
2726 - # The previous implementation ran str_replace('??','?') over the ENTIRE document,
2727 - # which corrupted inline JavaScript nullish operators (?? / ??=) and any literal
2728 - # "??" in page text — breaking Slider Revolution and other modern inline scripts.
4235 + # Normalize leftover query-string separators (?& / ?#) created by the param
4236 + # removal above — but ONLY inside URL attributes (href/src/action).
4237 + #
4238 + # IMPORTANT: never collapse "??". It is valid, load-bearing syntax for
4239 + # file-concatenation endpoints — Jetpack Boost (/_jb_static/??<hash>) and
4240 + # WordPress core (load-styles.php??... / load-scripts.php??...). Collapsing
4241 + # "??" -> "?" points those at a different URL that returns HTTP 400 / empty,
4242 + # so every affected CSS/JS bundle fails to load and the page renders broken
4243 + # (Kadence + Jetpack Boost "defer non-essential CSS" was the reported case).
4244 + # Removing an OTTO fetch param never produces a "??" anyway, so there is
4245 + # nothing to normalize there.
4246 + # (A previous implementation ran str_replace('??','?') over the ENTIRE
4247 + # document, which additionally corrupted inline JavaScript nullish operators.)
2729 4248 $html = preg_replace_callback(
2730 4249 '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is',
2731 4250 function ($m) {
2732 - $url = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $m[4]);
4251 + $url = str_replace(['?&', '?#'], ['?', '#'], $m[4]);
2733 4252 return $m[1] . $m[2] . $m[3] . $url . $m[3];
2734 4253 },
2735 4254 $html
2736 4255 );
@@ -2743,9 +4262,9 @@
2743 4262 return $html;
2744 4263 }
2745 4264
2746 4265 /**
2747 - * Fix Divi 5 shortcode framework class renumbering (WP-315)
4266 + * Fix Divi 5 shortcode framework class renumbering
2748 4267 *
2749 4268 * When Divi 5's shortcode framework loads during the internal HTTP fetch,
2750 4269 * it uses shared counters across all template parts (header + page + footer),
2751 4270 * causing page content element classes to be offset (et_pb_section_0 becomes
@@ -2785,9 +4304,9 @@
2785 4304 # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
2786 4305 # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
2787 4306 # wp_remote_get includes the Theme Builder header template in Divi's counter.
2788 4307 # If the blog module class doesn't match between page 1 (OTTO-processed) and
2789 - # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results (WP-315).
4308 + # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results.
2790 4309 $types = [
2791 4310 'section', 'row', 'column', 'text', 'blurb', 'toggle',
2792 4311 'button', 'image', 'group_carousel',
2793 4312 'blog', 'portfolio', 'filterable_portfolio', 'shop',
@@ -2802,9 +4321,9 @@
2802 4321 # The width fraction is NOT an instance counter; rewriting it (e.g.
2803 4322 # et_pb_column_4_4 → et_pb_column_0_4) strips the column's width rule from
2804 4323 # Divi's static stylesheet (.et_pb_column_4_4{width:100%}) and collapses the
2805 4324 # layout. Bare instance indices (et_pb_column_16, followed by space/quote)
2806 - # still match and renumber as intended (WP-470 / WP-315).
4325 + # still match and renumber as intended.
2807 4326 $instance_lookahead = '(?!_(?:tb_|\d))';
2808 4327
2809 4328 foreach ($types as $type) {
2810 4329 # Find the FIRST numbered instance in page content (not _tb_ or width-fraction)
@@ -2900,5 +4419,5 @@
2900 4419 return $html;
2901 4420 }
2902 4421
2903 4422
2904 -}
4423 +}