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