Skip to content

Commit a612500

Browse files
committed
Say why a network-active standalone was left active
1 parent 481ea2d commit a612500

11 files changed

Lines changed: 182 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`.
200200
| `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. |
201201
| `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`. |
202202
| `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. |
203-
| `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`. |
203+
| `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`. |
204204
| `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Activator_Interface`, `Config_Exception`. |
205205

206206
### Boot lifecycle
@@ -391,8 +391,9 @@ runnable inline as well as wirable.
391391

392392
- Filters: `{$hook_prefix}/plugin_absorber/should_load` (`Loader`),
393393
`{$hook_prefix}/plugin_absorber/conflict_policy`,
394-
`{$hook_prefix}/plugin_absorber/conflict_notice_message` and
395-
`{$hook_prefix}/plugin_absorber/dependency_notice_message` (all three `Sub_Plugin`)
394+
`{$hook_prefix}/plugin_absorber/conflict_notice_message`,
395+
`{$hook_prefix}/plugin_absorber/dependency_notice_message` and
396+
`{$hook_prefix}/plugin_absorber/stranding_notice_message` (all four `Sub_Plugin`)
396397
- Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`),
397398
`{$option_prefix}_plugin_absorber_notices` (`Notices\Store`)
398399

docs/filters.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
| `{prefix}/plugin_absorber/conflict_policy` | `string $policy`, `Sub_Plugin $sub_plugin` | Final say over the conflict policy. |
88
| `{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. |
99
| `{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. |
10+
| `{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. |
1011

1112
Each runs last, after the configured value and any fallback, and fires when the value is asked for
1213
rather than when the sub-plugin is registered — so it is also the place to call `__()`. A

docs/notices.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Notices
22

3-
The three notices this library raises — the standalone was deactivated, the standalone is still
4-
active, a dependency check failed — are queued in a single option named
3+
The notices this library raises — the standalone was deactivated, the standalone is still active, a
4+
network-active standalone was left active to avoid stranding sites, a dependency check failed — are
5+
queued in a single option named
56
`{option_prefix}_plugin_absorber_notices`, where `{option_prefix}` is the hook prefix lowercased
67
with hyphens folded to underscores: a hook prefix of `Give-Core` stores
78
`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)
2930
try to re-activate it. Write one sentence that reads sensibly both as a report of something already
3031
done and as the explanation standing in for a fatal-error warning.
3132

33+
## The stranding notice
34+
35+
On multisite only, a network-active standalone whose bundled copy ships in a host plugin that is not
36+
itself network-activated is left active rather than deactivated: turning it off across the network
37+
would remove it from the sites the host is not active on, where nothing loads the bundled copy. This
38+
notice explains that, and — unlike the one-time deactivation notice — it recurs until the topology is
39+
resolved, either by network-activating the host or by removing the standalone from the Network Admin.
40+
Its text is the `stranding_notice_message` [filter](filters.md); there is no config key for it.
41+
3242
## Rendering them yourself
3343

3444
`Absorber::notices()->option_name()` tells you where the queue is kept, so you can render it
3545
yourself without replacing anything. The value is an `array<string,string>` keyed `slug:type`, where
36-
the type is `merge`, `conflict` or `dependency``give-recurring:merge`, for example. The first
37-
two render as `notice-warning` and the third as `notice-error`, since a dependency notice reports a
38-
plugin that did not load at all. The messages may contain markup; the built-in rendering passes
46+
the type is `merge`, `conflict`, `stranding` or `dependency``give-recurring:merge`, for example.
47+
The first three render as `notice-warning` and the last as `notice-error`, since a dependency notice
48+
reports a plugin that did not load at all. The messages may contain markup; the built-in rendering passes
3949
them through `wp_kses_post()`, so a link, emphasis or a list survives while scripts and event
4050
handlers are stripped. Paragraphs come from `wpautop()`, so send the message unwrapped and let a
4151
blank line break it — a `<p>` of your own is left as it is rather than nested inside another.

src/Notices/Contracts/Writer_Interface.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ public function queue_merge_notice( Sub_Plugin $sub_plugin ): void;
5555
*/
5656
public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void;
5757

