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 +640 -138 2.6.23 → 2.7.2 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
@@ -1002,8 +1004,18 @@
1002 1004
1003 1005 # Get the response code
1004 1006 $response_code = wp_remote_retrieve_response_code($route_html);
1005 1007
1008 + // Requests follows redirects and returns the final body with its final
1009 + // status. Do not put that body on the original route when a redirect
1010 + // changed the URL: doing so creates a soft redirect with mismatched
1011 + // content and SEO metadata. The normal WordPress request can handle
1012 + // the redirect, and OTTO will process the destination on its own URL.
1013 + $effective_url = $this->get_effective_fetch_url($route_html);
1014 + if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
1015 + return false;
1016 + }
1017 +
1006 1018 # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
1007 1019 # body below and would otherwise discard this — but it is both the form/session
1008 1020 # signal for the caching decision AND (same-origin only) the session the visitor
1009 1021 # needs for their forms to work. $request_body is the URL actually fetched (it may
@@ -1032,9 +1044,11 @@
1032 1044
1033 1045
1034 1046 # check not empty
1035 1047 if(empty($html_body) || $response_code !== 200){
1036 - 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 + }
1037 1051 return false;
1038 1052 }
1039 1053
1040 1054 # Skip DOM processing on oversized documents to avoid fatal OOM.
@@ -1198,9 +1212,9 @@
1198 1212
1199 1213 $result_html = preg_replace_callback(
1200 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
1201 1215 function ($m) use ($current_value, $recommended_value) {
1202 - $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')));
1203 1217 if ($inner_text !== $current_value) {
1204 1218 return $m[0];
1205 1219 }
1206 1220 # Keep the author's inline formatting; skip when it can't
@@ -1222,18 +1236,16 @@
1222 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');
1223 1237
1224 1238 # Apply insertions — only if DOM insertion didn't already apply it
1225 1239 if (!empty($replacement_data['header_html_insertion'])) {
1226 - # Escape JSON-LD payload closers first; the DOM path inserts the
1227 - # same escaped bytes, so the already-applied check must match them.
1228 - $escaped_header_insertion = metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion']);
1229 - $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);
1230 1247 if (strpos($result_html, $header_html_check) === false) {
1231 - $header_html_insertion = preg_replace(
1232 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
1233 - '<script$1type=$2application/ld+json$2 data-otto="true"',
1234 - $escaped_header_insertion
1235 - );
1236 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
1237 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
1238 1250 }
1239 1251 }
@@ -1261,8 +1273,9 @@
1261 1273
1262 1274 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
1263 1275 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
1264 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');
1265 1278 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
1266 1279 $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
1267 1280 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
1268 1281 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
@@ -1323,8 +1336,111 @@
1323 1336 return $result_html;
1324 1337 }
1325 1338
1326 1339 /**
1340 + * Read the final URL from the WordPress HTTP response when Requests has
1341 + * followed one or more redirects. Older HTTP transports may not expose it;
1342 + * in that case the empty result leaves the existing fail-open behavior.
1343 + *
1344 + * @param mixed $response wp_remote_get() response.
1345 + * @return string Final URL, or an empty string when unavailable.
1346 + */
1347 + private function get_effective_fetch_url($response)
1348 + {
1349 + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
1350 + return '';
1351 + }
1352 +
1353 + $http_response = $response['http_response'];
1354 + if (!method_exists($http_response, 'get_response_object')) {
1355 + return '';
1356 + }
1357 +
1358 + $requests_response = $http_response->get_response_object();
1359 + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
1360 + return '';
1361 + }
1362 +
1363 + return $requests_response->url;
1364 + }
1365 +
1366 + /**
1367 + * Determine whether a followed response still represents the requested
1368 + * route. The internal fetch may use a rewritten host for tunnel support,
1369 + * so both the original and transport hosts are accepted; the path and
1370 + * non-OTTO query parameters must remain identical.
1371 + *
1372 + * @param string $effective_url Final URL reported by Requests.
1373 + * @param string $route Original route supplied to OTTO.
1374 + * @param string $fetch_url URL actually passed to wp_remote_get().
1375 + * @return bool True when no meaningful redirect changed the route.
1376 + */
1377 + private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1378 + {
1379 + $effective = wp_parse_url($effective_url);
1380 + $requested = wp_parse_url($route);
1381 + $transport = wp_parse_url($fetch_url);
1382 + if (!is_array($effective) || !is_array($requested)) {
1383 + return true;
1384 + }
1385 +
1386 + $effective_path = isset($effective['path']) ? $effective['path'] : '/';
1387 + $requested_path = isset($requested['path']) ? $requested['path'] : '/';
1388 + if ($effective_path !== $requested_path) {
1389 + return false;
1390 + }
1391 +
1392 + $allowed_hosts = [];
1393 + foreach ([$requested, $transport] as $parts) {
1394 + if (!empty($parts['host'])) {
1395 + $host = strtolower($parts['host']);
1396 + if (!empty($parts['port'])) {
1397 + $host .= ':' . $parts['port'];
1398 + }
1399 + $allowed_hosts[] = $host;
1400 + }
1401 + }
1402 + if (!empty($effective['host'])) {
1403 + $effective_host = strtolower($effective['host']);
1404 + if (!empty($effective['port'])) {
1405 + $effective_host .= ':' . $effective['port'];
1406 + }
1407 + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
1408 + return false;
1409 + }
1410 + }
1411 +
1412 + return $this->without_otto_fetch_params($effective['query'] ?? '')
1413 + === $this->without_otto_fetch_params($requested['query'] ?? '');
1414 + }
1415 +
1416 + /**
1417 + * Remove parameters used only to mark OTTO's internal request.
1418 + *
1419 + * @param string $query Query string without the leading '?'.
1420 + * @return string Query with OTTO markers removed.
1421 + */
1422 + private function without_otto_fetch_params($query)
1423 + {
1424 + $query = (string) $query;
1425 + if ($query === '') {
1426 + return '';
1427 + }
1428 +
1429 + $parts = [];
1430 + foreach (explode('&', $query) as $part) {
1431 + $name = strstr($part, '=', true);
1432 + $name = $name === false ? $part : $name;
1433 + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
1434 + continue;
1435 + }
1436 + $parts[] = $part;
1437 + }
1438 +
1439 + return implode('&', $parts);
1440 + }
1441 +
1442 + /**
1327 1443 * Repair double-encoded character references in serialized HTML.
1328 1444 *
1329 1445 * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
1330 1446 * $this->dom->save() and $root->outertext) runs every attribute value through
@@ -1985,10 +2101,10 @@
1985 2101 }
1986 2102
1987 2103 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1988 2104 if (strpos($url, '//') === 0) {
1989 - $parsed_home = parse_url($home_url);
1990 - $parsed_link = parse_url($url);
2105 + $parsed_home = wp_parse_url($home_url);
2106 + $parsed_link = wp_parse_url($url);
1991 2107
1992 2108 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1993 2109 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1994 2110 return false;
@@ -2110,21 +2226,32 @@
2110 2226 return $post_id ? (int) $post_id : 0;
2111 2227 }
2112 2228
2113 2229 /**
2114 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
2115 - * data-metasync-seo="custom"); insert one after <head> if none is present.
2116 - * Helper for apply_custom_seo_precedence().
2230 + * Replace the first <meta $attr="$val"> content with $content_escaped.
2117 2231 *
2118 2232 * @param string $html
2119 2233 * @param string $attr "name" or "property"
2120 2234 * @param string $val e.g. "description", "og:description"
2121 2235 * @param string $content_escaped Already HTML-escaped content value
2236 + * @param string $marker Provenance marker for the winning tier
2122 2237 * @return string
2123 2238 */
2124 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
2125 - $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '" data-metasync-seo="custom" />';
2126 - $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';
2127 2254 $count = 0;
2128 2255 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
2129 2256 return $tag;
2130 2257 }, $html, 1, $count);
@@ -2137,23 +2264,37 @@
2137 2264 }, $html, 1);
2138 2265 }
2139 2266
2140 2267 /**
2141 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2268 + * Make sure the precedence resolver is loaded before calling it.
2142 2269 *
2143 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
2144 - * OTTO for these fields, but later steps re-introduce OTTO's value:
2145 - * - the manual string-replacement title pass re-applies OTTO's title;
2146 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
2147 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
2148 - * exactly the case when the post is synced to a third-party SEO plugin and
2149 - * 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.
2150 2288 *
2151 - * Running LAST — after all dedup/precedence steps — this makes the user's
2152 - * explicit sidebar value authoritative regardless of how the tag was produced,
2153 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
2154 - * 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.
2155 2293 *
2294 + * OG and Twitter descriptions remain outside this method deliberately;
2295 + * social precedence is a separate follow-up scope.
2296 + *
2156 2297 * @param string $html Full HTML document (post-dedup).
2157 2298 * @return string
2158 2299 */
2159 2300 private function apply_custom_seo_precedence($html) {
@@ -2164,12 +2305,26 @@
2164 2305 if (!$post_id) {
2165 2306 return $html;
2166 2307 }
2167 2308
2168 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
2169 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
2170 - if (!empty($custom_title) && is_string($custom_title)) {
2171 - $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>';
2172 2327 $count = 0;
2173 2328 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
2174 2329 return $title_tag;
2175 2330 }, $html, 1, $count);
@@ -2181,19 +2336,59 @@
2181 2336 }, $html, 1);
2182 2337 }
2183 2338 }
2184 2339
2185 - # Description — force the custom sidebar description into the SEO
2186 - # <meta name="description"> only. og:/twitter: description are social tags
2187 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
2188 - # earlier); this pass must NOT touch them, or it would override a
2189 - # separately user-set og:description with the plain SEO description.
2190 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2191 - if (!empty($custom_desc) && is_string($custom_desc)) {
2192 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
2193 - $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);
2194 2357 }
2195 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 +
2196 2391 return $html;
2197 2392 }
2198 2393
2199 2394 # this function does the header replacements
@@ -2938,8 +3133,82 @@
2938 3133 return $html;
2939 3134 }
2940 3135
2941 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 + /**
2942 3211 * Remove duplicate <link rel="canonical"> tags from HTML.
2943 3212 *
2944 3213 * When OTTO injects a canonical via header_html_insertion and MetaSync's
2945 3214 * SEO output (or WordPress core) has already emitted one, keep only the
@@ -2980,101 +3249,301 @@
2980 3249 return $html;
2981 3250 }
2982 3251
2983 3252 /**
3253 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3254 + *
3255 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3256 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3257 + * Collapsing such a node to its first token hides it from a plain-typed
3258 + * duplicate, so every token is returned and indexed separately.
3259 + *
3260 + * @param mixed $raw The raw `@type` value.
3261 + * @return string[] Unique lowercased tokens, possibly empty.
3262 + */
3263 + private function schema_type_tokens($raw) {
3264 + $tokens = [];
3265 +
3266 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3267 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3268 + continue;
3269 + }
3270 + $type = strtolower(trim((string) $type));
3271 + if ($type === '') {
3272 + continue;
3273 + }
3274 + $tokens[$type] = true;
3275 + }
3276 +
3277 + return array_keys($tokens);
3278 + }
3279 +
3280 + /**
2984 3281 * Deduplicate JSON-LD schema blocks.
2985 3282 *
2986 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
2987 - * blocks, keep OTTO's version for any @type that appears in both.
2988 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3283 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3284 + * token the page already carries. Collisions resolve strictly in OTTO's
3285 + * favour:
2989 3286 *
3287 + * - an OTTO node (marked `data-otto`) displaces every colliding
3288 + * third-party node, whichever appeared first in the document;
3289 + * - two OTTO nodes describing the same entity keep the first;
3290 + * - two third-party nodes never evict each other, even when they share a
3291 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3292 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3293 + * output this pass has no mandate to fold.
3294 + *
3295 + * When OTTO contributed no node at all the document comes back untouched —
3296 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3297 + * into the original document, so a block that loses nothing keeps its exact
3298 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3299 + * and nothing is ever relocated.
3300 + *
3301 + * Blocks are left exactly where they are when their JSON does not parse,
3302 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3303 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3304 + * it in a comment must stay disabled, not be re-published by this pass.
3305 + *
2990 3306 * @param string $html Full HTML.
2991 3307 * @return string
2992 3308 */
2993 3309 private function deduplicate_schema_tags($html) {
2994 - // Find all JSON-LD script blocks
2995 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
2996 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3310 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3311 + // capturing only the part before `type=` would hide a `data-otto` marker
3312 + // that sits after it, which is where every insertion path puts it. The
3313 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3314 + // is recognised too.
3315 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3316 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
2997 3317 return $html;
2998 3318 }
2999 3319
3000 - $otto_by_type = []; // @type => decoded JSON object
3001 - $third_by_type = []; // @type => decoded JSON object
3002 - $otto_graph = []; // entries from OTTO @graph blocks
3003 - $third_graph = []; // entries from third-party @graph blocks
3320 + // Byte ranges this pass must not reach into. A commented-out (or
3321 + // <noscript>-wrapped) schema block is deliberately disabled output.
3322 + $masked = [];
3323 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3324 + foreach ($comments as $comment) {
3325 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3326 + }
3327 + }
3004 3328
3329 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3330 + // `data-otto`. A substring test would also catch a hypothetical
3331 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3332 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3333 +
3334 + // Blocks able to take part, and their entities in document order. An
3335 + // entity is one addressable node: a flat block's document, one
3336 + // `@graph` entry, or one node of a top-level array.
3337 + $blocks = [];
3338 + $entities = [];
3339 +
3005 3340 foreach ($matches as $m) {
3006 - $attrs = $m[1];
3007 - $json_str = $m[3];
3341 + $start = $m[0][1];
3342 + foreach ($masked as $range) {
3343 + if ($start >= $range[0] && $start < $range[1]) {
3344 + continue 2;
3345 + }
3346 + }
3347 +
3348 + $attrs = $m[1][0];
3349 + $json_str = $m[3][0];
3008 3350 $decoded = json_decode($json_str, true);
3009 3351 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
3010 - continue; // skip unparseable blocks — leave them in place
3352 + continue; // unparseable — leave the block in place
3011 3353 }
3012 - $is_otto = stripos($attrs, 'data-otto') !== false;
3013 3354
3355 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3356 + $end = $start + strlen($m[0][0]);
3357 +
3358 + // A trailing newline swallowed on full removal keeps the output tidy.
3359 + $end_ws = $end;
3360 + if (substr($html, $end, 2) === "\r\n") {
3361 + $end_ws = $end + 2;
3362 + } elseif (substr($html, $end, 1) === "\n") {
3363 + $end_ws = $end + 1;
3364 + }
3365 +
3366 + $register = function (array $node) use (&$entities, $is_otto) {
3367 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3368 + // @type tokens for the type fold. Entries with neither stay in
3369 + // their block but cannot collide, so they keep entity === null.
3370 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3371 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3372 + if ($types === [] && $id === '') {
3373 + return null;
3374 + }
3375 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3376 + return count($entities) - 1;
3377 + };
3378 +
3379 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3380 +
3014 3381 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
3015 - foreach ($decoded['@graph'] as $entry) {
3016 - if (!isset($entry['@type'])) continue;
3017 - // JSON-LD allows @type to be a string OR an array of strings.
3018 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
3019 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
3020 - $type = is_array($entry['@type'])
3021 - ? (string) reset($entry['@type'])
3022 - : (string) $entry['@type'];
3023 - if ($type === '') continue;
3024 - if ($is_otto) {
3025 - $otto_graph[$type] = $entry;
3026 - } else {
3027 - $third_graph[$type] = $entry;
3028 - }
3382 + foreach ($decoded['@graph'] as $node) {
3383 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3029 3384 }
3385 + $block['shape'] = 'graph';
3030 3386 } elseif (isset($decoded['@type'])) {
3031 - $type = is_array($decoded['@type'])
3032 - ? (string) reset($decoded['@type'])
3033 - : (string) $decoded['@type'];
3034 - if ($type === '') continue;
3035 - if ($is_otto) {
3036 - $otto_by_type[$type] = $decoded;
3387 + $block['shape'] = 'flat';
3388 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3389 + } elseif (isset($decoded[0])) {
3390 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3391 + // persistence path already folds this shape; the render path
3392 + // used to skip it entirely, leaving its duplicates standing.
3393 + $block['shape'] = 'list';
3394 + foreach ($decoded as $node) {
3395 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3396 + }
3397 + } else {
3398 + continue; // neither @type nor @graph nor array — nothing to key on
3399 + }
3400 +
3401 + $blocks[] = $block;
3402 + }
3403 +
3404 + foreach ($entities as $entity) {
3405 + if ($entity['otto']) {
3406 + $otto_present = true;
3407 + break;
3408 + }
3409 + }
3410 + if (empty($otto_present)) {
3411 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3412 + }
3413 +
3414 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3415 + // node can displace a whole set of third-party duplicates at once.
3416 + /** @var array<string, int> $by_id */
3417 + $by_id = []; // '@id:...' => entity index
3418 + /** @var array<string, int[]> $by_type */
3419 + $by_type = []; // type token => entity index[]
3420 +
3421 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3422 + foreach ($entities[$i]['types'] as $token) {
3423 + $holders = $by_type[$token] ?? [];
3424 + $holders = array_values(array_diff($holders, [$i]));
3425 + if ($holders === []) {
3426 + unset($by_type[$token]);
3037 3427 } else {
3038 - $third_by_type[$type] = $decoded;
3428 + $by_type[$token] = $holders;
3039 3429 }
3040 3430 }
3431 + $id = $entities[$i]['id'];
3432 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3433 + unset($by_id[$id]);
3434 + }
3435 + };
3436 +
3437 + foreach ($entities as $i => $entity) {
3438 + $alive = true;
3439 +
3440 + // The same real-world entity twice, whatever @type each copy carries.
3441 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3442 + $occupant = $by_id[$entity['id']];
3443 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3444 + $entities[$occupant]['alive'] = false;
3445 + $release($occupant);
3446 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3447 + } else {
3448 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3449 + }
3450 + }
3451 +
3452 + if ($alive) {
3453 + foreach ($entity['types'] as $token) {
3454 + $holders = $by_type[$token] ?? [];
3455 + if ($holders === []) {
3456 + continue;
3457 + }
3458 + $otto_holder = false;
3459 + foreach ($holders as $holder) {
3460 + if ($entities[$holder]['otto']) {
3461 + $otto_holder = true;
3462 + break;
3463 + }
3464 + }
3465 + if ($otto_holder) {
3466 + $alive = false; // an OTTO node already holds this token
3467 + break;
3468 + }
3469 + if ($entity['otto']) {
3470 + // OTTO displaces every third-party holder of the token and
3471 + // releases ALL their claims, not just this one — otherwise
3472 + // tokens the evicted node held alone would stay blocked and
3473 + // later, legitimate nodes of that type would lose.
3474 + foreach ($holders as $holder) {
3475 + $entities[$holder]['alive'] = false;
3476 + $release($holder);
3477 + }
3478 + }
3479 + // Third party meeting third-party holders: both stand.
3480 + }
3481 + }
3482 +
3483 + if ($alive) {
3484 + foreach ($entity['types'] as $token) {
3485 + $by_type[$token][] = $i;
3486 + }
3487 + if ($entity['id'] !== '') {
3488 + $by_id[$entity['id']] = $i;
3489 + }
3490 + } else {
3491 + $entities[$i]['alive'] = false;
3492 + }
3041 3493 }
3042 3494
3043 - // If OTTO provided no schema at all, nothing to deduplicate
3044 - if (empty($otto_by_type) && empty($otto_graph)) {
3045 - return $html;
3495 + $any_evicted = false;
3496 + foreach ($entities as $entity) {
3497 + if (!$entity['alive']) {
3498 + $any_evicted = true;
3499 + break;
3500 + }
3046 3501 }
3502 + if (!$any_evicted) {
3503 + return $html; // no collision — every original byte stays
3504 + }
3047 3505
3048 - // Remove all JSON-LD blocks from HTML
3049 - $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 + }
3050 3526
3051 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
3052 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
3053 - $rebuilt = '';
3054 - foreach ($kept as $decoded) {
3055 - $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);
3056 3535 if (!is_string($json) || $json === '') {
3057 - continue; // never re-emit a block whose payload failed to encode
3536 + continue; // never drop a block whose payload failed to re-encode
3058 3537 }
3059 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $json . "</script>\n";
3060 - }
3061 3538
3062 - // Re-insert merged @graph block (if any entries exist)
3063 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
3064 - if (!empty($merged_graph)) {
3065 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
3066 - $graph_json = metasync_safe_json_ld_encode($graph_obj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3067 - if (is_string($graph_json) && $graph_json !== '') {
3068 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . $graph_json . "</script>\n";
3069 - }
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>'];
3070 3542 }
3071 3543
3072 - // Re-inject before </head>
3073 - if (!empty($rebuilt)) {
3074 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
3075 - return $rebuilt . $m[1];
3076 - }, $html, 1);
3544 + foreach (array_reverse($edits) as $edit) {
3545 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
3077 3546 }
3078 3547
3079 3548 return $html;
3080 3549 }
@@ -3093,14 +3562,14 @@
3093 3562 return true;
3094 3563 }
3095 3564
3096 3565 # Check if amp=1 query parameter is present
3097 - 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
3098 3567 return true;
3099 3568 }
3100 3569
3101 3570 # Check for other common AMP query parameters
3102 - 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
3103 3572 return true;
3104 3573 }
3105 3574
3106 3575 return false;
@@ -3105,8 +3574,32 @@
3105 3574
3106 3575 return false;
3107 3576 }
3108 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 +
3109 3602 # this function insterts header html to the dom
3110 3603 function insert_header_html($data){
3111 3604
3112 3605 # check that we have the header html
@@ -3130,9 +3623,13 @@
3130 3623 $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
3131 3624
3132 3625 # Escape JSON-LD payload closers so a `</script>` inside a JSON
3133 3626 # string value cannot terminate the element early (XSS/page corruption).
3134 - $escaped_header_insertion = metasync_escape_json_ld_blocks_in_html($data['header_html_insertion']);
3627 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3628 + # fallbacks do, so the deduper can attribute them.
3629 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3630 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3631 + );
3135 3632
3136 3633 $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3137 3634
3138 3635 }
@@ -3516,18 +4013,16 @@
3516 4013 }
3517 4014
3518 4015 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3519 4016 if (!empty($replacement_data['header_html_insertion'])) {
3520 - # Escape JSON-LD payload closers first; the DOM path inserts the
3521 - # same escaped bytes, so the already-applied check must match them.
3522 - $escaped_header_insertion = metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion']);
3523 - $header_html_check = trim($escaped_header_insertion);
4017 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
4018 + # path inserts the same bytes, so the already-applied check must compare
4019 + # against the stamped form or it would insert a second copy.
4020 + $header_html_insertion = $this->stamp_otto_json_ld(
4021 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
4022 + );
4023 + $header_html_check = trim($header_html_insertion);
3524 4024 if (strpos($result_html, $header_html_check) === false) {
3525 - $header_html_insertion = preg_replace(
3526 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3527 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3528 - $escaped_header_insertion
3529 - );
3530 4025 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3531 4026 # Insert before </head>
3532 4027 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3533 4028 }
@@ -3599,9 +4094,9 @@
3599 4094
3600 4095 $result_html = preg_replace_callback(
3601 4096 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3602 4097 function ($m) use ($current_value, $recommended_value) {
3603 - $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')));
3604 4099 if ($inner_text !== $current_value) {
3605 4100 return $m[0];
3606 4101 }
3607 4102 # Keep the author's inline formatting; skip when it can't
@@ -3659,8 +4154,9 @@
3659 4154
3660 4155 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
3661 4156 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
3662 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');
3663 4159 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
3664 4160 $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
3665 4161 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
3666 4162 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
@@ -3741,12 +4237,26 @@
3741 4237
3742 4238 return $result_html;
3743 4239
3744 4240 } catch (Exception $e) {
3745 - 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 + }
3746 4249 return false;
3747 4250 } catch (Error $e) {
3748 - 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 + }
3749 4259 return false;
3750 4260 }
3751 4261 }
3752 4262
@@ -3795,23 +4305,15 @@
3795 4305 }
3796 4306 }
3797 4307 }
3798 4308
3799 - if ($remove_title && !$has_custom_title) {
3800 - # Remove Open Graph and Twitter title tags (keep main <title>)
3801 - $title_selectors = [
3802 - 'meta[property=og:title]',
3803 - 'meta[name=twitter:title]',
3804 - ];
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.
3805 4315
3806 - foreach ($title_selectors as $selector) {
3807 - $tags = $this->dom->find($selector);
3808 - foreach ($tags as $tag) {
3809 - $tag->outertext = ''; # Remove the tag
3810 - }
3811 - }
3812 - }
3813 -
3814 4316 # Save changes
3815 4317 $this->save_reload();
3816 4318 }
3817 4319
@@ -4035,5 +4537,5 @@
4035 4537 return $html;
4036 4538 }
4037 4539
4038 4540
4039 -}
4541 +}