*/ private array $modules = array(); private function __construct() { $this->container = new Container(); } public static function instance(): Kernel { if ( null === self::$instance ) { self::$instance = new Kernel(); } return self::$instance; } public function container(): Container { return $this->container; } /** * Resolve a shared service. * * Kernel::instance()->container()->get( Foo::class ) appeared 50+ times * across the modules and said nothing the shorter form does not. * * @template T of object * @param class-string $class Service class name. * @return T */ public static function service( string $class ): object { return self::instance()->container()->get( $class ); } /** * Static module registry (production uses module.json manifests). * * @return array> */ private function moduleClasses(): array { $classes = array( Modules\Core\CoreModule::class, Modules\Settings\SettingsModule::class, Modules\Ai\AiModule::class, Modules\Seo\SeoModule::class, Modules\Autopilot\AutopilotModule::class, Modules\Backlinks\BacklinksModule::class, Modules\Social\SocialModule::class, Modules\Robots\RobotsModule::class, Modules\Sitemap\SitemapModule::class, Modules\Breadcrumbs\BreadcrumbsModule::class, Modules\LocalSeo\LocalSeoModule::class, Modules\ImageSeo\ImageSeoModule::class, Modules\Schema\SchemaModule::class, Modules\LlmsTxt\LlmsTxtModule::class, Modules\ContentAnalyzer\ContentAnalyzerModule::class, Modules\Editor\EditorModule::class, Modules\Blocks\BlocksModule::class, Modules\GeoScore\GeoScoreModule::class, Modules\Insights\InsightsModule::class, Modules\SiteAudit\SiteAuditModule::class, Modules\BusinessPacks\BusinessPacksModule::class, Modules\CrawlerControls\CrawlerControlsModule::class, Modules\SearchConsole\SearchConsoleModule::class, Modules\Bing\BingModule::class, Modules\RankTracker\RankTrackerModule::class, Modules\SeoApis\SeoApisModule::class, Modules\Analytics\AnalyticsModule::class, Modules\PageSpeed\PageSpeedModule::class, Modules\WooCommerce\WooCommerceModule::class, Modules\Feeds\FeedsModule::class, Modules\Integrations\IntegrationsModule::class, Modules\Reports\ReportsModule::class, Modules\Bulk\BulkModule::class, Modules\Google\GoogleModule::class, Modules\ImportExport\ImportExportModule::class, Modules\InternalLinks\InternalLinksModule::class, Modules\Redirects\RedirectsModule::class, Modules\IndexNow\IndexNowModule::class, Modules\AiVisibility\AiVisibilityModule::class, Modules\LinkChecker\LinkCheckerModule::class, Modules\Urls\UrlsModule::class, Modules\Hardening\HardeningModule::class, Modules\DevTools\DevToolsModule::class, Modules\Coexistence\CoexistenceModule::class, Modules\Licensing\LicensingModule::class, ); // Customers can switch optional modules off (Settings → Modules); the // essential ones are never in that list, so the plugin cannot brick. $disabled = get_option( 'airanksuite_disabled_modules', array() ); if ( is_array( $disabled ) && ! empty( $disabled ) ) { $classes = array_values( array_filter( $classes, static function ( string $class ) use ( $disabled ): bool { $id = Support\ModuleCatalog::id_for_class( $class ); return null === $id || ! in_array( $id, $disabled, true ) || ! Support\ModuleCatalog::is_optional( $id ); } ) ); } /** * Filter the list of module classes to boot. * * @param array $classes Module class names. */ /** * The free edition ships without the Pro module folders, so their * classes simply do not exist. That is absence, not failure: filtering * here keeps them out of the quarantine list, which is reserved for * modules that are present and broken. */ $classes = array_values( array_filter( $classes, 'class_exists' ) ); return (array) apply_filters( 'airanksuite/kernel/modules', $classes ); } /** * Boot the plugin. A failing module is quarantined, never fatal. */ public function boot(): void { // Before anything reads a setting: an install that still stores its // data under an older prefix is carried across here, so not even the // first page view after the update runs on defaults. Options::prime(); if ( Support\Migrator::adopt_legacy() ) { // The move flushed the cache and renamed what was primed. Options::prime( true ); } // Modules schedule weekly events during boot, so register the interval first. add_filter( 'cron_schedules', array( Support\Cron::class, 'schedules' ) ); /* * Scheduled events that belong to a module this build does not have. * * A site that ran Pro and now runs the free edition still carries the * rank snapshot, the answer simulation, the e-mail report and the * backlink re-check in its cron table. Nothing registers those hooks * any more, so WordPress fires them into nothing on schedule, for * ever, and the systems board shows four gears that never turn. Once * per plugin version and module set, after every module has had `init` * to attach its callbacks, any airanksuite_ event with no listener is removed. * The module set is part of the key on purpose: Pro and free carry the * same version number, and swapping one for the other is exactly the * moment this has to run again. */ add_action( 'init', function (): void { $stamp = AIRANKSUITE_VERSION . ':' . md5( implode( ',', $this->moduleIds() ) ); if ( get_option( 'airanksuite_cron_pruned' ) === $stamp ) { return; } foreach ( (array) _get_cron_array() as $events ) { foreach ( array_keys( (array) $events ) as $hook ) { $hook = (string) $hook; if ( str_starts_with( $hook, 'airanksuite_' ) && ! has_action( $hook ) ) { wp_clear_scheduled_hook( $hook ); } } } update_option( 'airanksuite_cron_pruned', $stamp, false ); }, PHP_INT_MAX ); // On `init`, not here. boot() runs on plugins_loaded, and since // WordPress 6.7 loading a text domain before `init` produces a // _doing_it_wrong notice — and, more to the point, translations // genuinely may not apply. Nothing in this plugin needs a translated // string before init; the one place that did (the scheduled-job // watcher) was changed to work from hook names instead. /* * Only the Pro build loads its own translations. * * A plugin hosted on WordPress.org has had its translations delivered * by the language pack system since WordPress 4.6, and the directory * asks that neither the call nor the .mo files ship — translate. * wordpress.org generates and distributes them, and a bundled catalogue * silently shadows the community's. The Pro build is distributed by us * and gets no language packs, so it keeps both. */ add_action( 'init', static function (): void { if ( ! defined( 'AIRANKSUITE_IS_PRO' ) || ! AIRANKSUITE_IS_PRO ) { return; } load_plugin_textdomain( 'ai-rank-suite', false, dirname( plugin_basename( AIRANKSUITE_FILE ) ) . '/languages' ); }, 1 ); // The plugin's own capabilities are added to the administrator role on // activation — and only then. A role-manager plugin that rewrites the // role, a site copied from staging with a fresh roles option, or a // network site created after activation all leave an administrator // without them, and every REST route then answers 403 to the one // person who installed the plugin. Site owners can never be locked out // of their own tool: whoever may manage the site may manage this. add_filter( 'user_has_cap', static function ( array $allcaps, array $caps ): array { if ( empty( $allcaps['manage_options'] ) ) { return $allcaps; } foreach ( $caps as $cap ) { if ( is_string( $cap ) && str_starts_with( $cap, 'airanksuite_' ) ) { $allcaps[ $cap ] = true; } } return $allcaps; }, 10, 2 ); // Two events that already exist as actions become webhook events too. add_action( 'airanksuite/cache/purged', static function ( $cleared ): void { Support\Events::emit( 'cache.purged', array( 'cleared' => (array) $cleared ) ); } ); add_action( 'airanksuite/content_analyzer/analyzed', static function ( $result, $post = null ): void { Support\Events::emit( 'content.analyzed', array( 'post_id' => $post instanceof \WP_Post ? $post->ID : 0, 'geo' => is_array( $result ) ? ( $result['geo_score'] ?? $result['geo'] ?? null ) : null, 'seo' => is_array( $result ) ? ( $result['seo_score'] ?? $result['seo'] ?? null ) : null, ) ); }, 10, 2 ); $this->container->share( Options::class, static fn() => new Options() ); // One query for the whole settings set, before any module asks for its // own. Modules read their settings in register()/boot() and again on // wp_head, and every one of those was a separate round trip to the // database. Priming first makes them all cache hits. Options::prime(); foreach ( $this->moduleClasses() as $class ) { try { $module = new $class(); if ( ! $module instanceof ModuleInterface ) { continue; } $module->register( $this->container ); $this->modules[ $module->id() ] = $module; } catch ( Throwable $e ) { $this->quarantine( $class, $e ); } } foreach ( $this->modules as $id => $module ) { try { $module->boot( $this->container ); } catch ( Throwable $e ) { unset( $this->modules[ $id ] ); $this->quarantine( $id, $e ); } } // The daily prune of the plugin's own logs, to the days set in Settings. Support\Retention::schedule(); // One definition of "a page" for every counter; invalidated on post changes. Support\Inventory::boot(); // Shortcodes already typed into pages keep answering to their old names. Support\LegacyShortcodes::boot(); // Long jobs: one runner, back-off on failure, cron resumes abandoned runs. Support\Job::boot(); // Plugin updates do not fire the activation hook — migrate here instead. // Not only on admin pages: an auto-update lands between two REST ticks // of a running job, and the cron resumer may be the first request to // meet the new schema. Both would otherwise query columns that are not // there yet. The check is one option read when nothing changed. add_action( 'admin_init', array( Support\Migrator::class, 'maybe_upgrade' ), 1 ); // A data migration too large for one request continues here: the tick // re-enters maybe_upgrade, which resumes the cursor and reschedules // itself until the last slice — only then does the schema version move. add_action( 'airanksuite_migrate_tick', array( Support\Migrator::class, 'maybe_upgrade' ) ); // A link-graph build too large for its request continues here. The // graph is Pro; a site that downgraded can still carry the scheduled // event, so the handler is only registered when the class is present. if ( class_exists( \AiRankSuite\Modules\InternalLinks\Services\LinkGraph::class ) ) { add_action( 'airanksuite_graph_tick', array( \AiRankSuite\Modules\InternalLinks\Services\LinkGraph::class, 'cron_tick' ) ); add_action( 'airanksuite_linkscan_tick', array( \AiRankSuite\Modules\LinkChecker\Services\LinkScanner::class, 'cron_tick' ) ); } add_action( 'rest_api_init', array( Support\Migrator::class, 'maybe_upgrade' ), 1 ); if ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { add_action( 'init', array( Support\Migrator::class, 'maybe_upgrade' ), 1 ); } /** * Fires after every module booted — the safe point to interact with plugin services. * * @param Kernel $kernel The kernel; Kernel::service() resolves any registered service. */ do_action( 'airanksuite/booted', $this ); } /** * Booted module ids. * * @return array */ public function moduleIds(): array { return array_keys( $this->modules ); } /** * Record a module failure without taking the site down. * * @param string $module Module id or class. * @param Throwable $e The failure. */ private function quarantine( string $module, Throwable $e ): void { $log = get_option( 'airanksuite_quarantine', array() ); $log[ $module ] = array( 'message' => $e->getMessage(), 'time' => time(), ); update_option( 'airanksuite_quarantine', $log, false ); } /** * Activation: capabilities + rewrite flush. */ public static function activate(): void { // The plugin folder changed with the rebrand, so WordPress sees the // new build as a separate plugin and leaves AI GEO Suite active // beside it — two copies writing the same head tags. Retire the old // one here, before anything else runs. Its data is not touched: the // schema-24 migration moves it over on the next load. if ( function_exists( 'deactivate_plugins' ) ) { foreach ( array( 'ai-geo-suite/ai-geo-suite.php' ) as $legacy ) { if ( is_plugin_active( $legacy ) ) { deactivate_plugins( $legacy, true ); } } } $role = get_role( 'administrator' ); if ( $role ) { // The plugin's own capabilities are gone: every route and every // screen asks WordPress for one of its own (manage_options, or // edit_posts and edit_post where a post is named). Installs that // carry the old capabilities have them taken off here. foreach ( array( 'airanksuite_manage_settings', 'airanksuite_run_audits', 'airanksuite_edit_optimizations', 'airanksuite_view_reports' ) as $cap ) { $role->remove_cap( $cap ); } } // Before Database::install(): that records the schema version, and a // recorded version is one of the signs of an install that already exists. Support\ModuleCatalog::seed_fresh_install(); Support\Database::install(); // Ensure every served URL has its rewrite before flushing. Feeds is a // Pro module and simply is not present in the free edition, so this // asks each class whether it exists rather than assuming it does. foreach ( array( Modules\LlmsTxt\LlmsTxtModule::class, Modules\Sitemap\SitemapModule::class, Modules\IndexNow\IndexNowModule::class, Modules\Feeds\FeedsModule::class, ) as $rewriter ) { if ( class_exists( $rewriter ) && method_exists( $rewriter, 'add_rewrite' ) ) { $rewriter::add_rewrite(); } } flush_rewrite_rules(); update_option( 'airanksuite_version', AIRANKSUITE_VERSION, false ); } /** * Deactivation: stop background work and flush rewrites. Data is kept. */ public static function deactivate(): void { Support\Cron::clear(); flush_rewrite_rules(); } }