From cd4de693b379fe644b8fe566fac67729db597669 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 21 Aug 2026 09:26:10 -0400 Subject: [PATCH] Say why a network-active standalone was left active --- CLAUDE.md | 7 ++-- docs/filters.md | 1 + docs/notices.md | 20 +++++++--- src/Notices/Contracts/Writer_Interface.php | 17 +++++++++ src/Notices/Renderer.php | 5 ++- src/Notices/Writer.php | 27 +++++++++++++ src/Sub_Plugin.php | 44 ++++++++++++++++++++++ tests/_support/Spy_Writer.php | 16 ++++++++ tests/unit/Notices/RendererTest.php | 1 + tests/unit/Notices/WriterTest.php | 25 ++++++++++++ tests/unit/SubPluginTest.php | 29 ++++++++++++++ 11 files changed, 182 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c3e58a..1eaace3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. | | `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation-error screen for a registered standalone), `Contracts\Resolver_Interface`. | | `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing). Cross-cutting only: a trait used by one folder lives in that folder. | -| `src/Notices/` | `Writer` (what a notice says, stored under `slug:type`), `Presenter` (who may consume it, render-then-clear), `Store` (keeps it), `Renderer` (draws it), `Contracts\Writer_Interface`. | +| `src/Notices/` | `Writer` (what a notice says, stored under `slug:type` — `merge`, `conflict`, `stranding`, `dependency`), `Presenter` (who may consume it, render-then-clear), `Store` (keeps it), `Renderer` (draws it, `notice-error` for `dependency` and `notice-warning` for the rest), `Contracts\Writer_Interface`. | | `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Activator_Interface`, `Config_Exception`. | ### Boot lifecycle @@ -391,8 +391,9 @@ runnable inline as well as wirable. - Filters: `{$hook_prefix}/plugin_absorber/should_load` (`Loader`), `{$hook_prefix}/plugin_absorber/conflict_policy`, - `{$hook_prefix}/plugin_absorber/conflict_notice_message` and - `{$hook_prefix}/plugin_absorber/dependency_notice_message` (all three `Sub_Plugin`) + `{$hook_prefix}/plugin_absorber/conflict_notice_message`, + `{$hook_prefix}/plugin_absorber/dependency_notice_message` and + `{$hook_prefix}/plugin_absorber/stranding_notice_message` (all four `Sub_Plugin`) - Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`), `{$option_prefix}_plugin_absorber_notices` (`Notices\Store`) diff --git a/docs/filters.md b/docs/filters.md index 403b21e..a748871 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -7,6 +7,7 @@ | `{prefix}/plugin_absorber/conflict_policy` | `string $policy`, `Sub_Plugin $sub_plugin` | Final say over the conflict policy. | | `{prefix}/plugin_absorber/conflict_notice_message` | `string $message`, `Sub_Plugin $sub_plugin` | Final say over the conflict notice text. Receives the configured message, or the caller's fallback when nothing is configured. | | `{prefix}/plugin_absorber/dependency_notice_message` | `string $message`, `Sub_Plugin $sub_plugin` | Final say over the dependency notice text. Receives the configured message, or the generic default sentence when nothing is configured. | +| `{prefix}/plugin_absorber/stranding_notice_message` | `string $message`, `Sub_Plugin $sub_plugin` | Final say over the multisite stranding notice text. Receives the generic default; this notice has no config key, so the filter is its only override. | Each runs last, after the configured value and any fallback, and fires when the value is asked for rather than when the sub-plugin is registered — so it is also the place to call `__()`. A diff --git a/docs/notices.md b/docs/notices.md index 97ca05a..348bd62 100644 --- a/docs/notices.md +++ b/docs/notices.md @@ -1,7 +1,8 @@ # Notices -The three notices this library raises — the standalone was deactivated, the standalone is still -active, a dependency check failed — are queued in a single option named +The notices this library raises — the standalone was deactivated, the standalone is still active, a +network-active standalone was left active to avoid stranding sites, a dependency check failed — are +queued in a single option named `{option_prefix}_plugin_absorber_notices`, where `{option_prefix}` is the hook prefix lowercased with hyphens folded to underscores: a hook prefix of `Give-Core` stores `give_core_plugin_absorber_notices`. On multisite it is a **network** option, so the queue is shared @@ -29,13 +30,22 @@ the [activation-error screen](conflict-handling.md#reactivating-the-standalone) try to re-activate it. Write one sentence that reads sensibly both as a report of something already done and as the explanation standing in for a fatal-error warning. +## The stranding notice + +On multisite only, a network-active standalone whose bundled copy ships in a host plugin that is not +itself network-activated is left active rather than deactivated: turning it off across the network +would remove it from the sites the host is not active on, where nothing loads the bundled copy. This +notice explains that, and — unlike the one-time deactivation notice — it recurs until the topology is +resolved, either by network-activating the host or by removing the standalone from the Network Admin. +Its text is the `stranding_notice_message` [filter](filters.md); there is no config key for it. + ## Rendering them yourself `Absorber::notices()->option_name()` tells you where the queue is kept, so you can render it yourself without replacing anything. The value is an `array` keyed `slug:type`, where -the type is `merge`, `conflict` or `dependency` — `give-recurring:merge`, for example. The first -two render as `notice-warning` and the third as `notice-error`, since a dependency notice reports a -plugin that did not load at all. The messages may contain markup; the built-in rendering passes +the type is `merge`, `conflict`, `stranding` or `dependency` — `give-recurring:merge`, for example. +The first three render as `notice-warning` and the last as `notice-error`, since a dependency notice +reports a plugin that did not load at all. The messages may contain markup; the built-in rendering passes them through `wp_kses_post()`, so a link, emphasis or a list survives while scripts and event handlers are stripped. Paragraphs come from `wpautop()`, so send the message unwrapped and let a blank line break it — a `

