*/ public const MODULES = [ Modules\RobotsPolicy::class, Modules\LlmsTxt::class, Modules\HumansTxt::class, Modules\AiTxt::class, Modules\AiContext::class, Modules\SecurityTxt::class, Modules\Schema::class, Modules\HeadTags::class, Modules\HttpHeaders::class, Modules\IndexNow::class, Modules\Citations::class, Modules\ContentTools::class, Modules\StaticFiles::class, ]; /** * Always-on services, booted after the modules. * * @var array */ public const SERVICES = [ Endpoints\Router::class, Rest\Controller::class, Admin\Admin::class, Admin\UserFields::class, Admin\MediaFields::class, Cli\Commands::class, ]; /** * The single instance. * * @var Plugin|null */ private static ?Plugin $instance = null; /** * Booted modules, keyed by slug. * * @var array */ private array $modules = []; /** * Booted services, keyed by class name. * * @var array */ private array $services = []; /** * Whether `boot()` already ran. * * @var bool */ private bool $booted = false; /** * Keeps the constructor private; use `instance()`. */ private function __construct() {} /** * Returns the single plugin instance. * * @return Plugin The instance. */ public static function instance(): Plugin { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Boots modules and services and registers global hooks. * * Classes that do not exist yet are skipped, so the plugin stays loadable * while modules are still being built. * * @return void */ public function boot(): void { if ( $this->booted ) { return; } $this->booted = true; Activator::register_hooks(); // Before any module can query the events table. One option read on a // request where nothing has changed; see `Citations\Store::maybe_upgrade()`. if ( class_exists( Citations\Store::class ) ) { Citations\Store::maybe_upgrade(); } Upgrader::maybe_upgrade(); // The router owns the virtual file table and modules fill it from their // `register()`, so it has to be booted before them. $this->boot_service( Endpoints\Router::class ); foreach ( self::MODULES as $class_name ) { if ( ! class_exists( $class_name ) ) { continue; } $module = new $class_name(); if ( ! $module instanceof Module ) { continue; } $this->modules[ $module->slug() ] = $module; if ( $module->is_enabled() ) { $module->register(); } } foreach ( self::SERVICES as $class_name ) { $this->boot_service( $class_name ); } add_action( 'init', [ $this, 'maybe_flush_rewrites' ], 99 ); add_action( 'acid_aeo_daily', [ $this, 'run_daily_tasks' ] ); // Another plugin's `switch_to_blog()` would otherwise leave this one // reading the first site's settings while writing to the second. add_action( 'switch_blog', [ $this, 'forget_site_memos' ] ); // Two site-wide changes no module watches for itself, both of which // change what the generated files say: a new theme brings new // templates and a new set of credits, and the navigation menus are // where `llms.txt` gets its key pages from. add_action( 'switch_theme', [ $this, 'invalidate_cache' ] ); add_action( 'wp_update_nav_menu', [ $this, 'invalidate_cache' ] ); } /** * Drops every cached body. * * Public because WordPress invokes hook callbacks from outside the class, * and argument-free because the hooks it answers pass values it does not * need. * * @return void */ public function invalidate_cache(): void { Cache::invalidate_all(); } /** * Instantiates one service and lets it hook itself in. * * Skips classes that do not exist yet and services already booted, so a * service can be brought up early without being booted twice. * * @param string $class_name Fully qualified class name. * @return void */ private function boot_service( string $class_name ): void { if ( isset( $this->services[ $class_name ] ) || ! class_exists( $class_name ) ) { return; } $service = new $class_name(); $this->services[ $class_name ] = $service; if ( method_exists( $service, 'register' ) ) { $service->register(); } } /** * Every booted module, keyed by slug. * * @return array Modules. */ public function modules(): array { return $this->modules; } /** * One booted module by slug. * * @param string $slug Module slug. * @return Module|null The module, or null when it is not booted. */ public function module( string $slug ): ?Module { return $this->modules[ $slug ] ?? null; } /** * Flushes rewrite rules once, after activation or a rule change. * * @return void */ public function maybe_flush_rewrites(): void { if ( ! get_option( 'acid_aeo_flush_needed' ) ) { return; } flush_rewrite_rules( false ); delete_option( 'acid_aeo_flush_needed' ); } /** * Runs the daily maintenance hook modules subscribe to. * * @return void */ public function run_daily_tasks(): void { // Before the modules run, because the static files module writes the // files during this same pass and the sitemap address is one of the // values it writes. An SEO plugin switched on or off yesterday is // noticed here, without anybody opening the dashboard. Detect\Environment::resolve_sitemap(); $this->purge_citations(); /** * Fires once a day so modules can prune data and flush queues. */ do_action( 'acid_aeo_daily_tasks' ); // A fresh audit once a day keeps the dashboard widget honest between // visits to the plugin's own screen, where the audit otherwise runs. if ( class_exists( Audit\Runner::class ) ) { Audit\Runner::run( true ); } } /** * Drops citation events older than the retention setting. * * Retention is a promise made in the privacy text, so it holds whether or * not tracking is still switched on: a module switched off is not booted * and hooks nothing, which is why this lives here and not in the module. * The age is always passed explicitly — `Store::purge()` without one * empties the table. * * @return void */ public function purge_citations(): void { if ( ! class_exists( Citations\Store::class ) ) { return; } $days = (int) Options::get( 'citations.retention_days', 90 ); if ( $days > 0 ) { Citations\Store::purge( $days ); } } /** * Forgets everything memoised for the previous site after `switch_to_blog()`. * * A settings overlay in progress is kept: see `Options::forget()`. * * @return void */ public function forget_site_memos(): void { Options::forget(); Cache::reset(); Audit\Loopback::reset(); Data\SiteData::reset(); Data\ContentData::reset(); Detect\Environment::reset(); } }