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 | includes/class-metasync-seo-conflict-handler.php +1418 -147 2.6.14 → 2.7.2 View file →
@@ -35,8 +35,15 @@
35 35 */
36 36 private $has_description_cache = null;
37 37
38 38 /**
39 + * Resolved MetaSync title per post id, for this request only.
40 + *
41 + * @var array<int,string>
42 + */
43 + private $title_value_cache = [];
44 +
45 + /**
39 46 * Cached result for whether AIOSEO provides a description for the current page.
40 47 *
41 48 * @var bool|null
42 49 */
@@ -42,10 +49,19 @@
42 49 */
43 50 private $aioseo_has_description_cache = null;
44 51
45 52 /**
46 - * Cached result: whether the current post has been synced via WP-196.
53 + * Cached per-plugin robots-suppression decisions for the current
54 + * request (the Rank Math main + advanced robots filters both invoke
55 + * the check during wp_head).
47 56 *
57 + * @var array<string, bool>
58 + */
59 + private $robots_suppress_cache = [];
60 +
61 + /**
62 + * Cached result: whether the current post has been synced via.
63 + *
48 64 * @var array Keyed by post_id => bool
49 65 */
50 66 private $sync_cache = [];
51 67
@@ -136,8 +152,61 @@
136 152 || is_plugin_active('seo-by-rank-math/rank-math.php')
137 153 || is_plugin_active('seo-by-rankmath/rank-math.php');
138 154 }
139 155
156 + /**
157 + * Whether an active third-party SEO plugin actually holds a
158 + * non-empty meta description for this post in its OWN storage.
159 + *
160 + * The native-first output guards previously stood down whenever a
161 + * sync timestamp (_metasync_plugin_sync_ts) existed, assuming the plugin
162 + * would render the description. But a stale or partial sync leaves the
163 + * plugin's field empty — MetaSync suppresses its own tag, the plugin has
164 + * nothing to emit, and the description is dropped entirely. Callers use this
165 + * to only defer when the plugin can genuinely output a description.
166 + *
167 + * @param int $post_id Post ID.
168 + * @return bool True if the active/primary SEO plugin has a description.
169 + */
170 + public function active_plugin_has_description($post_id) {
171 + $post_id = (int) $post_id;
172 + if ($post_id <= 0) {
173 + return false;
174 + }
175 +
176 + $this->ensure_plugin_api();
177 +
178 + // Yoast (free or premium)
179 + if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
180 + if (!empty(get_post_meta($post_id, '_yoast_wpseo_metadesc', true))) {
181 + return true;
182 + }
183 + }
184 +
185 + // Rank Math
186 + if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
187 + if (!empty(get_post_meta($post_id, 'rank_math_description', true))) {
188 + return true;
189 + }
190 + }
191 +
192 + // AIOSEO stores its description in a custom table, not post meta.
193 + if ($this->is_aioseo_active()) {
194 + global $wpdb;
195 + // Defensive: $wpdb is always present on a booted frontend, but never
196 + // assume — a method call on a null/!object $wpdb would be fatal.
197 + if (isset($wpdb) && is_object($wpdb)) {
198 + $table = $wpdb->prefix . 'aioseo_posts';
199 + $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d", $post_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads third-party plugin storage — AIOSEO keeps descriptions in its own table, no WordPress API
200 + if (!empty($desc)) {
201 + return true;
202 + }
203 + }
204 + }
205 +
206 + return false;
207 + }
208 +
140 209 // ------------------------------------------------------------------
141 210 // MetaSync description resolution
142 211 // ------------------------------------------------------------------
143 212
@@ -167,9 +236,9 @@
167 236 // Term-level: on taxonomy archives MetaSync may have term meta
168 237 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
169 238 $term = $this->get_current_term();
170 239 if ($term) {
171 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
240 + $term_desc = $this->get_metasync_term_description($term);
172 241 if (!empty($term_desc)) {
173 242 $this->has_description_cache = true;
174 243 return true;
175 244 }
@@ -195,24 +264,271 @@
195 264 if (!$post_id) {
196 265 return '';
197 266 }
198 267
199 - // 1. SEO sidebar (highest priority — user-edited)
200 - $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
201 - if (!empty($desc)) {
202 - return $desc;
268 + // The full chain, from Metasync_Seo_Precedence. It has to be the same
269 + // chain the description filters hand to a third-party plugin: if this
270 + // reported a narrower set, MetaSync's own emitter would think it held
271 + // nothing, emit anyway, and the page would carry two identical
272 + // description tags.
273 + return $this->metasync_description_value($post_id);
274 + }
275 +
276 + /**
277 + * Return MetaSync's intentional SEO title for the current request.
278 + *
279 + * Mirrors get_metasync_description(): only values that were explicitly set
280 + * (sidebar or OTTO), never an auto-generated fallback such as the post title.
281 + * This is the value the og:title / twitter:title suppression keys off, so the
282 + * sidebar emitter can render the same title back as a replacement.
283 + *
284 + * @return string
285 + */
286 + public function get_metasync_title() {
287 + $post_id = $this->get_current_object_id();
288 +
289 + if (!$post_id) {
290 + return '';
203 291 }
204 292
205 - // 2. OTTO description
206 - $desc = get_post_meta($post_id, '_metasync_otto_description', true);
207 - if (!empty($desc)) {
208 - return $desc;
293 + // Post-scoped values only. On an archive $post_id is a term id and every
294 + // read below would answer for an unrelated post. get_metasync_description()
295 + // gates the same way through metasync_description_value().
296 + if (!$this->current_object_is_post()) {
297 + return '';
209 298 }
210 299
211 - return '';
300 + // 1. Social Media & Open Graph meta box (most specific — a social-only title)
301 + $og_title = $this->get_customized_og_title($post_id);
302 + if ($og_title !== '') {
303 + return $og_title;
304 + }
305 +
306 + // 2. The stored page title, in whatever order the site has asked for.
307 + //
308 + // This used to restate the order inline — sidebar key, then OTTO key —
309 + // so the global "SEO Meta Priority" setting could not
310 + // reach the og:title this method feeds, and a title held on the
311 + // persisted or imported tier was missed entirely. The description side
312 + // already resolves through metasync_description_value(); this is the
313 + // matching path for titles.
314 + //
315 + // Only which title WINS moves. metasync_has_title() is deliberately
316 + // left alone: it asks whether MetaSync holds a title at all, which is
317 + // an OR across the tiers and so cannot change with their order. The
318 + // suppression decision must stay identical in both modes.
319 + return $this->metasync_title_value($post_id);
212 320 }
213 321
214 322 /**
323 + * The per-post OG meta box title, but only when the user genuinely set it.
324 + *
325 + * The meta box used to pre-fill its Title from the post title as a real value
326 + * and PERSIST that default on save, so a non-empty `_metasync_og_title` alone
327 + * did not prove intent — treating it as one would let an auto-filled post
328 + * title override a deliberately-set SEO title on every ordinary edit. The box
329 + * no longer stores the default, and reads collapse rows that still hold it
330 + * ("Auto Draft" and title snapshots alike), so a non-empty value IS the
331 + * user's. The inline comparison survives only as the fallback for a partially
332 + * updated install pairing this file with an older emitter.
333 + *
334 + * @param int $post_id
335 + * @return string The customized OG title, or '' when unset or auto-filled.
336 + */
337 + private function get_customized_og_title($post_id) {
338 + // Per-post OG meta box values only exist on singular views. On archives
339 + // get_current_object_id() returns a TERM id, and reading post meta with it
340 + // would consult an unrelated post — suppressing the third-party tag on a
341 + // page where the singular-only replacement emitter never runs.
342 + if (!is_singular()) {
343 + return '';
344 + }
345 +
346 + // @phpstan-ignore-next-line function.alreadyNarrowedType
347 + if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot')) {
348 + return Metasync_OpenGraph::get_social_meta($post_id, '_metasync_og_title');
349 + }
350 +
351 + $og_title = (string) get_post_meta($post_id, '_metasync_og_title', true);
352 + if ($og_title === '') {
353 + return '';
354 + }
355 +
356 + $post = get_post($post_id);
357 + $default = ($post instanceof WP_Post) ? (string) $post->post_title : '';
358 +
359 + return $og_title === $default ? '' : $og_title;
360 + }
361 +
362 + /**
363 + * Whether this request suppressed a third-party plugin's og:title /
364 + * twitter:title and therefore owes a replacement tag.
365 + *
366 + * Only true in the narrow case where the suppression leaves a gap:
367 + * - Yoast or AIOSEO is active. Those are the only plugins whose og:title
368 + * we filter; Rank Math / SEOPress / TSF render their own untouched, and
369 + * on a MetaSync-only site Metasync_OpenGraph::output_opengraph_tags()
370 + * still emits og:title itself — emitting here would duplicate it.
371 + * - That plugin is not the primary output owner for the post (a synced
372 + * post renders the plugin's own tags, so we must not double up).
373 + * - The per-post OG toggle is not explicitly off (toggle off suppresses
374 + * nothing, so nothing is owed).
375 + * - MetaSync has an intentional title — the value that triggered the
376 + * suppression in filter_yoast_og_title() / filter_aioseo_facebook_tags().
377 + *
378 + * Pages where OTTO owns og:title are deliberately excluded: OTTO injects its
379 + * own tag through the output buffer, so there is no gap to fill.
380 + *
381 + * @return bool
382 + */
383 + public function og_title_needs_replacement() {
384 + if (!$this->suppresses_third_party_og_title()) {
385 + return false;
386 + }
387 +
388 + // Nothing to replace once the social filters are writing MetaSync's own
389 + // og:title into the plugin's tag — emitting here too would duplicate it
390 + // rather than fill a gap. This is only reached for a post that holds a
391 + // page title and no OG-specific one.
392 + $post_id_for_og = $this->get_current_object_id();
393 + if ($post_id_for_og
394 + && $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_TITLE, $post_id_for_og) !== '') {
395 + return false;
396 + }
397 +
398 + $post_id = $this->get_current_object_id();
399 + if (!$post_id) {
400 + return false;
401 + }
402 +
403 + if ($this->og_output_disabled($post_id)) {
404 + return false;
405 + }
406 +
407 + foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
408 + if ($this->is_primary_output_plugin($post_id, $slug)) {
409 + return false;
410 + }
411 + }
412 +
413 + return $this->metasync_has_title($post_id);
414 + }
415 +
416 + /**
417 + * Whether an active third-party plugin is one whose og:title we filter
418 + * (Yoast or AIOSEO). Rank Math, SEOPress and The SEO Framework emit their
419 + * own og:title untouched, so they never leave a gap to fill.
420 + *
421 + * @return bool
422 + */
423 + private function suppresses_third_party_og_title() {
424 + $this->ensure_plugin_api();
425 +
426 + return is_plugin_active('wordpress-seo/wp-seo.php')
427 + || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
428 + || $this->is_aioseo_active();
429 + }
430 +
431 + /**
432 + * Whether an active third-party SEO plugin renders the og:description /
433 + * twitter:description for this request, so MetaSync must not add its own.
434 + *
435 + * Mirrors the condition the suppression filters use: when that plugin is the
436 + * primary output owner, filter_yoast_og_description() (and the AIOSEO/Rank
437 + * Math equivalents) pass its tag through untouched, so a second tag from the
438 + * sidebar emitter would duplicate it.
439 + *
440 + * Deliberately does NOT ask whether the plugin's OG description field is
441 + * populated. Yoast falls back to its meta description, and then to the
442 + * excerpt, so it emits an og:description either way — an "is the field set"
443 + * test would miss those fallbacks and let the duplicate through.
444 + *
445 + * @return bool
446 + */
447 + public function third_party_owns_og_description() {
448 + if (!$this->has_active_seo_plugin()) {
449 + return false;
450 + }
451 +
452 + // Per-post ownership only means anything on singular views. On archives
453 + // get_current_object_id() returns a TERM id, and the sync timestamp would
454 + // be read from an unrelated post of the same numeric id.
455 + if (!is_singular()) {
456 + return false;
457 + }
458 +
459 + $post_id = $this->get_current_object_id();
460 + if (!$post_id) {
461 + return false;
462 + }
463 +
464 + foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
465 + if ($this->is_primary_output_plugin($post_id, $slug)) {
466 + return true;
467 + }
468 + }
469 +
470 + return false;
471 + }
472 +
473 + /**
474 + * Resolve the MetaSync-managed canonical URL for a post, if any.
475 + *
476 + * Routes through the same Metasync_Seo_Precedence::FIELD_CANONICAL chain
477 + * the emitter uses — the persisted or imported value
478 + * (_metasync_canonical_url), then the Canonical meta box value the customer
479 + * typed (meta_canonical), then OTTO's volatile staging key
480 + * (_metasync_otto_canonical) — so a third-party SEO plugin's canonical
481 + * filter emits the same canonical Metasync_Seo_Output would, including
482 + * OTTO's when no manual value exists, instead of leaving the third-party
483 + * tag pointing at the permalink. The staging tier sits below both manual
484 + * keys because OTTO's SSR injection stands down when either is set; any
485 + * other order would make the two paths disagree. Restricted to singular
486 + * views so term/archive queried-object ids are never misread as post ids.
487 + *
488 + * @param int $post_id Current object id.
489 + * @return string Escaped canonical URL, or '' when none is set.
490 + */
491 + private function get_metasync_canonical($post_id) {
492 + if (!$post_id || !is_singular()) {
493 + return '';
494 + }
495 +
496 + // Canonical feature switched off. Returning empty makes every canonical
497 + // filter fall back to the third-party plugin's own value rather than
498 + // overriding it with a MetaSync canonical the user has disabled.
499 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
500 + return '';
501 + }
502 +
503 + // Resolve the precedence chain. include_otto defaults to the per-post
504 + // "Disable OTTO" toggle inside resolve(), so a stood-down suggestion is
505 + // not served. Every tier is validated on the way out so legacy rows
506 + // corrupted to the literal "Array" (or stored as arrays) never emit.
507 + // A partial install without the resolver degrades to the direct reads
508 + // of the two manual keys the chain replaced, same order.
509 + if (self::precedence_available()) {
510 + $canonical = Metasync_Canonical_Sanitizer::sanitize(
511 + Metasync_Seo_Precedence::value(
512 + $post_id,
513 + Metasync_Seo_Precedence::FIELD_CANONICAL
514 + )
515 + );
516 + } else {
517 + $canonical = Metasync_Canonical_Sanitizer::sanitize(
518 + get_post_meta($post_id, '_metasync_canonical_url', true)
519 + );
520 + if ($canonical === '') {
521 + $canonical = Metasync_Canonical_Sanitizer::sanitize(
522 + get_post_meta($post_id, 'meta_canonical', true)
523 + );
524 + }
525 + }
526 +
527 + return $canonical !== '' ? esc_url($canonical) : '';
528 + }
529 +
530 + /**
215 531 * Reset the cached description flag (useful when the queried object changes).
216 532 */
217 533 public function reset_cache() {
218 534 $this->has_description_cache = null;
@@ -217,13 +533,14 @@
217 533 public function reset_cache() {
218 534 $this->has_description_cache = null;
219 535 $this->aioseo_has_description_cache = null;
220 536 $this->sync_cache = [];
537 + $this->robots_suppress_cache = [];
221 538 $this->live_suggestions_cache = null;
222 539 }
223 540
224 541 /**
225 - * Check whether a post has been synced to third-party plugins via WP-196.
542 + * Check whether a post has been synced to third-party plugins via.
226 543 *
227 544 * When native-first sync is active, each plugin reads MetaSync values from
228 545 * its own storage — no filter suppression needed. This method returns true
229 546 * when _metasync_plugin_sync_ts exists and contains a timestamp for the
@@ -271,9 +588,15 @@
271 588 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
272 589 'aioseo' => is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'),
273 590 ];
274 591
275 - $has_any_sync = $post_id > 0 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));
592 + // The sync stamp is post meta. On an archive $post_id is a term id, so
593 + // consulting it would adopt an unrelated post's sync state and hand this
594 + // archive's output to the wrong plugin. Archives fall through to the
595 + // unsynced branch below, which is what they are.
596 + $has_any_sync = $post_id > 0
597 + && $this->current_object_is_post()
598 + && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));
276 599
277 600 if ($has_any_sync) {
278 601 // Synced: primary = first active + synced plugin
279 602 foreach ($priority as $slug) {
@@ -331,11 +654,25 @@
331 654 add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999);
332 655
333 656 // Suppress AIOSEO schema/JSON-LD when OTTO has structured data
334 657 add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999);
658 +
659 + // Canonical — override AIOSEO's canonical with the MetaSync/OTTO value when set
660 + add_filter('aioseo_canonical_url', [$this, 'filter_aioseo_canonical'], 999);
335 661 }
336 662
337 663 /**
664 + * Filter AIOSEO's canonical URL.
665 + *
666 + * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
667 + * (OTTO or the Canonical meta box) when set, else pass AIOSEO's through.
668 + */
669 + public function filter_aioseo_canonical($canonical) {
670 + $custom = $this->get_metasync_canonical($this->get_current_object_id());
671 + return $custom !== '' ? $custom : $canonical;
672 + }
673 +
674 + /**
338 675 * Filter AIOSEO description output.
339 676 * Returns empty string when MetaSync has a description, letting MetaSync output it.
340 677 *
341 678 * @param string $description AIOSEO's computed description.
@@ -347,10 +684,20 @@
347 684 if ($this->aioseo_has_description_cache === null) {
348 685 $this->aioseo_has_description_cache = !empty($description);
349 686 }
350 687
351 - // WP-196: Primary plugin check — only the designated plugin outputs.
352 688 $post_id = $this->get_current_object_id();
689 +
690 + // If we hold a description, we write it. The sync stamp records that a
691 + // sync happened; it does not decide who renders. The mirrored copy lives
692 + // in the plugin's own storage and can be edited or overwritten there —
693 + // deferring to it hands our tag over with nothing saying so.
694 + $ours = $this->metasync_description_value($post_id);
695 + if ($ours !== '') {
696 + return $ours;
697 + }
698 +
699 + // Primary plugin check — only the designated plugin outputs.
353 700 if ($post_id && $this->has_active_seo_plugin()) {
354 701 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
355 702 return $description;
356 703 }
@@ -363,9 +710,9 @@
363 710 // from its `wp_aioseo_terms` table, so return the MetaSync value
364 711 // directly so AIOSEO renders it.
365 712 $term = $this->get_current_term();
366 713 if ($term) {
367 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
714 + $term_desc = $this->get_metasync_term_description($term);
368 715 if (!empty($term_desc)) {
369 716 return $term_desc;
370 717 }
371 718 }
@@ -387,10 +734,35 @@
387 734 * @param string $title AIOSEO's computed title.
388 735 * @return string
389 736 */
390 737 public function filter_aioseo_title($title) {
391 - // WP-196: Primary plugin check — only the designated plugin outputs.
738 + // Primary plugin check — only the designated plugin outputs.
392 739 $post_id = $this->get_current_object_id();
740 +
741 + // If we hold a title, we write it — the same rule the description path
742 + // follows. The sync stamp records that a sync happened; it does not decide
743 + // who renders. The mirrored copy lives in the plugin's own storage and can
744 + // be edited there, and deferring to it hands our tag over silently.
745 + //
746 + // A value, never '': on classic themes the plugin owns the sole <title>
747 + // renderer and OTTO's buffer replaces the tag afterwards, so blanking it
748 + // would leave nothing for OTTO to replace.
749 + $ours = $this->metasync_title_value($post_id);
750 + if ($ours !== '') {
751 + return $ours;
752 + }
753 +
754 + // Taxonomy archives resolve against term meta, not post meta. Resolve
755 + // the term before plugin ownership checks so a multi-plugin archive is
756 + // not suppressed by an unrelated primary-plugin decision.
757 + $term = $this->get_current_term();
758 + if ($term) {
759 + $term_title = $this->get_metasync_term_title($term);
760 + if (!empty($term_title)) {
761 + return $term_title;
762 + }
763 + }
764 +
393 765 if ($post_id && $this->has_active_seo_plugin()) {
394 766 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
395 767 return $title;
396 768 }
@@ -398,17 +770,8 @@
398 770 return '';
399 771 }
400 772 }
401 773
402 - // Term archives: return MetaSync term title directly.
403 - $term = $this->get_current_term();
404 - if ($term) {
405 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
406 - if (!empty($term_title)) {
407 - return $term_title;
408 - }
409 - }
410 -
411 774 if ($this->should_suppress_third_party_title()) {
412 775 return '';
413 776 }
414 777
@@ -431,26 +794,40 @@
431 794 }
432 795
433 796 $post_id = $this->get_current_object_id();
434 797
435 - // WP-196: Post synced to AIOSEO — let AIOSEO read from its own storage.
436 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
798 + // Per-post OG toggle off — MetaSync emits no OG, so leave AIOSEO's tags intact.
799 + if ($this->og_output_disabled($post_id)) {
437 800 return $meta;
438 801 }
439 802
440 - // og:title — suppress when OTTO has og:title OR MetaSync has title
441 - if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
803 + // og:title — ours when we hold one, otherwise drop it only if OTTO is
804 + // injecting its own through the buffer. The sync stamp no longer decides
805 + // this: AIOSEO's own copy can be edited there and would silently win.
806 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_TITLE, $post_id);
807 + if ($ours !== '') {
808 + $meta['og:title'] = $ours;
809 + } elseif ($this->otto_has_tag('og:title')) {
442 810 unset($meta['og:title']);
443 811 }
444 812
445 - // og:description — suppress when OTTO has og:description OR MetaSync has description
446 - if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
813 + // og:description — same rule.
814 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION, $post_id);
815 + if ($ours !== '') {
816 + $meta['og:description'] = $ours;
817 + } elseif ($this->otto_has_tag('og:description')) {
447 818 unset($meta['og:description']);
448 819 }
449 820
450 821 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
451 - // (OTTO injects these structural OG tags alongside og:title in its block)
452 - if ($this->otto_has_tag('og:title')) {
822 + // (OTTO injects these structural OG tags alongside og:title in its block).
823 + //
824 + // Still skipped for a post synced to AIOSEO. Only the two tags above stopped
825 + // deferring to the sync stamp; these structural tags carry no MetaSync value
826 + // to put in their place, so changing when they are dropped is not this
827 + // change's business.
828 + if (!($post_id && $this->is_primary_output_plugin($post_id, 'aioseo'))
829 + && $this->otto_has_tag('og:title')) {
453 830 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
454 831 }
455 832
456 833 return $meta;
@@ -472,23 +849,34 @@
472 849 }
473 850
474 851 $post_id = $this->get_current_object_id();
475 852
476 - // WP-196: Post synced to AIOSEO — let AIOSEO read from its own storage.
477 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
853 + // Per-post OG toggle off — MetaSync emits no Twitter tags, so leave AIOSEO's intact.
854 + if ($this->og_output_disabled($post_id)) {
478 855 return $meta;
479 856 }
480 857
481 - if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
858 + // See filter_aioseo_facebook_tags() for why the sync stamp no longer
859 + // decides who renders these.
860 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_TITLE, $post_id);
861 + if ($ours !== '') {
862 + $meta['twitter:title'] = $ours;
863 + } elseif ($this->otto_has_tag('twitter:title')) {
482 864 unset($meta['twitter:title']);
483 865 }
484 866
485 - if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
867 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION, $post_id);
868 + if ($ours !== '') {
869 + $meta['twitter:description'] = $ours;
870 + } elseif ($this->otto_has_tag('twitter:description')) {
486 871 unset($meta['twitter:description']);
487 872 }
488 873
489 - // twitter:card — suppress when OTTO has any twitter tag
490 - if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
874 + // twitter:card — suppress when OTTO has any twitter tag. See the
875 + // structural block in filter_aioseo_facebook_tags() for why this one
876 + // still defers to the sync stamp.
877 + if (!($post_id && $this->is_primary_output_plugin($post_id, 'aioseo'))
878 + && ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description'))) {
491 879 unset($meta['twitter:card']);
492 880 }
493 881
494 882 return $meta;
@@ -494,8 +882,54 @@
494 882 return $meta;
495 883 }
496 884
497 885 /**
886 + * Shared suppression decision for a third-party plugin's robots meta tag.
887 + *
888 + * Both the AIOSEO and Rank Math robots filters consult this so their
889 + * behaviour stays identical by construction: MetaSync holds an
890 + * intentional robots value for the current post, the post is not
891 + * synced to that plugin as its primary output owner, and the current
892 + * view is one where MetaSync actually emits its own robots tag.
893 + *
894 + * The singular guard matters on taxonomy archives: there
895 + * get_current_object_id() returns a TERM id, and reading post meta
896 + * with it would consult an unrelated post — suppressing the
897 + * third-party tag on a page where MetaSync's replacement emitter
898 + * never runs (hook_metasync_metatags() returns early off-singular),
899 + * leaving the archive with no robots meta at all.
900 + *
901 + * @param string $plugin_slug Plugin slug ('aioseo' or 'rankmath').
902 + * @return bool True when the third-party robots tag should be removed.
903 + */
904 + private function should_suppress_third_party_robots($plugin_slug) {
905 + if (!isset($this->robots_suppress_cache[$plugin_slug])) {
906 + $this->robots_suppress_cache[$plugin_slug] = $this->compute_should_suppress_third_party_robots($plugin_slug);
907 + }
908 + return $this->robots_suppress_cache[$plugin_slug];
909 + }
910 +
911 + /**
912 + * Uncached suppression decision; see should_suppress_third_party_robots().
913 + *
914 + * @param string $plugin_slug Plugin slug ('aioseo' or 'rankmath').
915 + * @return bool True when the third-party robots tag should be removed.
916 + */
917 + private function compute_should_suppress_third_party_robots($plugin_slug) {
918 + $post_id = $this->get_current_object_id();
919 + if (!$post_id || !is_singular()) {
920 + return false;
921 + }
922 +
923 + // Post synced to this plugin — let it read from its own storage.
924 + if ($this->is_primary_output_plugin($post_id, $plugin_slug)) {
925 + return false;
926 + }
927 +
928 + return $this->metasync_has_robots($post_id);
929 + }
930 +
931 + /**
498 932 * Filter AIOSEO robots meta output.
499 933 *
500 934 * When MetaSync has an intentional robots value (admin checkbox or REST API),
501 935 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
@@ -501,25 +935,18 @@
501 935 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
502 936 * hook_metasync_metatags() will output the MetaSync value instead.
503 937 *
504 938 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
505 - * Returning an empty array suppresses AIOSEO's robots tag entirely.
939 + * Returning an empty array suppresses AIOSEO's robots tag entirely. Unlike
940 + * the Rank Math path this keeps no noindex floor on non-public sites —
941 + * AIOSEO's array shape for that case is not yet verified, so the floor is
942 + * deliberately Rank Math-only until it can be confirmed against AIOSEO.
506 943 *
507 944 * @param array $robots AIOSEO's computed robots attributes array.
508 945 * @return array
509 946 */
510 947 public function filter_aioseo_robots($robots) {
511 - $post_id = $this->get_current_object_id();
512 - if (!$post_id) {
513 - return $robots;
514 - }
515 -
516 - // WP-196: Post synced to AIOSEO — let AIOSEO read from its own storage.
517 - if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
518 - return $robots;
519 - }
520 -
521 - if ($this->metasync_has_robots($post_id)) {
948 + if ($this->should_suppress_third_party_robots('aioseo')) {
522 949 // MetaSync has robots — suppress AIOSEO's tag.
523 950 return [];
524 951 }
525 952
@@ -557,8 +984,27 @@
557 984 * @param int $post_id Post ID.
558 985 * @return bool
559 986 */
560 987 public function metasync_has_robots($post_id) {
988 + // Both robots features switched off: MetaSync has no robots value to
989 + // offer, so it must not claim ownership of the tag. Reporting false here
990 + // also stops the third-party robots suppression filters, leaving the
991 + // other plugin's directives in place instead of blanking them.
992 + if (Metasync_Feature_Flags::robots_fully_disabled()) {
993 + return false;
994 + }
995 +
996 + // Both storage formats below hold common-half directives (noindex,
997 + // nofollow, noarchive, nosnippet, noimageindex). With that half switched
998 + // off the emitter drops every one of them, so claiming the tag here
999 + // would suppress the third party's robots tag and then emit nothing —
1000 + // leaving a page that asked to be noindex fully indexable. The advanced
1001 + // half being on does not help: it owns only the max-* directives, which
1002 + // neither of these keys carries.
1003 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) {
1004 + return false;
1005 + }
1006 +
561 1007 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
562 1008 if (!empty($meta_robots)) {
563 1009 return true;
564 1010 }
@@ -577,8 +1023,24 @@
577 1023 * @param int $post_id Post ID.
578 1024 * @return bool
579 1025 */
580 1026 private function metasync_has_title($post_id) {
1027 + // Term archives answer from term meta, and answer FIRST: $post_id is the
1028 + // term's id here, so the post-meta reads below would consult whatever
1029 + // post shares that number and report its title as this archive's.
1030 + $term = $this->get_current_term();
1031 + if ($term) {
1032 + return !empty($this->get_metasync_term_title($term));
1033 + }
1034 +
1035 + // Social Media & Open Graph meta box, when genuinely customized. Counted
1036 + // here so a post whose ONLY title is the OG one still suppresses the
1037 + // third-party tag — otherwise the plugin keeps rendering its own and the
1038 + // replacement below never gets the chance to emit.
1039 + if ($this->get_customized_og_title($post_id) !== '') {
1040 + return true;
1041 + }
1042 +
581 1043 $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
582 1044 if (!empty($seo_title)) {
583 1045 return true;
584 1046 }
@@ -587,18 +1049,8 @@
587 1049 if (!empty($otto_title)) {
588 1050 return true;
589 1051 }
590 1052
591 - // Term-level fallback: on taxonomy archives the "object" is a term,
592 - // so read `_metasync_metatitle` from term meta when we're rendering one.
593 - $term = $this->get_current_term();
594 - if ($term) {
595 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
596 - if (!empty($term_title)) {
597 - return true;
598 - }
599 - }
600 -
601 1053 return false;
602 1054 }
603 1055
604 1056 /**
@@ -701,8 +1153,15 @@
701 1153 if (!$post_id) {
702 1154 return false;
703 1155 }
704 1156
1157 + // OTTO stores nothing in term meta — on an archive it arrives through the
1158 + // output-buffer pass instead. Without this the post-meta read below would
1159 + // report an unrelated post's OTTO tag as this archive's.
1160 + if (!$this->current_object_is_post()) {
1161 + return false;
1162 + }
1163 +
705 1164 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
706 1165 return false;
707 1166 }
708 1167
@@ -721,8 +1180,36 @@
721 1180
722 1181 return !empty(get_post_meta($post_id, $meta_key_map[$tag], true));
723 1182 }
724 1183
1184 + /**
1185 + * Whether the per-post "Enable Open Graph & Social Media Tags" toggle is
1186 + * explicitly turned off for the given object.
1187 + *
1188 + * Mirrors the guard in Metasync_OpenGraph::will_emit()/output_opengraph_tags():
1189 + * only an explicit '0' opt-out disables MetaSync's OG/Twitter output; an
1190 + * unset/empty value counts as enabled. When disabled, MetaSync emits no
1191 + * OG/Twitter tags of its own, so it must NOT strip a third-party SEO
1192 + * plugin's OG/Twitter tags either — otherwise the page is left with none.
1193 + *
1194 + * @param int $post_id
1195 + * @return bool
1196 + */
1197 + private function og_output_disabled($post_id) {
1198 + // Feature switched off site-wide. Checked before the per-post opt-out so
1199 + // it applies even when no post is in scope: MetaSync emits no social tags,
1200 + // so it must leave the third-party plugin's alone.
1201 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
1202 + return true;
1203 + }
1204 +
1205 + if (class_exists('Metasync_OpenGraph')) {
1206 + return Metasync_OpenGraph::is_social_output_disabled($post_id);
1207 + }
1208 +
1209 + return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
1210 + }
1211 +
725 1212 // ------------------------------------------------------------------
726 1213 // Yoast SEO integration
727 1214 // ------------------------------------------------------------------
728 1215
@@ -750,11 +1237,27 @@
750 1237 add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999);
751 1238
752 1239 // Suppress Yoast schema/JSON-LD when OTTO has structured data
753 1240 add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999);
1241 +
1242 + // Canonical — override Yoast's canonical with the MetaSync/OTTO value when set
1243 + add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 999);
754 1244 }
755 1245
756 1246 /**
1247 + * Filter Yoast's canonical URL.
1248 + *
1249 + * When a MetaSync-managed canonical exists (OTTO or the Canonical meta box),
1250 + * return it so Yoast emits our value instead of its own — avoiding a duplicate
1251 + * <link rel="canonical"> while still honoring the per-post override.
1252 + * Otherwise let Yoast's canonical through unchanged.
1253 + */
1254 + public function filter_yoast_canonical($canonical) {
1255 + $custom = $this->get_metasync_canonical($this->get_current_object_id());
1256 + return $custom !== '' ? $custom : $canonical;
1257 + }
1258 +
1259 + /**
757 1260 * Filter Yoast SEO title output.
758 1261 *
759 1262 * When the MetaSync sidebar has an explicit SEO title, return that title so
760 1263 * Yoast's Title_Presenter renders it inside the <title> tag it controls.
@@ -768,21 +1271,50 @@
768 1271 */
769 1272 public function filter_yoast_title($title) {
770 1273 $post_id = $this->get_current_object_id();
771 1274
772 - // WP-196: When Yoast is the primary output plugin, let it through.
1275 + // If we hold a title, we write it — the same rule the description path
1276 + // follows. The sync stamp records that a sync happened; it does not decide
1277 + // who renders. The mirrored copy lives in the plugin's own storage and can
1278 + // be edited there, and deferring to it hands our tag over silently.
1279 + //
1280 + // A value, never '': on classic themes the plugin owns the sole <title>
1281 + // renderer and OTTO's buffer replaces the tag afterwards, so blanking it
1282 + // would leave nothing for OTTO to replace.
1283 + $ours = $this->metasync_title_value($post_id);
1284 + if ($ours !== '') {
1285 + return $ours;
1286 + }
1287 +
1288 +
1289 + // Taxonomy archives resolve against term meta, not post meta. Resolve
1290 + // the term before plugin ownership checks so a multi-plugin archive is
1291 + // not suppressed by an unrelated primary-plugin decision.
1292 + $term = $this->get_current_term();
1293 + if ($term) {
1294 + $term_title = $this->get_metasync_term_title($term);
1295 + if (!empty($term_title)) {
1296 + return $term_title;
1297 + }
1298 + }
1299 +
1300 + // When Yoast is the primary output plugin, let it through.
773 1301 // For synced posts, Yoast reads from its own storage (already has the value).
774 1302 // For unsynced posts as primary, still check for MetaSync sidebar override.
775 1303 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
776 - // Even in passthrough, a sidebar title override takes precedence
777 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
778 - if (!empty($sidebar_title)) {
1304 + // Even in passthrough, a MetaSync title takes precedence. Resolved
1305 + // through metasync_title_value() rather than read straight off
1306 + // `_metasync_seo_title`: that skipped the rest of the chain, and on
1307 + // an archive it read post meta with a term id, answering with
1308 + // whatever post happened to share that number.
1309 + $sidebar_title = $this->metasync_title_value($post_id);
1310 + if ($sidebar_title !== '') {
779 1311 return $sidebar_title;
780 1312 }
781 1313 return $title;
782 1314 }
783 1315
784 - // WP-196: Another plugin is primary — suppress Yoast.
1316 + // Another plugin is primary — suppress Yoast.
785 1317 if ($post_id && $this->has_active_seo_plugin()) {
786 1318 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
787 1319 return '';
788 1320 }
@@ -787,21 +1319,12 @@
787 1319 return '';
788 1320 }
789 1321 }
790 1322
791 - // Term-level archives
792 - $term = $this->get_current_term();
793 - if ($term) {
794 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
795 - if (!empty($term_title)) {
796 - return $term_title;
797 - }
798 - }
799 -
800 1323 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
801 1324 if ($post_id) {
802 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
803 - if (!empty($sidebar_title)) {
1325 + $sidebar_title = $this->metasync_title_value($post_id);
1326 + if ($sidebar_title !== '') {
804 1327 return $sidebar_title;
805 1328 }
806 1329 }
807 1330
@@ -815,17 +1338,103 @@
815 1338
816 1339 /**
817 1340 * Filter Yoast SEO description output.
818 1341 *
819 - * On term archives: the term-level sync writes MetaSync's description
820 - * into Yoast's native storage, so Yoast already computes the correct
821 - * value — let it through.
1342 + * On term archives: return MetaSync's resolved term value directly. The
1343 + * native plugin field may be empty even when the precedence chain has a
1344 + * value, and MetaSync's own archive emitter does not provide a fallback.
822 1345 *
823 1346 * On singular pages: suppress when OTTO or MetaSync sidebar provides
824 1347 * the description (MetaSync outputs its own tag).
825 1348 */
1349 + /**
1350 + * Replace a mirrored OTTO description that is dead data for this post.
1351 + *
1352 + * MetaSync syncs its resolved description into the active plugin's own
1353 + * storage, so that plugin renders it. When the per-post "Disable OTTO"
1354 + * toggle is set, OTTO stands down everywhere else — but the copy already
1355 + * written to the third-party plugin keeps rendering, and OTTO reads as
1356 + * "disabled but still leaking SEO".
1357 + *
1358 + * Only acts when the incoming value is byte-identical to OTTO's stored
1359 + * description: that proves the sync wrote it, so a description the customer
1360 + * typed into Rank Math or Yoast themselves is never touched. Falls through
1361 + * the same precedence the rest of the plugin uses — customer-typed, then
1362 + * imported — and suppresses if neither exists.
1363 + *
1364 + * @param string $description Description the third-party plugin is about to emit.
1365 + * @param int $post_id Post ID.
1366 + * @return string|null Replacement, or null to leave the value alone.
1367 + */
1368 + private function replace_dead_otto_description($description, $post_id) {
1369 + if (!$post_id || !class_exists('Metasync_Otto_Frontend_Toolbar')) {
1370 + return null;
1371 + }
1372 +
1373 + // The per-post OTTO toggle and OTTO's stored description are post-scoped.
1374 + // On an archive $post_id is a term id, and the byte-comparison below would
1375 + // match an unrelated post's OTTO description and return that post's value
1376 + // as this archive's.
1377 + if (!$this->current_object_is_post()) {
1378 + return null;
1379 + }
1380 +
1381 + if (!Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
1382 + return null;
1383 + }
1384 +
1385 + $otto_desc = get_post_meta($post_id, '_metasync_otto_description', true);
1386 + if (empty($otto_desc) || (string) $description !== (string) $otto_desc) {
1387 + return null;
1388 + }
1389 +
1390 + // Falls through the same chain as everywhere else, with the OTTO tiers
1391 + // dropped because OTTO has stood down for this post. Suppresses when
1392 + // nothing is left.
1393 + if (!self::precedence_available()) {
1394 + return '';
1395 + }
1396 +
1397 + return Metasync_Seo_Precedence::value(
1398 + $post_id,
1399 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
1400 + Metasync_Seo_Precedence::TYPE_POST,
1401 + array('include_otto' => false)
1402 + );
1403 + }
1404 +
826 1405 public function filter_yoast_description($description) {
827 1406 $post_id = $this->get_current_object_id();
1407 +
1408 + // A synced OTTO description is dead data once OTTO is switched off
1409 + // for this post; serving it would leak SEO OTTO has stood down from.
1410 + $replacement = $this->replace_dead_otto_description($description, $post_id);
1411 + if ($replacement !== null) {
1412 + return $replacement;
1413 + }
1414 +
1415 + // If we hold a description, we write it. The sync stamp records that a
1416 + // sync happened; it does not decide who renders. The mirrored copy lives
1417 + // in the plugin's own storage and can be edited or overwritten there —
1418 + // deferring to it hands our tag over with nothing saying so.
1419 + $ours = $this->metasync_description_value($post_id);
1420 + if ($ours !== '') {
1421 + return $ours;
1422 + }
1423 +
1424 + // On taxonomy archives the queried-object id is a term id, not a post
1425 + // id. Yoast may have no native term description to return (the value
1426 + // can exist only in MetaSync's precedence tiers), so hand it the
1427 + // resolved term value directly. MetaSync's own wp_head emitter exits
1428 + // early for archives and cannot provide a fallback tag.
1429 + $term = $this->get_current_term();
1430 + if ($term) {
1431 + $term_desc = $this->get_metasync_term_description($term);
1432 + if (!empty($term_desc)) {
1433 + return $term_desc;
1434 + }
1435 + }
1436 +
828 1437 if ($post_id && $this->has_active_seo_plugin()) {
829 1438 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
830 1439 return $description;
831 1440 }
@@ -833,17 +1442,8 @@
833 1442 return '';
834 1443 }
835 1444 }
836 1445
837 - // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
838 - $term = $this->get_current_term();
839 - if ($term) {
840 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
841 - if (!empty($term_desc)) {
842 - return $description;
843 - }
844 - }
845 -
846 1446 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
847 1447 return '';
848 1448 }
849 1449 return $description;
@@ -854,14 +1454,27 @@
854 1454 * Suppress when: OTTO active + has og:title, OR MetaSync has title.
855 1455 */
856 1456 public function filter_yoast_og_title($value) {
857 1457 $post_id = $this->get_current_object_id();
858 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1458 + if ($this->og_output_disabled($post_id)) {
859 1459 return $value;
860 1460 }
861 - if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
1461 +
1462 + // If we hold a value, we write it. The sync stamp records that a sync
1463 + // happened; it does not decide who renders. The mirrored copy in the
1464 + // plugin's own storage can be edited or overwritten there, and deferring
1465 + // to it hands our tag over without anything saying so.
1466 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_TITLE, $post_id);
1467 + if ($ours !== '') {
1468 + return $ours;
1469 + }
1470 +
1471 + // OTTO injects some social tags through the output buffer without ever
1472 + // persisting them, so its own tag check still has to run.
1473 + if ($this->otto_has_tag('og:title')) {
862 1474 return '';
863 1475 }
1476 +
864 1477 return $value;
865 1478 }
866 1479
867 1480 /**
@@ -869,14 +1482,27 @@
869 1482 * Suppress when: OTTO active + has og:description, OR MetaSync has description.
870 1483 */
871 1484 public function filter_yoast_og_description($value) {
872 1485 $post_id = $this->get_current_object_id();
873 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1486 + if ($this->og_output_disabled($post_id)) {
874 1487 return $value;
875 1488 }
876 - if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
1489 +
1490 + // If we hold a value, we write it. The sync stamp records that a sync
1491 + // happened; it does not decide who renders. The mirrored copy in the
1492 + // plugin's own storage can be edited or overwritten there, and deferring
1493 + // to it hands our tag over without anything saying so.
1494 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION, $post_id);
1495 + if ($ours !== '') {
1496 + return $ours;
1497 + }
1498 +
1499 + // OTTO injects some social tags through the output buffer without ever
1500 + // persisting them, so its own tag check still has to run.
1501 + if ($this->otto_has_tag('og:description')) {
877 1502 return '';
878 1503 }
1504 +
879 1505 return $value;
880 1506 }
881 1507
882 1508 /**
@@ -884,8 +1510,11 @@
884 1510 * Suppress when: OTTO active + has og:title (OTTO provides these alongside og:title).
885 1511 */
886 1512 public function filter_yoast_og_structural($value) {
887 1513 $post_id = $this->get_current_object_id();
1514 + if ($this->og_output_disabled($post_id)) {
1515 + return $value;
1516 + }
888 1517 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
889 1518 return $value;
890 1519 }
891 1520 if ($this->otto_has_tag('og:title')) {
@@ -899,14 +1528,27 @@
899 1528 * Suppress when: OTTO active + has twitter:title, OR MetaSync has title.
900 1529 */
901 1530 public function filter_yoast_twitter_title($value) {
902 1531 $post_id = $this->get_current_object_id();
903 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1532 + if ($this->og_output_disabled($post_id)) {
904 1533 return $value;
905 1534 }
906 - if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
1535 +
1536 + // If we hold a value, we write it. The sync stamp records that a sync
1537 + // happened; it does not decide who renders. The mirrored copy in the
1538 + // plugin's own storage can be edited or overwritten there, and deferring
1539 + // to it hands our tag over without anything saying so.
1540 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_TITLE, $post_id);
1541 + if ($ours !== '') {
1542 + return $ours;
1543 + }
1544 +
1545 + // OTTO injects some social tags through the output buffer without ever
1546 + // persisting them, so its own tag check still has to run.
1547 + if ($this->otto_has_tag('twitter:title')) {
907 1548 return '';
908 1549 }
1550 +
909 1551 return $value;
910 1552 }
911 1553
912 1554 /**
@@ -914,14 +1556,27 @@
914 1556 * Suppress when: OTTO active + has twitter:description, OR MetaSync has description.
915 1557 */
916 1558 public function filter_yoast_twitter_description($value) {
917 1559 $post_id = $this->get_current_object_id();
918 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1560 + if ($this->og_output_disabled($post_id)) {
919 1561 return $value;
920 1562 }
921 - if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
1563 +
1564 + // If we hold a value, we write it. The sync stamp records that a sync
1565 + // happened; it does not decide who renders. The mirrored copy in the
1566 + // plugin's own storage can be edited or overwritten there, and deferring
1567 + // to it hands our tag over without anything saying so.
1568 + $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION, $post_id);
1569 + if ($ours !== '') {
1570 + return $ours;
1571 + }
1572 +
1573 + // OTTO injects some social tags through the output buffer without ever
1574 + // persisting them, so its own tag check still has to run.
1575 + if ($this->otto_has_tag('twitter:description')) {
922 1576 return '';
923 1577 }
1578 +
924 1579 return $value;
925 1580 }
926 1581
927 1582 /**
@@ -929,8 +1584,11 @@
929 1584 * Suppress when: OTTO active + has any twitter tag.
930 1585 */
931 1586 public function filter_yoast_twitter_structural($value) {
932 1587 $post_id = $this->get_current_object_id();
1588 + if ($this->og_output_disabled($post_id)) {
1589 + return $value;
1590 + }
933 1591 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
934 1592 return $value;
935 1593 }
936 1594 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
@@ -944,14 +1602,22 @@
944 1602 * Suppress when OTTO has structured data for the current page.
945 1603 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
946 1604 * so MetaSync's own BreadcrumbList is the only one on the page.
947 1605 *
948 - * @param array|false $data Yoast's JSON-LD data.
949 - * @return array|false
1606 + * Yoast's wpseo_schema_graph filter contract expects an array of graph
1607 + * pieces. Yoast's Schema_Presenter::present() only validates the outer
1608 + * schema structure with is_array(), not the graph value itself, so a
1609 + * boolean false flows straight through wp_json_encode() and is printed as
1610 + * the invalid "@graph":false. Return an empty array here instead, which
1611 + * Yoast renders as a valid empty "@graph":[] — mirroring the AIOSEO and
1612 + * Rank Math filters in this same handler.
1613 + *
1614 + * @param array $data Yoast's JSON-LD data.
1615 + * @return array
950 1616 */
951 1617 public function filter_yoast_schema($data) {
952 1618 if ($this->otto_has_schema_for_current_page()) {
953 - return false;
1619 + return [];
954 1620 }
955 1621
956 1622 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
957 1623 $data = $this->strip_breadcrumb_from_graph($data);
@@ -973,11 +1639,188 @@
973 1639 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
974 1640
975 1641 // Suppress RankMath schema/JSON-LD when OTTO has structured data
976 1642 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1643 +
1644 + // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1645 + add_filter('rank_math/frontend/canonical', [$this, 'filter_rankmath_canonical'], 999);
1646 +
1647 + // Social — Rank Math renders these from its own storage, so without these
1648 + // filters MetaSync has no way to reach og:/twitter: on a Rank Math page.
1649 + add_filter('rank_math/opengraph/facebook/og_title', [$this, 'filter_rankmath_og_title'], 999);
1650 + add_filter('rank_math/opengraph/facebook/og_description', [$this, 'filter_rankmath_og_description'], 999);
1651 + add_filter('rank_math/opengraph/twitter/twitter_title', [$this, 'filter_rankmath_twitter_title'], 999);
1652 + add_filter('rank_math/opengraph/twitter/twitter_description', [$this, 'filter_rankmath_twitter_description'], 999);
1653 +
1654 + // Robots — suppress Rank Math's tag when MetaSync holds an intentional
1655 + // robots value for the post, mirroring the AIOSEO path. The advanced
1656 + // directives (max-snippet, max-video-preview, max-image-preview) run
1657 + // through a separate filter and must be suppressed alongside the main
1658 + // tag, or they survive as an orphaned meta tag.
1659 + add_filter('rank_math/frontend/robots', [$this, 'filter_rankmath_robots'], 999);
1660 + add_filter('rank_math/frontend/advanced_robots', [$this, 'filter_rankmath_advanced_robots'], 999);
977 1661 }
978 1662
979 1663 /**
1664 + * Shared body for the Rank Math social filters.
1665 + *
1666 + * Same rule as the Yoast and AIOSEO paths: when MetaSync holds a value for
1667 + * this specific tag we write it, and when it holds nothing we leave Rank
1668 + * Math's own tag alone. Each tag consults its OWN chain — twitter:description
1669 + * resolves to _metasync_twitter_description, not the og one — because sharing
1670 + * a chain would decide one tag on the strength of another's value and leave
1671 + * either a duplicate or a blank.
1672 + *
1673 + * @param string $value Rank Math's computed value.
1674 + * @param string $field A Metasync_Seo_Precedence social field constant.
1675 + * @param string $otto_tag Matching OTTO tag name.
1676 + * @return string
1677 + */
1678 + private function filter_rankmath_social($value, $field, $otto_tag) {
1679 + $post_id = $this->get_current_object_id();
1680 +
1681 + if ($this->og_output_disabled($post_id)) {
1682 + return $value;
1683 + }
1684 +
1685 + $ours = $this->metasync_social_value($field, $post_id);
1686 + if ($ours !== '') {
1687 + return $ours;
1688 + }
1689 +
1690 + // OTTO injects some social tags through the output buffer without ever
1691 + // persisting them, so its own tag check still has to run.
1692 + if ($this->otto_has_tag($otto_tag)) {
1693 + return '';
1694 + }
1695 +
1696 + return $value;
1697 + }
1698 +
1699 + /**
1700 + * Filter Rank Math's og:title output.
1701 + *
1702 + * @param string $value Rank Math's computed og:title.
1703 + * @return string
1704 + */
1705 + public function filter_rankmath_og_title($value) {
1706 + return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_OG_TITLE, 'og:title');
1707 + }
1708 +
1709 + /**
1710 + * Filter Rank Math's og:description output.
1711 + *
1712 + * @param string $value Rank Math's computed og:description.
1713 + * @return string
1714 + */
1715 + public function filter_rankmath_og_description($value) {
1716 + return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION, 'og:description');
1717 + }
1718 +
1719 + /**
1720 + * Filter Rank Math's twitter:title output.
1721 + *
1722 + * @param string $value Rank Math's computed twitter:title.
1723 + * @return string
1724 + */
1725 + public function filter_rankmath_twitter_title($value) {
1726 + return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_TWITTER_TITLE, 'twitter:title');
1727 + }
1728 +
1729 + /**
1730 + * Filter Rank Math's twitter:description output.
1731 + *
1732 + * @param string $value Rank Math's computed twitter:description.
1733 + * @return string
1734 + */
1735 + public function filter_rankmath_twitter_description($value) {
1736 + return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION, 'twitter:description');
1737 + }
1738 +
1739 + /**
1740 + * Filter Rank Math robots meta output.
1741 + *
1742 + * When MetaSync holds an intentional robots value (admin checkbox or REST
1743 + * API), suppress Rank Math's robots tag to avoid duplicates. MetaSync's
1744 + * own output in hook_metasync_metatags() will output the MetaSync value
1745 + * instead. The suppression decision is shared with the AIOSEO path so the
1746 + * two plugins behave identically.
1747 + *
1748 + * Registered against rank_math/frontend/robots (the main directives);
1749 + * the max-* directives are handled separately in
1750 + * filter_rankmath_advanced_robots(). Returning an empty array suppresses
1751 + * the tag entirely, except when Rank Math forces a noindex (see
1752 + * site_forces_noindex()), in which case a bare noindex/nofollow floor
1753 + * survives so the page stays deindexed even though MetaSync replaces
1754 + * every other directive.
1755 + *
1756 + * @param array $robots Rank Math's computed robots attributes array.
1757 + * @return array
1758 + */
1759 + public function filter_rankmath_robots($robots) {
1760 + if ($this->should_suppress_third_party_robots('rankmath')) {
1761 + // MetaSync has robots — suppress Rank Math's tag. Keep only the
1762 + // noindex Rank Math forces on non-public sites and replytocom
1763 + // URLs alive: MetaSync's replacement value carries no such
1764 + // directive, so a full suppression would leave those pages
1765 + // indexable even though the site asked not to be indexed.
1766 + if ($this->site_forces_noindex()) {
1767 + return ['index' => 'noindex', 'follow' => 'nofollow'];
1768 + }
1769 + return [];
1770 + }
1771 +
1772 + return $robots;
1773 + }
1774 +
1775 + /**
1776 + * Filter Rank Math's advanced (max-*) robots directives.
1777 + *
1778 + * Shares the suppression decision with the main robots filter; Rank Math
1779 + * merges the advanced payload into the same <meta name="robots"> tag, so
1780 + * when MetaSync replaces the tag the max-* directives must vanish with
1781 + * it. No noindex floor here — the main filter already carries it, and a
1782 + * second copy would just collide in Rank Math's array merge.
1783 + *
1784 + * @param array $robots Rank Math's advanced robots attributes array.
1785 + * @return array
1786 + */
1787 + public function filter_rankmath_advanced_robots($robots) {
1788 + if ($this->should_suppress_third_party_robots('rankmath')) {
1789 + return [];
1790 + }
1791 +
1792 + return $robots;
1793 + }
1794 +
1795 + /**
1796 + * Whether the current request carries a noindex that Rank Math forces
1797 + * independently of any per-post setting — a non-public site
1798 + * (blog_public = 0, "Discourage search engines") or a ?replytocom=
1799 + * comment-reply URL. Mirrors Rank Math's respect_settings_for_robots().
1800 + *
1801 + * @return bool
1802 + */
1803 + private function site_forces_noindex() {
1804 + if (0 === absint(get_option('blog_public')) || isset($_GET['replytocom'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end flag, only forces noindex
1805 + return true;
1806 + }
1807 +
1808 + return false;
1809 + }
1810 +
1811 + /**
1812 + * Filter RankMath's canonical URL.
1813 + *
1814 + * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
1815 + * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
1816 + */
1817 + public function filter_rankmath_canonical($canonical) {
1818 + $custom = $this->get_metasync_canonical($this->get_current_object_id());
1819 + return $custom !== '' ? $custom : $canonical;
1820 + }
1821 +
1822 + /**
980 1823 * Filter RankMath title output.
981 1824 *
982 1825 * On taxonomy archive pages: when MetaSync has an explicit `_metasync_metatitle`
983 1826 * term meta value, return it so Rank Math renders the MetaSync-managed archive
@@ -992,9 +1835,34 @@
992 1835 * @return string
993 1836 */
994 1837 public function filter_rankmath_title($title) {
995 1838 $post_id = $this->get_current_object_id();
996 - // WP-196: If another plugin is the primary output owner, suppress Rank Math.
1839 +
1840 + // If we hold a title, we write it — the same rule the description path
1841 + // follows. The sync stamp records that a sync happened; it does not decide
1842 + // who renders. The mirrored copy lives in the plugin's own storage and can
1843 + // be edited there, and deferring to it hands our tag over silently.
1844 + //
1845 + // A value, never '': on classic themes the plugin owns the sole <title>
1846 + // renderer and OTTO's buffer replaces the tag afterwards, so blanking it
1847 + // would leave nothing for OTTO to replace.
1848 + $ours = $this->metasync_title_value($post_id);
1849 + if ($ours !== '') {
1850 + return $ours;
1851 + }
1852 +
1853 + // Taxonomy archives resolve against term meta, not post meta. Resolve
1854 + // the term before plugin ownership checks so a multi-plugin archive is
1855 + // not suppressed by an unrelated primary-plugin decision.
1856 + $term = $this->get_current_term();
1857 + if ($term) {
1858 + $term_title = $this->get_metasync_term_title($term);
1859 + if (!empty($term_title)) {
1860 + return $term_title;
1861 + }
1862 + }
1863 +
1864 + // If another plugin is the primary output owner, suppress Rank Math.
997 1865 if ($post_id && $this->has_active_seo_plugin()) {
998 1866 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
999 1867 return $title;
1000 1868 }
@@ -1003,25 +1871,14 @@
1003 1871 return '';
1004 1872 }
1005 1873 }
1006 1874
1007 - // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1008 - // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1009 - // MetaSync-managed archive title inside <title>.
1010 - $term = $this->get_current_term();
1011 - if ($term) {
1012 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1013 - if (!empty($term_title)) {
1014 - return $term_title;
1015 - }
1016 - }
1017 -
1018 1875 $post_id = $this->get_current_object_id();
1019 1876
1020 1877 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1021 1878 if ($post_id) {
1022 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1023 - if (!empty($sidebar_title)) {
1879 + $sidebar_title = $this->metasync_title_value($post_id);
1880 + if ($sidebar_title !== '') {
1024 1881 return $sidebar_title;
1025 1882 }
1026 1883 }
1027 1884
@@ -1034,11 +1891,11 @@
1034 1891
1035 1892 /**
1036 1893 * Filter RankMath description output.
1037 1894 *
1038 - * On term archives: the term-level sync writes MetaSync's description
1039 - * into Rank Math's native term meta, so Rank Math already computes the
1040 - * correct value — let it through.
1895 + * On term archives: return MetaSync's resolved term value directly. Rank
1896 + * Math may compute an empty native value even when the precedence chain has
1897 + * a value, and MetaSync's own archive emitter does not provide a fallback.
1041 1898 *
1042 1899 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1043 1900 * the description.
1044 1901 *
@@ -1046,8 +1903,38 @@
1046 1903 * @return string
1047 1904 */
1048 1905 public function filter_rankmath_description($description) {
1049 1906 $post_id = $this->get_current_object_id();
1907 +
1908 + // A synced OTTO description is dead data once OTTO is switched off
1909 + // for this post; serving it would leak SEO OTTO has stood down from.
1910 + $replacement = $this->replace_dead_otto_description($description, $post_id);
1911 + if ($replacement !== null) {
1912 + return $replacement;
1913 + }
1914 +
1915 + // If we hold a description, we write it. The sync stamp records that a
1916 + // sync happened; it does not decide who renders. The mirrored copy lives
1917 + // in the plugin's own storage and can be edited or overwritten there —
1918 + // deferring to it hands our tag over with nothing saying so.
1919 + $ours = $this->metasync_description_value($post_id);
1920 + if ($ours !== '') {
1921 + return $ours;
1922 + }
1923 +
1924 + // On taxonomy archives the queried-object id is a term id, not a post
1925 + // id. Rank Math may have no native term description to return (the
1926 + // value can exist only in MetaSync's precedence tiers), so hand it the
1927 + // resolved term value directly. MetaSync's own wp_head emitter exits
1928 + // early for archives and cannot provide a fallback tag.
1929 + $term = $this->get_current_term();
1930 + if ($term) {
1931 + $term_desc = $this->get_metasync_term_description($term);
1932 + if (!empty($term_desc)) {
1933 + return $term_desc;
1934 + }
1935 + }
1936 +
1050 1937 if ($post_id && $this->has_active_seo_plugin()) {
1051 1938 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1052 1939 return $description;
1053 1940 }
@@ -1055,17 +1942,8 @@
1055 1942 return '';
1056 1943 }
1057 1944 }
1058 1945
1059 - // Term archives: MetaSync syncs to Rank Math storage — let it render.
1060 - $term = $this->get_current_term();
1061 - if ($term) {
1062 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1063 - if (!empty($term_desc)) {
1064 - return $description;
1065 - }
1066 - }
1067 -
1068 1946 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1069 1947 return '';
1070 1948 }
1071 1949
@@ -1109,9 +1987,9 @@
1109 1987 *
1110 1988 * @return bool True if the legacy output should include a description tag.
1111 1989 */
1112 1990 public function should_output_legacy_description() {
1113 - // WP-196: Post synced to any active plugin — that plugin now owns the
1991 + // Post synced to any active plugin — that plugin now owns the
1114 1992 // description output from its native storage. Suppress MetaSync's own tag.
1115 1993 $post_id = $this->get_current_object_id();
1116 1994 if ($post_id && $this->has_active_seo_plugin()) {
1117 1995 // If synced to any plugin, a primary output plugin exists — suppress MetaSync's own tag.
@@ -1189,9 +2067,9 @@
1189 2067 /**
1190 2068 * Strip BreadcrumbList nodes from a JSON-LD @graph array and remove
1191 2069 * dangling breadcrumb references from WebPage-type nodes.
1192 2070 *
1193 - * WP-369: Previously we only removed the BreadcrumbList entry but left
2071 + * Previously we only removed the BreadcrumbList entry but left
1194 2072 * the WebPage's `breadcrumb: { @id: "...#breadcrumb" }` property intact.
1195 2073 * Google follows that dangling @id, finds no matching node, and reports
1196 2074 * "Missing field itemListElement".
1197 2075 *
@@ -1198,10 +2076,32 @@
1198 2076 * @param array $graph The @graph array from a third-party SEO plugin.
1199 2077 * @return array
1200 2078 */
1201 2079 private function strip_breadcrumb_from_graph($graph) {
1202 - // Collect @ids of BreadcrumbList nodes being removed.
2080 + // Rank Math's graph is keyed by entity name (e.g. 'WebPage', 'ProfilePage')
2081 + // and later filters/consumers look entries up by those keys (see
2082 + // RankMath\Schema\Frontend::remove_person_entity(), hooked on
2083 + // rank_math/schema/validated_data, which reads $data['ProfilePage']).
2084 + // AIOSEO/Yoast pass a plain numeric list. Re-index with array_values()
2085 + // unless the graph carries string keys — a string-keyed graph is Rank
2086 + // Math's shape and must be preserved untouched. This is deliberately
2087 + // broader than array_is_list(): a numeric-keyed AIOSEO/Yoast graph
2088 + // that a third-party filter left with a gap (e.g. an earlier unset())
2089 + // still needs forcing back into a gap-free list, or json_encode()
2090 + // would serialize it as a JSON object instead of an array.
2091 + $has_string_keys = false;
2092 + foreach (array_keys($graph) as $key) {
2093 + if (is_string($key)) {
2094 + $has_string_keys = true;
2095 + break;
2096 + }
2097 + }
2098 +
2099 + // Collect @ids of BreadcrumbList nodes being removed. Track removal
2100 + // separately from the id list: a BreadcrumbList may carry no @id, in
2101 + // which case it is removed but contributes nothing to $removed_ids.
1203 2102 $removed_ids = [];
2103 + $removed_any = false;
1204 2104
1205 2105 foreach ($graph as $key => $entry) {
1206 2106 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1207 2107 if (!empty($entry['@id'])) {
@@ -1206,34 +2106,43 @@
1206 2106 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1207 2107 if (!empty($entry['@id'])) {
1208 2108 $removed_ids[] = $entry['@id'];
1209 2109 }
2110 + $removed_any = true;
1210 2111 unset($graph[$key]);
1211 2112 }
1212 2113 }
1213 2114
1214 - // Remove dangling breadcrumb references from WebPage-type nodes.
1215 - foreach ($graph as $key => &$entry) {
1216 - if (!is_array($entry) || !isset($entry['@type'])) {
1217 - continue;
1218 - }
2115 + // Remove dangling breadcrumb references from any node that carries one.
2116 + // Gating this on @type === 'WebPage' missed every WebPage subtype the
2117 + // SEO plugins actually emit — Rank Math uses 'CollectionPage' for
2118 + // archives and 'ItemPage' for products — so the BreadcrumbList was
2119 + // removed while the reference to it survived. The subtype set is
2120 + // open-ended, so match on the reference itself rather than the type.
2121 + //
2122 + // Only run when this method actually removed a BreadcrumbList. If it
2123 + // removed nothing, no reference in this graph was invalidated here, and
2124 + // stripping one would break a relationship that is still intact — the
2125 + // list may simply live in another JSON-LD block on the page.
2126 + if ($removed_any) {
2127 + foreach ($graph as $key => &$entry) {
2128 + if (!is_array($entry)) {
2129 + continue;
2130 + }
1219 2131
1220 - $type = $entry['@type'];
1221 - $is_page_type = $type === 'WebPage'
1222 - || (is_array($type) && in_array('WebPage', $type, true));
1223 -
1224 - if ($is_page_type && isset($entry['breadcrumb'])) {
1225 - // Remove if the reference points to a stripped node, or if
1226 - // no BreadcrumbList remains in this graph at all.
1227 - $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1228 - if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1229 - unset($entry['breadcrumb']);
2132 + if (isset($entry['breadcrumb'])) {
2133 + // Remove if the reference points to a stripped node, or if
2134 + // the removed list carried no @id to match against.
2135 + $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
2136 + if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
2137 + unset($entry['breadcrumb']);
2138 + }
1230 2139 }
1231 2140 }
2141 + unset($entry);
1232 2142 }
1233 - unset($entry);
1234 2143
1235 - return array_values($graph);
2144 + return $has_string_keys ? $graph : array_values($graph);
1236 2145 }
1237 2146
1238 2147 /**
1239 2148 * Determine whether MetaSync's own BreadcrumbList output is enabled.
@@ -1254,9 +2163,9 @@
1254 2163 if (array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
1255 2164 return false;
1256 2165 }
1257 2166
1258 - // WP-369: When schema output is explicitly disabled, don't strip
2167 + // When schema output is explicitly disabled, don't strip
1259 2168 // third-party breadcrumbs — MetaSync won't emit its own.
1260 2169 if (!empty($settings['disable_schema'])) {
1261 2170 return false;
1262 2171 }
@@ -1269,8 +2178,14 @@
1269 2178 *
1270 2179 * @return bool
1271 2180 */
1272 2181 private function otto_has_schema_for_current_page() {
2182 + // Schema feature switched off: MetaSync contributes no JSON-LD, so the
2183 + // third-party plugin's graph must be left intact rather than emptied.
2184 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
2185 + return false;
2186 + }
2187 +
1273 2188 if (!$this->is_otto_active()) {
1274 2189 return false;
1275 2190 }
1276 2191
@@ -1298,8 +2213,26 @@
1298 2213 * page, so this returns 0.
1299 2214 *
1300 2215 * @return int 0 when unknown.
1301 2216 */
2217 + /**
2218 + * Whether get_current_object_id() is currently answering with a post id.
2219 + *
2220 + * On a taxonomy archive the queried object is a term, so that method returns
2221 + * a TERM id. Reading post meta with it answers with whatever post happens to
2222 + * share that number — a collision that is ordinary rather than exotic on any
2223 + * site with a few dozen terms. Every post-meta read keyed on
2224 + * get_current_object_id() has to gate on this.
2225 + *
2226 + * A term is the only case: on the static posts page and the front page the
2227 + * queried object is a real page, and its id is a genuine post id.
2228 + *
2229 + * @return bool
2230 + */
2231 + private function current_object_is_post() {
2232 + return !$this->get_current_term();
2233 + }
2234 +
1302 2235 private function get_current_object_id() {
1303 2236 // Singular pages (posts, pages, CPTs, attachments)
1304 2237 if (is_singular()) {
1305 2238 return (int) get_the_ID();
@@ -1317,8 +2250,346 @@
1317 2250 return (int) $id;
1318 2251 }
1319 2252
1320 2253 return 0;
2254 + }
2255 +
2256 + /**
2257 + * MetaSync's title for a taxonomy archive, highest tier first.
2258 + *
2259 + * The order comes from Metasync_Seo_Precedence: on a term
2260 + * `_metasync_metatitle` means "someone set this deliberately" — the MCP
2261 + * taxonomy tool is its only writer — and an import sits one tier below on
2262 + * `_metasync_imported_seo_title`, so a bulk import can no longer take the
2263 + * slot a deliberate value occupies.
2264 + *
2265 + * Terms carry no OTTO meta at all, which is why that chain is shorter than
2266 + * the post one and why the same key can mean something different on a post
2267 + * without ambiguity. OTTO reaches an archive through the output-buffer pass,
2268 + * which runs after these filters and still overrides what we return.
2269 + *
2270 + * @param \WP_Term $term Term being rendered.
2271 + * @return string Title, or '' when MetaSync has none.
2272 + */
2273 + private function get_metasync_term_title($term) {
2274 + if (empty($term->term_id) || !self::precedence_available()) {
2275 + return '';
2276 + }
2277 +
2278 + return Metasync_Seo_Precedence::value(
2279 + $term->term_id,
2280 + Metasync_Seo_Precedence::FIELD_TITLE,
2281 + Metasync_Seo_Precedence::TYPE_TERM
2282 + );
2283 + }
2284 +
2285 + /**
2286 + * MetaSync's description for a taxonomy archive. See get_metasync_term_title().
2287 + *
2288 + * @param \WP_Term $term Term being rendered.
2289 + * @return string Description, or '' when MetaSync has none.
2290 + */
2291 + private function get_metasync_term_description($term) {
2292 + if (empty($term->term_id) || !self::precedence_available()) {
2293 + return '';
2294 + }
2295 +
2296 + return Metasync_Seo_Precedence::value(
2297 + $term->term_id,
2298 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2299 + Metasync_Seo_Precedence::TYPE_TERM
2300 + );
2301 + }
2302 +
2303 + /**
2304 + * The social value MetaSync holds for a tag, if any.
2305 + *
2306 + * Social fields carry their own values rather than deriving from the page
2307 + * title and description, so they are asked about on their own chain: a post
2308 + * can have an OG title and no SEO title, and the page-title chain would
2309 + * answer "we hold nothing" for a value we do hold. That mismatch is what let
2310 + * a third-party plugin's own OG tag outrank ours.
2311 + *
2312 + * @param string $field A Metasync_Seo_Precedence social field constant.
2313 + * @param int $post_id Post being rendered.
2314 + * @return string '' when MetaSync holds nothing for this tag.
2315 + */
2316 + private function metasync_social_value($field, $post_id) {
2317 + if (!$post_id || !self::precedence_available()) {
2318 + return '';
2319 + }
2320 +
2321 + // Social values are per-post. On an archive the queried-object id is a
2322 + // term id, and reading post meta with it would answer with whatever post
2323 + // happens to share that number — another page's og:title.
2324 + if (!is_singular()) {
2325 + return '';
2326 + }
2327 +
2328 + // The OG meta box pre-fills every field from the post and PERSISTS those
2329 + // defaults on save, so a non-empty value does not prove the customer chose
2330 + // it. Only a value that differs from the default counts as theirs; an
2331 + // auto-filled one must not outrank OTTO. Same comparison
2332 + // Otto_html_class::apply_metabox_og_precedence() makes — without it the two
2333 + // disagree, and a page can lose the tag entirely because each side expects
2334 + // the other to supply it.
2335 + $customized = $this->customized_metabox_value($post_id, $field);
2336 + if ($customized !== '') {
2337 + return $customized;
2338 + }
2339 +
2340 + // Auto-filled or unset: skip MetaSync's own tier and take the rest of the
2341 + // chain — OTTO, then whatever an import brought in.
2342 + $value = Metasync_Seo_Precedence::value(
2343 + $post_id,
2344 + $field,
2345 + Metasync_Seo_Precedence::TYPE_POST,
2346 + array('include_metasync' => false)
2347 + );
2348 + if ($value !== '') {
2349 + return $value;
2350 + }
2351 +
2352 + // A twitter field with no value of its own has always *rendered* its og
2353 + // twin — Metasync_OpenGraph's emitter cascades twitter:title from og:title
2354 + // and twitter:description from og:description — so the same cascade has to
2355 + // happen here before the generic page-title/description fallback. Without
2356 + // it a legacy twitter row that merely echoes the og value (collapsed by
2357 + // get_social_meta, so it reaches this point) would resolve to the page
2358 + // TITLE chain instead and the sidebar would emit a different twitter:title
2359 + // than the plugin's own tags — the exact class of divergence the
2360 + // comparison above exists to prevent.
2361 + //
2362 + // The cascade consults only the customer's own typed og value, never the
2363 + // og twin's full chain. OTTO's og row is a third-party tier, and by the
2364 + // matching-key rule it governs the og tag only — cascading it here would
2365 + // reach across and replace a third-party plugin's own twitter tag with a
2366 + // value OTTO never staged for that field. Typed og only, one direction;
2367 + // the og fields never consult the twitter ones.
2368 + if ($field === Metasync_Seo_Precedence::FIELD_TWITTER_TITLE
2369 + || $field === Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION
2370 + ) {
2371 + $og_field = ($field === Metasync_Seo_Precedence::FIELD_TWITTER_TITLE)
2372 + ? Metasync_Seo_Precedence::FIELD_OG_TITLE
2373 + : Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION;
2374 + $og = $this->customized_metabox_value($post_id, $og_field);
2375 + if ($og !== '') {
2376 + return $og;
2377 + }
2378 + }
2379 +
2380 + // A social description with no OG-specific value of its own falls back to
2381 + // the page description, which is what MetaSync's own emitter has always
2382 + // done. Without this the plugin renders its own og:description while the
2383 + // sidebar emits ours, and the page carries both.
2384 + if ($field === Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION
2385 + || $field === Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION) {
2386 + return $this->metasync_description_value($post_id);
2387 + }
2388 +
2389 + // Social titles fall back to the page title for the same reason, and it
2390 + // is the fallback Metasync_OpenGraph already uses. It also keeps the
2391 + // WP-609 replacement path from firing: with the plugin rendering our
2392 + // title there is no suppressed tag left to replace.
2393 + if ($field === Metasync_Seo_Precedence::FIELD_OG_TITLE
2394 + || $field === Metasync_Seo_Precedence::FIELD_TWITTER_TITLE) {
2395 + return Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
2396 + }
2397 +
2398 + return '';
2399 + }
2400 +
2401 + /**
2402 + * A social meta box value, but only when the customer genuinely set it.
2403 + *
2404 + * The box pre-fills Image from the featured image and persists whatever is
2405 + * in that field on save — so for it, "non-empty" is not intent: the image
2406 + * is the customer's only when it differs from the default it was
2407 + * pre-filled with. Title and Description no longer need the comparison:
2408 + * the box shows their defaults as placeholders only and stores nothing
2409 + * when the submitted text just echoes them, while rows polluted before
2410 + * that (the "Auto Draft" placeholder, a snapshot of the title that a
2411 + * rename left stale, a snapshot of the resolved description that an
2412 + * excerpt or content edit left stale) are collapsed on read.
2413 + *
2414 + * Compared per field rather than letting the Twitter fields inherit the OG
2415 + * verdict, so a customized og:title cannot silently promote an auto-filled
2416 + * twitter:title. An EMPTY twitter field does inherit a typed og twin later
2417 + * in metasync_social_value()'s cascade — but only after its own row has
2418 + * collapsed to empty, which is the emitter's exact behaviour.
2419 + *
2420 + * @param int $post_id
2421 + * @param string $field A Metasync_Seo_Precedence social field constant.
2422 + * @return string '' when unset, auto-filled, or not determinable.
2423 + */
2424 + private function customized_metabox_value($post_id, $field) {
2425 + $map = array(
2426 + Metasync_Seo_Precedence::FIELD_OG_TITLE => array('_metasync_og_title', 'title'),
2427 + Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION => array('_metasync_og_description', 'description'),
2428 + Metasync_Seo_Precedence::FIELD_OG_IMAGE => array('_metasync_og_image', 'image'),
2429 + Metasync_Seo_Precedence::FIELD_TWITTER_TITLE => array('_metasync_twitter_title', 'title'),
2430 + Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION => array('_metasync_twitter_description', 'description'),
2431 + Metasync_Seo_Precedence::FIELD_TWITTER_IMAGE => array('_metasync_twitter_image', 'image'),
2432 + );
2433 +
2434 + if (!isset($map[$field])) {
2435 + return '';
2436 + }
2437 +
2438 + list($meta_key, $default_key) = $map[$field];
2439 +
2440 + $stored = (string) get_post_meta($post_id, $meta_key, true);
2441 + if ($stored === '') {
2442 + return '';
2443 + }
2444 +
2445 + // A stored "Auto Draft" placeholder was never something the customer typed —
2446 + // it is the meta box's own pre-fill from a still-untitled post, persisted
2447 + // verbatim. Comparing only against the post's *current* title (below) misses
2448 + // this once the post is later given a real title: the placeholder no longer
2449 + // matches the default, so it reads as a deliberate customization and gets
2450 + // handed to a third-party SEO plugin as-is (WP-640). Collapse it here too,
2451 + // the same way the render-time resolver already does for MetaSync's own tag.
2452 + if (class_exists('Metasync_OpenGraph')
2453 + && in_array($meta_key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
2454 + && Metasync_OpenGraph::is_auto_draft_title($stored)
2455 + ) {
2456 + return '';
2457 + }
2458 +
2459 + // The title default is the post title, and the box no longer persists it.
2460 + // Rows that still hold such a snapshot (written by the old pre-fill) are
2461 + // collapsed here, so a title that survives is genuinely the customer's and
2462 + // needs no default comparison. Description is the same story with the
2463 + // resolved excerpt: the box now shows it as a placeholder only, and rows
2464 + // that still hold a snapshot of it are collapsed the same way — including
2465 + // the twitter twin, whose pre-fill echoed the og description when one was
2466 + // set. The image default still comes from the emitter's own resolver
2467 + // because the box still pre-fills and persists that field.
2468 + if ($default_key === 'title') {
2469 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2470 + if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot')) {
2471 + return Metasync_OpenGraph::strip_title_snapshot($post_id, $meta_key, $stored);
2472 + }
2473 +
2474 + $post = get_post($post_id);
2475 + $default = ($post instanceof WP_Post) ? (string) $post->post_title : '';
2476 +
2477 + return $stored === $default ? '' : $stored;
2478 + }
2479 +
2480 + if ($default_key === 'description') {
2481 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2482 + if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot')) {
2483 + return Metasync_OpenGraph::strip_description_snapshot($post_id, $meta_key, $stored);
2484 + }
2485 +
2486 + // Fall through to the emitter-resolver comparison below, which the
2487 + // og key satisfies and which stays a safe under-approximation for
2488 + // the twitter key: a stale snapshot it fails to catch here is
2489 + // still collapsed everywhere the box's own reads go.
2490 + }
2491 +
2492 + if (!class_exists('Metasync_OpenGraph') || !Metasync_OpenGraph::get_instance()) {
2493 + // Cannot prove it was auto-filled, so do not demote a value the
2494 + // customer may well have typed. Erring the other way would silently
2495 + // drop real values whenever the emitter is unavailable.
2496 + return $stored;
2497 + }
2498 +
2499 + $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
2500 + $default = (string) $defaults[$default_key];
2501 +
2502 + return $stored === $default ? '' : $stored;
2503 + }
2504 +
2505 + /**
2506 + * The page title MetaSync holds for a post, if any.
2507 + *
2508 + * The full chain — what the customer typed, then OTTO, then a value brought
2509 + * in from another SEO plugin — so a third-party plugin can never render its
2510 + * own title over one of ours. OTTO's tiers drop out on their own when the
2511 + * per-post toggle is off.
2512 + *
2513 + * Cached for the request: three plugins' title filters can each ask, and on
2514 + * a page where nothing is set that is otherwise four meta reads apiece.
2515 + *
2516 + * @param int $post_id Post being rendered.
2517 + * @return string '' when MetaSync holds nothing.
2518 + */
2519 + private function metasync_title_value($post_id) {
2520 + if (!$post_id || !self::precedence_available()) {
2521 + return '';
2522 + }
2523 +
2524 + if ($this->get_current_term()) {
2525 + return '';
2526 + }
2527 +
2528 + if (array_key_exists($post_id, $this->title_value_cache)) {
2529 + return $this->title_value_cache[$post_id];
2530 + }
2531 +
2532 + $this->title_value_cache[$post_id] = Metasync_Seo_Precedence::value(
2533 + $post_id,
2534 + Metasync_Seo_Precedence::FIELD_TITLE
2535 + );
2536 +
2537 + return $this->title_value_cache[$post_id];
2538 + }
2539 +
2540 + /**
2541 + * The meta description MetaSync holds for a post, if any.
2542 + *
2543 + * The full chain — what the customer typed, then OTTO, then a value brought
2544 + * in from another SEO plugin — so a third-party plugin can never render its
2545 + * own description over one of ours. OTTO's tiers drop out on their own when
2546 + * the per-post toggle is off, so a stood-down suggestion is not served.
2547 + *
2548 + * @param int $post_id Post being rendered.
2549 + * @return string '' when MetaSync holds nothing.
2550 + */
2551 + private function metasync_description_value($post_id) {
2552 + if (!$post_id || !self::precedence_available()) {
2553 + return '';
2554 + }
2555 +
2556 + // On a taxonomy archive the queried-object id is a term id; reading post
2557 + // meta with it would answer with whatever post shares that number. The
2558 + // term branch in each filter handles archives.
2559 + if ($this->get_current_term()) {
2560 + return '';
2561 + }
2562 +
2563 + return Metasync_Seo_Precedence::value(
2564 + $post_id,
2565 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2566 + );
2567 + }
2568 +
2569 + /**
2570 + * Make sure the precedence resolver is loaded before calling it.
2571 + *
2572 + * These filters run on wp_head, so an unresolvable class here takes the
2573 + * front end down rather than degrading. A partial update can leave newer
2574 + * PHP files beside an older committed autoload classmap that has never
2575 + * heard of this class, so fall back to requiring the file directly and
2576 + * report honestly if it is genuinely absent. Same reasoning as the post-meta
2577 + * sync bridges in class-metasync.php.
2578 + *
2579 + * @return bool
2580 + */
2581 + private static function precedence_available() {
2582 + if (class_exists('Metasync_Seo_Precedence')) {
2583 + return true;
2584 + }
2585 +
2586 + $file = plugin_dir_path(__FILE__) . 'class-metasync-seo-precedence.php';
2587 + if (is_readable($file)) {
2588 + require_once $file;
2589 + }
2590 +
2591 + return class_exists('Metasync_Seo_Precedence');
1321 2592 }
1322 2593
1323 2594 /**
1324 2595 * Return the WP_Term being rendered on taxonomy archive pages.