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 +770 -146 2.6.22 → 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
@@ -563,8 +568,52 @@
563 568 return $out === null ? $html : $out;
564 569 }
565 570
566 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 + /**
567 616 * Apply OTTO's canonical via string replacement (reliable fallback).
568 617 *
569 618 * Why the DOM path fails: do_header_replacements() sets the canonical with a
570 619 * DOM attribute edit ($link->href = …). That edit works in isolation, but
@@ -736,9 +785,9 @@
736 785 $response = wp_remote_get($url_with_params, $args);
737 786
738 787 # Check for errors
739 788 if (is_wp_error($response)) {
740 - 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
741 790 return false;
742 791 }
743 792
744 793 # get the response body
@@ -748,9 +797,9 @@
748 797 $response_code = wp_remote_retrieve_response_code($response);
749 798
750 799 # if no change data skip
751 800 if (empty($body) || $response_code !== 200){
752 - 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
753 802 return false;
754 803 }
755 804
756 805 # set the html file path
@@ -943,9 +992,11 @@
943 992
944 993 # Check for timeout or connection errors
945 994 if (is_wp_error($route_html)) {
946 995 $error_msg = $route_html->get_error_message();
947 - 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 + }
948 999 return false;
949 1000 }
950 1001
951 1002 # get body
@@ -953,12 +1004,51 @@
953 1004
954 1005 # Get the response code
955 1006 $response_code = wp_remote_retrieve_response_code($route_html);
956 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 + }
957 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 +
958 1046 # check not empty
959 1047 if(empty($html_body) || $response_code !== 200){
960 - 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 + }
961 1051 return false;
962 1052 }
963 1053
964 1054 # Skip DOM processing on oversized documents to avoid fatal OOM.
@@ -1122,9 +1212,9 @@
1122 1212
1123 1213 $result_html = preg_replace_callback(
1124 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
1125 1215 function ($m) use ($current_value, $recommended_value) {
1126 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
1216 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
1127 1217 if ($inner_text !== $current_value) {
1128 1218 return $m[0];
1129 1219 }
1130 1220 # Keep the author's inline formatting; skip when it can't
@@ -1146,15 +1236,16 @@
1146 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');
1147 1237
1148 1238 # Apply insertions — only if DOM insertion didn't already apply it
1149 1239 if (!empty($replacement_data['header_html_insertion'])) {
1150 - $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);
1151 1247 if (strpos($result_html, $header_html_check) === false) {
1152 - $header_html_insertion = preg_replace(
1153 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
1154 - '<script$1type=$2application/ld+json$2 data-otto="true"',
1155 - $replacement_data['header_html_insertion']
1156 - );
1157 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
1158 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
1159 1250 }
1160 1251 }
@@ -1188,11 +1279,11 @@
1188 1279 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
1189 1280 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
1190 1281 $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
1191 1282
1192 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
1193 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
1194 - $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);
1195 1286 }
1196 1287
1197 1288 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
1198 1289
@@ -1221,8 +1312,12 @@
1221 1312 # undo double-encoded numeric/hex character references produced by the
1222 1313 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
1223 1314 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
1224 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 +
1225 1320 # Remove any lazy-video facade <style> hoisted into the page
1226 1321 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
1227 1322 $result_html = $this->strip_hoisted_facade_styles($result_html);
1228 1323
@@ -1240,8 +1335,111 @@
1240 1335 return $result_html;
1241 1336 }
1242 1337
1243 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 + /**
1244 1442 * Repair double-encoded character references in serialized HTML.
1245 1443 *
1246 1444 * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
1247 1445 * $this->dom->save() and $root->outertext) runs every attribute value through
@@ -1264,8 +1462,118 @@
1264 1462 }
1265 1463 return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
1266 1464 }
1267 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 +
1268 1576 # do the footer html insertion
1269 1577 function do_footer_html_insertion($replacement_data){
1270 1578
1271 1579 # check that we have footer html
@@ -1792,10 +2100,10 @@
1792 2100 }
1793 2101
1794 2102 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1795 2103 if (strpos($url, '//') === 0) {
1796 - $parsed_home = parse_url($home_url);
1797 - $parsed_link = parse_url($url);
2104 + $parsed_home = wp_parse_url($home_url);
2105 + $parsed_link = wp_parse_url($url);
1798 2106
1799 2107 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1800 2108 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1801 2109 return false;
@@ -1917,20 +2225,22 @@
1917 2225 return $post_id ? (int) $post_id : 0;
1918 2226 }
1919 2227
1920 2228 /**
1921 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
1922 - * data-metasync-seo="custom"); insert one after <head> if none is present.
1923 - * Helper for apply_custom_seo_precedence().
2229 + * Replace the first <meta $attr="$val"> content with $content_escaped.
1924 2230 *
1925 2231 * @param string $html
1926 2232 * @param string $attr "name" or "property"
1927 2233 * @param string $val e.g. "description", "og:description"
1928 2234 * @param string $content_escaped Already HTML-escaped content value
2235 + * @param string $marker Provenance marker for the winning tier
1929 2236 * @return string
1930 2237 */
1931 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
1932 - $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 . ' />';
1933 2243 $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
1934 2244 $count = 0;
1935 2245 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
1936 2246 return $tag;
@@ -1944,23 +2254,36 @@
1944 2254 }, $html, 1);
1945 2255 }
1946 2256
1947 2257 /**
1948 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2258 + * Make sure the precedence resolver is loaded before calling it.
1949 2259 *
1950 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
1951 - * OTTO for these fields, but later steps re-introduce OTTO's value:
1952 - * - the manual string-replacement title pass re-applies OTTO's title;
1953 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
1954 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
1955 - * exactly the case when the post is synced to a third-party SEO plugin and
1956 - * 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.
1957 2277 *
1958 - * Running LAST — after all dedup/precedence steps — this makes the user's
1959 - * explicit sidebar value authoritative regardless of how the tag was produced,
1960 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
1961 - * 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.
1962 2282 *
2283 + * OG and Twitter descriptions remain outside this method deliberately;
2284 + * social precedence is a separate follow-up scope.
2285 + *
1963 2286 * @param string $html Full HTML document (post-dedup).
1964 2287 * @return string
1965 2288 */
1966 2289 private function apply_custom_seo_precedence($html) {
@@ -1971,12 +2294,26 @@
1971 2294 if (!$post_id) {
1972 2295 return $html;
1973 2296 }
1974 2297
1975 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
1976 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
1977 - if (!empty($custom_title) && is_string($custom_title)) {
1978 - $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>';
1979 2316 $count = 0;
1980 2317 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
1981 2318 return $title_tag;
1982 2319 }, $html, 1, $count);
@@ -1988,17 +2325,25 @@
1988 2325 }, $html, 1);
1989 2326 }
1990 2327 }
1991 2328
1992 - # Description — force the custom sidebar description into the SEO
1993 - # <meta name="description"> only. og:/twitter: description are social tags
1994 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
1995 - # earlier); this pass must NOT touch them, or it would override a
1996 - # separately user-set og:description with the plain SEO description.
1997 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
1998 - if (!empty($custom_desc) && is_string($custom_desc)) {
1999 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
2000 - $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);
2001 2346 }
2002 2347
2003 2348 return $html;
2004 2349 }
@@ -2404,8 +2749,34 @@
2404 2749 return $html;
2405 2750 }
2406 2751
2407 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 + /**
2408 2779 * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
2409 2780 *
2410 2781 * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
2411 2782 * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
@@ -2444,16 +2815,22 @@
2444 2815 }
2445 2816
2446 2817 # Explicit (user-typed) meta box values. A non-empty value means the user set
2447 2818 # this field themselves and it must take priority over OTTO.
2448 - $og_title_set = get_post_meta($post_id, '_metasync_og_title', true);
2449 - $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');
2450 2827 $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2451 2828 $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
2452 2829 $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true);
2453 2830 $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', true);
2454 - $tw_title_set = get_post_meta($post_id, '_metasync_twitter_title', true);
2455 - $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');
2456 2833 $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2457 2834 $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2458 2835
2459 2836 # Default OG values the meta box pre-fills (post title / generated excerpt /
@@ -2755,96 +3132,301 @@
2755 3132 return $html;
2756 3133 }
2757 3134
2758 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 + /**
2759 3164 * Deduplicate JSON-LD schema blocks.
2760 3165 *
2761 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
2762 - * blocks, keep OTTO's version for any @type that appears in both.
2763 - * 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:
2764 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 + *
2765 3189 * @param string $html Full HTML.
2766 3190 * @return string
2767 3191 */
2768 3192 private function deduplicate_schema_tags($html) {
2769 - // Find all JSON-LD script blocks
2770 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
2771 - 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)) {
2772 3200 return $html;
2773 3201 }
2774 3202
2775 - $otto_by_type = []; // @type => decoded JSON object
2776 - $third_by_type = []; // @type => decoded JSON object
2777 - $otto_graph = []; // entries from OTTO @graph blocks
2778 - $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 + }
2779 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 +
2780 3223 foreach ($matches as $m) {
2781 - $attrs = $m[1];
2782 - $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];
2783 3233 $decoded = json_decode($json_str, true);
2784 3234 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
2785 - continue; // skip unparseable blocks — leave them in place
3235 + continue; // unparseable — leave the block in place
2786 3236 }
2787 - $is_otto = stripos($attrs, 'data-otto') !== false;
2788 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 +
2789 3264 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
2790 - foreach ($decoded['@graph'] as $entry) {
2791 - if (!isset($entry['@type'])) continue;
2792 - // JSON-LD allows @type to be a string OR an array of strings.
2793 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
2794 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
2795 - $type = is_array($entry['@type'])
2796 - ? (string) reset($entry['@type'])
2797 - : (string) $entry['@type'];
2798 - if ($type === '') continue;
2799 - if ($is_otto) {
2800 - $otto_graph[$type] = $entry;
2801 - } else {
2802 - $third_graph[$type] = $entry;
2803 - }
3265 + foreach ($decoded['@graph'] as $node) {
3266 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
2804 3267 }
3268 + $block['shape'] = 'graph';
2805 3269 } elseif (isset($decoded['@type'])) {
2806 - $type = is_array($decoded['@type'])
2807 - ? (string) reset($decoded['@type'])
2808 - : (string) $decoded['@type'];
2809 - if ($type === '') continue;
2810 - if ($is_otto) {
2811 - $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]);
2812 3310 } else {
2813 - $third_by_type[$type] = $decoded;
3311 + $by_type[$token] = $holders;
2814 3312 }
2815 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 + }
2816 3376 }
2817 3377
2818 - // If OTTO provided no schema at all, nothing to deduplicate
2819 - if (empty($otto_by_type) && empty($otto_graph)) {
2820 - return $html;
3378 + $any_evicted = false;
3379 + foreach ($entities as $entity) {
3380 + if (!$entity['alive']) {
3381 + $any_evicted = true;
3382 + break;
3383 + }
2821 3384 }
3385 + if (!$any_evicted) {
3386 + return $html; // no collision — every original byte stays
3387 + }
2822 3388
2823 - // Remove all JSON-LD blocks from HTML
2824 - $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 + }
2825 3409
2826 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
2827 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
2828 - $rebuilt = '';
2829 - foreach ($kept as $decoded) {
2830 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2831 - wp_json_encode($decoded) . "</script>\n";
2832 - }
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 + }
2833 3416
2834 - // Re-insert merged @graph block (if any entries exist)
2835 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
2836 - if (!empty($merged_graph)) {
2837 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
2838 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2839 - 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>'];
2840 3425 }
2841 3426
2842 - // Re-inject before </head>
2843 - if (!empty($rebuilt)) {
2844 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
2845 - return $rebuilt . $m[1];
2846 - }, $html, 1);
3427 + foreach (array_reverse($edits) as $edit) {
3428 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
2847 3429 }
2848 3430
2849 3431 return $html;
2850 3432 }
@@ -2863,14 +3445,14 @@
2863 3445 return true;
2864 3446 }
2865 3447
2866 3448 # Check if amp=1 query parameter is present
2867 - 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
2868 3450 return true;
2869 3451 }
2870 3452
2871 3453 # Check for other common AMP query parameters
2872 - 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
2873 3455 return true;
2874 3456 }
2875 3457
2876 3458 return false;
@@ -2875,8 +3457,32 @@
2875 3457
2876 3458 return false;
2877 3459 }
2878 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 +
2879 3485 # this function insterts header html to the dom
2880 3486 function insert_header_html($data){
2881 3487
2882 3488 # check that we have the header html
@@ -2889,20 +3495,27 @@
2889 3495 $head = $this->dom->find('head', 0);
2890 3496
2891 3497 if ($head) {
2892 3498
2893 - # 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
2894 3500 $is_amp_page = $this->is_amp_page();
2895 3501
2896 - # Append the new HTML at the start of the <head> tag
2897 - # For AMP pages: use clean <head> tag without metasync_optimized attribute
2898 - # For non-AMP pages: add metasync_optimized attribute to <head> tag
2899 - if ($is_amp_page) {
2900 - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
2901 - } else {
2902 - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
2903 - }
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;
2904 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 +
2905 3518 }
2906 3519
2907 3520 # save and reload DOM
2908 3521 $this->save_reload();
@@ -3283,15 +3896,16 @@
3283 3896 }
3284 3897
3285 3898 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3286 3899 if (!empty($replacement_data['header_html_insertion'])) {
3287 - $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);
3288 3907 if (strpos($result_html, $header_html_check) === false) {
3289 - $header_html_insertion = preg_replace(
3290 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3291 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3292 - $replacement_data['header_html_insertion']
3293 - );
3294 3908 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3295 3909 # Insert before </head>
3296 3910 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3297 3911 }
@@ -3363,9 +3977,9 @@
3363 3977
3364 3978 $result_html = preg_replace_callback(
3365 3979 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3366 3980 function ($m) use ($current_value, $recommended_value) {
3367 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3981 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3368 3982 if ($inner_text !== $current_value) {
3369 3983 return $m[0];
3370 3984 }
3371 3985 # Keep the author's inline formatting; skip when it can't
@@ -3443,11 +4057,11 @@
3443 4057
3444 4058 # Clear element cache array
3445 4059 $this->cached_elements = [];
3446 4060
3447 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
3448 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
3449 - $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);
3450 4064 }
3451 4065
3452 4066 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
3453 4067
@@ -3483,8 +4097,12 @@
3483 4097 # undo double-encoded numeric/hex character references produced by the
3484 4098 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
3485 4099 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
3486 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 +
3487 4105 # Remove any lazy-video facade <style> hoisted into the page
3488 4106 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
3489 4107 $result_html = $this->strip_hoisted_facade_styles($result_html);
3490 4108
@@ -3501,12 +4119,26 @@
3501 4119
3502 4120 return $result_html;
3503 4121
3504 4122 } catch (Exception $e) {
3505 - 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 + }
3506 4131 return false;
3507 4132 } catch (Error $e) {
3508 - 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 + }
3509 4141 return false;
3510 4142 }
3511 4143 }
3512 4144
@@ -3555,23 +4187,15 @@
3555 4187 }
3556 4188 }
3557 4189 }
3558 4190
3559 - if ($remove_title && !$has_custom_title) {
3560 - # Remove Open Graph and Twitter title tags (keep main <title>)
3561 - $title_selectors = [
3562 - 'meta[property=og:title]',
3563 - 'meta[name=twitter:title]',
3564 - ];
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.
3565 4197
3566 - foreach ($title_selectors as $selector) {
3567 - $tags = $this->dom->find($selector);
3568 - foreach ($tags as $tag) {
3569 - $tag->outertext = ''; # Remove the tag
3570 - }
3571 - }
3572 - }
3573 -
3574 4198 # Save changes
3575 4199 $this->save_reload();
3576 4200 }
3577 4201
@@ -3795,5 +4419,5 @@
3795 4419 return $html;
3796 4420 }
3797 4421
3798 4422
3799 -}
4423 +}