| @@ -1273,8 +1273,9 @@ | ||
| 1273 | 1273 | |
| 1274 | 1274 | # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema |
| 1275 | 1275 | $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags'); |
| 1276 | 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'); | |
| 1277 | 1278 | $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags'); |
| 1278 | 1279 | $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence'); |
| 1279 | 1280 | $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); |
| 1280 | 1281 | $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); |
| @@ -2239,9 +2240,18 @@ | ||
| 2239 | 2240 | $marker_attribute = $marker === 'otto' |
| 2240 | 2241 | ? ' data-metasync-otto="true"' |
| 2241 | 2242 | : ' data-metasync-seo="custom"'; |
| 2242 | 2243 | $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />'; |
| 2243 | - $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i'; | |
| 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'; | |
| 2244 | 2254 | $count = 0; |
| 2245 | 2255 | $new = preg_replace_callback($pattern, function ($m) use ($tag) { |
| 2246 | 2256 | return $tag; |
| 2247 | 2257 | }, $html, 1, $count); |
| @@ -2272,9 +2282,10 @@ | ||
| 2272 | 2282 | return class_exists('Metasync_Seo_Precedence'); |
| 2273 | 2283 | } |
| 2274 | 2284 | |
| 2275 | 2285 | /** |
| 2276 | - * Enforce the resolved SEO title/description as the FINAL word over OTTO. | |
| 2286 | + * Enforce the resolved SEO title, description and keywords as the FINAL | |
| 2287 | + * word over OTTO. | |
| 2277 | 2288 | * |
| 2278 | 2289 | * The resolver owns the global custom-versus-OTTO order. This final buffer |
| 2279 | 2290 | * pass must use it too: otherwise the setting works through the sidebar |
| 2280 | 2291 | * and third-party filters but OTTO's later string pass puts the custom |
| @@ -2344,8 +2355,40 @@ | ||
| 2344 | 2355 | : 'custom'; |
| 2345 | 2356 | $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker); |
| 2346 | 2357 | } |
| 2347 | 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 | + | |
| 2348 | 2391 | return $html; |
| 2349 | 2392 | } |
| 2350 | 2393 | |
| 2351 | 2394 | # this function does the header replacements |
| @@ -3090,8 +3133,82 @@ | ||
| 3090 | 3133 | return $html; |
| 3091 | 3134 | } |
| 3092 | 3135 | |
| 3093 | 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 | + /** | |
| 3094 | 3211 | * Remove duplicate <link rel="canonical"> tags from HTML. |
| 3095 | 3212 | * |
| 3096 | 3213 | * When OTTO injects a canonical via header_html_insertion and MetaSync's |
| 3097 | 3214 | * SEO output (or WordPress core) has already emitted one, keep only the |
| @@ -4037,8 +4154,9 @@ | ||
| 4037 | 4154 | |
| 4038 | 4155 | # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema |
| 4039 | 4156 | $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags'); |
| 4040 | 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'); | |
| 4041 | 4159 | $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags'); |
| 4042 | 4160 | $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence'); |
| 4043 | 4161 | $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); |
| 4044 | 4162 | $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); |