*/ private array $factories = array(); /** * Services already built by {@see Plugin::get()}. * * @var array */ private array $resolved = array(); /** * Guards against a second `boot()` — double-registered hooks are hard to * spot and easy to cause from a test bootstrap or a stray `plugins_loaded`. * * @var bool */ private bool $booted = false; /** * Retrieve the singleton. */ public static function instance(): Plugin { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Not constructed directly — see {@see Plugin::instance()}. */ private function __construct() {} /** * Wire the plugin up. */ public function boot(): void { if ( $this->booted ) { return; } $this->booted = true; // No load_plugin_textdomain(): WordPress loads the `helpora` translations // from wp-content/languages/plugins on its own when a string needs them. // Services first: an upgrade fires `helpora_install`, and anything // listening for it has to be attached before it runs. The upgrade // itself waits for `init`: it can create the docs page, which needs // the rewrite API (not built during `plugins_loaded`), and its strings // need translations, which load at `init`. Priority 20 is after the // post type and taxonomies register. $this->register_services(); add_action( 'init', array( $this, 'maybe_upgrade' ), 20 ); Cli::register(); // Rewrite rules can only be rebuilt once the post type and its rules // exist, which is later than activation runs. See Activator::activate(). add_action( 'init', array( $this, 'maybe_flush_rewrites' ), 99 ); } /** * Add a lazy service factory. * * @param string $id Service identifier, conventionally the class name. * @param callable(Plugin): object $factory Builds the service on first use. */ public function register( string $id, callable $factory ): void { $this->factories[ $id ] = $factory; } /** * Whether a service id is known. * * @param string $id Service identifier. */ public function has( string $id ): bool { return isset( $this->factories[ $id ] ) || isset( $this->resolved[ $id ] ); } /** * Resolve a service, building it on first request. * * @param string $id Service identifier. * @return object|null The service, or null when the id is unknown — an id is * unknown whenever its context did not apply (asking for * an admin-only service on the frontend, say), which is a * normal condition rather than an error. */ public function get( string $id ): ?object { if ( isset( $this->resolved[ $id ] ) ) { return $this->resolved[ $id ]; } if ( ! isset( $this->factories[ $id ] ) ) { return null; } $this->resolved[ $id ] = ( $this->factories[ $id ] )( $this ); return $this->resolved[ $id ]; } /** * Build and hook up the services that apply to this request. * * Services are instantiated eagerly — a service that only hooks itself up * lazily would never hook up at all — but their dependencies are still * pulled from the registry, so anything expensive stays lazy. */ private function register_services(): void { foreach ( $this->service_classes() as $id => $class_name ) { $this->register( $id, static fn(): object => new $class_name() ); $service = $this->get( $id ); // Convention (ADR-011): a service declares its own hooks in // `register()`. Services that are pure collaborators — Visibility, // Tree, and the like — legitimately have no hooks and no method. if ( null !== $service && is_callable( array( $service, 'register' ) ) ) { $service->register(); } } } /** * The service classes that apply to this request, keyed by service id. * * Phase 0 ships the wiring, not the services: content, search, analytics, * REST, admin, and frontend services are added to these lists as their * phases land, and nothing else about the bootstrap has to change. * * @return array */ private function service_classes(): array { $always_on = array( // Content registration has to happen on every request: REST, cron, // and the frontend all need the post type to exist. 'post_types' => Content\Post_Types::class, 'taxonomies' => Content\Taxonomies::class, 'meta' => Content\Meta::class, 'visibility' => Content\Visibility::class, 'tree' => Content\Tree::class, 'catalog' => Content\Catalog::class, // The pages list labels the docs page and its permalink points at // the docs home, in the admin and on the frontend alike. 'docs_page' => Content\Docs_Page::class, 'rewrites' => Frontend\Rewrites::class, // Shortcodes render in REST and editor previews too, not only on // the frontend. 'shortcodes' => Frontend\Shortcodes::class, // Blocks register on every request: the editor needs them, and so // does any frontend page that contains one. 'blocks' => Blocks\Blocks::class, 'patterns' => Blocks\Patterns::class, // REST requests are not is_admin(), so controllers must be always-on. 'rest_settings' => Rest\Settings_Controller::class, 'rest_terms' => Rest\Terms_Controller::class, 'rest_dashboard' => Rest\Dashboard_Controller::class, 'rest_tree' => Rest\Tree_Controller::class, 'rest_articles' => Rest\Articles_Controller::class, 'rest_article_list' => Rest\Article_List_Controller::class, 'rest_search' => Rest\Search_Controller::class, // Index writes come from the editor, REST, cron, and WP-CLI alike. 'indexer' => Search\Indexer::class, 'rest_analytics' => Rest\Analytics_Controller::class, 'rest_feedback' => Rest\Feedback_Controller::class, 'rest_onboarding' => Rest\Onboarding_Controller::class, // Votes arrive via admin-post.php, which is is_admin(); views via // the frontend; the rollup via cron. All need these. 'recorder' => Analytics\Recorder::class, 'feedback' => Analytics\Feedback::class, 'rollup' => Analytics\Rollup::class, ); $contextual = is_admin() ? array( 'menu' => Admin\Menu::class, 'editor_sidebar' => Admin\Editor_Sidebar::class, 'notices' => Admin\Notices::class, ) : array( 'template_loader' => Frontend\Template_Loader::class, 'query_filters' => Frontend\Query_Filters::class, 'schema_org' => Frontend\Schema_Org::class, 'sitemap' => Frontend\Sitemap::class, ); $services = array_merge( $always_on, $contextual ); /** * Filters the services booted for this request. * * Intended for tests and for site-specific code that needs to replace a * service with its own subclass. Removing a core service is supported * but unsupported territory for bug reports. * * @param array $services Service classes, keyed by service id. * @param Plugin $plugin The plugin instance. */ return (array) apply_filters( 'helpora_services', $services, $this ); } /** * Bring an install forward when the plugin files change without activation. * * Updating over FTP, or via a host's auto-updater, never fires the * activation hook — so the install routine also has to run whenever the * stored version no longer matches the code. */ public function maybe_upgrade(): void { if ( HELPORA_VERSION === get_option( Options::VERSION_OPTION ) && Database::is_current() ) { return; } Activator::install(); } /** * Rebuild rewrite rules once, after a flush was requested. */ public function maybe_flush_rewrites(): void { if ( ! get_option( 'helpora_flush_rewrites' ) ) { return; } delete_option( 'helpora_flush_rewrites' ); flush_rewrite_rules( false ); } }