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 +393 -347 2.6.25 → 2.7.1 View file →
@@ -638,14 +638,8 @@
638 638 * @param array $replacement_data OTTO suggestions.
639 639 * @return string
640 640 */
641 641 private function apply_canonical_via_string($html, $replacement_data) {
642 - # Canonical feature switched off — do not apply OTTO's canonical, and do
643 - # not strip the canonical the page already carries.
644 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
645 - return $html;
646 - }
647 -
648 642 if (!is_string($html) || empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])) {
649 643 return $html;
650 644 }
651 645
@@ -791,9 +785,9 @@
791 785 $response = wp_remote_get($url_with_params, $args);
792 786
793 787 # Check for errors
794 788 if (is_wp_error($response)) {
795 - 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
796 790 return false;
797 791 }
798 792
799 793 # get the response body
@@ -803,9 +797,9 @@
803 797 $response_code = wp_remote_retrieve_response_code($response);
804 798
805 799 # if no change data skip
806 800 if (empty($body) || $response_code !== 200){
807 - 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
808 802 return false;
809 803 }
810 804
811 805 # set the html file path
@@ -998,9 +992,11 @@
998 992
999 993 # Check for timeout or connection errors
1000 994 if (is_wp_error($route_html)) {
1001 995 $error_msg = $route_html->get_error_message();
1002 - 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 + }
1003 999 return false;
1004 1000 }
1005 1001
1006 1002 # get body
@@ -1015,9 +1011,8 @@
1015 1011 // content and SEO metadata. The normal WordPress request can handle
1016 1012 // the redirect, and OTTO will process the destination on its own URL.
1017 1013 $effective_url = $this->get_effective_fetch_url($route_html);
1018 1014 if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
1019 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: SKIPPED - internal fetch followed redirect from ' . $route . ' to ' . $effective_url);
1020 1015 return false;
1021 1016 }
1022 1017
1023 1018 # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
@@ -1049,9 +1044,11 @@
1049 1044
1050 1045
1051 1046 # check not empty
1052 1047 if(empty($html_body) || $response_code !== 200){
1053 - 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 + }
1054 1051 return false;
1055 1052 }
1056 1053
1057 1054 # Skip DOM processing on oversized documents to avoid fatal OOM.
@@ -1157,15 +1154,8 @@
1157 1154 }, $result_html, 1);
1158 1155 } elseif ($type === 'meta') {
1159 1156 $name = $item['name'] ?? '';
1160 1157 $property = $item['property'] ?? '';
1161 -
1162 - # Skip entries owned by a switched-off feature, so OTTO
1163 - # neither writes its tag nor strips the page's own.
1164 - if ($this->replacement_is_feature_disabled($name, $property)) {
1165 - continue;
1166 - }
1167 -
1168 1158 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1169 1159
1170 1160 if (!empty($name)) {
1171 1161 $pattern = '/<meta\s+(?:name\s*=\s*["\']' . preg_quote($name, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']|content\s*=\s*["\'][^"\']*["\']\s+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'])\s*\/?>/i';
@@ -1222,9 +1212,9 @@
1222 1212
1223 1213 $result_html = preg_replace_callback(
1224 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
1225 1215 function ($m) use ($current_value, $recommended_value) {
1226 - $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')));
1227 1217 if ($inner_text !== $current_value) {
1228 1218 return $m[0];
1229 1219 }
1230 1220 # Keep the author's inline formatting; skip when it can't
@@ -1246,20 +1236,16 @@
1246 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');
1247 1237
1248 1238 # Apply insertions — only if DOM insertion didn't already apply it
1249 1239 if (!empty($replacement_data['header_html_insertion'])) {
1250 - # Escape JSON-LD payload closers first; the DOM path inserts the
1251 - # same escaped bytes, so the already-applied check must match them.
1252 - $escaped_header_insertion = $this->strip_disabled_features_from_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(
1253 1244 metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
1254 1245 );
1255 - $header_html_check = trim($escaped_header_insertion);
1256 - if ($header_html_check !== '' && strpos($result_html, $header_html_check) === false) {
1257 - $header_html_insertion = preg_replace(
1258 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
1259 - '<script$1type=$2application/ld+json$2 data-otto="true"',
1260 - $escaped_header_insertion
1261 - );
1246 + $header_html_check = trim($header_html_insertion);
1247 + if (strpos($result_html, $header_html_check) === false) {
1262 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
1263 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
1264 1250 }
1265 1251 }
@@ -1388,11 +1374,11 @@
1388 1374 * @return bool True when no meaningful redirect changed the route.
1389 1375 */
1390 1376 private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1391 1377 {
1392 - $effective = parse_url($effective_url);
1393 - $requested = parse_url($route);
1394 - $transport = parse_url($fetch_url);
1378 + $effective = wp_parse_url($effective_url);
1379 + $requested = wp_parse_url($route);
1380 + $transport = wp_parse_url($fetch_url);
1395 1381 if (!is_array($effective) || !is_array($requested)) {
1396 1382 return true;
1397 1383 }
1398 1384
@@ -2114,10 +2100,10 @@
2114 2100 }
2115 2101
2116 2102 # If it's a protocol-relative URL (//example.com), check if it matches our domain
2117 2103 if (strpos($url, '//') === 0) {
2118 - $parsed_home = parse_url($home_url);
2119 - $parsed_link = parse_url($url);
2104 + $parsed_home = wp_parse_url($home_url);
2105 + $parsed_link = wp_parse_url($url);
2120 2106
2121 2107 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
2122 2108 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
2123 2109 return false;
@@ -2239,44 +2225,22 @@
2239 2225 return $post_id ? (int) $post_id : 0;
2240 2226 }
2241 2227
2242 2228 /**
2243 - * Whether a meta tag carries the marker that means "the customer typed this".
2229 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2244 2230 *
2245 - * The sidebar stamps two different values on the same attribute: "custom" for
2246 - * a value the customer set, and "imported" for one migrated from another SEO
2247 - * plugin as a last-resort fallback. Only the first outranks OTTO — imported
2248 - * values are migration data, not a per-post decision, so OTTO must beat them.
2249 - *
2250 - * Matching the attribute NAME alone therefore promotes an imported tag to
2251 - * keeper and deletes OTTO's, which is the precedence inversion this check
2252 - * exists to prevent. Match the value exactly instead.
2253 - *
2254 - * Shared by both dedup passes rather than inlined twice: their keeper
2255 - * detection has already drifted apart once (see deduplicate_description_tags,
2256 - * which had to special-case the OTTO markers the generic pass misses), and a
2257 - * second copy of this rule would be free to drift the same way.
2258 - *
2259 - * @param string $tag A single <meta ...> tag.
2260 - * @return bool
2261 - */
2262 - private static function tag_is_customer_authored($tag) {
2263 - return (bool) preg_match('/data-metasync-seo\s*=\s*["\']custom["\']/i', $tag);
2264 - }
2265 -
2266 - /**
2267 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
2268 - * data-metasync-seo="custom"); insert one after <head> if none is present.
2269 - * Helper for apply_custom_seo_precedence().
2270 - *
2271 2231 * @param string $html
2272 2232 * @param string $attr "name" or "property"
2273 2233 * @param string $val e.g. "description", "og:description"
2274 2234 * @param string $content_escaped Already HTML-escaped content value
2235 + * @param string $marker Provenance marker for the winning tier
2275 2236 * @return string
2276 2237 */
2277 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
2278 - $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 . ' />';
2279 2243 $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2280 2244 $count = 0;
2281 2245 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2282 2246 return $tag;
@@ -2290,23 +2254,36 @@
2290 2254 }, $html, 1);
2291 2255 }
2292 2256
2293 2257 /**
2294 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2258 + * Make sure the precedence resolver is loaded before calling it.
2295 2259 *
2296 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
2297 - * OTTO for these fields, but later steps re-introduce OTTO's value:
2298 - * - the manual string-replacement title pass re-applies OTTO's title;
2299 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
2300 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
2301 - * exactly the case when the post is synced to a third-party SEO plugin and
2302 - * 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.
2303 2277 *
2304 - * Running LAST — after all dedup/precedence steps — this makes the user's
2305 - * explicit sidebar value authoritative regardless of how the tag was produced,
2306 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
2307 - * 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.
2308 2282 *
2283 + * OG and Twitter descriptions remain outside this method deliberately;
2284 + * social precedence is a separate follow-up scope.
2285 + *
2309 2286 * @param string $html Full HTML document (post-dedup).
2310 2287 * @return string
2311 2288 */
2312 2289 private function apply_custom_seo_precedence($html) {
@@ -2317,12 +2294,26 @@
2317 2294 if (!$post_id) {
2318 2295 return $html;
2319 2296 }
2320 2297
2321 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
2322 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
2323 - if (!empty($custom_title) && is_string($custom_title)) {
2324 - $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>';
2325 2316 $count = 0;
2326 2317 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2327 2318 return $title_tag;
2328 2319 }, $html, 1, $count);
@@ -2334,17 +2325,25 @@
2334 2325 }, $html, 1);
2335 2326 }
2336 2327 }
2337 2328
2338 - # Description — force the custom sidebar description into the SEO
2339 - # <meta name="description"> only. og:/twitter: description are social tags
2340 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
2341 - # earlier); this pass must NOT touch them, or it would override a
2342 - # separately user-set og:description with the plain SEO description.
2343 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2344 - if (!empty($custom_desc) && is_string($custom_desc)) {
2345 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
2346 - $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);
2347 2346 }
2348 2347
2349 2348 return $html;
2350 2349 }
@@ -2399,13 +2398,8 @@
2399 2398
2400 2399 # handle canonical links
2401 2400 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
2402 2401
2403 - # Canonical feature switched off — leave the page's canonical be.
2404 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
2405 - continue;
2406 - }
2407 -
2408 2402 # Protect manually-set canonical from OTTO override.
2409 2403 # Validated: a legacy row corrupted to "Array" must not
2410 2404 # count as a manual canonical and block OTTO's correct value.
2411 2405 if (function_exists('is_singular') && is_singular()) {
@@ -2466,13 +2460,8 @@
2466 2460
2467 2461 # Protect manually-set robots meta from OTTO override
2468 2462 # If the user set noindex via Common Robots Meta or meta_robots, honour it
2469 2463 if (!empty($name) && $name === 'robots') {
2470 - # Both robots features switched off — OTTO must not write the tag,
2471 - # leaving whatever core or a third-party plugin produced in place.
2472 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
2473 - return;
2474 - }
2475 2464 if (function_exists('is_singular') && is_singular()) {
2476 2465 $post_id = get_the_ID();
2477 2466 if ($post_id) {
2478 2467 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
@@ -2508,25 +2497,8 @@
2508 2497
2509 2498 # extract name value
2510 2499 $name = $data['name'] ?? false;
2511 2500
2512 - # Social feature switched off — OTTO must not create or rewrite any
2513 - # og:*/twitter:* tag. Both attributes are checked because either can
2514 - # carry either prefix depending on how the suggestion was authored.
2515 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
2516 - foreach ([$property, $name] as $candidate) {
2517 - if (!is_string($candidate) || $candidate === '') {
2518 - continue;
2519 - }
2520 - $candidate = strtolower(trim($candidate));
2521 - if (strncmp($candidate, 'og:', 3) === 0
2522 - || strncmp($candidate, 'twitter:', 8) === 0
2523 - || strncmp($candidate, 'article:', 8) === 0) {
2524 - return;
2525 - }
2526 - }
2527 - }
2528 -
2529 2501 # set the selector
2530 2502 $meta_selector = '';
2531 2503
2532 2504 # extend selector
@@ -2753,14 +2725,8 @@
2753 2725 * @param string $html Full HTML document.
2754 2726 * @return string HTML with at most one tag per OG/Twitter property.
2755 2727 */
2756 2728 private function deduplicate_og_twitter_tags($html) {
2757 - # Social feature switched off — MetaSync adds no OG/Twitter tags here, so
2758 - # whatever the page or a third-party plugin emitted is left as it is.
2759 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
2760 - return $html;
2761 - }
2762 -
2763 2729 # OG properties to deduplicate
2764 2730 $og_properties = [
2765 2731 'og:title', 'og:description', 'og:url', 'og:type',
2766 2732 'og:locale', 'og:site_name', 'og:image',
@@ -2829,13 +2795,8 @@
2829 2795 * @param string $html Full HTML document (post-dedup).
2830 2796 * @return string
2831 2797 */
2832 2798 private function apply_metabox_og_precedence($html) {
2833 - # Social feature switched off — the meta box values must not be injected.
2834 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
2835 - return $html;
2836 - }
2837 -
2838 2799 if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
2839 2800 return $html;
2840 2801 }
2841 2802 # Per-post OG only applies to singular posts/pages. On archives/home the queried
@@ -3031,9 +2992,9 @@
3031 2992 # OTTO's injected tag.
3032 2993 $custom_tag = null;
3033 2994 $otto_tag = null;
3034 2995 foreach ($all_tags as $tag) {
3035 - if ($custom_tag === null && self::tag_is_customer_authored($tag)) {
2996 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3036 2997 $custom_tag = $tag;
3037 2998 }
3038 2999 if ($otto_tag === null && stripos($tag, 'data-otto') !== false) {
3039 3000 $otto_tag = $tag;
@@ -3069,10 +3030,9 @@
3069 3030 *
3070 3031 * This runs at the buffer level — after every source has written its tag —
3071 3032 * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented
3072 3033 * "custom always wins over OTTO" intent):
3073 - * 1. custom sidebar (data-metasync-seo="custom" exactly; an "imported"
3074 - * value is migration data and must lose to OTTO)
3034 + * 1. custom sidebar (data-metasync-seo)
3075 3035 * 2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto)
3076 3036 * 3. first occurrence
3077 3037 *
3078 3038 * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches
@@ -3101,9 +3061,9 @@
3101 3061 # Choose the keeper by precedence: custom sidebar → OTTO → first.
3102 3062 $custom_tag = null;
3103 3063 $otto_tag = null;
3104 3064 foreach ($all_tags as $tag) {
3105 - if ($custom_tag === null && self::tag_is_customer_authored($tag)) {
3065 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3106 3066 $custom_tag = $tag;
3107 3067 }
3108 3068 if ($otto_tag === null && (
3109 3069 stripos($tag, 'data-otto-pixel') !== false ||
@@ -3141,14 +3101,8 @@
3141 3101 * @param string $html Full HTML document.
3142 3102 * @return string HTML with at most one canonical tag.
3143 3103 */
3144 3104 private function deduplicate_canonical_tags($html) {
3145 - # Canonical feature switched off — leave every canonical on the page,
3146 - # including any a third-party SEO plugin owns.
3147 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
3148 - return $html;
3149 - }
3150 -
3151 3105 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
3152 3106
3153 3107 if (preg_match_all($pattern, $html, $matches) <= 1) {
3154 3108 return $html;
@@ -3178,199 +3132,301 @@
3178 3132 return $html;
3179 3133 }
3180 3134
3181 3135 /**
3182 - * Whether a switched-off feature owns this header_replacements entry.
3136 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3183 3137 *
3184 - * The string fallbacks rewrite meta tags with a bare preg_replace, so they
3185 - * cannot lean on the DOM path's per-element guards. A disabled feature must
3186 - * not have OTTO write its tag, and — because these fallbacks delete every
3187 - * match before re-inserting — must not have the page's own tag removed
3188 - * either.
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.
3189 3142 *
3190 - * @param string $name The entry's `name` attribute, if any.
3191 - * @param string $property The entry's `property` attribute, if any.
3192 - * @return bool True when the entry must be skipped.
3143 + * @param mixed $raw The raw `@type` value.
3144 + * @return string[] Unique lowercased tokens, possibly empty.
3193 3145 */
3194 - private function replacement_is_feature_disabled($name, $property) {
3195 - foreach ([$name, $property] as $attribute) {
3196 - if ($attribute === '') {
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) {
3197 3151 continue;
3198 3152 }
3199 - $attribute = strtolower(trim($attribute));
3200 -
3201 - if ($attribute === 'robots') {
3202 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
3203 - return true;
3204 - }
3153 + $type = strtolower(trim((string) $type));
3154 + if ($type === '') {
3205 3155 continue;
3206 3156 }
3207 -
3208 - if (strncmp($attribute, 'og:', 3) === 0
3209 - || strncmp($attribute, 'twitter:', 8) === 0
3210 - || strncmp($attribute, 'article:', 8) === 0) {
3211 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
3212 - return true;
3213 - }
3214 - }
3157 + $tokens[$type] = true;
3215 3158 }
3216 3159
3217 - return false;
3160 + return array_keys($tokens);
3218 3161 }
3219 3162
3220 3163 /**
3221 - * Removes markup owned by a switched-off feature from an OTTO header
3222 - * insertion.
3164 + * Deduplicate JSON-LD schema blocks.
3223 3165 *
3224 - * header_html_insertion is a raw-HTML channel, not a JSON-LD one: the same
3225 - * payload can carry a canonical link and og/twitter meta tags as well as
3226 - * structured data. Each is therefore dropped against its own switch, so a
3227 - * disabled feature neither injects markup nor — because the matching dedup
3228 - * pass has stood down too — leaves a duplicate behind.
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:
3229 3169 *
3230 - * Runs after metasync_escape_json_ld_blocks_in_html(), so a payload that
3231 - * itself contains a `</script>` sequence cannot cut a match short.
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.
3232 3177 *
3233 - * @param string $html Header insertion HTML, already block-escaped.
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 + *
3189 + * @param string $html Full HTML.
3234 3190 * @return string
3235 3191 */
3236 - private function strip_disabled_features_from_insertion($html) {
3237 - if ($html === '') {
3192 + private function deduplicate_schema_tags($html) {
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)) {
3238 3200 return $html;
3239 3201 }
3240 3202
3241 - $patterns = [];
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 + }
3242 3211
3243 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
3244 - $patterns[] = '/<script(?:\s[^>]*)?type\s*=\s*(["\'])application\/ld\+json\1[^>]*>[\s\S]*?<\/script>\s*/i';
3245 - }
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';
3246 3216
3247 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
3248 - $patterns[] = '/<link\b[^>]*\brel\s*=\s*(["\'])canonical\1[^>]*>\s*/i';
3249 - }
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 = [];
3250 3222
3251 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
3252 - # og:*, twitter:* and article:* all belong to the social feature, and
3253 - # either attribute can carry either prefix depending on the payload.
3254 - $patterns[] = '/<meta\b[^>]*\b(?:property|name)\s*=\s*(["\'])(?:og|twitter|article):[^"\']*\1[^>]*>\s*/i';
3255 - }
3223 + foreach ($matches as $m) {
3224 + $start = $m[0][1];
3225 + foreach ($masked as $range) {
3226 + if ($start >= $range[0] && $start < $range[1]) {
3227 + continue 2;
3228 + }
3229 + }
3256 3230
3257 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
3258 - $patterns[] = '/<meta\b[^>]*\bname\s*=\s*(["\'])robots\1[^>]*>\s*/i';
3259 - }
3231 + $attrs = $m[1][0];
3232 + $json_str = $m[3][0];
3233 + $decoded = json_decode($json_str, true);
3234 + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
3235 + continue; // unparseable — leave the block in place
3236 + }
3260 3237
3261 - foreach ($patterns as $pattern) {
3262 - $stripped = preg_replace($pattern, '', $html);
3263 - # preg_replace returns null on failure; keeping the current string is
3264 - # safer than emitting nothing at all.
3265 - if ($stripped !== null) {
3266 - $html = $stripped;
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;
3267 3247 }
3268 - }
3269 3248
3270 - return $html;
3271 - }
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 + };
3272 3261
3273 - /**
3274 - * Deduplicate JSON-LD schema blocks.
3275 - *
3276 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
3277 - * blocks, keep OTTO's version for any @type that appears in both.
3278 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3279 - *
3280 - * @param string $html Full HTML.
3281 - * @return string
3282 - */
3283 - private function deduplicate_schema_tags($html) {
3284 - # Schema feature switched off — MetaSync injects no JSON-LD here, so the
3285 - # page's existing blocks (core, theme or another SEO plugin) are untouched.
3286 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
3287 - return $html;
3262 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3263 +
3264 + if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
3265 + foreach ($decoded['@graph'] as $node) {
3266 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3267 + }
3268 + $block['shape'] = 'graph';
3269 + } elseif (isset($decoded['@type'])) {
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;
3288 3285 }
3289 3286
3290 - // Find all JSON-LD script blocks
3291 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
3292 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3293 - return $html;
3287 + foreach ($entities as $entity) {
3288 + if ($entity['otto']) {
3289 + $otto_present = true;
3290 + break;
3291 + }
3294 3292 }
3293 + if (empty($otto_present)) {
3294 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3295 + }
3295 3296
3296 - $otto_by_type = []; // @type => decoded JSON object
3297 - $third_by_type = []; // @type => decoded JSON object
3298 - $otto_graph = []; // entries from OTTO @graph blocks
3299 - $third_graph = []; // entries from third-party @graph blocks
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[]
3300 3303
3301 - foreach ($matches as $m) {
3302 - $attrs = $m[1];
3303 - $json_str = $m[3];
3304 - $decoded = json_decode($json_str, true);
3305 - if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
3306 - continue; // skip unparseable blocks — leave them in place
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]);
3310 + } else {
3311 + $by_type[$token] = $holders;
3312 + }
3307 3313 }
3308 - $is_otto = stripos($attrs, 'data-otto') !== false;
3314 + $id = $entities[$i]['id'];
3315 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3316 + unset($by_id[$id]);
3317 + }
3318 + };
3309 3319
3310 - if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
3311 - foreach ($decoded['@graph'] as $entry) {
3312 - if (!isset($entry['@type'])) continue;
3313 - // JSON-LD allows @type to be a string OR an array of strings.
3314 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
3315 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
3316 - $type = is_array($entry['@type'])
3317 - ? (string) reset($entry['@type'])
3318 - : (string) $entry['@type'];
3319 - if ($type === '') continue;
3320 - if ($is_otto) {
3321 - $otto_graph[$type] = $entry;
3322 - } else {
3323 - $third_graph[$type] = $entry;
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;
3324 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.
3325 3363 }
3326 - } elseif (isset($decoded['@type'])) {
3327 - $type = is_array($decoded['@type'])
3328 - ? (string) reset($decoded['@type'])
3329 - : (string) $decoded['@type'];
3330 - if ($type === '') continue;
3331 - if ($is_otto) {
3332 - $otto_by_type[$type] = $decoded;
3333 - } else {
3334 - $third_by_type[$type] = $decoded;
3364 + }
3365 +
3366 + if ($alive) {
3367 + foreach ($entity['types'] as $token) {
3368 + $by_type[$token][] = $i;
3335 3369 }
3370 + if ($entity['id'] !== '') {
3371 + $by_id[$entity['id']] = $i;
3372 + }
3373 + } else {
3374 + $entities[$i]['alive'] = false;
3336 3375 }
3337 3376 }
3338 3377
3339 - // If OTTO provided no schema at all, nothing to deduplicate
3340 - if (empty($otto_by_type) && empty($otto_graph)) {
3341 - return $html;
3378 + $any_evicted = false;
3379 + foreach ($entities as $entity) {
3380 + if (!$entity['alive']) {
3381 + $any_evicted = true;
3382 + break;
3383 + }
3342 3384 }
3385 + if (!$any_evicted) {
3386 + return $html; // no collision — every original byte stays
3387 + }
3343 3388
3344 - // Remove all JSON-LD blocks from HTML
3345 - $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 + }
3346 3409
3347 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
3348 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
3349 - $rebuilt = '';
3350 - foreach ($kept as $decoded) {
3351 - $json = metasync_safe_json_ld_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
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 + }
3416 +
3417 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3352 3418 if (!is_string($json) || $json === '') {
3353 - continue; // never re-emit a block whose payload failed to encode
3419 + continue; // never drop a block whose payload failed to re-encode
3354 3420 }
3355 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $json . "</script>\n";
3356 - }
3357 3421
3358 - // Re-insert merged @graph block (if any entries exist)
3359 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
3360 - if (!empty($merged_graph)) {
3361 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
3362 - $graph_json = metasync_safe_json_ld_encode($graph_obj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3363 - if (is_string($graph_json) && $graph_json !== '') {
3364 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $graph_json . "</script>\n";
3365 - }
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>'];
3366 3425 }
3367 3426
3368 - // Re-inject before </head>
3369 - if (!empty($rebuilt)) {
3370 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
3371 - return $rebuilt . $m[1];
3372 - }, $html, 1);
3427 + foreach (array_reverse($edits) as $edit) {
3428 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
3373 3429 }
3374 3430
3375 3431 return $html;
3376 3432 }
@@ -3389,14 +3445,14 @@
3389 3445 return true;
3390 3446 }
3391 3447
3392 3448 # Check if amp=1 query parameter is present
3393 - 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
3394 3450 return true;
3395 3451 }
3396 3452
3397 3453 # Check for other common AMP query parameters
3398 - 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
3399 3455 return true;
3400 3456 }
3401 3457
3402 3458 return false;
@@ -3401,8 +3457,32 @@
3401 3457
3402 3458 return false;
3403 3459 }
3404 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 +
3405 3485 # this function insterts header html to the dom
3406 3486 function insert_header_html($data){
3407 3487
3408 3488 # check that we have the header html
@@ -3425,13 +3505,12 @@
3425 3505 # see mark_head_optimized() for what breaks when it does not.
3426 3506 $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
3427 3507
3428 3508 # Escape JSON-LD payload closers so a `</script>` inside a JSON
3429 - # string value cannot terminate the element early (XSS/page corruption),
3430 - # then drop whatever belongs to a switched-off feature. This is the DOM
3431 - # insertion path and it runs before the string fallbacks, so it needs the
3432 - # same filtering they apply.
3433 - $escaped_header_insertion = $this->strip_disabled_features_from_insertion(
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(
3434 3513 metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3435 3514 );
3436 3515
3437 3516 $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
@@ -3741,15 +3820,8 @@
3741 3820 }
3742 3821 } elseif ($type === 'meta') {
3743 3822 $name = $item['name'] ?? '';
3744 3823 $property = $item['property'] ?? '';
3745 -
3746 - # Skip entries owned by a switched-off feature, so OTTO
3747 - # neither writes its tag nor strips the page's own.
3748 - if ($this->replacement_is_feature_disabled($name, $property)) {
3749 - continue;
3750 - }
3751 -
3752 3824 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
3753 3825
3754 3826 if (!empty($name)) {
3755 3827 # MEMORY OPTIMIZED: Count meta tags without storing all matches
@@ -3824,20 +3896,16 @@
3824 3896 }
3825 3897
3826 3898 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3827 3899 if (!empty($replacement_data['header_html_insertion'])) {
3828 - # Escape JSON-LD payload closers first; the DOM path inserts the
3829 - # same escaped bytes, so the already-applied check must match them.
3830 - $escaped_header_insertion = $this->strip_disabled_features_from_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(
3831 3904 metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3832 3905 );
3833 - $header_html_check = trim($escaped_header_insertion);
3834 - if ($header_html_check !== '' && strpos($result_html, $header_html_check) === false) {
3835 - $header_html_insertion = preg_replace(
3836 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3837 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3838 - $escaped_header_insertion
3839 - );
3906 + $header_html_check = trim($header_html_insertion);
3907 + if (strpos($result_html, $header_html_check) === false) {
3840 3908 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3841 3909 # Insert before </head>
3842 3910 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3843 3911 }
@@ -3909,9 +3977,9 @@
3909 3977
3910 3978 $result_html = preg_replace_callback(
3911 3979 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3912 3980 function ($m) use ($current_value, $recommended_value) {
3913 - $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')));
3914 3982 if ($inner_text !== $current_value) {
3915 3983 return $m[0];
3916 3984 }
3917 3985 # Keep the author's inline formatting; skip when it can't
@@ -4051,12 +4119,26 @@
4051 4119
4052 4120 return $result_html;
4053 4121
4054 4122 } catch (Exception $e) {
4055 - 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 + }
4056 4131 return false;
4057 4132 } catch (Error $e) {
4058 - 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 + }
4059 4141 return false;
4060 4142 }
4061 4143 }
4062 4144
@@ -4097,16 +4179,8 @@
4097 4179 # Handle description tags - only remove specific tags that Otto is providing
4098 4180 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
4099 4181 # Remove only the specific description tags that Otto is providing
4100 4182 foreach ($remove_description_tags as $selector) {
4101 - # With the social feature switched off MetaSync writes no
4102 - # og:/twitter: tag of its own, so deleting the page's would leave
4103 - # it with none. The plain description belongs to a different
4104 - # feature and is unaffected.
4105 - if ($this->selector_is_social($selector)
4106 - && Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
4107 - continue;
4108 - }
4109 4183 $tags = $this->dom->find($selector);
4110 4184 foreach ($tags as $tag) {
4111 4185 # Remove the tag
4112 4186 $tag->outertext = '';
@@ -4113,45 +4187,17 @@
4113 4187 }
4114 4188 }
4115 4189 }
4116 4190
4117 - # og:title / twitter:title are social tags, so the social switch decides
4118 - # whether MetaSync may take the page's away (keep main <title>).
4119 - if ($remove_title && !$has_custom_title
4120 - && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG)) {
4121 - # Remove Open Graph and Twitter title tags (keep main <title>)
4122 - $title_selectors = [
4123 - 'meta[property=og:title]',
4124 - 'meta[name=twitter:title]',
4125 - ];
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.
4126 4197
4127 - foreach ($title_selectors as $selector) {
4128 - $tags = $this->dom->find($selector);
4129 - foreach ($tags as $tag) {
4130 - $tag->outertext = ''; # Remove the tag
4131 - }
4132 - }
4133 - }
4134 -
4135 4198 # Save changes
4136 4199 $this->save_reload();
4137 - }
4138 -
4139 - /**
4140 - * Whether a DOM selector targets an Open Graph / Twitter / article tag.
4141 - *
4142 - * The description selectors reach this class from the caller, so they have
4143 - * to be inspected rather than assumed.
4144 - *
4145 - * @param string $selector SimpleHtmlDom selector.
4146 - * @return bool
4147 - */
4148 - private function selector_is_social($selector) {
4149 - $selector = strtolower($selector);
4150 -
4151 - return strpos($selector, 'og:') !== false
4152 - || strpos($selector, 'twitter:') !== false
4153 - || strpos($selector, 'article:') !== false;
4154 4200 }
4155 4201
4156 4202 /**
4157 4203 * Clean OTTO internal fetch query parameters from rendered HTML.