| @@ -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 | } |
| @@ -1182,8 +1273,9 @@ | ||
| 1182 | 1273 | |
| 1183 | 1274 | # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema |
| 1184 | 1275 | $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags'); |
| 1185 | 1276 | $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags'); |
| 1277 | + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags'); | |
| 1186 | 1278 | $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags'); |
| 1187 | 1279 | $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence'); |
| 1188 | 1280 | $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); |
| 1189 | 1281 | $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); |
| @@ -1188,11 +1280,11 @@ | ||
| 1188 | 1280 | $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); |
| 1189 | 1281 | $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); |
| 1190 | 1282 | $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags'); |
| 1191 | 1283 | |
| 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); | |
| 1284 | + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it) | |
| 1285 | + if (!$this->is_amp_page()) { | |
| 1286 | + $result_html = $this->mark_head_optimized($result_html); | |
| 1195 | 1287 | } |
| 1196 | 1288 | |
| 1197 | 1289 | $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes'); |
| 1198 | 1290 | |
| @@ -1221,8 +1313,12 @@ | ||
| 1221 | 1313 | # undo double-encoded numeric/hex character references produced by the |
| 1222 | 1314 | # bundled simplehtmldom serializer from attributes like data-x-icon-s="". |
| 1223 | 1315 | $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities'); |
| 1224 | 1316 | |
| 1317 | + # restore literal '&' query-string separators the serializer escaped to | |
| 1318 | + # '&', which silently truncates multi-family Google Fonts URLs. | |
| 1319 | + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands'); | |
| 1320 | + | |
| 1225 | 1321 | # Remove any lazy-video facade <style> hoisted into the page |
| 1226 | 1322 | # (runs while srcdoc is still tokenized, so the iframe's own copy is safe). |
| 1227 | 1323 | $result_html = $this->strip_hoisted_facade_styles($result_html); |
| 1228 | 1324 | |
| @@ -1240,8 +1336,111 @@ | ||
| 1240 | 1336 | return $result_html; |
| 1241 | 1337 | } |
| 1242 | 1338 | |
| 1243 | 1339 | /** |
| 1340 | + * Read the final URL from the WordPress HTTP response when Requests has | |
| 1341 | + * followed one or more redirects. Older HTTP transports may not expose it; | |
| 1342 | + * in that case the empty result leaves the existing fail-open behavior. | |
| 1343 | + * | |
| 1344 | + * @param mixed $response wp_remote_get() response. | |
| 1345 | + * @return string Final URL, or an empty string when unavailable. | |
| 1346 | + */ | |
| 1347 | + private function get_effective_fetch_url($response) | |
| 1348 | + { | |
| 1349 | + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) { | |
| 1350 | + return ''; | |
| 1351 | + } | |
| 1352 | + | |
| 1353 | + $http_response = $response['http_response']; | |
| 1354 | + if (!method_exists($http_response, 'get_response_object')) { | |
| 1355 | + return ''; | |
| 1356 | + } | |
| 1357 | + | |
| 1358 | + $requests_response = $http_response->get_response_object(); | |
| 1359 | + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) { | |
| 1360 | + return ''; | |
| 1361 | + } | |
| 1362 | + | |
| 1363 | + return $requests_response->url; | |
| 1364 | + } | |
| 1365 | + | |
| 1366 | + /** | |
| 1367 | + * Determine whether a followed response still represents the requested | |
| 1368 | + * route. The internal fetch may use a rewritten host for tunnel support, | |
| 1369 | + * so both the original and transport hosts are accepted; the path and | |
| 1370 | + * non-OTTO query parameters must remain identical. | |
| 1371 | + * | |
| 1372 | + * @param string $effective_url Final URL reported by Requests. | |
| 1373 | + * @param string $route Original route supplied to OTTO. | |
| 1374 | + * @param string $fetch_url URL actually passed to wp_remote_get(). | |
| 1375 | + * @return bool True when no meaningful redirect changed the route. | |
| 1376 | + */ | |
| 1377 | + private function fetch_url_matches_route($effective_url, $route, $fetch_url) | |
| 1378 | + { | |
| 1379 | + $effective = wp_parse_url($effective_url); | |
| 1380 | + $requested = wp_parse_url($route); | |
| 1381 | + $transport = wp_parse_url($fetch_url); | |
| 1382 | + if (!is_array($effective) || !is_array($requested)) { | |
| 1383 | + return true; | |
| 1384 | + } | |
| 1385 | + | |
| 1386 | + $effective_path = isset($effective['path']) ? $effective['path'] : '/'; | |
| 1387 | + $requested_path = isset($requested['path']) ? $requested['path'] : '/'; | |
| 1388 | + if ($effective_path !== $requested_path) { | |
| 1389 | + return false; | |
| 1390 | + } | |
| 1391 | + | |
| 1392 | + $allowed_hosts = []; | |
| 1393 | + foreach ([$requested, $transport] as $parts) { | |
| 1394 | + if (!empty($parts['host'])) { | |
| 1395 | + $host = strtolower($parts['host']); | |
| 1396 | + if (!empty($parts['port'])) { | |
| 1397 | + $host .= ':' . $parts['port']; | |
| 1398 | + } | |
| 1399 | + $allowed_hosts[] = $host; | |
| 1400 | + } | |
| 1401 | + } | |
| 1402 | + if (!empty($effective['host'])) { | |
| 1403 | + $effective_host = strtolower($effective['host']); | |
| 1404 | + if (!empty($effective['port'])) { | |
| 1405 | + $effective_host .= ':' . $effective['port']; | |
| 1406 | + } | |
| 1407 | + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) { | |
| 1408 | + return false; | |
| 1409 | + } | |
| 1410 | + } | |
| 1411 | + | |
| 1412 | + return $this->without_otto_fetch_params($effective['query'] ?? '') | |
| 1413 | + === $this->without_otto_fetch_params($requested['query'] ?? ''); | |
| 1414 | + } | |
| 1415 | + | |
| 1416 | + /** | |
| 1417 | + * Remove parameters used only to mark OTTO's internal request. | |
| 1418 | + * | |
| 1419 | + * @param string $query Query string without the leading '?'. | |
| 1420 | + * @return string Query with OTTO markers removed. | |
| 1421 | + */ | |
| 1422 | + private function without_otto_fetch_params($query) | |
| 1423 | + { | |
| 1424 | + $query = (string) $query; | |
| 1425 | + if ($query === '') { | |
| 1426 | + return ''; | |
| 1427 | + } | |
| 1428 | + | |
| 1429 | + $parts = []; | |
| 1430 | + foreach (explode('&', $query) as $part) { | |
| 1431 | + $name = strstr($part, '=', true); | |
| 1432 | + $name = $name === false ? $part : $name; | |
| 1433 | + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) { | |
| 1434 | + continue; | |
| 1435 | + } | |
| 1436 | + $parts[] = $part; | |
| 1437 | + } | |
| 1438 | + | |
| 1439 | + return implode('&', $parts); | |
| 1440 | + } | |
| 1441 | + | |
| 1442 | + /** | |
| 1244 | 1443 | * Repair double-encoded character references in serialized HTML. |
| 1245 | 1444 | * |
| 1246 | 1445 | * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via |
| 1247 | 1446 | * $this->dom->save() and $root->outertext) runs every attribute value through |
| @@ -1264,8 +1463,118 @@ | ||
| 1264 | 1463 | } |
| 1265 | 1464 | return preg_replace('/&(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html); |
| 1266 | 1465 | } |
| 1267 | 1466 | |
| 1467 | + /** | |
| 1468 | + * Restore literal '&' query-string separators inside URL attributes. | |
| 1469 | + * | |
| 1470 | + * HtmlNode::makeup() serialises every attribute through htmlentities(), so a | |
| 1471 | + * separator that arrived as a bare '&' - or as '&', which HtmlDocument | |
| 1472 | + * decodes to '&' when it parses the attribute - is written back as '&'. | |
| 1473 | + * Both forms decode to '&' in a conforming HTML parser, so the browser is | |
| 1474 | + * unaffected. A consumer that reads the raw attribute WITHOUT decoding | |
| 1475 | + * entities sees a different URL, and Cloudflare Fonts is one of those: it | |
| 1476 | + * refetches fonts.googleapis.com/css2?family=A&family=B, Google treats | |
| 1477 | + * only the first 'family=' as a real parameter (the rest arrive named | |
| 1478 | + * 'amp;family'), and every font after the first is dropped from the response. | |
| 1479 | + * The page keeps its font-family declarations but loses the @font-face rules, | |
| 1480 | + * so text silently falls back to a system font. | |
| 1481 | + * | |
| 1482 | + * Rewrites only a '&' that is immediately followed by a parameter name | |
| 1483 | + * and '=', and only inside the listed URL attributes. A '&' elsewhere is | |
| 1484 | + * left escaped, and the mandatory '=' in the lookahead (with ';' excluded | |
| 1485 | + * from the name class) means no ambiguous ampersand ('&' + alphanumerics + | |
| 1486 | + * ';') is ever produced. | |
| 1487 | + * | |
| 1488 | + * All three delimiter forms the serializer can emit are handled: double | |
| 1489 | + * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and | |
| 1490 | + * emits no delimiter for a value with no whitespace, which is common on | |
| 1491 | + * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted | |
| 1492 | + * value may legitimately contain an apostrophe. | |
| 1493 | + * | |
| 1494 | + * The attribute list stops at attributes whose value is always a URL. | |
| 1495 | + * 'content' is deliberately excluded because it also carries prose | |
| 1496 | + * (<meta name="description" content="A & B">), where unescaping would | |
| 1497 | + * corrupt visible text. Attributes read only via JavaScript getAttribute() | |
| 1498 | + * do not need it either - the DOM decodes entities for them; only consumers | |
| 1499 | + * that scan raw HTML are affected. | |
| 1500 | + * | |
| 1501 | + * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded | |
| 1502 | + * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes | |
| 1503 | + * there would edit a JS string literal, or turn "&copy=2" in a textarea | |
| 1504 | + * into "(c)=2" once the browser decodes it. | |
| 1505 | + * | |
| 1506 | + * Plain negated character classes only - no tempered-greedy lookaheads, which | |
| 1507 | + * blow the PCRE JIT stack on pages with very large inline blocks. | |
| 1508 | + * | |
| 1509 | + * @param string $html Serialized HTML from save()/outertext. | |
| 1510 | + * @return string | |
| 1511 | + */ | |
| 1512 | + private function repair_query_string_ampersands($html) { | |
| 1513 | + # strpos guard: skip the regex entirely when there is nothing to repair | |
| 1514 | + if (strpos($html, '&') === false) { | |
| 1515 | + return $html; | |
| 1516 | + } | |
| 1517 | + | |
| 1518 | + # Shield verbatim / RCDATA regions and comments before touching anything. | |
| 1519 | + # Masking also shrinks large inline blocks to a token, so the attribute | |
| 1520 | + # pass never walks them. | |
| 1521 | + $shielded = array(); | |
| 1522 | + $masked = preg_replace_callback( | |
| 1523 | + '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is', | |
| 1524 | + function ($m) use (&$shielded) { | |
| 1525 | + $token = '<!--METASYNC_QSA_' . count($shielded) . '-->'; | |
| 1526 | + $shielded[$token] = $m[0]; | |
| 1527 | + return $token; | |
| 1528 | + }, | |
| 1529 | + $html | |
| 1530 | + ); | |
| 1531 | + | |
| 1532 | + # preg_* returns null on a PCRE failure; keep the original document. | |
| 1533 | + if ($masked === null) { | |
| 1534 | + return $html; | |
| 1535 | + } | |
| 1536 | + | |
| 1537 | + $repaired = preg_replace_callback( | |
| 1538 | + '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i', | |
| 1539 | + function ($m) { | |
| 1540 | + # Pick the delimiter branch that matched. An empty value carries | |
| 1541 | + # no query string, so the ambiguity between an empty quoted value | |
| 1542 | + # and an unset group cannot change the outcome. | |
| 1543 | + if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones) | |
| 1544 | + $quote = ''; | |
| 1545 | + $value = $m[4]; | |
| 1546 | + } elseif (isset($m[3]) && $m[3] !== '') { | |
| 1547 | + $quote = "'"; | |
| 1548 | + $value = $m[3]; | |
| 1549 | + } else { | |
| 1550 | + $quote = '"'; | |
| 1551 | + $value = isset($m[2]) ? $m[2] : ''; | |
| 1552 | + } | |
| 1553 | + | |
| 1554 | + # Only a value carrying a query string can hold separators. | |
| 1555 | + if (strpos($value, '?') === false || strpos($value, '&') === false) { | |
| 1556 | + return $m[0]; | |
| 1557 | + } | |
| 1558 | + | |
| 1559 | + $fixed = preg_replace('/&(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value); | |
| 1560 | + | |
| 1561 | + if ($fixed === null) { | |
| 1562 | + return $m[0]; | |
| 1563 | + } | |
| 1564 | + | |
| 1565 | + return $m[1] . $quote . $fixed . $quote; | |
| 1566 | + }, | |
| 1567 | + $masked | |
| 1568 | + ); | |
| 1569 | + | |
| 1570 | + if ($repaired === null) { | |
| 1571 | + return $html; | |
| 1572 | + } | |
| 1573 | + | |
| 1574 | + return empty($shielded) ? $repaired : strtr($repaired, $shielded); | |
| 1575 | + } | |
| 1576 | + | |
| 1268 | 1577 | # do the footer html insertion |
| 1269 | 1578 | function do_footer_html_insertion($replacement_data){ |
| 1270 | 1579 | |
| 1271 | 1580 | # check that we have footer html |
| @@ -1792,10 +2101,10 @@ | ||
| 1792 | 2101 | } |
| 1793 | 2102 | |
| 1794 | 2103 | # If it's a protocol-relative URL (//example.com), check if it matches our domain |
| 1795 | 2104 | if (strpos($url, '//') === 0) { |
| 1796 | - $parsed_home = parse_url($home_url); | |
| 1797 | - $parsed_link = parse_url($url); | |
| 2105 | + $parsed_home = wp_parse_url($home_url); | |
| 2106 | + $parsed_link = wp_parse_url($url); | |
| 1798 | 2107 | |
| 1799 | 2108 | if (isset($parsed_home['host']) && isset($parsed_link['host'])) { |
| 1800 | 2109 | if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) { |
| 1801 | 2110 | return false; |
| @@ -1917,21 +2226,32 @@ | ||
| 1917 | 2226 | return $post_id ? (int) $post_id : 0; |
| 1918 | 2227 | } |
| 1919 | 2228 | |
| 1920 | 2229 | /** |
| 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(). | |
| 2230 | + * Replace the first <meta $attr="$val"> content with $content_escaped. | |
| 1924 | 2231 | * |
| 1925 | 2232 | * @param string $html |
| 1926 | 2233 | * @param string $attr "name" or "property" |
| 1927 | 2234 | * @param string $val e.g. "description", "og:description" |
| 1928 | 2235 | * @param string $content_escaped Already HTML-escaped content value |
| 2236 | + * @param string $marker Provenance marker for the winning tier | |
| 1929 | 2237 | * @return string |
| 1930 | 2238 | */ |
| 1931 | - private function force_custom_meta($html, $attr, $val, $content_escaped) { | |
| 1932 | - $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '" data-metasync-seo="custom" />'; | |
| 1933 | - $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i'; | |
| 2239 | + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') { | |
| 2240 | + $marker_attribute = $marker === 'otto' | |
| 2241 | + ? ' data-metasync-otto="true"' | |
| 2242 | + : ' data-metasync-seo="custom"'; | |
| 2243 | + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />'; | |
| 2244 | + # The optional prefix group must end on whitespace or a quote so the | |
| 2245 | + # attribute matched is $attr itself, not something merely ending in it: | |
| 2246 | + # a bare [^>]*name= also matches data-name="keywords", so a third party's | |
| 2247 | + # unrelated tag was consumed and rewritten while the real one survived — | |
| 2248 | + # destroying their value AND leaving the duplicate the dedup had just | |
| 2249 | + # collapsed. Making the group optional keeps the common | |
| 2250 | + # <meta name="..." ...> form matching, where <meta\s has already consumed | |
| 2251 | + # the only separator. Same rule as deduplicate_keywords_tags(); this | |
| 2252 | + # helper serves title, description and keywords, so all three are covered. | |
| 2253 | + $pattern = '/<meta\s(?:[^>]*[\s"\'])?' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i'; | |
| 1934 | 2254 | $count = 0; |
| 1935 | 2255 | $new = preg_replace_callback($pattern, function ($m) use ($tag) { |
| 1936 | 2256 | return $tag; |
| 1937 | 2257 | }, $html, 1, $count); |
| @@ -1944,23 +2264,37 @@ | ||
| 1944 | 2264 | }, $html, 1); |
| 1945 | 2265 | } |
| 1946 | 2266 | |
| 1947 | 2267 | /** |
| 1948 | - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO. | |
| 2268 | + * Make sure the precedence resolver is loaded before calling it. | |
| 1949 | 2269 | * |
| 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()). | |
| 2270 | + * @return bool | |
| 2271 | + */ | |
| 2272 | + private static function precedence_available() { | |
| 2273 | + if (class_exists('Metasync_Seo_Precedence')) { | |
| 2274 | + return true; | |
| 2275 | + } | |
| 2276 | + | |
| 2277 | + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php'; | |
| 2278 | + if (is_readable($file)) { | |
| 2279 | + require_once $file; | |
| 2280 | + } | |
| 2281 | + | |
| 2282 | + return class_exists('Metasync_Seo_Precedence'); | |
| 2283 | + } | |
| 2284 | + | |
| 2285 | + /** | |
| 2286 | + * Enforce the resolved SEO title, description and keywords as the FINAL | |
| 2287 | + * word over OTTO. | |
| 1957 | 2288 | * |
| 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. | |
| 2289 | + * The resolver owns the global custom-versus-OTTO order. This final buffer | |
| 2290 | + * pass must use it too: otherwise the setting works through the sidebar | |
| 2291 | + * and third-party filters but OTTO's later string pass puts the custom | |
| 2292 | + * value back on the page. | |
| 1962 | 2293 | * |
| 2294 | + * OG and Twitter descriptions remain outside this method deliberately; | |
| 2295 | + * social precedence is a separate follow-up scope. | |
| 2296 | + * | |
| 1963 | 2297 | * @param string $html Full HTML document (post-dedup). |
| 1964 | 2298 | * @return string |
| 1965 | 2299 | */ |
| 1966 | 2300 | private function apply_custom_seo_precedence($html) { |
| @@ -1971,12 +2305,26 @@ | ||
| 1971 | 2305 | if (!$post_id) { |
| 1972 | 2306 | return $html; |
| 1973 | 2307 | } |
| 1974 | 2308 | |
| 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>'; | |
| 2309 | + # This runs inside the output buffer, where an unresolvable class is a | |
| 2310 | + # fatal that takes the whole page down — otto_guard_html() only catches | |
| 2311 | + # a NULL return, not a thrown Error. A partial update can leave newer | |
| 2312 | + # PHP files beside an older committed autoload classmap, so resolve the | |
| 2313 | + # file directly before trusting the class, and leave the document | |
| 2314 | + # untouched if it is genuinely absent. Same reasoning as | |
| 2315 | + # Metasync_SEO_Conflict_Handler::precedence_available(). | |
| 2316 | + if (!self::precedence_available()) { | |
| 2317 | + return $html; | |
| 2318 | + } | |
| 2319 | + | |
| 2320 | + # Title — replace the resolved page title into the (single, post-dedup) <title>. | |
| 2321 | + $resolved_title = Metasync_Seo_Precedence::resolve( | |
| 2322 | + $post_id, | |
| 2323 | + Metasync_Seo_Precedence::FIELD_TITLE | |
| 2324 | + ); | |
| 2325 | + if (!empty($resolved_title['value'])) { | |
| 2326 | + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>'; | |
| 1979 | 2327 | $count = 0; |
| 1980 | 2328 | $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) { |
| 1981 | 2329 | return $title_tag; |
| 1982 | 2330 | }, $html, 1, $count); |
| @@ -1988,19 +2336,59 @@ | ||
| 1988 | 2336 | }, $html, 1); |
| 1989 | 2337 | } |
| 1990 | 2338 | } |
| 1991 | 2339 | |
| 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); | |
| 2340 | + # Description — replace the resolved page description into the SEO | |
| 2341 | + # <meta name="description"> only. OG/Twitter description is social | |
| 2342 | + # precedence and remains outside this setting's scope. | |
| 2343 | + $resolved_description = Metasync_Seo_Precedence::resolve( | |
| 2344 | + $post_id, | |
| 2345 | + Metasync_Seo_Precedence::FIELD_DESCRIPTION | |
| 2346 | + ); | |
| 2347 | + if (!empty($resolved_description['value'])) { | |
| 2348 | + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8'); | |
| 2349 | + $marker = Metasync_Seo_Precedence::is_otto_value( | |
| 2350 | + $post_id, | |
| 2351 | + Metasync_Seo_Precedence::FIELD_DESCRIPTION, | |
| 2352 | + $resolved_description | |
| 2353 | + ) | |
| 2354 | + ? 'otto' | |
| 2355 | + : 'custom'; | |
| 2356 | + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker); | |
| 2001 | 2357 | } |
| 2002 | 2358 | |
| 2359 | + # Keywords — the same final pass as the description above, on the chain | |
| 2360 | + # the resolver owns, so the global priority setting governs this tag | |
| 2361 | + # too. OTTO's keywords tag arrives via header_html_insertion carrying | |
| 2362 | + # a data-otto marker and is spliced in after wp_head, so it is always | |
| 2363 | + # the later tag and always wins deduplicate_keywords_tags(); forcing | |
| 2364 | + # the resolved winner here is what keeps the losing tier reachable at | |
| 2365 | + # all — the single-printing-owner rule the description branch follows. | |
| 2366 | + # | |
| 2367 | + # The tier this forces is NOT guaranteed customer-typed, unlike the | |
| 2368 | + # title and description keys above: the meta_keywords persistence flag | |
| 2369 | + # writes OTTO's value straight to _metasync_focus_keyword, and neither | |
| 2370 | + # that conditional write nor the clone cleaner (which strips only the | |
| 2371 | + # _metasync_otto_* keys) ever clears it, so a stale flag copy can | |
| 2372 | + # outrank a newer suggestion under custom-first. That key sharing is | |
| 2373 | + # tracked separately — the fix is a key of its own for the persisted | |
| 2374 | + # keyword, not a special case here. | |
| 2375 | + $resolved_keyword = Metasync_Seo_Precedence::resolve( | |
| 2376 | + $post_id, | |
| 2377 | + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD | |
| 2378 | + ); | |
| 2379 | + if (!empty($resolved_keyword['value'])) { | |
| 2380 | + $esc = htmlspecialchars($resolved_keyword['value'], ENT_QUOTES, 'UTF-8'); | |
| 2381 | + $marker = Metasync_Seo_Precedence::is_otto_value( | |
| 2382 | + $post_id, | |
| 2383 | + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD, | |
| 2384 | + $resolved_keyword | |
| 2385 | + ) | |
| 2386 | + ? 'otto' | |
| 2387 | + : 'custom'; | |
| 2388 | + $html = $this->force_custom_meta($html, 'name', 'keywords', $esc, $marker); | |
| 2389 | + } | |
| 2390 | + | |
| 2003 | 2391 | return $html; |
| 2004 | 2392 | } |
| 2005 | 2393 | |
| 2006 | 2394 | # this function does the header replacements |
| @@ -2404,8 +2792,34 @@ | ||
| 2404 | 2792 | return $html; |
| 2405 | 2793 | } |
| 2406 | 2794 | |
| 2407 | 2795 | /** |
| 2796 | + * Read one of the meta box's social title/description keys with the "Auto Draft" | |
| 2797 | + * placeholder collapsed to ''. | |
| 2798 | + * | |
| 2799 | + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one | |
| 2800 | + * place. Falls back to a raw read when that class — or that method on it — is | |
| 2801 | + * unavailable: this buffer filter runs on the front end, and a partially updated | |
| 2802 | + * install can pair a newer otto/ file with an older includes/ one, where calling | |
| 2803 | + * a method the loaded class doesn't define would fatal the page rather than | |
| 2804 | + * degrade. | |
| 2805 | + * | |
| 2806 | + * @param int $post_id | |
| 2807 | + * @param string $key | |
| 2808 | + * @return string | |
| 2809 | + */ | |
| 2810 | + private function social_meta($post_id, $key) { | |
| 2811 | + // PHPStan resolves the literal class-string against the current source and so | |
| 2812 | + // sees the method as always present; the check is deliberate runtime | |
| 2813 | + // version-skew defence. Suppressed the same way otto_pixel.php does. | |
| 2814 | + // @phpstan-ignore-next-line function.alreadyNarrowedType | |
| 2815 | + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) { | |
| 2816 | + return Metasync_OpenGraph::get_social_meta($post_id, $key); | |
| 2817 | + } | |
| 2818 | + return (string) get_post_meta($post_id, $key, true); | |
| 2819 | + } | |
| 2820 | + | |
| 2821 | + /** | |
| 2408 | 2822 | * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO. |
| 2409 | 2823 | * |
| 2410 | 2824 | * Precedence per tag: an explicit meta box value set by the user wins over OTTO; |
| 2411 | 2825 | * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible |
| @@ -2444,16 +2858,22 @@ | ||
| 2444 | 2858 | } |
| 2445 | 2859 | |
| 2446 | 2860 | # Explicit (user-typed) meta box values. A non-empty value means the user set |
| 2447 | 2861 | # 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); | |
| 2862 | + # | |
| 2863 | + # The four social title/description keys are read through | |
| 2864 | + # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft" | |
| 2865 | + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a | |
| 2866 | + # brand-new post reads as a deliberate override here (it differs from the real | |
| 2867 | + # title) and would beat OTTO's correct og:title. | |
| 2868 | + $og_title_set = $this->social_meta($post_id, '_metasync_og_title'); | |
| 2869 | + $og_desc_set = $this->social_meta($post_id, '_metasync_og_description'); | |
| 2450 | 2870 | $og_image_set = get_post_meta($post_id, '_metasync_og_image', true); |
| 2451 | 2871 | $og_type_set = get_post_meta($post_id, '_metasync_og_type', true); |
| 2452 | 2872 | $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true); |
| 2453 | 2873 | $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); | |
| 2874 | + $tw_title_set = $this->social_meta($post_id, '_metasync_twitter_title'); | |
| 2875 | + $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description'); | |
| 2456 | 2876 | $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true); |
| 2457 | 2877 | $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true); |
| 2458 | 2878 | |
| 2459 | 2879 | # Default OG values the meta box pre-fills (post title / generated excerpt / |
| @@ -2713,8 +3133,82 @@ | ||
| 2713 | 3133 | return $html; |
| 2714 | 3134 | } |
| 2715 | 3135 | |
| 2716 | 3136 | /** |
| 3137 | + * Remove duplicate <meta name="keywords"> tags from HTML. | |
| 3138 | + * | |
| 3139 | + * OTTO ships its keywords tag inside header_html_insertion, and MetaSync's | |
| 3140 | + * own SEO output now emits one too whenever the precedence chain resolves a | |
| 3141 | + * keyword — so a server-side-rendered page can carry both. | |
| 3142 | + * | |
| 3143 | + * The keeper is OTTO's tag, or the first occurrence when no OTTO tag is | |
| 3144 | + * present. Unlike deduplicate_description_tags() there is no | |
| 3145 | + * customer-authored branch here: print_metatag() emits no data attribute, so | |
| 3146 | + * a keywords tag never carries data-metasync-seo="custom" at this point in | |
| 3147 | + * the pipeline. The customer tier is re-applied afterwards by | |
| 3148 | + * apply_custom_seo_precedence(), which runs later and stamps the marker via | |
| 3149 | + * force_custom_meta(). Collapsing to OTTO's tag here is therefore safe: it is | |
| 3150 | + * not the final word on what the page renders. | |
| 3151 | + * | |
| 3152 | + * @param string $html Full HTML document. | |
| 3153 | + * @return string HTML with at most one <meta name="keywords">. | |
| 3154 | + */ | |
| 3155 | + private function deduplicate_keywords_tags($html) { | |
| 3156 | + # No is_string() guard, unlike deduplicate_description_tags() beside it. | |
| 3157 | + # That asymmetry is forced, not an oversight: @param string makes the | |
| 3158 | + # check provably always-true at PHPStan level 5, and the description | |
| 3159 | + # sibling's copy is one of a fixed number of occurrences the baseline | |
| 3160 | + # pins by count, so adding another fails the build. Both methods are | |
| 3161 | + # only ever called with the buffer string. | |
| 3162 | + if ($html === '') { | |
| 3163 | + return $html; | |
| 3164 | + } | |
| 3165 | + | |
| 3166 | + # Match <meta ... name="keywords" ...> in either attribute order. | |
| 3167 | + # | |
| 3168 | + # The optional prefix group must end on whitespace or a quote so the | |
| 3169 | + # attribute is `name`, not something merely ending in it: a bare | |
| 3170 | + # [^>]*name= also matches data-name="keywords". Making the group | |
| 3171 | + # optional keeps the common `<meta name="keywords" ...>` form matching, | |
| 3172 | + # where <meta\s has already consumed the only separator. | |
| 3173 | + # | |
| 3174 | + # The opening quote must be immediately followed by "keywords", so | |
| 3175 | + # name="news_keywords" is not matched. | |
| 3176 | + $pattern = '/<meta\s(?:[^>]*[\s"\'])?name\s*=\s*["\']keywords["\'][^>]*\/?>/i'; | |
| 3177 | + | |
| 3178 | + if (preg_match_all($pattern, $html, $matches) <= 1) { | |
| 3179 | + return $html; # 0 or 1 — nothing to deduplicate | |
| 3180 | + } | |
| 3181 | + | |
| 3182 | + $all_tags = $matches[0]; | |
| 3183 | + | |
| 3184 | + $otto_tag = null; | |
| 3185 | + foreach ($all_tags as $tag) { | |
| 3186 | + if (stripos($tag, 'data-otto-pixel') !== false || | |
| 3187 | + stripos($tag, 'data-metasync-otto') !== false || | |
| 3188 | + stripos($tag, 'data-otto') !== false | |
| 3189 | + ) { | |
| 3190 | + $otto_tag = $tag; | |
| 3191 | + break; | |
| 3192 | + } | |
| 3193 | + } | |
| 3194 | + $keeper = $otto_tag ?: $all_tags[0]; | |
| 3195 | + | |
| 3196 | + # Callback form avoids backreference injection when the keyword list | |
| 3197 | + # contains $ followed by digits. | |
| 3198 | + $first_replaced = false; | |
| 3199 | + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) { | |
| 3200 | + if (!$first_replaced) { | |
| 3201 | + $first_replaced = true; | |
| 3202 | + return $keeper; | |
| 3203 | + } | |
| 3204 | + return ''; # Remove subsequent duplicates | |
| 3205 | + }, $html); | |
| 3206 | + | |
| 3207 | + return $html; | |
| 3208 | + } | |
| 3209 | + | |
| 3210 | + /** | |
| 2717 | 3211 | * Remove duplicate <link rel="canonical"> tags from HTML. |
| 2718 | 3212 | * |
| 2719 | 3213 | * When OTTO injects a canonical via header_html_insertion and MetaSync's |
| 2720 | 3214 | * SEO output (or WordPress core) has already emitted one, keep only the |
| @@ -2755,96 +3249,301 @@ | ||
| 2755 | 3249 | return $html; |
| 2756 | 3250 | } |
| 2757 | 3251 | |
| 2758 | 3252 | /** |
| 3253 | + * Normalize a JSON-LD `@type` into a list of lowercased type tokens. | |
| 3254 | + * | |
| 3255 | + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math | |
| 3256 | + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]). | |
| 3257 | + * Collapsing such a node to its first token hides it from a plain-typed | |
| 3258 | + * duplicate, so every token is returned and indexed separately. | |
| 3259 | + * | |
| 3260 | + * @param mixed $raw The raw `@type` value. | |
| 3261 | + * @return string[] Unique lowercased tokens, possibly empty. | |
| 3262 | + */ | |
| 3263 | + private function schema_type_tokens($raw) { | |
| 3264 | + $tokens = []; | |
| 3265 | + | |
| 3266 | + foreach ((is_array($raw) ? $raw : [$raw]) as $type) { | |
| 3267 | + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) { | |
| 3268 | + continue; | |
| 3269 | + } | |
| 3270 | + $type = strtolower(trim((string) $type)); | |
| 3271 | + if ($type === '') { | |
| 3272 | + continue; | |
| 3273 | + } | |
| 3274 | + $tokens[$type] = true; | |
| 3275 | + } | |
| 3276 | + | |
| 3277 | + return array_keys($tokens); | |
| 3278 | + } | |
| 3279 | + | |
| 3280 | + /** | |
| 2759 | 3281 | * Deduplicate JSON-LD schema blocks. |
| 2760 | 3282 | * |
| 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. | |
| 3283 | + * Two nodes collide when they share an `@id`, or when one claims an `@type` | |
| 3284 | + * token the page already carries. Collisions resolve strictly in OTTO's | |
| 3285 | + * favour: | |
| 2764 | 3286 | * |
| 3287 | + * - an OTTO node (marked `data-otto`) displaces every colliding | |
| 3288 | + * third-party node, whichever appeared first in the document; | |
| 3289 | + * - two OTTO nodes describing the same entity keep the first; | |
| 3290 | + * - two third-party nodes never evict each other, even when they share a | |
| 3291 | + * type: a Yoast graph legitimately carries two distinct ImageObjects, | |
| 3292 | + * and MetaSync's own breadcrumb / local-business blocks are third-party | |
| 3293 | + * output this pass has no mandate to fold. | |
| 3294 | + * | |
| 3295 | + * When OTTO contributed no node at all the document comes back untouched — | |
| 3296 | + * a third-party-only page is not ours to rewrite. Edits are offset splices | |
| 3297 | + * into the original document, so a block that loses nothing keeps its exact | |
| 3298 | + * bytes (attributes, `class`, `@context`, position — head, body or footer) | |
| 3299 | + * and nothing is ever relocated. | |
| 3300 | + * | |
| 3301 | + * Blocks are left exactly where they are when their JSON does not parse, | |
| 3302 | + * when they carry neither `@type` nor `@graph`, or when they sit inside an | |
| 3303 | + * HTML comment or <noscript> — a schema node the site disabled by wrapping | |
| 3304 | + * it in a comment must stay disabled, not be re-published by this pass. | |
| 3305 | + * | |
| 2765 | 3306 | * @param string $html Full HTML. |
| 2766 | 3307 | * @return string |
| 2767 | 3308 | */ |
| 2768 | 3309 | 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) { | |
| 3310 | + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string — | |
| 3311 | + // capturing only the part before `type=` would hide a `data-otto` marker | |
| 3312 | + // that sits after it, which is where every insertion path puts it. The | |
| 3313 | + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8` | |
| 3314 | + // is recognised too. | |
| 3315 | + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i'; | |
| 3316 | + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { | |
| 2772 | 3317 | return $html; |
| 2773 | 3318 | } |
| 2774 | 3319 | |
| 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 | |
| 3320 | + // Byte ranges this pass must not reach into. A commented-out (or | |
| 3321 | + // <noscript>-wrapped) schema block is deliberately disabled output. | |
| 3322 | + $masked = []; | |
| 3323 | + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { | |
| 3324 | + foreach ($comments as $comment) { | |
| 3325 | + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])]; | |
| 3326 | + } | |
| 3327 | + } | |
| 2779 | 3328 | |
| 3329 | + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare | |
| 3330 | + // `data-otto`. A substring test would also catch a hypothetical | |
| 3331 | + // `data-otto-thing="x"` and misattribute that block to OTTO. | |
| 3332 | + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i'; | |
| 3333 | + | |
| 3334 | + // Blocks able to take part, and their entities in document order. An | |
| 3335 | + // entity is one addressable node: a flat block's document, one | |
| 3336 | + // `@graph` entry, or one node of a top-level array. | |
| 3337 | + $blocks = []; | |
| 3338 | + $entities = []; | |
| 3339 | + | |
| 2780 | 3340 | foreach ($matches as $m) { |
| 2781 | - $attrs = $m[1]; | |
| 2782 | - $json_str = $m[3]; | |
| 3341 | + $start = $m[0][1]; | |
| 3342 | + foreach ($masked as $range) { | |
| 3343 | + if ($start >= $range[0] && $start < $range[1]) { | |
| 3344 | + continue 2; | |
| 3345 | + } | |
| 3346 | + } | |
| 3347 | + | |
| 3348 | + $attrs = $m[1][0]; | |
| 3349 | + $json_str = $m[3][0]; | |
| 2783 | 3350 | $decoded = json_decode($json_str, true); |
| 2784 | 3351 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { |
| 2785 | - continue; // skip unparseable blocks — leave them in place | |
| 3352 | + continue; // unparseable — leave the block in place | |
| 2786 | 3353 | } |
| 2787 | - $is_otto = stripos($attrs, 'data-otto') !== false; | |
| 2788 | 3354 | |
| 3355 | + $is_otto = preg_match($is_otto_attr, $attrs) === 1; | |
| 3356 | + $end = $start + strlen($m[0][0]); | |
| 3357 | + | |
| 3358 | + // A trailing newline swallowed on full removal keeps the output tidy. | |
| 3359 | + $end_ws = $end; | |
| 3360 | + if (substr($html, $end, 2) === "\r\n") { | |
| 3361 | + $end_ws = $end + 2; | |
| 3362 | + } elseif (substr($html, $end, 1) === "\n") { | |
| 3363 | + $end_ws = $end + 1; | |
| 3364 | + } | |
| 3365 | + | |
| 3366 | + $register = function (array $node) use (&$entities, $is_otto) { | |
| 3367 | + // Keyed like metasync_otto_structured_entity_key(): @id first, | |
| 3368 | + // @type tokens for the type fold. Entries with neither stay in | |
| 3369 | + // their block but cannot collide, so they keep entity === null. | |
| 3370 | + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : []; | |
| 3371 | + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : ''; | |
| 3372 | + if ($types === [] && $id === '') { | |
| 3373 | + return null; | |
| 3374 | + } | |
| 3375 | + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true]; | |
| 3376 | + return count($entities) - 1; | |
| 3377 | + }; | |
| 3378 | + | |
| 3379 | + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []]; | |
| 3380 | + | |
| 2789 | 3381 | 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 | - } | |
| 3382 | + foreach ($decoded['@graph'] as $node) { | |
| 3383 | + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null]; | |
| 2804 | 3384 | } |
| 3385 | + $block['shape'] = 'graph'; | |
| 2805 | 3386 | } 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; | |
| 3387 | + $block['shape'] = 'flat'; | |
| 3388 | + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)]; | |
| 3389 | + } elseif (isset($decoded[0])) { | |
| 3390 | + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The | |
| 3391 | + // persistence path already folds this shape; the render path | |
| 3392 | + // used to skip it entirely, leaving its duplicates standing. | |
| 3393 | + $block['shape'] = 'list'; | |
| 3394 | + foreach ($decoded as $node) { | |
| 3395 | + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null]; | |
| 3396 | + } | |
| 3397 | + } else { | |
| 3398 | + continue; // neither @type nor @graph nor array — nothing to key on | |
| 3399 | + } | |
| 3400 | + | |
| 3401 | + $blocks[] = $block; | |
| 3402 | + } | |
| 3403 | + | |
| 3404 | + foreach ($entities as $entity) { | |
| 3405 | + if ($entity['otto']) { | |
| 3406 | + $otto_present = true; | |
| 3407 | + break; | |
| 3408 | + } | |
| 3409 | + } | |
| 3410 | + if (empty($otto_present)) { | |
| 3411 | + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite | |
| 3412 | + } | |
| 3413 | + | |
| 3414 | + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO | |
| 3415 | + // node can displace a whole set of third-party duplicates at once. | |
| 3416 | + /** @var array<string, int> $by_id */ | |
| 3417 | + $by_id = []; // '@id:...' => entity index | |
| 3418 | + /** @var array<string, int[]> $by_type */ | |
| 3419 | + $by_type = []; // type token => entity index[] | |
| 3420 | + | |
| 3421 | + $release = function ($i) use (&$by_id, &$by_type, &$entities) { | |
| 3422 | + foreach ($entities[$i]['types'] as $token) { | |
| 3423 | + $holders = $by_type[$token] ?? []; | |
| 3424 | + $holders = array_values(array_diff($holders, [$i])); | |
| 3425 | + if ($holders === []) { | |
| 3426 | + unset($by_type[$token]); | |
| 2812 | 3427 | } else { |
| 2813 | - $third_by_type[$type] = $decoded; | |
| 3428 | + $by_type[$token] = $holders; | |
| 2814 | 3429 | } |
| 2815 | 3430 | } |
| 3431 | + $id = $entities[$i]['id']; | |
| 3432 | + if ($id !== '' && ($by_id[$id] ?? -1) === $i) { | |
| 3433 | + unset($by_id[$id]); | |
| 3434 | + } | |
| 3435 | + }; | |
| 3436 | + | |
| 3437 | + foreach ($entities as $i => $entity) { | |
| 3438 | + $alive = true; | |
| 3439 | + | |
| 3440 | + // The same real-world entity twice, whatever @type each copy carries. | |
| 3441 | + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) { | |
| 3442 | + $occupant = $by_id[$entity['id']]; | |
| 3443 | + if ($entity['otto'] && !$entities[$occupant]['otto']) { | |
| 3444 | + $entities[$occupant]['alive'] = false; | |
| 3445 | + $release($occupant); | |
| 3446 | + unset($by_id[$entity['id']]); // re-claimed below if the entity survives | |
| 3447 | + } else { | |
| 3448 | + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's) | |
| 3449 | + } | |
| 3450 | + } | |
| 3451 | + | |
| 3452 | + if ($alive) { | |
| 3453 | + foreach ($entity['types'] as $token) { | |
| 3454 | + $holders = $by_type[$token] ?? []; | |
| 3455 | + if ($holders === []) { | |
| 3456 | + continue; | |
| 3457 | + } | |
| 3458 | + $otto_holder = false; | |
| 3459 | + foreach ($holders as $holder) { | |
| 3460 | + if ($entities[$holder]['otto']) { | |
| 3461 | + $otto_holder = true; | |
| 3462 | + break; | |
| 3463 | + } | |
| 3464 | + } | |
| 3465 | + if ($otto_holder) { | |
| 3466 | + $alive = false; // an OTTO node already holds this token | |
| 3467 | + break; | |
| 3468 | + } | |
| 3469 | + if ($entity['otto']) { | |
| 3470 | + // OTTO displaces every third-party holder of the token and | |
| 3471 | + // releases ALL their claims, not just this one — otherwise | |
| 3472 | + // tokens the evicted node held alone would stay blocked and | |
| 3473 | + // later, legitimate nodes of that type would lose. | |
| 3474 | + foreach ($holders as $holder) { | |
| 3475 | + $entities[$holder]['alive'] = false; | |
| 3476 | + $release($holder); | |
| 3477 | + } | |
| 3478 | + } | |
| 3479 | + // Third party meeting third-party holders: both stand. | |
| 3480 | + } | |
| 3481 | + } | |
| 3482 | + | |
| 3483 | + if ($alive) { | |
| 3484 | + foreach ($entity['types'] as $token) { | |
| 3485 | + $by_type[$token][] = $i; | |
| 3486 | + } | |
| 3487 | + if ($entity['id'] !== '') { | |
| 3488 | + $by_id[$entity['id']] = $i; | |
| 3489 | + } | |
| 3490 | + } else { | |
| 3491 | + $entities[$i]['alive'] = false; | |
| 3492 | + } | |
| 2816 | 3493 | } |
| 2817 | 3494 | |
| 2818 | - // If OTTO provided no schema at all, nothing to deduplicate | |
| 2819 | - if (empty($otto_by_type) && empty($otto_graph)) { | |
| 2820 | - return $html; | |
| 3495 | + $any_evicted = false; | |
| 3496 | + foreach ($entities as $entity) { | |
| 3497 | + if (!$entity['alive']) { | |
| 3498 | + $any_evicted = true; | |
| 3499 | + break; | |
| 3500 | + } | |
| 2821 | 3501 | } |
| 3502 | + if (!$any_evicted) { | |
| 3503 | + return $html; // no collision — every original byte stays | |
| 3504 | + } | |
| 2822 | 3505 | |
| 2823 | - // Remove all JSON-LD blocks from HTML | |
| 2824 | - $html = preg_replace($pattern, '', $html); | |
| 3506 | + // Rebuild only the blocks that lost something, back-to-front so the | |
| 3507 | + // recorded offsets of earlier blocks stay valid. | |
| 3508 | + $edits = []; | |
| 3509 | + foreach ($blocks as $block) { | |
| 3510 | + $kept = []; | |
| 3511 | + $lost = 0; | |
| 3512 | + foreach ($block['items'] as $item) { | |
| 3513 | + if ($item['e'] === null || $entities[$item['e']]['alive']) { | |
| 3514 | + $kept[] = $item['node']; | |
| 3515 | + } else { | |
| 3516 | + $lost++; | |
| 3517 | + } | |
| 3518 | + } | |
| 3519 | + if ($lost === 0) { | |
| 3520 | + continue; | |
| 3521 | + } | |
| 3522 | + if ($kept === []) { | |
| 3523 | + $edits[] = [$block['start'], $block['end_ws'], '']; | |
| 3524 | + continue; | |
| 3525 | + } | |
| 2825 | 3526 | |
| 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 | - } | |
| 3527 | + if ($block['shape'] === 'list') { | |
| 3528 | + $payload = $kept; | |
| 3529 | + } else { // graph — a flat block that lost its only entity is removed above | |
| 3530 | + $payload = $block['decoded']; | |
| 3531 | + $payload['@graph'] = $kept; | |
| 3532 | + } | |
| 2833 | 3533 | |
| 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"; | |
| 3534 | + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); | |
| 3535 | + if (!is_string($json) || $json === '') { | |
| 3536 | + continue; // never drop a block whose payload failed to re-encode | |
| 3537 | + } | |
| 3538 | + | |
| 3539 | + // The original attribute string is reused verbatim so class, id and | |
| 3540 | + // the data-otto marker itself survive the rebuild. | |
| 3541 | + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>']; | |
| 2840 | 3542 | } |
| 2841 | 3543 | |
| 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); | |
| 3544 | + foreach (array_reverse($edits) as $edit) { | |
| 3545 | + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]); | |
| 2847 | 3546 | } |
| 2848 | 3547 | |
| 2849 | 3548 | return $html; |
| 2850 | 3549 | } |
| @@ -2863,14 +3562,14 @@ | ||
| 2863 | 3562 | return true; |
| 2864 | 3563 | } |
| 2865 | 3564 | |
| 2866 | 3565 | # Check if amp=1 query parameter is present |
| 2867 | - if (isset($_GET['amp']) && $_GET['amp'] == '1') { | |
| 3566 | + if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection | |
| 2868 | 3567 | return true; |
| 2869 | 3568 | } |
| 2870 | 3569 | |
| 2871 | 3570 | # Check for other common AMP query parameters |
| 2872 | - if (isset($_GET['amp']) && !empty($_GET['amp'])) { | |
| 3571 | + if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection | |
| 2873 | 3572 | return true; |
| 2874 | 3573 | } |
| 2875 | 3574 | |
| 2876 | 3575 | return false; |
| @@ -2875,8 +3574,32 @@ | ||
| 2875 | 3574 | |
| 2876 | 3575 | return false; |
| 2877 | 3576 | } |
| 2878 | 3577 | |
| 3578 | + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can | |
| 3579 | + # tell them apart from a third-party SEO plugin's blocks. Every insertion path | |
| 3580 | + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the | |
| 3581 | + # bytes they insert are identical and the "already applied" checks still match. | |
| 3582 | + private function stamp_otto_json_ld($html){ | |
| 3583 | + | |
| 3584 | + $stamped = preg_replace_callback( | |
| 3585 | + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i', | |
| 3586 | + function ($m) { | |
| 3587 | + # idempotent: a block already carrying a data-otto attribute — | |
| 3588 | + # anywhere in the tag, not just before `type` — is left alone, | |
| 3589 | + # never given a second one | |
| 3590 | + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) { | |
| 3591 | + return $m[0]; | |
| 3592 | + } | |
| 3593 | + return '<script' . $m[1] . ' data-otto="true">'; | |
| 3594 | + }, | |
| 3595 | + $html | |
| 3596 | + ); | |
| 3597 | + | |
| 3598 | + # preg_replace returns null on a PCRE failure — never hand back less than we got | |
| 3599 | + return is_string($stamped) ? $stamped : $html; | |
| 3600 | + } | |
| 3601 | + | |
| 2879 | 3602 | # this function insterts header html to the dom |
| 2880 | 3603 | function insert_header_html($data){ |
| 2881 | 3604 | |
| 2882 | 3605 | # check that we have the header html |
| @@ -2889,20 +3612,27 @@ | ||
| 2889 | 3612 | $head = $this->dom->find('head', 0); |
| 2890 | 3613 | |
| 2891 | 3614 | if ($head) { |
| 2892 | 3615 | |
| 2893 | - # Check if this is an AMP page - if so, don't add metasync_optimized attribute | |
| 3616 | + # Check if this is an AMP page - if so, skip the processed marker | |
| 2894 | 3617 | $is_amp_page = $this->is_amp_page(); |
| 2895 | 3618 | |
| 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 | - } | |
| 3619 | + # Append the new HTML at the start of the <head> tag. The tag itself | |
| 3620 | + # stays a literal '<head>' either way, so plugins that inject with a | |
| 3621 | + # literal str_replace('<head>', ...) still match the real tag — | |
| 3622 | + # see mark_head_optimized() for what breaks when it does not. | |
| 3623 | + $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER; | |
| 2904 | 3624 | |
| 3625 | + # Escape JSON-LD payload closers so a `</script>` inside a JSON | |
| 3626 | + # string value cannot terminate the element early (XSS/page corruption). | |
| 3627 | + # Then stamp the blocks as OTTO's, exactly as the string and HTTP | |
| 3628 | + # fallbacks do, so the deduper can attribute them. | |
| 3629 | + $escaped_header_insertion = $this->stamp_otto_json_ld( | |
| 3630 | + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion']) | |
| 3631 | + ); | |
| 3632 | + | |
| 3633 | + $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>'; | |
| 3634 | + | |
| 2905 | 3635 | } |
| 2906 | 3636 | |
| 2907 | 3637 | # save and reload DOM |
| 2908 | 3638 | $this->save_reload(); |
| @@ -3283,15 +4013,16 @@ | ||
| 3283 | 4013 | } |
| 3284 | 4014 | |
| 3285 | 4015 | # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it |
| 3286 | 4016 | if (!empty($replacement_data['header_html_insertion'])) { |
| 3287 | - $header_html_check = trim($replacement_data['header_html_insertion']); | |
| 4017 | + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM | |
| 4018 | + # path inserts the same bytes, so the already-applied check must compare | |
| 4019 | + # against the stamped form or it would insert a second copy. | |
| 4020 | + $header_html_insertion = $this->stamp_otto_json_ld( | |
| 4021 | + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion']) | |
| 4022 | + ); | |
| 4023 | + $header_html_check = trim($header_html_insertion); | |
| 3288 | 4024 | 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 | 4025 | $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion); |
| 3295 | 4026 | # Insert before </head> |
| 3296 | 4027 | $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1); |
| 3297 | 4028 | } |
| @@ -3363,9 +4094,9 @@ | ||
| 3363 | 4094 | |
| 3364 | 4095 | $result_html = preg_replace_callback( |
| 3365 | 4096 | '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is', |
| 3366 | 4097 | 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'))); | |
| 4098 | + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8'))); | |
| 3368 | 4099 | if ($inner_text !== $current_value) { |
| 3369 | 4100 | return $m[0]; |
| 3370 | 4101 | } |
| 3371 | 4102 | # Keep the author's inline formatting; skip when it can't |
| @@ -3423,8 +4154,9 @@ | ||
| 3423 | 4154 | |
| 3424 | 4155 | # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema |
| 3425 | 4156 | $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags'); |
| 3426 | 4157 | $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags'); |
| 4158 | + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags'); | |
| 3427 | 4159 | $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags'); |
| 3428 | 4160 | $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence'); |
| 3429 | 4161 | $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); |
| 3430 | 4162 | $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); |
| @@ -3443,11 +4175,11 @@ | ||
| 3443 | 4175 | |
| 3444 | 4176 | # Clear element cache array |
| 3445 | 4177 | $this->cached_elements = []; |
| 3446 | 4178 | |
| 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); | |
| 4179 | + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it) | |
| 4180 | + if (!$this->is_amp_page()) { | |
| 4181 | + $result_html = $this->mark_head_optimized($result_html); | |
| 3450 | 4182 | } |
| 3451 | 4183 | |
| 3452 | 4184 | $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes'); |
| 3453 | 4185 | |
| @@ -3483,8 +4215,12 @@ | ||
| 3483 | 4215 | # undo double-encoded numeric/hex character references produced by the |
| 3484 | 4216 | # bundled simplehtmldom serializer from attributes like data-x-icon-s="". |
| 3485 | 4217 | $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities'); |
| 3486 | 4218 | |
| 4219 | + # restore literal '&' query-string separators the serializer escaped to | |
| 4220 | + # '&', which silently truncates multi-family Google Fonts URLs. | |
| 4221 | + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands'); | |
| 4222 | + | |
| 3487 | 4223 | # Remove any lazy-video facade <style> hoisted into the page |
| 3488 | 4224 | # (runs while srcdoc is still tokenized, so the iframe's own copy is safe). |
| 3489 | 4225 | $result_html = $this->strip_hoisted_facade_styles($result_html); |
| 3490 | 4226 | |
| @@ -3501,12 +4237,26 @@ | ||
| 3501 | 4237 | |
| 3502 | 4238 | return $result_html; |
| 3503 | 4239 | |
| 3504 | 4240 | } catch (Exception $e) { |
| 3505 | - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage()); | |
| 4241 | + if (function_exists('metasync_otto_report_render_failure')) { | |
| 4242 | + metasync_otto_report_render_failure( | |
| 4243 | + 'MODIFICATION_EXCEPTION', | |
| 4244 | + 'OTTO rewrite threw: ' . $e->getMessage(), | |
| 4245 | + ['exception' => get_class($e)], | |
| 4246 | + 'error' | |
| 4247 | + ); | |
| 4248 | + } | |
| 3506 | 4249 | return false; |
| 3507 | 4250 | } catch (Error $e) { |
| 3508 | - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage()); | |
| 4251 | + if (function_exists('metasync_otto_report_render_failure')) { | |
| 4252 | + metasync_otto_report_render_failure( | |
| 4253 | + 'MODIFICATION_ERROR', | |
| 4254 | + 'OTTO rewrite errored: ' . $e->getMessage(), | |
| 4255 | + ['error' => get_class($e)], | |
| 4256 | + 'error' | |
| 4257 | + ); | |
| 4258 | + } | |
| 3509 | 4259 | return false; |
| 3510 | 4260 | } |
| 3511 | 4261 | } |
| 3512 | 4262 | |
| @@ -3555,23 +4305,15 @@ | ||
| 3555 | 4305 | } |
| 3556 | 4306 | } |
| 3557 | 4307 | } |
| 3558 | 4308 | |
| 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 | - ]; | |
| 4309 | + # Preserve third-party og:title/twitter:title tags here. They used to be | |
| 4310 | + # stripped whenever $remove_title was set, but that also removes them | |
| 4311 | + # when no custom title exists to replace them, leaving pages with no | |
| 4312 | + # og:title/twitter:title at all. OTTO's own title/OG dedup and | |
| 4313 | + # precedence passes already reconcile duplicates, so this branch no | |
| 4314 | + # longer needs to delete those tags. | |
| 3565 | 4315 | |
| 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 | 4316 | # Save changes |
| 3575 | 4317 | $this->save_reload(); |
| 3576 | 4318 | } |
| 3577 | 4319 | |
| @@ -3795,5 +4537,5 @@ | ||
| 3795 | 4537 | return $html; |
| 3796 | 4538 | } |
| 3797 | 4539 | |
| 3798 | 4540 | |
| 3799 | -} | |
| 4541 | +} | |