Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)

Expand Down
1 change: 1 addition & 0 deletions docs/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions docs/notices.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`{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
Expand Down Expand Up @@ -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<string,string>` 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 `<p>` of your own is left as it is rather than nested inside another.
Expand Down
17 changes: 17 additions & 0 deletions src/Notices/Contracts/Writer_Interface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
5 changes: 3 additions & 2 deletions src/Notices/Renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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',
];

Expand Down
27 changes: 27 additions & 0 deletions src/Notices/Writer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
*
Expand Down
44 changes: 44 additions & 0 deletions src/Sub_Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
16 changes: 16 additions & 0 deletions tests/_support/Spy_Writer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions tests/unit/Notices/RendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ];
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/Notices/WriterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}

/**
Expand Down Expand Up @@ -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() );
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/SubPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
Loading