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 +942 -228 2.6.24 → 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 */
@@ -188,9 +195,9 @@
188 195 // Defensive: $wpdb is always present on a booted frontend, but never
189 196 // assume — a method call on a null/!object $wpdb would be fatal.
190 197 if (isset($wpdb) && is_object($wpdb)) {
191 198 $table = $wpdb->prefix . 'aioseo_posts';
192 - $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id));
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
193 200 if (!empty($desc)) {
194 201 return true;
195 202 }
196 203 }
@@ -229,9 +236,9 @@
229 236 // Term-level: on taxonomy archives MetaSync may have term meta
230 237 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
231 238 $term = $this->get_current_term();
232 239 if ($term) {
233 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
240 + $term_desc = $this->get_metasync_term_description($term);
234 241 if (!empty($term_desc)) {
235 242 $this->has_description_cache = true;
236 243 return true;
237 244 }
@@ -257,21 +264,14 @@
257 264 if (!$post_id) {
258 265 return '';
259 266 }
260 267
261 - // 1. SEO sidebar (highest priority — user-edited)
262 - $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
263 - if (!empty($desc)) {
264 - return $desc;
265 - }
266 -
267 - // 2. OTTO description
268 - $desc = get_post_meta($post_id, '_metasync_otto_description', true);
269 - if (!empty($desc)) {
270 - return $desc;
271 - }
272 -
273 - return '';
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 274 }
275 275
276 276 /**
277 277 * Return MetaSync's intentional SEO title for the current request.
@@ -289,8 +289,15 @@
289 289 if (!$post_id) {
290 290 return '';
291 291 }
292 292
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 '';
298 + }
299 +
293 300 // 1. Social Media & Open Graph meta box (most specific — a social-only title)
294 301 $og_title = $this->get_customized_og_title($post_id);
295 302 if ($og_title !== '') {
296 303 return $og_title;
@@ -295,32 +302,35 @@
295 302 if ($og_title !== '') {
296 303 return $og_title;
297 304 }
298 305
299 - // 2. SEO sidebar (user-edited)
300 - $title = get_post_meta($post_id, '_metasync_seo_title', true);
301 - if (!empty($title)) {
302 - return $title;
303 - }
304 -
305 - // 3. OTTO title
306 - $title = get_post_meta($post_id, '_metasync_otto_title', true);
307 - if (!empty($title)) {
308 - return $title;
309 - }
310 -
311 - return '';
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);
312 320 }
313 321
314 322 /**
315 323 * The per-post OG meta box title, but only when the user genuinely set it.
316 324 *
317 - * The meta box pre-fills its Title from the post title and PERSISTS that
318 - * default on save, so a non-empty `_metasync_og_title` alone does not prove
319 - * intent — treating it as one would let an auto-filled post title override a
320 - * deliberately-set SEO title on every ordinary edit. A value counts as the
321 - * user's only when it differs from that default, the same comparison
322 - * Otto_html_class::apply_metabox_og_precedence() makes.
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.
323 333 *
324 334 * @param int $post_id
325 335 * @return string The customized OG title, or '' when unset or auto-filled.
326 336 */
@@ -332,8 +342,13 @@
332 342 if (!is_singular()) {
333 343 return '';
334 344 }
335 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 +
336 351 $og_title = (string) get_post_meta($post_id, '_metasync_og_title', true);
337 352 if ($og_title === '') {
338 353 return '';
339 354 }
@@ -369,8 +384,18 @@
369 384 if (!$this->suppresses_third_party_og_title()) {
370 385 return false;
371 386 }
372 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 +
373 398 $post_id = $this->get_current_object_id();
374 399 if (!$post_id) {
375 400 return false;
376 401 }
@@ -447,13 +472,19 @@
447 472
448 473 /**
449 474 * Resolve the MetaSync-managed canonical URL for a post, if any.
450 475 *
451 - * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta
452 - * box value (meta_canonical). Restricted to singular views so term/archive
453 - * queried-object ids are never misread as post ids. Mirrors the fallback in
454 - * Metasync_Seo_Output::get_canonical_url() so both the no-SEO-plugin path and
455 - * the third-party-plugin path honor the same value.
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.
456 487 *
457 488 * @param int $post_id Current object id.
458 489 * @return string Escaped canonical URL, or '' when none is set.
459 490 */
@@ -461,17 +492,37 @@
461 492 if (!$post_id || !is_singular()) {
462 493 return '';
463 494 }
464 495
465 - // Validate both sources: legacy rows corrupted to the literal "Array"
466 - // (or stored as arrays) must never be emitted as a canonical.
467 - $canonical = Metasync_Canonical_Sanitizer::sanitize(
468 - get_post_meta($post_id, '_metasync_canonical_url', true)
469 - );
470 - if ($canonical === '') {
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()) {
471 510 $canonical = Metasync_Canonical_Sanitizer::sanitize(
472 - get_post_meta($post_id, 'meta_canonical', true)
511 + Metasync_Seo_Precedence::value(
512 + $post_id,
513 + Metasync_Seo_Precedence::FIELD_CANONICAL
514 + )
473 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 + }
474 525 }
475 526
476 527 return $canonical !== '' ? esc_url($canonical) : '';
477 528 }
@@ -537,9 +588,15 @@
537 588 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
538 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'),
539 590 ];
540 591
541 - $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'));
542 599
543 600 if ($has_any_sync) {
544 601 // Synced: primary = first active + synced plugin
545 602 foreach ($priority as $slug) {
@@ -627,10 +684,20 @@
627 684 if ($this->aioseo_has_description_cache === null) {
628 685 $this->aioseo_has_description_cache = !empty($description);
629 686 }
630 687
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 +
631 699 // Primary plugin check — only the designated plugin outputs.
632 - $post_id = $this->get_current_object_id();
633 700 if ($post_id && $this->has_active_seo_plugin()) {
634 701 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
635 702 return $description;
636 703 }
@@ -643,9 +710,9 @@
643 710 // from its `wp_aioseo_terms` table, so return the MetaSync value
644 711 // directly so AIOSEO renders it.
645 712 $term = $this->get_current_term();
646 713 if ($term) {
647 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
714 + $term_desc = $this->get_metasync_term_description($term);
648 715 if (!empty($term_desc)) {
649 716 return $term_desc;
650 717 }
651 718 }
@@ -669,8 +736,33 @@
669 736 */
670 737 public function filter_aioseo_title($title) {
671 738 // Primary plugin check — only the designated plugin outputs.
672 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 +
673 765 if ($post_id && $this->has_active_seo_plugin()) {
674 766 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
675 767 return $title;
676 768 }
@@ -678,17 +770,8 @@
678 770 return '';
679 771 }
680 772 }
681 773
682 - // Term archives: return MetaSync term title directly.
683 - $term = $this->get_current_term();
684 - if ($term) {
685 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
686 - if (!empty($term_title)) {
687 - return $term_title;
688 - }
689 - }
690 -
691 774 if ($this->should_suppress_third_party_title()) {
692 775 return '';
693 776 }
694 777
@@ -716,26 +799,35 @@
716 799 if ($this->og_output_disabled($post_id)) {
717 800 return $meta;
718 801 }
719 802
720 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
721 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
722 - return $meta;
723 - }
724 -
725 - // og:title — suppress when OTTO has og:title OR MetaSync has title
726 - 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')) {
727 810 unset($meta['og:title']);
728 811 }
729 812
730 - // og:description — suppress when OTTO has og:description OR MetaSync has description
731 - 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')) {
732 818 unset($meta['og:description']);
733 819 }
734 820
735 821 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
736 - // (OTTO injects these structural OG tags alongside og:title in its block)
737 - 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')) {
738 830 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
739 831 }
740 832
741 833 return $meta;
@@ -762,23 +854,29 @@
762 854 if ($this->og_output_disabled($post_id)) {
763 855 return $meta;
764 856 }
765 857
766 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
767 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
768 - return $meta;
769 - }
770 -
771 - 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')) {
772 864 unset($meta['twitter:title']);
773 865 }
774 866
775 - 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')) {
776 871 unset($meta['twitter:description']);
777 872 }
778 873
779 - // twitter:card — suppress when OTTO has any twitter tag
780 - 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'))) {
781 879 unset($meta['twitter:card']);
782 880 }
783 881
784 882 return $meta;
@@ -886,8 +984,27 @@
886 984 * @param int $post_id Post ID.
887 985 * @return bool
888 986 */
889 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 +
890 1007 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
891 1008 if (!empty($meta_robots)) {
892 1009 return true;
893 1010 }
@@ -906,8 +1023,16 @@
906 1023 * @param int $post_id Post ID.
907 1024 * @return bool
908 1025 */
909 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 +
910 1035 // Social Media & Open Graph meta box, when genuinely customized. Counted
911 1036 // here so a post whose ONLY title is the OG one still suppresses the
912 1037 // third-party tag — otherwise the plugin keeps rendering its own and the
913 1038 // replacement below never gets the chance to emit.
@@ -924,18 +1049,8 @@
924 1049 if (!empty($otto_title)) {
925 1050 return true;
926 1051 }
927 1052
928 - // Term-level fallback: on taxonomy archives the "object" is a term,
929 - // so read `_metasync_metatitle` from term meta when we're rendering one.
930 - $term = $this->get_current_term();
931 - if ($term) {
932 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
933 - if (!empty($term_title)) {
934 - return true;
935 - }
936 - }
937 -
938 1053 return false;
939 1054 }
940 1055
941 1056 /**
@@ -1038,8 +1153,15 @@
1038 1153 if (!$post_id) {
1039 1154 return false;
1040 1155 }
1041 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 +
1042 1164 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
1043 1165 return false;
1044 1166 }
1045 1167
@@ -1072,8 +1194,19 @@
1072 1194 * @param int $post_id
1073 1195 * @return bool
1074 1196 */
1075 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 +
1076 1209 return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
1077 1210 }
1078 1211
1079 1212 // ------------------------------------------------------------------
@@ -1138,15 +1271,44 @@
1138 1271 */
1139 1272 public function filter_yoast_title($title) {
1140 1273 $post_id = $this->get_current_object_id();
1141 1274
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 +
1142 1300 // When Yoast is the primary output plugin, let it through.
1143 1301 // For synced posts, Yoast reads from its own storage (already has the value).
1144 1302 // For unsynced posts as primary, still check for MetaSync sidebar override.
1145 1303 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1146 - // Even in passthrough, a sidebar title override takes precedence
1147 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1148 - 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 !== '') {
1149 1311 return $sidebar_title;
1150 1312 }
1151 1313 return $title;
1152 1314 }
@@ -1157,21 +1319,12 @@
1157 1319 return '';
1158 1320 }
1159 1321 }
1160 1322
1161 - // Term-level archives
1162 - $term = $this->get_current_term();
1163 - if ($term) {
1164 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1165 - if (!empty($term_title)) {
1166 - return $term_title;
1167 - }
1168 - }
1169 -
1170 1323 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
1171 1324 if ($post_id) {
1172 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1173 - if (!empty($sidebar_title)) {
1325 + $sidebar_title = $this->metasync_title_value($post_id);
1326 + if ($sidebar_title !== '') {
1174 1327 return $sidebar_title;
1175 1328 }
1176 1329 }
1177 1330
@@ -1185,17 +1338,103 @@
1185 1338
1186 1339 /**
1187 1340 * Filter Yoast SEO description output.
1188 1341 *
1189 - * On term archives: the term-level sync writes MetaSync's description
1190 - * into Yoast's native storage, so Yoast already computes the correct
1191 - * 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.
1192 1345 *
1193 1346 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1194 1347 * the description (MetaSync outputs its own tag).
1195 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 +
1196 1405 public function filter_yoast_description($description) {
1197 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 +
1198 1437 if ($post_id && $this->has_active_seo_plugin()) {
1199 1438 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
1200 1439 return $description;
1201 1440 }
@@ -1203,17 +1442,8 @@
1203 1442 return '';
1204 1443 }
1205 1444 }
1206 1445
1207 - // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
1208 - $term = $this->get_current_term();
1209 - if ($term) {
1210 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1211 - if (!empty($term_desc)) {
1212 - return $description;
1213 - }
1214 - }
1215 -
1216 1446 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1217 1447 return '';
1218 1448 }
1219 1449 return $description;
@@ -1227,14 +1457,24 @@
1227 1457 $post_id = $this->get_current_object_id();
1228 1458 if ($this->og_output_disabled($post_id)) {
1229 1459 return $value;
1230 1460 }
1231 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1232 - return $value;
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;
1233 1469 }
1234 - if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
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')) {
1235 1474 return '';
1236 1475 }
1476 +
1237 1477 return $value;
1238 1478 }
1239 1479
1240 1480 /**
@@ -1245,14 +1485,24 @@
1245 1485 $post_id = $this->get_current_object_id();
1246 1486 if ($this->og_output_disabled($post_id)) {
1247 1487 return $value;
1248 1488 }
1249 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1250 - return $value;
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;
1251 1497 }
1252 - if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
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')) {
1253 1502 return '';
1254 1503 }
1504 +
1255 1505 return $value;
1256 1506 }
1257 1507
1258 1508 /**
@@ -1281,14 +1531,24 @@
1281 1531 $post_id = $this->get_current_object_id();
1282 1532 if ($this->og_output_disabled($post_id)) {
1283 1533 return $value;
1284 1534 }
1285 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1286 - return $value;
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;
1287 1543 }
1288 - if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
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')) {
1289 1548 return '';
1290 1549 }
1550 +
1291 1551 return $value;
1292 1552 }
1293 1553
1294 1554 /**
@@ -1299,14 +1559,24 @@
1299 1559 $post_id = $this->get_current_object_id();
1300 1560 if ($this->og_output_disabled($post_id)) {
1301 1561 return $value;
1302 1562 }
1303 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1304 - return $value;
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;
1305 1571 }
1306 - if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
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')) {
1307 1576 return '';
1308 1577 }
1578 +
1309 1579 return $value;
1310 1580 }
1311 1581
1312 1582 /**
@@ -1332,14 +1602,22 @@
1332 1602 * Suppress when OTTO has structured data for the current page.
1333 1603 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1334 1604 * so MetaSync's own BreadcrumbList is the only one on the page.
1335 1605 *
1336 - * @param array|false $data Yoast's JSON-LD data.
1337 - * @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
1338 1616 */
1339 1617 public function filter_yoast_schema($data) {
1340 1618 if ($this->otto_has_schema_for_current_page()) {
1341 - return false;
1619 + return [];
1342 1620 }
1343 1621
1344 1622 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1345 1623 $data = $this->strip_breadcrumb_from_graph($data);
@@ -1359,14 +1637,8 @@
1359 1637 private function register_rankmath_filters() {
1360 1638 add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
1361 1639 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
1362 1640
1363 - // OG/Twitter description — per-tag suppression, mirroring the Yoast filters.
1364 - // Without these Rank Math renders its own og:description alongside the one
1365 - // MetaSync emits, leaving two conflicting tags on the page.
1366 - add_filter('rank_math/opengraph/facebook/og_description', [$this, 'filter_rankmath_og_description'], 999);
1367 - add_filter('rank_math/opengraph/twitter/twitter_description', [$this, 'filter_rankmath_twitter_description'], 999);
1368 -
1369 1641 // Suppress RankMath schema/JSON-LD when OTTO has structured data
1370 1642 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1371 1643
1372 1644 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
@@ -1371,8 +1643,15 @@
1371 1643
1372 1644 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1373 1645 add_filter('rank_math/frontend/canonical', [$this, 'filter_rankmath_canonical'], 999);
1374 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 +
1375 1654 // Robots — suppress Rank Math's tag when MetaSync holds an intentional
1376 1655 // robots value for the post, mirroring the AIOSEO path. The advanced
1377 1656 // directives (max-snippet, max-video-preview, max-image-preview) run
1378 1657 // through a separate filter and must be suppressed alongside the main
@@ -1381,8 +1660,84 @@
1381 1660 add_filter('rank_math/frontend/advanced_robots', [$this, 'filter_rankmath_advanced_robots'], 999);
1382 1661 }
1383 1662
1384 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 + /**
1385 1740 * Filter Rank Math robots meta output.
1386 1741 *
1387 1742 * When MetaSync holds an intentional robots value (admin checkbox or REST
1388 1743 * API), suppress Rank Math's robots tag to avoid duplicates. MetaSync's
@@ -1445,9 +1800,9 @@
1445 1800 *
1446 1801 * @return bool
1447 1802 */
1448 1803 private function site_forces_noindex() {
1449 - if (0 === absint(get_option('blog_public')) || isset($_GET['replytocom'])) {
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
1450 1805 return true;
1451 1806 }
1452 1807
1453 1808 return false;
@@ -1480,8 +1835,33 @@
1480 1835 * @return string
1481 1836 */
1482 1837 public function filter_rankmath_title($title) {
1483 1838 $post_id = $this->get_current_object_id();
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 +
1484 1864 // If another plugin is the primary output owner, suppress Rank Math.
1485 1865 if ($post_id && $this->has_active_seo_plugin()) {
1486 1866 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1487 1867 return $title;
@@ -1491,25 +1871,14 @@
1491 1871 return '';
1492 1872 }
1493 1873 }
1494 1874
1495 - // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1496 - // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1497 - // MetaSync-managed archive title inside <title>.
1498 - $term = $this->get_current_term();
1499 - if ($term) {
1500 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1501 - if (!empty($term_title)) {
1502 - return $term_title;
1503 - }
1504 - }
1505 -
1506 1875 $post_id = $this->get_current_object_id();
1507 1876
1508 1877 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1509 1878 if ($post_id) {
1510 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1511 - if (!empty($sidebar_title)) {
1879 + $sidebar_title = $this->metasync_title_value($post_id);
1880 + if ($sidebar_title !== '') {
1512 1881 return $sidebar_title;
1513 1882 }
1514 1883 }
1515 1884
@@ -1522,11 +1891,11 @@
1522 1891
1523 1892 /**
1524 1893 * Filter RankMath description output.
1525 1894 *
1526 - * On term archives: the term-level sync writes MetaSync's description
1527 - * into Rank Math's native term meta, so Rank Math already computes the
1528 - * 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.
1529 1898 *
1530 1899 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1531 1900 * the description.
1532 1901 *
@@ -1534,8 +1903,38 @@
1534 1903 * @return string
1535 1904 */
1536 1905 public function filter_rankmath_description($description) {
1537 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 +
1538 1937 if ($post_id && $this->has_active_seo_plugin()) {
1539 1938 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1540 1939 return $description;
1541 1940 }
@@ -1543,17 +1942,8 @@
1543 1942 return '';
1544 1943 }
1545 1944 }
1546 1945
1547 - // Term archives: MetaSync syncs to Rank Math storage — let it render.
1548 - $term = $this->get_current_term();
1549 - if ($term) {
1550 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1551 - if (!empty($term_desc)) {
1552 - return $description;
1553 - }
1554 - }
1555 -
1556 1946 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1557 1947 return '';
1558 1948 }
1559 1949
@@ -1560,58 +1950,8 @@
1560 1950 return $description;
1561 1951 }
1562 1952
1563 1953 /**
1564 - * Filter Rank Math's og:description / twitter:description output.
1565 - *
1566 - * Mirrors filter_yoast_og_description(): suppress when OTTO has the tag or
1567 - * MetaSync has an intentional description, so exactly one description tag
1568 - * reaches the page. Returning '' removes Rank Math's.
1569 - *
1570 - * @param string $value Rank Math's computed description.
1571 - * @return string
1572 - */
1573 - public function filter_rankmath_og_description($value) {
1574 - return $this->filter_rankmath_social_description($value, 'og:description');
1575 - }
1576 -
1577 - /**
1578 - * Filter Rank Math's twitter:description output.
1579 - *
1580 - * Kept separate from the og:description filter so each consults its OWN
1581 - * OTTO key — twitter:description resolves to _metasync_otto_twitter_description,
1582 - * not the og one. Sharing a callback would suppress Rank Math's tag on the
1583 - * strength of the wrong key, either leaving a duplicate or a blank.
1584 - *
1585 - * @param string $value Rank Math's computed description.
1586 - * @return string
1587 - */
1588 - public function filter_rankmath_twitter_description($value) {
1589 - return $this->filter_rankmath_social_description($value, 'twitter:description');
1590 - }
1591 -
1592 - /**
1593 - * Shared body for the two Rank Math social-description filters.
1594 - *
1595 - * @param string $value Rank Math's computed description.
1596 - * @param string $otto_tag OTTO tag identifier to test for this specific tag.
1597 - * @return string
1598 - */
1599 - private function filter_rankmath_social_description($value, $otto_tag) {
1600 - $post_id = $this->get_current_object_id();
1601 - if ($post_id && $this->og_output_disabled($post_id)) {
1602 - return $value;
1603 - }
1604 - if ($post_id && $this->is_primary_output_plugin($post_id, 'rankmath')) {
1605 - return $value;
1606 - }
1607 - if ($this->otto_has_tag($otto_tag) || $this->metasync_has_description()) {
1608 - return '';
1609 - }
1610 - return $value;
1611 - }
1612 -
1613 - /**
1614 1954 * Filter RankMath schema/JSON-LD output.
1615 1955 * Suppress when OTTO has structured data for the current page.
1616 1956 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1617 1957 * so MetaSync's own BreadcrumbList is the only one on the page.
@@ -1755,10 +2095,13 @@
1755 2095 break;
1756 2096 }
1757 2097 }
1758 2098
1759 - // Collect @ids of BreadcrumbList nodes being removed.
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.
1760 2102 $removed_ids = [];
2103 + $removed_any = false;
1761 2104
1762 2105 foreach ($graph as $key => $entry) {
1763 2106 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1764 2107 if (!empty($entry['@id'])) {
@@ -1763,32 +2106,41 @@
1763 2106 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1764 2107 if (!empty($entry['@id'])) {
1765 2108 $removed_ids[] = $entry['@id'];
1766 2109 }
2110 + $removed_any = true;
1767 2111 unset($graph[$key]);
1768 2112 }
1769 2113 }
1770 2114
1771 - // Remove dangling breadcrumb references from WebPage-type nodes.
1772 - foreach ($graph as $key => &$entry) {
1773 - if (!is_array($entry) || !isset($entry['@type'])) {
1774 - continue;
1775 - }
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 + }
1776 2131
1777 - $type = $entry['@type'];
1778 - $is_page_type = $type === 'WebPage'
1779 - || (is_array($type) && in_array('WebPage', $type, true));
1780 -
1781 - if ($is_page_type && isset($entry['breadcrumb'])) {
1782 - // Remove if the reference points to a stripped node, or if
1783 - // no BreadcrumbList remains in this graph at all.
1784 - $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1785 - if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1786 - 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 + }
1787 2139 }
1788 2140 }
2141 + unset($entry);
1789 2142 }
1790 - unset($entry);
1791 2143
1792 2144 return $has_string_keys ? $graph : array_values($graph);
1793 2145 }
1794 2146
@@ -1826,8 +2178,14 @@
1826 2178 *
1827 2179 * @return bool
1828 2180 */
1829 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 +
1830 2188 if (!$this->is_otto_active()) {
1831 2189 return false;
1832 2190 }
1833 2191
@@ -1855,8 +2213,26 @@
1855 2213 * page, so this returns 0.
1856 2214 *
1857 2215 * @return int 0 when unknown.
1858 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 +
1859 2235 private function get_current_object_id() {
1860 2236 // Singular pages (posts, pages, CPTs, attachments)
1861 2237 if (is_singular()) {
1862 2238 return (int) get_the_ID();
@@ -1874,8 +2250,346 @@
1874 2250 return (int) $id;
1875 2251 }
1876 2252
1877 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');
1878 2592 }
1879 2593
1880 2594 /**
1881 2595 * Return the WP_Term being rendered on taxonomy archive pages.