*/ private array $loaded_modules = array(); /** * In-memory request cache for `wp_modulustools_meta` rows. * * @var array */ private static array $meta_cache = array(); /** * Retrieves singleton instance. * * @return Core */ public static function get_instance(): Core { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor. */ private function __construct() { $this->boot_enabled_modules(); $this->register_core_hooks(); } /** * Returns all available modules and their metadata. * * @return array> */ public static function get_available_modules(): array { return array( 'frontend_head' => array( 'title' => __( 'On-Page Meta, OpenGraph & Breadcrumbs', 'modulustools-seo-suite' ), 'description' => __( 'Title rewrites, meta descriptions, canonicals, robots directives, social cards, RSS attribution, and breadcrumbs.', 'modulustools-seo-suite' ), 'badge' => __( 'Core SEO', 'modulustools-seo-suite' ), ), 'schema_graph' => array( 'title' => __( 'Unified JSON-LD Schema, Merchant & E-E-A-T', 'modulustools-seo-suite' ), 'description' => __( 'Deduplicated @graph supporting Article, Product (GTIN, MPN, Brand, ReturnPolicy), FAQPage, LocalBusiness, and Author E-E-A-T.', 'modulustools-seo-suite' ), 'badge' => __( 'Structured Data', 'modulustools-seo-suite' ), ), 'sitemaps' => array( 'title' => __( 'XML / News / Video / HTML Sitemaps & IndexNow', 'modulustools-seo-suite' ), 'description' => __( 'Serves /sitemap_index.xml, /news-sitemap.xml, /video-sitemap.xml, [modulustools_html_sitemap], and IndexNow notifications.', 'modulustools-seo-suite' ), 'badge' => __( 'Technical SEO', 'modulustools-seo-suite' ), ), 'redirects_404' => array( 'title' => __( 'Regex Redirects, Slug Watcher & 404 Monitor', 'modulustools-seo-suite' ), 'description' => __( '301/302/307/410/451 redirects, automatic slug-change protection, and privacy-hashed 404 logging with 1-click redirect creation.', 'modulustools-seo-suite' ), 'badge' => __( 'Technical SEO', 'modulustools-seo-suite' ), ), 'geo_llms' => array( 'title' => __( 'GEO /llms.txt Router & AI Bot Firewall', 'modulustools-seo-suite' ), 'description' => __( 'Serves /llms.txt and /llms-full.txt prioritizing Pillar Content, manages AI bot rules in robots.txt, and logs crawler visits.', 'modulustools-seo-suite' ), 'badge' => __( 'GEO / AI Search', 'modulustools-seo-suite' ), ), 'ai_byok' => array( 'title' => __( 'WordPress Core AI Client & Content Assistant', 'modulustools-seo-suite' ), 'description' => __( 'Integrates with the WordPress Core AI Client, custom gateway endpoints, or local NLP heuristics for 1-click title, meta, and FAQ drafting.', 'modulustools-seo-suite' ), 'badge' => __( 'Automation', 'modulustools-seo-suite' ), ), 'image_seo' => array( 'title' => __( 'Dynamic Image Alt & Title Automation', 'modulustools-seo-suite' ), 'description' => __( 'Populates missing alt, title, and async decoding attributes dynamically on front-end render using customizable variable templates.', 'modulustools-seo-suite' ), 'badge' => __( 'Automation', 'modulustools-seo-suite' ), ), 'link_assistant' => array( 'title' => __( 'Topical Cluster Graph & Orphan Detector', 'modulustools-seo-suite' ), 'description' => __( 'Tracks inbound/outbound internal links, prioritizes Pillar Content, surfaces Orphan Pages, and suggests contextual anchor links.', 'modulustools-seo-suite' ), 'badge' => __( 'Internal Links', 'modulustools-seo-suite' ), ), 'migrator' => array( 'title' => __( 'Metadata Importer & JSON Snapshot', 'modulustools-seo-suite' ), 'description' => __( 'Batch imports existing SEO metadata into custom tables and provides 1-click JSON configuration export and restore.', 'modulustools-seo-suite' ), 'badge' => __( 'System Utility', 'modulustools-seo-suite' ), ), ); } /** * Checks whether a specific module is enabled. * * @param string $module_key Module slug. * @return bool */ public static function is_module_enabled( string $module_key ): bool { $modules = get_option( 'modulustools_seo_modules', array() ); if ( ! is_array( $modules ) || ! array_key_exists( $module_key, $modules ) ) { return true; } return (bool) $modules[ $module_key ]; } /** * Checks if the current user's role is permitted to access a specific capability group. * * @param string $group 'metabox' or 'redirects'. * @return bool */ public static function user_has_role_access( string $group = 'metabox' ): bool { if ( current_user_can( 'manage_options' ) ) { return true; } $user = wp_get_current_user(); if ( ! $user || empty( $user->roles ) ) { return false; } $settings = get_option( 'modulustools_seo_settings', array() ); $key = 'redirects' === $group ? 'allowed_roles_redirects' : 'allowed_roles_metabox'; $allowed = isset( $settings[ $key ] ) && is_array( $settings[ $key ] ) ? $settings[ $key ] : array( 'administrator', 'editor', 'author' ); foreach ( $user->roles as $role ) { if ( in_array( $role, $allowed, true ) ) { return true; } } return false; } /** * Instantiates only enabled modules. * * @return void */ private function boot_enabled_modules(): void { if ( get_option( 'modulustools_seo_db_version' ) !== MODULUSTOOLS_SEO_DB_VERSION ) { Activator::create_tables(); } $module_map = array( 'frontend_head' => Frontend_Head::class, 'schema_graph' => Schema_Graph::class, 'sitemaps' => Sitemaps::class, 'redirects_404' => Redirects_404::class, 'geo_llms' => GEO_LLMs::class, 'ai_byok' => AI_BYOK::class, 'image_seo' => Image_SEO::class, 'link_assistant' => Link_Assistant::class, 'migrator' => Migrator::class, ); foreach ( $module_map as $key => $class_name ) { if ( self::is_module_enabled( $key ) && class_exists( $class_name ) ) { $this->loaded_modules[ $key ] = new $class_name(); } } $this->loaded_modules['rest_api'] = new REST_Controller(); } /** * Registers admin menus, metaboxes, post save hooks, and asset loaders. * * @return void */ private function register_core_hooks(): void { add_action( 'admin_menu', array( $this, 'register_admin_menu' ) ); add_action( 'admin_init', array( $this, 'handle_admin_form_submissions' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); add_action( 'add_meta_boxes', array( $this, 'register_seo_metabox' ) ); add_action( 'save_post', array( $this, 'save_post_seo_meta' ), 10, 2 ); add_filter( 'manage_post_posts_columns', array( $this, 'add_seo_columns' ) ); add_filter( 'manage_page_posts_columns', array( $this, 'add_seo_columns' ) ); add_action( 'manage_post_posts_custom_column', array( $this, 'render_seo_column' ), 10, 2 ); add_action( 'manage_page_posts_custom_column', array( $this, 'render_seo_column' ), 10, 2 ); } /** * Retrieves custom SEO metadata row from `wp_modulustools_meta` with static caching. * * @param int $object_id Post ID or Term ID. * @param string $object_type 'post' or 'term'. * @return object|null */ public static function get_meta( int $object_id, string $object_type = 'post' ): ?object { if ( $object_id <= 0 ) { return null; } $cache_key = $object_type . ':' . $object_id; if ( array_key_exists( $cache_key, self::$meta_cache ) ) { return self::$meta_cache[ $cache_key ]; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE object_id = %d AND object_type = %s LIMIT 1', $wpdb->prefix . 'modulustools_meta', $object_id, $object_type ) ); self::$meta_cache[ $cache_key ] = $row ? $row : null; return self::$meta_cache[ $cache_key ]; } /** * Inserts or updates a row in `wp_modulustools_meta`. * * @param int $object_id Post ID or Term ID. * @param string $object_type 'post' or 'term'. * @param array $data Associative array of column values. * @return bool */ public static function save_meta( int $object_id, string $object_type, array $data ): bool { if ( $object_id <= 0 ) { return false; } global $wpdb; $table = $wpdb->prefix . 'modulustools_meta'; $existing = self::get_meta( $object_id, $object_type ); $focus_kw = isset( $data['focus_keyword'] ) ? sanitize_text_field( (string) $data['focus_keyword'] ) : ( $existing->focus_keyword ?? '' ); $kw_type = self::classify_keyword_type( $focus_kw ); $record = array( 'object_id' => $object_id, 'object_type' => sanitize_key( $object_type ), 'title' => isset( $data['title'] ) ? sanitize_text_field( (string) $data['title'] ) : ( $existing->title ?? '' ), 'description' => isset( $data['description'] ) ? sanitize_textarea_field( (string) $data['description'] ) : ( $existing->description ?? '' ), 'focus_keyword' => $focus_kw, 'keyword_type' => $kw_type, 'secondary_keywords' => isset( $data['secondary_keywords'] ) ? sanitize_text_field( (string) $data['secondary_keywords'] ) : ( $existing->secondary_keywords ?? '' ), 'lsi_keywords' => isset( $data['lsi_keywords'] ) ? sanitize_text_field( (string) $data['lsi_keywords'] ) : ( $existing->lsi_keywords ?? '' ), 'canonical_url' => isset( $data['canonical_url'] ) ? esc_url_raw( (string) $data['canonical_url'] ) : ( $existing->canonical_url ?? '' ), 'robots_index' => isset( $data['robots_index'] ) && in_array( $data['robots_index'], array( 'index', 'noindex' ), true ) ? $data['robots_index'] : ( $existing->robots_index ?? 'index' ), 'robots_follow' => isset( $data['robots_follow'] ) && in_array( $data['robots_follow'], array( 'follow', 'nofollow' ), true ) ? $data['robots_follow'] : ( $existing->robots_follow ?? 'follow' ), 'robots_advanced' => isset( $data['robots_advanced'] ) ? sanitize_text_field( (string) $data['robots_advanced'] ) : ( $existing->robots_advanced ?? 'max-snippet:-1,max-image-preview:large,max-video-preview:-1' ), 'og_title' => isset( $data['og_title'] ) ? sanitize_text_field( (string) $data['og_title'] ) : ( $existing->og_title ?? '' ), 'og_description' => isset( $data['og_description'] ) ? sanitize_textarea_field( (string) $data['og_description'] ) : ( $existing->og_description ?? '' ), 'og_image' => isset( $data['og_image'] ) ? esc_url_raw( (string) $data['og_image'] ) : ( $existing->og_image ?? '' ), 'schema_type' => isset( $data['schema_type'] ) ? sanitize_text_field( (string) $data['schema_type'] ) : ( $existing->schema_type ?? 'Article' ), 'product_brand' => isset( $data['product_brand'] ) ? sanitize_text_field( (string) $data['product_brand'] ) : ( $existing->product_brand ?? '' ), 'product_gtin' => isset( $data['product_gtin'] ) ? sanitize_text_field( (string) $data['product_gtin'] ) : ( $existing->product_gtin ?? '' ), 'product_mpn' => isset( $data['product_mpn'] ) ? sanitize_text_field( (string) $data['product_mpn'] ) : ( $existing->product_mpn ?? '' ), 'eeat_reviewed_by' => isset( $data['eeat_reviewed_by'] ) ? sanitize_text_field( (string) $data['eeat_reviewed_by'] ) : ( $existing->eeat_reviewed_by ?? '' ), 'eeat_citations' => isset( $data['eeat_citations'] ) ? sanitize_textarea_field( (string) $data['eeat_citations'] ) : ( $existing->eeat_citations ?? '' ), 'is_pillar' => array_key_exists( 'is_pillar', $data ) ? (int) (bool) $data['is_pillar'] : (int) ( $existing->is_pillar ?? 0 ), 'seo_score' => isset( $data['seo_score'] ) ? min( 100, absint( $data['seo_score'] ) ) : (int) ( $existing->seo_score ?? 0 ), 'readability_score' => isset( $data['readability_score'] ) ? min( 100, absint( $data['readability_score'] ) ) : (int) ( $existing->readability_score ?? 0 ), 'geo_score' => isset( $data['geo_score'] ) ? min( 100, absint( $data['geo_score'] ) ) : (int) ( $existing->geo_score ?? 0 ), 'sge_ready' => isset( $data['sge_ready'] ) ? (int) (bool) $data['sge_ready'] : (int) ( $existing->sge_ready ?? 0 ), 'inbound_links_count' => isset( $data['inbound_links_count'] ) ? absint( $data['inbound_links_count'] ) : (int) ( $existing->inbound_links_count ?? 0 ), 'outbound_links_count' => isset( $data['outbound_links_count'] ) ? absint( $data['outbound_links_count'] ) : (int) ( $existing->outbound_links_count ?? 0 ), 'external_links_count' => isset( $data['external_links_count'] ) ? absint( $data['external_links_count'] ) : (int) ( $existing->external_links_count ?? 0 ), 'updated_at' => current_time( 'mysql' ), ); $formats = array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%s', ); if ( $existing ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $result = $wpdb->update( $table, $record, array( 'object_id' => $object_id, 'object_type' => $object_type, ), $formats, array( '%d', '%s' ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter $result = $wpdb->insert( $table, $record, $formats ); } unset( self::$meta_cache[ $object_type . ':' . $object_id ] ); return false !== $result; } /** * Classifies a focus keyword into Head Term, Body Keyword, or Long-Tail Keyword. * * @param string $keyword Target focus query. * @return string 'head', 'body', or 'long_tail'. */ public static function classify_keyword_type( string $keyword ): string { $trimmed = trim( $keyword ); if ( '' === $trimmed ) { return 'none'; } $words = preg_split( '/\s+/', $trimmed ); $count = is_array( $words ) ? count( $words ) : 1; if ( $count <= 2 ) { return 'head'; } if ( $count <= 4 ) { return 'body'; } return 'long_tail'; } /** * Registers the top-level ModulusTools SEO Suite admin menu. * * @return void */ public function register_admin_menu(): void { add_menu_page( __( 'ModulusTools SEO Suite', 'modulustools-seo-suite' ), __( 'ModulusTools SEO', 'modulustools-seo-suite' ), 'manage_options', 'modulustools-seo-suite', array( $this, 'render_admin_dashboard' ), 'dashicons-chart-area', 58 ); } /** * Enqueues admin CSS and JS on ModulusTools SEO admin screens and post editors. * * @param string $hook Current admin page hook suffix. * @return void */ public function enqueue_admin_assets( string $hook ): void { $allowed_hooks = array( 'toplevel_page_modulustools-seo-suite', 'post.php', 'post-new.php', 'profile.php', 'user-edit.php' ); if ( ! in_array( $hook, $allowed_hooks, true ) ) { return; } wp_enqueue_style( 'modulustools-seo-admin', MODULUSTOOLS_SEO_URL . 'assets/css/admin-style.css', array(), MODULUSTOOLS_SEO_VERSION ); wp_enqueue_script( 'modulustools-seo-admin', MODULUSTOOLS_SEO_URL . 'assets/js/admin-app.js', array( 'wp-api-fetch' ), MODULUSTOOLS_SEO_VERSION, true ); $current_post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended wp_localize_script( 'modulustools-seo-admin', 'modulustoolsAdminData', array( 'restUrl' => esc_url_raw( rest_url( 'modulustools-seo/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'postId' => $current_post_id, 'siteName' => get_bloginfo( 'name' ), 'siteUrl' => home_url( '/' ), 'separator' => get_option( 'modulustools_seo_settings', array() )['title_separator'] ?? '-', 'i18n' => array( 'headTerm' => __( 'HEAD TERM', 'modulustools-seo-suite' ), 'bodyKeyword' => __( 'BODY KEYWORD', 'modulustools-seo-suite' ), 'longTail' => __( 'LONG-TAIL QUERY', 'modulustools-seo-suite' ), 'sgePass' => __( 'Verified: 40–60 word inverted-pyramid Direct Answer block detected under a question heading.', 'modulustools-seo-suite' ), 'sgeFail' => __( 'Recommendation: Place a 40–60 word direct answer paragraph immediately below an H2/H3 question heading.', 'modulustools-seo-suite' ), 'generating' => __( 'Processing...', 'modulustools-seo-suite' ), 'cannibalWarn' => __( 'Competing URL targeting this primary keyword:', 'modulustools-seo-suite' ), ), ) ); } /** * Handles standard admin form submissions. * * @return void */ public function handle_admin_form_submissions(): void { if ( ! isset( $_POST['modulustools_action'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'modulustools-seo-suite' ) ); } $action = sanitize_key( wp_unslash( $_POST['modulustools_action'] ) ); if ( 'save_general_settings' === $action ) { check_admin_referer( 'modulustools_save_settings_action', 'modulustools_settings_nonce' ); $settings = get_option( 'modulustools_seo_settings', array() ); $settings['title_separator'] = isset( $_POST['title_separator'] ) ? sanitize_text_field( wp_unslash( $_POST['title_separator'] ) ) : '-'; $settings['organization_name'] = isset( $_POST['organization_name'] ) ? sanitize_text_field( wp_unslash( $_POST['organization_name'] ) ) : get_bloginfo( 'name' ); $settings['organization_logo'] = isset( $_POST['organization_logo'] ) ? esc_url_raw( wp_unslash( $_POST['organization_logo'] ) ) : ''; $settings['default_schema_type'] = isset( $_POST['default_schema_type'] ) ? sanitize_text_field( wp_unslash( $_POST['default_schema_type'] ) ) : 'Article'; $settings['image_alt_template'] = isset( $_POST['image_alt_template'] ) ? sanitize_text_field( wp_unslash( $_POST['image_alt_template'] ) ) : '%post_title% - %site_name%'; $settings['image_title_template'] = isset( $_POST['image_title_template'] ) ? sanitize_text_field( wp_unslash( $_POST['image_title_template'] ) ) : '%post_title%'; $settings['auto_redirect_slug_change'] = ! empty( $_POST['auto_redirect_slug_change'] ); $settings['enable_indexnow'] = ! empty( $_POST['enable_indexnow'] ); $settings['clean_wp_head'] = ! empty( $_POST['clean_wp_head'] ); $settings['indexnow_webhook_url'] = isset( $_POST['indexnow_webhook_url'] ) ? esc_url_raw( wp_unslash( $_POST['indexnow_webhook_url'] ) ) : ''; $settings['llms_txt_intro'] = isset( $_POST['llms_txt_intro'] ) ? sanitize_textarea_field( wp_unslash( $_POST['llms_txt_intro'] ) ) : ''; $settings['custom_robots_rules'] = isset( $_POST['custom_robots_rules'] ) ? sanitize_textarea_field( wp_unslash( $_POST['custom_robots_rules'] ) ) : ''; $settings['delete_data_on_uninstall'] = ! empty( $_POST['delete_data_on_uninstall'] ); if ( isset( $_POST['ai_bots_rules'] ) && is_array( $_POST['ai_bots_rules'] ) ) { $raw_rules = wp_unslash( $_POST['ai_bots_rules'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $clean_rules = array(); foreach ( $raw_rules as $bot => $directive ) { $clean_bot = sanitize_text_field( (string) $bot ); $clean_dir = ( 'disallow' === $directive ) ? 'disallow' : 'allow'; $clean_rules[ $clean_bot ] = $clean_dir; } $settings['ai_bots_rules'] = $clean_rules; } update_option( 'modulustools_seo_settings', $settings ); delete_transient( 'modulustools_seo_llms_txt_cache' ); delete_transient( 'modulustools_seo_llms_full_cache' ); wp_safe_redirect( add_query_arg( array( 'page' => 'modulustools-seo-suite', 'tab' => 'settings', 'updated' => 'true' ), admin_url( 'admin.php' ) ) ); exit; } if ( 'save_local_rss_settings' === $action ) { check_admin_referer( 'modulustools_save_local_action', 'modulustools_local_nonce' ); $settings = get_option( 'modulustools_seo_settings', array() ); $fields = array( 'local_business_type', 'local_phone', 'local_price_range', 'local_street', 'local_city', 'local_region', 'local_postal', 'local_country', 'local_lat', 'local_lng', 'local_opening_hours', 'rss_before_content', 'rss_after_content', ); foreach ( $fields as $f ) { if ( isset( $_POST[ $f ] ) ) { $settings[ $f ] = sanitize_text_field( wp_unslash( $_POST[ $f ] ) ); } } if ( isset( $_POST['allowed_roles_metabox'] ) && is_array( $_POST['allowed_roles_metabox'] ) ) { $settings['allowed_roles_metabox'] = array_map( 'sanitize_key', wp_unslash( $_POST['allowed_roles_metabox'] ) ); } if ( isset( $_POST['allowed_roles_redirects'] ) && is_array( $_POST['allowed_roles_redirects'] ) ) { $settings['allowed_roles_redirects'] = array_map( 'sanitize_key', wp_unslash( $_POST['allowed_roles_redirects'] ) ); } update_option( 'modulustools_seo_settings', $settings ); wp_safe_redirect( add_query_arg( array( 'page' => 'modulustools-seo-suite', 'tab' => 'local-roles', 'updated' => 'true' ), admin_url( 'admin.php' ) ) ); exit; } if ( 'save_ai_settings' === $action ) { check_admin_referer( 'modulustools_save_ai_action', 'modulustools_ai_nonce' ); $provider = isset( $_POST['ai_provider'] ) ? sanitize_key( wp_unslash( $_POST['ai_provider'] ) ) : 'wp_core'; $model = isset( $_POST['ai_model'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_model'] ) ) : 'default'; $custom_url = isset( $_POST['ai_custom_endpoint'] ) ? esc_url_raw( wp_unslash( $_POST['ai_custom_endpoint'] ) ) : ''; $raw_key = isset( $_POST['ai_api_key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['ai_api_key'] ) ) ) : ''; AI_BYOK::save_credentials( $provider, $model, $raw_key, $custom_url ); wp_safe_redirect( add_query_arg( array( 'page' => 'modulustools-seo-suite', 'tab' => 'ai-byok', 'updated' => 'true' ), admin_url( 'admin.php' ) ) ); exit; } if ( 'export_settings_json' === $action ) { check_admin_referer( 'modulustools_export_json_action', 'modulustools_export_nonce' ); $export = array( 'plugin' => 'modulustools-seo-suite', 'version' => MODULUSTOOLS_SEO_VERSION, 'exportedAt' => gmdate( DATE_W3C ), 'modules' => get_option( 'modulustools_seo_modules', array() ), 'settings' => get_option( 'modulustools_seo_settings', array() ), ); nocache_headers(); header( 'Content-Disposition: attachment; filename="modulustools-seo-settings-' . gmdate( 'Y-m-d' ) . '.json"' ); wp_send_json( $export ); } if ( 'import_settings_json' === $action ) { check_admin_referer( 'modulustools_import_json_action', 'modulustools_import_nonce' ); $raw_json = isset( $_POST['modulustools_import_payload'] ) ? wp_unslash( $_POST['modulustools_import_payload'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $decoded = json_decode( (string) $raw_json, true ); if ( is_array( $decoded ) && isset( $decoded['plugin'] ) && 'modulustools-seo-suite' === $decoded['plugin'] ) { if ( isset( $decoded['modules'] ) && is_array( $decoded['modules'] ) ) { $clean_mods = array(); foreach ( $decoded['modules'] as $mk => $mv ) { $clean_mods[ sanitize_key( (string) $mk ) ] = (bool) $mv; } update_option( 'modulustools_seo_modules', $clean_mods ); } if ( isset( $decoded['settings'] ) && is_array( $decoded['settings'] ) ) { $current_settings = get_option( 'modulustools_seo_settings', array() ); foreach ( $decoded['settings'] as $sk => $sv ) { if ( is_scalar( $sv ) ) { $current_settings[ sanitize_key( (string) $sk ) ] = sanitize_text_field( (string) $sv ); } } update_option( 'modulustools_seo_settings', $current_settings ); } } wp_safe_redirect( add_query_arg( array( 'page' => 'modulustools-seo-suite', 'tab' => 'migrator', 'updated' => 'true' ), admin_url( 'admin.php' ) ) ); exit; } } /** * Registers the Semantic SEO & GEO Metabox on all public post types. * * @return void */ public function register_seo_metabox(): void { if ( ! self::user_has_role_access( 'metabox' ) ) { return; } $post_types = get_post_types( array( 'public' => true ), 'names' ); foreach ( $post_types as $post_type ) { add_meta_box( 'modulustools_seo_metabox', __( 'ModulusTools SEO — Search & Generative Engine Optimization', 'modulustools-seo-suite' ), array( $this, 'render_seo_metabox' ), $post_type, 'normal', 'high' ); } } /** * Renders the Gutenberg & Classic Editor Metabox UI. * * @param \WP_Post $post Current post object. * @return void */ public function render_seo_metabox( \WP_Post $post ): void { wp_nonce_field( 'modulustools_save_post_meta', 'modulustools_meta_nonce' ); $meta = self::get_meta( (int) $post->ID, 'post' ); $title = $meta->title ?? ''; $description = $meta->description ?? ''; $focus_keyword = $meta->focus_keyword ?? ''; $keyword_type = $meta->keyword_type ?? 'none'; $secondary_keywords = $meta->secondary_keywords ?? ''; $lsi_keywords = $meta->lsi_keywords ?? ''; $canonical_url = $meta->canonical_url ?? ''; $robots_index = $meta->robots_index ?? 'index'; $robots_follow = $meta->robots_follow ?? 'follow'; $og_title = $meta->og_title ?? ''; $og_description = $meta->og_description ?? ''; $og_image = $meta->og_image ?? ''; $schema_type = $meta->schema_type ?? 'Article'; $product_brand = $meta->product_brand ?? ''; $product_gtin = $meta->product_gtin ?? ''; $product_mpn = $meta->product_mpn ?? ''; $eeat_reviewed_by = $meta->eeat_reviewed_by ?? ''; $eeat_citations = $meta->eeat_citations ?? ''; $is_pillar = (int) ( $meta->is_pillar ?? 0 ); $seo_score = (int) ( $meta->seo_score ?? 0 ); $readability_score = (int) ( $meta->readability_score ?? 0 ); $geo_score = (int) ( $meta->geo_score ?? 0 ); $sge_ready = (int) ( $meta->sge_ready ?? 0 ); $link_suggestions = array(); if ( self::is_module_enabled( 'link_assistant' ) && class_exists( Link_Assistant::class ) ) { $link_suggestions = Link_Assistant::get_contextual_suggestions( (int) $post->ID, $focus_keyword ); } ?>
/ 100
/ 100
/ 100
post_content ), 25 ) ); ?>

post_status ) { return; } if ( ! isset( $_POST['modulustools_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['modulustools_meta_nonce'] ) ), 'modulustools_save_post_meta' ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } if ( isset( $_POST['modulustools_meta'] ) && is_array( $_POST['modulustools_meta'] ) ) { $raw_meta = wp_unslash( $_POST['modulustools_meta'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw_meta['is_pillar'] = ! empty( $raw_meta['is_pillar'] ) ? 1 : 0; self::save_meta( $post_id, 'post', $raw_meta ); } } /** * Adds an SEO/GEO Score column to post/page list tables. * * @param array $columns Existing columns. * @return array */ public function add_seo_columns( array $columns ): array { $columns['modulustools_score'] = __( 'Search & GEO Index', 'modulustools-seo-suite' ); return $columns; } /** * Renders the custom SEO/GEO score badge in post/page list tables. * * @param string $column Column slug. * @param int $post_id Post ID. * @return void */ public function render_seo_column( string $column, int $post_id ): void { if ( 'modulustools_score' !== $column ) { return; } $meta = self::get_meta( $post_id, 'post' ); if ( ! $meta ) { echo '' . esc_html__( 'Unscored', 'modulustools-seo-suite' ) . ''; return; } $score = (int) $meta->seo_score; $class = $score >= 80 ? 'good' : ( $score >= 50 ? 'ok' : 'poor' ); $pillar = ! empty( $meta->is_pillar ) ? ' PILLAR' : ''; printf( 'SEO %2$d GEO %3$d%4$s', esc_attr( $class ), absint( $score ), absint( (int) $meta->geo_score ), wp_kses_post( $pillar ) ); } /** * Computes live telemetry metrics for the Executive KPI Strip and Site Audit using %i placeholders. * * @return array */ private function get_dashboard_telemetry(): array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $avg_seo = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT AVG(seo_score) FROM %i WHERE seo_score > %d', $wpdb->prefix . 'modulustools_meta', 0 ) ); $avg_geo = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT AVG(geo_score) FROM %i WHERE geo_score > %d', $wpdb->prefix . 'modulustools_meta', 0 ) ); $indexed_rows = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE robots_index = %s', $wpdb->prefix . 'modulustools_meta', 'index' ) ); $pillar_count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE is_pillar = %d', $wpdb->prefix . 'modulustools_meta', 1 ) ); $redirects_cnt = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE is_active = %d', $wpdb->prefix . 'modulustools_redirects', 1 ) ); $logs_404_cnt = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE hits >= %d', $wpdb->prefix . 'modulustools_404_logs', 1 ) ); $ai_bot_hits = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE http_status >= %d', $wpdb->prefix . 'modulustools_ai_bots', 100 ) ); // phpcs:enable return array( 'avg_seo' => $avg_seo, 'avg_geo' => $avg_geo, 'indexed_rows' => $indexed_rows, 'pillar_count' => $pillar_count, 'redirects_cnt' => $redirects_cnt, 'logs_404_cnt' => $logs_404_cnt, 'ai_bot_hits' => $ai_bot_hits, ); } /** * Runs an automated Technical SEO, Database & GEO Diagnostic Audit. * * @return array */ private function run_site_health_audit(): array { $checks = array(); $passed = 0; $blog_public = '1' === (string) get_option( 'blog_public', '1' ); $checks[] = array( 'category' => __( 'Indexing & Crawlability', 'modulustools-seo-suite' ), 'label' => __( 'Search Engine Visibility (blog_public)', 'modulustools-seo-suite' ), 'status' => $blog_public ? 'pass' : 'fail', 'detail' => $blog_public ? __( 'Site is open to search engines and AI answer crawlers.', 'modulustools-seo-suite' ) : __( 'CRITICAL: "Discourage search engines from indexing this site" is checked in Settings > Reading.', 'modulustools-seo-suite' ), ); if ( $blog_public ) { ++$passed; } $permalink_structure = (string) get_option( 'permalink_structure', '' ); $pretty_permalinks = '' !== $permalink_structure && false !== strpos( $permalink_structure, '%postname%' ); $checks[] = array( 'category' => __( 'Technical Architecture', 'modulustools-seo-suite' ), 'label' => __( 'SEO-Friendly Permalink Structure', 'modulustools-seo-suite' ), 'status' => $pretty_permalinks ? 'pass' : 'warn', 'detail' => $pretty_permalinks ? __( 'Clean slug-based URLs are active.', 'modulustools-seo-suite' ) : __( 'Update Settings > Permalinks to "Post name" for optimal keyword URLs.', 'modulustools-seo-suite' ), ); if ( $pretty_permalinks ) { ++$passed; } $is_https = is_ssl() || 0 === strpos( home_url(), 'https://' ); $checks[] = array( 'category' => __( 'Security & Trust', 'modulustools-seo-suite' ), 'label' => __( 'HTTPS / TLS Canonical Protocol', 'modulustools-seo-suite' ), 'status' => $is_https ? 'pass' : 'warn', 'detail' => $is_https ? __( 'Site URL uses HTTPS encryption.', 'modulustools-seo-suite' ) : __( 'Site is running over HTTP; switch to HTTPS for ranking trust.', 'modulustools-seo-suite' ), ); if ( $is_https ) { ++$passed; } global $wpdb; $meta_table = $wpdb->prefix . 'modulustools_meta'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $meta_table ) ) === $meta_table; $checks[] = array( 'category' => __( 'Database Architecture', 'modulustools-seo-suite' ), 'label' => __( 'Dedicated Custom Index Tables (wp_modulustools_*)', 'modulustools-seo-suite' ), 'status' => $table_exists ? 'pass' : 'fail', 'detail' => $table_exists ? __( 'All 4 indexed custom tables are active.', 'modulustools-seo-suite' ) : __( 'Deactivate and reactivate plugin to initialize dbDelta tables.', 'modulustools-seo-suite' ), ); if ( $table_exists ) { ++$passed; } $sitemaps_on = self::is_module_enabled( 'sitemaps' ); $checks[] = array( 'category' => __( 'Indexing & Crawlability', 'modulustools-seo-suite' ), 'label' => __( 'XML, News & Video Sitemap Router (/sitemap_index.xml)', 'modulustools-seo-suite' ), 'status' => $sitemaps_on ? 'pass' : 'warn', 'detail' => $sitemaps_on ? __( 'XML, Google News (/news-sitemap.xml), Video (/video-sitemap.xml), and IndexNow are active.', 'modulustools-seo-suite' ) : __( 'Enable the Sitemaps module in Module Manager.', 'modulustools-seo-suite' ), ); if ( $sitemaps_on ) { ++$passed; } $geo_on = self::is_module_enabled( 'geo_llms' ); $checks[] = array( 'category' => __( 'Generative Engine Optimization', 'modulustools-seo-suite' ), 'label' => __( 'LLM Directory (/llms.txt & /llms-full.txt)', 'modulustools-seo-suite' ), 'status' => $geo_on ? 'pass' : 'warn', 'detail' => $geo_on ? __( 'Dynamic LLM directories are active.', 'modulustools-seo-suite' ) : __( 'Enable the GEO /llms.txt module.', 'modulustools-seo-suite' ), ); if ( $geo_on ) { ++$passed; } $schema_on = self::is_module_enabled( 'schema_graph' ); $checks[] = array( 'category' => __( 'Structured Data & E-E-A-T', 'modulustools-seo-suite' ), 'label' => __( 'Unified @graph JSON-LD, Merchant & E-E-A-T Credentials', 'modulustools-seo-suite' ), 'status' => $schema_on ? 'pass' : 'warn', 'detail' => $schema_on ? __( 'Deduplicated Schema.org graph is active in wp_head.', 'modulustools-seo-suite' ) : __( 'Enable Schema Graph module.', 'modulustools-seo-suite' ), ); if ( $schema_on ) { ++$passed; } $tagline = trim( (string) get_bloginfo( 'description' ) ); $valid_desc = '' !== $tagline && false === stripos( $tagline, 'Just another WordPress site' ); $checks[] = array( 'category' => __( 'On-Page Essentials', 'modulustools-seo-suite' ), 'label' => __( 'Custom Brand Tagline Configured', 'modulustools-seo-suite' ), 'status' => $valid_desc ? 'pass' : 'warn', 'detail' => $valid_desc ? __( 'Active tagline:', 'modulustools-seo-suite' ) . ' "' . $tagline . '"' : __( 'Customize your site tagline under Settings > General.', 'modulustools-seo-suite' ), ); if ( $valid_desc ) { ++$passed; } $score = (int) round( ( $passed / count( $checks ) ) * 100 ); return array( 'score' => $score, 'passed' => $passed, 'total' => count( $checks ), 'checks' => $checks, ); } /** * Renders the Spreadsheet-Style Bulk Quick-Editor Grid. * * @return void */ private function render_bulk_editor_view(): void { $posts = get_posts( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => 50, 'orderby' => 'date', 'order' => 'DESC', ) ); ?>

ID, 'post' ); $title = $m->title ?? ''; $desc = $m->description ?? ''; $kw = $m->focus_keyword ?? ''; $r_idx = $m->robots_index ?? 'index'; $pillar = (int) ( $m->is_pillar ?? 0 ); ?>
post_type ) ); ?> • ID #ID ); ?>
/>
get_dashboard_telemetry(); ?>

v

0 ? $telemetry['avg_seo'] . '/100' : '—' ); ?>
0 ? $telemetry['avg_geo'] . '/100' : '—' ); ?>
$m_info ) : ?>

render_bulk_editor_view(); ?> run_site_health_audit(); ?>

/ 100


%post_link%, %site_link%, %author_link%


get_names(); $allowed_metabox = $settings['allowed_roles_metabox'] ?? array( 'administrator', 'editor', 'author' ); $allowed_redir = $settings['allowed_roles_redirects'] ?? array( 'administrator', 'editor' ); ?>