` of your own is left as it is rather than nested inside another. diff --git a/src/Notices/Contracts/Writer_Interface.php b/src/Notices/Contracts/Writer_Interface.php index b0fd92c..4a6009d 100644 --- a/src/Notices/Contracts/Writer_Interface.php +++ b/src/Notices/Contracts/Writer_Interface.php @@ -55,6 +55,23 @@ public function queue_merge_notice( Sub_Plugin $sub_plugin ): void; */ public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void; + /** + * Queue the "we left the standalone active to avoid stranding sites" notice. + * + * Raised in one topology only: on multisite, a network-active standalone whose host plugin is not + * network-activated, where deactivating it network-wide would remove it from the sites the host + * never reached. Its wording must not tell the user to deactivate the standalone. + * + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin concerned. + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return void + */ + public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void; + /** * Queue the "requirements not met" notice. * diff --git a/src/Notices/Renderer.php b/src/Notices/Renderer.php index a31ab98..cb14651 100644 --- a/src/Notices/Renderer.php +++ b/src/Notices/Renderer.php @@ -24,8 +24,8 @@ class Renderer { * The `notice-*` class each notice type renders with. * * A dependency notice reports a plugin that did not load at all, which is `notice-error` by - * WordPress convention. The other two report a conflict the library has already handled — the - * site works, so they are warnings. + * WordPress convention. The other three report a conflict the library has already handled or held + * off — the site works, so they are warnings. * * @since 1.0.0 * @@ -34,6 +34,7 @@ class Renderer { private const CLASSES = [ Writer::TYPE_MERGE => 'notice-warning', Writer::TYPE_CONFLICT => 'notice-warning', + Writer::TYPE_STRANDING => 'notice-warning', Writer::TYPE_DEPENDENCY => 'notice-error', ]; diff --git a/src/Notices/Writer.php b/src/Notices/Writer.php index 6b3edd5..1b5dfd2 100644 --- a/src/Notices/Writer.php +++ b/src/Notices/Writer.php @@ -56,6 +56,13 @@ class Writer implements Writer_Interface { */ public const TYPE_DEPENDENCY = 'dependency'; + /** + * @since 1.0.0 + * + * @var string + */ + public const TYPE_STRANDING = 'stranding'; + /** * @since 1.0.0 * @@ -119,6 +126,26 @@ public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void { ); } + /** + * The "we left the standalone active to avoid stranding sites" notice. + * + * Its own type and its own default because it must not carry the conflict notice's "you can + * safely deactivate the standalone": on the topology it fires for -- a network-active standalone + * whose host is not network-activated -- a network-wide deactivation is the very thing that would + * strand the sites the host never reached. The wording lives on `Sub_Plugin` with the others. + * + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin concerned. + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return void + */ + public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void { + $this->queue( $sub_plugin, self::TYPE_STRANDING, $sub_plugin->get_stranding_notice_message() ); + } + /** * @since 1.0.0 * diff --git a/src/Sub_Plugin.php b/src/Sub_Plugin.php index 8858a21..19a7469 100644 --- a/src/Sub_Plugin.php +++ b/src/Sub_Plugin.php @@ -429,6 +429,50 @@ public function get_dependency_notice_message(): string { return $this->as_string( $message ); } + /** + * Shown on multisite when a network-active standalone is left active because the host plugin is + * not itself network-activated -- deactivating it network-wide would strand the sites the host + * never reached. Self-contained, with no config key: the text has no per-host variant worth a + * registration-time value, and the filter below is the seam for rewording or translating it. + * + * @since 1.0.0 + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return string + */ + public function get_stranding_notice_message(): string { + $message = sprintf( + '%1$s was left active. Its bundled copy loads only where the host plugin is active, and ' + . 'the host plugin is not network-activated, so deactivating %1$s across the network would ' + . 'leave the sites without the host plugin with no copy of it at all. To finish absorbing ' + . "it, either network-activate the host plugin, or deactivate %1\$s yourself from the " + . "Network Admin's Plugins screen.", + $this->get_slug() + ); + + /** + * Filters the notice shown when a network-active standalone is left active to avoid stranding + * the sites a host that is not network-activated does not reach. + * + * The dynamic portion of the hook name, `$hook_prefix`, is the prefix given to + * Config::set_hook_prefix(). + * + * Fires when the message is asked for rather than when the sub-plugin is registered, which is + * what makes this the place to translate the default: the textdomain is loaded by then. + * + * @since 1.0.0 + * + * @param string $message The generic, untranslated default. + * @param Sub_Plugin $sub_plugin The sub-plugin left active. + */ + $message = apply_filters( Config::get_hook_name( 'stranding_notice_message' ), $message, $this ); + + // An empty string renders no notice, which is where a filter returning an array or an + // object lands rather than in a fatal cast. + return $this->as_string( $message ); + } + /** * @since 1.0.0 * diff --git a/tests/_support/Spy_Writer.php b/tests/_support/Spy_Writer.php index 57a0d34..85a06fc 100644 --- a/tests/_support/Spy_Writer.php +++ b/tests/_support/Spy_Writer.php @@ -54,6 +54,13 @@ class Spy_Writer implements Writer_Interface { */ public $dependency_notices = []; + /** + * Slugs handed to queue_stranding_notice(), in order. + * + * @var string[] + */ + public $stranding_notices = []; + /** * @param Sub_Plugin $sub_plugin Sub-plugin concerned. * @@ -81,6 +88,15 @@ public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void { $this->dependency_notices[] = $sub_plugin->get_slug(); } + /** + * @param Sub_Plugin $sub_plugin Sub-plugin concerned. + * + * @return void + */ + public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void { + $this->stranding_notices[] = $sub_plugin->get_slug(); + } + /** * @return string */ diff --git a/tests/unit/Notices/RendererTest.php b/tests/unit/Notices/RendererTest.php index 04f69a7..b7f4419 100644 --- a/tests/unit/Notices/RendererTest.php +++ b/tests/unit/Notices/RendererTest.php @@ -52,6 +52,7 @@ public function test_the_type_half_of_the_key_picks_the_severity( string $key, s public static function notice_severities(): Generator { yield 'merge' => [ 'give-recurring:' . Writer::TYPE_MERGE, 'notice-warning' ]; yield 'conflict' => [ 'give-recurring:' . Writer::TYPE_CONFLICT, 'notice-warning' ]; + yield 'stranding' => [ 'give-recurring:' . Writer::TYPE_STRANDING, 'notice-warning' ]; yield 'dependency' => [ 'give-recurring:' . Writer::TYPE_DEPENDENCY, 'notice-error' ]; yield 'unknown type' => [ 'give-recurring:invented', 'notice-warning' ]; yield 'no type at all' => [ 'give-recurring', 'notice-warning' ]; diff --git a/tests/unit/Notices/WriterTest.php b/tests/unit/Notices/WriterTest.php index 73ee61d..f7e99d3 100644 --- a/tests/unit/Notices/WriterTest.php +++ b/tests/unit/Notices/WriterTest.php @@ -144,6 +144,16 @@ public static function queued_notices(): Generator { 'give-recurring could not be loaded because its requirements are not met.', true, ]; + + // The stranding notice has no config key -- the filter is its only override -- so only its + // fallback is exercised here, the way it lands under its own slug:type key. + yield 'stranding, fallback' => [ + 'queue_stranding_notice', + [], + 'give-recurring:stranding', + 'give-recurring', + false, + ]; } /** @@ -175,6 +185,21 @@ public function test_a_configured_message_is_used_for_both_conflict_types(): voi $this->assertSame( 'Ours.', $queue['give-recurring:conflict'] ); } + /** + * The stranding notice is the one conflict-flavoured notice that must not tell the user to + * deactivate the standalone: on the topology it fires for, a network-wide deactivation is exactly + * what would strand the sites the host never reached. It borrows nothing from the conflict + * notice's "you can safely deactivate the standalone" wording. + */ + public function test_the_stranding_notice_does_not_say_the_standalone_is_safe_to_deactivate(): void { + $this->make_writer()->queue_stranding_notice( $this->make_sub_plugin() ); + + $message = $this->queue()['give-recurring:stranding'] ?? ''; + + $this->assertStringContainsString( 'give-recurring', $message ); + $this->assertStringNotContainsStringIgnoringCase( 'safely deactivate', $message ); + } + public function test_queueing_the_same_slug_and_type_twice_does_not_duplicate(): void { $writer = $this->make_writer(); $writer->queue_merge_notice( $this->make_sub_plugin() ); diff --git a/tests/unit/SubPluginTest.php b/tests/unit/SubPluginTest.php index 67a07f3..7441874 100644 --- a/tests/unit/SubPluginTest.php +++ b/tests/unit/SubPluginTest.php @@ -839,6 +839,7 @@ public static function filtered_strings(): Generator { yield 'conflict_policy' => [ 'conflict_policy', 'get_conflict_policy' ]; yield 'conflict_notice_message' => [ 'conflict_notice_message', 'get_conflict_notice_message' ]; yield 'dependency_notice_message' => [ 'dependency_notice_message', 'get_dependency_notice_message' ]; + yield 'stranding_notice_message' => [ 'stranding_notice_message', 'get_stranding_notice_message' ]; } public function test_the_conflict_notice_message_defaults_to_empty(): void { @@ -852,6 +853,34 @@ public function test_the_dependency_notice_message_falls_back_to_a_default(): vo ); } + /** + * The stranding notice takes no config key, so its default is self-contained. It has to name the + * sub-plugin and offer both ways out -- network-activate the host, or remove the standalone from + * the Network Admin -- so a superadmin is never left with the misleading "safely deactivate" of + * the ordinary conflict notice. + */ + public function test_the_stranding_notice_message_falls_back_to_a_default_with_both_exits(): void { + $message = $this->make_sub_plugin()->get_stranding_notice_message(); + + $this->assertStringContainsString( 'give-recurring', $message ); + $this->assertStringContainsString( 'network-activate', $message ); + $this->assertStringContainsString( 'Network Admin', $message ); + } + + public function test_the_filter_overrides_the_stranding_notice_message(): void { + add_filter( + 'give/plugin_absorber/stranding_notice_message', + static function () { + return 'Filtered.'; + } + ); + + $this->assertSame( + 'Filtered.', + $this->make_sub_plugin()->get_stranding_notice_message() + ); + } + public function test_the_enabled_callable_receives_the_sub_plugin(): void { $received = null; $sub_plugin = $this->make_sub_plugin(