withProviders( [ ... ] ) // optional, additive * ->boot(); // schedules plugins_loaded handler * * on plugins_loaded: * 1. Run EnvironmentChecker::check(). * 2. If failing: register an admin_notices renderer describing every * failure and return early. No providers are registered, no hooks * beyond the notice are wired, no fatal is raised. * 3. If passing: ServiceProviderRegistry::registerAll() then * bootAll(), then fire the `affilun_loaded` action so external * integrations can hook in after the plugin is fully wired. * * This class contains no feature-specific logic — that lives in the * service providers it orchestrates. Its responsibility is strictly * "start the plugin, or fail safely". * * @package Affilun\Bootstrap */ declare(strict_types=1); namespace Affilun\Bootstrap; /** * Top-level plugin bootstrap. * * Intentionally not final so that tests can subclass to observe internal * transitions without having to exercise the full WordPress lifecycle. * Production code should treat it as effectively final and always * instantiate the base class directly. */ class Plugin { /** * Absolute path to the plugin main file. * * Used as the canonical identity for activation / deactivation hooks * and for resolving plugin-relative paths. Stored once at construction * time so downstream code never has to guess. */ private string $pluginFile; /** * Environment checker used to gate bootstrap. */ private EnvironmentChecker $environmentChecker; /** * Service provider registry driving the two-phase bootstrap. */ private ServiceProviderRegistry $registry; /** * Hook registrar shared with every provider's boot() method. */ private HookRegistrar $hooks; /** * Whether {@see self::boot()} has already been invoked. * * Guards against accidental double-bootstrapping, which would cause * hooks to be registered twice and break idempotency. */ private bool $booted = false; /** * Last environment check result, populated on the plugins_loaded * handler. Exposed for tests and diagnostics; production code does * not need to read it. */ private ?EnvironmentCheckResult $lastEnvironmentCheckResult = null; /** * Construct the bootstrap. * * Collaborators default to fresh instances so that the common case * (`new Plugin( AFFILUN_PLUGIN_FILE )`) is ergonomic. Tests may inject * synthetic collaborators to observe behavior without touching * WordPress globals. * * @param string $pluginFile Absolute path to the plugin main file. * @param EnvironmentChecker|null $environmentChecker Override the environment checker. * @param ServiceProviderRegistry|null $registry Override the provider registry. * @param HookRegistrar|null $hooks Override the hook registrar. */ public function __construct( string $pluginFile, ?EnvironmentChecker $environmentChecker = null, ?ServiceProviderRegistry $registry = null, ?HookRegistrar $hooks = null ) { $this->pluginFile = $pluginFile; $this->environmentChecker = $environmentChecker ?? new EnvironmentChecker(); $this->registry = $registry ?? new ServiceProviderRegistry(); $this->hooks = $hooks ?? new HookRegistrar(); } /** * Absolute path to the plugin main file as supplied at construction. */ public function pluginFile(): string { return $this->pluginFile; } /** * Service provider registry owned by this plugin instance. * * Exposed so that the plugin entry file or tests can add providers * without having to thread the registry through other collaborators. */ public function registry(): ServiceProviderRegistry { return $this->registry; } /** * Hook registrar owned by this plugin instance. * * Providers receive this registrar in their boot() method; it is * exposed here primarily for tests and diagnostics. */ public function hooks(): HookRegistrar { return $this->hooks; } /** * Environment checker owned by this plugin instance. */ public function environmentChecker(): EnvironmentChecker { return $this->environmentChecker; } /** * Result of the most recent environment check, or null when boot() * has not yet been driven through its plugins_loaded handler. */ public function lastEnvironmentCheckResult(): ?EnvironmentCheckResult { return $this->lastEnvironmentCheckResult; } /** * Report whether {@see self::boot()} has already been invoked. */ public function isBooted(): bool { return $this->booted; } /** * Add one or more service providers to the registry. * * Sugar over {@see ServiceProviderRegistry::add()} that returns $this * so the plugin entry file can chain the call. * * @param list $providers Providers to append, in order. * * @return static */ public function withProviders( array $providers ): self { foreach ( $providers as $provider ) { $this->registry->add( $provider ); } return $this; } /** * Schedule the bootstrap pipeline on `plugins_loaded`. * * Safe to call exactly once per instance. Repeat calls are no-ops so * that accidental double bootstrapping does not double-register the * plugins_loaded handler. * * When WordPress is not loaded (pure-unit test context), this still * records the intent to register via {@see HookRegistrar} so tests * can assert the registration happened, and it is safe to call * {@see self::runBootstrap()} manually to exercise the pipeline. */ public function boot(): void { if ( $this->booted ) { return; } $this->booted = true; $this->hooks->addAction( 'plugins_loaded', function (): void { $this->runBootstrap(); } ); } /** * Execute the bootstrap pipeline synchronously. * * This is the function scheduled on `plugins_loaded` by {@see self::boot()}. * It is also callable directly from tests and from code paths that * need to drive bootstrap outside of the normal WordPress lifecycle * (for example a diagnostics CLI command). * * Behavior: * * - Run the environment check. If it fails, register an admin * notice renderer describing every failure and return. No * providers are registered, no `affilun_loaded` action is fired. * * - If the environment passes, call registerAll() then bootAll() * on the registry, then fire the `affilun_loaded` action so that * external integrations can reliably hook in after the plugin * is fully wired. */ public function runBootstrap(): void { $result = $this->environmentChecker->check(); $this->lastEnvironmentCheckResult = $result; if ( $result->hasErrors() ) { $this->registerEnvironmentFailureNotice( $result ); return; } $this->registry->registerAll(); $this->registry->bootAll( $this->hooks ); if ( function_exists( 'do_action' ) ) { /** * Fires after affilun has finished its bootstrap * sequence and every service provider has been booted. * * Integrators SHOULD hook here rather than relying on file * load order or on `plugins_loaded` directly, because this * action is only fired once the environment check has passed * and the provider pipeline has completed. * * @since 0.1.0 */ do_action( 'affilun_loaded', $this ); } } /** * Activation entry point. * * Exposed as an instance method so that the plugin entry file can * register it via register_activation_hook() without reaching into * a static method. * * Behavior: * * - Run the environment check. If it fails, abort activation via * wp_die() so the operator sees the reason in the admin UI * rather than ending up with a half-activated plugin. No * activation-time side effects are performed. * * - If the environment passes, fire the `affilun_activate` action so * that service providers (notably the persistence provider, * which runs the schema migration pipeline) can perform their * activation-time setup without needing a new lifecycle method * on the {@see ServiceProvider} contract. The plugin instance * is passed as the first argument so handlers can reach the * registry / bindings installed during register(). * * Providers that want to hook activation MUST subscribe to * `affilun_activate` from their boot() method so that the hook is * wired by the time register_activation_hook() fires the callback * — WordPress loads the plugin main file (which calls boot()) on * the activation request just like on any other request. * * Note that wp_die() normally terminates the request in production, * but in test contexts the stub merely records the call and returns, * so the method explicitly returns after wp_die() to guarantee the * `affilun_activate` action is never dispatched on a failing environment. */ public function onActivate(): void { $result = $this->environmentChecker->check(); if ( $result->hasErrors() ) { if ( function_exists( 'wp_die' ) ) { wp_die( esc_html( implode( "\n", $result->messages() ) ), esc_html__( 'affilun の有効化を中止しました', 'affilun' ), array( 'back_link' => true ) ); } return; } /* * Eagerly drive the bootstrap pipeline before dispatching * `affilun_activate`. * * Rationale: WordPress runs `register_activation_hook()` * callbacks from inside `activate_plugin()`, which loads the * plugin file via `plugin_sandbox_scrape()` — a fresh include * that does NOT fire `plugins_loaded` for the sandboxed scope. * That means the `plugins_loaded` handler scheduled by * {@see self::boot()} never runs on the activation request, * so {@see ServiceProviderRegistry::registerAll()} and * {@see ServiceProviderRegistry::bootAll()} have not been * called yet, and no provider has subscribed to * `affilun_activate`. The persistence provider's migration * runner would fire into the void and the host site would be * left with an "active" plugin and no `affilun_*` tables. * * `runBootstrap()` is idempotent on repeat calls because it * re-runs the environment check (already passed above) and * the registry's `registerAll()` / `bootAll()` themselves * guard against double-registration. After this call returns * the persistence provider's `affilun_activate` listener is * wired and the migration pipeline will run when we dispatch * the action below. */ $this->runBootstrap(); if ( function_exists( 'do_action' ) ) { /** * Fires during plugin activation, after the environment * check has passed and before WordPress finalizes the * activation response. * * Service providers that need to perform activation-time * setup (schema migrations, default option seeding, cron * scheduling) SHOULD hook here from their boot() method. * The plugin main file is loaded on the activation request * just like on any other request, so boot() has already * wired the provider's hooks by the time this action fires. * * @since 0.1.0 * * @param Plugin $plugin The plugin bootstrap instance, so * handlers can reach the registry * and its bindings. */ do_action( 'affilun_activate', $this ); } } /** * Deactivation entry point. * * Currently a no-op. Real cleanup (flushing rewrite rules, * unscheduling cron events, clearing caches) will be added in * later slices alongside the subsystems that introduce those side * effects. */ public function onDeactivate(): void { // Intentionally empty. Populated in later slices. } /** * Register an admin notice renderer describing every environment * check failure. * * The renderer is wired through {@see HookRegistrar} so that tests * can observe the registration, and so that the diagnostics surface * can enumerate every hook the plugin has attached. * * @param EnvironmentCheckResult $result Failing environment check result. */ private function registerEnvironmentFailureNotice( EnvironmentCheckResult $result ): void { $messages = $result->messages(); $this->hooks->addAction( 'admin_notices', static function () use ( $messages ): void { if ( function_exists( 'current_user_can' ) && ! current_user_can( 'activate_plugins' ) ) { return; } $escape = static function ( string $text ): string { if ( function_exists( 'esc_html' ) ) { return (string) esc_html( $text ); } return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }; $heading = function_exists( 'esc_html__' ) ? (string) esc_html__( 'affilun は停止中です:', 'affilun' ) : $escape( 'affilun は停止中です:' ); echo '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above. echo $heading; echo '

'; } ); } }