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
29 changes: 20 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,33 @@ seams a host may rebind:
| Interface | Bound to | Responsibility |
|---|---|---|
| `Contracts\Registrar_Interface` | `Registrar` | holds registered `Sub_Plugin` objects |
| `Notices\Contracts\Queue_Interface` | `Notices\Queue` | notice queue + activation-error rewrite |
| `Notices\Contracts\Writer_Interface` | `Notices\Writer` | what each notice says |
| `Conflict\Contracts\Resolver_Interface` | `Conflict\Resolver` | one method: which policy branch a conflict takes |
| `Contracts\Plugin_Deactivator_Interface` | `Plugin_Deactivator` | deactivates the standalone, network-aware |
| `Contracts\Plugin_Checker_Interface` | `Plugin_Checker` | answers whether a plugin is active |
| `Contracts\Activator_Interface` | `Activator` | run-once activation-callback tracking |

The rest — `Boot\Scheduler`, `Loader`, `Registry_Reader`, `Conflict\Detector`, `Conflict\Gatekeeper`,
`Conflict\Redirector`, `Notices\Store`, `Notices\Renderer` — are bound as concrete classes. A host
that wants one of them different rebinds the class name; there is no interface because nothing in the
library dispatches on one.
`Conflict\Redirector`, `Notices\Store`, `Notices\Renderer`, `Notices\Presenter` — are bound as concrete
classes. A host that wants one of them different rebinds the class name; there is no interface because
nothing in the library dispatches on one.

An interface belonging to a folder-scoped concern lives in that folder's `Contracts\`, not beside its
implementation and not in the top-level `src/Contracts/`. `src/Contracts/` is for the interfaces whose
implementations sit at the root — `Registrar`, `Plugin_Deactivator`, `Plugin_Checker`, `Activator` —
plus `Provider_Interface`.

**`Notices\Writer` and `Notices\Presenter` split because they change for different reasons.** One
answers "what does this notice say", the other "who may see the pending set, and is it gone once they
have" — a host already running `stellarwp/admin-notices` has an opinion about the first and none about
the second. Only `Writer` earns `Writer_Interface`: wording is what a host rebinds, and nothing in the
library dispatches on how a notice reaches the screen, since the trampoline on `all_admin_notices` is
`Presenter`'s only caller — a host that wants no rendering of ours removes that callback rather than
binding a no-op. The capability check stays on `Presenter`, next to the render-then-clear, rather than
moving to `Renderer` alongside the markup: it guards the clearing as much as the drawing, so deciding
those two separately would let a user who may not see the queue destroy it anyway, through a class
that never checked.

**The container is required.** `Config::get_container()` throws `Config_Exception` when unset, which
is what `uplink`, `telemetry`, `schema` and `harbor` all do; `has_container()` stays as the probe.
Optional was the outlier — of nineteen vendored StellarWP packages exactly one falls back to `new`,
Expand Down Expand Up @@ -161,7 +172,7 @@ is not built yet. Currently:
| `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), `Contracts\Resolver_Interface`. |
| `src/Traits/` | `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing). |
| `src/Notices/` | `Queue` (what a notice says, who may consume it), `Store` (keeps it), `Renderer` (draws it), `Contracts\Queue_Interface`. |
| `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/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Registrar_Interface`, `Plugin_Deactivator_Interface`, `Plugin_Checker_Interface`, `Activator_Interface`, `Config_Exception`. |