58+
/**
59+
* Queue the "we left the standalone active to avoid stranding sites" notice.
60+
*
61+
* Raised in one topology only: on multisite, a network-active standalone whose host plugin is not
62+
* network-activated, where deactivating it network-wide would remove it from the sites the host
63+
* never reached. Its wording must not tell the user to deactivate the standalone.
64+
*
65+
* @since 1.0.0
66+
*
67+
* @param Sub_Plugin $sub_plugin Sub-plugin concerned.
68+
*
69+
* @throws Config_Exception When no hook prefix has been set.
70+
*
71+
* @return void
72+
*/
73+
public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void;
74+
5875
/**
5976
* Queue the "requirements not met" notice.
6077
*

src/Notices/Renderer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ class Renderer {
2424
* The `notice-*` class each notice type renders with.
2525
*
2626
* A dependency notice reports a plugin that did not load at all, which is `notice-error` by
27-
* WordPress convention. The other two report a conflict the library has already handled — the
28-
* site works, so they are warnings.
27+
* WordPress convention. The other three report a conflict the library has already handled or held
28+
* off — the site works, so they are warnings.
2929
*
3030
* @since 1.0.0
3131
*
@@ -34,6 +34,7 @@ class Renderer {
3434
private const CLASSES = [
3535
Writer::TYPE_MERGE => 'notice-warning',
3636
Writer::TYPE_CONFLICT => 'notice-warning',
37+
Writer::TYPE_STRANDING => 'notice-warning',
3738
Writer::TYPE_DEPENDENCY => 'notice-error',
3839
];
3940

src/Notices/Writer.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ class Writer implements Writer_Interface {
5656
*/
5757
public const TYPE_DEPENDENCY = 'dependency';
5858

59+
/**
60+
* @since 1.0.0
61+
*
62+
* @var string
63+
*/
64+
public const TYPE_STRANDING = 'stranding';
65+
5966
/**
6067
* @since 1.0.0
6168
*
@@ -119,6 +126,26 @@ public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void {
119126
);
120127
}
121128

129+
/**
130+
* The "we left the standalone active to avoid stranding sites" notice.
131+
*
132+
* Its own type and its own default because it must not carry the conflict notice's "you can
133+
* safely deactivate the standalone": on the topology it fires for -- a network-active standalone
134+
* whose host is not network-activated -- a network-wide deactivation is the very thing that would
135+
* strand the sites the host never reached. The wording lives on `Sub_Plugin` with the others.
136+
*
137+
* @since 1.0.0
138+
*
139+
* @param Sub_Plugin $sub_plugin Sub-plugin concerned.
140+
*
141+
* @throws Config_Exception When no hook prefix has been set.
142+
*
143+
* @return void
144+
*/
145+
public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void {
146+
$this->queue( $sub_plugin, self::TYPE_STRANDING, $sub_plugin->get_stranding_notice_message() );
147+
}
148+
122149
/**
123150
* @since 1.0.0
124151
*

src/Sub_Plugin.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,50 @@ public function get_dependency_notice_message(): string {
429429
return $this->as_string( $message );
430430
}
431431

432+
/**
433+
* Shown on multisite when a network-active standalone is left active because the host plugin is
434+
* not itself network-activated -- deactivating it network-wide would strand the sites the host
435+
* never reached. Self-contained, with no config key: the text has no per-host variant worth a
436+
* registration-time value, and the filter below is the seam for rewording or translating it.
437+
*
438+
* @since 1.0.0
439+
*
440+
* @throws Config_Exception When no hook prefix has been set.
441+
*
442+
* @return string
443+
*/
444+
public function get_stranding_notice_message(): string {
445+
$message = sprintf(
446+
'%1$s was left active. Its bundled copy loads only where the host plugin is active, and '
447+
. 'the host plugin is not network-activated, so deactivating %1$s across the network would '
448+
. 'leave the sites without the host plugin with no copy of it at all. To finish absorbing '
449+
. "it, either network-activate the host plugin, or deactivate %1\$s yourself from the "
450+
. "Network Admin's Plugins screen.",
451+
$this->get_slug()
452+
);
453+
454+
/**
455+
* Filters the notice shown when a network-active standalone is left active to avoid stranding
456+
* the sites a host that is not network-activated does not reach.
457+
*
458+
* The dynamic portion of the hook name, `$hook_prefix`, is the prefix given to
459+
* Config::set_hook_prefix().
460+
*
461+
* Fires when the message is asked for rather than when the sub-plugin is registered, which is
462+
* what makes this the place to translate the default: the textdomain is loaded by then.
463+
*
464+
* @since 1.0.0
465+
*
466+
* @param string $message The generic, untranslated default.
467+
* @param Sub_Plugin $sub_plugin The sub-plugin left active.
468+
*/
469+
$message = apply_filters( Config::get_hook_name( 'stranding_notice_message' ), $message, $this );
470+
471+
// An empty string renders no notice, which is where a filter returning an array or an
472+
// object lands rather than in a fatal cast.
473+
return $this->as_string( $message );
474+
}
475+
432476
/**
433477
* @since 1.0.0
434478
*

tests/_support/Spy_Writer.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ class Spy_Writer implements Writer_Interface {
5454
*/
5555
public $dependency_notices = [];
5656

