PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +512 -348 2.6.25 → 2.7.2 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 }
@@ -1287,8 +1273,9 @@
1287 1273
1288 1274 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
1289 1275 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
1290 1276 $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
1277 + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags');
1291 1278 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
1292 1279 $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
1293 1280 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
1294 1281 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
@@ -1388,11 +1375,11 @@
1388 1375 * @return bool True when no meaningful redirect changed the route.
1389 1376 */
1390 1377 private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1391 1378 {
1392 - $effective = parse_url($effective_url);
1393 - $requested = parse_url($route);
1394 - $transport = parse_url($fetch_url);
1379 + $effective = wp_parse_url($effective_url);
1380 + $requested = wp_parse_url($route);
1381 + $transport = wp_parse_url($fetch_url);
1395 1382 if (!is_array($effective) || !is_array($requested)) {
1396 1383 return true;
1397 1384 }
1398 1385
@@ -2114,10 +2101,10 @@
2114 2101 }
2115 2102
2116 2103 # If it's a protocol-relative URL (//example.com), check if it matches our domain
2117 2104 if (strpos($url, '//') === 0) {
2118 - $parsed_home = parse_url($home_url);
2119 - $parsed_link = parse_url($url);
2105 + $parsed_home = wp_parse_url($home_url);
2106 + $parsed_link = wp_parse_url($url);
2120 2107
2121 2108 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
2122 2109 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
2123 2110 return false;
@@ -2239,45 +2226,32 @@
2239 2226 return $post_id ? (int) $post_id : 0;
2240 2227 }
2241 2228
2242 2229 /**
2243 - * Whether a meta tag carries the marker that means "the customer typed this".
2230 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2244 2231 *
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 2232 * @param string $html
2272 2233 * @param string $attr "name" or "property"
2273 2234 * @param string $val e.g. "description", "og:description"
2274 2235 * @param string $content_escaped Already HTML-escaped content value
2236 + * @param string $marker Provenance marker for the winning tier
2275 2237 * @return string
2276 2238 */
2277 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
2278 - $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '" data-metasync-seo="custom" />';
2279 - $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2239 + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
2240 + $marker_attribute = $marker === 'otto'
2241 + ? ' data-metasync-otto="true"'
2242 + : ' data-metasync-seo="custom"';
2243 + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
2244 + # The optional prefix group must end on whitespace or a quote so the
2245 + # attribute matched is $attr itself, not something merely ending in it:
2246 + # a bare [^>]*name= also matches data-name="keywords", so a third party's
2247 + # unrelated tag was consumed and rewritten while the real one survived —
2248 + # destroying their value AND leaving the duplicate the dedup had just
2249 + # collapsed. Making the group optional keeps the common
2250 + # <meta name="..." ...> form matching, where <meta\s has already consumed
2251 + # the only separator. Same rule as deduplicate_keywords_tags(); this
2252 + # helper serves title, description and keywords, so all three are covered.
2253 + $pattern = '/<meta\s(?:[^>]*[\s"\'])?' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2280 2254 $count = 0;
2281 2255 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2282 2256 return $tag;
2283 2257 }, $html, 1, $count);
@@ -2290,23 +2264,37 @@
2290 2264 }, $html, 1);
2291 2265 }
2292 2266
2293 2267 /**
2294 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2268 + * Make sure the precedence resolver is loaded before calling it.
2295 2269 *
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()).
2270 + * @return bool
2271 + */
2272 + private static function precedence_available() {
2273 + if (class_exists('Metasync_Seo_Precedence')) {
2274 + return true;
2275 + }
2276 +
2277 + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
2278 + if (is_readable($file)) {
2279 + require_once $file;
2280 + }
2281 +
2282 + return class_exists('Metasync_Seo_Precedence');
2283 + }
2284 +
2285 + /**
2286 + * Enforce the resolved SEO title, description and keywords as the FINAL
2287 + * word over OTTO.
2303 2288 *
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.
2289 + * The resolver owns the global custom-versus-OTTO order. This final buffer
2290 + * pass must use it too: otherwise the setting works through the sidebar
2291 + * and third-party filters but OTTO's later string pass puts the custom
2292 + * value back on the page.
2308 2293 *
2294 + * OG and Twitter descriptions remain outside this method deliberately;
2295 + * social precedence is a separate follow-up scope.
2296 + *
2309 2297 * @param string $html Full HTML document (post-dedup).
2310 2298 * @return string
2311 2299 */
2312 2300 private function apply_custom_seo_precedence($html) {
@@ -2317,12 +2305,26 @@
2317 2305 if (!$post_id) {
2318 2306 return $html;
2319 2307 }
2320 2308
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>';
2309 + # This runs inside the output buffer, where an unresolvable class is a
2310 + # fatal that takes the whole page down — otto_guard_html() only catches
2311 + # a NULL return, not a thrown Error. A partial update can leave newer
2312 + # PHP files beside an older committed autoload classmap, so resolve the
2313 + # file directly before trusting the class, and leave the document
2314 + # untouched if it is genuinely absent. Same reasoning as
2315 + # Metasync_SEO_Conflict_Handler::precedence_available().
2316 + if (!self::precedence_available()) {
2317 + return $html;
2318 + }
2319 +
2320 + # Title — replace the resolved page title into the (single, post-dedup) <title>.
2321 + $resolved_title = Metasync_Seo_Precedence::resolve(
2322 + $post_id,
2323 + Metasync_Seo_Precedence::FIELD_TITLE
2324 + );
2325 + if (!empty($resolved_title['value'])) {
2326 + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
2325 2327 $count = 0;
2326 2328 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2327 2329 return $title_tag;
2328 2330 }, $html, 1, $count);
@@ -2334,19 +2336,59 @@
2334 2336 }, $html, 1);
2335 2337 }
2336 2338 }
2337 2339
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);
2340 + # Description — replace the resolved page description into the SEO
2341 + # <meta name="description"> only. OG/Twitter description is social
2342 + # precedence and remains outside this setting's scope.
2343 + $resolved_description = Metasync_Seo_Precedence::resolve(
2344 + $post_id,
2345 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2346 + );
2347 + if (!empty($resolved_description['value'])) {
2348 + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
2349 + $marker = Metasync_Seo_Precedence::is_otto_value(
2350 + $post_id,
2351 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2352 + $resolved_description
2353 + )
2354 + ? 'otto'
2355 + : 'custom';
2356 + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
2347 2357 }
2348 2358
2359 + # Keywords — the same final pass as the description above, on the chain
2360 + # the resolver owns, so the global priority setting governs this tag
2361 + # too. OTTO's keywords tag arrives via header_html_insertion carrying
2362 + # a data-otto marker and is spliced in after wp_head, so it is always
2363 + # the later tag and always wins deduplicate_keywords_tags(); forcing
2364 + # the resolved winner here is what keeps the losing tier reachable at
2365 + # all — the single-printing-owner rule the description branch follows.
2366 + #
2367 + # The tier this forces is NOT guaranteed customer-typed, unlike the
2368 + # title and description keys above: the meta_keywords persistence flag
2369 + # writes OTTO's value straight to _metasync_focus_keyword, and neither
2370 + # that conditional write nor the clone cleaner (which strips only the
2371 + # _metasync_otto_* keys) ever clears it, so a stale flag copy can
2372 + # outrank a newer suggestion under custom-first. That key sharing is
2373 + # tracked separately — the fix is a key of its own for the persisted
2374 + # keyword, not a special case here.
2375 + $resolved_keyword = Metasync_Seo_Precedence::resolve(
2376 + $post_id,
2377 + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD
2378 + );
2379 + if (!empty($resolved_keyword['value'])) {
2380 + $esc = htmlspecialchars($resolved_keyword['value'], ENT_QUOTES, 'UTF-8');
2381 + $marker = Metasync_Seo_Precedence::is_otto_value(
2382 + $post_id,
2383 + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD,
2384 + $resolved_keyword
2385 + )
2386 + ? 'otto'
2387 + : 'custom';
2388 + $html = $this->force_custom_meta($html, 'name', 'keywords', $esc, $marker);
2389 + }
2390 +
2349 2391 return $html;
2350 2392 }
2351 2393
2352 2394 # this function does the header replacements
@@ -2399,13 +2441,8 @@
2399 2441
2400 2442 # handle canonical links
2401 2443 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
2402 2444
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 2445 # Protect manually-set canonical from OTTO override.
2409 2446 # Validated: a legacy row corrupted to "Array" must not
2410 2447 # count as a manual canonical and block OTTO's correct value.
2411 2448 if (function_exists('is_singular') && is_singular()) {
@@ -2466,13 +2503,8 @@
2466 2503
2467 2504 # Protect manually-set robots meta from OTTO override
2468 2505 # If the user set noindex via Common Robots Meta or meta_robots, honour it
2469 2506 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 2507 if (function_exists('is_singular') && is_singular()) {
2476 2508 $post_id = get_the_ID();
2477 2509 if ($post_id) {
2478 2510 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
@@ -2508,25 +2540,8 @@
2508 2540
2509 2541 # extract name value
2510 2542 $name = $data['name'] ?? false;
2511 2543
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 2544 # set the selector
2530 2545 $meta_selector = '';
2531 2546
2532 2547 # extend selector
@@ -2753,14 +2768,8 @@
2753 2768 * @param string $html Full HTML document.
2754 2769 * @return string HTML with at most one tag per OG/Twitter property.
2755 2770 */
2756 2771 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 2772 # OG properties to deduplicate
2764 2773 $og_properties = [
2765 2774 'og:title', 'og:description', 'og:url', 'og:type',
2766 2775 'og:locale', 'og:site_name', 'og:image',
@@ -2829,13 +2838,8 @@
2829 2838 * @param string $html Full HTML document (post-dedup).
2830 2839 * @return string
2831 2840 */
2832 2841 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 2842 if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
2839 2843 return $html;
2840 2844 }
2841 2845 # Per-post OG only applies to singular posts/pages. On archives/home the queried
@@ -3031,9 +3035,9 @@
3031 3035 # OTTO's injected tag.
3032 3036 $custom_tag = null;
3033 3037 $otto_tag = null;
3034 3038 foreach ($all_tags as $tag) {
3035 - if ($custom_tag === null && self::tag_is_customer_authored($tag)) {
3039 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3036 3040 $custom_tag = $tag;
3037 3041 }
3038 3042 if ($otto_tag === null && stripos($tag, 'data-otto') !== false) {
3039 3043 $otto_tag = $tag;
@@ -3069,10 +3073,9 @@
3069 3073 *
3070 3074 * This runs at the buffer level — after every source has written its tag —
3071 3075 * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented
3072 3076 * "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)
3077 + * 1. custom sidebar (data-metasync-seo)
3075 3078 * 2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto)
3076 3079 * 3. first occurrence
3077 3080 *
3078 3081 * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches
@@ -3101,9 +3104,9 @@
3101 3104 # Choose the keeper by precedence: custom sidebar → OTTO → first.
3102 3105 $custom_tag = null;
3103 3106 $otto_tag = null;
3104 3107 foreach ($all_tags as $tag) {
3105 - if ($custom_tag === null && self::tag_is_customer_authored($tag)) {
3108 + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
3106 3109 $custom_tag = $tag;
3107 3110 }
3108 3111 if ($otto_tag === null && (
3109 3112 stripos($tag, 'data-otto-pixel') !== false ||
@@ -3130,8 +3133,82 @@
3130 3133 return $html;
3131 3134 }
3132 3135
3133 3136 /**
3137 + * Remove duplicate <meta name="keywords"> tags from HTML.
3138 + *
3139 + * OTTO ships its keywords tag inside header_html_insertion, and MetaSync's
3140 + * own SEO output now emits one too whenever the precedence chain resolves a
3141 + * keyword — so a server-side-rendered page can carry both.
3142 + *
3143 + * The keeper is OTTO's tag, or the first occurrence when no OTTO tag is
3144 + * present. Unlike deduplicate_description_tags() there is no
3145 + * customer-authored branch here: print_metatag() emits no data attribute, so
3146 + * a keywords tag never carries data-metasync-seo="custom" at this point in
3147 + * the pipeline. The customer tier is re-applied afterwards by
3148 + * apply_custom_seo_precedence(), which runs later and stamps the marker via
3149 + * force_custom_meta(). Collapsing to OTTO's tag here is therefore safe: it is
3150 + * not the final word on what the page renders.
3151 + *
3152 + * @param string $html Full HTML document.
3153 + * @return string HTML with at most one <meta name="keywords">.
3154 + */
3155 + private function deduplicate_keywords_tags($html) {
3156 + # No is_string() guard, unlike deduplicate_description_tags() beside it.
3157 + # That asymmetry is forced, not an oversight: @param string makes the
3158 + # check provably always-true at PHPStan level 5, and the description
3159 + # sibling's copy is one of a fixed number of occurrences the baseline
3160 + # pins by count, so adding another fails the build. Both methods are
3161 + # only ever called with the buffer string.
3162 + if ($html === '') {
3163 + return $html;
3164 + }
3165 +
3166 + # Match <meta ... name="keywords" ...> in either attribute order.
3167 + #
3168 + # The optional prefix group must end on whitespace or a quote so the
3169 + # attribute is `name`, not something merely ending in it: a bare
3170 + # [^>]*name= also matches data-name="keywords". Making the group
3171 + # optional keeps the common `<meta name="keywords" ...>` form matching,
3172 + # where <meta\s has already consumed the only separator.
3173 + #
3174 + # The opening quote must be immediately followed by "keywords", so
3175 + # name="news_keywords" is not matched.
3176 + $pattern = '/<meta\s(?:[^>]*[\s"\'])?name\s*=\s*["\']keywords["\'][^>]*\/?>/i';
3177 +
3178 + if (preg_match_all($pattern, $html, $matches) <= 1) {
3179 + return $html; # 0 or 1 — nothing to deduplicate
3180 + }
3181 +
3182 + $all_tags = $matches[0];
3183 +
3184 + $otto_tag = null;
3185 + foreach ($all_tags as $tag) {
3186 + if (stripos($tag, 'data-otto-pixel') !== false ||
3187 + stripos($tag, 'data-metasync-otto') !== false ||
3188 + stripos($tag, 'data-otto') !== false
3189 + ) {
3190 + $otto_tag = $tag;
3191 + break;
3192 + }
3193 + }
3194 + $keeper = $otto_tag ?: $all_tags[0];
3195 +
3196 + # Callback form avoids backreference injection when the keyword list
3197 + # contains $ followed by digits.
3198 + $first_replaced = false;
3199 + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
3200 + if (!$first_replaced) {
3201 + $first_replaced = true;
3202 + return $keeper;
3203 + }
3204 + return ''; # Remove subsequent duplicates
3205 + }, $html);
3206 +
3207 + return $html;
3208 + }
3209 +
3210 + /**
3134 3211 * Remove duplicate <link rel="canonical"> tags from HTML.
3135 3212 *
3136 3213 * When OTTO injects a canonical via header_html_insertion and MetaSync's
3137 3214 * SEO output (or WordPress core) has already emitted one, keep only the
@@ -3141,14 +3218,8 @@
3141 3218 * @param string $html Full HTML document.
3142 3219 * @return string HTML with at most one canonical tag.
3143 3220 */
3144 3221 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 3222 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
3152 3223
3153 3224 if (preg_match_all($pattern, $html, $matches) <= 1) {
3154 3225 return $html;
@@ -3178,199 +3249,301 @@
3178 3249 return $html;
3179 3250 }
3180 3251
3181 3252 /**
3182 - * Whether a switched-off feature owns this header_replacements entry.
3253 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3183 3254 *
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.
3255 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3256 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3257 + * Collapsing such a node to its first token hides it from a plain-typed
3258 + * duplicate, so every token is returned and indexed separately.
3189 3259 *
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.
3260 + * @param mixed $raw The raw `@type` value.
3261 + * @return string[] Unique lowercased tokens, possibly empty.
3193 3262 */
3194 - private function replacement_is_feature_disabled($name, $property) {
3195 - foreach ([$name, $property] as $attribute) {
3196 - if ($attribute === '') {
3263 + private function schema_type_tokens($raw) {
3264 + $tokens = [];
3265 +
3266 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3267 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3197 3268 continue;
3198 3269 }
3199 - $attribute = strtolower(trim($attribute));
3200 -
3201 - if ($attribute === 'robots') {
3202 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
3203 - return true;
3204 - }
3270 + $type = strtolower(trim((string) $type));
3271 + if ($type === '') {
3205 3272 continue;
3206 3273 }
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 - }
3274 + $tokens[$type] = true;
3215 3275 }
3216 3276
3217 - return false;
3277 + return array_keys($tokens);
3218 3278 }
3219 3279
3220 3280 /**
3221 - * Removes markup owned by a switched-off feature from an OTTO header
3222 - * insertion.
3281 + * Deduplicate JSON-LD schema blocks.
3223 3282 *
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.
3283 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3284 + * token the page already carries. Collisions resolve strictly in OTTO's
3285 + * favour:
3229 3286 *
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.
3287 + * - an OTTO node (marked `data-otto`) displaces every colliding
3288 + * third-party node, whichever appeared first in the document;
3289 + * - two OTTO nodes describing the same entity keep the first;
3290 + * - two third-party nodes never evict each other, even when they share a
3291 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3292 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3293 + * output this pass has no mandate to fold.
3232 3294 *
3233 - * @param string $html Header insertion HTML, already block-escaped.
3295 + * When OTTO contributed no node at all the document comes back untouched —
3296 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3297 + * into the original document, so a block that loses nothing keeps its exact
3298 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3299 + * and nothing is ever relocated.
3300 + *
3301 + * Blocks are left exactly where they are when their JSON does not parse,
3302 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3303 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3304 + * it in a comment must stay disabled, not be re-published by this pass.
3305 + *
3306 + * @param string $html Full HTML.
3234 3307 * @return string
3235 3308 */
3236 - private function strip_disabled_features_from_insertion($html) {
3237 - if ($html === '') {
3309 + private function deduplicate_schema_tags($html) {
3310 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3311 + // capturing only the part before `type=` would hide a `data-otto` marker
3312 + // that sits after it, which is where every insertion path puts it. The
3313 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3314 + // is recognised too.
3315 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3316 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3238 3317 return $html;
3239 3318 }
3240 3319
3241 - $patterns = [];
3320 + // Byte ranges this pass must not reach into. A commented-out (or
3321 + // <noscript>-wrapped) schema block is deliberately disabled output.
3322 + $masked = [];
3323 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3324 + foreach ($comments as $comment) {
3325 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3326 + }
3327 + }
3242 3328
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 - }
3329 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3330 + // `data-otto`. A substring test would also catch a hypothetical
3331 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3332 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3246 3333
3247 - if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
3248 - $patterns[] = '/<link\b[^>]*\brel\s*=\s*(["\'])canonical\1[^>]*>\s*/i';
3249 - }
3334 + // Blocks able to take part, and their entities in document order. An
3335 + // entity is one addressable node: a flat block's document, one
3336 + // `@graph` entry, or one node of a top-level array.
3337 + $blocks = [];
3338 + $entities = [];
3250 3339
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 - }
3340 + foreach ($matches as $m) {
3341 + $start = $m[0][1];
3342 + foreach ($masked as $range) {
3343 + if ($start >= $range[0] && $start < $range[1]) {
3344 + continue 2;
3345 + }
3346 + }
3256 3347
3257 - if (Metasync_Feature_Flags::robots_fully_disabled()) {
3258 - $patterns[] = '/<meta\b[^>]*\bname\s*=\s*(["\'])robots\1[^>]*>\s*/i';
3259 - }
3348 + $attrs = $m[1][0];
3349 + $json_str = $m[3][0];
3350 + $decoded = json_decode($json_str, true);
3351 + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
3352 + continue; // unparseable — leave the block in place
3353 + }
3260 3354
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;
3355 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3356 + $end = $start + strlen($m[0][0]);
3357 +
3358 + // A trailing newline swallowed on full removal keeps the output tidy.
3359 + $end_ws = $end;
3360 + if (substr($html, $end, 2) === "\r\n") {
3361 + $end_ws = $end + 2;
3362 + } elseif (substr($html, $end, 1) === "\n") {
3363 + $end_ws = $end + 1;
3267 3364 }
3268 - }
3269 3365
3270 - return $html;
3271 - }
3366 + $register = function (array $node) use (&$entities, $is_otto) {
3367 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3368 + // @type tokens for the type fold. Entries with neither stay in
3369 + // their block but cannot collide, so they keep entity === null.
3370 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3371 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3372 + if ($types === [] && $id === '') {
3373 + return null;
3374 + }
3375 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3376 + return count($entities) - 1;
3377 + };
3272 3378
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;
3379 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3380 +
3381 + if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
3382 + foreach ($decoded['@graph'] as $node) {
3383 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3384 + }
3385 + $block['shape'] = 'graph';
3386 + } elseif (isset($decoded['@type'])) {
3387 + $block['shape'] = 'flat';
3388 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3389 + } elseif (isset($decoded[0])) {
3390 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3391 + // persistence path already folds this shape; the render path
3392 + // used to skip it entirely, leaving its duplicates standing.
3393 + $block['shape'] = 'list';
3394 + foreach ($decoded as $node) {
3395 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3396 + }
3397 + } else {
3398 + continue; // neither @type nor @graph nor array — nothing to key on
3399 + }
3400 +
3401 + $blocks[] = $block;
3288 3402 }
3289 3403
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;
3404 + foreach ($entities as $entity) {
3405 + if ($entity['otto']) {
3406 + $otto_present = true;
3407 + break;
3408 + }
3294 3409 }
3410 + if (empty($otto_present)) {
3411 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3412 + }
3295 3413
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
3414 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3415 + // node can displace a whole set of third-party duplicates at once.
3416 + /** @var array<string, int> $by_id */
3417 + $by_id = []; // '@id:...' => entity index
3418 + /** @var array<string, int[]> $by_type */
3419 + $by_type = []; // type token => entity index[]
3300 3420
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
3421 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3422 + foreach ($entities[$i]['types'] as $token) {
3423 + $holders = $by_type[$token] ?? [];
3424 + $holders = array_values(array_diff($holders, [$i]));
3425 + if ($holders === []) {
3426 + unset($by_type[$token]);
3427 + } else {
3428 + $by_type[$token] = $holders;
3429 + }
3307 3430 }
3308 - $is_otto = stripos($attrs, 'data-otto') !== false;
3431 + $id = $entities[$i]['id'];
3432 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3433 + unset($by_id[$id]);
3434 + }
3435 + };
3309 3436
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;
3437 + foreach ($entities as $i => $entity) {
3438 + $alive = true;
3439 +
3440 + // The same real-world entity twice, whatever @type each copy carries.
3441 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3442 + $occupant = $by_id[$entity['id']];
3443 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3444 + $entities[$occupant]['alive'] = false;
3445 + $release($occupant);
3446 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3447 + } else {
3448 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3449 + }
3450 + }
3451 +
3452 + if ($alive) {
3453 + foreach ($entity['types'] as $token) {
3454 + $holders = $by_type[$token] ?? [];
3455 + if ($holders === []) {
3456 + continue;
3324 3457 }
3458 + $otto_holder = false;
3459 + foreach ($holders as $holder) {
3460 + if ($entities[$holder]['otto']) {
3461 + $otto_holder = true;
3462 + break;
3463 + }
3464 + }
3465 + if ($otto_holder) {
3466 + $alive = false; // an OTTO node already holds this token
3467 + break;
3468 + }
3469 + if ($entity['otto']) {
3470 + // OTTO displaces every third-party holder of the token and
3471 + // releases ALL their claims, not just this one — otherwise
3472 + // tokens the evicted node held alone would stay blocked and
3473 + // later, legitimate nodes of that type would lose.
3474 + foreach ($holders as $holder) {
3475 + $entities[$holder]['alive'] = false;
3476 + $release($holder);
3477 + }
3478 + }
3479 + // Third party meeting third-party holders: both stand.
3325 3480 }
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;
3481 + }
3482 +
3483 + if ($alive) {
3484 + foreach ($entity['types'] as $token) {
3485 + $by_type[$token][] = $i;
3335 3486 }
3487 + if ($entity['id'] !== '') {
3488 + $by_id[$entity['id']] = $i;
3489 + }
3490 + } else {
3491 + $entities[$i]['alive'] = false;
3336 3492 }
3337 3493 }
3338 3494
3339 - // If OTTO provided no schema at all, nothing to deduplicate
3340 - if (empty($otto_by_type) && empty($otto_graph)) {
3341 - return $html;
3495 + $any_evicted = false;
3496 + foreach ($entities as $entity) {
3497 + if (!$entity['alive']) {
3498 + $any_evicted = true;
3499 + break;
3500 + }
3342 3501 }
3502 + if (!$any_evicted) {
3503 + return $html; // no collision — every original byte stays
3504 + }
3343 3505
3344 - // Remove all JSON-LD blocks from HTML
3345 - $html = preg_replace($pattern, '', $html);
3506 + // Rebuild only the blocks that lost something, back-to-front so the
3507 + // recorded offsets of earlier blocks stay valid.
3508 + $edits = [];
3509 + foreach ($blocks as $block) {
3510 + $kept = [];
3511 + $lost = 0;
3512 + foreach ($block['items'] as $item) {
3513 + if ($item['e'] === null || $entities[$item['e']]['alive']) {
3514 + $kept[] = $item['node'];
3515 + } else {
3516 + $lost++;
3517 + }
3518 + }
3519 + if ($lost === 0) {
3520 + continue;
3521 + }
3522 + if ($kept === []) {
3523 + $edits[] = [$block['start'], $block['end_ws'], ''];
3524 + continue;
3525 + }
3346 3526
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);
3527 + if ($block['shape'] === 'list') {
3528 + $payload = $kept;
3529 + } else { // graph — a flat block that lost its only entity is removed above
3530 + $payload = $block['decoded'];
3531 + $payload['@graph'] = $kept;
3532 + }
3533 +
3534 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3352 3535 if (!is_string($json) || $json === '') {
3353 - continue; // never re-emit a block whose payload failed to encode
3536 + continue; // never drop a block whose payload failed to re-encode
3354 3537 }
3355 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $json . "</script>\n";
3356 - }
3357 3538
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 - }
3539 + // The original attribute string is reused verbatim so class, id and
3540 + // the data-otto marker itself survive the rebuild.
3541 + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
3366 3542 }
3367 3543
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);
3544 + foreach (array_reverse($edits) as $edit) {
3545 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
3373 3546 }
3374 3547
3375 3548 return $html;
3376 3549 }
@@ -3389,14 +3562,14 @@
3389 3562 return true;
3390 3563 }
3391 3564
3392 3565 # Check if amp=1 query parameter is present
3393 - if (isset($_GET['amp']) && $_GET['amp'] == '1') {
3566 + if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
3394 3567 return true;
3395 3568 }
3396 3569
3397 3570 # Check for other common AMP query parameters
3398 - if (isset($_GET['amp']) && !empty($_GET['amp'])) {
3571 + if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
3399 3572 return true;
3400 3573 }
3401 3574
3402 3575 return false;
@@ -3401,8 +3574,32 @@
3401 3574
3402 3575 return false;
3403 3576 }
3404 3577
3578 + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
3579 + # tell them apart from a third-party SEO plugin's blocks. Every insertion path
3580 + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
3581 + # bytes they insert are identical and the "already applied" checks still match.
3582 + private function stamp_otto_json_ld($html){
3583 +
3584 + $stamped = preg_replace_callback(
3585 + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
3586 + function ($m) {
3587 + # idempotent: a block already carrying a data-otto attribute —
3588 + # anywhere in the tag, not just before `type` — is left alone,
3589 + # never given a second one
3590 + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
3591 + return $m[0];
3592 + }
3593 + return '<script' . $m[1] . ' data-otto="true">';
3594 + },
3595 + $html
3596 + );
3597 +
3598 + # preg_replace returns null on a PCRE failure — never hand back less than we got
3599 + return is_string($stamped) ? $stamped : $html;
3600 + }
3601 +
3405 3602 # this function insterts header html to the dom
3406 3603 function insert_header_html($data){
3407 3604
3408 3605 # check that we have the header html
@@ -3425,13 +3622,12 @@
3425 3622 # see mark_head_optimized() for what breaks when it does not.
3426 3623 $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
3427 3624
3428 3625 # 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(
3626 + # string value cannot terminate the element early (XSS/page corruption).
3627 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3628 + # fallbacks do, so the deduper can attribute them.
3629 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3434 3630 metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3435 3631 );
3436 3632
3437 3633 $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
@@ -3741,15 +3937,8 @@
3741 3937 }
3742 3938 } elseif ($type === 'meta') {
3743 3939 $name = $item['name'] ?? '';
3744 3940 $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 3941 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
3753 3942
3754 3943 if (!empty($name)) {
3755 3944 # MEMORY OPTIMIZED: Count meta tags without storing all matches
@@ -3824,20 +4013,16 @@
3824 4013 }
3825 4014
3826 4015 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3827 4016 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(
4017 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
4018 + # path inserts the same bytes, so the already-applied check must compare
4019 + # against the stamped form or it would insert a second copy.
4020 + $header_html_insertion = $this->stamp_otto_json_ld(
3831 4021 metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3832 4022 );
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 - );
4023 + $header_html_check = trim($header_html_insertion);
4024 + if (strpos($result_html, $header_html_check) === false) {
3840 4025 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3841 4026 # Insert before </head>
3842 4027 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3843 4028 }
@@ -3909,9 +4094,9 @@
3909 4094
3910 4095 $result_html = preg_replace_callback(
3911 4096 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3912 4097 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')));
4098 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3914 4099 if ($inner_text !== $current_value) {
3915 4100 return $m[0];
3916 4101 }
3917 4102 # Keep the author's inline formatting; skip when it can't
@@ -3969,8 +4154,9 @@
3969 4154
3970 4155 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
3971 4156 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
3972 4157 $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
4158 + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags');
3973 4159 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
3974 4160 $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
3975 4161 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
3976 4162 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
@@ -4051,12 +4237,26 @@
4051 4237
4052 4238 return $result_html;
4053 4239
4054 4240 } catch (Exception $e) {
4055 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
4241 + if (function_exists('metasync_otto_report_render_failure')) {
4242 + metasync_otto_report_render_failure(
4243 + 'MODIFICATION_EXCEPTION',
4244 + 'OTTO rewrite threw: ' . $e->getMessage(),
4245 + ['exception' => get_class($e)],
4246 + 'error'
4247 + );
4248 + }
4056 4249 return false;
4057 4250 } catch (Error $e) {
4058 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
4251 + if (function_exists('metasync_otto_report_render_failure')) {
4252 + metasync_otto_report_render_failure(
4253 + 'MODIFICATION_ERROR',
4254 + 'OTTO rewrite errored: ' . $e->getMessage(),
4255 + ['error' => get_class($e)],
4256 + 'error'
4257 + );
4258 + }
4059 4259 return false;
4060 4260 }
4061 4261 }
4062 4262
@@ -4097,16 +4297,8 @@
4097 4297 # Handle description tags - only remove specific tags that Otto is providing
4098 4298 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
4099 4299 # Remove only the specific description tags that Otto is providing
4100 4300 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 4301 $tags = $this->dom->find($selector);
4110 4302 foreach ($tags as $tag) {
4111 4303 # Remove the tag
4112 4304 $tag->outertext = '';
@@ -4113,45 +4305,17 @@
4113 4305 }
4114 4306 }
4115 4307 }
4116 4308
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 - ];
4309 + # Preserve third-party og:title/twitter:title tags here. They used to be
4310 + # stripped whenever $remove_title was set, but that also removes them
4311 + # when no custom title exists to replace them, leaving pages with no
4312 + # og:title/twitter:title at all. OTTO's own title/OG dedup and
4313 + # precedence passes already reconcile duplicates, so this branch no
4314 + # longer needs to delete those tags.
4126 4315
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 4316 # Save changes
4136 4317 $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 4318 }
4155 4319
4156 4320 /**
4157 4321 * Clean OTTO internal fetch query parameters from rendered HTML.