From 2ef95c991ef97d5253232ecf18e9d473ae4f7187 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Thu, 20 Aug 2026 16:49:50 -0400 Subject: [PATCH 1/2] Let a host give the library its own plugin basename --- CLAUDE.md | 5 ++-- README.md | 4 ++++ docs/configuration.md | 6 +++++ src/Config.php | 41 +++++++++++++++++++++++++++++++++ tests/_support/Config_State.php | 5 ++-- tests/unit/ConfigTest.php | 31 +++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d448b79..96b48be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,8 @@ declarations). ### Public surface -A two-class static facade — `Config` (hook prefix + container) and `Absorber` (register/boot, plus +A two-class static facade — `Config` (hook prefix, container, and optionally the host plugin +basename) and `Absorber` (register/boot, plus accessors) — matching the shape of `stellarwp/assets` and `stellarwp/admin-notices`. Everything else is an implementation detail behind it. `Absorber` is `final`: every member is private static and every internal call is `self::`, so a subclass could @@ -187,7 +188,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | Path | What | |---|---| -| `src/Config.php` | Static facade: hook prefix + container. | +| `src/Config.php` | Static facade: hook prefix, container, and the optional host plugin basename. | | `src/Absorber.php` | Static facade: registration, `boot()`, the accessors, and the two notice trampolines. Holds no collaborator's state. | | `src/Provider.php` | Binds every collaborator; the only file that names a default implementation. | | `src/Boot/Scheduler.php` | Hook wiring and boot timing: the sequence, the priorities, and the fallback for a host that boots too late. | diff --git a/README.md b/README.md index 0b9a0fe..9bdd1b2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ add_action( 'plugins_loaded', function () { The container is required, and any StellarWP `ContainerInterface` implementation will do — the one you already hand to Telemetry or Uplink. +On multisite, optionally add `Config::set_host_plugin_basename( plugin_basename( __FILE__ ) )` so the +library never deactivates a network-active standalone when your plugin is not itself network-activated +— which would leave the network's other sites with no copy of it at all. + **Keep the `, 0`.** Anything below `plugins_loaded` priority 5 wires cleanly, but priority 0 is the recommendation, in the block that owns your container rather than in a service provider. Booting at 5 or later still works and is reported through `_doing_it_wrong()`, with the whole sequence running diff --git a/docs/configuration.md b/docs/configuration.md index 44b0556..4059ee3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,12 @@ hands to Telemetry, Uplink or Harbor. It is required: `Config::get_container()` `Config_Exception` when none is set, and `Config::has_container()` is the probe. To replace one of the library's own pieces, see [Extending](extending.md). +`Config::set_host_plugin_basename( plugin_basename( __FILE__ ) )` is optional and matters only on +multisite. It lets the library compare your plugin's activation scope against a standalone's, so it +declines to deactivate a network-active standalone when your plugin is not itself network-activated +— a deactivation that would otherwise pull the standalone from the network's other sites, where your +bundled copy never loads. Left unset, that guard stays off and deactivation behaves as it always has. + Both calls belong at `plugins_loaded` priority 0, in the block that owns your container rather than in a service provider. Priority matters twice, for unrelated reasons: diff --git a/src/Config.php b/src/Config.php index bfe3fa6..b18edbe 100644 --- a/src/Config.php +++ b/src/Config.php @@ -26,6 +26,11 @@ class Config { */ protected static $container = null; + /** + * @var string + */ + protected static $host_plugin_basename = ''; + /** * Set the unique per-host slug that keys this library's hooks and options. * @@ -160,6 +165,42 @@ public static function has_container(): bool { return self::$container !== null; } + /** + * Tell the library the host plugin's own basename, so a standalone's activation scope can be + * compared against the host's on multisite. + * + * Optional, and read by one thing: the conflict resolver's guard against deactivating a + * network-active standalone whose bundled replacement ships in a host that is not itself + * network-active -- a deactivation that would strand the sites the host never reached. Left + * unset, that guard stands down and deactivation behaves exactly as it always has. Stored + * exactly as given: a basename is a path, `directory/file.php`, not a hook-naming value, so the + * hook-prefix validator's character rules deliberately do not apply here. + * + * @since 1.0.0 + * + * @param string $basename Host plugin basename, e.g. `plugin_basename( __FILE__ )`. + * + * @return void + */ + public static function set_host_plugin_basename( string $basename ): void { + self::$host_plugin_basename = $basename; + } + + /** + * The host plugin basename, or an empty string when none was set. + * + * Does not throw the way `get_hook_prefix()` and `get_container()` do. Those name a step the host + * must take before boot; this is optional, and an empty string is the honest answer for a host + * that did not opt into the multisite guard -- the same value that stands the guard down. + * + * @since 1.0.0 + * + * @return string + */ + public static function get_host_plugin_basename(): string { + return self::$host_plugin_basename; + } + /** * The hook prefix folded into the shape a storage key takes. * diff --git a/tests/_support/Config_State.php b/tests/_support/Config_State.php index a14ce74..5afa2b0 100644 --- a/tests/_support/Config_State.php +++ b/tests/_support/Config_State.php @@ -30,8 +30,9 @@ class Config_State { * @var array */ protected const DEFAULTS = [ - 'hook_prefix' => '', - 'container' => null, + 'hook_prefix' => '', + 'container' => null, + 'host_plugin_basename' => '', ]; /** diff --git a/tests/unit/ConfigTest.php b/tests/unit/ConfigTest.php index 1e92d7b..4928de7 100644 --- a/tests/unit/ConfigTest.php +++ b/tests/unit/ConfigTest.php @@ -189,13 +189,44 @@ public function test_it_stores_and_returns_a_container(): void { $this->assertSame( $container, Config::get_container() ); } + /** + * The host plugin basename is optional -- unset means the multisite stranding guard stays off -- + * so its getter answers an empty string rather than throwing the way the prefix and container do. + */ + public function test_it_reports_no_host_plugin_basename_by_default(): void { + $this->assertSame( '', Config::get_host_plugin_basename() ); + } + + public function test_it_stores_and_returns_a_host_plugin_basename(): void { + Config::set_host_plugin_basename( 'give/give.php' ); + + $this->assertSame( 'give/give.php', Config::get_host_plugin_basename() ); + } + + /** + * A basename is a path -- slashes, a dot, and whatever case the directory was installed under -- + * not a hook-naming value, so it is stored exactly as given. The hook-prefix validator would + * reject every one of these characters, which is why this key does not share it. + */ + public function test_it_keeps_a_host_plugin_basename_verbatim(): void { + Config::set_host_plugin_basename( 'My-Plugin/My_Plugin.php' ); + + $this->assertSame( 'My-Plugin/My_Plugin.php', Config::get_host_plugin_basename() ); + } + public function test_the_state_helper_clears_every_value(): void { Config::set_hook_prefix( 'give' ); Config::set_container( new Test_Container() ); + Config::set_host_plugin_basename( 'give/give.php' ); Config_State::reset(); $this->assertFalse( Config::has_container() ); + $this->assertSame( + '', + Config::get_host_plugin_basename(), + 'The state helper has to clear the host plugin basename too.' + ); foreach ( [ 'get_container', 'get_hook_prefix' ] as $accessor ) { try { From f97357dc72e039ac84abf21a848c8be0496649d1 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 21 Aug 2026 10:44:06 -0400 Subject: [PATCH 2/2] Frame the host basename note by multisite support, not the current site --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9bdd1b2..f99b0a5 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ add_action( 'plugins_loaded', function () { The container is required, and any StellarWP `ContainerInterface` implementation will do — the one you already hand to Telemetry or Uplink. -On multisite, optionally add `Config::set_host_plugin_basename( plugin_basename( __FILE__ ) )` so the -library never deactivates a network-active standalone when your plugin is not itself network-activated -— which would leave the network's other sites with no copy of it at all. +If your plugin can run on multisite, add +`Config::set_host_plugin_basename( plugin_basename( __FILE__ ) )`. It is a no-op off a network, so set +it unconditionally: where it matters is the one topology the library must not deactivate a standalone +in — a network-active standalone whose host plugin is not itself network-activated, where a +network-wide deactivation would leave the network's other sites with no copy of it at all. **Keep the `, 0`.** Anything below `plugins_loaded` priority 5 wires cleanly, but priority 0 is the recommendation, in the block that owns your container rather than in a service provider. Booting at 5