PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 All 139 releases
← All changes | otto/Otto_html_class.php +2055 -176 2.6.10trunk 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.
@@ -184,8 +376,371 @@
184 376
185 377 return $html;
186 378 }
187 379
380 + /**
381 + * Build the new inner HTML for a heading whose text OTTO is replacing,
382 + * preserving the author's inline formatting.
383 + *
384 + * Headings are matched by their stripped text, so the recommended value is
385 + * plain text with no way to know how it maps onto the original markup.
386 + * Writing it back directly would discard whatever was inside — a coloured
387 + * <span>, <em>, <br> line breaks — and visibly restyle the page.
388 + *
389 + * Two cases are safe:
390 + * - the heading is already plain text -> replace it
391 + * - the heading is ONE nested inline wrapper -> keep the wrappers,
392 + * chain around a single run of text swap only the text
393 + *
394 + * Anything else is ambiguous: sibling elements, <br>-separated lines, or
395 + * text mixed with elements have no single place the new sentence belongs.
396 + * Those return null so the caller leaves the heading alone — an unapplied
397 + * suggestion is better than silently restyling the customer's content.
398 + *
399 + * @param string $inner_html Current inner HTML of the heading.
400 + * @param string $replacement_text New text, already escaped by the caller.
401 + * @return string|null New inner HTML, or null to skip this heading.
402 + */
403 + public static function build_heading_inner_html($inner_html, $replacement_text) {
404 + $inner = trim($inner_html);
405 +
406 + # Plain text — nothing to preserve.
407 + if (strpos($inner, '<') === false) {
408 + return $replacement_text;
409 + }
410 +
411 + # Inline elements whose wrapper carries the styling worth keeping.
412 + # Deliberately excludes <br>: it encodes line structure, not styling,
413 + # and its presence means the heading has more than one text run.
414 + $inline = 'span|em|strong|b|i|u|a|small|mark|abbr|cite|q|s|sub|sup|font';
415 +
416 + $open = '';
417 + $close = '';
418 +
419 + # Peel wrappers outermost-in. The greedy (.*) means a heading with
420 + # sibling elements (e.g. <em>A</em><br><em>B</em>) leaves stray tags in
421 + # $inner, which the ambiguity check below then rejects.
422 + while (preg_match('#^<(' . $inline . ')(\s[^>]*)?>(.*)</\1\s*>$#is', $inner, $m)) {
423 + # Group 2 (the attribute list) is optional but always captured as ''
424 + # when absent, because group 3 follows it.
425 + $open .= '<' . $m[1] . $m[2] . '>';
426 + $close = '</' . $m[1] . '>' . $close;
427 + $inner = trim($m[3]);
428 +
429 + if (strpos($inner, '<') === false) {
430 + return $open . $replacement_text . $close;
431 + }
432 + }
433 +
434 + # Still markup left after peeling — ambiguous, so don't touch it.
435 + return null;
436 + }
437 +
438 + /**
439 + * / Protect entity-encoded `srcdoc` attribute values before DOM parsing.
440 + *
441 + * Lazy-loaded YouTube/video facades embed a full HTML document inside an
442 + * iframe's `srcdoc` attribute, entity-encoded:
443 + * <iframe srcdoc="&lt;style&gt;*{overflow:hidden}...&lt;/style&gt;&lt;a&gt;..."></iframe>
444 + * SimpleHtmlDom decodes that value and re-emits the inner <style>/<a>/<img>
445 + * as REAL nodes hoisted into the document <head>. The facade's global CSS
446 + * (`*{overflow:hidden}`, `img,span{position:absolute;width:100%;...}`) then
447 + * nukes the entire page layout — the page renders blank below the header even
448 + * though all body content is still present (so the element-loss guard can't
449 + * see it). We swap each srcdoc value for an opaque token before parsing and
450 + * restore it verbatim afterward, so SimpleHtmlDom never touches it.
451 + *
452 + * @param string $html Raw HTML before DOM parsing.
453 + * @param array $store (out) token => original encoded value.
454 + * @return string HTML with srcdoc values tokenized.
455 + */
456 + private function protect_srcdoc_attributes($html, &$store) {
457 + $store = array();
458 + $index = 0;
459 + return preg_replace_callback(
460 + '/\bsrcdoc\s*=\s*(["\'])(.*?)\1/is',
461 + function ($m) use (&$store, &$index) {
462 + $token = '__METASYNC_SRCDOC_' . ($index++) . '__';
463 + $store[$token] = $m[2];
464 + return 'srcdoc="' . $token . '"';
465 + },
466 + $html
467 + );
468 + }
469 +
470 + /**
471 + * Restore srcdoc values tokenized by protect_srcdoc_attributes().
472 + * Tokens are unique and never altered by SimpleHtmlDom, so a plain
473 + * str_replace on the final output is safe.
474 + *
475 + * @param string $html Processed HTML.
476 + * @param array $store Token map from protect_srcdoc_attributes().
477 + * @return string
478 + */
479 + private function restore_srcdoc_attributes($html, $store) {
480 + if (empty($store)) {
481 + return $html;
482 + }
483 + return str_replace(array_keys($store), array_values($store), $html);
484 + }
485 +
486 + /**
487 + * Safety net — strip any lazy-video FACADE <style> that leaked into the
488 + * document as a real stylesheet.
489 + *
490 + * Lazy YouTube/video facades carry this exact CSS reset inside their iframe
491 + * srcdoc: *{...overflow:hidden} html,body{height:100%} img,span{position:absolute;width:100%;...}
492 + * If anything (SimpleHtmlDom attribute-decode, capture/restore_lost_style_blocks)
493 + * hoists it out of the srcdoc into a real <style>, it absolutely-positions every
494 + * image and span on the page and clips all overflow — rendering the page blank
495 + * below the header. No legitimate global stylesheet ever does this to img,span,
496 + * so removing such a block is safe.
497 + *
498 + * Must run AFTER srcdoc values are tokenized (protect_srcdoc_attributes) so the
499 + * encoded copy still living inside the iframe attribute is never matched.
500 + *
501 + * @param string $html
502 + * @return string
503 + */
504 + /**
505 + * Crash-free guard for full-document string transforms.
506 + *
507 + * PCRE operations (preg_replace / preg_replace_callback) return NULL when they
508 + * hit a limit — most notably "JIT stack limit exhausted" on Divi/page-builder
509 + * pages whose large inline <style> blocks defeat tempered-greedy patterns.
510 + * Historically that NULL propagated through the rest of the pipeline, so
511 + * process_html_directly() returned empty HTML, the render strategy's 50%-size
512 + * sanity check rejected it, and the ORIGINAL un-optimised page was served —
513 + * OTTO silently did nothing.
514 + *
515 + * This guard keeps the last-good HTML whenever a transform yields NULL (or an
516 + * unexpectedly empty string), so a single failing regex can no longer discard
517 + * every OTTO optimisation. It is defence-in-depth: the specific pattern that
518 + * triggered (strip_hoisted_facade_styles) has been rewritten to be
519 + * non-backtracking, but this guard still protects the rest of the pipeline.
520 + *
521 + * @param string|null $new Result of the transform.
522 + * @param string $prev HTML before the transform (fallback value).
523 + * @param string $where Transform name, for the log line.
524 + * @return string
525 + */
526 + private function otto_guard_html($new, $prev, $where = '') {
527 + if ($new === null || (is_string($new) && $new === '' && $prev !== '')) {
528 + # A full-document transform ($where) returned NULL (e.g. a PCRE
529 + # limit such as "JIT stack limit exhausted"). Keep the last-good
530 + # HTML so one failed step can't discard every OTTO optimisation.
531 + # Intentionally silent — not logged, to keep customer logs clean.
532 + return $prev;
533 + }
534 + return $new;
535 + }
536 +
537 + private function strip_hoisted_facade_styles($html) {
538 + # Non-backtracking implementation.
539 + #
540 + # The previous single-regex approach used two tempered-greedy segments
541 + # ((?:(?!</style>).)*?) to keep the match inside one <style> block. That
542 + # runs a negative lookahead for every character, and on Divi/page-builder
543 + # pages whose inline <style> blocks are tens of KB it overflows the PCRE
544 + # JIT stack ("JIT stack limit exhausted"). preg_replace() then returns
545 + # NULL, which discarded the entire OTTO-modified document.
546 + #
547 + # Instead, isolate each <style>…</style> block with a single lazy .*?
548 + # (cheap, no per-char lookahead) and test only that bounded block for the
549 + # facade signature. The signature check runs on one small block at a time,
550 + # so neither pattern can strain the JIT stack.
551 + if (!is_string($html) || stripos($html, '<style') === false) {
552 + return $html;
553 + }
554 +
555 + $out = preg_replace_callback(
556 + '#<style\b[^>]*>.*?</style>#is',
557 + static function ($m) {
558 + # Facade reset: "img,span{ … position:absolute … }" inside this block.
559 + if (preg_match('#\bimg\s*,\s*span\s*\{[^}]*position\s*:\s*absolute[^}]*\}#is', $m[0])) {
560 + return '';
561 + }
562 + return $m[0];
563 + },
564 + $html
565 + );
566 +
567 + # Belt-and-suspenders: if PCRE still fails for any reason, keep the input.
568 + return $out === null ? $html : $out;
569 + }
570 +
571 + /**
572 + * Mark the <head> as OTTO-processed without altering the <head> tag itself.
573 + *
574 + * This marker used to be an attribute — <head metasync_optimized> — which
575 + * removed the literal string "<head>" from the document. Anything that
576 + * injects into the head with a literal str_replace('<head>', ...) then found
577 + * no match on the real tag and silently matched the *next* literal "<head>"
578 + * further down the page. That next one can sit inside a JS comment, a <pre>
579 + * block or escaped documentation text; injecting a <script>...</script> there
580 + * closes the enclosing script early, strands the rest of its code as a text
581 + * node in <head>, and the browser therefore ends <head> at that point —
582 + * pushing title, canonical, OG/Twitter and JSON-LD into <body>, where
583 + * crawlers and social scrapers ignore them.
584 + *
585 + * A comment placed immediately after the tag carries the same signal and
586 + * leaves the tag byte-identical. Note that an HTML minifier may strip
587 + * comments, so treat the <meta name="otto"> tag as the durable marker; this
588 + * one is a convenience for support and QA.
589 + *
590 + * Only the first <head> is marked. The real tag is always the first one in
591 + * the document, so this pass cannot wander the way a bare literal match can.
592 + *
593 + * @param string $html Serialized HTML.
594 + * @return string
595 + */
596 + private function mark_head_optimized($html)
597 + {
598 + # Idempotent, and also a no-op on pages still carrying the legacy
599 + # attribute form (e.g. HTML already in a page cache).
600 + if ($html === '' || strpos($html, self::HEAD_OPTIMIZED_TOKEN) !== false) {
601 + return $html;
602 + }
603 +
604 + $out = preg_replace(
605 + '/(<head(?:\s[^>]*)?>)/i',
606 + '$1' . self::HEAD_OPTIMIZED_MARKER,
607 + $html,
608 + 1
609 + );
610 +
611 + # Belt-and-suspenders: if PCRE fails for any reason, keep the input.
612 + return $out === null ? $html : $out;
613 + }
614 +
615 + /**
616 + * Apply OTTO's canonical via string replacement (reliable fallback).
617 + *
618 + * Why the DOM path fails: do_header_replacements() sets the canonical with a
619 + * DOM attribute edit ($link->href = …). That edit works in isolation, but
620 + * insert_header_html() runs *earlier* in the same pipeline and reassigns
621 + * $head->outertext to a raw string (to inject OTTO's schema). In SimpleHtmlDom,
622 + * assigning ->outertext freezes that node — <head> now serializes as that
623 + * literal string and no longer re-renders from its child node tree. The
624 + * canonical <link> is captured inside that frozen string with its OLD href, so
625 + * the later $link->href edit is never reflected in the output and the SEO
626 + * plugin's (Yoast/Rank Math/AIOSEO) canonical always wins. This is the same
627 + * head-freeze that forced title/meta onto string-replacement fallbacks;
628 + * canonical was the one case that never got one.
629 + *
630 + * This pass runs on the final serialized HTML (after the freeze), so it is
631 + * immune to the ordering problem. It guarantees exactly one canonical —
632 + * OTTO's: it removes every existing <link rel="canonical"> and inserts OTTO's
633 + * recommended value (marked data-otto="true") right after <head>. Manual
634 + * canonicals (_metasync_canonical_url / meta_canonical) still take priority,
635 + * matching the DOM path's protection.
636 + *
637 + * @param string $html Serialized HTML.
638 + * @param array $replacement_data OTTO suggestions.
639 + * @return string
640 + */
641 + private function apply_canonical_via_string($html, $replacement_data) {
642 + if (!is_string($html) || empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])) {
643 + return $html;
644 + }
645 +
646 + # Find OTTO's canonical recommendation.
647 + $canonical = '';
648 + foreach ($replacement_data['header_replacements'] as $item) {
649 + if (($item['type'] ?? '') === 'link' && ($item['rel'] ?? '') === 'canonical') {
650 + $canonical = $item['recommended_value'] ?? $item['value'] ?? '';
651 + break;
652 + }
653 + }
654 + if (empty($canonical) || !is_string($canonical)) {
655 + return $html;
656 + }
657 +
658 + # Validate OTTO's own suggestion too: if the platform payload
659 + # carries a corrupted/non-URL canonical, leave the document untouched.
660 + $canonical = Metasync_Canonical_Sanitizer::sanitize($canonical);
661 + if ($canonical === '') {
662 + return $html;
663 + }
664 +
665 + # Respect a manually-set canonical (same protection as the DOM path).
666 + # Validated: a legacy row corrupted to "Array" must not count
667 + # as a manual canonical — that would suppress OTTO's correct value.
668 + if (function_exists('is_singular') && is_singular()) {
669 + $post_id = function_exists('get_the_ID') ? get_the_ID() : 0;
670 + if ($post_id) {
671 + $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true));
672 + if ($custom === '') {
673 + $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true));
674 + }
675 + if ($custom !== '') {
676 + return $html; # manual canonical wins — leave the document untouched
677 + }
678 + }
679 + }
680 +
681 + # Only proceed if there is a <head> to place the tag in (never end up with zero canonical).
682 + if (!preg_match('#<head\b[^>]*>#i', $html)) {
683 + return $html;
684 + }
685 +
686 + $tag = '<link rel="canonical" href="' . htmlspecialchars($canonical, ENT_QUOTES, 'UTF-8') . '" data-otto="true" />';
687 +
688 + # Remove every existing canonical link (bounded per-tag pattern; null-safe).
689 + $stripped = preg_replace('#<link\b[^>]*\brel=(["\'])canonical\1[^>]*>\s*#i', '', $html);
690 + if (is_string($stripped)) {
691 + $html = $stripped;
692 + }
693 +
694 + # Insert OTTO's canonical right after <head> (callback avoids $/\ interpolation from the URL).
695 + $inserted = preg_replace_callback('#(<head\b[^>]*>)#i', function ($m) use ($tag) {
696 + return $m[1] . "\n" . $tag;
697 + }, $html, 1);
698 + if (is_string($inserted)) {
699 + $html = $inserted;
700 + }
701 +
702 + return $html;
703 + }
704 +
705 + /**
706 + * Keep the charset declaration within the first 1024 bytes of <head>.
707 + *
708 + * OTTO injects meta tags + JSON-LD schema at the top of <head>, which can push
709 + * the theme's <meta charset="utf-8"> past the 1024-byte limit that browsers
710 + * enforce for in-document charset detection (HTML spec). When that happens —
711 + * and a cached/CDN response is served without an HTTP charset header — the
712 + * document falls back to the locale encoding (Windows-1252). External
713 + * stylesheets that declare no @charset of their own (e.g. a theme rule like
714 + * `content:"\2713"` written as a raw UTF-8 ✓) then inherit that wrong encoding
715 + * and render as mojibake ("âœ"" instead of "✓").
716 + *
717 + * We guarantee a <meta charset="UTF-8"> as the first child of <head> whenever
718 + * one isn't already present within the first 1024 bytes. Harmless when a valid
719 + * early charset already exists (we skip), and a duplicate later declaration is
720 + * ignored by the browser (first one wins).
721 + *
722 + * @param string $html
723 + * @return string
724 + */
725 + private function ensure_early_charset_meta($html) {
726 + if (!preg_match('/<head\b[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) {
727 + return $html;
728 + }
729 + $inner_start = $m[0][1] + strlen($m[0][0]);
730 +
731 + # Already declared early enough? Leave it alone (also covers AMP, which
732 + # requires charset as the first child).
733 + $window = substr($html, $inner_start, 1024);
734 + if (preg_match('/<meta[^>]*charset/i', $window)) {
735 + return $html;
736 + }
737 +
738 + return substr($html, 0, $inner_start)
739 + . '<meta charset="UTF-8">'
740 + . substr($html, $inner_start);
741 + }
742 +
188 743 #
189 744 function __construct($otto_uuid){
190 745
191 746 # set the site uuid using the provided string
@@ -447,9 +1002,47 @@
447 1002
448 1003 # Get the response code
449 1004 $response_code = wp_remote_retrieve_response_code($route_html);
450 1005
1006 + // Requests follows redirects and returns the final body with its final
1007 + // status. Do not put that body on the original route when a redirect
1008 + // changed the URL: doing so creates a soft redirect with mismatched
1009 + // content and SEO metadata. The normal WordPress request can handle
1010 + // the redirect, and OTTO will process the destination on its own URL.
1011 + $effective_url = $this->get_effective_fetch_url($route_html);
1012 + if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
1013 + error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: SKIPPED - internal fetch followed redirect from ' . $route . ' to ' . $effective_url);
1014 + return false;
1015 + }
451 1016
1017 + # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
1018 + # body below and would otherwise discard this — but it is both the form/session
1019 + # signal for the caching decision AND (same-origin only) the session the visitor
1020 + # needs for their forms to work. $request_body is the URL actually fetched (it may
1021 + # be rewritten to localhost for tunnels), so only allow pass-through when its
1022 + # scheme+host match the visitor's; otherwise we would set a wrong-domain cookie.
1023 + if (class_exists('Metasync_Otto_Render_Strategy')) {
1024 + $fetch_set_cookie = wp_remote_retrieve_header($route_html, 'set-cookie');
1025 + $fetch_parts = wp_parse_url($request_body);
1026 + $visitor_host = preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'] ?? '');
1027 + $same_origin = !empty($fetch_parts['host'])
1028 + && strcasecmp($fetch_parts['host'], $visitor_host) === 0
1029 + && ((($fetch_parts['scheme'] ?? '') === 'https') === (bool) is_ssl());
1030 + Metasync_Otto_Render_Strategy::set_http_fetch_cookie($fetch_set_cookie, $same_origin);
1031 +
1032 + # Also record the internal response's own `Cache-Control`. When the page's
1033 + # render starts a PHP session, PHP emits its session cache-limiter headers
1034 + # (`no-store, no-cache, must-revalidate`) — and it does so whether the session
1035 + # is new OR resumed, so this catches session pages that set no NEW cookie and
1036 + # would otherwise look cacheable. It is read as a signal only and never
1037 + # forwarded to the visitor: our own no-cache request header is not echoed into
1038 + # the response, so this cannot make ordinary pages uncacheable.
1039 + Metasync_Otto_Render_Strategy::set_http_fetch_cache_control(
1040 + wp_remote_retrieve_header($route_html, 'cache-control')
1041 + );
1042 + }
1043 +
1044 +
452 1045 # check not empty
453 1046 if(empty($html_body) || $response_code !== 200){
454 1047 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
455 1048 return false;
@@ -454,24 +1047,41 @@
454 1047 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
455 1048 return false;
456 1049 }
457 1050
1051 + # Skip DOM processing on oversized documents to avoid fatal OOM.
1052 + if (class_exists('Metasync_Otto_Render_Strategy')
1053 + && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html_body))
1054 + ) {
1055 + Metasync_Otto_Render_Strategy::log_oversized_skip('handle_route_html', strlen($html_body));
1056 + return false;
1057 + }
1058 +
458 1059 # Remove XML declaration
459 1060 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
460 1061
461 - # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
1062 + # / Save ALL original <style> blocks before DOM processing.
462 1063 # SimpleHtmlDom can lose or corrupt style blocks during parse/serialize —
463 1064 # not only Divi's id-tagged ones but also anonymous <style> blocks used by
464 1065 # custom themes for nav collapse CSS, responsive breakpoints, etc.
465 1066 $original_style_blocks = $this->capture_style_blocks($html_body);
466 1067
1068 + # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
1069 + # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
1070 + $srcdoc_store = array();
1071 + $html_body = $this->protect_srcdoc_attributes($html_body, $srcdoc_store);
1072 +
467 1073 # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
468 1074 $html_body = $this->sanitize_text_less_than($html_body);
469 1075
470 - # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
1076 + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
471 1077 # before DOM parsing — SimpleHtmlDom strips attributes from these.
472 1078 $html_body = $this->fix_malformed_self_closing_tags($html_body);
473 1079
1080 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
1081 + # SimpleHtmlDom swallows every following attribute into such a value.
1082 + $html_body = $this->quote_unquoted_attribute_values($html_body);
1083 +
474 1084 # now that the html is not empty
475 1085 # load it into the simple html dom
476 1086 $this->dom->load($html_body, true, false);
477 1087
@@ -600,12 +1210,18 @@
600 1210 $result_html = preg_replace_callback(
601 1211 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
602 1212 function ($m) use ($current_value, $recommended_value) {
603 1213 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
604 - if ($inner_text === $current_value) {
605 - return $m[1] . $recommended_value . $m[3];
1214 + if ($inner_text !== $current_value) {
1215 + return $m[0];
606 1216 }
607 - return $m[0];
1217 + # Keep the author's inline formatting; skip when it can't
1218 + # be preserved rather than flattening the heading.
1219 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
1220 + if ($new_inner === null) {
1221 + return $m[0];
1222 + }
1223 + return $m[1] . $new_inner . $m[3];
608 1224 },
609 1225 $result_html,
610 1226 -1
611 1227 );
@@ -613,19 +1229,20 @@
613 1229 }
614 1230
615 1231 # CRITICAL FIX: Apply image alt text manually via string replacement
616 1232 # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path
617 - $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
1233 + $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');
618 1234
619 1235 # Apply insertions — only if DOM insertion didn't already apply it
620 1236 if (!empty($replacement_data['header_html_insertion'])) {
621 - $header_html_check = trim($replacement_data['header_html_insertion']);
1237 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
1238 + # path inserts the same bytes, so the already-applied check must compare
1239 + # against the stamped form or it would insert a second copy.
1240 + $header_html_insertion = $this->stamp_otto_json_ld(
1241 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
1242 + );
1243 + $header_html_check = trim($header_html_insertion);
622 1244 if (strpos($result_html, $header_html_check) === false) {
623 - $header_html_insertion = preg_replace(
624 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
625 - '<script$1type=$2application/ld+json$2 data-otto="true"',
626 - $replacement_data['header_html_insertion']
627 - );
628 1245 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
629 1246 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
630 1247 }
631 1248 }
@@ -647,35 +1264,313 @@
647 1264 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
648 1265 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
649 1266 }
650 1267
651 - # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
652 - $result_html = $this->deduplicate_title_tags($result_html);
653 - $result_html = $this->deduplicate_og_twitter_tags($result_html);
654 - $result_html = $this->deduplicate_schema_tags($result_html);
655 - $result_html = $this->deduplicate_canonical_tags($result_html);
1268 + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
1269 + $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);
656 1270
657 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
658 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
659 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
1271 + # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
1272 + $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
1273 + $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
1274 + $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
1275 + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
1276 + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
1277 + $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
1278 + $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
1279 +
1280 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
1281 + if (!$this->is_amp_page()) {
1282 + $result_html = $this->mark_head_optimized($result_html);
660 1283 }
661 1284
662 - $result_html = $this->restore_case_sensitive_attributes($result_html);
1285 + $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
663 1286
664 - # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
665 - $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
1287 + # / Re-inject any <style> blocks lost during processing.
1288 + $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');
666 1289
667 - # WP-315: Fix Divi 5 shortcode framework class renumbering (HTTP path)
668 - $result_html = $this->fix_divi_class_renumbering($result_html);
1290 + # Fix Divi 5 shortcode framework class renumbering (HTTP path)
1291 + $result_html = $this->otto_guard_html($this->fix_divi_class_renumbering($result_html), $result_html, 'fix_divi_class_renumbering');
669 1292
670 - # WP-315: Clean OTTO internal fetch params from HTML output.
1293 + # Clean OTTO internal fetch params from HTML output.
671 1294 # The HTTP render uses wp_remote_get with ?is_otto_page_fetch=1&otto_block_title=1&otto_block_desc=1
672 1295 # These leak into form action URLs, canonical links, etc. in the rendered HTML.
673 - $result_html = $this->clean_otto_fetch_params($result_html);
1296 + $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');
674 1297
1298 + # Remove any lazy-video facade <style> hoisted into the page
1299 + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
1300 + $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');
1301 +
1302 + # Restore the original encoded srcdoc values (undo tokenization).
1303 + $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');
1304 +
1305 + # Keep <meta charset> within the first 1024 bytes so external CSS
1306 + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
1307 + $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');
1308 +
1309 + # undo double-encoded numeric/hex character references produced by the
1310 + # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
1311 + $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
1312 +
1313 + # restore literal '&' query-string separators the serializer escaped to
1314 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
1315 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
1316 +
1317 + # Remove any lazy-video facade <style> hoisted into the page
1318 + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
1319 + $result_html = $this->strip_hoisted_facade_styles($result_html);
1320 +
1321 + # Restore the original encoded srcdoc values (undo tokenization).
1322 + $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);
1323 +
1324 + # Keep <meta charset> within the first 1024 bytes so external CSS
1325 + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
1326 + $result_html = $this->ensure_early_charset_meta($result_html);
1327 +
1328 + # undo double-encoded numeric/hex character references produced by the
1329 + # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
1330 + $result_html = $this->repair_double_encoded_entities($result_html);
1331 +
675 1332 return $result_html;
676 1333 }
677 1334
1335 + /**
1336 + * Read the final URL from the WordPress HTTP response when Requests has
1337 + * followed one or more redirects. Older HTTP transports may not expose it;
1338 + * in that case the empty result leaves the existing fail-open behavior.
1339 + *
1340 + * @param mixed $response wp_remote_get() response.
1341 + * @return string Final URL, or an empty string when unavailable.
1342 + */
1343 + private function get_effective_fetch_url($response)
1344 + {
1345 + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
1346 + return '';
1347 + }
1348 +
1349 + $http_response = $response['http_response'];
1350 + if (!method_exists($http_response, 'get_response_object')) {
1351 + return '';
1352 + }
1353 +
1354 + $requests_response = $http_response->get_response_object();
1355 + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
1356 + return '';
1357 + }
1358 +
1359 + return $requests_response->url;
1360 + }
1361 +
1362 + /**
1363 + * Determine whether a followed response still represents the requested
1364 + * route. The internal fetch may use a rewritten host for tunnel support,
1365 + * so both the original and transport hosts are accepted; the path and
1366 + * non-OTTO query parameters must remain identical.
1367 + *
1368 + * @param string $effective_url Final URL reported by Requests.
1369 + * @param string $route Original route supplied to OTTO.
1370 + * @param string $fetch_url URL actually passed to wp_remote_get().
1371 + * @return bool True when no meaningful redirect changed the route.
1372 + */
1373 + private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1374 + {
1375 + $effective = parse_url($effective_url);
1376 + $requested = parse_url($route);
1377 + $transport = parse_url($fetch_url);
1378 + if (!is_array($effective) || !is_array($requested)) {
1379 + return true;
1380 + }
1381 +
1382 + $effective_path = isset($effective['path']) ? $effective['path'] : '/';
1383 + $requested_path = isset($requested['path']) ? $requested['path'] : '/';
1384 + if ($effective_path !== $requested_path) {
1385 + return false;
1386 + }
1387 +
1388 + $allowed_hosts = [];
1389 + foreach ([$requested, $transport] as $parts) {
1390 + if (!empty($parts['host'])) {
1391 + $host = strtolower($parts['host']);
1392 + if (!empty($parts['port'])) {
1393 + $host .= ':' . $parts['port'];
1394 + }
1395 + $allowed_hosts[] = $host;
1396 + }
1397 + }
1398 + if (!empty($effective['host'])) {
1399 + $effective_host = strtolower($effective['host']);
1400 + if (!empty($effective['port'])) {
1401 + $effective_host .= ':' . $effective['port'];
1402 + }
1403 + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
1404 + return false;
1405 + }
1406 + }
1407 +
1408 + return $this->without_otto_fetch_params($effective['query'] ?? '')
1409 + === $this->without_otto_fetch_params($requested['query'] ?? '');
1410 + }
1411 +
1412 + /**
1413 + * Remove parameters used only to mark OTTO's internal request.
1414 + *
1415 + * @param string $query Query string without the leading '?'.
1416 + * @return string Query with OTTO markers removed.
1417 + */
1418 + private function without_otto_fetch_params($query)
1419 + {
1420 + $query = (string) $query;
1421 + if ($query === '') {
1422 + return '';
1423 + }
1424 +
1425 + $parts = [];
1426 + foreach (explode('&', $query) as $part) {
1427 + $name = strstr($part, '=', true);
1428 + $name = $name === false ? $part : $name;
1429 + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
1430 + continue;
1431 + }
1432 + $parts[] = $part;
1433 + }
1434 +
1435 + return implode('&', $parts);
1436 + }
1437 +
1438 + /**
1439 + * Repair double-encoded character references in serialized HTML.
1440 + *
1441 + * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
1442 + * $this->dom->save() and $root->outertext) runs every attribute value through
1443 + * htmlentities(), which escapes the leading '&' of numeric/hex character
1444 + * references. Theme icon attributes such as data-x-icon-s="&#xf3c5"
1445 + * (Themeco X/Cornerstone) therefore become "&amp;#xf3c5", so the browser
1446 + * prints the literal text instead of drawing the icon glyph.
1447 + *
1448 + * Restores only NUMERIC and HEX character references (&#NNN; / &#xHHHH;).
1449 + * Bare ampersands in query strings (?a=1&b=2 -> ?a=1&amp;b=2) and named
1450 + * entities are intentionally left escaped.
1451 + *
1452 + * @param string $html Serialized HTML from save()/outertext.
1453 + * @return string
1454 + */
1455 + private function repair_double_encoded_entities($html) {
1456 + # strpos guard: skip the regex entirely when there is nothing to repair
1457 + if (!is_string($html) || strpos($html, '&amp;#') === false) {
1458 + return $html;
1459 + }
1460 + return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
1461 + }
1462 +
1463 + /**
1464 + * Restore literal '&' query-string separators inside URL attributes.
1465 + *
1466 + * HtmlNode::makeup() serialises every attribute through htmlentities(), so a
1467 + * separator that arrived as a bare '&' - or as '&#038;', which HtmlDocument
1468 + * decodes to '&' when it parses the attribute - is written back as '&amp;'.
1469 + * Both forms decode to '&' in a conforming HTML parser, so the browser is
1470 + * unaffected. A consumer that reads the raw attribute WITHOUT decoding
1471 + * entities sees a different URL, and Cloudflare Fonts is one of those: it
1472 + * refetches fonts.googleapis.com/css2?family=A&amp;family=B, Google treats
1473 + * only the first 'family=' as a real parameter (the rest arrive named
1474 + * 'amp;family'), and every font after the first is dropped from the response.
1475 + * The page keeps its font-family declarations but loses the @font-face rules,
1476 + * so text silently falls back to a system font.
1477 + *
1478 + * Rewrites only a '&amp;' that is immediately followed by a parameter name
1479 + * and '=', and only inside the listed URL attributes. A '&amp;' elsewhere is
1480 + * left escaped, and the mandatory '=' in the lookahead (with ';' excluded
1481 + * from the name class) means no ambiguous ampersand ('&' + alphanumerics +
1482 + * ';') is ever produced.
1483 + *
1484 + * All three delimiter forms the serializer can emit are handled: double
1485 + * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and
1486 + * emits no delimiter for a value with no whitespace, which is common on
1487 + * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted
1488 + * value may legitimately contain an apostrophe.
1489 + *
1490 + * The attribute list stops at attributes whose value is always a URL.
1491 + * 'content' is deliberately excluded because it also carries prose
1492 + * (<meta name="description" content="A &amp; B">), where unescaping would
1493 + * corrupt visible text. Attributes read only via JavaScript getAttribute()
1494 + * do not need it either - the DOM decodes entities for them; only consumers
1495 + * that scan raw HTML are affected.
1496 + *
1497 + * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded
1498 + * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes
1499 + * there would edit a JS string literal, or turn "&amp;copy=2" in a textarea
1500 + * into "(c)=2" once the browser decodes it.
1501 + *
1502 + * Plain negated character classes only - no tempered-greedy lookaheads, which
1503 + * blow the PCRE JIT stack on pages with very large inline blocks.
1504 + *
1505 + * @param string $html Serialized HTML from save()/outertext.
1506 + * @return string
1507 + */
1508 + private function repair_query_string_ampersands($html) {
1509 + # strpos guard: skip the regex entirely when there is nothing to repair
1510 + if (strpos($html, '&amp;') === false) {
1511 + return $html;
1512 + }
1513 +
1514 + # Shield verbatim / RCDATA regions and comments before touching anything.
1515 + # Masking also shrinks large inline blocks to a token, so the attribute
1516 + # pass never walks them.
1517 + $shielded = array();
1518 + $masked = preg_replace_callback(
1519 + '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is',
1520 + function ($m) use (&$shielded) {
1521 + $token = '<!--METASYNC_QSA_' . count($shielded) . '-->';
1522 + $shielded[$token] = $m[0];
1523 + return $token;
1524 + },
1525 + $html
1526 + );
1527 +
1528 + # preg_* returns null on a PCRE failure; keep the original document.
1529 + if ($masked === null) {
1530 + return $html;
1531 + }
1532 +
1533 + $repaired = preg_replace_callback(
1534 + '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i',
1535 + function ($m) {
1536 + # Pick the delimiter branch that matched. An empty value carries
1537 + # no query string, so the ambiguity between an empty quoted value
1538 + # and an unset group cannot change the outcome.
1539 + if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones)
1540 + $quote = '';
1541 + $value = $m[4];
1542 + } elseif (isset($m[3]) && $m[3] !== '') {
1543 + $quote = "'";
1544 + $value = $m[3];
1545 + } else {
1546 + $quote = '"';
1547 + $value = isset($m[2]) ? $m[2] : '';
1548 + }
1549 +
1550 + # Only a value carrying a query string can hold separators.
1551 + if (strpos($value, '?') === false || strpos($value, '&amp;') === false) {
1552 + return $m[0];
1553 + }
1554 +
1555 + $fixed = preg_replace('/&amp;(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value);
1556 +
1557 + if ($fixed === null) {
1558 + return $m[0];
1559 + }
1560 +
1561 + return $m[1] . $quote . $fixed . $quote;
1562 + },
1563 + $masked
1564 + );
1565 +
1566 + if ($repaired === null) {
1567 + return $html;
1568 + }
1569 +
1570 + return empty($shielded) ? $repaired : strtr($repaired, $shielded);
1571 + }
1572 +
678 1573 # do the footer html insertion
679 1574 function do_footer_html_insertion($replacement_data){
680 1575
681 1576 # check that we have footer html
@@ -776,8 +1671,12 @@
776 1671 if (empty($images)) {
777 1672 return;
778 1673 }
779 1674
1675 + # index suggestions by URL path as well, so images authored with
1676 + # relative srcs still match OTTO's absolute-URL keys (and vice versa).
1677 + $alt_lookup = $this->build_image_alt_lookup($image_data);
1678 +
780 1679 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
781 1680 # Single pass with hash map lookup instead of nested loop
782 1681 foreach($images AS $key => $image){
783 1682 # Get image src
@@ -787,12 +1686,13 @@
787 1686 continue;
788 1687 }
789 1688
790 1689 # Hash map lookup O(1) instead of loop O(n)
791 - if (isset($image_data[$image_src])) {
1690 + $alt_text = $this->lookup_image_alt($alt_lookup, $image_src);
1691 + if ($alt_text !== null) {
792 1692 # Set alt text - Note: This may not persist in all cases
793 1693 # Manual string replacement in process_html_directly() ensures it's applied
794 - $new_alt = htmlspecialchars($image_data[$image_src], ENT_QUOTES, 'UTF-8');
1694 + $new_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
795 1695
796 1696 # Get current img tag HTML and update alt attribute
797 1697 $current_html = $image->outertext;
798 1698
@@ -806,15 +1706,104 @@
806 1706 $image->outertext = $updated_html;
807 1707
808 1708 $multi_view_attr = $image->getAttribute('data-et-multi-view');
809 1709 if (!empty($multi_view_attr)) {
810 - $this->update_divi_multi_view_alt($image, $image_data[$image_src]);
1710 + $this->update_divi_multi_view_alt($image, $alt_text);
811 1711 }
812 1712 }
813 1713 }
814 1714 }
815 1715
1716 + # memoized site host — home_url() runs its filter chain on every
1717 + # call and these helpers run per suggestion and per img.
1718 + private ?string $site_host_cache = null;
1719 +
816 1720 /**
1721 + * Get this site's host (lowercase) for same-host URL comparisons.
1722 + *
1723 + * @return string
1724 + */
1725 + private function get_site_host() {
1726 + if ($this->site_host_cache === null) {
1727 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1728 + $this->site_host_cache = is_string($host) ? strtolower($host) : '';
1729 + }
1730 + return $this->site_host_cache;
1731 + }
1732 +
1733 + /**
1734 + * Candidate lookup keys for an image URL — the URL itself plus,
1735 + * for relative URLs or absolute/protocol-relative URLs on this site's
1736 + * host, the bare URL path. URLs on a different host only ever match
1737 + * exactly, so a same path on a foreign host cannot produce a false match.
1738 + *
1739 + * @param string $url Image URL (absolute, protocol-relative, or relative)
1740 + * @return array Candidate keys, original URL first
1741 + */
1742 + private function get_image_url_variants($url) {
1743 + $variants = [$url];
1744 +
1745 + if (!is_string($url) || $url === '') {
1746 + return $variants;
1747 + }
1748 +
1749 + $host = wp_parse_url($url, PHP_URL_HOST);
1750 + if (!empty($host) && strtolower($host) !== $this->get_site_host()) {
1751 + # Foreign host: exact match only
1752 + return $variants;
1753 + }
1754 +
1755 + $path = wp_parse_url($url, PHP_URL_PATH);
1756 + if (is_string($path) && $path !== '' && $path !== $url) {
1757 + $variants[] = $path;
1758 + }
1759 +
1760 + return $variants;
1761 + }
1762 +
1763 + /**
1764 + * Build the image alt lookup keyed by every URL variant of each
1765 + * OTTO suggestion, so relative img srcs match absolute suggestion URLs.
1766 + *
1767 + * @param array $image_data OTTO body_substitutions.images map (url => alt)
1768 + * @return array Expanded lookup (url-or-path => alt)
1769 + */
1770 + private function build_image_alt_lookup($image_data) {
1771 + $lookup = [];
1772 +
1773 + foreach ($image_data as $image_url => $alt_text) {
1774 + foreach ($this->get_image_url_variants($image_url) as $variant) {
1775 + # First suggestion wins when two keys collapse to the same
1776 + # path (e.g. a relative and an absolute key for one image);
1777 + # exact-src matches still take priority in lookup_image_alt().
1778 + if (!isset($lookup[$variant])) {
1779 + $lookup[$variant] = $alt_text;
1780 + }
1781 + }
1782 + }
1783 +
1784 + return $lookup;
1785 + }
1786 +
1787 + /**
1788 + * Resolve the OTTO alt text for an img src, trying the exact src
1789 + * first and then its same-host path variant.
1790 + *
1791 + * @param array $lookup Expanded lookup from build_image_alt_lookup()
1792 + * @param string $image_src The img element's src attribute
1793 + * @return string|null Alt text, or null when no suggestion matches
1794 + */
1795 + private function lookup_image_alt($lookup, $image_src) {
1796 + foreach ($this->get_image_url_variants($image_src) as $variant) {
1797 + if (isset($lookup[$variant])) {
1798 + return $lookup[$variant];
1799 + }
1800 + }
1801 +
1802 + return null;
1803 + }
1804 +
1805 + /**
817 1806 * Update Divi's multi-view data attribute with alt text
818 1807 * Divi stores image attributes in a JSON structure within data-et-multi-view
819 1808 *
820 1809 * @param object $image The image DOM element
@@ -857,8 +1846,13 @@
857 1846 /**
858 1847 * Apply image alt text via string replacement on raw HTML.
859 1848 * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites).
860 1849 *
1850 + * Known limitation (pre-existing): the `<img[^>]*src=` pattern can match a
1851 + * `data-src`/`data-lazy-src` attribute that ends with the target URL before
1852 + * the real `src`, mis-attributing the alt on lazy-loaded markup. Tracked as
1853 + * a follow-up to.
1854 + *
861 1855 * @param string $html The HTML to process
862 1856 * @param array $replacement_data The replacement data containing body_substitutions.images
863 1857 * @return string Modified HTML with alt text applied
864 1858 */
@@ -867,15 +1861,18 @@
867 1861 return $html;
868 1862 }
869 1863
870 1864 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
871 - if (empty($alt_text) || strpos($html, $image_url) === false) {
1865 + # prefilter and match on the same-host path variant too, so
1866 + # relative img srcs match OTTO's absolute suggestion URLs.
1867 + $src_pattern = $this->get_image_src_pattern($image_url);
1868 +
1869 + if (empty($alt_text) || $src_pattern === null || strpos($html, $src_pattern['needle']) === false) {
872 1870 continue;
873 1871 }
874 1872
875 1873 $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
876 - $escaped_url = preg_quote($image_url, '/');
877 - $img_pattern = '/<img[^>]*src=["\']' . $escaped_url . '["\'][^>]*>/i';
1874 + $img_pattern = '/<img[^>]*src=["\']' . $src_pattern['pattern'] . '["\'][^>]*>/i';
878 1875
879 1876 if (preg_match_all($img_pattern, $html, $img_matches)) {
880 1877 foreach ($img_matches[0] as $original_img) {
881 1878 if (strpos($original_img, $escaped_alt) !== false) {
@@ -920,8 +1917,47 @@
920 1917
921 1918 return $html;
922 1919 }
923 1920
1921 + /**
1922 + * Build the src match for an OTTO image suggestion URL.
1923 + *
1924 + * For relative URLs or absolute/protocol-relative URLs on this site's
1925 + * host, the regex matches the URL path with an optional scheme+host
1926 + * prefix — so a relative img src matches an absolute suggestion URL and
1927 + * vice versa. URLs on a foreign host keep the original exact match, so a
1928 + * same path on a different host cannot produce a false positive.
1929 + *
1930 + * @param string|int $image_url OTTO suggestion URL (may arrive as an int
1931 + * when it originates from an array key that PHP coerced)
1932 + * @return array|null ['needle' => strpos prefilter string,
1933 + * 'pattern' => regex fragment ('/' delimiter)] or null
1934 + */
1935 + private function get_image_src_pattern($image_url) {
1936 + if (!is_string($image_url) || $image_url === '') {
1937 + return null;
1938 + }
1939 +
1940 + $host = wp_parse_url($image_url, PHP_URL_HOST);
1941 + $site_host = $this->get_site_host();
1942 +
1943 + # Foreign host: exact match only
1944 + if (!empty($host) && strtolower($host) !== $site_host) {
1945 + return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')];
1946 + }
1947 +
1948 + $path = wp_parse_url($image_url, PHP_URL_PATH);
1949 + if (!is_string($path) || $path === '') {
1950 + return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')];
1951 + }
1952 +
1953 + $prefix = $site_host !== '' ? '(?:(?:https?:)?\/\/' . preg_quote($site_host, '/') . ')?' : '';
1954 +
1955 + # Allow an optional query/fragment after the path (e.g. WordPress
1956 + # `?ver=` cache-busters) so src="/up/a.jpg?ver=2" still matches.
1957 + return ['needle' => $path, 'pattern' => $prefix . preg_quote($path, '/') . '(?:[?#][^"\']*)?'];
1958 + }
1959 +
924 1960 # heading substitutions
925 1961 function do_heading_body_substitutions($heading_data){
926 1962
927 1963 # loop all data
@@ -938,15 +1974,28 @@
938 1974
939 1975 # check matching text
940 1976 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
941 1977
942 - # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
1978 + # Rebuild the inner HTML, keeping the author's inline
1979 + # formatting. Returns null when the markup can't be preserved
1980 + # unambiguously — leave that heading alone rather than strip
1981 + # its <span style>/<em>/<br> and restyle the page.
1982 + $new_inner = self::build_heading_inner_html(
1983 + $heading_old->innertext,
1984 + $heading['recommended_value']
1985 + );
1986 +
1987 + if ($new_inner === null) {
1988 + continue;
1989 + }
1990 +
1991 + # replace the tag output, preserving the opening tag's attributes
943 1992 $outer = $heading_old->outertext;
944 1993 $open_end = strpos($outer, '>');
945 1994 if ($open_end !== false) {
946 1995 $open_tag = substr($outer, 0, $open_end + 1);
947 1996 $close_tag = '</' . $heading['type'] . '>';
948 - $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
1997 + $heading_old->outertext = $open_tag . $new_inner . $close_tag;
949 1998 }
950 1999
951 2000 }
952 2001 }
@@ -1151,8 +2200,152 @@
1151 2200 # now do the actual html replacements
1152 2201 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1153 2202 }
1154 2203
2204 + /**
2205 + * Resolve the current singular post id reliably inside the OTTO output
2206 + * buffer / direct-render context.
2207 + *
2208 + * get_the_ID() depends on the global $post/loop state, which is not reliable
2209 + * once WordPress has finished rendering and the buffer/shutdown pass runs.
2210 + * get_queried_object_id() reads the parsed main query and stays correct there
2211 + * (same approach as apply_metabox_og_precedence()). Restricted to
2212 + * singular views: on archives/home the queried object id can be a term or
2213 + * user id, which must not be read as post meta.
2214 + *
2215 + * @return int Post id, or 0 when not applicable.
2216 + */
2217 + private function get_buffer_post_id() {
2218 + if (!function_exists('is_singular') || !is_singular()) {
2219 + return 0;
2220 + }
2221 + $post_id = function_exists('get_queried_object_id') ? get_queried_object_id() : 0;
2222 + return $post_id ? (int) $post_id : 0;
2223 + }
2224 +
2225 + /**
2226 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2227 + *
2228 + * @param string $html
2229 + * @param string $attr "name" or "property"
2230 + * @param string $val e.g. "description", "og:description"
2231 + * @param string $content_escaped Already HTML-escaped content value
2232 + * @param string $marker Provenance marker for the winning tier
2233 + * @return string
2234 + */
2235 + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
2236 + $marker_attribute = $marker === 'otto'
2237 + ? ' data-metasync-otto="true"'
2238 + : ' data-metasync-seo="custom"';
2239 + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
2240 + $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2241 + $count = 0;
2242 + $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2243 + return $tag;
2244 + }, $html, 1, $count);
2245 + if ($count > 0) {
2246 + return $new;
2247 + }
2248 + # None present — insert after <head>.
2249 + return preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($tag) {
2250 + return $m[1] . "\n" . $tag;
2251 + }, $html, 1);
2252 + }
2253 +
2254 + /**
2255 + * Make sure the precedence resolver is loaded before calling it.
2256 + *
2257 + * @return bool
2258 + */
2259 + private static function precedence_available() {
2260 + if (class_exists('Metasync_Seo_Precedence')) {
2261 + return true;
2262 + }
2263 +
2264 + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
2265 + if (is_readable($file)) {
2266 + require_once $file;
2267 + }
2268 +
2269 + return class_exists('Metasync_Seo_Precedence');
2270 + }
2271 +
2272 + /**
2273 + * Enforce the resolved SEO title/description as the FINAL word over OTTO.
2274 + *
2275 + * The resolver owns the global custom-versus-OTTO order. This final buffer
2276 + * pass must use it too: otherwise the setting works through the sidebar
2277 + * and third-party filters but OTTO's later string pass puts the custom
2278 + * value back on the page.
2279 + *
2280 + * OG and Twitter descriptions remain outside this method deliberately;
2281 + * social precedence is a separate follow-up scope.
2282 + *
2283 + * @param string $html Full HTML document (post-dedup).
2284 + * @return string
2285 + */
2286 + private function apply_custom_seo_precedence($html) {
2287 + if ($html === '') {
2288 + return $html;
2289 + }
2290 + $post_id = $this->get_buffer_post_id();
2291 + if (!$post_id) {
2292 + return $html;
2293 + }
2294 +
2295 + # This runs inside the output buffer, where an unresolvable class is a
2296 + # fatal that takes the whole page down — otto_guard_html() only catches
2297 + # a NULL return, not a thrown Error. A partial update can leave newer
2298 + # PHP files beside an older committed autoload classmap, so resolve the
2299 + # file directly before trusting the class, and leave the document
2300 + # untouched if it is genuinely absent. Same reasoning as
2301 + # Metasync_SEO_Conflict_Handler::precedence_available().
2302 + if (!self::precedence_available()) {
2303 + return $html;
2304 + }
2305 +
2306 + # Title — replace the resolved page title into the (single, post-dedup) <title>.
2307 + $resolved_title = Metasync_Seo_Precedence::resolve(
2308 + $post_id,
2309 + Metasync_Seo_Precedence::FIELD_TITLE
2310 + );
2311 + if (!empty($resolved_title['value'])) {
2312 + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
2313 + $count = 0;
2314 + $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2315 + return $title_tag;
2316 + }, $html, 1, $count);
2317 + if ($count > 0) {
2318 + $html = $replaced;
2319 + } elseif (stripos($html, '<title') === false) {
2320 + $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
2321 + return $m[1] . "\n" . $title_tag;
2322 + }, $html, 1);
2323 + }
2324 + }
2325 +
2326 + # Description — replace the resolved page description into the SEO
2327 + # <meta name="description"> only. OG/Twitter description is social
2328 + # precedence and remains outside this setting's scope.
2329 + $resolved_description = Metasync_Seo_Precedence::resolve(
2330 + $post_id,
2331 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2332 + );
2333 + if (!empty($resolved_description['value'])) {
2334 + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
2335 + $marker = Metasync_Seo_Precedence::is_otto_value(
2336 + $post_id,
2337 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2338 + $resolved_description
2339 + )
2340 + ? 'otto'
2341 + : 'custom';
2342 + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
2343 + }
2344 +
2345 + return $html;
2346 + }
2347 +
1155 2348 # this function does the header replacements
1156 2349 function do_header_replacements($replacement_data){
1157 2350
1158 2351 # check that we have header replacements
@@ -1202,21 +2395,19 @@
1202 2395
1203 2396 # handle canonical links
1204 2397 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1205 2398
1206 - # Protect manually-set canonical from OTTO override
2399 + # Protect manually-set canonical from OTTO override.
2400 + # Validated: a legacy row corrupted to "Array" must not
2401 + # count as a manual canonical and block OTTO's correct value.
1207 2402 if (function_exists('is_singular') && is_singular()) {
1208 2403 $post_id = get_the_ID();
1209 2404 if ($post_id) {
1210 - $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1211 - if (empty($custom_canonical)) {
1212 - $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1213 - // Handle legacy array values
1214 - if (is_array($custom_canonical)) {
1215 - $custom_canonical = reset($custom_canonical) ?: '';
1216 - }
2405 + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true));
2406 + if ($custom_canonical === '') {
2407 + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true));
1217 2408 }
1218 - if (!empty($custom_canonical)) {
2409 + if ($custom_canonical !== '') {
1219 2410 # Manual canonical takes priority — skip OTTO override
1220 2411 continue;
1221 2412 }
1222 2413 }
@@ -1224,11 +2415,15 @@
1224 2415
1225 2416 # find the cannonical dom element
1226 2417 $link = $this->dom->find('link[rel="canonical"]', 0);
1227 2418
1228 - # set the link property if not empty
2419 + # set the link property if not empty — only to a validated URL
2420 + # never inject a corrupted/non-URL suggestion.
1229 2421 if(!empty($link->href)){
1230 - $link->href = $data['recommended_value'] ?? $link->href;
2422 + $otto_canonical = Metasync_Canonical_Sanitizer::sanitize($data['recommended_value'] ?? '');
2423 + if ($otto_canonical !== '') {
2424 + $link->href = $otto_canonical;
2425 + }
1231 2426 }
1232 2427
1233 2428 #
1234 2429 continue;
@@ -1244,8 +2439,14 @@
1244 2439
1245 2440 # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
1246 2441 # with empty content when OTTO has no recommendation for this meta field
1247 2442 $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
2443 + if (is_object($recommended_value) || is_array($recommended_value)) {
2444 + return;
2445 + }
2446 + if (!is_string($recommended_value)) {
2447 + $recommended_value = (string) $recommended_value;
2448 + }
1248 2449 if (empty(trim($recommended_value))) {
1249 2450 return;
1250 2451 }
1251 2452
@@ -1340,9 +2541,9 @@
1340 2541 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
1341 2542
1342 2543 if ($meta_tag_fresh) {
1343 2544 # Use outertext for replacement
1344 - $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8');
2545 + $new_value = htmlspecialchars($recommended_value, ENT_QUOTES, 'UTF-8');
1345 2546
1346 2547 # Determine attribute name
1347 2548 $attr_name = !empty($data['name']) ? 'name' : 'property';
1348 2549 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
@@ -1545,8 +2746,221 @@
1545 2746 return $html;
1546 2747 }
1547 2748
1548 2749 /**
2750 + * Read one of the meta box's social title/description keys with the "Auto Draft"
2751 + * placeholder collapsed to ''.
2752 + *
2753 + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
2754 + * place. Falls back to a raw read when that class — or that method on it — is
2755 + * unavailable: this buffer filter runs on the front end, and a partially updated
2756 + * install can pair a newer otto/ file with an older includes/ one, where calling
2757 + * a method the loaded class doesn't define would fatal the page rather than
2758 + * degrade.
2759 + *
2760 + * @param int $post_id
2761 + * @param string $key
2762 + * @return string
2763 + */
2764 + private function social_meta($post_id, $key) {
2765 + // PHPStan resolves the literal class-string against the current source and so
2766 + // sees the method as always present; the check is deliberate runtime
2767 + // version-skew defence. Suppressed the same way otto_pixel.php does.
2768 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2769 + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
2770 + return Metasync_OpenGraph::get_social_meta($post_id, $key);
2771 + }
2772 + return (string) get_post_meta($post_id, $key, true);
2773 + }
2774 +
2775 + /**
2776 + * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
2777 + *
2778 + * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
2779 + * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
2780 + * default fills the gap. Mirrors how the custom SEO sidebar values take priority
2781 + * over OTTO (see do_header_replacements()).
2782 + *
2783 + * Runs *after* deduplicate_og_twitter_tags(), and only within <head>. For a field the
2784 + * user explicitly set it removes any existing tag (OTTO's) and re-adds the user's
2785 + * value; for the rest it only adds a tag when absent — so OTTO stays authoritative
2786 + * where the user left a field blank and no duplicate is produced. OTTO never emits an
2787 + * og:image, so this also surfaces a user-set social image on an OTTO-managed page.
2788 + *
2789 + * Only applies to singular posts/pages: the meta box values are per-post, and on
2790 + * archives/home get_queried_object_id() can return a term or user id.
2791 + *
2792 + * @param string $html Full HTML document (post-dedup).
2793 + * @return string
2794 + */
2795 + private function apply_metabox_og_precedence($html) {
2796 + if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
2797 + return $html;
2798 + }
2799 + # Per-post OG only applies to singular posts/pages. On archives/home the queried
2800 + # object id can be a term or user id, which must not be read as post meta.
2801 + if (!is_singular()) {
2802 + return $html;
2803 + }
2804 + $post_id = get_queried_object_id();
2805 + if (!$post_id) {
2806 + return $html;
2807 + }
2808 +
2809 + # Respect the meta box opt-out (only an explicit '0' disables it)
2810 + if (get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
2811 + return $html;
2812 + }
2813 +
2814 + # Explicit (user-typed) meta box values. A non-empty value means the user set
2815 + # this field themselves and it must take priority over OTTO.
2816 + #
2817 + # The four social title/description keys are read through
2818 + # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
2819 + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
2820 + # brand-new post reads as a deliberate override here (it differs from the real
2821 + # title) and would beat OTTO's correct og:title.
2822 + $og_title_set = $this->social_meta($post_id, '_metasync_og_title');
2823 + $og_desc_set = $this->social_meta($post_id, '_metasync_og_description');
2824 + $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2825 + $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
2826 + $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true);
2827 + $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', true);
2828 + $tw_title_set = $this->social_meta($post_id, '_metasync_twitter_title');
2829 + $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description');
2830 + $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2831 + $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2832 +
2833 + # Default OG values the meta box pre-fills (post title / generated excerpt /
2834 + # featured image). The meta box PERSISTS these defaults on save, so a non-empty
2835 + # _metasync_og_* value alone does not prove the user customized it — a field is a
2836 + # genuine override only when its stored value differs from this default. Reuse the
2837 + # emitter's own resolver so the defaults match exactly (falling back to empty
2838 + # defaults, i.e. treat nothing as customized, if the instance is unavailable).
2839 + $defaults = ['title' => '', 'description' => '', 'image' => ''];
2840 + if (class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::get_instance()) {
2841 + $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
2842 + }
2843 +
2844 + $title_is_custom = ($og_title_set !== '' && $og_title_set !== $defaults['title']);
2845 + $desc_is_custom = ($og_desc_set !== '' && $og_desc_set !== $defaults['description']);
2846 + $image_is_custom = ($og_image_set !== '' && $og_image_set !== $defaults['image']);
2847 +
2848 + # Resolved values (explicit value first, then the meta box default).
2849 + $og_title = $og_title_set !== '' ? $og_title_set : $defaults['title'];
2850 + $og_desc = $og_desc_set !== '' ? $og_desc_set : $defaults['description'];
2851 + $og_image = $og_image_set !== '' ? $og_image_set : $defaults['image'];
2852 + $og_type = $og_type_set ?: 'article';
2853 + $site_name = get_bloginfo('name');
2854 +
2855 + # Twitter inherits the OG value when its own field is blank; a user-customized
2856 + # og:image therefore also overrides OTTO's twitter:image (the meta box treats them
2857 + # as one). A twitter field is custom if its own value differs from the default OR
2858 + # the OG field it inherits was customized.
2859 + $tw_card = $tw_card_set ?: 'summary_large_image';
2860 + $tw_title = $tw_title_set !== '' ? $tw_title_set : $og_title;
2861 + $tw_desc = $tw_desc_set !== '' ? $tw_desc_set : $og_desc;
2862 + $tw_image = $tw_image_set !== '' ? $tw_image_set : $og_image;
2863 +
2864 + $tw_title_is_custom = ($tw_title_set !== '' && $tw_title_set !== $defaults['title']) || $title_is_custom;
2865 + $tw_desc_is_custom = ($tw_desc_set !== '' && $tw_desc_set !== $defaults['description']) || $desc_is_custom;
2866 + $tw_image_is_custom = ($tw_image_set !== '' && $tw_image_set !== $defaults['image']) || $image_is_custom;
2867 +
2868 + # twitter:site: per-post value, else the site-wide handle from Social Meta settings.
2869 + # twitter:site / twitter:image:alt are not auto-defaulted by the meta box, so a
2870 + # non-empty per-post value is itself the override signal.
2871 + $tw_site = $tw_site_set;
2872 + if ($tw_site === '' && class_exists('Metasync')) {
2873 + $social = Metasync::get_option('social_meta');
2874 + if (is_array($social) && !empty($social['twitter_username'])) {
2875 + $tw_site = '@' . ltrim($social['twitter_username'], '@');
2876 + }
2877 + }
2878 +
2879 + # og:type / twitter:card are dropdowns that always carry a value, so they override
2880 + # OTTO only when the user picked a non-default option. og:url is auto-populated with
2881 + # the permalink on save, so it is fill-only (never an override) to avoid clobbering
2882 + # OTTO's canonical URL with a stale/auto value. og:site_name is a site default.
2883 + $og_type_override = ($og_type_set !== '' && $og_type_set !== 'article');
2884 + $tw_card_override = ($tw_card_set !== '' && $tw_card_set !== 'summary_large_image');
2885 +
2886 + # [attr, property/name, value, is_url, is_override]
2887 + $tags = [
2888 + ['property', 'og:title', $og_title, false, $title_is_custom],
2889 + ['property', 'og:description', $og_desc, false, $desc_is_custom],
2890 + ['property', 'og:image', $og_image, true, $image_is_custom],
2891 + ['property', 'og:url', get_permalink($post_id), true, false],
2892 + ['property', 'og:type', $og_type, false, $og_type_override],
2893 + ['property', 'og:site_name', $site_name, false, false],
2894 + ['name', 'twitter:card', $tw_card, false, $tw_card_override],
2895 + ['name', 'twitter:site', $tw_site, false, $tw_site_set !== ''],
2896 + ['name', 'twitter:title', $tw_title, false, $tw_title_is_custom],
2897 + ['name', 'twitter:description', $tw_desc, false, $tw_desc_is_custom],
2898 + ['name', 'twitter:image', $tw_image, true, $tw_image_is_custom],
2899 + ['name', 'twitter:image:alt', $tw_image_alt_set, false, $tw_image_alt_set !== ''],
2900 + ];
2901 +
2902 + # Operate only within <head> so meta tags in the body are never touched.
2903 + if (!preg_match('/(<head\b[^>]*>)(.*?)(<\/head>)/is', $html, $hm)) {
2904 + return $html;
2905 + }
2906 + $head = $hm[2];
2907 +
2908 + # 1) Remove existing head tags for user-overridden properties so the user's value wins.
2909 + foreach ($tags as $t) {
2910 + list($attr, $key, $val, $is_url, $is_override) = $t;
2911 + if ($is_override && $val !== '' && $val !== null) {
2912 + $stripped = preg_replace(
2913 + '/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1[^>]*>\s*/i',
2914 + '',
2915 + $head
2916 + );
2917 + if ($stripped !== null) {
2918 + $head = $stripped;
2919 + }
2920 + }
2921 + }
2922 +
2923 + # 2) Build additions: overrides (removed above) + gap-fills for anything still absent.
2924 + #
2925 + # When a third-party SEO plugin is active the conflict handler may have suppressed
2926 + # that plugin's og:title / twitter:title so MetaSync can supply the replacement
2927 + # (Metasync_SEO_Sidebar::output_seo_meta_description). Gap-filling those two tags
2928 + # here would inject the meta-box post-title default over that replacement, so skip
2929 + # them in that case. User-customized overrides are unaffected — they were already
2930 + # applied above.
2931 + $seo_plugin_active = class_exists('Metasync_SEO_Conflict_Handler')
2932 + && Metasync_SEO_Conflict_Handler::get_instance()->has_active_seo_plugin();
2933 +
2934 + $additions = '';
2935 + foreach ($tags as $t) {
2936 + list($attr, $key, $val, $is_url, $is_override) = $t;
2937 + if ($val === '' || $val === null) {
2938 + continue;
2939 + }
2940 + if (!$is_override && $seo_plugin_active && ($key === 'og:title' || $key === 'twitter:title')) {
2941 + continue; # never gap-fill the post title over a third-party plugin's own title
2942 + }
2943 + if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) {
2944 + continue; # OTTO (or another source) already provides it and the user did not override
2945 + }
2946 + $content = $is_url ? esc_url($val) : esc_attr($val);
2947 + if ($content === '') {
2948 + continue;
2949 + }
2950 + $marker = $is_override ? 'override' : 'fill';
2951 + $additions .= '<meta ' . $attr . '="' . $key . '" content="' . $content . '" data-metasync-og="' . $marker . '">' . "\n";
2952 + }
2953 +
2954 + # Reassemble the head block (str_replace avoids backreference issues from $ in values).
2955 + if ($head === $hm[2] && $additions === '') {
2956 + return $html; # nothing removed, nothing added
2957 + }
2958 + $new_head_block = $hm[1] . $head . $additions . $hm[3];
2959 + return str_replace($hm[0], $new_head_block, $html);
2960 + }
2961 +
2962 + /**
1549 2963 * Deduplicate meta tags by a specific attribute (property= or name=).
1550 2964 *
1551 2965 * When duplicates exist and one carries a data-otto marker, keep only
1552 2966 * the OTTO version. Otherwise keep the first occurrence.
@@ -1566,19 +2980,27 @@
1566 2980 }
1567 2981
1568 2982 $all_tags = $matches[0];
1569 2983
1570 - # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
2984 + # Choose the keeper by precedence: custom sidebar → OTTO → first.
2985 + # A user-set SEO sidebar value (data-metasync-seo="custom") must win over
2986 + # OTTO, matching deduplicate_description_tags() and the sidebar's documented
2987 + # "custom always wins over OTTO" intent. Without this, the generic keeper
2988 + # (OTTO-or-first) drops the sidebar's og:/twitter: description in favor of
2989 + # OTTO's injected tag.
2990 + $custom_tag = null;
1571 2991 $otto_tag = null;
1572 2992 foreach ($all_tags as $tag) {
1573 - if (stripos($tag, 'data-otto') !== false) {
2993 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
2994 + $custom_tag = $tag;
2995 + }
2996 + if ($otto_tag === null && stripos($tag, 'data-otto') !== false) {
1574 2997 $otto_tag = $tag;
1575 - break;
1576 2998 }
1577 2999 }
1578 3000
1579 - # Determine the keeper: OTTO tag if present, otherwise the first tag
1580 - $keeper = $otto_tag ?: $all_tags[0];
3001 + # Determine the keeper: custom sidebar tag, else OTTO tag, else the first tag
3002 + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]);
1581 3003
1582 3004 # Remove all occurrences, then re-insert the keeper at the first position
1583 3005 $first_replaced = false;
1584 3006 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
@@ -1592,8 +3014,81 @@
1592 3014 return $html;
1593 3015 }
1594 3016
1595 3017 /**
3018 + * Deduplicate <meta name="description"> tags after OTTO processing.
3019 + *
3020 + * Unlike title, og, twitter, and canonical tags, the plain name="description"
3021 + * tag had NO dedup pass, so a page could end up with 2-3 copies when several
3022 + * subsystems each emit one:
3023 + * - OTTO backend payload (header_html_insertion) → data-otto-pixel="dynamic-seo"
3024 + * (spliced in additively before </head>, never replacing an existing tag)
3025 + * - MetaSync's persisted-meta wp_head hook → data-metasync-otto="true"
3026 + * - MetaSync SEO sidebar custom value → data-metasync-seo="custom"
3027 + *
3028 + * This runs at the buffer level — after every source has written its tag —
3029 + * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented
3030 + * "custom always wins over OTTO" intent):
3031 + * 1. custom sidebar (data-metasync-seo)
3032 + * 2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto)
3033 + * 3. first occurrence
3034 + *
3035 + * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches
3036 + * substring "data-otto", which misses "data-metasync-otto"; this method checks
3037 + * both OTTO markers explicitly, so either OTTO source is recognized.
3038 + *
3039 + * @param string $html Full HTML document.
3040 + * @return string HTML with at most one <meta name="description">.
3041 + */
3042 + private function deduplicate_description_tags($html) {
3043 + if (!is_string($html) || $html === '') {
3044 + return $html;
3045 + }
3046 +
3047 + # Match <meta ... name="description" ...> in either attribute order.
3048 + # [^>]* is bounded to a single tag; name="twitter:description" is NOT matched
3049 + # because the opening quote must be immediately followed by "description".
3050 + $pattern = '/<meta\s[^>]*name\s*=\s*["\']description["\'][^>]*\/?>/i';
3051 +
3052 + if (preg_match_all($pattern, $html, $matches) <= 1) {
3053 + return $html; # 0 or 1 — nothing to deduplicate
3054 + }
3055 +
3056 + $all_tags = $matches[0];
3057 +
3058 + # Choose the keeper by precedence: custom sidebar → OTTO → first.
3059 + $custom_tag = null;
3060 + $otto_tag = null;
3061 + foreach ($all_tags as $tag) {
3062 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3063 + $custom_tag = $tag;
3064 + }
3065 + if ($otto_tag === null && (
3066 + stripos($tag, 'data-otto-pixel') !== false ||
3067 + stripos($tag, 'data-metasync-otto') !== false ||
3068 + stripos($tag, 'data-otto') !== false
3069 + )) {
3070 + $otto_tag = $tag;
3071 + }
3072 + }
3073 + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]);
3074 +
3075 + # Remove all occurrences, re-inserting the keeper at the first position.
3076 + # Callback form avoids backreference injection when the description content
3077 + # contains $ followed by digits (e.g. "$50 off").
3078 + $first_replaced = false;
3079 + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
3080 + if (!$first_replaced) {
3081 + $first_replaced = true;
3082 + return $keeper;
3083 + }
3084 + return ''; # Remove subsequent duplicates
3085 + }, $html);
3086 +
3087 + return $html;
3088 + }
3089 +
3090 + /**
1596 3091 * Remove duplicate <link rel="canonical"> tags from HTML.
1597 3092 *
1598 3093 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1599 3094 * SEO output (or WordPress core) has already emitted one, keep only the
@@ -1634,96 +3129,301 @@
1634 3129 return $html;
1635 3130 }
1636 3131
1637 3132 /**
3133 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3134 + *
3135 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3136 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3137 + * Collapsing such a node to its first token hides it from a plain-typed
3138 + * duplicate, so every token is returned and indexed separately.
3139 + *
3140 + * @param mixed $raw The raw `@type` value.
3141 + * @return string[] Unique lowercased tokens, possibly empty.
3142 + */
3143 + private function schema_type_tokens($raw) {
3144 + $tokens = [];
3145 +
3146 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3147 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3148 + continue;
3149 + }
3150 + $type = strtolower(trim((string) $type));
3151 + if ($type === '') {
3152 + continue;
3153 + }
3154 + $tokens[$type] = true;
3155 + }
3156 +
3157 + return array_keys($tokens);
3158 + }
3159 +
3160 + /**
1638 3161 * Deduplicate JSON-LD schema blocks.
1639 3162 *
1640 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1641 - * blocks, keep OTTO's version for any @type that appears in both.
1642 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3163 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3164 + * token the page already carries. Collisions resolve strictly in OTTO's
3165 + * favour:
1643 3166 *
3167 + * - an OTTO node (marked `data-otto`) displaces every colliding
3168 + * third-party node, whichever appeared first in the document;
3169 + * - two OTTO nodes describing the same entity keep the first;
3170 + * - two third-party nodes never evict each other, even when they share a
3171 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3172 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3173 + * output this pass has no mandate to fold.
3174 + *
3175 + * When OTTO contributed no node at all the document comes back untouched —
3176 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3177 + * into the original document, so a block that loses nothing keeps its exact
3178 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3179 + * and nothing is ever relocated.
3180 + *
3181 + * Blocks are left exactly where they are when their JSON does not parse,
3182 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3183 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3184 + * it in a comment must stay disabled, not be re-published by this pass.
3185 + *
1644 3186 * @param string $html Full HTML.
1645 3187 * @return string
1646 3188 */
1647 3189 private function deduplicate_schema_tags($html) {
1648 - // Find all JSON-LD script blocks
1649 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1650 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3190 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3191 + // capturing only the part before `type=` would hide a `data-otto` marker
3192 + // that sits after it, which is where every insertion path puts it. The
3193 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3194 + // is recognised too.
3195 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3196 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
1651 3197 return $html;
1652 3198 }
1653 3199
1654 - $otto_by_type = []; // @type => decoded JSON object
1655 - $third_by_type = []; // @type => decoded JSON object
1656 - $otto_graph = []; // entries from OTTO @graph blocks
1657 - $third_graph = []; // entries from third-party @graph blocks
3200 + // Byte ranges this pass must not reach into. A commented-out (or
3201 + // <noscript>-wrapped) schema block is deliberately disabled output.
3202 + $masked = [];
3203 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3204 + foreach ($comments as $comment) {
3205 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3206 + }
3207 + }
1658 3208
3209 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3210 + // `data-otto`. A substring test would also catch a hypothetical
3211 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3212 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3213 +
3214 + // Blocks able to take part, and their entities in document order. An
3215 + // entity is one addressable node: a flat block's document, one
3216 + // `@graph` entry, or one node of a top-level array.
3217 + $blocks = [];
3218 + $entities = [];
3219 +
1659 3220 foreach ($matches as $m) {
1660 - $attrs = $m[1];
1661 - $json_str = $m[3];
3221 + $start = $m[0][1];
3222 + foreach ($masked as $range) {
3223 + if ($start >= $range[0] && $start < $range[1]) {
3224 + continue 2;
3225 + }
3226 + }
3227 +
3228 + $attrs = $m[1][0];
3229 + $json_str = $m[3][0];
1662 3230 $decoded = json_decode($json_str, true);
1663 3231 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1664 - continue; // skip unparseable blocks — leave them in place
3232 + continue; // unparseable — leave the block in place
1665 3233 }
1666 - $is_otto = stripos($attrs, 'data-otto') !== false;
1667 3234
3235 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3236 + $end = $start + strlen($m[0][0]);
3237 +
3238 + // A trailing newline swallowed on full removal keeps the output tidy.
3239 + $end_ws = $end;
3240 + if (substr($html, $end, 2) === "\r\n") {
3241 + $end_ws = $end + 2;
3242 + } elseif (substr($html, $end, 1) === "\n") {
3243 + $end_ws = $end + 1;
3244 + }
3245 +
3246 + $register = function (array $node) use (&$entities, $is_otto) {
3247 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3248 + // @type tokens for the type fold. Entries with neither stay in
3249 + // their block but cannot collide, so they keep entity === null.
3250 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3251 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3252 + if ($types === [] && $id === '') {
3253 + return null;
3254 + }
3255 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3256 + return count($entities) - 1;
3257 + };
3258 +
3259 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3260 +
1668 3261 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1669 - foreach ($decoded['@graph'] as $entry) {
1670 - if (!isset($entry['@type'])) continue;
1671 - // JSON-LD allows @type to be a string OR an array of strings.
1672 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1673 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1674 - $type = is_array($entry['@type'])
1675 - ? (string) reset($entry['@type'])
1676 - : (string) $entry['@type'];
1677 - if ($type === '') continue;
1678 - if ($is_otto) {
1679 - $otto_graph[$type] = $entry;
1680 - } else {
1681 - $third_graph[$type] = $entry;
1682 - }
3262 + foreach ($decoded['@graph'] as $node) {
3263 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
1683 3264 }
3265 + $block['shape'] = 'graph';
1684 3266 } elseif (isset($decoded['@type'])) {
1685 - $type = is_array($decoded['@type'])
1686 - ? (string) reset($decoded['@type'])
1687 - : (string) $decoded['@type'];
1688 - if ($type === '') continue;
1689 - if ($is_otto) {
1690 - $otto_by_type[$type] = $decoded;
3267 + $block['shape'] = 'flat';
3268 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3269 + } elseif (isset($decoded[0])) {
3270 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3271 + // persistence path already folds this shape; the render path
3272 + // used to skip it entirely, leaving its duplicates standing.
3273 + $block['shape'] = 'list';
3274 + foreach ($decoded as $node) {
3275 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3276 + }
3277 + } else {
3278 + continue; // neither @type nor @graph nor array — nothing to key on
3279 + }
3280 +
3281 + $blocks[] = $block;
3282 + }
3283 +
3284 + foreach ($entities as $entity) {
3285 + if ($entity['otto']) {
3286 + $otto_present = true;
3287 + break;
3288 + }
3289 + }
3290 + if (empty($otto_present)) {
3291 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3292 + }
3293 +
3294 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3295 + // node can displace a whole set of third-party duplicates at once.
3296 + /** @var array<string, int> $by_id */
3297 + $by_id = []; // '@id:...' => entity index
3298 + /** @var array<string, int[]> $by_type */
3299 + $by_type = []; // type token => entity index[]
3300 +
3301 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3302 + foreach ($entities[$i]['types'] as $token) {
3303 + $holders = $by_type[$token] ?? [];
3304 + $holders = array_values(array_diff($holders, [$i]));
3305 + if ($holders === []) {
3306 + unset($by_type[$token]);
1691 3307 } else {
1692 - $third_by_type[$type] = $decoded;
3308 + $by_type[$token] = $holders;
1693 3309 }
1694 3310 }
3311 + $id = $entities[$i]['id'];
3312 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3313 + unset($by_id[$id]);
3314 + }
3315 + };
3316 +
3317 + foreach ($entities as $i => $entity) {
3318 + $alive = true;
3319 +
3320 + // The same real-world entity twice, whatever @type each copy carries.
3321 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3322 + $occupant = $by_id[$entity['id']];
3323 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3324 + $entities[$occupant]['alive'] = false;
3325 + $release($occupant);
3326 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3327 + } else {
3328 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3329 + }
3330 + }
3331 +
3332 + if ($alive) {
3333 + foreach ($entity['types'] as $token) {
3334 + $holders = $by_type[$token] ?? [];
3335 + if ($holders === []) {
3336 + continue;
3337 + }
3338 + $otto_holder = false;
3339 + foreach ($holders as $holder) {
3340 + if ($entities[$holder]['otto']) {
3341 + $otto_holder = true;
3342 + break;
3343 + }
3344 + }
3345 + if ($otto_holder) {
3346 + $alive = false; // an OTTO node already holds this token
3347 + break;
3348 + }
3349 + if ($entity['otto']) {
3350 + // OTTO displaces every third-party holder of the token and
3351 + // releases ALL their claims, not just this one — otherwise
3352 + // tokens the evicted node held alone would stay blocked and
3353 + // later, legitimate nodes of that type would lose.
3354 + foreach ($holders as $holder) {
3355 + $entities[$holder]['alive'] = false;
3356 + $release($holder);
3357 + }
3358 + }
3359 + // Third party meeting third-party holders: both stand.
3360 + }
3361 + }
3362 +
3363 + if ($alive) {
3364 + foreach ($entity['types'] as $token) {
3365 + $by_type[$token][] = $i;
3366 + }
3367 + if ($entity['id'] !== '') {
3368 + $by_id[$entity['id']] = $i;
3369 + }
3370 + } else {
3371 + $entities[$i]['alive'] = false;
3372 + }
1695 3373 }
1696 3374
1697 - // If OTTO provided no schema at all, nothing to deduplicate
1698 - if (empty($otto_by_type) && empty($otto_graph)) {
1699 - return $html;
3375 + $any_evicted = false;
3376 + foreach ($entities as $entity) {
3377 + if (!$entity['alive']) {
3378 + $any_evicted = true;
3379 + break;
3380 + }
1700 3381 }
3382 + if (!$any_evicted) {
3383 + return $html; // no collision — every original byte stays
3384 + }
1701 3385
1702 - // Remove all JSON-LD blocks from HTML
1703 - $html = preg_replace($pattern, '', $html);
3386 + // Rebuild only the blocks that lost something, back-to-front so the
3387 + // recorded offsets of earlier blocks stay valid.
3388 + $edits = [];
3389 + foreach ($blocks as $block) {
3390 + $kept = [];
3391 + $lost = 0;
3392 + foreach ($block['items'] as $item) {
3393 + if ($item['e'] === null || $entities[$item['e']]['alive']) {
3394 + $kept[] = $item['node'];
3395 + } else {
3396 + $lost++;
3397 + }
3398 + }
3399 + if ($lost === 0) {
3400 + continue;
3401 + }
3402 + if ($kept === []) {
3403 + $edits[] = [$block['start'], $block['end_ws'], ''];
3404 + continue;
3405 + }
1704 3406
1705 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
1706 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
1707 - $rebuilt = '';
1708 - foreach ($kept as $decoded) {
1709 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1710 - wp_json_encode($decoded) . "</script>\n";
1711 - }
3407 + if ($block['shape'] === 'list') {
3408 + $payload = $kept;
3409 + } else { // graph — a flat block that lost its only entity is removed above
3410 + $payload = $block['decoded'];
3411 + $payload['@graph'] = $kept;
3412 + }
1712 3413
1713 - // Re-insert merged @graph block (if any entries exist)
1714 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
1715 - if (!empty($merged_graph)) {
1716 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
1717 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1718 - wp_json_encode($graph_obj) . "</script>\n";
3414 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3415 + if (!is_string($json) || $json === '') {
3416 + continue; // never drop a block whose payload failed to re-encode
3417 + }
3418 +
3419 + // The original attribute string is reused verbatim so class, id and
3420 + // the data-otto marker itself survive the rebuild.
3421 + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
1719 3422 }
1720 3423
1721 - // Re-inject before </head>
1722 - if (!empty($rebuilt)) {
1723 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
1724 - return $rebuilt . $m[1];
1725 - }, $html, 1);
3424 + foreach (array_reverse($edits) as $edit) {
3425 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
1726 3426 }
1727 3427
1728 3428 return $html;
1729 3429 }
@@ -1754,8 +3454,32 @@
1754 3454
1755 3455 return false;
1756 3456 }
1757 3457
3458 + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
3459 + # tell them apart from a third-party SEO plugin's blocks. Every insertion path
3460 + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
3461 + # bytes they insert are identical and the "already applied" checks still match.
3462 + private function stamp_otto_json_ld($html){
3463 +
3464 + $stamped = preg_replace_callback(
3465 + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
3466 + function ($m) {
3467 + # idempotent: a block already carrying a data-otto attribute —
3468 + # anywhere in the tag, not just before `type` — is left alone,
3469 + # never given a second one
3470 + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
3471 + return $m[0];
3472 + }
3473 + return '<script' . $m[1] . ' data-otto="true">';
3474 + },
3475 + $html
3476 + );
3477 +
3478 + # preg_replace returns null on a PCRE failure — never hand back less than we got
3479 + return is_string($stamped) ? $stamped : $html;
3480 + }
3481 +
1758 3482 # this function insterts header html to the dom
1759 3483 function insert_header_html($data){
1760 3484
1761 3485 # check that we have the header html
@@ -1768,20 +3492,27 @@
1768 3492 $head = $this->dom->find('head', 0);
1769 3493
1770 3494 if ($head) {
1771 3495
1772 - # Check if this is an AMP page - if so, don't add metasync_optimized attribute
3496 + # Check if this is an AMP page - if so, skip the processed marker
1773 3497 $is_amp_page = $this->is_amp_page();
1774 3498
1775 - # Append the new HTML at the start of the <head> tag
1776 - # For AMP pages: use clean <head> tag without metasync_optimized attribute
1777 - # For non-AMP pages: add metasync_optimized attribute to <head> tag
1778 - if ($is_amp_page) {
1779 - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1780 - } else {
1781 - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1782 - }
3499 + # Append the new HTML at the start of the <head> tag. The tag itself
3500 + # stays a literal '<head>' either way, so plugins that inject with a
3501 + # literal str_replace('<head>', ...) still match the real tag —
3502 + # see mark_head_optimized() for what breaks when it does not.
3503 + $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
1783 3504
3505 + # Escape JSON-LD payload closers so a `</script>` inside a JSON
3506 + # string value cannot terminate the element early (XSS/page corruption).
3507 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3508 + # fallbacks do, so the deduper can attribute them.
3509 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3510 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3511 + );
3512 +
3513 + $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3514 +
1784 3515 }
1785 3516
1786 3517 # save and reload DOM
1787 3518 $this->save_reload();
@@ -1898,8 +3629,35 @@
1898 3629
1899 3630 }
1900 3631
1901 3632 /**
3633 + * Explicitly free the SimpleHtmlDom node tree and reclaim memory.
3634 + *
3635 + * SimpleHtmlDom nodes hold circular parent/child references, so simply
3636 + * dropping the document does not free the tree until request shutdown.
3637 + * HtmlNode::clear() breaks those cycles; we then drop the document, force a
3638 + * GC pass, and re-instantiate a fresh empty parser so the instance stays
3639 + * reusable for any subsequent route on the same request.
3640 + */
3641 + function free_dom(){
3642 + if (isset($this->dom)) {
3643 + # Break the node tree's parent/child cycles so GC can reclaim it now.
3644 + if (isset($this->dom->root) && is_object($this->dom->root)
3645 + && method_exists($this->dom->root, 'clear')) {
3646 + $this->dom->root->clear();
3647 + }
3648 + unset($this->dom);
3649 + }
3650 +
3651 + # Fresh, empty parser — mirrors the constructor so the object is reusable.
3652 + $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
3653 +
3654 + if (function_exists('gc_collect_cycles')) {
3655 + gc_collect_cycles();
3656 + }
3657 + }
3658 +
3659 + /**
1902 3660 * Process HTML directly without HTTP request (for buffer approach)
1903 3661 * This is the FAST path - eliminates the internal wp_remote_get call
1904 3662 *
1905 3663 * CRITICAL: This method is called from the output buffer callback.
@@ -1921,20 +3679,39 @@
1921 3679 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
1922 3680 return false;
1923 3681 }
1924 3682
3683 + # Skip DOM processing on oversized documents to avoid fatal OOM.
3684 + # Returning false makes the buffer callback serve the original HTML
3685 + # unmodified — the page still renders, just without OTTO changes.
3686 + if (class_exists('Metasync_Otto_Render_Strategy')
3687 + && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html))
3688 + ) {
3689 + Metasync_Otto_Render_Strategy::log_oversized_skip('process_html_directly', strlen($html));
3690 + return false;
3691 + }
3692 +
1925 3693 # Remove XML declaration if present
1926 3694 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
1927 3695
1928 - # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
3696 + # / Save ALL original <style> blocks before DOM processing.
1929 3697 $original_style_blocks = $this->capture_style_blocks($html);
1930 3698
3699 + # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
3700 + # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
3701 + $srcdoc_store = array();
3702 + $html = $this->protect_srcdoc_attributes($html, $srcdoc_store);
3703 +
1931 3704 # Escape bare < in text content before DOM parsing
1932 3705 $html = $this->sanitize_text_less_than($html);
1933 3706
1934 - # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
3707 + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
1935 3708 $html = $this->fix_malformed_self_closing_tags($html);
1936 3709
3710 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
3711 + # SimpleHtmlDom swallows every following attribute into such a value.
3712 + $html = $this->quote_unquoted_attribute_values($html);
3713 +
1937 3714 # Load HTML into DOM
1938 3715 $this->dom->load($html, true, false);
1939 3716
1940 3717 # Force UTF-8 charset to preserve emojis and special characters
@@ -2116,15 +3893,16 @@
2116 3893 }
2117 3894
2118 3895 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
2119 3896 if (!empty($replacement_data['header_html_insertion'])) {
2120 - $header_html_check = trim($replacement_data['header_html_insertion']);
3897 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
3898 + # path inserts the same bytes, so the already-applied check must compare
3899 + # against the stamped form or it would insert a second copy.
3900 + $header_html_insertion = $this->stamp_otto_json_ld(
3901 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3902 + );
3903 + $header_html_check = trim($header_html_insertion);
2121 3904 if (strpos($result_html, $header_html_check) === false) {
2122 - $header_html_insertion = preg_replace(
2123 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
2124 - '<script$1type=$2application/ld+json$2 data-otto="true"',
2125 - $replacement_data['header_html_insertion']
2126 - );
2127 3905 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
2128 3906 # Insert before </head>
2129 3907 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
2130 3908 }
@@ -2179,9 +3957,9 @@
2179 3957 }
2180 3958
2181 3959 # CRITICAL FIX: Apply image alt text manually via string replacement
2182 3960 # DOM changes don't persist, must use string replacement
2183 - $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
3961 + $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');
2184 3962
2185 3963 # String-based heading fallback for body_substitutions
2186 3964 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2187 3965 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
@@ -2197,12 +3975,18 @@
2197 3975 $result_html = preg_replace_callback(
2198 3976 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2199 3977 function ($m) use ($current_value, $recommended_value) {
2200 3978 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2201 - if ($inner_text === $current_value) {
2202 - return $m[1] . $recommended_value . $m[3];
3979 + if ($inner_text !== $current_value) {
3980 + return $m[0];
2203 3981 }
2204 - return $m[0];
3982 + # Keep the author's inline formatting; skip when it can't
3983 + # be preserved rather than flattening the heading.
3984 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
3985 + if ($new_inner === null) {
3986 + return $m[0];
3987 + }
3988 + return $m[1] . $new_inner . $m[3];
2205 3989 },
2206 3990 $result_html,
2207 3991 -1
2208 3992 );
@@ -2244,43 +4028,93 @@
2244 4028 $result_html
2245 4029 );
2246 4030 }
2247 4031
2248 - # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2249 - $result_html = $this->deduplicate_title_tags($result_html);
2250 - $result_html = $this->deduplicate_og_twitter_tags($result_html);
2251 - $result_html = $this->deduplicate_schema_tags($result_html);
2252 - $result_html = $this->deduplicate_canonical_tags($result_html);
4032 + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
4033 + $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);
2253 4034
4035 + # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
4036 + $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
4037 + $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
4038 + $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
4039 + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
4040 + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
4041 + $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
4042 + $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
4043 +
2254 4044 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2255 4045 # This ensures memory is released immediately, especially important for high-traffic sites
2256 4046 unset($final_meta_matches, $matches);
2257 4047
2258 - # Clear SimpleHtmlDom internal cache to free memory
2259 - # Note: We don't unset $this->dom as the object may be reused
2260 - if ($this->dom && method_exists($this->dom, 'clear')) {
2261 - $this->dom->clear();
2262 - }
4048 + # Explicitly free the SimpleHtmlDom node tree now that the
4049 + # result has been serialized to a string. The bundled HtmlDocument has
4050 + # no clear() method, so the previous method_exists() guard was a no-op
4051 + # and the (circular-referenced) node tree lingered until object
4052 + # destruction. free_dom() breaks those references and reclaims memory.
4053 + $this->free_dom();
2263 4054
2264 4055 # Clear element cache array
2265 4056 $this->cached_elements = [];
2266 4057
2267 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2268 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2269 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
4058 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
4059 + if (!$this->is_amp_page()) {
4060 + $result_html = $this->mark_head_optimized($result_html);
2270 4061 }
2271 4062
2272 - $result_html = $this->restore_case_sensitive_attributes($result_html);
4063 + $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
2273 4064
2274 - # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
2275 - $result_html = $this->restore_lost_style_blocks($result_html, $original_style_blocks);
4065 + # / Re-inject any <style> blocks lost during processing.
4066 + $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');
2276 4067
2277 - # WP-315: Fix Divi 5 shortcode framework class renumbering
2278 - $result_html = $this->fix_divi_class_renumbering($result_html);
4068 + # Do NOT renumber Divi classes on the buffer/in-place path.
4069 + # fix_divi_class_renumbering() exists to undo the index offset that
4070 + # Divi's shortcode framework introduces ONLY during the internal
4071 + # wp_remote_get fetch (handle_route_html), where the Theme Builder
4072 + # header is counted twice. process_html_directly() processes the
4073 + # page Divi already rendered normally — its module numbering and
4074 + # Divi's own CSS are already in sync. Renumbering here desynced every
4075 + # section/row/column/text/button index (e.g. et_pb_section_10 → _0),
4076 + # colliding page modules with header/global modules of the same
4077 + # number and collapsing layouts (Divi hero/slider) on Divi sites.
4078 + # The renumber stays only on the HTTP-fetch path (handle_route_html).
2279 4079
2280 - # WP-315: Clean OTTO internal fetch params from HTML output
2281 - $result_html = $this->clean_otto_fetch_params($result_html);
4080 + # Clean OTTO internal fetch params from HTML output
4081 + $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');
2282 4082
4083 + # Remove any lazy-video facade <style> hoisted into the page
4084 + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
4085 + $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');
4086 +
4087 + # Restore the original encoded srcdoc values (undo tokenization).
4088 + $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');
4089 +
4090 + # Keep <meta charset> within the first 1024 bytes so external CSS
4091 + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
4092 + $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');
4093 +
4094 + # undo double-encoded numeric/hex character references produced by the
4095 + # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
4096 + $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
4097 +
4098 + # restore literal '&' query-string separators the serializer escaped to
4099 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
4100 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
4101 +
4102 + # Remove any lazy-video facade <style> hoisted into the page
4103 + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
4104 + $result_html = $this->strip_hoisted_facade_styles($result_html);
4105 +
4106 + # Restore the original encoded srcdoc values (undo tokenization).
4107 + $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);
4108 +
4109 + # Keep <meta charset> within the first 1024 bytes so external CSS
4110 + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
4111 + $result_html = $this->ensure_early_charset_meta($result_html);
4112 +
4113 + # undo double-encoded numeric/hex character references produced by the
4114 + # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
4115 + $result_html = $this->repair_double_encoded_entities($result_html);
4116 +
2283 4117 return $result_html;
2284 4118
2285 4119 } catch (Exception $e) {
2286 4120 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
@@ -2336,29 +4170,21 @@
2336 4170 }
2337 4171 }
2338 4172 }
2339 4173
2340 - if ($remove_title && !$has_custom_title) {
2341 - # Remove Open Graph and Twitter title tags (keep main <title>)
2342 - $title_selectors = [
2343 - 'meta[property=og:title]',
2344 - 'meta[name=twitter:title]',
2345 - ];
4174 + # Preserve third-party og:title/twitter:title tags here. They used to be
4175 + # stripped whenever $remove_title was set, but that also removes them
4176 + # when no custom title exists to replace them, leaving pages with no
4177 + # og:title/twitter:title at all. OTTO's own title/OG dedup and
4178 + # precedence passes already reconcile duplicates, so this branch no
4179 + # longer needs to delete those tags.
2346 4180
2347 - foreach ($title_selectors as $selector) {
2348 - $tags = $this->dom->find($selector);
2349 - foreach ($tags as $tag) {
2350 - $tag->outertext = ''; # Remove the tag
2351 - }
2352 - }
2353 - }
2354 -
2355 4181 # Save changes
2356 4182 $this->save_reload();
2357 4183 }
2358 4184
2359 4185 /**
2360 - * Clean OTTO internal fetch query parameters from rendered HTML (WP-315).
4186 + * Clean OTTO internal fetch query parameters from rendered HTML.
2361 4187 *
2362 4188 * @param string $html The rendered HTML
2363 4189 * @return string HTML with OTTO fetch params removed
2364 4190 */
@@ -2371,14 +4197,57 @@
2371 4197 '/[?&]amp;otto_block_title=1/',
2372 4198 '/[?&]amp;otto_block_desc=1/',
2373 4199 ];
2374 4200 $html = preg_replace($patterns, '', $html);
2375 - $html = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $html);
4201 +
4202 + # Protect <script>/<style> bodies before the URL cleanup below.
4203 + # The href/src/action matcher can otherwise match JS that looks like an
4204 + # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the
4205 + # ?? inside it. Swap each block for a placeholder comment, run the cleanup,
4206 + # then restore byte-for-byte.
4207 + $protected = [];
4208 + $html = preg_replace_callback(
4209 + '/<(script|style)(\b[^>]*)>([\s\S]*?)<\/\1>/i',
4210 + function ($m) use (&$protected) {
4211 + $key = '<!--METASYNC_FETCHCLEAN_' . count($protected) . '-->';
4212 + $protected[$key] = $m[0];
4213 + return $key;
4214 + },
4215 + $html
4216 + );
4217 +
4218 + # Normalize leftover query-string separators (?& / ?#) created by the param
4219 + # removal above — but ONLY inside URL attributes (href/src/action).
4220 + #
4221 + # IMPORTANT: never collapse "??". It is valid, load-bearing syntax for
4222 + # file-concatenation endpoints — Jetpack Boost (/_jb_static/??<hash>) and
4223 + # WordPress core (load-styles.php??... / load-scripts.php??...). Collapsing
4224 + # "??" -> "?" points those at a different URL that returns HTTP 400 / empty,
4225 + # so every affected CSS/JS bundle fails to load and the page renders broken
4226 + # (Kadence + Jetpack Boost "defer non-essential CSS" was the reported case).
4227 + # Removing an OTTO fetch param never produces a "??" anyway, so there is
4228 + # nothing to normalize there.
4229 + # (A previous implementation ran str_replace('??','?') over the ENTIRE
4230 + # document, which additionally corrupted inline JavaScript nullish operators.)
4231 + $html = preg_replace_callback(
4232 + '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is',
4233 + function ($m) {
4234 + $url = str_replace(['?&', '?#'], ['?', '#'], $m[4]);
4235 + return $m[1] . $m[2] . $m[3] . $url . $m[3];
4236 + },
4237 + $html
4238 + );
4239 +
4240 + # Restore protected <script>/<style> blocks byte-for-byte.
4241 + if (!empty($protected)) {
4242 + $html = str_replace(array_keys($protected), array_values($protected), $html);
4243 + }
4244 +
2376 4245 return $html;
2377 4246 }
2378 4247
2379 4248 /**
2380 - * Fix Divi 5 shortcode framework class renumbering (WP-315)
4249 + * Fix Divi 5 shortcode framework class renumbering
2381 4250 *
2382 4251 * When Divi 5's shortcode framework loads during the internal HTTP fetch,
2383 4252 * it uses shared counters across all template parts (header + page + footer),
2384 4253 * causing page content element classes to be offset (et_pb_section_0 becomes
@@ -2418,9 +4287,9 @@
2418 4287 # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
2419 4288 # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
2420 4289 # wp_remote_get includes the Theme Builder header template in Divi's counter.
2421 4290 # If the blog module class doesn't match between page 1 (OTTO-processed) and
2422 - # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results (WP-315).
4291 + # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results.
2423 4292 $types = [
2424 4293 'section', 'row', 'column', 'text', 'blurb', 'toggle',
2425 4294 'button', 'image', 'group_carousel',
2426 4295 'blog', 'portfolio', 'filterable_portfolio', 'shop',
@@ -2428,11 +4297,21 @@
2428 4297 ];
2429 4298
2430 4299 $offsets = [];
2431 4300
4301 + # Lookahead excludes two non-instance class shapes so we never rewrite them:
4302 + # _tb_ → Theme Builder index suffix (et_pb_column_0_tb_header)
4303 + # _\d → Divi column WIDTH fractions (et_pb_column_4_4, _1_2, _1_3, …).
4304 + # The width fraction is NOT an instance counter; rewriting it (e.g.
4305 + # et_pb_column_4_4 → et_pb_column_0_4) strips the column's width rule from
4306 + # Divi's static stylesheet (.et_pb_column_4_4{width:100%}) and collapses the
4307 + # layout. Bare instance indices (et_pb_column_16, followed by space/quote)
4308 + # still match and renumber as intended.
4309 + $instance_lookahead = '(?!_(?:tb_|\d))';
4310 +
2432 4311 foreach ($types as $type) {
2433 - # Find the FIRST numbered instance in page content (not _tb_ suffixed)
2434 - if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)(?!_tb_)/', $page_content, $m)) {
4312 + # Find the FIRST numbered instance in page content (not _tb_ or width-fraction)
4313 + if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)' . $instance_lookahead . '/', $page_content, $m)) {
2435 4314 $first_num = (int) $m[1];
2436 4315 if ($first_num > 0) {
2437 4316 $offsets[$type] = $first_num;
2438 4317 }
@@ -2446,9 +4325,9 @@
2446 4325 # Remap each element type back to 0-based numbering
2447 4326 foreach ($offsets as $etype => $offset) {
2448 4327 $escaped = preg_quote($etype, '/');
2449 4328 $html = preg_replace_callback(
2450 - '/et_pb_' . $escaped . '_(\d+)(?!_tb_)/',
4329 + '/et_pb_' . $escaped . '_(\d+)' . $instance_lookahead . '/',
2451 4330 function ($m) use ($etype, $offset) {
2452 4331 $num = (int) $m[1];
2453 4332 if ($num >= $offset) {
2454 4333 return 'et_pb_' . $etype . '_' . ($num - $offset);
@@ -2523,5 +4402,5 @@
2523 4402 return $html;
2524 4403 }
2525 4404
2526 4405
2527 -}
4406 +}