PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | otto/Otto_html_class.php +1545 -209 2.6.16 → 2.7.2 View file →
@@ -14,8 +14,13 @@
14 14 */
15 15
16 16 Class Metasync_otto_html{
17 17
18 + # Marker announcing that OTTO processed this <head>. Deliberately a comment
19 + # rather than an attribute on the tag — see mark_head_optimized().
20 + const HEAD_OPTIMIZED_TOKEN = 'metasync_optimized';
21 + const HEAD_OPTIMIZED_MARKER = '<!--metasync_optimized-->';
22 +
18 23 # html dom
19 24 private $dom;
20 25
21 26 # the file path to save to
@@ -62,9 +67,9 @@
62 67 return $html;
63 68 }
64 69
65 70 /**
66 - * WP-355: Fix malformed self-closing non-void HTML tags before DOM parsing.
71 + * Fix malformed self-closing non-void HTML tags before DOM parsing.
67 72 *
68 73 * Some themes (e.g. Bootstrap Component Blox) output malformed tags like:
69 74 * <ul/ class="sub-menu dropdown-menu" />
70 75 * SimpleHtmlDom interprets <ul/ as a void element, strips all attributes,
@@ -126,10 +131,197 @@
126 131 return str_ireplace(array_keys($map), array_values($map), $html);
127 132 }
128 133
129 134 /**
130 - * WP-355 / WP-315: Capture ALL <style> blocks before DOM processing.
135 + * Quote unquoted attribute values before DOM parsing.
131 136 *
137 + * Per the HTML spec an unquoted attribute value ends at whitespace, so
138 + * browsers read this correctly:
139 + *
140 + * data-autoplay-viewport=80%
141 + * data-mousewheel="false"
142 + * data-type="carousel"
143 + *
144 + * SimpleHtmlDom does not stop at the whitespace. It runs to the next quote
145 + * character, so the value becomes
146 + * `80%\n data-mousewheel="false"\n data-type="carousel"...` and EVERY
147 + * following attribute on the tag is swallowed into it and lost.
148 + *
149 + * Supreme Modules' image carousel emits exactly that markup. The attributes
150 + * lost after it include data-type, data-slider-orientation and
151 + * data-slideshow-to-show, so Swiper initialises on defaults, stacks all
152 + * slides and grows the page by one slide-set per autoplay tick until it is
153 + * tens of thousands of pixels tall.
154 + *
155 + * Normalising to `data-autoplay-viewport="80%"` before parsing makes the
156 + * markup unambiguous, so no attribute is lost. Already-quoted values are
157 + * left untouched, and <script>/<style> bodies are shielded so their
158 + * contents are never mistaken for tags.
159 + *
160 + * @param string $html Raw HTML before DOM parsing.
161 + * @return string HTML with every unquoted attribute value quoted.
162 + */
163 + private function quote_unquoted_attribute_values($html) {
164 + # Shield <script>/<style> bodies — their contents can contain '<' and '='
165 + # and must never be treated as markup.
166 + $protected = array();
167 + $html = preg_replace_callback(
168 + '/<(script|style)\b([^>]*)>(.*?)<\/\1>/is',
169 + function ($m) use (&$protected) {
170 + $token = '<!--METASYNC_ATTRQ_' . count($protected) . '-->';
171 + $protected[$token] = $m[0];
172 + return $token;
173 + },
174 + $html
175 + );
176 +
177 + # Walk real tags only. The alternation lets a quoted value contain '>'
178 + # so the tag boundary is found correctly. Possessive quantifiers (*+ / ++)
179 + # are deliberate: the three branches are mutually exclusive on their first
180 + # character, so backtracking can never produce a different match, and
181 + # forbidding it removes the catastrophic-backtracking risk this shape
182 + # otherwise carries on markup with many quotes and no '>'.
183 + $result = preg_replace_callback(
184 + '/<([a-zA-Z][a-zA-Z0-9:-]*+)((?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*+)>/s',
185 + function ($m) {
186 + if (strpos($m[2], '=') === false) {
187 + return $m[0];
188 + }
189 +
190 + $attrs = $this->quote_unquoted_values_in_attr_region($m[2]);
191 +
192 + # null means nothing needed changing — return the ORIGINAL tag so
193 + # a document of well-formed HTML comes back byte-identical rather
194 + # than merely equivalent.
195 + return $attrs === null ? $m[0] : '<' . $m[1] . $attrs . '>';
196 + },
197 + $html
198 + );
199 +
200 + if ($result === null) {
201 + $result = $html;
202 + }
203 +
204 + if (!empty($protected)) {
205 + $result = str_replace(array_keys($protected), array_values($protected), $result);
206 + }
207 +
208 + return $result;
209 + }
210 +
211 + /**
212 + * Quote the unquoted values inside one tag's attribute region.
213 + *
214 + * Must be a tokeniser, not a regex. A flat pattern over the region cannot
215 + * tell an attribute from ordinary text inside an already-quoted value, and
216 + * would wreck common markup — `content="width=device-width,
217 + * initial-scale=1"` looks like it contains an `initial-scale=1` attribute.
218 + * Quoted values are therefore copied through verbatim and never inspected.
219 + *
220 + * @param string $attrs Attribute region: everything between the tag name and '>'.
221 + * @return string|null Rewritten region, or null when nothing needed changing.
222 + */
223 + private function quote_unquoted_values_in_attr_region($attrs) {
224 + $len = strlen($attrs);
225 + $out = '';
226 + $i = 0;
227 + $changed = false;
228 +
229 + while ($i < $len) {
230 + $c = $attrs[$i];
231 +
232 + # Whitespace between attributes — preserve exactly.
233 + if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $c === "\f" || $c === "\v") {
234 + $out .= $c;
235 + $i++;
236 + continue;
237 + }
238 +
239 + # Attribute name.
240 + if (preg_match('/\G[a-zA-Z_:][a-zA-Z0-9_:.\-]*+/', $attrs, $nm, 0, $i)) {
241 + $out .= $nm[0];
242 + $i += strlen($nm[0]);
243 +
244 + # Look past optional whitespace for '='. No '=' means a valueless
245 + # boolean attribute; leave it alone.
246 + $j = $i;
247 + while ($j < $len && strpos(" \t\n\r\f\v", $attrs[$j]) !== false) {
248 + $j++;
249 + }
250 + if ($j >= $len || $attrs[$j] !== '=') {
251 + continue;
252 + }
253 +
254 + $out .= substr($attrs, $i, $j - $i) . '=';
255 + $i = $j + 1;
256 +
257 + # Whitespace after '='.
258 + while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) !== false) {
259 + $out .= $attrs[$i];
260 + $i++;
261 + }
262 +
263 + if ($i >= $len) {
264 + continue;
265 + }
266 +
267 + # Quoted value — copy verbatim through the closing quote so its
268 + # contents are never treated as attributes.
269 + if ($attrs[$i] === '"' || $attrs[$i] === "'") {
270 + $quote = $attrs[$i];
271 + $end = strpos($attrs, $quote, $i + 1);
272 + if ($end === false) {
273 + $out .= substr($attrs, $i);
274 + $i = $len;
275 + } else {
276 + $out .= substr($attrs, $i, $end - $i + 1);
277 + $i = $end + 1;
278 + }
279 + continue;
280 + }
281 +
282 + # Unquoted value — runs to the next whitespace. ('>' cannot occur:
283 + # it would have ended the tag before this region.)
284 + $start = $i;
285 + while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) === false) {
286 + $i++;
287 + }
288 + $value = substr($attrs, $start, $i - $start);
289 +
290 + if ($value === '') {
291 + continue;
292 + }
293 +
294 + # Only rewrite when it actually prevents a loss. SimpleHtmlDom
295 + # mis-parses an unquoted value by running from it to the NEXT
296 + # QUOTE CHARACTER, swallowing everything between. With no quote
297 + # left in this region there is nothing to swallow and the value
298 + # already parses correctly — so leave that markup byte-identical
299 + # rather than churn it. (OceanWP's `style=color:#;` as the final
300 + # attribute is the common benign case.)
301 + $rest = substr($attrs, $i);
302 + if (strpos($rest, '"') === false && strpos($rest, "'") === false) {
303 + $out .= $value;
304 + continue;
305 + }
306 +
307 + $out .= '"' . str_replace('"', '&quot;', $value) . '"';
308 + $changed = true;
309 + continue;
310 + }
311 +
312 + # Anything else (stray punctuation, a '/' before '>', malformed input)
313 + # — copy through untouched.
314 + $out .= $c;
315 + $i++;
316 + }
317 +
318 + return $changed ? $out : null;
319 + }
320 +
321 + /**
322 + * / Capture ALL <style> blocks before DOM processing.
323 + *
132 324 * SimpleHtmlDom can lose or corrupt <style> blocks during parse/serialize,
133 325 * especially those without an id attribute (common in custom themes).
134 326 * This captures every <style> block so we can re-inject any that are lost.
135 327 *
@@ -151,9 +343,9 @@
151 343 return $blocks;
152 344 }
153 345
154 346 /**
155 - * WP-355 / WP-315: Re-inject any <style> blocks lost during DOM processing.
347 + * / Re-inject any <style> blocks lost during DOM processing.
156 348 *
157 349 * Compares the captured blocks against the final HTML and re-injects any
158 350 * that disappeared. Named blocks (with id) are checked by id; anonymous
159 351 * blocks are checked by exact content match.
@@ -185,10 +377,68 @@
185 377 return $html;
186 378 }
187 379
188 380 /**
189 - * WP-465: Protect entity-encoded `srcdoc` attribute values before DOM parsing.
381 + * Build the new inner HTML for a heading whose text OTTO is replacing,
382 + * preserving the author's inline formatting.
190 383 *
384 + * Headings are matched by their stripped text, so the recommended value is
385 + * plain text with no way to know how it maps onto the original markup.
386 + * Writing it back directly would discard whatever was inside — a coloured
387 + * <span>, <em>, <br> line breaks — and visibly restyle the page.
388 + *
389 + * Two cases are safe:
390 + * - the heading is already plain text -> replace it
391 + * - the heading is ONE nested inline wrapper -> keep the wrappers,
392 + * chain around a single run of text swap only the text
393 + *
394 + * Anything else is ambiguous: sibling elements, <br>-separated lines, or
395 + * text mixed with elements have no single place the new sentence belongs.
396 + * Those return null so the caller leaves the heading alone — an unapplied
397 + * suggestion is better than silently restyling the customer's content.
398 + *
399 + * @param string $inner_html Current inner HTML of the heading.
400 + * @param string $replacement_text New text, already escaped by the caller.
401 + * @return string|null New inner HTML, or null to skip this heading.
402 + */
403 + public static function build_heading_inner_html($inner_html, $replacement_text) {
404 + $inner = trim($inner_html);
405 +
406 + # Plain text — nothing to preserve.
407 + if (strpos($inner, '<') === false) {
408 + return $replacement_text;
409 + }
410 +
411 + # Inline elements whose wrapper carries the styling worth keeping.
412 + # Deliberately excludes <br>: it encodes line structure, not styling,
413 + # and its presence means the heading has more than one text run.
414 + $inline = 'span|em|strong|b|i|u|a|small|mark|abbr|cite|q|s|sub|sup|font';
415 +
416 + $open = '';
417 + $close = '';
418 +
419 + # Peel wrappers outermost-in. The greedy (.*) means a heading with
420 + # sibling elements (e.g. <em>A</em><br><em>B</em>) leaves stray tags in
421 + # $inner, which the ambiguity check below then rejects.
422 + while (preg_match('#^<(' . $inline . ')(\s[^>]*)?>(.*)</\1\s*>$#is', $inner, $m)) {
423 + # Group 2 (the attribute list) is optional but always captured as ''
424 + # when absent, because group 3 follows it.
425 + $open .= '<' . $m[1] . $m[2] . '>';
426 + $close = '</' . $m[1] . '>' . $close;
427 + $inner = trim($m[3]);
428 +
429 + if (strpos($inner, '<') === false) {
430 + return $open . $replacement_text . $close;
431 + }
432 + }
433 +
434 + # Still markup left after peeling — ambiguous, so don't touch it.
435 + return null;
436 + }
437 +
438 + /**
439 + * / Protect entity-encoded `srcdoc` attribute values before DOM parsing.
440 + *
191 441 * Lazy-loaded YouTube/video facades embed a full HTML document inside an
192 442 * iframe's `srcdoc` attribute, entity-encoded:
193 443 * <iframe srcdoc="&lt;style&gt;*{overflow:hidden}...&lt;/style&gt;&lt;a&gt;..."></iframe>
194 444 * SimpleHtmlDom decodes that value and re-emits the inner <style>/<a>/<img>
@@ -217,9 +467,9 @@
217 467 );
218 468 }
219 469
220 470 /**
221 - * WP-465: Restore srcdoc values tokenized by protect_srcdoc_attributes().
471 + * Restore srcdoc values tokenized by protect_srcdoc_attributes().
222 472 * Tokens are unique and never altered by SimpleHtmlDom, so a plain
223 473 * str_replace on the final output is safe.
224 474 *
225 475 * @param string $html Processed HTML.
@@ -233,9 +483,9 @@
233 483 return str_replace(array_keys($store), array_values($store), $html);
234 484 }
235 485
236 486 /**
237 - * WP-465: Safety net — strip any lazy-video FACADE <style> that leaked into the
487 + * Safety net — strip any lazy-video FACADE <style> that leaked into the
238 488 * document as a real stylesheet.
239 489 *
240 490 * Lazy YouTube/video facades carry this exact CSS reset inside their iframe
241 491 * srcdoc: *{...overflow:hidden} html,body{height:100%} img,span{position:absolute;width:100%;...}
@@ -251,9 +501,9 @@
251 501 * @param string $html
252 502 * @return string
253 503 */
254 504 /**
255 - * WP-535: Crash-free guard for full-document string transforms.
505 + * Crash-free guard for full-document string transforms.
256 506 *
257 507 * PCRE operations (preg_replace / preg_replace_callback) return NULL when they
258 508 * hit a limit — most notably "JIT stack limit exhausted" on Divi/page-builder
259 509 * pages whose large inline <style> blocks defeat tempered-greedy patterns.
@@ -264,9 +514,9 @@
264 514 *
265 515 * This guard keeps the last-good HTML whenever a transform yields NULL (or an
266 516 * unexpectedly empty string), so a single failing regex can no longer discard
267 517 * every OTTO optimisation. It is defence-in-depth: the specific pattern that
268 - * triggered WP-535 (strip_hoisted_facade_styles) has been rewritten to be
518 + * triggered (strip_hoisted_facade_styles) has been rewritten to be
269 519 * non-backtracking, but this guard still protects the rest of the pipeline.
270 520 *
271 521 * @param string|null $new Result of the transform.
272 522 * @param string $prev HTML before the transform (fallback value).
@@ -284,9 +534,9 @@
284 534 return $new;
285 535 }
286 536
287 537 private function strip_hoisted_facade_styles($html) {
288 - # WP-535: Non-backtracking implementation.
538 + # Non-backtracking implementation.
289 539 #
290 540 # The previous single-regex approach used two tempered-greedy segments
291 541 # ((?:(?!</style>).)*?) to keep the match inside one <style> block. That
292 542 # runs a negative lookahead for every character, and on Divi/page-builder
@@ -318,10 +568,54 @@
318 568 return $out === null ? $html : $out;
319 569 }
320 570
321 571 /**
322 - * WP-536: Apply OTTO's canonical via string replacement (reliable fallback).
572 + * Mark the <head> as OTTO-processed without altering the <head> tag itself.
323 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 + *
324 618 * Why the DOM path fails: do_header_replacements() sets the canonical with a
325 619 * DOM attribute edit ($link->href = …). That edit works in isolation, but
326 620 * insert_header_html() runs *earlier* in the same pipeline and reassigns
327 621 * $head->outertext to a raw string (to inject OTTO's schema). In SimpleHtmlDom,
@@ -360,20 +654,26 @@
360 654 if (empty($canonical) || !is_string($canonical)) {
361 655 return $html;
362 656 }
363 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 +
364 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.
365 668 if (function_exists('is_singular') && is_singular()) {
366 669 $post_id = function_exists('get_the_ID') ? get_the_ID() : 0;
367 670 if ($post_id) {
368 - $custom = get_post_meta($post_id, '_metasync_canonical_url', true);
369 - if (empty($custom)) {
370 - $custom = get_post_meta($post_id, 'meta_canonical', true);
371 - if (is_array($custom)) {
372 - $custom = reset($custom) ?: '';
373 - }
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));
374 674 }
375 - if (!empty($custom)) {
675 + if ($custom !== '') {
376 676 return $html; # manual canonical wins — leave the document untouched
377 677 }
378 678 }
379 679 }
@@ -402,9 +702,9 @@
402 702 return $html;
403 703 }
404 704
405 705 /**
406 - * WP-465: Keep the charset declaration within the first 1024 bytes of <head>.
706 + * Keep the charset declaration within the first 1024 bytes of <head>.
407 707 *
408 708 * OTTO injects meta tags + JSON-LD schema at the top of <head>, which can push
409 709 * the theme's <meta charset="utf-8"> past the 1024-byte limit that browsers
410 710 * enforce for in-document charset detection (HTML spec). When that happens —
@@ -485,9 +785,9 @@
485 785 $response = wp_remote_get($url_with_params, $args);
486 786
487 787 # Check for errors
488 788 if (is_wp_error($response)) {
489 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
789 + error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
490 790 return false;
491 791 }
492 792
493 793 # get the response body
@@ -497,9 +797,9 @@
497 797 $response_code = wp_remote_retrieve_response_code($response);
498 798
499 799 # if no change data skip
500 800 if (empty($body) || $response_code !== 200){
501 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
801 + error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
502 802 return false;
503 803 }
504 804
505 805 # set the html file path
@@ -692,9 +992,11 @@
692 992
693 993 # Check for timeout or connection errors
694 994 if (is_wp_error($route_html)) {
695 995 $error_msg = $route_html->get_error_message();
696 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
996 + if (function_exists('metasync_otto_report_render_failure')) {
997 + metasync_otto_report_render_failure('HTTP_FETCH_FAILED', 'Internal fetch failed: ' . $error_msg, [], 'warning');
998 + }
697 999 return false;
698 1000 }
699 1001
700 1002 # get body
@@ -702,16 +1004,55 @@
702 1004
703 1005 # Get the response code
704 1006 $response_code = wp_remote_retrieve_response_code($route_html);
705 1007
1008 + // Requests follows redirects and returns the final body with its final
1009 + // status. Do not put that body on the original route when a redirect
1010 + // changed the URL: doing so creates a soft redirect with mismatched
1011 + // content and SEO metadata. The normal WordPress request can handle
1012 + // the redirect, and OTTO will process the destination on its own URL.
1013 + $effective_url = $this->get_effective_fetch_url($route_html);
1014 + if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
1015 + return false;
1016 + }
706 1017
1018 + # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
1019 + # body below and would otherwise discard this — but it is both the form/session
1020 + # signal for the caching decision AND (same-origin only) the session the visitor
1021 + # needs for their forms to work. $request_body is the URL actually fetched (it may
1022 + # be rewritten to localhost for tunnels), so only allow pass-through when its
1023 + # scheme+host match the visitor's; otherwise we would set a wrong-domain cookie.
1024 + if (class_exists('Metasync_Otto_Render_Strategy')) {
1025 + $fetch_set_cookie = wp_remote_retrieve_header($route_html, 'set-cookie');
1026 + $fetch_parts = wp_parse_url($request_body);
1027 + $visitor_host = preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'] ?? '');
1028 + $same_origin = !empty($fetch_parts['host'])
1029 + && strcasecmp($fetch_parts['host'], $visitor_host) === 0
1030 + && ((($fetch_parts['scheme'] ?? '') === 'https') === (bool) is_ssl());
1031 + Metasync_Otto_Render_Strategy::set_http_fetch_cookie($fetch_set_cookie, $same_origin);
1032 +
1033 + # Also record the internal response's own `Cache-Control`. When the page's
1034 + # render starts a PHP session, PHP emits its session cache-limiter headers
1035 + # (`no-store, no-cache, must-revalidate`) — and it does so whether the session
1036 + # is new OR resumed, so this catches session pages that set no NEW cookie and
1037 + # would otherwise look cacheable. It is read as a signal only and never
1038 + # forwarded to the visitor: our own no-cache request header is not echoed into
1039 + # the response, so this cannot make ordinary pages uncacheable.
1040 + Metasync_Otto_Render_Strategy::set_http_fetch_cache_control(
1041 + wp_remote_retrieve_header($route_html, 'cache-control')
1042 + );
1043 + }
1044 +
1045 +
707 1046 # check not empty
708 1047 if(empty($html_body) || $response_code !== 200){
709 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
1048 + if (function_exists('metasync_otto_report_render_failure')) {
1049 + metasync_otto_report_render_failure('HTTP_FETCH_EMPTY', 'Internal fetch returned empty body or non-200 status (HTTP ' . intval($response_code) . ')', [], 'warning');
1050 + }
710 1051 return false;
711 1052 }
712 1053
713 - # WP-488: Skip DOM processing on oversized documents to avoid fatal OOM.
1054 + # Skip DOM processing on oversized documents to avoid fatal OOM.
714 1055 if (class_exists('Metasync_Otto_Render_Strategy')
715 1056 && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html_body))
716 1057 ) {
717 1058 Metasync_Otto_Render_Strategy::log_oversized_skip('handle_route_html', strlen($html_body));
@@ -720,15 +1061,15 @@
720 1061
721 1062 # Remove XML declaration
722 1063 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
723 1064
724 - # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
1065 + # / Save ALL original <style> blocks before DOM processing.
725 1066 # SimpleHtmlDom can lose or corrupt style blocks during parse/serialize —
726 1067 # not only Divi's id-tagged ones but also anonymous <style> blocks used by
727 1068 # custom themes for nav collapse CSS, responsive breakpoints, etc.
728 1069 $original_style_blocks = $this->capture_style_blocks($html_body);
729 1070
730 - # WP-465: Shield entity-encoded srcdoc values (lazy YouTube/video facades)
1071 + # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
731 1072 # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
732 1073 $srcdoc_store = array();
733 1074 $html_body = $this->protect_srcdoc_attributes($html_body, $srcdoc_store);
734 1075
@@ -734,12 +1075,16 @@
734 1075
735 1076 # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
736 1077 $html_body = $this->sanitize_text_less_than($html_body);
737 1078
738 - # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
1079 + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
739 1080 # before DOM parsing — SimpleHtmlDom strips attributes from these.
740 1081 $html_body = $this->fix_malformed_self_closing_tags($html_body);
741 1082
1083 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
1084 + # SimpleHtmlDom swallows every following attribute into such a value.
1085 + $html_body = $this->quote_unquoted_attribute_values($html_body);
1086 +
742 1087 # now that the html is not empty
743 1088 # load it into the simple html dom
744 1089 $this->dom->load($html_body, true, false);
745 1090
@@ -867,13 +1212,19 @@
867 1212
868 1213 $result_html = preg_replace_callback(
869 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
870 1215 function ($m) use ($current_value, $recommended_value) {
871 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
872 - if ($inner_text === $current_value) {
873 - return $m[1] . $recommended_value . $m[3];
1216 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
1217 + if ($inner_text !== $current_value) {
1218 + return $m[0];
874 1219 }
875 - return $m[0];
1220 + # Keep the author's inline formatting; skip when it can't
1221 + # be preserved rather than flattening the heading.
1222 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
1223 + if ($new_inner === null) {
1224 + return $m[0];
1225 + }
1226 + return $m[1] . $new_inner . $m[3];
876 1227 },
877 1228 $result_html,
878 1229 -1
879 1230 );
@@ -885,15 +1236,16 @@
885 1236 $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');
886 1237
887 1238 # Apply insertions — only if DOM insertion didn't already apply it
888 1239 if (!empty($replacement_data['header_html_insertion'])) {
889 - $header_html_check = trim($replacement_data['header_html_insertion']);
1240 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
1241 + # path inserts the same bytes, so the already-applied check must compare
1242 + # against the stamped form or it would insert a second copy.
1243 + $header_html_insertion = $this->stamp_otto_json_ld(
1244 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
1245 + );
1246 + $header_html_check = trim($header_html_insertion);
890 1247 if (strpos($result_html, $header_html_check) === false) {
891 - $header_html_insertion = preg_replace(
892 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
893 - '<script$1type=$2application/ld+json$2 data-otto="true"',
894 - $replacement_data['header_html_insertion']
895 - );
896 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
897 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
898 1250 }
899 1251 }
@@ -915,63 +1267,70 @@
915 1267 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
916 1268 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
917 1269 }
918 1270
919 - # WP-536: Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
1271 + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
920 1272 $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);
921 1273
922 1274 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
923 1275 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
924 1276 $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
1277 + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags');
925 1278 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
1279 + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
1280 + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
926 1281 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
927 1282 $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
928 1283
929 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
930 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
931 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
1284 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
1285 + if (!$this->is_amp_page()) {
1286 + $result_html = $this->mark_head_optimized($result_html);
932 1287 }
933 1288
934 1289 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
935 1290
936 - # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
1291 + # / Re-inject any <style> blocks lost during processing.
937 1292 $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');
938 1293
939 - # WP-315: Fix Divi 5 shortcode framework class renumbering (HTTP path)
1294 + # Fix Divi 5 shortcode framework class renumbering (HTTP path)
940 1295 $result_html = $this->otto_guard_html($this->fix_divi_class_renumbering($result_html), $result_html, 'fix_divi_class_renumbering');
941 1296
942 - # WP-315: Clean OTTO internal fetch params from HTML output.
1297 + # Clean OTTO internal fetch params from HTML output.
943 1298 # The HTTP render uses wp_remote_get with ?is_otto_page_fetch=1&otto_block_title=1&otto_block_desc=1
944 1299 # These leak into form action URLs, canonical links, etc. in the rendered HTML.
945 1300 $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');
946 1301
947 - # WP-465: Remove any lazy-video facade <style> hoisted into the page
1302 + # Remove any lazy-video facade <style> hoisted into the page
948 1303 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
949 1304 $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');
950 1305
951 - # WP-465: Restore the original encoded srcdoc values (undo tokenization).
1306 + # Restore the original encoded srcdoc values (undo tokenization).
952 1307 $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');
953 1308
954 - # WP-465: Keep <meta charset> within the first 1024 bytes so external CSS
1309 + # Keep <meta charset> within the first 1024 bytes so external CSS
955 1310 # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
956 1311 $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');
957 1312
958 - # WP-471: undo double-encoded numeric/hex character references produced by the
1313 + # undo double-encoded numeric/hex character references produced by the
959 1314 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
960 1315 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
961 1316
962 - # WP-465: Remove any lazy-video facade <style> hoisted into the page
1317 + # restore literal '&' query-string separators the serializer escaped to
1318 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
1319 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
1320 +
1321 + # Remove any lazy-video facade <style> hoisted into the page
963 1322 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
964 1323 $result_html = $this->strip_hoisted_facade_styles($result_html);
965 1324
966 - # WP-465: Restore the original encoded srcdoc values (undo tokenization).
1325 + # Restore the original encoded srcdoc values (undo tokenization).
967 1326 $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);
968 1327
969 - # WP-465: Keep <meta charset> within the first 1024 bytes so external CSS
1328 + # Keep <meta charset> within the first 1024 bytes so external CSS
970 1329 # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
971 1330 $result_html = $this->ensure_early_charset_meta($result_html);
972 1331
973 - # WP-471: undo double-encoded numeric/hex character references produced by the
1332 + # undo double-encoded numeric/hex character references produced by the
974 1333 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
975 1334 $result_html = $this->repair_double_encoded_entities($result_html);
976 1335
977 1336 return $result_html;
@@ -977,10 +1336,113 @@
977 1336 return $result_html;
978 1337 }
979 1338
980 1339 /**
981 - * WP-471: Repair double-encoded character references in serialized HTML.
1340 + * Read the final URL from the WordPress HTTP response when Requests has
1341 + * followed one or more redirects. Older HTTP transports may not expose it;
1342 + * in that case the empty result leaves the existing fail-open behavior.
982 1343 *
1344 + * @param mixed $response wp_remote_get() response.
1345 + * @return string Final URL, or an empty string when unavailable.
1346 + */
1347 + private function get_effective_fetch_url($response)
1348 + {
1349 + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
1350 + return '';
1351 + }
1352 +
1353 + $http_response = $response['http_response'];
1354 + if (!method_exists($http_response, 'get_response_object')) {
1355 + return '';
1356 + }
1357 +
1358 + $requests_response = $http_response->get_response_object();
1359 + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
1360 + return '';
1361 + }
1362 +
1363 + return $requests_response->url;
1364 + }
1365 +
1366 + /**
1367 + * Determine whether a followed response still represents the requested
1368 + * route. The internal fetch may use a rewritten host for tunnel support,
1369 + * so both the original and transport hosts are accepted; the path and
1370 + * non-OTTO query parameters must remain identical.
1371 + *
1372 + * @param string $effective_url Final URL reported by Requests.
1373 + * @param string $route Original route supplied to OTTO.
1374 + * @param string $fetch_url URL actually passed to wp_remote_get().
1375 + * @return bool True when no meaningful redirect changed the route.
1376 + */
1377 + private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1378 + {
1379 + $effective = wp_parse_url($effective_url);
1380 + $requested = wp_parse_url($route);
1381 + $transport = wp_parse_url($fetch_url);
1382 + if (!is_array($effective) || !is_array($requested)) {
1383 + return true;
1384 + }
1385 +
1386 + $effective_path = isset($effective['path']) ? $effective['path'] : '/';
1387 + $requested_path = isset($requested['path']) ? $requested['path'] : '/';
1388 + if ($effective_path !== $requested_path) {
1389 + return false;
1390 + }
1391 +
1392 + $allowed_hosts = [];
1393 + foreach ([$requested, $transport] as $parts) {
1394 + if (!empty($parts['host'])) {
1395 + $host = strtolower($parts['host']);
1396 + if (!empty($parts['port'])) {
1397 + $host .= ':' . $parts['port'];
1398 + }
1399 + $allowed_hosts[] = $host;
1400 + }
1401 + }
1402 + if (!empty($effective['host'])) {
1403 + $effective_host = strtolower($effective['host']);
1404 + if (!empty($effective['port'])) {
1405 + $effective_host .= ':' . $effective['port'];
1406 + }
1407 + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
1408 + return false;
1409 + }
1410 + }
1411 +
1412 + return $this->without_otto_fetch_params($effective['query'] ?? '')
1413 + === $this->without_otto_fetch_params($requested['query'] ?? '');
1414 + }
1415 +
1416 + /**
1417 + * Remove parameters used only to mark OTTO's internal request.
1418 + *
1419 + * @param string $query Query string without the leading '?'.
1420 + * @return string Query with OTTO markers removed.
1421 + */
1422 + private function without_otto_fetch_params($query)
1423 + {
1424 + $query = (string) $query;
1425 + if ($query === '') {
1426 + return '';
1427 + }
1428 +
1429 + $parts = [];
1430 + foreach (explode('&', $query) as $part) {
1431 + $name = strstr($part, '=', true);
1432 + $name = $name === false ? $part : $name;
1433 + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
1434 + continue;
1435 + }
1436 + $parts[] = $part;
1437 + }
1438 +
1439 + return implode('&', $parts);
1440 + }
1441 +
1442 + /**
1443 + * Repair double-encoded character references in serialized HTML.
1444 + *
983 1445 * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
984 1446 * $this->dom->save() and $root->outertext) runs every attribute value through
985 1447 * htmlentities(), which escapes the leading '&' of numeric/hex character
986 1448 * references. Theme icon attributes such as data-x-icon-s="&#xf3c5"
@@ -1001,8 +1463,118 @@
1001 1463 }
1002 1464 return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
1003 1465 }
1004 1466
1467 + /**
1468 + * Restore literal '&' query-string separators inside URL attributes.
1469 + *
1470 + * HtmlNode::makeup() serialises every attribute through htmlentities(), so a
1471 + * separator that arrived as a bare '&' - or as '&#038;', which HtmlDocument
1472 + * decodes to '&' when it parses the attribute - is written back as '&amp;'.
1473 + * Both forms decode to '&' in a conforming HTML parser, so the browser is
1474 + * unaffected. A consumer that reads the raw attribute WITHOUT decoding
1475 + * entities sees a different URL, and Cloudflare Fonts is one of those: it
1476 + * refetches fonts.googleapis.com/css2?family=A&amp;family=B, Google treats
1477 + * only the first 'family=' as a real parameter (the rest arrive named
1478 + * 'amp;family'), and every font after the first is dropped from the response.
1479 + * The page keeps its font-family declarations but loses the @font-face rules,
1480 + * so text silently falls back to a system font.
1481 + *
1482 + * Rewrites only a '&amp;' that is immediately followed by a parameter name
1483 + * and '=', and only inside the listed URL attributes. A '&amp;' elsewhere is
1484 + * left escaped, and the mandatory '=' in the lookahead (with ';' excluded
1485 + * from the name class) means no ambiguous ampersand ('&' + alphanumerics +
1486 + * ';') is ever produced.
1487 + *
1488 + * All three delimiter forms the serializer can emit are handled: double
1489 + * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and
1490 + * emits no delimiter for a value with no whitespace, which is common on
1491 + * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted
1492 + * value may legitimately contain an apostrophe.
1493 + *
1494 + * The attribute list stops at attributes whose value is always a URL.
1495 + * 'content' is deliberately excluded because it also carries prose
1496 + * (<meta name="description" content="A &amp; B">), where unescaping would
1497 + * corrupt visible text. Attributes read only via JavaScript getAttribute()
1498 + * do not need it either - the DOM decodes entities for them; only consumers
1499 + * that scan raw HTML are affected.
1500 + *
1501 + * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded
1502 + * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes
1503 + * there would edit a JS string literal, or turn "&amp;copy=2" in a textarea
1504 + * into "(c)=2" once the browser decodes it.
1505 + *
1506 + * Plain negated character classes only - no tempered-greedy lookaheads, which
1507 + * blow the PCRE JIT stack on pages with very large inline blocks.
1508 + *
1509 + * @param string $html Serialized HTML from save()/outertext.
1510 + * @return string
1511 + */
1512 + private function repair_query_string_ampersands($html) {
1513 + # strpos guard: skip the regex entirely when there is nothing to repair
1514 + if (strpos($html, '&amp;') === false) {
1515 + return $html;
1516 + }
1517 +
1518 + # Shield verbatim / RCDATA regions and comments before touching anything.
1519 + # Masking also shrinks large inline blocks to a token, so the attribute
1520 + # pass never walks them.
1521 + $shielded = array();
1522 + $masked = preg_replace_callback(
1523 + '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is',
1524 + function ($m) use (&$shielded) {
1525 + $token = '<!--METASYNC_QSA_' . count($shielded) . '-->';
1526 + $shielded[$token] = $m[0];
1527 + return $token;
1528 + },
1529 + $html
1530 + );
1531 +
1532 + # preg_* returns null on a PCRE failure; keep the original document.
1533 + if ($masked === null) {
1534 + return $html;
1535 + }
1536 +
1537 + $repaired = preg_replace_callback(
1538 + '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i',
1539 + function ($m) {
1540 + # Pick the delimiter branch that matched. An empty value carries
1541 + # no query string, so the ambiguity between an empty quoted value
1542 + # and an unset group cannot change the outcome.
1543 + if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones)
1544 + $quote = '';
1545 + $value = $m[4];
1546 + } elseif (isset($m[3]) && $m[3] !== '') {
1547 + $quote = "'";
1548 + $value = $m[3];
1549 + } else {
1550 + $quote = '"';
1551 + $value = isset($m[2]) ? $m[2] : '';
1552 + }
1553 +
1554 + # Only a value carrying a query string can hold separators.
1555 + if (strpos($value, '?') === false || strpos($value, '&amp;') === false) {
1556 + return $m[0];
1557 + }
1558 +
1559 + $fixed = preg_replace('/&amp;(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value);
1560 +
1561 + if ($fixed === null) {
1562 + return $m[0];
1563 + }
1564 +
1565 + return $m[1] . $quote . $fixed . $quote;
1566 + },
1567 + $masked
1568 + );
1569 +
1570 + if ($repaired === null) {
1571 + return $html;
1572 + }
1573 +
1574 + return empty($shielded) ? $repaired : strtr($repaired, $shielded);
1575 + }
1576 +
1005 1577 # do the footer html insertion
1006 1578 function do_footer_html_insertion($replacement_data){
1007 1579
1008 1580 # check that we have footer html
@@ -1103,9 +1675,9 @@
1103 1675 if (empty($images)) {
1104 1676 return;
1105 1677 }
1106 1678
1107 - # WP-425: index suggestions by URL path as well, so images authored with
1679 + # index suggestions by URL path as well, so images authored with
1108 1680 # relative srcs still match OTTO's absolute-URL keys (and vice versa).
1109 1681 $alt_lookup = $this->build_image_alt_lookup($image_data);
1110 1682
1111 1683 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
@@ -1144,9 +1716,9 @@
1144 1716 }
1145 1717 }
1146 1718 }
1147 1719
1148 - # WP-425: memoized site host — home_url() runs its filter chain on every
1720 + # memoized site host — home_url() runs its filter chain on every
1149 1721 # call and these helpers run per suggestion and per img.
1150 1722 private ?string $site_host_cache = null;
1151 1723
1152 1724 /**
@@ -1162,9 +1734,9 @@
1162 1734 return $this->site_host_cache;
1163 1735 }
1164 1736
1165 1737 /**
1166 - * WP-425: Candidate lookup keys for an image URL — the URL itself plus,
1738 + * Candidate lookup keys for an image URL — the URL itself plus,
1167 1739 * for relative URLs or absolute/protocol-relative URLs on this site's
1168 1740 * host, the bare URL path. URLs on a different host only ever match
1169 1741 * exactly, so a same path on a foreign host cannot produce a false match.
1170 1742 *
@@ -1192,9 +1764,9 @@
1192 1764 return $variants;
1193 1765 }
1194 1766
1195 1767 /**
1196 - * WP-425: Build the image alt lookup keyed by every URL variant of each
1768 + * Build the image alt lookup keyed by every URL variant of each
1197 1769 * OTTO suggestion, so relative img srcs match absolute suggestion URLs.
1198 1770 *
1199 1771 * @param array $image_data OTTO body_substitutions.images map (url => alt)
1200 1772 * @return array Expanded lookup (url-or-path => alt)
@@ -1216,9 +1788,9 @@
1216 1788 return $lookup;
1217 1789 }
1218 1790
1219 1791 /**
1220 - * WP-425: Resolve the OTTO alt text for an img src, trying the exact src
1792 + * Resolve the OTTO alt text for an img src, trying the exact src
1221 1793 * first and then its same-host path variant.
1222 1794 *
1223 1795 * @param array $lookup Expanded lookup from build_image_alt_lookup()
1224 1796 * @param string $image_src The img element's src attribute
@@ -1281,9 +1853,9 @@
1281 1853 *
1282 1854 * Known limitation (pre-existing): the `<img[^>]*src=` pattern can match a
1283 1855 * `data-src`/`data-lazy-src` attribute that ends with the target URL before
1284 1856 * the real `src`, mis-attributing the alt on lazy-loaded markup. Tracked as
1285 - * a follow-up to WP-425.
1857 + * a follow-up to.
1286 1858 *
1287 1859 * @param string $html The HTML to process
1288 1860 * @param array $replacement_data The replacement data containing body_substitutions.images
1289 1861 * @return string Modified HTML with alt text applied
@@ -1293,9 +1865,9 @@
1293 1865 return $html;
1294 1866 }
1295 1867
1296 1868 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
1297 - # WP-425: prefilter and match on the same-host path variant too, so
1869 + # prefilter and match on the same-host path variant too, so
1298 1870 # relative img srcs match OTTO's absolute suggestion URLs.
1299 1871 $src_pattern = $this->get_image_src_pattern($image_url);
1300 1872
1301 1873 if (empty($alt_text) || $src_pattern === null || strpos($html, $src_pattern['needle']) === false) {
@@ -1350,9 +1922,9 @@
1350 1922 return $html;
1351 1923 }
1352 1924
1353 1925 /**
1354 - * WP-425: Build the src match for an OTTO image suggestion URL.
1926 + * Build the src match for an OTTO image suggestion URL.
1355 1927 *
1356 1928 * For relative URLs or absolute/protocol-relative URLs on this site's
1357 1929 * host, the regex matches the URL path with an optional scheme+host
1358 1930 * prefix — so a relative img src matches an absolute suggestion URL and
@@ -1406,15 +1978,28 @@
1406 1978
1407 1979 # check matching text
1408 1980 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
1409 1981
1410 - # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
1982 + # Rebuild the inner HTML, keeping the author's inline
1983 + # formatting. Returns null when the markup can't be preserved
1984 + # unambiguously — leave that heading alone rather than strip
1985 + # its <span style>/<em>/<br> and restyle the page.
1986 + $new_inner = self::build_heading_inner_html(
1987 + $heading_old->innertext,
1988 + $heading['recommended_value']
1989 + );
1990 +
1991 + if ($new_inner === null) {
1992 + continue;
1993 + }
1994 +
1995 + # replace the tag output, preserving the opening tag's attributes
1411 1996 $outer = $heading_old->outertext;
1412 1997 $open_end = strpos($outer, '>');
1413 1998 if ($open_end !== false) {
1414 1999 $open_tag = substr($outer, 0, $open_end + 1);
1415 2000 $close_tag = '</' . $heading['type'] . '>';
1416 - $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
2001 + $heading_old->outertext = $open_tag . $new_inner . $close_tag;
1417 2002 }
1418 2003
1419 2004 }
1420 2005 }
@@ -1516,10 +2101,10 @@
1516 2101 }
1517 2102
1518 2103 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1519 2104 if (strpos($url, '//') === 0) {
1520 - $parsed_home = parse_url($home_url);
1521 - $parsed_link = parse_url($url);
2105 + $parsed_home = wp_parse_url($home_url);
2106 + $parsed_link = wp_parse_url($url);
1522 2107
1523 2108 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1524 2109 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1525 2110 return false;
@@ -1619,8 +2204,194 @@
1619 2204 # now do the actual html replacements
1620 2205 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1621 2206 }
1622 2207
2208 + /**
2209 + * Resolve the current singular post id reliably inside the OTTO output
2210 + * buffer / direct-render context.
2211 + *
2212 + * get_the_ID() depends on the global $post/loop state, which is not reliable
2213 + * once WordPress has finished rendering and the buffer/shutdown pass runs.
2214 + * get_queried_object_id() reads the parsed main query and stays correct there
2215 + * (same approach as apply_metabox_og_precedence()). Restricted to
2216 + * singular views: on archives/home the queried object id can be a term or
2217 + * user id, which must not be read as post meta.
2218 + *
2219 + * @return int Post id, or 0 when not applicable.
2220 + */
2221 + private function get_buffer_post_id() {
2222 + if (!function_exists('is_singular') || !is_singular()) {
2223 + return 0;
2224 + }
2225 + $post_id = function_exists('get_queried_object_id') ? get_queried_object_id() : 0;
2226 + return $post_id ? (int) $post_id : 0;
2227 + }
2228 +
2229 + /**
2230 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2231 + *
2232 + * @param string $html
2233 + * @param string $attr "name" or "property"
2234 + * @param string $val e.g. "description", "og:description"
2235 + * @param string $content_escaped Already HTML-escaped content value
2236 + * @param string $marker Provenance marker for the winning tier
2237 + * @return string
2238 + */
2239 + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
2240 + $marker_attribute = $marker === 'otto'
2241 + ? ' data-metasync-otto="true"'
2242 + : ' data-metasync-seo="custom"';
2243 + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
2244 + # The optional prefix group must end on whitespace or a quote so the
2245 + # attribute matched is $attr itself, not something merely ending in it:
2246 + # a bare [^>]*name= also matches data-name="keywords", so a third party's
2247 + # unrelated tag was consumed and rewritten while the real one survived —
2248 + # destroying their value AND leaving the duplicate the dedup had just
2249 + # collapsed. Making the group optional keeps the common
2250 + # <meta name="..." ...> form matching, where <meta\s has already consumed
2251 + # the only separator. Same rule as deduplicate_keywords_tags(); this
2252 + # helper serves title, description and keywords, so all three are covered.
2253 + $pattern = '/<meta\s(?:[^>]*[\s"\'])?' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2254 + $count = 0;
2255 + $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2256 + return $tag;
2257 + }, $html, 1, $count);
2258 + if ($count > 0) {
2259 + return $new;
2260 + }
2261 + # None present — insert after <head>.
2262 + return preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($tag) {
2263 + return $m[1] . "\n" . $tag;
2264 + }, $html, 1);
2265 + }
2266 +
2267 + /**
2268 + * Make sure the precedence resolver is loaded before calling it.
2269 + *
2270 + * @return bool
2271 + */
2272 + private static function precedence_available() {
2273 + if (class_exists('Metasync_Seo_Precedence')) {
2274 + return true;
2275 + }
2276 +
2277 + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
2278 + if (is_readable($file)) {
2279 + require_once $file;
2280 + }
2281 +
2282 + return class_exists('Metasync_Seo_Precedence');
2283 + }
2284 +
2285 + /**
2286 + * Enforce the resolved SEO title, description and keywords as the FINAL
2287 + * word over OTTO.
2288 + *
2289 + * The resolver owns the global custom-versus-OTTO order. This final buffer
2290 + * pass must use it too: otherwise the setting works through the sidebar
2291 + * and third-party filters but OTTO's later string pass puts the custom
2292 + * value back on the page.
2293 + *
2294 + * OG and Twitter descriptions remain outside this method deliberately;
2295 + * social precedence is a separate follow-up scope.
2296 + *
2297 + * @param string $html Full HTML document (post-dedup).
2298 + * @return string
2299 + */
2300 + private function apply_custom_seo_precedence($html) {
2301 + if ($html === '') {
2302 + return $html;
2303 + }
2304 + $post_id = $this->get_buffer_post_id();
2305 + if (!$post_id) {
2306 + return $html;
2307 + }
2308 +
2309 + # This runs inside the output buffer, where an unresolvable class is a
2310 + # fatal that takes the whole page down — otto_guard_html() only catches
2311 + # a NULL return, not a thrown Error. A partial update can leave newer
2312 + # PHP files beside an older committed autoload classmap, so resolve the
2313 + # file directly before trusting the class, and leave the document
2314 + # untouched if it is genuinely absent. Same reasoning as
2315 + # Metasync_SEO_Conflict_Handler::precedence_available().
2316 + if (!self::precedence_available()) {
2317 + return $html;
2318 + }
2319 +
2320 + # Title — replace the resolved page title into the (single, post-dedup) <title>.
2321 + $resolved_title = Metasync_Seo_Precedence::resolve(
2322 + $post_id,
2323 + Metasync_Seo_Precedence::FIELD_TITLE
2324 + );
2325 + if (!empty($resolved_title['value'])) {
2326 + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
2327 + $count = 0;
2328 + $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2329 + return $title_tag;
2330 + }, $html, 1, $count);
2331 + if ($count > 0) {
2332 + $html = $replaced;
2333 + } elseif (stripos($html, '<title') === false) {
2334 + $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
2335 + return $m[1] . "\n" . $title_tag;
2336 + }, $html, 1);
2337 + }
2338 + }
2339 +
2340 + # Description — replace the resolved page description into the SEO
2341 + # <meta name="description"> only. OG/Twitter description is social
2342 + # precedence and remains outside this setting's scope.
2343 + $resolved_description = Metasync_Seo_Precedence::resolve(
2344 + $post_id,
2345 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2346 + );
2347 + if (!empty($resolved_description['value'])) {
2348 + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
2349 + $marker = Metasync_Seo_Precedence::is_otto_value(
2350 + $post_id,
2351 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2352 + $resolved_description
2353 + )
2354 + ? 'otto'
2355 + : 'custom';
2356 + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
2357 + }
2358 +
2359 + # Keywords — the same final pass as the description above, on the chain
2360 + # the resolver owns, so the global priority setting governs this tag
2361 + # too. OTTO's keywords tag arrives via header_html_insertion carrying
2362 + # a data-otto marker and is spliced in after wp_head, so it is always
2363 + # the later tag and always wins deduplicate_keywords_tags(); forcing
2364 + # the resolved winner here is what keeps the losing tier reachable at
2365 + # all — the single-printing-owner rule the description branch follows.
2366 + #
2367 + # The tier this forces is NOT guaranteed customer-typed, unlike the
2368 + # title and description keys above: the meta_keywords persistence flag
2369 + # writes OTTO's value straight to _metasync_focus_keyword, and neither
2370 + # that conditional write nor the clone cleaner (which strips only the
2371 + # _metasync_otto_* keys) ever clears it, so a stale flag copy can
2372 + # outrank a newer suggestion under custom-first. That key sharing is
2373 + # tracked separately — the fix is a key of its own for the persisted
2374 + # keyword, not a special case here.
2375 + $resolved_keyword = Metasync_Seo_Precedence::resolve(
2376 + $post_id,
2377 + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD
2378 + );
2379 + if (!empty($resolved_keyword['value'])) {
2380 + $esc = htmlspecialchars($resolved_keyword['value'], ENT_QUOTES, 'UTF-8');
2381 + $marker = Metasync_Seo_Precedence::is_otto_value(
2382 + $post_id,
2383 + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD,
2384 + $resolved_keyword
2385 + )
2386 + ? 'otto'
2387 + : 'custom';
2388 + $html = $this->force_custom_meta($html, 'name', 'keywords', $esc, $marker);
2389 + }
2390 +
2391 + return $html;
2392 + }
2393 +
1623 2394 # this function does the header replacements
1624 2395 function do_header_replacements($replacement_data){
1625 2396
1626 2397 # check that we have header replacements
@@ -1670,21 +2441,19 @@
1670 2441
1671 2442 # handle canonical links
1672 2443 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1673 2444
1674 - # Protect manually-set canonical from OTTO override
2445 + # Protect manually-set canonical from OTTO override.
2446 + # Validated: a legacy row corrupted to "Array" must not
2447 + # count as a manual canonical and block OTTO's correct value.
1675 2448 if (function_exists('is_singular') && is_singular()) {
1676 2449 $post_id = get_the_ID();
1677 2450 if ($post_id) {
1678 - $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1679 - if (empty($custom_canonical)) {
1680 - $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1681 - // Handle legacy array values
1682 - if (is_array($custom_canonical)) {
1683 - $custom_canonical = reset($custom_canonical) ?: '';
1684 - }
2451 + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true));
2452 + if ($custom_canonical === '') {
2453 + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true));
1685 2454 }
1686 - if (!empty($custom_canonical)) {
2455 + if ($custom_canonical !== '') {
1687 2456 # Manual canonical takes priority — skip OTTO override
1688 2457 continue;
1689 2458 }
1690 2459 }
@@ -1692,11 +2461,15 @@
1692 2461
1693 2462 # find the cannonical dom element
1694 2463 $link = $this->dom->find('link[rel="canonical"]', 0);
1695 2464
1696 - # set the link property if not empty
2465 + # set the link property if not empty — only to a validated URL
2466 + # never inject a corrupted/non-URL suggestion.
1697 2467 if(!empty($link->href)){
1698 - $link->href = $data['recommended_value'] ?? $link->href;
2468 + $otto_canonical = Metasync_Canonical_Sanitizer::sanitize($data['recommended_value'] ?? '');
2469 + if ($otto_canonical !== '') {
2470 + $link->href = $otto_canonical;
2471 + }
1699 2472 }
1700 2473
1701 2474 #
1702 2475 continue;
@@ -2019,8 +2792,221 @@
2019 2792 return $html;
2020 2793 }
2021 2794
2022 2795 /**
2796 + * Read one of the meta box's social title/description keys with the "Auto Draft"
2797 + * placeholder collapsed to ''.
2798 + *
2799 + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
2800 + * place. Falls back to a raw read when that class — or that method on it — is
2801 + * unavailable: this buffer filter runs on the front end, and a partially updated
2802 + * install can pair a newer otto/ file with an older includes/ one, where calling
2803 + * a method the loaded class doesn't define would fatal the page rather than
2804 + * degrade.
2805 + *
2806 + * @param int $post_id
2807 + * @param string $key
2808 + * @return string
2809 + */
2810 + private function social_meta($post_id, $key) {
2811 + // PHPStan resolves the literal class-string against the current source and so
2812 + // sees the method as always present; the check is deliberate runtime
2813 + // version-skew defence. Suppressed the same way otto_pixel.php does.
2814 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2815 + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
2816 + return Metasync_OpenGraph::get_social_meta($post_id, $key);
2817 + }
2818 + return (string) get_post_meta($post_id, $key, true);
2819 + }
2820 +
2821 + /**
2822 + * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
2823 + *
2824 + * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
2825 + * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
2826 + * default fills the gap. Mirrors how the custom SEO sidebar values take priority
2827 + * over OTTO (see do_header_replacements()).
2828 + *
2829 + * Runs *after* deduplicate_og_twitter_tags(), and only within <head>. For a field the
2830 + * user explicitly set it removes any existing tag (OTTO's) and re-adds the user's
2831 + * value; for the rest it only adds a tag when absent — so OTTO stays authoritative
2832 + * where the user left a field blank and no duplicate is produced. OTTO never emits an
2833 + * og:image, so this also surfaces a user-set social image on an OTTO-managed page.
2834 + *
2835 + * Only applies to singular posts/pages: the meta box values are per-post, and on
2836 + * archives/home get_queried_object_id() can return a term or user id.
2837 + *
2838 + * @param string $html Full HTML document (post-dedup).
2839 + * @return string
2840 + */
2841 + private function apply_metabox_og_precedence($html) {
2842 + if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
2843 + return $html;
2844 + }
2845 + # Per-post OG only applies to singular posts/pages. On archives/home the queried
2846 + # object id can be a term or user id, which must not be read as post meta.
2847 + if (!is_singular()) {
2848 + return $html;
2849 + }
2850 + $post_id = get_queried_object_id();
2851 + if (!$post_id) {
2852 + return $html;
2853 + }
2854 +
2855 + # Respect the meta box opt-out (only an explicit '0' disables it)
2856 + if (get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
2857 + return $html;
2858 + }
2859 +
2860 + # Explicit (user-typed) meta box values. A non-empty value means the user set
2861 + # this field themselves and it must take priority over OTTO.
2862 + #
2863 + # The four social title/description keys are read through
2864 + # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
2865 + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
2866 + # brand-new post reads as a deliberate override here (it differs from the real
2867 + # title) and would beat OTTO's correct og:title.
2868 + $og_title_set = $this->social_meta($post_id, '_metasync_og_title');
2869 + $og_desc_set = $this->social_meta($post_id, '_metasync_og_description');
2870 + $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2871 + $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
2872 + $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true);
2873 + $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', true);
2874 + $tw_title_set = $this->social_meta($post_id, '_metasync_twitter_title');
2875 + $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description');
2876 + $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2877 + $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2878 +
2879 + # Default OG values the meta box pre-fills (post title / generated excerpt /
2880 + # featured image). The meta box PERSISTS these defaults on save, so a non-empty
2881 + # _metasync_og_* value alone does not prove the user customized it — a field is a
2882 + # genuine override only when its stored value differs from this default. Reuse the
2883 + # emitter's own resolver so the defaults match exactly (falling back to empty
2884 + # defaults, i.e. treat nothing as customized, if the instance is unavailable).
2885 + $defaults = ['title' => '', 'description' => '', 'image' => ''];
2886 + if (class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::get_instance()) {
2887 + $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
2888 + }
2889 +
2890 + $title_is_custom = ($og_title_set !== '' && $og_title_set !== $defaults['title']);
2891 + $desc_is_custom = ($og_desc_set !== '' && $og_desc_set !== $defaults['description']);
2892 + $image_is_custom = ($og_image_set !== '' && $og_image_set !== $defaults['image']);
2893 +
2894 + # Resolved values (explicit value first, then the meta box default).
2895 + $og_title = $og_title_set !== '' ? $og_title_set : $defaults['title'];
2896 + $og_desc = $og_desc_set !== '' ? $og_desc_set : $defaults['description'];
2897 + $og_image = $og_image_set !== '' ? $og_image_set : $defaults['image'];
2898 + $og_type = $og_type_set ?: 'article';
2899 + $site_name = get_bloginfo('name');
2900 +
2901 + # Twitter inherits the OG value when its own field is blank; a user-customized
2902 + # og:image therefore also overrides OTTO's twitter:image (the meta box treats them
2903 + # as one). A twitter field is custom if its own value differs from the default OR
2904 + # the OG field it inherits was customized.
2905 + $tw_card = $tw_card_set ?: 'summary_large_image';
2906 + $tw_title = $tw_title_set !== '' ? $tw_title_set : $og_title;
2907 + $tw_desc = $tw_desc_set !== '' ? $tw_desc_set : $og_desc;
2908 + $tw_image = $tw_image_set !== '' ? $tw_image_set : $og_image;
2909 +
2910 + $tw_title_is_custom = ($tw_title_set !== '' && $tw_title_set !== $defaults['title']) || $title_is_custom;
2911 + $tw_desc_is_custom = ($tw_desc_set !== '' && $tw_desc_set !== $defaults['description']) || $desc_is_custom;
2912 + $tw_image_is_custom = ($tw_image_set !== '' && $tw_image_set !== $defaults['image']) || $image_is_custom;
2913 +
2914 + # twitter:site: per-post value, else the site-wide handle from Social Meta settings.
2915 + # twitter:site / twitter:image:alt are not auto-defaulted by the meta box, so a
2916 + # non-empty per-post value is itself the override signal.
2917 + $tw_site = $tw_site_set;
2918 + if ($tw_site === '' && class_exists('Metasync')) {
2919 + $social = Metasync::get_option('social_meta');
2920 + if (is_array($social) && !empty($social['twitter_username'])) {
2921 + $tw_site = '@' . ltrim($social['twitter_username'], '@');
2922 + }
2923 + }
2924 +
2925 + # og:type / twitter:card are dropdowns that always carry a value, so they override
2926 + # OTTO only when the user picked a non-default option. og:url is auto-populated with
2927 + # the permalink on save, so it is fill-only (never an override) to avoid clobbering
2928 + # OTTO's canonical URL with a stale/auto value. og:site_name is a site default.
2929 + $og_type_override = ($og_type_set !== '' && $og_type_set !== 'article');
2930 + $tw_card_override = ($tw_card_set !== '' && $tw_card_set !== 'summary_large_image');
2931 +
2932 + # [attr, property/name, value, is_url, is_override]
2933 + $tags = [
2934 + ['property', 'og:title', $og_title, false, $title_is_custom],
2935 + ['property', 'og:description', $og_desc, false, $desc_is_custom],
2936 + ['property', 'og:image', $og_image, true, $image_is_custom],
2937 + ['property', 'og:url', get_permalink($post_id), true, false],
2938 + ['property', 'og:type', $og_type, false, $og_type_override],
2939 + ['property', 'og:site_name', $site_name, false, false],
2940 + ['name', 'twitter:card', $tw_card, false, $tw_card_override],
2941 + ['name', 'twitter:site', $tw_site, false, $tw_site_set !== ''],
2942 + ['name', 'twitter:title', $tw_title, false, $tw_title_is_custom],
2943 + ['name', 'twitter:description', $tw_desc, false, $tw_desc_is_custom],
2944 + ['name', 'twitter:image', $tw_image, true, $tw_image_is_custom],
2945 + ['name', 'twitter:image:alt', $tw_image_alt_set, false, $tw_image_alt_set !== ''],
2946 + ];
2947 +
2948 + # Operate only within <head> so meta tags in the body are never touched.
2949 + if (!preg_match('/(<head\b[^>]*>)(.*?)(<\/head>)/is', $html, $hm)) {
2950 + return $html;
2951 + }
2952 + $head = $hm[2];
2953 +
2954 + # 1) Remove existing head tags for user-overridden properties so the user's value wins.
2955 + foreach ($tags as $t) {
2956 + list($attr, $key, $val, $is_url, $is_override) = $t;
2957 + if ($is_override && $val !== '' && $val !== null) {
2958 + $stripped = preg_replace(
2959 + '/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1[^>]*>\s*/i',
2960 + '',
2961 + $head
2962 + );
2963 + if ($stripped !== null) {
2964 + $head = $stripped;
2965 + }
2966 + }
2967 + }
2968 +
2969 + # 2) Build additions: overrides (removed above) + gap-fills for anything still absent.
2970 + #
2971 + # When a third-party SEO plugin is active the conflict handler may have suppressed
2972 + # that plugin's og:title / twitter:title so MetaSync can supply the replacement
2973 + # (Metasync_SEO_Sidebar::output_seo_meta_description). Gap-filling those two tags
2974 + # here would inject the meta-box post-title default over that replacement, so skip
2975 + # them in that case. User-customized overrides are unaffected — they were already
2976 + # applied above.
2977 + $seo_plugin_active = class_exists('Metasync_SEO_Conflict_Handler')
2978 + && Metasync_SEO_Conflict_Handler::get_instance()->has_active_seo_plugin();
2979 +
2980 + $additions = '';
2981 + foreach ($tags as $t) {
2982 + list($attr, $key, $val, $is_url, $is_override) = $t;
2983 + if ($val === '' || $val === null) {
2984 + continue;
2985 + }
2986 + if (!$is_override && $seo_plugin_active && ($key === 'og:title' || $key === 'twitter:title')) {
2987 + continue; # never gap-fill the post title over a third-party plugin's own title
2988 + }
2989 + if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) {
2990 + continue; # OTTO (or another source) already provides it and the user did not override
2991 + }
2992 + $content = $is_url ? esc_url($val) : esc_attr($val);
2993 + if ($content === '') {
2994 + continue;
2995 + }
2996 + $marker = $is_override ? 'override' : 'fill';
2997 + $additions .= '<meta ' . $attr . '="' . $key . '" content="' . $content . '" data-metasync-og="' . $marker . '">' . "\n";
2998 + }
2999 +
3000 + # Reassemble the head block (str_replace avoids backreference issues from $ in values).
3001 + if ($head === $hm[2] && $additions === '') {
3002 + return $html; # nothing removed, nothing added
3003 + }
3004 + $new_head_block = $hm[1] . $head . $additions . $hm[3];
3005 + return str_replace($hm[0], $new_head_block, $html);
3006 + }
3007 +
3008 + /**
2023 3009 * Deduplicate meta tags by a specific attribute (property= or name=).
2024 3010 *
2025 3011 * When duplicates exist and one carries a data-otto marker, keep only
2026 3012 * the OTTO version. Otherwise keep the first occurrence.
@@ -2040,19 +3026,27 @@
2040 3026 }
2041 3027
2042 3028 $all_tags = $matches[0];
2043 3029
2044 - # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
3030 + # Choose the keeper by precedence: custom sidebar → OTTO → first.
3031 + # A user-set SEO sidebar value (data-metasync-seo="custom") must win over
3032 + # OTTO, matching deduplicate_description_tags() and the sidebar's documented
3033 + # "custom always wins over OTTO" intent. Without this, the generic keeper
3034 + # (OTTO-or-first) drops the sidebar's og:/twitter: description in favor of
3035 + # OTTO's injected tag.
3036 + $custom_tag = null;
2045 3037 $otto_tag = null;
2046 3038 foreach ($all_tags as $tag) {
2047 - if (stripos($tag, 'data-otto') !== false) {
3039 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3040 + $custom_tag = $tag;
3041 + }
3042 + if ($otto_tag === null && stripos($tag, 'data-otto') !== false) {
2048 3043 $otto_tag = $tag;
2049 - break;
2050 3044 }
2051 3045 }
2052 3046
2053 - # Determine the keeper: OTTO tag if present, otherwise the first tag
2054 - $keeper = $otto_tag ?: $all_tags[0];
3047 + # Determine the keeper: custom sidebar tag, else OTTO tag, else the first tag
3048 + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]);
2055 3049
2056 3050 # Remove all occurrences, then re-insert the keeper at the first position
2057 3051 $first_replaced = false;
2058 3052 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
@@ -2066,9 +3060,9 @@
2066 3060 return $html;
2067 3061 }
2068 3062
2069 3063 /**
2070 - * WP-550: Deduplicate <meta name="description"> tags after OTTO processing.
3064 + * Deduplicate <meta name="description"> tags after OTTO processing.
2071 3065 *
2072 3066 * Unlike title, og, twitter, and canonical tags, the plain name="description"
2073 3067 * tag had NO dedup pass, so a page could end up with 2-3 copies when several
2074 3068 * subsystems each emit one:
@@ -2139,8 +3133,82 @@
2139 3133 return $html;
2140 3134 }
2141 3135
2142 3136 /**
3137 + * Remove duplicate <meta name="keywords"> tags from HTML.
3138 + *
3139 + * OTTO ships its keywords tag inside header_html_insertion, and MetaSync's
3140 + * own SEO output now emits one too whenever the precedence chain resolves a
3141 + * keyword — so a server-side-rendered page can carry both.
3142 + *
3143 + * The keeper is OTTO's tag, or the first occurrence when no OTTO tag is
3144 + * present. Unlike deduplicate_description_tags() there is no
3145 + * customer-authored branch here: print_metatag() emits no data attribute, so
3146 + * a keywords tag never carries data-metasync-seo="custom" at this point in
3147 + * the pipeline. The customer tier is re-applied afterwards by
3148 + * apply_custom_seo_precedence(), which runs later and stamps the marker via
3149 + * force_custom_meta(). Collapsing to OTTO's tag here is therefore safe: it is
3150 + * not the final word on what the page renders.
3151 + *
3152 + * @param string $html Full HTML document.
3153 + * @return string HTML with at most one <meta name="keywords">.
3154 + */
3155 + private function deduplicate_keywords_tags($html) {
3156 + # No is_string() guard, unlike deduplicate_description_tags() beside it.
3157 + # That asymmetry is forced, not an oversight: @param string makes the
3158 + # check provably always-true at PHPStan level 5, and the description
3159 + # sibling's copy is one of a fixed number of occurrences the baseline
3160 + # pins by count, so adding another fails the build. Both methods are
3161 + # only ever called with the buffer string.
3162 + if ($html === '') {
3163 + return $html;
3164 + }
3165 +
3166 + # Match <meta ... name="keywords" ...> in either attribute order.
3167 + #
3168 + # The optional prefix group must end on whitespace or a quote so the
3169 + # attribute is `name`, not something merely ending in it: a bare
3170 + # [^>]*name= also matches data-name="keywords". Making the group
3171 + # optional keeps the common `<meta name="keywords" ...>` form matching,
3172 + # where <meta\s has already consumed the only separator.
3173 + #
3174 + # The opening quote must be immediately followed by "keywords", so
3175 + # name="news_keywords" is not matched.
3176 + $pattern = '/<meta\s(?:[^>]*[\s"\'])?name\s*=\s*["\']keywords["\'][^>]*\/?>/i';
3177 +
3178 + if (preg_match_all($pattern, $html, $matches) <= 1) {
3179 + return $html; # 0 or 1 — nothing to deduplicate
3180 + }
3181 +
3182 + $all_tags = $matches[0];
3183 +
3184 + $otto_tag = null;
3185 + foreach ($all_tags as $tag) {
3186 + if (stripos($tag, 'data-otto-pixel') !== false ||
3187 + stripos($tag, 'data-metasync-otto') !== false ||
3188 + stripos($tag, 'data-otto') !== false
3189 + ) {
3190 + $otto_tag = $tag;
3191 + break;
3192 + }
3193 + }
3194 + $keeper = $otto_tag ?: $all_tags[0];
3195 +
3196 + # Callback form avoids backreference injection when the keyword list
3197 + # contains $ followed by digits.
3198 + $first_replaced = false;
3199 + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
3200 + if (!$first_replaced) {
3201 + $first_replaced = true;
3202 + return $keeper;
3203 + }
3204 + return ''; # Remove subsequent duplicates
3205 + }, $html);
3206 +
3207 + return $html;
3208 + }
3209 +
3210 + /**
2143 3211 * Remove duplicate <link rel="canonical"> tags from HTML.
2144 3212 *
2145 3213 * When OTTO injects a canonical via header_html_insertion and MetaSync's
2146 3214 * SEO output (or WordPress core) has already emitted one, keep only the
@@ -2181,96 +3249,301 @@
2181 3249 return $html;
2182 3250 }
2183 3251
2184 3252 /**
3253 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3254 + *
3255 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3256 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3257 + * Collapsing such a node to its first token hides it from a plain-typed
3258 + * duplicate, so every token is returned and indexed separately.
3259 + *
3260 + * @param mixed $raw The raw `@type` value.
3261 + * @return string[] Unique lowercased tokens, possibly empty.
3262 + */
3263 + private function schema_type_tokens($raw) {
3264 + $tokens = [];
3265 +
3266 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3267 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3268 + continue;
3269 + }
3270 + $type = strtolower(trim((string) $type));
3271 + if ($type === '') {
3272 + continue;
3273 + }
3274 + $tokens[$type] = true;
3275 + }
3276 +
3277 + return array_keys($tokens);
3278 + }
3279 +
3280 + /**
2185 3281 * Deduplicate JSON-LD schema blocks.
2186 3282 *
2187 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
2188 - * blocks, keep OTTO's version for any @type that appears in both.
2189 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3283 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3284 + * token the page already carries. Collisions resolve strictly in OTTO's
3285 + * favour:
2190 3286 *
3287 + * - an OTTO node (marked `data-otto`) displaces every colliding
3288 + * third-party node, whichever appeared first in the document;
3289 + * - two OTTO nodes describing the same entity keep the first;
3290 + * - two third-party nodes never evict each other, even when they share a
3291 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3292 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3293 + * output this pass has no mandate to fold.
3294 + *
3295 + * When OTTO contributed no node at all the document comes back untouched —
3296 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3297 + * into the original document, so a block that loses nothing keeps its exact
3298 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3299 + * and nothing is ever relocated.
3300 + *
3301 + * Blocks are left exactly where they are when their JSON does not parse,
3302 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3303 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3304 + * it in a comment must stay disabled, not be re-published by this pass.
3305 + *
2191 3306 * @param string $html Full HTML.
2192 3307 * @return string
2193 3308 */
2194 3309 private function deduplicate_schema_tags($html) {
2195 - // Find all JSON-LD script blocks
2196 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
2197 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3310 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3311 + // capturing only the part before `type=` would hide a `data-otto` marker
3312 + // that sits after it, which is where every insertion path puts it. The
3313 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3314 + // is recognised too.
3315 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3316 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
2198 3317 return $html;
2199 3318 }
2200 3319
2201 - $otto_by_type = []; // @type => decoded JSON object
2202 - $third_by_type = []; // @type => decoded JSON object
2203 - $otto_graph = []; // entries from OTTO @graph blocks
2204 - $third_graph = []; // entries from third-party @graph blocks
3320 + // Byte ranges this pass must not reach into. A commented-out (or
3321 + // <noscript>-wrapped) schema block is deliberately disabled output.
3322 + $masked = [];
3323 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3324 + foreach ($comments as $comment) {
3325 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3326 + }
3327 + }
2205 3328
3329 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3330 + // `data-otto`. A substring test would also catch a hypothetical
3331 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3332 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3333 +
3334 + // Blocks able to take part, and their entities in document order. An
3335 + // entity is one addressable node: a flat block's document, one
3336 + // `@graph` entry, or one node of a top-level array.
3337 + $blocks = [];
3338 + $entities = [];
3339 +
2206 3340 foreach ($matches as $m) {
2207 - $attrs = $m[1];
2208 - $json_str = $m[3];
3341 + $start = $m[0][1];
3342 + foreach ($masked as $range) {
3343 + if ($start >= $range[0] && $start < $range[1]) {
3344 + continue 2;
3345 + }
3346 + }
3347 +
3348 + $attrs = $m[1][0];
3349 + $json_str = $m[3][0];
2209 3350 $decoded = json_decode($json_str, true);
2210 3351 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
2211 - continue; // skip unparseable blocks — leave them in place
3352 + continue; // unparseable — leave the block in place
2212 3353 }
2213 - $is_otto = stripos($attrs, 'data-otto') !== false;
2214 3354
3355 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3356 + $end = $start + strlen($m[0][0]);
3357 +
3358 + // A trailing newline swallowed on full removal keeps the output tidy.
3359 + $end_ws = $end;
3360 + if (substr($html, $end, 2) === "\r\n") {
3361 + $end_ws = $end + 2;
3362 + } elseif (substr($html, $end, 1) === "\n") {
3363 + $end_ws = $end + 1;
3364 + }
3365 +
3366 + $register = function (array $node) use (&$entities, $is_otto) {
3367 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3368 + // @type tokens for the type fold. Entries with neither stay in
3369 + // their block but cannot collide, so they keep entity === null.
3370 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3371 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3372 + if ($types === [] && $id === '') {
3373 + return null;
3374 + }
3375 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3376 + return count($entities) - 1;
3377 + };
3378 +
3379 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3380 +
2215 3381 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
2216 - foreach ($decoded['@graph'] as $entry) {
2217 - if (!isset($entry['@type'])) continue;
2218 - // JSON-LD allows @type to be a string OR an array of strings.
2219 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
2220 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
2221 - $type = is_array($entry['@type'])
2222 - ? (string) reset($entry['@type'])
2223 - : (string) $entry['@type'];
2224 - if ($type === '') continue;
2225 - if ($is_otto) {
2226 - $otto_graph[$type] = $entry;
2227 - } else {
2228 - $third_graph[$type] = $entry;
2229 - }
3382 + foreach ($decoded['@graph'] as $node) {
3383 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
2230 3384 }
3385 + $block['shape'] = 'graph';
2231 3386 } elseif (isset($decoded['@type'])) {
2232 - $type = is_array($decoded['@type'])
2233 - ? (string) reset($decoded['@type'])
2234 - : (string) $decoded['@type'];
2235 - if ($type === '') continue;
2236 - if ($is_otto) {
2237 - $otto_by_type[$type] = $decoded;
3387 + $block['shape'] = 'flat';
3388 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3389 + } elseif (isset($decoded[0])) {
3390 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3391 + // persistence path already folds this shape; the render path
3392 + // used to skip it entirely, leaving its duplicates standing.
3393 + $block['shape'] = 'list';
3394 + foreach ($decoded as $node) {
3395 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3396 + }
3397 + } else {
3398 + continue; // neither @type nor @graph nor array — nothing to key on
3399 + }
3400 +
3401 + $blocks[] = $block;
3402 + }
3403 +
3404 + foreach ($entities as $entity) {
3405 + if ($entity['otto']) {
3406 + $otto_present = true;
3407 + break;
3408 + }
3409 + }
3410 + if (empty($otto_present)) {
3411 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3412 + }
3413 +
3414 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3415 + // node can displace a whole set of third-party duplicates at once.
3416 + /** @var array<string, int> $by_id */
3417 + $by_id = []; // '@id:...' => entity index
3418 + /** @var array<string, int[]> $by_type */
3419 + $by_type = []; // type token => entity index[]
3420 +
3421 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3422 + foreach ($entities[$i]['types'] as $token) {
3423 + $holders = $by_type[$token] ?? [];
3424 + $holders = array_values(array_diff($holders, [$i]));
3425 + if ($holders === []) {
3426 + unset($by_type[$token]);
2238 3427 } else {
2239 - $third_by_type[$type] = $decoded;
3428 + $by_type[$token] = $holders;
2240 3429 }
2241 3430 }
3431 + $id = $entities[$i]['id'];
3432 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3433 + unset($by_id[$id]);
3434 + }
3435 + };
3436 +
3437 + foreach ($entities as $i => $entity) {
3438 + $alive = true;
3439 +
3440 + // The same real-world entity twice, whatever @type each copy carries.
3441 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3442 + $occupant = $by_id[$entity['id']];
3443 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3444 + $entities[$occupant]['alive'] = false;
3445 + $release($occupant);
3446 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3447 + } else {
3448 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3449 + }
3450 + }
3451 +
3452 + if ($alive) {
3453 + foreach ($entity['types'] as $token) {
3454 + $holders = $by_type[$token] ?? [];
3455 + if ($holders === []) {
3456 + continue;
3457 + }
3458 + $otto_holder = false;
3459 + foreach ($holders as $holder) {
3460 + if ($entities[$holder]['otto']) {
3461 + $otto_holder = true;
3462 + break;
3463 + }
3464 + }
3465 + if ($otto_holder) {
3466 + $alive = false; // an OTTO node already holds this token
3467 + break;
3468 + }
3469 + if ($entity['otto']) {
3470 + // OTTO displaces every third-party holder of the token and
3471 + // releases ALL their claims, not just this one — otherwise
3472 + // tokens the evicted node held alone would stay blocked and
3473 + // later, legitimate nodes of that type would lose.
3474 + foreach ($holders as $holder) {
3475 + $entities[$holder]['alive'] = false;
3476 + $release($holder);
3477 + }
3478 + }
3479 + // Third party meeting third-party holders: both stand.
3480 + }
3481 + }
3482 +
3483 + if ($alive) {
3484 + foreach ($entity['types'] as $token) {
3485 + $by_type[$token][] = $i;
3486 + }
3487 + if ($entity['id'] !== '') {
3488 + $by_id[$entity['id']] = $i;
3489 + }
3490 + } else {
3491 + $entities[$i]['alive'] = false;
3492 + }
2242 3493 }
2243 3494
2244 - // If OTTO provided no schema at all, nothing to deduplicate
2245 - if (empty($otto_by_type) && empty($otto_graph)) {
2246 - return $html;
3495 + $any_evicted = false;
3496 + foreach ($entities as $entity) {
3497 + if (!$entity['alive']) {
3498 + $any_evicted = true;
3499 + break;
3500 + }
2247 3501 }
3502 + if (!$any_evicted) {
3503 + return $html; // no collision — every original byte stays
3504 + }
2248 3505
2249 - // Remove all JSON-LD blocks from HTML
2250 - $html = preg_replace($pattern, '', $html);
3506 + // Rebuild only the blocks that lost something, back-to-front so the
3507 + // recorded offsets of earlier blocks stay valid.
3508 + $edits = [];
3509 + foreach ($blocks as $block) {
3510 + $kept = [];
3511 + $lost = 0;
3512 + foreach ($block['items'] as $item) {
3513 + if ($item['e'] === null || $entities[$item['e']]['alive']) {
3514 + $kept[] = $item['node'];
3515 + } else {
3516 + $lost++;
3517 + }
3518 + }
3519 + if ($lost === 0) {
3520 + continue;
3521 + }
3522 + if ($kept === []) {
3523 + $edits[] = [$block['start'], $block['end_ws'], ''];
3524 + continue;
3525 + }
2251 3526
2252 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
2253 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
2254 - $rebuilt = '';
2255 - foreach ($kept as $decoded) {
2256 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2257 - wp_json_encode($decoded) . "</script>\n";
2258 - }
3527 + if ($block['shape'] === 'list') {
3528 + $payload = $kept;
3529 + } else { // graph — a flat block that lost its only entity is removed above
3530 + $payload = $block['decoded'];
3531 + $payload['@graph'] = $kept;
3532 + }
2259 3533
2260 - // Re-insert merged @graph block (if any entries exist)
2261 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
2262 - if (!empty($merged_graph)) {
2263 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
2264 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2265 - wp_json_encode($graph_obj) . "</script>\n";
3534 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3535 + if (!is_string($json) || $json === '') {
3536 + continue; // never drop a block whose payload failed to re-encode
3537 + }
3538 +
3539 + // The original attribute string is reused verbatim so class, id and
3540 + // the data-otto marker itself survive the rebuild.
3541 + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
2266 3542 }
2267 3543
2268 - // Re-inject before </head>
2269 - if (!empty($rebuilt)) {
2270 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
2271 - return $rebuilt . $m[1];
2272 - }, $html, 1);
3544 + foreach (array_reverse($edits) as $edit) {
3545 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
2273 3546 }
2274 3547
2275 3548 return $html;
2276 3549 }
@@ -2289,14 +3562,14 @@
2289 3562 return true;
2290 3563 }
2291 3564
2292 3565 # Check if amp=1 query parameter is present
2293 - if (isset($_GET['amp']) && $_GET['amp'] == '1') {
3566 + if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2294 3567 return true;
2295 3568 }
2296 3569
2297 3570 # Check for other common AMP query parameters
2298 - if (isset($_GET['amp']) && !empty($_GET['amp'])) {
3571 + if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2299 3572 return true;
2300 3573 }
2301 3574
2302 3575 return false;
@@ -2301,8 +3574,32 @@
2301 3574
2302 3575 return false;
2303 3576 }
2304 3577
3578 + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
3579 + # tell them apart from a third-party SEO plugin's blocks. Every insertion path
3580 + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
3581 + # bytes they insert are identical and the "already applied" checks still match.
3582 + private function stamp_otto_json_ld($html){
3583 +
3584 + $stamped = preg_replace_callback(
3585 + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
3586 + function ($m) {
3587 + # idempotent: a block already carrying a data-otto attribute —
3588 + # anywhere in the tag, not just before `type` — is left alone,
3589 + # never given a second one
3590 + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
3591 + return $m[0];
3592 + }
3593 + return '<script' . $m[1] . ' data-otto="true">';
3594 + },
3595 + $html
3596 + );
3597 +
3598 + # preg_replace returns null on a PCRE failure — never hand back less than we got
3599 + return is_string($stamped) ? $stamped : $html;
3600 + }
3601 +
2305 3602 # this function insterts header html to the dom
2306 3603 function insert_header_html($data){
2307 3604
2308 3605 # check that we have the header html
@@ -2315,20 +3612,27 @@
2315 3612 $head = $this->dom->find('head', 0);
2316 3613
2317 3614 if ($head) {
2318 3615
2319 - # Check if this is an AMP page - if so, don't add metasync_optimized attribute
3616 + # Check if this is an AMP page - if so, skip the processed marker
2320 3617 $is_amp_page = $this->is_amp_page();
2321 3618
2322 - # Append the new HTML at the start of the <head> tag
2323 - # For AMP pages: use clean <head> tag without metasync_optimized attribute
2324 - # For non-AMP pages: add metasync_optimized attribute to <head> tag
2325 - if ($is_amp_page) {
2326 - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
2327 - } else {
2328 - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
2329 - }
3619 + # Append the new HTML at the start of the <head> tag. The tag itself
3620 + # stays a literal '<head>' either way, so plugins that inject with a
3621 + # literal str_replace('<head>', ...) still match the real tag —
3622 + # see mark_head_optimized() for what breaks when it does not.
3623 + $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
2330 3624
3625 + # Escape JSON-LD payload closers so a `</script>` inside a JSON
3626 + # string value cannot terminate the element early (XSS/page corruption).
3627 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3628 + # fallbacks do, so the deduper can attribute them.
3629 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3630 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3631 + );
3632 +
3633 + $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3634 +
2331 3635 }
2332 3636
2333 3637 # save and reload DOM
2334 3638 $this->save_reload();
@@ -2445,9 +3749,9 @@
2445 3749
2446 3750 }
2447 3751
2448 3752 /**
2449 - * WP-488: Explicitly free the SimpleHtmlDom node tree and reclaim memory.
3753 + * Explicitly free the SimpleHtmlDom node tree and reclaim memory.
2450 3754 *
2451 3755 * SimpleHtmlDom nodes hold circular parent/child references, so simply
2452 3756 * dropping the document does not free the tree until request shutdown.
2453 3757 * HtmlNode::clear() breaks those cycles; we then drop the document, force a
@@ -2495,9 +3799,9 @@
2495 3799 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
2496 3800 return false;
2497 3801 }
2498 3802
2499 - # WP-488: Skip DOM processing on oversized documents to avoid fatal OOM.
3803 + # Skip DOM processing on oversized documents to avoid fatal OOM.
2500 3804 # Returning false makes the buffer callback serve the original HTML
2501 3805 # unmodified — the page still renders, just without OTTO changes.
2502 3806 if (class_exists('Metasync_Otto_Render_Strategy')
2503 3807 && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html))
@@ -2508,12 +3812,12 @@
2508 3812
2509 3813 # Remove XML declaration if present
2510 3814 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
2511 3815
2512 - # WP-355 / WP-315: Save ALL original <style> blocks before DOM processing.
3816 + # / Save ALL original <style> blocks before DOM processing.
2513 3817 $original_style_blocks = $this->capture_style_blocks($html);
2514 3818
2515 - # WP-465: Shield entity-encoded srcdoc values (lazy YouTube/video facades)
3819 + # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
2516 3820 # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
2517 3821 $srcdoc_store = array();
2518 3822 $html = $this->protect_srcdoc_attributes($html, $srcdoc_store);
2519 3823
@@ -2519,11 +3823,15 @@
2519 3823
2520 3824 # Escape bare < in text content before DOM parsing
2521 3825 $html = $this->sanitize_text_less_than($html);
2522 3826
2523 - # WP-355: Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
3827 + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
2524 3828 $html = $this->fix_malformed_self_closing_tags($html);
2525 3829
3830 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
3831 + # SimpleHtmlDom swallows every following attribute into such a value.
3832 + $html = $this->quote_unquoted_attribute_values($html);
3833 +
2526 3834 # Load HTML into DOM
2527 3835 $this->dom->load($html, true, false);
2528 3836
2529 3837 # Force UTF-8 charset to preserve emojis and special characters
@@ -2705,15 +4013,16 @@
2705 4013 }
2706 4014
2707 4015 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
2708 4016 if (!empty($replacement_data['header_html_insertion'])) {
2709 - $header_html_check = trim($replacement_data['header_html_insertion']);
4017 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
4018 + # path inserts the same bytes, so the already-applied check must compare
4019 + # against the stamped form or it would insert a second copy.
4020 + $header_html_insertion = $this->stamp_otto_json_ld(
4021 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
4022 + );
4023 + $header_html_check = trim($header_html_insertion);
2710 4024 if (strpos($result_html, $header_html_check) === false) {
2711 - $header_html_insertion = preg_replace(
2712 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
2713 - '<script$1type=$2application/ld+json$2 data-otto="true"',
2714 - $replacement_data['header_html_insertion']
2715 - );
2716 4025 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
2717 4026 # Insert before </head>
2718 4027 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
2719 4028 }
@@ -2785,13 +4094,19 @@
2785 4094
2786 4095 $result_html = preg_replace_callback(
2787 4096 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2788 4097 function ($m) use ($current_value, $recommended_value) {
2789 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2790 - if ($inner_text === $current_value) {
2791 - return $m[1] . $recommended_value . $m[3];
4098 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
4099 + if ($inner_text !== $current_value) {
4100 + return $m[0];
2792 4101 }
2793 - return $m[0];
4102 + # Keep the author's inline formatting; skip when it can't
4103 + # be preserved rather than flattening the heading.
4104 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
4105 + if ($new_inner === null) {
4106 + return $m[0];
4107 + }
4108 + return $m[1] . $new_inner . $m[3];
2794 4109 },
2795 4110 $result_html,
2796 4111 -1
2797 4112 );
@@ -2833,15 +4148,18 @@
2833 4148 $result_html
2834 4149 );
2835 4150 }
2836 4151
2837 - # WP-536: Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
4152 + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
2838 4153 $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);
2839 4154
2840 4155 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
2841 4156 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
2842 4157 $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
4158 + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags');
2843 4159 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
4160 + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
4161 + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
2844 4162 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
2845 4163 $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
2846 4164
2847 4165 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
@@ -2847,9 +4165,9 @@
2847 4165 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2848 4166 # This ensures memory is released immediately, especially important for high-traffic sites
2849 4167 unset($final_meta_matches, $matches);
2850 4168
2851 - # WP-488: Explicitly free the SimpleHtmlDom node tree now that the
4169 + # Explicitly free the SimpleHtmlDom node tree now that the
2852 4170 # result has been serialized to a string. The bundled HtmlDocument has
2853 4171 # no clear() method, so the previous method_exists() guard was a no-op
2854 4172 # and the (circular-referenced) node tree lingered until object
2855 4173 # destruction. free_dom() breaks those references and reclaims memory.
@@ -2857,19 +4175,19 @@
2857 4175
2858 4176 # Clear element cache array
2859 4177 $this->cached_elements = [];
2860 4178
2861 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2862 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2863 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
4179 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
4180 + if (!$this->is_amp_page()) {
4181 + $result_html = $this->mark_head_optimized($result_html);
2864 4182 }
2865 4183
2866 4184 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
2867 4185
2868 - # WP-355 / WP-315: Re-inject any <style> blocks lost during processing.
4186 + # / Re-inject any <style> blocks lost during processing.
2869 4187 $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');
2870 4188
2871 - # WP-480: Do NOT renumber Divi classes on the buffer/in-place path.
4189 + # Do NOT renumber Divi classes on the buffer/in-place path.
2872 4190 # fix_divi_class_renumbering() exists to undo the index offset that
2873 4191 # Divi's shortcode framework introduces ONLY during the internal
2874 4192 # wp_remote_get fetch (handle_route_html), where the Theme Builder
2875 4193 # header is counted twice. process_html_directly() processes the
@@ -2879,38 +4197,42 @@
2879 4197 # colliding page modules with header/global modules of the same
2880 4198 # number and collapsing layouts (Divi hero/slider) on Divi sites.
2881 4199 # The renumber stays only on the HTTP-fetch path (handle_route_html).
2882 4200
2883 - # WP-315: Clean OTTO internal fetch params from HTML output
4201 + # Clean OTTO internal fetch params from HTML output
2884 4202 $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');
2885 4203
2886 - # WP-465: Remove any lazy-video facade <style> hoisted into the page
4204 + # Remove any lazy-video facade <style> hoisted into the page
2887 4205 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
2888 4206 $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');
2889 4207
2890 - # WP-465: Restore the original encoded srcdoc values (undo tokenization).
4208 + # Restore the original encoded srcdoc values (undo tokenization).
2891 4209 $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');
2892 4210
2893 - # WP-465: Keep <meta charset> within the first 1024 bytes so external CSS
4211 + # Keep <meta charset> within the first 1024 bytes so external CSS
2894 4212 # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
2895 4213 $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');
2896 4214
2897 - # WP-471: undo double-encoded numeric/hex character references produced by the
4215 + # undo double-encoded numeric/hex character references produced by the
2898 4216 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
2899 4217 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
2900 4218
2901 - # WP-465: Remove any lazy-video facade <style> hoisted into the page
4219 + # restore literal '&' query-string separators the serializer escaped to
4220 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
4221 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
4222 +
4223 + # Remove any lazy-video facade <style> hoisted into the page
2902 4224 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
2903 4225 $result_html = $this->strip_hoisted_facade_styles($result_html);
2904 4226
2905 - # WP-465: Restore the original encoded srcdoc values (undo tokenization).
4227 + # Restore the original encoded srcdoc values (undo tokenization).
2906 4228 $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);
2907 4229
2908 - # WP-465: Keep <meta charset> within the first 1024 bytes so external CSS
4230 + # Keep <meta charset> within the first 1024 bytes so external CSS
2909 4231 # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
2910 4232 $result_html = $this->ensure_early_charset_meta($result_html);
2911 4233
2912 - # WP-471: undo double-encoded numeric/hex character references produced by the
4234 + # undo double-encoded numeric/hex character references produced by the
2913 4235 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
2914 4236 $result_html = $this->repair_double_encoded_entities($result_html);
2915 4237
2916 4238 return $result_html;
@@ -2915,12 +4237,26 @@
2915 4237
2916 4238 return $result_html;
2917 4239
2918 4240 } catch (Exception $e) {
2919 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
4241 + if (function_exists('metasync_otto_report_render_failure')) {
4242 + metasync_otto_report_render_failure(
4243 + 'MODIFICATION_EXCEPTION',
4244 + 'OTTO rewrite threw: ' . $e->getMessage(),
4245 + ['exception' => get_class($e)],
4246 + 'error'
4247 + );
4248 + }
2920 4249 return false;
2921 4250 } catch (Error $e) {
2922 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
4251 + if (function_exists('metasync_otto_report_render_failure')) {
4252 + metasync_otto_report_render_failure(
4253 + 'MODIFICATION_ERROR',
4254 + 'OTTO rewrite errored: ' . $e->getMessage(),
4255 + ['error' => get_class($e)],
4256 + 'error'
4257 + );
4258 + }
2923 4259 return false;
2924 4260 }
2925 4261 }
2926 4262
@@ -2969,29 +4305,21 @@
2969 4305 }
2970 4306 }
2971 4307 }
2972 4308
2973 - if ($remove_title && !$has_custom_title) {
2974 - # Remove Open Graph and Twitter title tags (keep main <title>)
2975 - $title_selectors = [
2976 - 'meta[property=og:title]',
2977 - 'meta[name=twitter:title]',
2978 - ];
4309 + # Preserve third-party og:title/twitter:title tags here. They used to be
4310 + # stripped whenever $remove_title was set, but that also removes them
4311 + # when no custom title exists to replace them, leaving pages with no
4312 + # og:title/twitter:title at all. OTTO's own title/OG dedup and
4313 + # precedence passes already reconcile duplicates, so this branch no
4314 + # longer needs to delete those tags.
2979 4315
2980 - foreach ($title_selectors as $selector) {
2981 - $tags = $this->dom->find($selector);
2982 - foreach ($tags as $tag) {
2983 - $tag->outertext = ''; # Remove the tag
2984 - }
2985 - }
2986 - }
2987 -
2988 4316 # Save changes
2989 4317 $this->save_reload();
2990 4318 }
2991 4319
2992 4320 /**
2993 - * Clean OTTO internal fetch query parameters from rendered HTML (WP-315).
4321 + * Clean OTTO internal fetch query parameters from rendered HTML.
2994 4322 *
2995 4323 * @param string $html The rendered HTML
2996 4324 * @return string HTML with OTTO fetch params removed
2997 4325 */
@@ -3005,9 +4333,9 @@
3005 4333 '/[?&]amp;otto_block_desc=1/',
3006 4334 ];
3007 4335 $html = preg_replace($patterns, '', $html);
3008 4336
3009 - # WP-398: Protect <script>/<style> bodies before the URL cleanup below.
4337 + # Protect <script>/<style> bodies before the URL cleanup below.
3010 4338 # The href/src/action matcher can otherwise match JS that looks like an
3011 4339 # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the
3012 4340 # ?? inside it. Swap each block for a placeholder comment, run the cleanup,
3013 4341 # then restore byte-for-byte.
@@ -3021,17 +4349,25 @@
3021 4349 },
3022 4350 $html
3023 4351 );
3024 4352
3025 - # WP-398: Normalize leftover query-string separators (?? / ?& / ?#) created
3026 - # by the param removal above — but ONLY inside URL attributes (href/src/action).
3027 - # The previous implementation ran str_replace('??','?') over the ENTIRE document,
3028 - # which corrupted inline JavaScript nullish operators (?? / ??=) and any literal
3029 - # "??" in page text — breaking Slider Revolution and other modern inline scripts.
4353 + # Normalize leftover query-string separators (?& / ?#) created by the param
4354 + # removal above — but ONLY inside URL attributes (href/src/action).
4355 + #
4356 + # IMPORTANT: never collapse "??". It is valid, load-bearing syntax for
4357 + # file-concatenation endpoints — Jetpack Boost (/_jb_static/??<hash>) and
4358 + # WordPress core (load-styles.php??... / load-scripts.php??...). Collapsing
4359 + # "??" -> "?" points those at a different URL that returns HTTP 400 / empty,
4360 + # so every affected CSS/JS bundle fails to load and the page renders broken
4361 + # (Kadence + Jetpack Boost "defer non-essential CSS" was the reported case).
4362 + # Removing an OTTO fetch param never produces a "??" anyway, so there is
4363 + # nothing to normalize there.
4364 + # (A previous implementation ran str_replace('??','?') over the ENTIRE
4365 + # document, which additionally corrupted inline JavaScript nullish operators.)
3030 4366 $html = preg_replace_callback(
3031 4367 '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is',
3032 4368 function ($m) {
3033 - $url = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $m[4]);
4369 + $url = str_replace(['?&', '?#'], ['?', '#'], $m[4]);
3034 4370 return $m[1] . $m[2] . $m[3] . $url . $m[3];
3035 4371 },
3036 4372 $html
3037 4373 );
@@ -3044,9 +4380,9 @@
3044 4380 return $html;
3045 4381 }
3046 4382
3047 4383 /**
3048 - * Fix Divi 5 shortcode framework class renumbering (WP-315)
4384 + * Fix Divi 5 shortcode framework class renumbering
3049 4385 *
3050 4386 * When Divi 5's shortcode framework loads during the internal HTTP fetch,
3051 4387 * it uses shared counters across all template parts (header + page + footer),
3052 4388 * causing page content element classes to be offset (et_pb_section_0 becomes
@@ -3086,9 +4422,9 @@
3086 4422 # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
3087 4423 # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
3088 4424 # wp_remote_get includes the Theme Builder header template in Divi's counter.
3089 4425 # If the blog module class doesn't match between page 1 (OTTO-processed) and
3090 - # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results (WP-315).
4426 + # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results.
3091 4427 $types = [
3092 4428 'section', 'row', 'column', 'text', 'blurb', 'toggle',
3093 4429 'button', 'image', 'group_carousel',
3094 4430 'blog', 'portfolio', 'filterable_portfolio', 'shop',
@@ -3103,9 +4439,9 @@
3103 4439 # The width fraction is NOT an instance counter; rewriting it (e.g.
3104 4440 # et_pb_column_4_4 → et_pb_column_0_4) strips the column's width rule from
3105 4441 # Divi's static stylesheet (.et_pb_column_4_4{width:100%}) and collapses the
3106 4442 # layout. Bare instance indices (et_pb_column_16, followed by space/quote)
3107 - # still match and renumber as intended (WP-470 / WP-315).
4443 + # still match and renumber as intended.
3108 4444 $instance_lookahead = '(?!_(?:tb_|\d))';
3109 4445
3110 4446 foreach ($types as $type) {
3111 4447 # Find the FIRST numbered instance in page content (not _tb_ or width-fraction)
@@ -3201,5 +4537,5 @@
3201 4537 return $html;
3202 4538 }
3203 4539
3204 4540
3205 -}
4541 +}