### Boot lifecycle
Expand Down Expand Up @@ -211,7 +222,7 @@ loaded → dependencies met → file exists → `should_load` filter → `requir
callback (only after a *successful* require).

The activation callback is the last of those and runs through `Activator`, which `Loader` takes
as a constructor argument like the notice queue and the registry reader. Last, because a bundled plugin is included rather
as a constructor argument like the writer and the registry reader. Last, because a bundled plugin is included rather
than activated: `register_activation_hook()` never fires for it, so the callback stands in for
whatever that hook would have done, and it has to run with the plugin's own code already in memory.
Only after a require that happened, because creating tables and seeding options for a sub-plugin
Expand Down Expand Up @@ -241,15 +252,15 @@ the screens the mistaken registration would have to be corrected from.

The container is no longer the other half of that. A pass is handed a reader that already holds its
registrar, so a container that cannot supply one fails while the *pass* is being built — where an
unbuildable `Queue_Interface` or `Plugin_Checker_Interface` has always failed. Read-time and
unbuildable `Writer_Interface` or `Plugin_Checker_Interface` has always failed. Read-time and
build-time failures stopped being the same event when the registry became an argument, and the
registrar now fails like every other binding rather than being the one collaborator whose broken
binding surfaced late and politely.

`Conflict\Resolver` switches on the policy: `DEFER` no-ops, `NOTICE_ONLY` queues a notice, and
`DEACTIVATE` (the default) deactivates network-aware, queues a merge notice, and redirects. It is
the worked example of required injection — `Conflict\Detector` to say which sub-plugins are in
conflict, `Plugin_Deactivator_Interface` to turn the standalone off, `Queue_Interface` for the notice
conflict, `Plugin_Deactivator_Interface` to turn the standalone off, `Writer_Interface` for the notice
and `Conflict\Redirector` for the destination, all four constructor arguments with no default — so
the object a test builds is the object the provider builds, and a host's rebinding of either plugin
seam reaches it, the deactivator directly and the checker through the detector, without the resolver
Expand Down Expand Up @@ -297,7 +308,7 @@ resolve. The detector reports and changes nothing, so it is the cheap question t
the expensive one. All three live in the step rather than in the resolver, so a host binding its own
cannot drop one by omission — and a request that fails any of them never builds a resolver. The
capability gate covers every policy, not just the destructive one, and that is free: the other
branches only queue a notice, and `Notices\Queue::render()` refuses to render *or clear* for a user
branches only queue a notice, and `Notices\Presenter::render()` refuses to render *or clear* for a user
without the same capability, so queuing earlier would only park it until a capable admin arrives.

An unknown policy is normalised to `NOTICE_ONLY` through `Conflict_Policy::is_valid()` before the
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ $container->singleton( Registrar_Interface::class, My_Registrar::class );
| Interface | Default | Responsibility |
|---|---|---|
| `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. |
| `Notices\Contracts\Queue_Interface` | `Notices\Queue` | Queues and renders the admin notices. |
| `Notices\Contracts\Writer_Interface` | `Notices\Writer` | Words the admin notices. |
| `Contracts\Plugin_Deactivator_Interface` | `Plugin_Deactivator` | Deactivates the standalone. |
| `Contracts\Plugin_Checker_Interface` | `Plugin_Checker` | Answers whether a plugin is active. |
| `Conflict\Contracts\Resolver_Interface` | `Conflict\Resolver` | Detects the active standalone and applies the policy. |
Expand Down
15 changes: 10 additions & 5 deletions docs/notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ the site owner is never told their plugin was turned off.

## Who sees them

