*/ public static function detect(): array { $found = array(); // ── Yoast SEO / Yoast SEO Premium ──────────────────────────────── if ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Frontend' ) ) { $conflicts = self::detect_yoast_conflicts(); if ( ! empty( $conflicts ) ) { $found['yoast'] = array( 'name' => 'Yoast SEO', 'conflicts' => $conflicts, 'doc_url' => 'https://yoast.com/features/schema/', ); } } // ── Rank Math ───────────────────────────────────────────────────── if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) { $conflicts = self::detect_rankmath_conflicts(); if ( ! empty( $conflicts ) ) { $found['rankmath'] = array( 'name' => 'Rank Math', 'conflicts' => $conflicts, 'doc_url' => 'https://rankmath.com/kb/schema-markup/', ); } } // ── All in One SEO ──────────────────────────────────────────────── if ( defined( 'AIOSEO_VERSION' ) || class_exists( 'AIOSEO\Plugin\AIOSEO' ) ) { $found['aioseo'] = array( 'name' => 'All in One SEO', 'conflicts' => array( 'article', 'breadcrumb', 'organization', 'author', 'faq' ), 'doc_url' => 'https://aioseo.com/docs/schema-markup/', ); } // ── SEOPress ────────────────────────────────────────────────────── if ( defined( 'SEOPRESS_VERSION' ) || function_exists( 'seopress_activation' ) ) { $found['seopress'] = array( 'name' => 'SEOPress', 'conflicts' => array( 'article', 'breadcrumb', 'organization' ), 'doc_url' => 'https://www.seopress.org/support/guides/schema-markup/', ); } // ── The SEO Framework ───────────────────────────────────────────── if ( function_exists( 'the_seo_framework' ) ) { $found['tsf'] = array( 'name' => 'The SEO Framework', 'conflicts' => array( 'article', 'breadcrumb', 'organization', 'author' ), 'doc_url' => 'https://theseoframework.com/?p=2582', ); } /** * Filter the detected SEO plugin conflicts. * * Allows sites with unusual setups to drop or adjust entries when * the detector's heuristics don't match their reality (e.g., a * custom filter that suppresses Yoast's schema output). * * Return an empty array (or unset a plugin key) to suppress its * conflict entirely. * * @param array $found */ $found = (array) apply_filters( 'aeomatic_seo_conflicts_detected', $found ); // Drop plugin entries whose 'conflicts' list was emptied by the filter. foreach ( $found as $slug => $entry ) { if ( empty( $entry['conflicts'] ) || ! is_array( $entry['conflicts'] ) ) { unset( $found[ $slug ] ); } } return $found; } /** * Build the Yoast conflict list based on its actual settings. * * Yoast has no global schema off switch, but per-CPT you can set the * page-type / article-type to "None", and breadcrumbs have their own * toggle. We treat each schema key independently: * • breadcrumb — only if breadcrumbs-enable is truthy * • article — only if any schema-article-type-* is set to a real type * • organization — only if company_or_person resolves to company/person * AND at least one schema-page-type-* is a real type * • author — same trigger as article * • faq — only if Yoast FAQ block class exists * * @return string[] */ protected static function detect_yoast_conflicts(): array { // Yoast's master schema toggle (introduced in the Schema Framework // feature). When explicitly false, Yoast emits no JSON-LD regardless // of the per-CPT schema-*-type-* settings below. $main = (array) get_option( 'wpseo', array() ); if ( array_key_exists( 'enable_schema', $main ) && false === (bool) $main['enable_schema'] ) { return array(); } $titles = (array) get_option( 'wpseo_titles', array() ); $conflicts = array(); $has_real_type = static function ( array $opts, string $prefix ): bool { foreach ( $opts as $key => $val ) { if ( 0 !== strpos( (string) $key, $prefix ) ) { continue; } $val = is_string( $val ) ? trim( $val ) : ''; if ( '' !== $val && 'None' !== $val && 'none' !== $val ) { return true; } } return false; }; $article_on = $has_real_type( $titles, 'schema-article-type-' ); $page_on = $has_real_type( $titles, 'schema-page-type-' ); if ( $article_on ) { $conflicts[] = 'article'; $conflicts[] = 'author'; } if ( $page_on ) { $org_type = isset( $titles['company_or_person'] ) ? (string) $titles['company_or_person'] : ''; if ( 'company' === $org_type || 'person' === $org_type ) { $conflicts[] = 'organization'; } } if ( ! empty( $titles['breadcrumbs-enable'] ) ) { $conflicts[] = 'breadcrumb'; } if ( class_exists( 'WPSEO_Schema_FAQ_Section' ) ) { $conflicts[] = 'faq'; } return array_values( array_unique( $conflicts ) ); } /** * Build the Rank Math conflict list based on which modules are active. * * Rank Math ships a "Schema (Structured Data)" module with ID * `rich-snippet` that users can toggle from Dashboard → Modules. When * it's off Rank Math emits no schema, so we drop the entire conflict * list for that plugin. * * @return string[] */ protected static function detect_rankmath_conflicts(): array { $modules = (array) get_option( 'rank_math_modules', array() ); $schema_on = in_array( 'rich-snippet', $modules, true ); if ( ! $schema_on ) { return array(); } return array( 'article', 'breadcrumb', 'organization', 'author', 'faq', 'howto' ); } /** * Return a flat list of schema setting keys that are conflicted by * any currently-active SEO plugin. * * @return string[] e.g. ['article', 'breadcrumb', 'organization'] */ public static function conflicted_keys(): array { $keys = array(); foreach ( self::detect() as $plugin ) { $keys = array_merge( $keys, $plugin['conflicts'] ); } return array_values( array_unique( $keys ) ); } /** * Whether AEOmatic — AI based Answer Engine Optimization currently has any conflicting schema types enabled. */ public static function has_active_conflicts(): bool { $settings = AEOMATIC_Settings::get( 'schema' ); $conflicts = self::conflicted_keys(); foreach ( $conflicts as $key ) { if ( ! empty( $settings[ $key ] ) ) { return true; } } return false; } /** * Show the conflict notice in the admin — only on AEOmatic — AI based Answer Engine Optimization pages, * and only if not already dismissed. */ public function maybe_show_notice(): void { // Only on our admin pages. Reading $_GET['page'] for routing display only — no form processing, no nonce needed. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $aeomatic_current_page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( false === strpos( $aeomatic_current_page, AEOMATIC_Admin::MENU_SLUG ) ) { return; } if ( get_option( self::NOTICE_DISMISSED_KEY ) ) { return; } $plugins = self::detect(); if ( empty( $plugins ) || ! self::has_active_conflicts() ) { return; } $names = implode( ' and ', array_column( $plugins, 'name' ) ); $fix_url = wp_nonce_url( admin_url( 'admin-post.php?action=aeomatic_fix_conflicts' ), 'aeomatic_fix_conflicts' ); $dismiss_url = wp_nonce_url( admin_url( 'admin-post.php?action=aeomatic_fix_conflicts&dismiss=1' ), 'aeomatic_fix_conflicts' ); $conflicted = self::conflicted_keys(); $labels = array( 'article' => 'Article', 'breadcrumb' => 'Breadcrumb', 'organization' => 'Organization', 'author' => 'Author', 'faq' => 'FAQ', 'howto' => 'HowTo', ); $conflict_labels = array_filter( array_map( static fn( $k ) => $labels[ $k ] ?? null, $conflicted ) ); ?>

⚠️

' . esc_html( $names ) . '', '' . esc_html( implode( ', ', $conflict_labels ) ) . '' ); ?>

✓ ' . esc_html__( 'No conflicting SEO plugins detected.', 'aeomatic' ) . ''; } $names = array_column( $plugins, 'name' ); if ( self::has_active_conflicts() ) { $fix_url = wp_nonce_url( admin_url( 'admin-post.php?action=aeomatic_fix_conflicts' ), 'aeomatic_fix_conflicts' ); // translators: %s: comma-separated list of detected SEO plugin names. $aeomatic_conflict_msg = esc_html__( '%s detected — some schema types overlap.', 'aeomatic' ); return sprintf( '⚠ ' . $aeomatic_conflict_msg . ' %s', esc_html( implode( ', ', $names ) ), esc_url( $fix_url ), esc_html__( 'Fix conflicts', 'aeomatic' ) ); } // translators: %s: comma-separated list of detected SEO plugin names. $aeomatic_resolved_msg = esc_html__( '%s detected — conflicts already resolved.', 'aeomatic' ); return sprintf( '✓ ' . $aeomatic_resolved_msg . '', esc_html( implode( ', ', $names ) ) ); } /** * Collect the raw settings the detector reads, so admins can see * exactly why a conflict is (or isn't) being reported. * * Only reads state — makes no changes. Intended for the diagnostic * panel rendered in Settings. * * @return array */ public static function diagnostic(): array { $out = array( 'plugins_active' => array( 'yoast' => ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Frontend' ) ), 'rankmath' => ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ), 'aioseo' => ( defined( 'AIOSEO_VERSION' ) || class_exists( 'AIOSEO\\Plugin\\AIOSEO' ) ), 'seopress' => ( defined( 'SEOPRESS_VERSION' ) || function_exists( 'seopress_activation' ) ), 'tsf' => function_exists( 'the_seo_framework' ), ), 'yoast' => null, 'rankmath' => null, 'detected' => self::detect(), ); if ( $out['plugins_active']['yoast'] ) { $titles = (array) get_option( 'wpseo_titles', array() ); $main = (array) get_option( 'wpseo', array() ); $social = (array) get_option( 'wpseo_social', array() ); $article_type_keys = array(); $page_type_keys = array(); foreach ( $titles as $k => $v ) { if ( 0 === strpos( (string) $k, 'schema-article-type-' ) ) { $article_type_keys[ (string) $k ] = is_scalar( $v ) ? (string) $v : gettype( $v ); } if ( 0 === strpos( (string) $k, 'schema-page-type-' ) ) { $page_type_keys[ (string) $k ] = is_scalar( $v ) ? (string) $v : gettype( $v ); } } // Scan every Yoast option array for keys that look like schema // toggles, so we can find the "Enable Schema Framework" setting // no matter which option it's stored in. $stringify = static function ( $val ) { if ( is_bool( $val ) ) { return $val ? 'true' : 'false'; } if ( is_scalar( $val ) ) { return (string) $val; } return gettype( $val ); }; $schema_like_keys = array(); foreach ( array( 'wpseo' => $main, 'wpseo_titles' => $titles, 'wpseo_social' => $social ) as $opt_name => $opt_arr ) { foreach ( $opt_arr as $k => $v ) { $k_lower = strtolower( (string) $k ); if ( false !== strpos( $k_lower, 'schema' ) || false !== strpos( $k_lower, 'framework' ) || false !== strpos( $k_lower, 'structured' ) ) { $schema_like_keys[ $opt_name . '.' . (string) $k ] = $stringify( $v ); } } } $out['yoast'] = array( 'wpseo_titles_option_exists' => ! empty( $titles ), 'enable_schema_master' => array_key_exists( 'enable_schema', $main ) ? $stringify( $main['enable_schema'] ) : '(missing → treated as on)', 'breadcrumbs_enable' => isset( $titles['breadcrumbs-enable'] ) ? $stringify( $titles['breadcrumbs-enable'] ) : '(missing)', 'company_or_person' => isset( $titles['company_or_person'] ) ? (string) $titles['company_or_person'] : '(missing)', 'schema_article_type_keys' => $article_type_keys, 'schema_page_type_keys' => $page_type_keys, 'faq_block_class_loaded' => class_exists( 'WPSEO_Schema_FAQ_Section' ), 'schema_like_keys_scan' => $schema_like_keys, 'wpseo_option_all_keys' => array_keys( $main ), ); } if ( $out['plugins_active']['rankmath'] ) { $modules = (array) get_option( 'rank_math_modules', array() ); $out['rankmath'] = array( 'rank_math_modules_option' => $modules, 'schema_module_active' => in_array( 'rich-snippet', $modules, true ), ); } return $out; } /** * Render the diagnostic as an HTML block for the Settings page. * Admin-only; safe to call anywhere. */ public static function get_diagnostic_html(): string { if ( ! current_user_can( 'manage_options' ) ) { return ''; } $diag = self::diagnostic(); $pretty = wp_json_encode( $diag, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); if ( false === $pretty ) { $pretty = 'wp_json_encode failed'; } ob_start(); ?>