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 +410 -140 2.6.24 → 2.7.1 View file →
@@ -785,9 +785,9 @@
785 785 $response = wp_remote_get($url_with_params, $args);
786 786
787 787 # Check for errors
788 788 if (is_wp_error($response)) {
789 - 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
790 790 return false;
791 791 }
792 792
793 793 # get the response body
@@ -797,9 +797,9 @@
797 797 $response_code = wp_remote_retrieve_response_code($response);
798 798
799 799 # if no change data skip
800 800 if (empty($body) || $response_code !== 200){
801 - 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
802 802 return false;
803 803 }
804 804
805 805 # set the html file path
@@ -992,9 +992,11 @@
992 992
993 993 # Check for timeout or connection errors
994 994 if (is_wp_error($route_html)) {
995 995 $error_msg = $route_html->get_error_message();
996 - 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 + }
997 999 return false;
998 1000 }
999 1001
1000 1002 # get body
@@ -1009,9 +1011,8 @@
1009 1011 // content and SEO metadata. The normal WordPress request can handle
1010 1012 // the redirect, and OTTO will process the destination on its own URL.
1011 1013 $effective_url = $this->get_effective_fetch_url($route_html);
1012 1014 if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
1013 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: SKIPPED - internal fetch followed redirect from ' . $route . ' to ' . $effective_url);
1014 1015 return false;
1015 1016 }
1016 1017
1017 1018 # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
@@ -1043,9 +1044,11 @@
1043 1044
1044 1045
1045 1046 # check not empty
1046 1047 if(empty($html_body) || $response_code !== 200){
1047 - 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 + }
1048 1051 return false;
1049 1052 }
1050 1053
1051 1054 # Skip DOM processing on oversized documents to avoid fatal OOM.
@@ -1209,9 +1212,9 @@
1209 1212
1210 1213 $result_html = preg_replace_callback(
1211 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
1212 1215 function ($m) use ($current_value, $recommended_value) {
1213 - $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')));
1214 1217 if ($inner_text !== $current_value) {
1215 1218 return $m[0];
1216 1219 }
1217 1220 # Keep the author's inline formatting; skip when it can't
@@ -1233,18 +1236,16 @@
1233 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');
1234 1237
1235 1238 # Apply insertions — only if DOM insertion didn't already apply it
1236 1239 if (!empty($replacement_data['header_html_insertion'])) {
1237 - # Escape JSON-LD payload closers first; the DOM path inserts the
1238 - # same escaped bytes, so the already-applied check must match them.
1239 - $escaped_header_insertion = metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion']);
1240 - $header_html_check = trim($escaped_header_insertion);
1240 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
1241 + # path inserts the same bytes, so the already-applied check must compare
1242 + # against the stamped form or it would insert a second copy.
1243 + $header_html_insertion = $this->stamp_otto_json_ld(
1244 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
1245 + );
1246 + $header_html_check = trim($header_html_insertion);
1241 1247 if (strpos($result_html, $header_html_check) === false) {
1242 - $header_html_insertion = preg_replace(
1243 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
1244 - '<script$1type=$2application/ld+json$2 data-otto="true"',
1245 - $escaped_header_insertion
1246 - );
1247 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
1248 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
1249 1250 }
1250 1251 }
@@ -1373,11 +1374,11 @@
1373 1374 * @return bool True when no meaningful redirect changed the route.
1374 1375 */
1375 1376 private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1376 1377 {
1377 - $effective = parse_url($effective_url);
1378 - $requested = parse_url($route);
1379 - $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);
1380 1381 if (!is_array($effective) || !is_array($requested)) {
1381 1382 return true;
1382 1383 }
1383 1384
@@ -2099,10 +2100,10 @@
2099 2100 }
2100 2101
2101 2102 # If it's a protocol-relative URL (//example.com), check if it matches our domain
2102 2103 if (strpos($url, '//') === 0) {
2103 - $parsed_home = parse_url($home_url);
2104 - $parsed_link = parse_url($url);
2104 + $parsed_home = wp_parse_url($home_url);
2105 + $parsed_link = wp_parse_url($url);
2105 2106
2106 2107 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
2107 2108 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
2108 2109 return false;
@@ -2224,20 +2225,22 @@
2224 2225 return $post_id ? (int) $post_id : 0;
2225 2226 }
2226 2227
2227 2228 /**
2228 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
2229 - * data-metasync-seo="custom"); insert one after <head> if none is present.
2230 - * Helper for apply_custom_seo_precedence().
2229 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2231 2230 *
2232 2231 * @param string $html
2233 2232 * @param string $attr "name" or "property"
2234 2233 * @param string $val e.g. "description", "og:description"
2235 2234 * @param string $content_escaped Already HTML-escaped content value
2235 + * @param string $marker Provenance marker for the winning tier
2236 2236 * @return string
2237 2237 */
2238 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
2239 - $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 . ' />';
2240 2243 $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2241 2244 $count = 0;
2242 2245 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2243 2246 return $tag;
@@ -2251,23 +2254,36 @@
2251 2254 }, $html, 1);
2252 2255 }
2253 2256
2254 2257 /**
2255 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2258 + * Make sure the precedence resolver is loaded before calling it.
2256 2259 *
2257 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
2258 - * OTTO for these fields, but later steps re-introduce OTTO's value:
2259 - * - the manual string-replacement title pass re-applies OTTO's title;
2260 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
2261 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
2262 - * exactly the case when the post is synced to a third-party SEO plugin and
2263 - * 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.
2264 2277 *
2265 - * Running LAST — after all dedup/precedence steps — this makes the user's
2266 - * explicit sidebar value authoritative regardless of how the tag was produced,
2267 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
2268 - * 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.
2269 2282 *
2283 + * OG and Twitter descriptions remain outside this method deliberately;
2284 + * social precedence is a separate follow-up scope.
2285 + *
2270 2286 * @param string $html Full HTML document (post-dedup).
2271 2287 * @return string
2272 2288 */
2273 2289 private function apply_custom_seo_precedence($html) {
@@ -2278,12 +2294,26 @@
2278 2294 if (!$post_id) {
2279 2295 return $html;
2280 2296 }
2281 2297
2282 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
2283 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
2284 - if (!empty($custom_title) && is_string($custom_title)) {
2285 - $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>';
2286 2316 $count = 0;
2287 2317 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2288 2318 return $title_tag;
2289 2319 }, $html, 1, $count);
@@ -2295,17 +2325,25 @@
2295 2325 }, $html, 1);
2296 2326 }
2297 2327 }
2298 2328
2299 - # Description — force the custom sidebar description into the SEO
2300 - # <meta name="description"> only. og:/twitter: description are social tags
2301 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
2302 - # earlier); this pass must NOT touch them, or it would override a
2303 - # separately user-set og:description with the plain SEO description.
2304 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2305 - if (!empty($custom_desc) && is_string($custom_desc)) {
2306 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
2307 - $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);
2308 2346 }
2309 2347
2310 2348 return $html;
2311 2349 }
@@ -3094,101 +3132,301 @@
3094 3132 return $html;
3095 3133 }
3096 3134
3097 3135 /**
3136 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3137 + *
3138 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3139 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3140 + * Collapsing such a node to its first token hides it from a plain-typed
3141 + * duplicate, so every token is returned and indexed separately.
3142 + *
3143 + * @param mixed $raw The raw `@type` value.
3144 + * @return string[] Unique lowercased tokens, possibly empty.
3145 + */
3146 + private function schema_type_tokens($raw) {
3147 + $tokens = [];
3148 +
3149 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3150 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3151 + continue;
3152 + }
3153 + $type = strtolower(trim((string) $type));
3154 + if ($type === '') {
3155 + continue;
3156 + }
3157 + $tokens[$type] = true;
3158 + }
3159 +
3160 + return array_keys($tokens);
3161 + }
3162 +
3163 + /**
3098 3164 * Deduplicate JSON-LD schema blocks.
3099 3165 *
3100 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
3101 - * blocks, keep OTTO's version for any @type that appears in both.
3102 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3166 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3167 + * token the page already carries. Collisions resolve strictly in OTTO's
3168 + * favour:
3103 3169 *
3170 + * - an OTTO node (marked `data-otto`) displaces every colliding
3171 + * third-party node, whichever appeared first in the document;
3172 + * - two OTTO nodes describing the same entity keep the first;
3173 + * - two third-party nodes never evict each other, even when they share a
3174 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3175 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3176 + * output this pass has no mandate to fold.
3177 + *
3178 + * When OTTO contributed no node at all the document comes back untouched —
3179 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3180 + * into the original document, so a block that loses nothing keeps its exact
3181 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3182 + * and nothing is ever relocated.
3183 + *
3184 + * Blocks are left exactly where they are when their JSON does not parse,
3185 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3186 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3187 + * it in a comment must stay disabled, not be re-published by this pass.
3188 + *
3104 3189 * @param string $html Full HTML.
3105 3190 * @return string
3106 3191 */
3107 3192 private function deduplicate_schema_tags($html) {
3108 - // Find all JSON-LD script blocks
3109 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
3110 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3193 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3194 + // capturing only the part before `type=` would hide a `data-otto` marker
3195 + // that sits after it, which is where every insertion path puts it. The
3196 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3197 + // is recognised too.
3198 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3199 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3111 3200 return $html;
3112 3201 }
3113 3202
3114 - $otto_by_type = []; // @type => decoded JSON object
3115 - $third_by_type = []; // @type => decoded JSON object
3116 - $otto_graph = []; // entries from OTTO @graph blocks
3117 - $third_graph = []; // entries from third-party @graph blocks
3203 + // Byte ranges this pass must not reach into. A commented-out (or
3204 + // <noscript>-wrapped) schema block is deliberately disabled output.
3205 + $masked = [];
3206 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3207 + foreach ($comments as $comment) {
3208 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3209 + }
3210 + }
3118 3211
3212 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3213 + // `data-otto`. A substring test would also catch a hypothetical
3214 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3215 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3216 +
3217 + // Blocks able to take part, and their entities in document order. An
3218 + // entity is one addressable node: a flat block's document, one
3219 + // `@graph` entry, or one node of a top-level array.
3220 + $blocks = [];
3221 + $entities = [];
3222 +
3119 3223 foreach ($matches as $m) {
3120 - $attrs = $m[1];
3121 - $json_str = $m[3];
3224 + $start = $m[0][1];
3225 + foreach ($masked as $range) {
3226 + if ($start >= $range[0] && $start < $range[1]) {
3227 + continue 2;
3228 + }
3229 + }
3230 +
3231 + $attrs = $m[1][0];
3232 + $json_str = $m[3][0];
3122 3233 $decoded = json_decode($json_str, true);
3123 3234 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
3124 - continue; // skip unparseable blocks — leave them in place
3235 + continue; // unparseable — leave the block in place
3125 3236 }
3126 - $is_otto = stripos($attrs, 'data-otto') !== false;
3127 3237
3238 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3239 + $end = $start + strlen($m[0][0]);
3240 +
3241 + // A trailing newline swallowed on full removal keeps the output tidy.
3242 + $end_ws = $end;
3243 + if (substr($html, $end, 2) === "\r\n") {
3244 + $end_ws = $end + 2;
3245 + } elseif (substr($html, $end, 1) === "\n") {
3246 + $end_ws = $end + 1;
3247 + }
3248 +
3249 + $register = function (array $node) use (&$entities, $is_otto) {
3250 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3251 + // @type tokens for the type fold. Entries with neither stay in
3252 + // their block but cannot collide, so they keep entity === null.
3253 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3254 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3255 + if ($types === [] && $id === '') {
3256 + return null;
3257 + }
3258 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3259 + return count($entities) - 1;
3260 + };
3261 +
3262 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3263 +
3128 3264 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
3129 - foreach ($decoded['@graph'] as $entry) {
3130 - if (!isset($entry['@type'])) continue;
3131 - // JSON-LD allows @type to be a string OR an array of strings.
3132 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
3133 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
3134 - $type = is_array($entry['@type'])
3135 - ? (string) reset($entry['@type'])
3136 - : (string) $entry['@type'];
3137 - if ($type === '') continue;
3138 - if ($is_otto) {
3139 - $otto_graph[$type] = $entry;
3140 - } else {
3141 - $third_graph[$type] = $entry;
3142 - }
3265 + foreach ($decoded['@graph'] as $node) {
3266 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3143 3267 }
3268 + $block['shape'] = 'graph';
3144 3269 } elseif (isset($decoded['@type'])) {
3145 - $type = is_array($decoded['@type'])
3146 - ? (string) reset($decoded['@type'])
3147 - : (string) $decoded['@type'];
3148 - if ($type === '') continue;
3149 - if ($is_otto) {
3150 - $otto_by_type[$type] = $decoded;
3270 + $block['shape'] = 'flat';
3271 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3272 + } elseif (isset($decoded[0])) {
3273 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3274 + // persistence path already folds this shape; the render path
3275 + // used to skip it entirely, leaving its duplicates standing.
3276 + $block['shape'] = 'list';
3277 + foreach ($decoded as $node) {
3278 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3279 + }
3280 + } else {
3281 + continue; // neither @type nor @graph nor array — nothing to key on
3282 + }
3283 +
3284 + $blocks[] = $block;
3285 + }
3286 +
3287 + foreach ($entities as $entity) {
3288 + if ($entity['otto']) {
3289 + $otto_present = true;
3290 + break;
3291 + }
3292 + }
3293 + if (empty($otto_present)) {
3294 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3295 + }
3296 +
3297 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3298 + // node can displace a whole set of third-party duplicates at once.
3299 + /** @var array<string, int> $by_id */
3300 + $by_id = []; // '@id:...' => entity index
3301 + /** @var array<string, int[]> $by_type */
3302 + $by_type = []; // type token => entity index[]
3303 +
3304 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3305 + foreach ($entities[$i]['types'] as $token) {
3306 + $holders = $by_type[$token] ?? [];
3307 + $holders = array_values(array_diff($holders, [$i]));
3308 + if ($holders === []) {
3309 + unset($by_type[$token]);
3151 3310 } else {
3152 - $third_by_type[$type] = $decoded;
3311 + $by_type[$token] = $holders;
3153 3312 }
3154 3313 }
3314 + $id = $entities[$i]['id'];
3315 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3316 + unset($by_id[$id]);
3317 + }
3318 + };
3319 +
3320 + foreach ($entities as $i => $entity) {
3321 + $alive = true;
3322 +
3323 + // The same real-world entity twice, whatever @type each copy carries.
3324 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3325 + $occupant = $by_id[$entity['id']];
3326 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3327 + $entities[$occupant]['alive'] = false;
3328 + $release($occupant);
3329 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3330 + } else {
3331 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3332 + }
3333 + }
3334 +
3335 + if ($alive) {
3336 + foreach ($entity['types'] as $token) {
3337 + $holders = $by_type[$token] ?? [];
3338 + if ($holders === []) {
3339 + continue;
3340 + }
3341 + $otto_holder = false;
3342 + foreach ($holders as $holder) {
3343 + if ($entities[$holder]['otto']) {
3344 + $otto_holder = true;
3345 + break;
3346 + }
3347 + }
3348 + if ($otto_holder) {
3349 + $alive = false; // an OTTO node already holds this token
3350 + break;
3351 + }
3352 + if ($entity['otto']) {
3353 + // OTTO displaces every third-party holder of the token and
3354 + // releases ALL their claims, not just this one — otherwise
3355 + // tokens the evicted node held alone would stay blocked and
3356 + // later, legitimate nodes of that type would lose.
3357 + foreach ($holders as $holder) {
3358 + $entities[$holder]['alive'] = false;
3359 + $release($holder);
3360 + }
3361 + }
3362 + // Third party meeting third-party holders: both stand.
3363 + }
3364 + }
3365 +
3366 + if ($alive) {
3367 + foreach ($entity['types'] as $token) {
3368 + $by_type[$token][] = $i;
3369 + }
3370 + if ($entity['id'] !== '') {
3371 + $by_id[$entity['id']] = $i;
3372 + }
3373 + } else {
3374 + $entities[$i]['alive'] = false;
3375 + }
3155 3376 }
3156 3377
3157 - // If OTTO provided no schema at all, nothing to deduplicate
3158 - if (empty($otto_by_type) && empty($otto_graph)) {
3159 - return $html;
3378 + $any_evicted = false;
3379 + foreach ($entities as $entity) {
3380 + if (!$entity['alive']) {
3381 + $any_evicted = true;
3382 + break;
3383 + }
3160 3384 }
3385 + if (!$any_evicted) {
3386 + return $html; // no collision — every original byte stays
3387 + }
3161 3388
3162 - // Remove all JSON-LD blocks from HTML
3163 - $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 + }
3164 3409
3165 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
3166 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
3167 - $rebuilt = '';
3168 - foreach ($kept as $decoded) {
3169 - $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);
3170 3418 if (!is_string($json) || $json === '') {
3171 - continue; // never re-emit a block whose payload failed to encode
3419 + continue; // never drop a block whose payload failed to re-encode
3172 3420 }
3173 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $json . "</script>\n";
3174 - }
3175 3421
3176 - // Re-insert merged @graph block (if any entries exist)
3177 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
3178 - if (!empty($merged_graph)) {
3179 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
3180 - $graph_json = metasync_safe_json_ld_encode($graph_obj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3181 - if (is_string($graph_json) && $graph_json !== '') {
3182 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $graph_json . "</script>\n";
3183 - }
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>'];
3184 3425 }
3185 3426
3186 - // Re-inject before </head>
3187 - if (!empty($rebuilt)) {
3188 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
3189 - return $rebuilt . $m[1];
3190 - }, $html, 1);
3427 + foreach (array_reverse($edits) as $edit) {
3428 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
3191 3429 }
3192 3430
3193 3431 return $html;
3194 3432 }
@@ -3207,14 +3445,14 @@
3207 3445 return true;
3208 3446 }
3209 3447
3210 3448 # Check if amp=1 query parameter is present
3211 - 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
3212 3450 return true;
3213 3451 }
3214 3452
3215 3453 # Check for other common AMP query parameters
3216 - 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
3217 3455 return true;
3218 3456 }
3219 3457
3220 3458 return false;
@@ -3219,8 +3457,32 @@
3219 3457
3220 3458 return false;
3221 3459 }
3222 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 +
3223 3485 # this function insterts header html to the dom
3224 3486 function insert_header_html($data){
3225 3487
3226 3488 # check that we have the header html
@@ -3244,9 +3506,13 @@
3244 3506 $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
3245 3507
3246 3508 # Escape JSON-LD payload closers so a `</script>` inside a JSON
3247 3509 # string value cannot terminate the element early (XSS/page corruption).
3248 - $escaped_header_insertion = metasync_escape_json_ld_blocks_in_html($data['header_html_insertion']);
3510 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3511 + # fallbacks do, so the deduper can attribute them.
3512 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3513 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3514 + );
3249 3515
3250 3516 $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3251 3517
3252 3518 }
@@ -3630,18 +3896,16 @@
3630 3896 }
3631 3897
3632 3898 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3633 3899 if (!empty($replacement_data['header_html_insertion'])) {
3634 - # Escape JSON-LD payload closers first; the DOM path inserts the
3635 - # same escaped bytes, so the already-applied check must match them.
3636 - $escaped_header_insertion = metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion']);
3637 - $header_html_check = trim($escaped_header_insertion);
3900 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
3901 + # path inserts the same bytes, so the already-applied check must compare
3902 + # against the stamped form or it would insert a second copy.
3903 + $header_html_insertion = $this->stamp_otto_json_ld(
3904 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
3905 + );
3906 + $header_html_check = trim($header_html_insertion);
3638 3907 if (strpos($result_html, $header_html_check) === false) {
3639 - $header_html_insertion = preg_replace(
3640 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3641 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3642 - $escaped_header_insertion
3643 - );
3644 3908 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3645 3909 # Insert before </head>
3646 3910 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3647 3911 }
@@ -3713,9 +3977,9 @@
3713 3977
3714 3978 $result_html = preg_replace_callback(
3715 3979 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3716 3980 function ($m) use ($current_value, $recommended_value) {
3717 - $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')));
3718 3982 if ($inner_text !== $current_value) {
3719 3983 return $m[0];
3720 3984 }
3721 3985 # Keep the author's inline formatting; skip when it can't
@@ -3855,12 +4119,26 @@
3855 4119
3856 4120 return $result_html;
3857 4121
3858 4122 } catch (Exception $e) {
3859 - 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 + }
3860 4131 return false;
3861 4132 } catch (Error $e) {
3862 - 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 + }
3863 4141 return false;
3864 4142 }
3865 4143 }
3866 4144
@@ -3909,22 +4187,14 @@
3909 4187 }
3910 4188 }
3911 4189 }
3912 4190
3913 - if ($remove_title && !$has_custom_title) {
3914 - # Remove Open Graph and Twitter title tags (keep main <title>)
3915 - $title_selectors = [
3916 - 'meta[property=og:title]',
3917 - 'meta[name=twitter:title]',
3918 - ];
3919 -
3920 - foreach ($title_selectors as $selector) {
3921 - $tags = $this->dom->find($selector);
3922 - foreach ($tags as $tag) {
3923 - $tag->outertext = ''; # Remove the tag
3924 - }
3925 - }
3926 - }
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.
3927 4197
3928 4198 # Save changes
3929 4199 $this->save_reload();
3930 4200 }