57+
/**
58+
* Slugs handed to queue_stranding_notice(), in order.
59+
*
60+
* @var string[]
61+
*/
62+
public $stranding_notices = [];
63+
5764
/**
5865
* @param Sub_Plugin $sub_plugin Sub-plugin concerned.
5966
*
@@ -81,6 +88,15 @@ public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void {
8188
$this->dependency_notices[] = $sub_plugin->get_slug();
8289
}
8390

91+
/**
92+
* @param Sub_Plugin $sub_plugin Sub-plugin concerned.
93+
*
94+
* @return void
95+
*/
96+
public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void {
97+
$this->stranding_notices[] = $sub_plugin->get_slug();
98+
}
99+
84100
/**
85101
* @return string
86102
*/

tests/unit/Notices/RendererTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public function test_the_type_half_of_the_key_picks_the_severity( string $key, s
5252
public static function notice_severities(): Generator {
5353
yield 'merge' => [ 'give-recurring:' . Writer::TYPE_MERGE, 'notice-warning' ];
5454
yield 'conflict' => [ 'give-recurring:' . Writer::TYPE_CONFLICT, 'notice-warning' ];
55+
yield 'stranding' => [ 'give-recurring:' . Writer::TYPE_STRANDING, 'notice-warning' ];
5556
yield 'dependency' => [ 'give-recurring:' . Writer::TYPE_DEPENDENCY, 'notice-error' ];
5657
yield 'unknown type' => [ 'give-recurring:invented', 'notice-warning' ];
5758
yield 'no type at all' => [ 'give-recurring', 'notice-warning' ];

tests/unit/Notices/WriterTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,16 @@ public static function queued_notices(): Generator {
144144
'give-recurring could not be loaded because its requirements are not met.',
145145
true,
146146
];
147+
148+
// The stranding notice has no config key -- the filter is its only override -- so only its
149+
// fallback is exercised here, the way it lands under its own slug:type key.
150+
yield 'stranding, fallback' => [
151+
'queue_stranding_notice',
152+
[],
153+
'give-recurring:stranding',
154+
'give-recurring',
155+
false,
156+
];
147157
}
148158

149159
/**
@@ -175,6 +185,21 @@ public function test_a_configured_message_is_used_for_both_conflict_types(): voi
175185
$this->assertSame( 'Ours.', $queue['give-recurring:conflict'] );
176186
}
177187

188+
/**
189+
* The stranding notice is the one conflict-flavoured notice that must not tell the user to
190+
* deactivate the standalone: on the topology it fires for, a network-wide deactivation is exactly
191+
* what would strand the sites the host never reached. It borrows nothing from the conflict
192+
* notice's "you can safely deactivate the standalone" wording.
193+
*/
194+
public function test_the_stranding_notice_does_not_say_the_standalone_is_safe_to_deactivate(): void {
195+
$this->make_writer()->queue_stranding_notice( $this->make_sub_plugin() );
196+
197+
$message = $this->queue()['give-recurring:stranding'] ?? '';
198+
199+
$this->assertStringContainsString( 'give-recurring', $message );
200+
$this->assertStringNotContainsStringIgnoringCase( 'safely deactivate', $message );
201+
}
202+
178203
public function test_queueing_the_same_slug_and_type_twice_does_not_duplicate(): void {
179204
$writer = $this->make_writer();
180205
$writer->queue_merge_notice( $this->make_sub_plugin() );

0 commit comments

Comments
 (0)