`Notices\Queue::render()` prints the queue and then clears it, and it is gated on the
`Notices\Presenter::render()` prints the queue and then clears it, and it is gated on the
`activate_plugins` capability. Since rendering consumes the queue, a user who cannot act on a notice
must not be shown one — a subscriber loading their profile page would otherwise silently swallow the
only warning an administrator was ever going to get.
Expand Down Expand Up @@ -65,7 +65,12 @@ add_action( 'admin_init', function () {
option there leaves ours nothing to draw and the notice is shown once, by you. Do the deleting: a
notice read and not cleared is shown on every request forever.

The queue is three classes: `Notices\Queue` decides what a notice says and who may consume it,
`Notices\Store` keeps it, `Notices\Renderer` draws it. `Queue` takes both as constructor arguments
and all three are bound in the container, so rebinding `Notices\Renderer` replaces the markup and
leaves the storage alone, and rebinding `Notices\Store` does the reverse.
The queue is four classes: `Notices\Writer` decides what a notice says, `Notices\Presenter` decides
who may consume it and does the render-then-clear, `Notices\Store` keeps it, `Notices\Renderer` draws
it. `Writer` takes `Store` as its only constructor argument and is the one bound behind an interface,
`Writer_Interface` — the seam for a host that already runs its own notices library and wants to reword
rather than replace the plumbing. `Presenter` takes `Store` and `Renderer` and is bound by class name:
nothing in the library dispatches on it, since the trampoline on `all_admin_notices` is its only
caller. Rebinding `Notices\Renderer` replaces the markup and leaves the storage alone; rebinding
`Notices\Store` does the reverse; rebinding `Writer_Interface` replaces the wording without touching
either.
19 changes: 10 additions & 9 deletions src/Absorber.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
use Nexcess\PluginAbsorber\Contracts\Provider_Interface;
use Nexcess\PluginAbsorber\Contracts\Registrar_Interface;
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Notices\Contracts\Queue_Interface;
use Nexcess\PluginAbsorber\Notices\Contracts\Writer_Interface;
use Nexcess\PluginAbsorber\Notices\Presenter;
use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
use Throwable;

Expand Down Expand Up @@ -53,10 +54,10 @@ public static function registrar(): Registrar_Interface {
*
* @throws Config_Exception When no container has been set, or its binding is unusable.
*
* @return Queue_Interface
* @return Writer_Interface
*/
public static function notices(): Queue_Interface {
return self::collaborator( Queue_Interface::class );
public static function notices(): Writer_Interface {
return self::collaborator( Writer_Interface::class );
}

/**
Expand Down Expand Up @@ -162,12 +163,12 @@ public static function render_notices(): void {
return;
}

// The queue is a rebindable seam and the messages inside it are host callables, so rendering
// runs somebody else's code -- on all_admin_notices, which every admin screen fires. A throw
// out of here would white-screen wp-admin, which is exactly where a site owner would go to
// undo whatever caused it. The notice is worth less than the screen it would be read on.
// The messages a presenter draws were worded by host callables, so rendering runs somebody
// else's code -- on all_admin_notices, which every admin screen fires. A throw out of here
// would white-screen wp-admin, which is exactly where a site owner would go to undo whatever
// caused it. The notice is worth less than the screen it would be read on.
try {
self::notices()->render();
self::collaborator( Presenter::class )->render();
} catch ( Throwable $thrown ) {
_doing_it_wrong(
self::class . '::render_notices',
Expand Down
2 changes: 1 addition & 1 deletion src/Conflict/Gatekeeper.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public function request_may_resolve(): bool {
* be able to deactivate one, and a replacement that forgot to re-check would reopen exactly that.
*
* It gates every policy, not only the destructive one, and that costs nothing. The other
* policies queue a notice, and Notices\Queue::render() will not render -- or clear -- for a user
* policies queue a notice, and Notices\Presenter::render() will not render -- or clear -- for a user
* with no plugin capability at all. Queuing on a request that cannot act only parks the notice
* until an administrator who can act arrives, which is the request this gate lets resolution run
* on anyway. Nothing is consumed or suppressed by waiting: the standalone is still there to
Expand Down
8 changes: 4 additions & 4 deletions src/Conflict/Resolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use Nexcess\PluginAbsorber\Conflict_Policy;
use Nexcess\PluginAbsorber\Contracts\Plugin_Deactivator_Interface;
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Notices\Contracts\Queue_Interface;
use Nexcess\PluginAbsorber\Notices\Contracts\Writer_Interface;
use Nexcess\PluginAbsorber\Registry_Reader;
use Nexcess\PluginAbsorber\Sub_Plugin;
use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
Expand Down Expand Up @@ -62,7 +62,7 @@ class Resolver implements Resolver_Interface {
/**
* @since 1.0.0
*
* @var Queue_Interface
* @var Writer_Interface
*/
private $notices;

Expand All @@ -79,14 +79,14 @@ class Resolver implements Resolver_Interface {
* @param Registry_Reader $registry Which sub-plugins are registered.
* @param Detector $detector Whether a sub-plugin is in conflict.
* @param Plugin_Deactivator_Interface $plugin_deactivator Turns the standalone off.
* @param Queue_Interface $notices Where the user is told what happened.
* @param Writer_Interface $notices Where the user is told what happened.
* @param Redirector $redirector Where the user lands afterwards.
*/
public function __construct(
Registry_Reader $registry,
Detector $detector,
Plugin_Deactivator_Interface $plugin_deactivator,
Queue_Interface $notices,
Writer_Interface $notices,
Redirector $redirector
) {
$this->registry = $registry;
Expand Down
8 changes: 4 additions & 4 deletions src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use Nexcess\PluginAbsorber\Contracts\Activator_Interface;
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Notices\Contracts\Queue_Interface;
use Nexcess\PluginAbsorber\Notices\Contracts\Writer_Interface;
use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
use Throwable;

Expand All @@ -33,7 +33,7 @@ class Loader {
/**
* @since 1.0.0
*
* @var Queue_Interface
* @var Writer_Interface
*/
private $notices;

Expand All @@ -48,12 +48,12 @@ class Loader {
* @since 1.0.0
*
* @param Registry_Reader $registry Which sub-plugins are registered.
* @param Queue_Interface $notices Where a sub-plugin that could not load says so.
* @param Writer_Interface $notices Where a sub-plugin that could not load says so.
* @param Activator_Interface $activator Runs the activation callback of one that did.
*/
public function __construct(
Registry_Reader $registry,
Queue_Interface $notices,
Writer_Interface $notices,
Activator_Interface $activator
) {
$this->registry = $registry;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,35 @@
* @package Nexcess\PluginAbsorber
*/

declare( strict_types=1 );

namespace Nexcess\PluginAbsorber\Notices\Contracts;

use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Sub_Plugin;

/**
* Admin notices raised by the absorber. Bind a replacement to render them your own way.
* What the absorber has to say about a sub-plugin. Bind a replacement to word or keep it your own way.
*
* The seam, and the only one in this folder: what a notice says is the thing a host has an opinion
* about, and a host already running `stellarwp/admin-notices` binds its own here. How a pending
* notice reaches the screen is `Notices\Presenter`'s, which is a class rather than a contract because
* nothing in the library dispatches on it — the trampoline on `all_admin_notices` is the only caller,
* and a host that wants it gone takes the callback off.
*
* @since 1.0.0
*/
interface Queue_Interface {
interface Writer_Interface {
/**
* Queue the "we deactivated the standalone for you" notice.
*
* Queued after the deactivation has already happened, and raised exactly once — nothing
* re-queues it on a later request, so an implementation that drops it drops the only warning
* the site owner gets.
*
* Whatever an implementation writes has to survive the request that wrote it: the resolver
* redirects, so the notice is almost never read by the request that raised it.
*
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin concerned.
Expand Down Expand Up @@ -58,39 +69,10 @@ public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void;
public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void;

/**
* Render every queued notice, then clear the queue.
*
* Two obligations an implementation must honour. It has to survive the request that queued
* it, because the resolver redirects and the notice is almost never rendered by the request
* that raised it. And because rendering consumes the queue, it must not render for a user who
* cannot act on the notice — otherwise any logged-in user loading an admin page swallows a
* warning meant for an administrator.
*
* The queue is single-consumer. Rendering consumes it for everybody, so the first eligible
* administrator to load any admin screen is the only person who ever sees a given notice —
* network-wide on multisite, where the queue is one network option. An implementation that
* wants every administrator to see it has to track consumption per user itself.
*
* Messages may carry markup. They come from the host's own configuration or from its filters
* rather than from user input, so the default implementation prints them through
* `wp_kses_post()` — the standard WordPress post-content allowlist — and a link to a
* knowledge-base article, emphasis or a list reaches the screen intact while a script or an
* event handler attribute is stripped. An implementation bound in place of the default owns
* its own escaping.
*
* @since 1.0.0
*
* @throws Config_Exception When no hook prefix has been set.
*
* @return void
*/
public function render(): void;

/**
* Where these notices are kept, so a host can render them itself without replacing the queue.
* Where these notices are kept, so a host can render them itself without replacing the writer.
*
* On the contract rather than on the default implementation, and an instance method rather than
* a static one, because the honest answer depends on which queue a site is running: an
* a static one, because the honest answer depends on which writer a site is running: an
* implementation bound in place of the default keeps its notices where it likes, and a host
* reading a name off the default class would read an option nothing writes to.
*
Expand Down
Loading
Loading