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