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 +419 -410 2.6.26 → 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;
@@ -2238,51 +2224,23 @@
2238 2224 $post_id = function_exists('get_queried_object_id') ? get_queried_object_id() : 0;
2239 2225 return $post_id ? (int) $post_id : 0;
2240 2226 }
2241 2227
2242 - private function social_output_disabled_for_buffer() {
2243 - $post_id = $this->get_buffer_post_id();
2244 - return $post_id > 0 && class_exists('Metasync_OpenGraph')
2245 - && Metasync_OpenGraph::is_social_output_disabled($post_id);
2246 - }
2247 -
2248 2228 /**
2249 - * Whether a meta tag carries the marker that means "the customer typed this".
2229 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2250 2230 *
2251 - * The sidebar stamps two different values on the same attribute: "custom" for
2252 - * a value the customer set, and "imported" for one migrated from another SEO
2253 - * plugin as a last-resort fallback. Only the first outranks OTTO — imported
2254 - * values are migration data, not a per-post decision, so OTTO must beat them.
2255 - *
2256 - * Matching the attribute NAME alone therefore promotes an imported tag to
2257 - * keeper and deletes OTTO's, which is the precedence inversion this check
2258 - * exists to prevent. Match the value exactly instead.
2259 - *
2260 - * Shared by both dedup passes rather than inlined twice: their keeper
2261 - * detection has already drifted apart once (see deduplicate_description_tags,
2262 - * which had to special-case the OTTO markers the generic pass misses), and a
2263 - * second copy of this rule would be free to drift the same way.
2264 - *
2265 - * @param string $tag A single <meta ...> tag.
2266 - * @return bool
2267 - */
2268 - private static function tag_is_customer_authored($tag) {
2269 - return (bool) preg_match('/data-metasync-seo\s*=\s*["\']custom["\']/i', $tag);
2270 - }
2271 -
2272 - /**
2273 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
2274 - * data-metasync-seo="custom"); insert one after <head> if none is present.
2275 - * Helper for apply_custom_seo_precedence().
2276 - *
2277 2231 * @param string $html
2278 2232 * @param string $attr "name" or "property"
2279 2233 * @param string $val e.g. "description", "og:description"
2280 2234 * @param string $content_escaped Already HTML-escaped content value
2235 + * @param string $marker Provenance marker for the winning tier
2281 2236 * @return string
2282 2237 */
2283 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
2284 - $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 . ' />';
2285 2243 $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2286 2244 $count = 0;
2287 2245 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2288 2246 return $tag;
@@ -2296,23 +2254,36 @@
2296 2254 }, $html, 1);
2297 2255 }
2298 2256
2299 2257 /**
2300 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2258 + * Make sure the precedence resolver is loaded before calling it.
2301 2259 *
2302 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
2303 - * OTTO for these fields, but later steps re-introduce OTTO's value:
2304 - * - the manual string-replacement title pass re-applies OTTO's title;
2305 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
2306 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
2307 - * exactly the case when the post is synced to a third-party SEO plugin and
2308 - * 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.
2309 2277 *
2310 - * Running LAST — after all dedup/precedence steps — this makes the user's
2311 - * explicit sidebar value authoritative regardless of how the tag was produced,
2312 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
2313 - * 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.
2314 2282 *
2283 + * OG and Twitter descriptions remain outside this method deliberately;
2284 + * social precedence is a separate follow-up scope.
2285 + *
2315 2286 * @param string $html Full HTML document (post-dedup).
2316 2287 * @return string
2317 2288 */
2318 2289 private function apply_custom_seo_precedence($html) {
@@ -2323,12 +2294,26 @@
2323 2294 if (!$post_id) {
2324 2295 return $html;
2325 2296 }
2326 2297
2327 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
2328 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
2329 - if (!empty($custom_title) && is_string($custom_title)) {
2330 - $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>';
2331 2316 $count = 0;
2332 2317 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2333 2318 return $title_tag;
2334 2319 }, $html, 1, $count);
@@ -2340,17 +2325,25 @@
2340 2325 }, $html, 1);
2341 2326 }
2342 2327 }
2343 2328
2344 - # Description — force the custom sidebar description into the SEO
2345 - # <meta name="description"> only. og:/twitter: description are social tags
2346 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
2347 - # earlier); this pass must NOT touch them, or it would override a
2348 - # separately user-set og:description with the plain SEO description.
2349 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2350 - if (!empty($custom_desc) && is_string($custom_desc)) {
2351 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
2352 - $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);
2353 2346 }
2354 2347
2355 2348 return $html;
2356 2349 }
@@ -2405,13 +2398,8 @@
2405 2398
2406 2399 # handle canonical links
2407 2400 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
2408 2401
2409 - # Canonical feature switched off — leave the page's canonical be.
2410 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
2411 - continue;
2412 - }
2413 -
2414 2402 # Protect manually-set canonical from OTTO override.
2415 2403 # Validated: a legacy row corrupted to "Array" must not
2416 2404 # count as a manual canonical and block OTTO's correct value.
2417 2405 if (function_exists('is_singular') && is_singular()) {
@@ -2472,13 +2460,8 @@
2472 2460
2473 2461 # Protect manually-set robots meta from OTTO override
2474 2462 # If the user set noindex via Common Robots Meta or meta_robots, honour it
2475 2463 if (!empty($name) && $name === 'robots') {
2476 - # Both robots features switched off — OTTO must not write the tag,
2477 - # leaving whatever core or a third-party plugin produced in place.
2478 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
2479 - return;
2480 - }
2481 2464 if (function_exists('is_singular') && is_singular()) {
2482 2465 $post_id = get_the_ID();
2483 2466 if ($post_id) {
2484 2467 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
@@ -2514,23 +2497,8 @@
2514 2497
2515 2498 # extract name value
2516 2499 $name = $data['name'] ?? false;
2517 2500
2518 - if ($this->social_output_disabled_for_buffer()
2519 - || Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
2520 - foreach ([$property, $name] as $candidate) {
2521 - if (!is_string($candidate) || $candidate === '') {
2522 - continue;
2523 - }
2524 - $candidate = strtolower(trim($candidate));
2525 - if (strncmp($candidate, 'og:', 3) === 0
2526 - || strncmp($candidate, 'twitter:', 8) === 0
2527 - || strncmp($candidate, 'article:', 8) === 0) {
2528 - return;
2529 - }
2530 - }
2531 - }
2532 -
2533 2501 # set the selector
2534 2502 $meta_selector = '';
2535 2503
2536 2504 # extend selector
@@ -2757,21 +2725,8 @@
2757 2725 * @param string $html Full HTML document.
2758 2726 * @return string HTML with at most one tag per OG/Twitter property.
2759 2727 */
2760 2728 private function deduplicate_og_twitter_tags($html) {
2761 - # A per-post opt-out means MetaSync must neither emit nor deduplicate
2762 - # social tags. In particular, leave third-party tags untouched.
2763 - $post_id = $this->get_buffer_post_id();
2764 - if ($post_id > 0 && get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
2765 - return $html;
2766 - }
2767 -
2768 - # Social feature switched off — MetaSync adds no OG/Twitter tags here, so
2769 - # whatever the page or a third-party plugin emitted is left as it is.
2770 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
2771 - return $html;
2772 - }
2773 -
2774 2729 # OG properties to deduplicate
2775 2730 $og_properties = [
2776 2731 'og:title', 'og:description', 'og:url', 'og:type',
2777 2732 'og:locale', 'og:site_name', 'og:image',
@@ -2795,24 +2750,16 @@
2795 2750 }
2796 2751
2797 2752 /**
2798 2753 * Read one of the meta box's social title/description keys with the "Auto Draft"
2799 - * placeholder and metabox-default snapshots collapsed to ''.
2754 + * placeholder collapsed to ''.
2800 2755 *
2801 - * Delegates to Metasync_OpenGraph so the collapse definitions live in one
2802 - * place. Degrades per capability when that class — or individual methods on
2803 - * it — is unavailable: this buffer filter runs on the front end, and a
2804 - * partially updated install can pair a newer otto/ file with an older
2805 - * includes/ one, where calling a method the loaded class doesn't define
2806 - * would fatal the page rather than degrade. The probes target the collapse
2807 - * methods rather than get_social_meta itself because the reader predates
2808 - * them — an older build defines it while collapsing only the "Auto Draft"
2809 - * placeholder, which here would read a pre-fill snapshot as a deliberate
2810 - * override. On the degraded path the Auto Draft placeholder collapse —
2811 - * which predates the snapshot strips — still runs when present, so a skew
2812 - * never resurrects an "Auto Draft" row either. For anything the available
2813 - * methods don't collapse, the default comparisons in
2814 - * apply_metabox_og_precedence() pick up the defence instead.
2756 + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
2757 + * place. Falls back to a raw read when that class — or that method on it — is
2758 + * unavailable: this buffer filter runs on the front end, and a partially updated
2759 + * install can pair a newer otto/ file with an older includes/ one, where calling
2760 + * a method the loaded class doesn't define would fatal the page rather than
2761 + * degrade.
2815 2762 *
2816 2763 * @param int $post_id
2817 2764 * @param string $key
2818 2765 * @return string
@@ -2821,22 +2768,12 @@
2821 2768 // PHPStan resolves the literal class-string against the current source and so
2822 2769 // sees the method as always present; the check is deliberate runtime
2823 2770 // version-skew defence. Suppressed the same way otto_pixel.php does.
2824 2771 // @phpstan-ignore-next-line function.alreadyNarrowedType
2825 - if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot') && method_exists('Metasync_OpenGraph', 'strip_description_snapshot')) {
2772 + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
2826 2773 return Metasync_OpenGraph::get_social_meta($post_id, $key);
2827 2774 }
2828 - $value = get_post_meta($post_id, $key, true);
2829 - // A skew-era build can leave an array in a scalar slot (multiple rows
2830 - // under one key); a bare cast would emit "Array" into the tag.
2831 - if (!is_scalar($value)) {
2832 - $value = '';
2833 - }
2834 - // @phpstan-ignore-next-line function.alreadyNarrowedType
2835 - if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')) {
2836 - $value = Metasync_OpenGraph::strip_auto_draft_title($value);
2837 - }
2838 - return (string) $value;
2775 + return (string) get_post_meta($post_id, $key, true);
2839 2776 }
2840 2777
2841 2778 /**
2842 2779 * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
@@ -2858,13 +2795,8 @@
2858 2795 * @param string $html Full HTML document (post-dedup).
2859 2796 * @return string
2860 2797 */
2861 2798 private function apply_metabox_og_precedence($html) {
2862 - # Social feature switched off — the meta box values must not be injected.
2863 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
2864 - return $html;
2865 - }
2866 -
2867 2799 if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
2868 2800 return $html;
2869 2801 }
2870 2802 # Per-post OG only applies to singular posts/pages. On archives/home the queried
@@ -2872,22 +2804,25 @@
2872 2804 if (!is_singular()) {
2873 2805 return $html;
2874 2806 }
2875 2807 $post_id = get_queried_object_id();
2876 - if (!$post_id || get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
2808 + if (!$post_id) {
2877 2809 return $html;
2878 2810 }
2879 2811
2812 + # Respect the meta box opt-out (only an explicit '0' disables it)
2813 + if (get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
2814 + return $html;
2815 + }
2816 +
2880 2817 # Explicit (user-typed) meta box values. A non-empty value means the user set
2881 2818 # this field themselves and it must take priority over OTTO.
2882 2819 #
2883 2820 # The four social title/description keys are read through
2884 2821 # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
2885 - # placeholder — and, on the title keys, a stored snapshot of the post
2886 - # title, and on the description keys a stored snapshot of the resolved
2887 - # description — to ''. Without that, a row polluted by the meta box
2888 - # pre-fill reads as a deliberate override here and would beat OTTO's
2889 - # correct og:title, or go stale after a rename or excerpt edit.
2822 + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
2823 + # brand-new post reads as a deliberate override here (it differs from the real
2824 + # title) and would beat OTTO's correct og:title.
2890 2825 $og_title_set = $this->social_meta($post_id, '_metasync_og_title');
2891 2826 $og_desc_set = $this->social_meta($post_id, '_metasync_og_description');
2892 2827 $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2893 2828 $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
@@ -2897,20 +2832,14 @@
2897 2832 $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description');
2898 2833 $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2899 2834 $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2900 2835
2901 - # Default OG values the meta box pre-fills (generated excerpt / featured
2902 - # image). Every field stays a genuine override only when its stored value
2903 - # differs from the default — the image default is still PERSISTED by the
2904 - # meta box on save, and for title/description the comparison is redundant
2905 - # while the collapsed read above runs (a value that survives the collapse
2906 - # provably differs from its default), but it is what restores develop's
2907 - # defence on a partially updated install: there social_meta() falls back
2908 - # to the raw read, and without the comparison a pre-fill snapshot would
2909 - # read as a deliberate override and beat OTTO's correct tag. Reuse the
2910 - # emitter's own resolver so the defaults match exactly (falling back to
2911 - # empty defaults, i.e. treat nothing as customized, if the instance is
2912 - # unavailable).
2836 + # Default OG values the meta box pre-fills (post title / generated excerpt /
2837 + # featured image). The meta box PERSISTS these defaults on save, so a non-empty
2838 + # _metasync_og_* value alone does not prove the user customized it — a field is a
2839 + # genuine override only when its stored value differs from this default. Reuse the
2840 + # emitter's own resolver so the defaults match exactly (falling back to empty
2841 + # defaults, i.e. treat nothing as customized, if the instance is unavailable).
2913 2842 $defaults = ['title' => '', 'description' => '', 'image' => ''];
2914 2843 if (class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::get_instance()) {
2915 2844 $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
2916 2845 }
@@ -2927,11 +2856,10 @@
2927 2856 $site_name = get_bloginfo('name');
2928 2857
2929 2858 # Twitter inherits the OG value when its own field is blank; a user-customized
2930 2859 # og:image therefore also overrides OTTO's twitter:image (the meta box treats them
2931 - # as one). A twitter field is custom if its own value was set (against the same
2932 - # default comparison — redundant under the collapsed read, develop's defence on
2933 - # the raw-read fallback) OR the OG field it inherits was customized.
2860 + # as one). A twitter field is custom if its own value differs from the default OR
2861 + # the OG field it inherits was customized.
2934 2862 $tw_card = $tw_card_set ?: 'summary_large_image';
2935 2863 $tw_title = $tw_title_set !== '' ? $tw_title_set : $og_title;
2936 2864 $tw_desc = $tw_desc_set !== '' ? $tw_desc_set : $og_desc;
2937 2865 $tw_image = $tw_image_set !== '' ? $tw_image_set : $og_image;
@@ -3064,9 +2992,9 @@
3064 2992 # OTTO's injected tag.
3065 2993 $custom_tag = null;
3066 2994 $otto_tag = null;
3067 2995 foreach ($all_tags as $tag) {
3068 - if ($custom_tag === null && self::tag_is_customer_authored($tag)) {
2996 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3069 2997 $custom_tag = $tag;
3070 2998 }
3071 2999 if ($otto_tag === null && stripos($tag, 'data-otto') !== false) {
3072 3000 $otto_tag = $tag;
@@ -3102,10 +3030,9 @@
3102 3030 *
3103 3031 * This runs at the buffer level — after every source has written its tag —
3104 3032 * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented
3105 3033 * "custom always wins over OTTO" intent):
3106 - * 1. custom sidebar (data-metasync-seo="custom" exactly; an "imported"
3107 - * value is migration data and must lose to OTTO)
3034 + * 1. custom sidebar (data-metasync-seo)
3108 3035 * 2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto)
3109 3036 * 3. first occurrence
3110 3037 *
3111 3038 * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches
@@ -3134,9 +3061,9 @@
3134 3061 # Choose the keeper by precedence: custom sidebar → OTTO → first.
3135 3062 $custom_tag = null;
3136 3063 $otto_tag = null;
3137 3064 foreach ($all_tags as $tag) {
3138 - if ($custom_tag === null && self::tag_is_customer_authored($tag)) {
3065 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3139 3066 $custom_tag = $tag;
3140 3067 }
3141 3068 if ($otto_tag === null && (
3142 3069 stripos($tag, 'data-otto-pixel') !== false ||
@@ -3174,14 +3101,8 @@
3174 3101 * @param string $html Full HTML document.
3175 3102 * @return string HTML with at most one canonical tag.
3176 3103 */
3177 3104 private function deduplicate_canonical_tags($html) {
3178 - # Canonical feature switched off — leave every canonical on the page,
3179 - # including any a third-party SEO plugin owns.
3180 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
3181 - return $html;
3182 - }
3183 -
3184 3105 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
3185 3106
3186 3107 if (preg_match_all($pattern, $html, $matches) <= 1) {
3187 3108 return $html;
@@ -3211,203 +3132,301 @@
3211 3132 return $html;
3212 3133 }
3213 3134
3214 3135 /**
3215 - * Whether a switched-off feature owns this header_replacements entry.
3136 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3216 3137 *
3217 - * The string fallbacks rewrite meta tags with a bare preg_replace, so they
3218 - * cannot lean on the DOM path's per-element guards. A disabled feature must
3219 - * not have OTTO write its tag, and — because these fallbacks delete every
3220 - * match before re-inserting — must not have the page's own tag removed
3221 - * 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.
3222 3142 *
3223 - * @param string $name The entry's `name` attribute, if any.
3224 - * @param string $property The entry's `property` attribute, if any.
3225 - * @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.
3226 3145 */
3227 - private function replacement_is_feature_disabled($name, $property) {
3228 - foreach ([$name, $property] as $attribute) {
3229 - 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) {
3230 3151 continue;
3231 3152 }
3232 - $attribute = strtolower(trim($attribute));
3233 -
3234 - if ($attribute === 'robots') {
3235 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
3236 - return true;
3237 - }
3153 + $type = strtolower(trim((string) $type));
3154 + if ($type === '') {
3238 3155 continue;
3239 3156 }
3240 -
3241 - if (strncmp($attribute, 'og:', 3) === 0
3242 - || strncmp($attribute, 'twitter:', 8) === 0
3243 - || strncmp($attribute, 'article:', 8) === 0) {
3244 - if ($this->social_output_disabled_for_buffer()) {
3245 - return true;
3246 - }
3247 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
3248 - return true;
3249 - }
3250 - }
3157 + $tokens[$type] = true;
3251 3158 }
3252 3159
3253 - return false;
3160 + return array_keys($tokens);
3254 3161 }
3255 3162
3256 3163 /**
3257 - * Removes markup owned by a switched-off feature from an OTTO header
3258 - * insertion.
3164 + * Deduplicate JSON-LD schema blocks.
3259 3165 *
3260 - * header_html_insertion is a raw-HTML channel, not a JSON-LD one: the same
3261 - * payload can carry a canonical link and og/twitter meta tags as well as
3262 - * structured data. Each is therefore dropped against its own switch, so a
3263 - * disabled feature neither injects markup nor — because the matching dedup
3264 - * 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:
3265 3169 *
3266 - * Runs after metasync_escape_json_ld_blocks_in_html(), so a payload that
3267 - * 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.
3268 3177 *
3269 - * @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.
3270 3190 * @return string
3271 3191 */
3272 - private function strip_disabled_features_from_insertion($html) {
3273 - 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)) {
3274 3200 return $html;
3275 3201 }
3276 3202
3277 - $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 + }
3278 3211
3279 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
3280 - $patterns[] = '/<script(?:\s[^>]*)?type\s*=\s*(["\'])application\/ld\+json\1[^>]*>[\s\S]*?<\/script>\s*/i';
3281 - }
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';
3282 3216
3283 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
3284 - $patterns[] = '/<link\b[^>]*\brel\s*=\s*(["\'])canonical\1[^>]*>\s*/i';
3285 - }
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 = [];
3286 3222
3287 - if ($this->social_output_disabled_for_buffer()
3288 - || Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
3289 - # og:*, twitter:* and article:* all belong to the social feature, and
3290 - # either attribute can carry either prefix depending on the payload.
3291 - $patterns[] = '/<meta\b[^>]*\b(?:property|name)\s*=\s*(["\'])(?:og|twitter|article):[^"\']*\1[^>]*>\s*/i';
3292 - }
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 + }
3293 3230
3294 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
3295 - $patterns[] = '/<meta\b[^>]*\bname\s*=\s*(["\'])robots\1[^>]*>\s*/i';
3296 - }
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 + }
3297 3237
3298 - foreach ($patterns as $pattern) {
3299 - $stripped = preg_replace($pattern, '', $html);
3300 - # preg_replace returns null on failure; keeping the current string is
3301 - # safer than emitting nothing at all.
3302 - if ($stripped !== null) {
3303 - $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;
3304 3247 }
3305 - }
3306 3248
3307 - return $html;
3308 - }
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 + };
3309 3261
3310 - /**
3311 - * Deduplicate JSON-LD schema blocks.
3312 - *
3313 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
3314 - * blocks, keep OTTO's version for any @type that appears in both.
3315 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3316 - *
3317 - * @param string $html Full HTML.
3318 - * @return string
3319 - */
3320 - private function deduplicate_schema_tags($html) {
3321 - # Schema feature switched off — MetaSync injects no JSON-LD here, so the
3322 - # page's existing blocks (core, theme or another SEO plugin) are untouched.
3323 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
3324 - 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;
3325 3285 }
3326 3286
3327 - // Find all JSON-LD script blocks
3328 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
3329 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3330 - return $html;
3287 + foreach ($entities as $entity) {
3288 + if ($entity['otto']) {
3289 + $otto_present = true;
3290 + break;
3291 + }
3331 3292 }
3293 + if (empty($otto_present)) {
3294 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3295 + }
3332 3296
3333 - $otto_by_type = []; // @type => decoded JSON object
3334 - $third_by_type = []; // @type => decoded JSON object
3335 - $otto_graph = []; // entries from OTTO @graph blocks
3336 - $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[]
3337 3303
3338 - foreach ($matches as $m) {
3339 - $attrs = $m[1];
3340 - $json_str = $m[3];
3341 - $decoded = json_decode($json_str, true);
3342 - if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
3343 - 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 + }
3344 3313 }
3345 - $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 + };
3346 3319
3347 - if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
3348 - foreach ($decoded['@graph'] as $entry) {
3349 - if (!isset($entry['@type'])) continue;
3350 - // JSON-LD allows @type to be a string OR an array of strings.
3351 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
3352 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
3353 - $type = is_array($entry['@type'])
3354 - ? (string) reset($entry['@type'])
3355 - : (string) $entry['@type'];
3356 - if ($type === '') continue;
3357 - if ($is_otto) {
3358 - $otto_graph[$type] = $entry;
3359 - } else {
3360 - $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;
3361 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.
3362 3363 }
3363 - } elseif (isset($decoded['@type'])) {
3364 - $type = is_array($decoded['@type'])
3365 - ? (string) reset($decoded['@type'])
3366 - : (string) $decoded['@type'];
3367 - if ($type === '') continue;
3368 - if ($is_otto) {
3369 - $otto_by_type[$type] = $decoded;
3370 - } else {
3371 - $third_by_type[$type] = $decoded;
3364 + }
3365 +
3366 + if ($alive) {
3367 + foreach ($entity['types'] as $token) {
3368 + $by_type[$token][] = $i;
3372 3369 }
3370 + if ($entity['id'] !== '') {
3371 + $by_id[$entity['id']] = $i;
3372 + }
3373 + } else {
3374 + $entities[$i]['alive'] = false;
3373 3375 }
3374 3376 }
3375 3377
3376 - // If OTTO provided no schema at all, nothing to deduplicate
3377 - if (empty($otto_by_type) && empty($otto_graph)) {
3378 - return $html;
3378 + $any_evicted = false;
3379 + foreach ($entities as $entity) {
3380 + if (!$entity['alive']) {
3381 + $any_evicted = true;
3382 + break;
3383 + }
3379 3384 }
3385 + if (!$any_evicted) {
3386 + return $html; // no collision — every original byte stays
3387 + }
3380 3388
3381 - // Remove all JSON-LD blocks from HTML
3382 - $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 + }
3383 3409
3384 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
3385 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
3386 - $rebuilt = '';
3387 - foreach ($kept as $decoded) {
3388 - $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);
3389 3418 if (!is_string($json) || $json === '') {
3390 - continue; // never re-emit a block whose payload failed to encode
3419 + continue; // never drop a block whose payload failed to re-encode
3391 3420 }
3392 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $json . "</script>\n";
3393 - }
3394 3421
3395 - // Re-insert merged @graph block (if any entries exist)
3396 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
3397 - if (!empty($merged_graph)) {
3398 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
3399 - $graph_json = metasync_safe_json_ld_encode($graph_obj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3400 - if (is_string($graph_json) && $graph_json !== '') {
3401 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $graph_json . "</script>\n";
3402 - }
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>'];
3403 3425 }
3404 3426
3405 - // Re-inject before </head>
3406 - if (!empty($rebuilt)) {
3407 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
3408 - return $rebuilt . $m[1];
3409 - }, $html, 1);
3427 + foreach (array_reverse($edits) as $edit) {
3428 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
3410 3429 }
3411 3430
3412 3431 return $html;
3413 3432 }
@@ -3426,14 +3445,14 @@
3426 3445 return true;
3427 3446 }
3428 3447
3429 3448 # Check if amp=1 query parameter is present
3430 - 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
3431 3450 return true;
3432 3451 }
3433 3452
3434 3453 # Check for other common AMP query parameters
3435 - 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
3436 3455 return true;
3437 3456 }
3438 3457
3439 3458 return false;
@@ -3438,8 +3457,32 @@
3438 3457
3439 3458 return false;
3440 3459 }
3441 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 +
3442 3485 # this function insterts header html to the dom
3443 3486 function insert_header_html($data){
3444 3487
3445 3488 # check that we have the header html
@@ -3462,13 +3505,12 @@
3462 3505 # see mark_head_optimized() for what breaks when it does not.
3463 3506 $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
3464 3507
3465 3508 # Escape JSON-LD payload closers so a `</script>` inside a JSON
3466 - # string value cannot terminate the element early (XSS/page corruption),
3467 - # then drop whatever belongs to a switched-off feature. This is the DOM
3468 - # insertion path and it runs before the string fallbacks, so it needs the
3469 - # same filtering they apply.
3470 - $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(
3471 3513 metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3472 3514 );
3473 3515
3474 3516 $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
@@ -3778,15 +3820,8 @@
3778 3820 }
3779 3821 } elseif ($type === 'meta') {
3780 3822 $name = $item['name'] ?? '';
3781 3823 $property = $item['property'] ?? '';
3782 -
3783 - # Skip entries owned by a switched-off feature, so OTTO
3784 - # neither writes its tag nor strips the page's own.
3785 - if ($this->replacement_is_feature_disabled($name, $property)) {
3786 - continue;
3787 - }
3788 -
3789 3824 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
3790 3825
3791 3826 if (!empty($name)) {
3792 3827 # MEMORY OPTIMIZED: Count meta tags without storing all matches
@@ -3861,20 +3896,16 @@
3861 3896 }
3862 3897
3863 3898 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3864 3899 if (!empty($replacement_data['header_html_insertion'])) {
3865 - # Escape JSON-LD payload closers first; the DOM path inserts the
3866 - # same escaped bytes, so the already-applied check must match them.
3867 - $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(
3868 3904 metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3869 3905 );
3870 - $header_html_check = trim($escaped_header_insertion);
3871 - if ($header_html_check !== '' && strpos($result_html, $header_html_check) === false) {
3872 - $header_html_insertion = preg_replace(
3873 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3874 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3875 - $escaped_header_insertion
3876 - );
3906 + $header_html_check = trim($header_html_insertion);
3907 + if (strpos($result_html, $header_html_check) === false) {
3877 3908 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3878 3909 # Insert before </head>
3879 3910 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3880 3911 }
@@ -3946,9 +3977,9 @@
3946 3977
3947 3978 $result_html = preg_replace_callback(
3948 3979 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3949 3980 function ($m) use ($current_value, $recommended_value) {
3950 - $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')));
3951 3982 if ($inner_text !== $current_value) {
3952 3983 return $m[0];
3953 3984 }
3954 3985 # Keep the author's inline formatting; skip when it can't
@@ -4088,12 +4119,26 @@
4088 4119
4089 4120 return $result_html;
4090 4121
4091 4122 } catch (Exception $e) {
4092 - 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 + }
4093 4131 return false;
4094 4132 } catch (Error $e) {
4095 - 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 + }
4096 4141 return false;
4097 4142 }
4098 4143 }
4099 4144
@@ -4134,16 +4179,8 @@
4134 4179 # Handle description tags - only remove specific tags that Otto is providing
4135 4180 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
4136 4181 # Remove only the specific description tags that Otto is providing
4137 4182 foreach ($remove_description_tags as $selector) {
4138 - # With the social feature switched off MetaSync writes no
4139 - # og:/twitter: tag of its own, so deleting the page's would leave
4140 - # it with none. The plain description belongs to a different
4141 - # feature and is unaffected.
4142 - if ($this->selector_is_social($selector)
4143 - && Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
4144 - continue;
4145 - }
4146 4183 $tags = $this->dom->find($selector);
4147 4184 foreach ($tags as $tag) {
4148 4185 # Remove the tag
4149 4186 $tag->outertext = '';
@@ -4150,45 +4187,17 @@
4150 4187 }
4151 4188 }
4152 4189 }
4153 4190
4154 - # og:title / twitter:title are social tags, so the social switch decides
4155 - # whether MetaSync may take the page's away (keep main <title>).
4156 - if ($remove_title && !$has_custom_title
4157 - && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG)) {
4158 - # Remove Open Graph and Twitter title tags (keep main <title>)
4159 - $title_selectors = [
4160 - 'meta[property=og:title]',
4161 - 'meta[name=twitter:title]',
4162 - ];
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.
4163 4197
4164 - foreach ($title_selectors as $selector) {
4165 - $tags = $this->dom->find($selector);
4166 - foreach ($tags as $tag) {
4167 - $tag->outertext = ''; # Remove the tag
4168 - }
4169 - }
4170 - }
4171 -
4172 4198 # Save changes
4173 4199 $this->save_reload();
4174 - }
4175 -
4176 - /**
4177 - * Whether a DOM selector targets an Open Graph / Twitter / article tag.
4178 - *
4179 - * The description selectors reach this class from the caller, so they have
4180 - * to be inspected rather than assumed.
4181 - *
4182 - * @param string $selector SimpleHtmlDom selector.
4183 - * @return bool
4184 - */
4185 - private function selector_is_social($selector) {
4186 - $selector = strtolower($selector);
4187 -
4188 - return strpos($selector, 'og:') !== false
4189 - || strpos($selector, 'twitter:') !== false
4190 - || strpos($selector, 'article:') !== false;
4191 4200 }
4192 4201
4193 4202 /**
4194 4203 * Clean OTTO internal fetch query parameters from rendered HTML.