PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | otto/Otto_html_class.php +1070 -155 2.6.20 → 2.7.1 View file →
@@ -14,8 +14,13 @@
14 14 */
15 15
16 16 Class Metasync_otto_html{
17 17
18 + # Marker announcing that OTTO processed this <head>. Deliberately a comment
19 + # rather than an attribute on the tag — see mark_head_optimized().
20 + const HEAD_OPTIMIZED_TOKEN = 'metasync_optimized';
21 + const HEAD_OPTIMIZED_MARKER = '<!--metasync_optimized-->';
22 +
18 23 # html dom
19 24 private $dom;
20 25
21 26 # the file path to save to
@@ -126,8 +131,195 @@
126 131 return str_ireplace(array_keys($map), array_values($map), $html);
127 132 }
128 133
129 134 /**
135 + * Quote unquoted attribute values before DOM parsing.
136 + *
137 + * Per the HTML spec an unquoted attribute value ends at whitespace, so
138 + * browsers read this correctly:
139 + *
140 + * data-autoplay-viewport=80%
141 + * data-mousewheel="false"
142 + * data-type="carousel"
143 + *
144 + * SimpleHtmlDom does not stop at the whitespace. It runs to the next quote
145 + * character, so the value becomes
146 + * `80%\n data-mousewheel="false"\n data-type="carousel"...` and EVERY
147 + * following attribute on the tag is swallowed into it and lost.
148 + *
149 + * Supreme Modules' image carousel emits exactly that markup. The attributes
150 + * lost after it include data-type, data-slider-orientation and
151 + * data-slideshow-to-show, so Swiper initialises on defaults, stacks all
152 + * slides and grows the page by one slide-set per autoplay tick until it is
153 + * tens of thousands of pixels tall.
154 + *
155 + * Normalising to `data-autoplay-viewport="80%"` before parsing makes the
156 + * markup unambiguous, so no attribute is lost. Already-quoted values are
157 + * left untouched, and <script>/<style> bodies are shielded so their
158 + * contents are never mistaken for tags.
159 + *
160 + * @param string $html Raw HTML before DOM parsing.
161 + * @return string HTML with every unquoted attribute value quoted.
162 + */
163 + private function quote_unquoted_attribute_values($html) {
164 + # Shield <script>/<style> bodies — their contents can contain '<' and '='
165 + # and must never be treated as markup.
166 + $protected = array();
167 + $html = preg_replace_callback(
168 + '/<(script|style)\b([^>]*)>(.*?)<\/\1>/is',
169 + function ($m) use (&$protected) {
170 + $token = '<!--METASYNC_ATTRQ_' . count($protected) . '-->';
171 + $protected[$token] = $m[0];
172 + return $token;
173 + },
174 + $html
175 + );
176 +
177 + # Walk real tags only. The alternation lets a quoted value contain '>'
178 + # so the tag boundary is found correctly. Possessive quantifiers (*+ / ++)
179 + # are deliberate: the three branches are mutually exclusive on their first
180 + # character, so backtracking can never produce a different match, and
181 + # forbidding it removes the catastrophic-backtracking risk this shape
182 + # otherwise carries on markup with many quotes and no '>'.
183 + $result = preg_replace_callback(
184 + '/<([a-zA-Z][a-zA-Z0-9:-]*+)((?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*+)>/s',
185 + function ($m) {
186 + if (strpos($m[2], '=') === false) {
187 + return $m[0];
188 + }
189 +
190 + $attrs = $this->quote_unquoted_values_in_attr_region($m[2]);
191 +
192 + # null means nothing needed changing — return the ORIGINAL tag so
193 + # a document of well-formed HTML comes back byte-identical rather
194 + # than merely equivalent.
195 + return $attrs === null ? $m[0] : '<' . $m[1] . $attrs . '>';
196 + },
197 + $html
198 + );
199 +
200 + if ($result === null) {
201 + $result = $html;
202 + }
203 +
204 + if (!empty($protected)) {
205 + $result = str_replace(array_keys($protected), array_values($protected), $result);
206 + }
207 +
208 + return $result;
209 + }
210 +
211 + /**
212 + * Quote the unquoted values inside one tag's attribute region.
213 + *
214 + * Must be a tokeniser, not a regex. A flat pattern over the region cannot
215 + * tell an attribute from ordinary text inside an already-quoted value, and
216 + * would wreck common markup — `content="width=device-width,
217 + * initial-scale=1"` looks like it contains an `initial-scale=1` attribute.
218 + * Quoted values are therefore copied through verbatim and never inspected.
219 + *
220 + * @param string $attrs Attribute region: everything between the tag name and '>'.
221 + * @return string|null Rewritten region, or null when nothing needed changing.
222 + */
223 + private function quote_unquoted_values_in_attr_region($attrs) {
224 + $len = strlen($attrs);
225 + $out = '';
226 + $i = 0;
227 + $changed = false;
228 +
229 + while ($i < $len) {
230 + $c = $attrs[$i];
231 +
232 + # Whitespace between attributes — preserve exactly.
233 + if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $c === "\f" || $c === "\v") {
234 + $out .= $c;
235 + $i++;
236 + continue;
237 + }
238 +
239 + # Attribute name.
240 + if (preg_match('/\G[a-zA-Z_:][a-zA-Z0-9_:.\-]*+/', $attrs, $nm, 0, $i)) {
241 + $out .= $nm[0];
242 + $i += strlen($nm[0]);
243 +
244 + # Look past optional whitespace for '='. No '=' means a valueless
245 + # boolean attribute; leave it alone.
246 + $j = $i;
247 + while ($j < $len && strpos(" \t\n\r\f\v", $attrs[$j]) !== false) {
248 + $j++;
249 + }
250 + if ($j >= $len || $attrs[$j] !== '=') {
251 + continue;
252 + }
253 +
254 + $out .= substr($attrs, $i, $j - $i) . '=';
255 + $i = $j + 1;
256 +
257 + # Whitespace after '='.
258 + while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) !== false) {
259 + $out .= $attrs[$i];
260 + $i++;
261 + }
262 +
263 + if ($i >= $len) {
264 + continue;
265 + }
266 +
267 + # Quoted value — copy verbatim through the closing quote so its
268 + # contents are never treated as attributes.
269 + if ($attrs[$i] === '"' || $attrs[$i] === "'") {
270 + $quote = $attrs[$i];
271 + $end = strpos($attrs, $quote, $i + 1);
272 + if ($end === false) {
273 + $out .= substr($attrs, $i);
274 + $i = $len;
275 + } else {
276 + $out .= substr($attrs, $i, $end - $i + 1);
277 + $i = $end + 1;
278 + }
279 + continue;
280 + }
281 +
282 + # Unquoted value — runs to the next whitespace. ('>' cannot occur:
283 + # it would have ended the tag before this region.)
284 + $start = $i;
285 + while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) === false) {
286 + $i++;
287 + }
288 + $value = substr($attrs, $start, $i - $start);
289 +
290 + if ($value === '') {
291 + continue;
292 + }
293 +
294 + # Only rewrite when it actually prevents a loss. SimpleHtmlDom
295 + # mis-parses an unquoted value by running from it to the NEXT
296 + # QUOTE CHARACTER, swallowing everything between. With no quote
297 + # left in this region there is nothing to swallow and the value
298 + # already parses correctly — so leave that markup byte-identical
299 + # rather than churn it. (OceanWP's `style=color:#;` as the final
300 + # attribute is the common benign case.)
301 + $rest = substr($attrs, $i);
302 + if (strpos($rest, '"') === false && strpos($rest, "'") === false) {
303 + $out .= $value;
304 + continue;
305 + }
306 +
307 + $out .= '"' . str_replace('"', '&quot;', $value) . '"';
308 + $changed = true;
309 + continue;
310 + }
311 +
312 + # Anything else (stray punctuation, a '/' before '>', malformed input)
313 + # — copy through untouched.
314 + $out .= $c;
315 + $i++;
316 + }
317 +
318 + return $changed ? $out : null;
319 + }
320 +
321 + /**
130 322 * / Capture ALL <style> blocks before DOM processing.
131 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).
@@ -185,10 +377,68 @@
185 377 return $html;
186 378 }
187 379
188 380 /**
189 - * 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>
@@ -318,8 +568,52 @@
318 568 return $out === null ? $html : $out;
319 569 }
320 570
321 571 /**
572 + * Mark the <head> as OTTO-processed without altering the <head> tag itself.
573 + *
574 + * This marker used to be an attribute — <head metasync_optimized> — which
575 + * removed the literal string "<head>" from the document. Anything that
576 + * injects into the head with a literal str_replace('<head>', ...) then found
577 + * no match on the real tag and silently matched the *next* literal "<head>"
578 + * further down the page. That next one can sit inside a JS comment, a <pre>
579 + * block or escaped documentation text; injecting a <script>...</script> there
580 + * closes the enclosing script early, strands the rest of its code as a text
581 + * node in <head>, and the browser therefore ends <head> at that point —
582 + * pushing title, canonical, OG/Twitter and JSON-LD into <body>, where
583 + * crawlers and social scrapers ignore them.
584 + *
585 + * A comment placed immediately after the tag carries the same signal and
586 + * leaves the tag byte-identical. Note that an HTML minifier may strip
587 + * comments, so treat the <meta name="otto"> tag as the durable marker; this
588 + * one is a convenience for support and QA.
589 + *
590 + * Only the first <head> is marked. The real tag is always the first one in
591 + * the document, so this pass cannot wander the way a bare literal match can.
592 + *
593 + * @param string $html Serialized HTML.
594 + * @return string
595 + */
596 + private function mark_head_optimized($html)
597 + {
598 + # Idempotent, and also a no-op on pages still carrying the legacy
599 + # attribute form (e.g. HTML already in a page cache).
600 + if ($html === '' || strpos($html, self::HEAD_OPTIMIZED_TOKEN) !== false) {
601 + return $html;
602 + }
603 +
604 + $out = preg_replace(
605 + '/(<head(?:\s[^>]*)?>)/i',
606 + '$1' . self::HEAD_OPTIMIZED_MARKER,
607 + $html,
608 + 1
609 + );
610 +
611 + # Belt-and-suspenders: if PCRE fails for any reason, keep the input.
612 + return $out === null ? $html : $out;
613 + }
614 +
615 + /**
322 616 * Apply OTTO's canonical via string replacement (reliable fallback).
323 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
@@ -491,9 +785,9 @@
491 785 $response = wp_remote_get($url_with_params, $args);
492 786
493 787 # Check for errors
494 788 if (is_wp_error($response)) {
495 - 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
496 790 return false;
497 791 }
498 792
499 793 # get the response body
@@ -503,9 +797,9 @@
503 797 $response_code = wp_remote_retrieve_response_code($response);
504 798
505 799 # if no change data skip
506 800 if (empty($body) || $response_code !== 200){
507 - 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
508 802 return false;
509 803 }
510 804
511 805 # set the html file path
@@ -698,9 +992,11 @@
698 992
699 993 # Check for timeout or connection errors
700 994 if (is_wp_error($route_html)) {
701 995 $error_msg = $route_html->get_error_message();
702 - 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 + }
703 999 return false;
704 1000 }
705 1001
706 1002 # get body
@@ -708,12 +1004,51 @@
708 1004
709 1005 # Get the response code
710 1006 $response_code = wp_remote_retrieve_response_code($route_html);
711 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 + }
712 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 +
713 1046 # check not empty
714 1047 if(empty($html_body) || $response_code !== 200){
715 - 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 + }
716 1051 return false;
717 1052 }
718 1053
719 1054 # Skip DOM processing on oversized documents to avoid fatal OOM.
@@ -744,8 +1079,12 @@
744 1079 # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
745 1080 # before DOM parsing — SimpleHtmlDom strips attributes from these.
746 1081 $html_body = $this->fix_malformed_self_closing_tags($html_body);
747 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 +
748 1087 # now that the html is not empty
749 1088 # load it into the simple html dom
750 1089 $this->dom->load($html_body, true, false);
751 1090
@@ -873,13 +1212,19 @@
873 1212
874 1213 $result_html = preg_replace_callback(
875 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
876 1215 function ($m) use ($current_value, $recommended_value) {
877 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
878 - if ($inner_text === $current_value) {
879 - 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];
880 1219 }
881 - 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];
882 1227 },
883 1228 $result_html,
884 1229 -1
885 1230 );
@@ -891,15 +1236,16 @@
891 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');
892 1237
893 1238 # Apply insertions — only if DOM insertion didn't already apply it
894 1239 if (!empty($replacement_data['header_html_insertion'])) {
895 - $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);
896 1247 if (strpos($result_html, $header_html_check) === false) {
897 - $header_html_insertion = preg_replace(
898 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
899 - '<script$1type=$2application/ld+json$2 data-otto="true"',
900 - $replacement_data['header_html_insertion']
901 - );
902 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
903 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
904 1250 }
905 1251 }
@@ -933,11 +1279,11 @@
933 1279 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
934 1280 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
935 1281 $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
936 1282
937 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
938 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
939 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
1283 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
1284 + if (!$this->is_amp_page()) {
1285 + $result_html = $this->mark_head_optimized($result_html);
940 1286 }
941 1287
942 1288 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
943 1289
@@ -966,8 +1312,12 @@
966 1312 # undo double-encoded numeric/hex character references produced by the
967 1313 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
968 1314 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
969 1315
1316 + # restore literal '&' query-string separators the serializer escaped to
1317 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
1318 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
1319 +
970 1320 # Remove any lazy-video facade <style> hoisted into the page
971 1321 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
972 1322 $result_html = $this->strip_hoisted_facade_styles($result_html);
973 1323
@@ -985,8 +1335,111 @@
985 1335 return $result_html;
986 1336 }
987 1337
988 1338 /**
1339 + * Read the final URL from the WordPress HTTP response when Requests has
1340 + * followed one or more redirects. Older HTTP transports may not expose it;
1341 + * in that case the empty result leaves the existing fail-open behavior.
1342 + *
1343 + * @param mixed $response wp_remote_get() response.
1344 + * @return string Final URL, or an empty string when unavailable.
1345 + */
1346 + private function get_effective_fetch_url($response)
1347 + {
1348 + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
1349 + return '';
1350 + }
1351 +
1352 + $http_response = $response['http_response'];
1353 + if (!method_exists($http_response, 'get_response_object')) {
1354 + return '';
1355 + }
1356 +
1357 + $requests_response = $http_response->get_response_object();
1358 + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
1359 + return '';
1360 + }
1361 +
1362 + return $requests_response->url;
1363 + }
1364 +
1365 + /**
1366 + * Determine whether a followed response still represents the requested
1367 + * route. The internal fetch may use a rewritten host for tunnel support,
1368 + * so both the original and transport hosts are accepted; the path and
1369 + * non-OTTO query parameters must remain identical.
1370 + *
1371 + * @param string $effective_url Final URL reported by Requests.
1372 + * @param string $route Original route supplied to OTTO.
1373 + * @param string $fetch_url URL actually passed to wp_remote_get().
1374 + * @return bool True when no meaningful redirect changed the route.
1375 + */
1376 + private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1377 + {
1378 + $effective = wp_parse_url($effective_url);
1379 + $requested = wp_parse_url($route);
1380 + $transport = wp_parse_url($fetch_url);
1381 + if (!is_array($effective) || !is_array($requested)) {
1382 + return true;
1383 + }
1384 +
1385 + $effective_path = isset($effective['path']) ? $effective['path'] : '/';
1386 + $requested_path = isset($requested['path']) ? $requested['path'] : '/';
1387 + if ($effective_path !== $requested_path) {
1388 + return false;
1389 + }
1390 +
1391 + $allowed_hosts = [];
1392 + foreach ([$requested, $transport] as $parts) {
1393 + if (!empty($parts['host'])) {
1394 + $host = strtolower($parts['host']);
1395 + if (!empty($parts['port'])) {
1396 + $host .= ':' . $parts['port'];
1397 + }
1398 + $allowed_hosts[] = $host;
1399 + }
1400 + }
1401 + if (!empty($effective['host'])) {
1402 + $effective_host = strtolower($effective['host']);
1403 + if (!empty($effective['port'])) {
1404 + $effective_host .= ':' . $effective['port'];
1405 + }
1406 + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
1407 + return false;
1408 + }
1409 + }
1410 +
1411 + return $this->without_otto_fetch_params($effective['query'] ?? '')
1412 + === $this->without_otto_fetch_params($requested['query'] ?? '');
1413 + }
1414 +
1415 + /**
1416 + * Remove parameters used only to mark OTTO's internal request.
1417 + *
1418 + * @param string $query Query string without the leading '?'.
1419 + * @return string Query with OTTO markers removed.
1420 + */
1421 + private function without_otto_fetch_params($query)
1422 + {
1423 + $query = (string) $query;
1424 + if ($query === '') {
1425 + return '';
1426 + }
1427 +
1428 + $parts = [];
1429 + foreach (explode('&', $query) as $part) {
1430 + $name = strstr($part, '=', true);
1431 + $name = $name === false ? $part : $name;
1432 + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
1433 + continue;
1434 + }
1435 + $parts[] = $part;
1436 + }
1437 +
1438 + return implode('&', $parts);
1439 + }
1440 +
1441 + /**
989 1442 * Repair double-encoded character references in serialized HTML.
990 1443 *
991 1444 * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
992 1445 * $this->dom->save() and $root->outertext) runs every attribute value through
@@ -1009,8 +1462,118 @@
1009 1462 }
1010 1463 return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
1011 1464 }
1012 1465
1466 + /**
1467 + * Restore literal '&' query-string separators inside URL attributes.
1468 + *
1469 + * HtmlNode::makeup() serialises every attribute through htmlentities(), so a
1470 + * separator that arrived as a bare '&' - or as '&#038;', which HtmlDocument
1471 + * decodes to '&' when it parses the attribute - is written back as '&amp;'.
1472 + * Both forms decode to '&' in a conforming HTML parser, so the browser is
1473 + * unaffected. A consumer that reads the raw attribute WITHOUT decoding
1474 + * entities sees a different URL, and Cloudflare Fonts is one of those: it
1475 + * refetches fonts.googleapis.com/css2?family=A&amp;family=B, Google treats
1476 + * only the first 'family=' as a real parameter (the rest arrive named
1477 + * 'amp;family'), and every font after the first is dropped from the response.
1478 + * The page keeps its font-family declarations but loses the @font-face rules,
1479 + * so text silently falls back to a system font.
1480 + *
1481 + * Rewrites only a '&amp;' that is immediately followed by a parameter name
1482 + * and '=', and only inside the listed URL attributes. A '&amp;' elsewhere is
1483 + * left escaped, and the mandatory '=' in the lookahead (with ';' excluded
1484 + * from the name class) means no ambiguous ampersand ('&' + alphanumerics +
1485 + * ';') is ever produced.
1486 + *
1487 + * All three delimiter forms the serializer can emit are handled: double
1488 + * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and
1489 + * emits no delimiter for a value with no whitespace, which is common on
1490 + * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted
1491 + * value may legitimately contain an apostrophe.
1492 + *
1493 + * The attribute list stops at attributes whose value is always a URL.
1494 + * 'content' is deliberately excluded because it also carries prose
1495 + * (<meta name="description" content="A &amp; B">), where unescaping would
1496 + * corrupt visible text. Attributes read only via JavaScript getAttribute()
1497 + * do not need it either - the DOM decodes entities for them; only consumers
1498 + * that scan raw HTML are affected.
1499 + *
1500 + * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded
1501 + * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes
1502 + * there would edit a JS string literal, or turn "&amp;copy=2" in a textarea
1503 + * into "(c)=2" once the browser decodes it.
1504 + *
1505 + * Plain negated character classes only - no tempered-greedy lookaheads, which
1506 + * blow the PCRE JIT stack on pages with very large inline blocks.
1507 + *
1508 + * @param string $html Serialized HTML from save()/outertext.
1509 + * @return string
1510 + */
1511 + private function repair_query_string_ampersands($html) {
1512 + # strpos guard: skip the regex entirely when there is nothing to repair
1513 + if (strpos($html, '&amp;') === false) {
1514 + return $html;
1515 + }
1516 +
1517 + # Shield verbatim / RCDATA regions and comments before touching anything.
1518 + # Masking also shrinks large inline blocks to a token, so the attribute
1519 + # pass never walks them.
1520 + $shielded = array();
1521 + $masked = preg_replace_callback(
1522 + '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is',
1523 + function ($m) use (&$shielded) {
1524 + $token = '<!--METASYNC_QSA_' . count($shielded) . '-->';
1525 + $shielded[$token] = $m[0];
1526 + return $token;
1527 + },
1528 + $html
1529 + );
1530 +
1531 + # preg_* returns null on a PCRE failure; keep the original document.
1532 + if ($masked === null) {
1533 + return $html;
1534 + }
1535 +
1536 + $repaired = preg_replace_callback(
1537 + '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i',
1538 + function ($m) {
1539 + # Pick the delimiter branch that matched. An empty value carries
1540 + # no query string, so the ambiguity between an empty quoted value
1541 + # and an unset group cannot change the outcome.
1542 + if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones)
1543 + $quote = '';
1544 + $value = $m[4];
1545 + } elseif (isset($m[3]) && $m[3] !== '') {
1546 + $quote = "'";
1547 + $value = $m[3];
1548 + } else {
1549 + $quote = '"';
1550 + $value = isset($m[2]) ? $m[2] : '';
1551 + }
1552 +
1553 + # Only a value carrying a query string can hold separators.
1554 + if (strpos($value, '?') === false || strpos($value, '&amp;') === false) {
1555 + return $m[0];
1556 + }
1557 +
1558 + $fixed = preg_replace('/&amp;(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value);
1559 +
1560 + if ($fixed === null) {
1561 + return $m[0];
1562 + }
1563 +
1564 + return $m[1] . $quote . $fixed . $quote;
1565 + },
1566 + $masked
1567 + );
1568 +
1569 + if ($repaired === null) {
1570 + return $html;
1571 + }
1572 +
1573 + return empty($shielded) ? $repaired : strtr($repaired, $shielded);
1574 + }
1575 +
1013 1576 # do the footer html insertion
1014 1577 function do_footer_html_insertion($replacement_data){
1015 1578
1016 1579 # check that we have footer html
@@ -1414,15 +1977,28 @@
1414 1977
1415 1978 # check matching text
1416 1979 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
1417 1980
1418 - # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
1981 + # Rebuild the inner HTML, keeping the author's inline
1982 + # formatting. Returns null when the markup can't be preserved
1983 + # unambiguously — leave that heading alone rather than strip
1984 + # its <span style>/<em>/<br> and restyle the page.
1985 + $new_inner = self::build_heading_inner_html(
1986 + $heading_old->innertext,
1987 + $heading['recommended_value']
1988 + );
1989 +
1990 + if ($new_inner === null) {
1991 + continue;
1992 + }
1993 +
1994 + # replace the tag output, preserving the opening tag's attributes
1419 1995 $outer = $heading_old->outertext;
1420 1996 $open_end = strpos($outer, '>');
1421 1997 if ($open_end !== false) {
1422 1998 $open_tag = substr($outer, 0, $open_end + 1);
1423 1999 $close_tag = '</' . $heading['type'] . '>';
1424 - $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
2000 + $heading_old->outertext = $open_tag . $new_inner . $close_tag;
1425 2001 }
1426 2002
1427 2003 }
1428 2004 }
@@ -1524,10 +2100,10 @@
1524 2100 }
1525 2101
1526 2102 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1527 2103 if (strpos($url, '//') === 0) {
1528 - $parsed_home = parse_url($home_url);
1529 - $parsed_link = parse_url($url);
2104 + $parsed_home = wp_parse_url($home_url);
2105 + $parsed_link = wp_parse_url($url);
1530 2106
1531 2107 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1532 2108 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1533 2109 return false;
@@ -1649,20 +2225,22 @@
1649 2225 return $post_id ? (int) $post_id : 0;
1650 2226 }
1651 2227
1652 2228 /**
1653 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
1654 - * data-metasync-seo="custom"); insert one after <head> if none is present.
1655 - * Helper for apply_custom_seo_precedence().
2229 + * Replace the first <meta $attr="$val"> content with $content_escaped.
1656 2230 *
1657 2231 * @param string $html
1658 2232 * @param string $attr "name" or "property"
1659 2233 * @param string $val e.g. "description", "og:description"
1660 2234 * @param string $content_escaped Already HTML-escaped content value
2235 + * @param string $marker Provenance marker for the winning tier
1661 2236 * @return string
1662 2237 */
1663 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
1664 - $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '" data-metasync-seo="custom" />';
2238 + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
2239 + $marker_attribute = $marker === 'otto'
2240 + ? ' data-metasync-otto="true"'
2241 + : ' data-metasync-seo="custom"';
2242 + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
1665 2243 $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
1666 2244 $count = 0;
1667 2245 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
1668 2246 return $tag;
@@ -1676,23 +2254,36 @@
1676 2254 }, $html, 1);
1677 2255 }
1678 2256
1679 2257 /**
1680 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2258 + * Make sure the precedence resolver is loaded before calling it.
1681 2259 *
1682 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
1683 - * OTTO for these fields, but later steps re-introduce OTTO's value:
1684 - * - the manual string-replacement title pass re-applies OTTO's title;
1685 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
1686 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
1687 - * exactly the case when the post is synced to a third-party SEO plugin and
1688 - * the sidebar deferred emitting its own tag (output_seo_meta_description()).
2260 + * @return bool
2261 + */
2262 + private static function precedence_available() {
2263 + if (class_exists('Metasync_Seo_Precedence')) {
2264 + return true;
2265 + }
2266 +
2267 + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
2268 + if (is_readable($file)) {
2269 + require_once $file;
2270 + }
2271 +
2272 + return class_exists('Metasync_Seo_Precedence');
2273 + }
2274 +
2275 + /**
2276 + * Enforce the resolved SEO title/description as the FINAL word over OTTO.
1689 2277 *
1690 - * Running LAST — after all dedup/precedence steps — this makes the user's
1691 - * explicit sidebar value authoritative regardless of how the tag was produced,
1692 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
1693 - * only; a value must be non-empty (explicitly set) to take precedence.
2278 + * The resolver owns the global custom-versus-OTTO order. This final buffer
2279 + * pass must use it too: otherwise the setting works through the sidebar
2280 + * and third-party filters but OTTO's later string pass puts the custom
2281 + * value back on the page.
1694 2282 *
2283 + * OG and Twitter descriptions remain outside this method deliberately;
2284 + * social precedence is a separate follow-up scope.
2285 + *
1695 2286 * @param string $html Full HTML document (post-dedup).
1696 2287 * @return string
1697 2288 */
1698 2289 private function apply_custom_seo_precedence($html) {
@@ -1703,12 +2294,26 @@
1703 2294 if (!$post_id) {
1704 2295 return $html;
1705 2296 }
1706 2297
1707 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
1708 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
1709 - if (!empty($custom_title) && is_string($custom_title)) {
1710 - $title_tag = '<title>' . htmlspecialchars($custom_title, ENT_QUOTES, 'UTF-8') . '</title>';
2298 + # This runs inside the output buffer, where an unresolvable class is a
2299 + # fatal that takes the whole page down — otto_guard_html() only catches
2300 + # a NULL return, not a thrown Error. A partial update can leave newer
2301 + # PHP files beside an older committed autoload classmap, so resolve the
2302 + # file directly before trusting the class, and leave the document
2303 + # untouched if it is genuinely absent. Same reasoning as
2304 + # Metasync_SEO_Conflict_Handler::precedence_available().
2305 + if (!self::precedence_available()) {
2306 + return $html;
2307 + }
2308 +
2309 + # Title — replace the resolved page title into the (single, post-dedup) <title>.
2310 + $resolved_title = Metasync_Seo_Precedence::resolve(
2311 + $post_id,
2312 + Metasync_Seo_Precedence::FIELD_TITLE
2313 + );
2314 + if (!empty($resolved_title['value'])) {
2315 + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
1711 2316 $count = 0;
1712 2317 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
1713 2318 return $title_tag;
1714 2319 }, $html, 1, $count);
@@ -1720,17 +2325,25 @@
1720 2325 }, $html, 1);
1721 2326 }
1722 2327 }
1723 2328
1724 - # Description — force the custom sidebar description into the SEO
1725 - # <meta name="description"> only. og:/twitter: description are social tags
1726 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
1727 - # earlier); this pass must NOT touch them, or it would override a
1728 - # separately user-set og:description with the plain SEO description.
1729 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
1730 - if (!empty($custom_desc) && is_string($custom_desc)) {
1731 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
1732 - $html = $this->force_custom_meta($html, 'name', 'description', $esc);
2329 + # Description — replace the resolved page description into the SEO
2330 + # <meta name="description"> only. OG/Twitter description is social
2331 + # precedence and remains outside this setting's scope.
2332 + $resolved_description = Metasync_Seo_Precedence::resolve(
2333 + $post_id,
2334 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2335 + );
2336 + if (!empty($resolved_description['value'])) {
2337 + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
2338 + $marker = Metasync_Seo_Precedence::is_otto_value(
2339 + $post_id,
2340 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2341 + $resolved_description
2342 + )
2343 + ? 'otto'
2344 + : 'custom';
2345 + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
1733 2346 }
1734 2347
1735 2348 return $html;
1736 2349 }
@@ -2136,8 +2749,34 @@
2136 2749 return $html;
2137 2750 }
2138 2751
2139 2752 /**
2753 + * Read one of the meta box's social title/description keys with the "Auto Draft"
2754 + * placeholder collapsed to ''.
2755 + *
2756 + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
2757 + * place. Falls back to a raw read when that class — or that method on it — is
2758 + * unavailable: this buffer filter runs on the front end, and a partially updated
2759 + * install can pair a newer otto/ file with an older includes/ one, where calling
2760 + * a method the loaded class doesn't define would fatal the page rather than
2761 + * degrade.
2762 + *
2763 + * @param int $post_id
2764 + * @param string $key
2765 + * @return string
2766 + */
2767 + private function social_meta($post_id, $key) {
2768 + // PHPStan resolves the literal class-string against the current source and so
2769 + // sees the method as always present; the check is deliberate runtime
2770 + // version-skew defence. Suppressed the same way otto_pixel.php does.
2771 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2772 + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
2773 + return Metasync_OpenGraph::get_social_meta($post_id, $key);
2774 + }
2775 + return (string) get_post_meta($post_id, $key, true);
2776 + }
2777 +
2778 + /**
2140 2779 * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
2141 2780 *
2142 2781 * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
2143 2782 * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
@@ -2176,16 +2815,22 @@
2176 2815 }
2177 2816
2178 2817 # Explicit (user-typed) meta box values. A non-empty value means the user set
2179 2818 # this field themselves and it must take priority over OTTO.
2180 - $og_title_set = get_post_meta($post_id, '_metasync_og_title', true);
2181 - $og_desc_set = get_post_meta($post_id, '_metasync_og_description', true);
2819 + #
2820 + # The four social title/description keys are read through
2821 + # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
2822 + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
2823 + # brand-new post reads as a deliberate override here (it differs from the real
2824 + # title) and would beat OTTO's correct og:title.
2825 + $og_title_set = $this->social_meta($post_id, '_metasync_og_title');
2826 + $og_desc_set = $this->social_meta($post_id, '_metasync_og_description');
2182 2827 $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2183 2828 $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
2184 2829 $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true);
2185 2830 $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', true);
2186 - $tw_title_set = get_post_meta($post_id, '_metasync_twitter_title', true);
2187 - $tw_desc_set = get_post_meta($post_id, '_metasync_twitter_description', true);
2831 + $tw_title_set = $this->social_meta($post_id, '_metasync_twitter_title');
2832 + $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description');
2188 2833 $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2189 2834 $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2190 2835
2191 2836 # Default OG values the meta box pre-fills (post title / generated excerpt /
@@ -2278,8 +2923,18 @@
2278 2923 }
2279 2924 }
2280 2925
2281 2926 # 2) Build additions: overrides (removed above) + gap-fills for anything still absent.
2927 + #
2928 + # When a third-party SEO plugin is active the conflict handler may have suppressed
2929 + # that plugin's og:title / twitter:title so MetaSync can supply the replacement
2930 + # (Metasync_SEO_Sidebar::output_seo_meta_description). Gap-filling those two tags
2931 + # here would inject the meta-box post-title default over that replacement, so skip
2932 + # them in that case. User-customized overrides are unaffected — they were already
2933 + # applied above.
2934 + $seo_plugin_active = class_exists('Metasync_SEO_Conflict_Handler')
2935 + && Metasync_SEO_Conflict_Handler::get_instance()->has_active_seo_plugin();
2936 +
2282 2937 $additions = '';
2283 2938 foreach ($tags as $t) {
2284 2939 list($attr, $key, $val, $is_url, $is_override) = $t;
2285 2940 if ($val === '' || $val === null) {
@@ -2284,8 +2939,11 @@
2284 2939 list($attr, $key, $val, $is_url, $is_override) = $t;
2285 2940 if ($val === '' || $val === null) {
2286 2941 continue;
2287 2942 }
2943 + if (!$is_override && $seo_plugin_active && ($key === 'og:title' || $key === 'twitter:title')) {
2944 + continue; # never gap-fill the post title over a third-party plugin's own title
2945 + }
2288 2946 if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) {
2289 2947 continue; # OTTO (or another source) already provides it and the user did not override
2290 2948 }
2291 2949 $content = $is_url ? esc_url($val) : esc_attr($val);
@@ -2474,96 +3132,301 @@
2474 3132 return $html;
2475 3133 }
2476 3134
2477 3135 /**
3136 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3137 + *
3138 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3139 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3140 + * Collapsing such a node to its first token hides it from a plain-typed
3141 + * duplicate, so every token is returned and indexed separately.
3142 + *
3143 + * @param mixed $raw The raw `@type` value.
3144 + * @return string[] Unique lowercased tokens, possibly empty.
3145 + */
3146 + private function schema_type_tokens($raw) {
3147 + $tokens = [];
3148 +
3149 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3150 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3151 + continue;
3152 + }
3153 + $type = strtolower(trim((string) $type));
3154 + if ($type === '') {
3155 + continue;
3156 + }
3157 + $tokens[$type] = true;
3158 + }
3159 +
3160 + return array_keys($tokens);
3161 + }
3162 +
3163 + /**
2478 3164 * Deduplicate JSON-LD schema blocks.
2479 3165 *
2480 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
2481 - * blocks, keep OTTO's version for any @type that appears in both.
2482 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3166 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3167 + * token the page already carries. Collisions resolve strictly in OTTO's
3168 + * favour:
2483 3169 *
3170 + * - an OTTO node (marked `data-otto`) displaces every colliding
3171 + * third-party node, whichever appeared first in the document;
3172 + * - two OTTO nodes describing the same entity keep the first;
3173 + * - two third-party nodes never evict each other, even when they share a
3174 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3175 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3176 + * output this pass has no mandate to fold.
3177 + *
3178 + * When OTTO contributed no node at all the document comes back untouched —
3179 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3180 + * into the original document, so a block that loses nothing keeps its exact
3181 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3182 + * and nothing is ever relocated.
3183 + *
3184 + * Blocks are left exactly where they are when their JSON does not parse,
3185 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3186 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3187 + * it in a comment must stay disabled, not be re-published by this pass.
3188 + *
2484 3189 * @param string $html Full HTML.
2485 3190 * @return string
2486 3191 */
2487 3192 private function deduplicate_schema_tags($html) {
2488 - // Find all JSON-LD script blocks
2489 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
2490 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3193 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3194 + // capturing only the part before `type=` would hide a `data-otto` marker
3195 + // that sits after it, which is where every insertion path puts it. The
3196 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3197 + // is recognised too.
3198 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3199 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
2491 3200 return $html;
2492 3201 }
2493 3202
2494 - $otto_by_type = []; // @type => decoded JSON object
2495 - $third_by_type = []; // @type => decoded JSON object
2496 - $otto_graph = []; // entries from OTTO @graph blocks
2497 - $third_graph = []; // entries from third-party @graph blocks
3203 + // Byte ranges this pass must not reach into. A commented-out (or
3204 + // <noscript>-wrapped) schema block is deliberately disabled output.
3205 + $masked = [];
3206 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3207 + foreach ($comments as $comment) {
3208 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3209 + }
3210 + }
2498 3211
3212 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3213 + // `data-otto`. A substring test would also catch a hypothetical
3214 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3215 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3216 +
3217 + // Blocks able to take part, and their entities in document order. An
3218 + // entity is one addressable node: a flat block's document, one
3219 + // `@graph` entry, or one node of a top-level array.
3220 + $blocks = [];
3221 + $entities = [];
3222 +
2499 3223 foreach ($matches as $m) {
2500 - $attrs = $m[1];
2501 - $json_str = $m[3];
3224 + $start = $m[0][1];
3225 + foreach ($masked as $range) {
3226 + if ($start >= $range[0] && $start < $range[1]) {
3227 + continue 2;
3228 + }
3229 + }
3230 +
3231 + $attrs = $m[1][0];
3232 + $json_str = $m[3][0];
2502 3233 $decoded = json_decode($json_str, true);
2503 3234 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
2504 - continue; // skip unparseable blocks — leave them in place
3235 + continue; // unparseable — leave the block in place
2505 3236 }
2506 - $is_otto = stripos($attrs, 'data-otto') !== false;
2507 3237
3238 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3239 + $end = $start + strlen($m[0][0]);
3240 +
3241 + // A trailing newline swallowed on full removal keeps the output tidy.
3242 + $end_ws = $end;
3243 + if (substr($html, $end, 2) === "\r\n") {
3244 + $end_ws = $end + 2;
3245 + } elseif (substr($html, $end, 1) === "\n") {
3246 + $end_ws = $end + 1;
3247 + }
3248 +
3249 + $register = function (array $node) use (&$entities, $is_otto) {
3250 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3251 + // @type tokens for the type fold. Entries with neither stay in
3252 + // their block but cannot collide, so they keep entity === null.
3253 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3254 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3255 + if ($types === [] && $id === '') {
3256 + return null;
3257 + }
3258 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3259 + return count($entities) - 1;
3260 + };
3261 +
3262 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3263 +
2508 3264 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
2509 - foreach ($decoded['@graph'] as $entry) {
2510 - if (!isset($entry['@type'])) continue;
2511 - // JSON-LD allows @type to be a string OR an array of strings.
2512 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
2513 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
2514 - $type = is_array($entry['@type'])
2515 - ? (string) reset($entry['@type'])
2516 - : (string) $entry['@type'];
2517 - if ($type === '') continue;
2518 - if ($is_otto) {
2519 - $otto_graph[$type] = $entry;
2520 - } else {
2521 - $third_graph[$type] = $entry;
2522 - }
3265 + foreach ($decoded['@graph'] as $node) {
3266 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
2523 3267 }
3268 + $block['shape'] = 'graph';
2524 3269 } elseif (isset($decoded['@type'])) {
2525 - $type = is_array($decoded['@type'])
2526 - ? (string) reset($decoded['@type'])
2527 - : (string) $decoded['@type'];
2528 - if ($type === '') continue;
2529 - if ($is_otto) {
2530 - $otto_by_type[$type] = $decoded;
3270 + $block['shape'] = 'flat';
3271 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3272 + } elseif (isset($decoded[0])) {
3273 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3274 + // persistence path already folds this shape; the render path
3275 + // used to skip it entirely, leaving its duplicates standing.
3276 + $block['shape'] = 'list';
3277 + foreach ($decoded as $node) {
3278 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3279 + }
3280 + } else {
3281 + continue; // neither @type nor @graph nor array — nothing to key on
3282 + }
3283 +
3284 + $blocks[] = $block;
3285 + }
3286 +
3287 + foreach ($entities as $entity) {
3288 + if ($entity['otto']) {
3289 + $otto_present = true;
3290 + break;
3291 + }
3292 + }
3293 + if (empty($otto_present)) {
3294 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3295 + }
3296 +
3297 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3298 + // node can displace a whole set of third-party duplicates at once.
3299 + /** @var array<string, int> $by_id */
3300 + $by_id = []; // '@id:...' => entity index
3301 + /** @var array<string, int[]> $by_type */
3302 + $by_type = []; // type token => entity index[]
3303 +
3304 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3305 + foreach ($entities[$i]['types'] as $token) {
3306 + $holders = $by_type[$token] ?? [];
3307 + $holders = array_values(array_diff($holders, [$i]));
3308 + if ($holders === []) {
3309 + unset($by_type[$token]);
2531 3310 } else {
2532 - $third_by_type[$type] = $decoded;
3311 + $by_type[$token] = $holders;
2533 3312 }
2534 3313 }
3314 + $id = $entities[$i]['id'];
3315 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3316 + unset($by_id[$id]);
3317 + }
3318 + };
3319 +
3320 + foreach ($entities as $i => $entity) {
3321 + $alive = true;
3322 +
3323 + // The same real-world entity twice, whatever @type each copy carries.
3324 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3325 + $occupant = $by_id[$entity['id']];
3326 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3327 + $entities[$occupant]['alive'] = false;
3328 + $release($occupant);
3329 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3330 + } else {
3331 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3332 + }
3333 + }
3334 +
3335 + if ($alive) {
3336 + foreach ($entity['types'] as $token) {
3337 + $holders = $by_type[$token] ?? [];
3338 + if ($holders === []) {
3339 + continue;
3340 + }
3341 + $otto_holder = false;
3342 + foreach ($holders as $holder) {
3343 + if ($entities[$holder]['otto']) {
3344 + $otto_holder = true;
3345 + break;
3346 + }
3347 + }
3348 + if ($otto_holder) {
3349 + $alive = false; // an OTTO node already holds this token
3350 + break;
3351 + }
3352 + if ($entity['otto']) {
3353 + // OTTO displaces every third-party holder of the token and
3354 + // releases ALL their claims, not just this one — otherwise
3355 + // tokens the evicted node held alone would stay blocked and
3356 + // later, legitimate nodes of that type would lose.
3357 + foreach ($holders as $holder) {
3358 + $entities[$holder]['alive'] = false;
3359 + $release($holder);
3360 + }
3361 + }
3362 + // Third party meeting third-party holders: both stand.
3363 + }
3364 + }
3365 +
3366 + if ($alive) {
3367 + foreach ($entity['types'] as $token) {
3368 + $by_type[$token][] = $i;
3369 + }
3370 + if ($entity['id'] !== '') {
3371 + $by_id[$entity['id']] = $i;
3372 + }
3373 + } else {
3374 + $entities[$i]['alive'] = false;
3375 + }
2535 3376 }
2536 3377
2537 - // If OTTO provided no schema at all, nothing to deduplicate
2538 - if (empty($otto_by_type) && empty($otto_graph)) {
2539 - return $html;
3378 + $any_evicted = false;
3379 + foreach ($entities as $entity) {
3380 + if (!$entity['alive']) {
3381 + $any_evicted = true;
3382 + break;
3383 + }
2540 3384 }
3385 + if (!$any_evicted) {
3386 + return $html; // no collision — every original byte stays
3387 + }
2541 3388
2542 - // Remove all JSON-LD blocks from HTML
2543 - $html = preg_replace($pattern, '', $html);
3389 + // Rebuild only the blocks that lost something, back-to-front so the
3390 + // recorded offsets of earlier blocks stay valid.
3391 + $edits = [];
3392 + foreach ($blocks as $block) {
3393 + $kept = [];
3394 + $lost = 0;
3395 + foreach ($block['items'] as $item) {
3396 + if ($item['e'] === null || $entities[$item['e']]['alive']) {
3397 + $kept[] = $item['node'];
3398 + } else {
3399 + $lost++;
3400 + }
3401 + }
3402 + if ($lost === 0) {
3403 + continue;
3404 + }
3405 + if ($kept === []) {
3406 + $edits[] = [$block['start'], $block['end_ws'], ''];
3407 + continue;
3408 + }
2544 3409
2545 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
2546 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
2547 - $rebuilt = '';
2548 - foreach ($kept as $decoded) {
2549 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2550 - wp_json_encode($decoded) . "</script>\n";
2551 - }
3410 + if ($block['shape'] === 'list') {
3411 + $payload = $kept;
3412 + } else { // graph — a flat block that lost its only entity is removed above
3413 + $payload = $block['decoded'];
3414 + $payload['@graph'] = $kept;
3415 + }
2552 3416
2553 - // Re-insert merged @graph block (if any entries exist)
2554 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
2555 - if (!empty($merged_graph)) {
2556 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
2557 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2558 - wp_json_encode($graph_obj) . "</script>\n";
3417 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3418 + if (!is_string($json) || $json === '') {
3419 + continue; // never drop a block whose payload failed to re-encode
3420 + }
3421 +
3422 + // The original attribute string is reused verbatim so class, id and
3423 + // the data-otto marker itself survive the rebuild.
3424 + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
2559 3425 }
2560 3426
2561 - // Re-inject before </head>
2562 - if (!empty($rebuilt)) {
2563 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
2564 - return $rebuilt . $m[1];
2565 - }, $html, 1);
3427 + foreach (array_reverse($edits) as $edit) {
3428 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
2566 3429 }
2567 3430
2568 3431 return $html;
2569 3432 }
@@ -2582,14 +3445,14 @@
2582 3445 return true;
2583 3446 }
2584 3447
2585 3448 # Check if amp=1 query parameter is present
2586 - if (isset($_GET['amp']) && $_GET['amp'] == '1') {
3449 + if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2587 3450 return true;
2588 3451 }
2589 3452
2590 3453 # Check for other common AMP query parameters
2591 - if (isset($_GET['amp']) && !empty($_GET['amp'])) {
3454 + if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2592 3455 return true;
2593 3456 }
2594 3457
2595 3458 return false;
@@ -2594,8 +3457,32 @@
2594 3457
2595 3458 return false;
2596 3459 }
2597 3460
3461 + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
3462 + # tell them apart from a third-party SEO plugin's blocks. Every insertion path
3463 + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
3464 + # bytes they insert are identical and the "already applied" checks still match.
3465 + private function stamp_otto_json_ld($html){
3466 +
3467 + $stamped = preg_replace_callback(
3468 + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
3469 + function ($m) {
3470 + # idempotent: a block already carrying a data-otto attribute —
3471 + # anywhere in the tag, not just before `type` — is left alone,
3472 + # never given a second one
3473 + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
3474 + return $m[0];
3475 + }
3476 + return '<script' . $m[1] . ' data-otto="true">';
3477 + },
3478 + $html
3479 + );
3480 +
3481 + # preg_replace returns null on a PCRE failure — never hand back less than we got
3482 + return is_string($stamped) ? $stamped : $html;
3483 + }
3484 +
2598 3485 # this function insterts header html to the dom
2599 3486 function insert_header_html($data){
2600 3487
2601 3488 # check that we have the header html
@@ -2608,20 +3495,27 @@
2608 3495 $head = $this->dom->find('head', 0);
2609 3496
2610 3497 if ($head) {
2611 3498
2612 - # Check if this is an AMP page - if so, don't add metasync_optimized attribute
3499 + # Check if this is an AMP page - if so, skip the processed marker
2613 3500 $is_amp_page = $this->is_amp_page();
2614 3501
2615 - # Append the new HTML at the start of the <head> tag
2616 - # For AMP pages: use clean <head> tag without metasync_optimized attribute
2617 - # For non-AMP pages: add metasync_optimized attribute to <head> tag
2618 - if ($is_amp_page) {
2619 - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
2620 - } else {
2621 - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
2622 - }
3502 + # Append the new HTML at the start of the <head> tag. The tag itself
3503 + # stays a literal '<head>' either way, so plugins that inject with a
3504 + # literal str_replace('<head>', ...) still match the real tag —
3505 + # see mark_head_optimized() for what breaks when it does not.
3506 + $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
2623 3507
3508 + # Escape JSON-LD payload closers so a `</script>` inside a JSON
3509 + # string value cannot terminate the element early (XSS/page corruption).
3510 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3511 + # fallbacks do, so the deduper can attribute them.
3512 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3513 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3514 + );
3515 +
3516 + $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3517 +
2624 3518 }
2625 3519
2626 3520 # save and reload DOM
2627 3521 $this->save_reload();
@@ -2815,8 +3709,12 @@
2815 3709
2816 3710 # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
2817 3711 $html = $this->fix_malformed_self_closing_tags($html);
2818 3712
3713 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
3714 + # SimpleHtmlDom swallows every following attribute into such a value.
3715 + $html = $this->quote_unquoted_attribute_values($html);
3716 +
2819 3717 # Load HTML into DOM
2820 3718 $this->dom->load($html, true, false);
2821 3719
2822 3720 # Force UTF-8 charset to preserve emojis and special characters
@@ -2998,15 +3896,16 @@
2998 3896 }
2999 3897
3000 3898 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3001 3899 if (!empty($replacement_data['header_html_insertion'])) {
3002 - $header_html_check = trim($replacement_data['header_html_insertion']);
3900 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
3901 + # path inserts the same bytes, so the already-applied check must compare
3902 + # against the stamped form or it would insert a second copy.
3903 + $header_html_insertion = $this->stamp_otto_json_ld(
3904 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3905 + );
3906 + $header_html_check = trim($header_html_insertion);
3003 3907 if (strpos($result_html, $header_html_check) === false) {
3004 - $header_html_insertion = preg_replace(
3005 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3006 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3007 - $replacement_data['header_html_insertion']
3008 - );
3009 3908 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3010 3909 # Insert before </head>
3011 3910 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3012 3911 }
@@ -3078,13 +3977,19 @@
3078 3977
3079 3978 $result_html = preg_replace_callback(
3080 3979 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3081 3980 function ($m) use ($current_value, $recommended_value) {
3082 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3083 - if ($inner_text === $current_value) {
3084 - return $m[1] . $recommended_value . $m[3];
3981 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3982 + if ($inner_text !== $current_value) {
3983 + return $m[0];
3085 3984 }
3086 - return $m[0];
3985 + # Keep the author's inline formatting; skip when it can't
3986 + # be preserved rather than flattening the heading.
3987 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
3988 + if ($new_inner === null) {
3989 + return $m[0];
3990 + }
3991 + return $m[1] . $new_inner . $m[3];
3087 3992 },
3088 3993 $result_html,
3089 3994 -1
3090 3995 );
@@ -3152,11 +4057,11 @@
3152 4057
3153 4058 # Clear element cache array
3154 4059 $this->cached_elements = [];
3155 4060
3156 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
3157 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
3158 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
4061 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
4062 + if (!$this->is_amp_page()) {
4063 + $result_html = $this->mark_head_optimized($result_html);
3159 4064 }
3160 4065
3161 4066 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
3162 4067
@@ -3192,8 +4097,12 @@
3192 4097 # undo double-encoded numeric/hex character references produced by the
3193 4098 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
3194 4099 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
3195 4100
4101 + # restore literal '&' query-string separators the serializer escaped to
4102 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
4103 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
4104 +
3196 4105 # Remove any lazy-video facade <style> hoisted into the page
3197 4106 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
3198 4107 $result_html = $this->strip_hoisted_facade_styles($result_html);
3199 4108
@@ -3210,12 +4119,26 @@
3210 4119
3211 4120 return $result_html;
3212 4121
3213 4122 } catch (Exception $e) {
3214 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
4123 + if (function_exists('metasync_otto_report_render_failure')) {
4124 + metasync_otto_report_render_failure(
4125 + 'MODIFICATION_EXCEPTION',
4126 + 'OTTO rewrite threw: ' . $e->getMessage(),
4127 + ['exception' => get_class($e)],
4128 + 'error'
4129 + );
4130 + }
3215 4131 return false;
3216 4132 } catch (Error $e) {
3217 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
4133 + if (function_exists('metasync_otto_report_render_failure')) {
4134 + metasync_otto_report_render_failure(
4135 + 'MODIFICATION_ERROR',
4136 + 'OTTO rewrite errored: ' . $e->getMessage(),
4137 + ['error' => get_class($e)],
4138 + 'error'
4139 + );
4140 + }
3218 4141 return false;
3219 4142 }
3220 4143 }
3221 4144
@@ -3264,23 +4187,15 @@
3264 4187 }
3265 4188 }
3266 4189 }
3267 4190
3268 - if ($remove_title && !$has_custom_title) {
3269 - # Remove Open Graph and Twitter title tags (keep main <title>)
3270 - $title_selectors = [
3271 - 'meta[property=og:title]',
3272 - 'meta[name=twitter:title]',
3273 - ];
4191 + # Preserve third-party og:title/twitter:title tags here. They used to be
4192 + # stripped whenever $remove_title was set, but that also removes them
4193 + # when no custom title exists to replace them, leaving pages with no
4194 + # og:title/twitter:title at all. OTTO's own title/OG dedup and
4195 + # precedence passes already reconcile duplicates, so this branch no
4196 + # longer needs to delete those tags.
3274 4197
3275 - foreach ($title_selectors as $selector) {
3276 - $tags = $this->dom->find($selector);
3277 - foreach ($tags as $tag) {
3278 - $tag->outertext = ''; # Remove the tag
3279 - }
3280 - }
3281 - }
3282 -
3283 4198 # Save changes
3284 4199 $this->save_reload();
3285 4200 }
3286 4201
@@ -3504,5 +4419,5 @@
3504 4419 return $html;
3505 4420 }
3506 4421
3507 4422
3508 -}
4423 +}