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
1 change: 0 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,5 @@
/codeception.dist.yml export-ignore
/codeception.slic.yml export-ignore
/cspell.json export-ignore
/engineering-plan.md export-ignore
/phpstan-cache export-ignore
/phpstan.neon.dist export-ignore
200 changes: 133 additions & 67 deletions CLAUDE.md

Large diffs are not rendered by default.

39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ composer require stellarwp/plugin-absorber

**Use [Strauss](https://github.com/stellarwp/global-docs/blob/main/docs/strauss-setup.md)** — two
plugins shipping different versions of this library will collide otherwise. See
[Installing](docs/installing.md) for the one prefixing rule you must not get wrong.
[Installing][installing] for the one prefixing rule you must not get wrong.

## Quick start

Expand All @@ -39,23 +39,40 @@ add_action( 'plugins_loaded', function () {
The container is required — any StellarWP `ContainerInterface` implementation, the one you already
hand to Telemetry or Uplink. Every collaborator comes from it.

Boot before `plugins_loaded` priority 5, where conflict resolution runs: WordPress silently ignores a
callback added at or past the priority it is already dispatching. Later is reported through
`_doing_it_wrong()` and run inline, but the ordering guarantees are weaker.
Keep the `, 0`. `boot()` wires conflict resolution at `plugins_loaded` priority 5 and the load at
priority 6, and WordPress silently ignores a callback added at or past the priority it is already
dispatching — so anything below priority 5 wires cleanly, the priority 1 where several hosts wire
their container today included. Booting later is reported through `_doing_it_wrong()` and both steps
run inline instead — which on an admin page view can end the request in a redirect before `boot()`
returns.

Priority 0 is the recommendation, in the block that owns your container rather than in a service
provider: a host that builds one lazily and replaces it at priority 0 leaves us holding an orphan
whose bindings were discarded.

A [complete bootstrap][configuration] — two sub-plugins, every optional key — closes the
configuration doc.

## Docs

- [Installing](docs/installing.md) — Composer, Strauss, and the constants Strauss must leave alone.
- [Configuration](docs/configuration.md) — the hook prefix, the container, every sub-plugin key.
- [Conflict handling](docs/conflict-handling.md) — the policies, when they run, and the guard's limits.
- [Filters](docs/filters.md) — the runtime overrides for policies and notice text.
- [Notices](docs/notices.md) — where the queue lives, who may see it, and how to render it yourself.
- [Tests](tests/README.md) — running the suite, the fixtures and traits it offers, and every scenario
it drives the library through.
- [Installing][installing] — Composer, Strauss, and the constants Strauss must leave alone.
- [Configuration][configuration] — the hook prefix, the container, every sub-plugin key.
- [Conflict handling][conflicts] — the policies, when they run, and the guard's limits.
- [Filters][filters] — the runtime overrides for policies and notice text.
- [Notices][notices] — where the queue lives, who may see it, and how to render it yourself.
- [Tests][tests] — running the suite, the fixtures and traits it offers, and every scenario it drives
the library through.

`docs/` and `tests/` are both `export-ignore`d, so neither is in a vendored copy of this library —
these point at the repository rather than at a path that would be missing beside the installed
source.

[installing]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/installing.md
[configuration]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/configuration.md
[conflicts]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/conflict-handling.md
[filters]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/filters.md
[notices]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/notices.md
[tests]: https://github.com/stellarwp/plugin-absorber/blob/main/tests/README.md

## License

Expand Down
25 changes: 23 additions & 2 deletions cspell.json
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
{
"version": "0.2",
"language": "en",
"language": "en,en-GB",
"words": [
"absorber",
"assertable",
"codeception",
"docblocks",
"fatals",
"invokable",
"kadence",
"kses",
"learndash",
"lucatume",
"multisite",
"nexcess",
"packagist",
"phpstan",
"phpunit",
"propanel",
"redirector",
"referer",
"sapi",
"sfwd",
"singlesite",
"sitewide",
"slic",
"stellarwp",
"strauss",
"togglable",
"unbuilt",
"uncallable",
"uncastable",
"unhookable",
"ungated",
"unnegated",
"unreviewed",
"uopz",
"worktree",
"wpautop",
"wpunit"
],
"ignoreWords": [
"ance",
"defered"
],
"ignorePaths": [
"vendor/**",
"tests/_output/**",
Expand Down
90 changes: 71 additions & 19 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ out of the five, so if your container is already built by then, anywhere below 5

## Rebinding a collaborator

`Absorber::boot()` binds the defaults, and skips any id your container already has — so your binding
wins whether you make it before boot or after, and nothing is resolved until `plugins_loaded`
priority 5 in any case:
`Absorber::boot()` binds the defaults, and skips any *interface* your container already answers for
— so a binding against one of the ids in the table below wins whether you make it before boot or
after. A *class* id must be bound after boot: di52 reports `has()` true for any class that exists,
bound or not, so the provider cannot tell your binding from the container's own willingness to build
`Notices\Store`, `Conflict\Gatekeeper` or any other concrete collaborator, and replaces it. Booting
resolves only the two objects that do the booting; every collaborator below is built by the hook
that needs it, when it fires:

```php
use Nexcess\PluginAbsorber\Contracts\Registrar_Interface;
Expand All @@ -55,24 +59,30 @@ $container->singleton( Registrar_Interface::class, My_Registrar::class );
`site_option_active_sitewide_plugins` — LearnDash injects and then strips a synthetic path — because
`is_plugin_active()` then does not report what is in the database.

Rebinding `Resolver_Interface` does not put you in charge of *when* resolution may run. Both gates —
Rebinding `Resolver_Interface` does not put you in charge of *when* resolution may run. The gates —
[an interactive admin `GET` that carries no action, and the capability to deactivate across the
network](conflict-handling.md#when-resolution-runs) — live in `Conflict\Gatekeeper`, which the hook
consults rather than the resolver, so an implementation that never thought about either is still
safe. Everything the resolver *does* — which policy branch, what the notice says,
consults before it resolves the resolver at all, so an implementation that never thought about
either is still safe. Everything the resolver *does* — which policy branch, what the notice says,
where the user lands — is yours.

`set_container()` is a configuration call like `set_hook_prefix()`, and order does not matter among
the configuration calls: it may come before or after your `Absorber::register()` calls, so long as it
comes before boot. Registering buffers the sub-plugin and resolves nothing, so nothing is decided
until the first read.

A binding that does not implement the interface it is bound to throws `Config_Exception` when it is
resolved, rather than being cached and failing later somewhere less obvious. So does a binding whose
factory throws — with the original failure kept as the previous exception.
The accessors — `Absorber::registrar()`, `notices()` and `resolver()` — check what your container
hands back and throw a `Config_Exception` naming the interface and the class that failed it, because a
binding that does not implement its interface would otherwise be a `TypeError` blaming this library
for your typo, raised inside `plugins_loaded` where nobody is looking. Whatever your container
raises for a binding it cannot build at all comes through unwrapped: that one is already yours, and
already says so. The one narrowing anywhere is `Absorber::all()`, which drops anything a rebound
registrar returns that is not a `Sub_Plugin` rather than letting it fatal inside `plugins_loaded`.

The container is **not** used to wire hooks. Those are closures that resolve when they fire, so
registering them instantiates nothing and a request that triggers none builds none.
The container does not decide when anything runs. Each hook resolves its collaborator inside the
callback, so wiring instantiates nothing and a request that reaches none of them builds none of
them. The two admin hooks are named `[ Absorber::class, … ]` callbacks precisely so you can
`remove_filter()` them; the two `plugins_loaded` steps are closures over the container.

## Sub-plugin keys

Expand All @@ -84,7 +94,7 @@ registering them instantiates nothing and a request that triggers none builds no
| `standalone_plugin_basename` | `string` | | The standalone's `dir/file.php` basename. Used for `is_plugin_active()` and `deactivate_plugins()`. Omit when there is no standalone. **Detection only.** |
| `enabled` | `bool\|callable` | | `true` by default. A `callable( Sub_Plugin ): bool` is re-evaluated on every call, not cached. |
| `conflict_policy` | `string\|callable` | | `Conflict_Policy::DEACTIVATE` by default. |
| `conflict_notice_message` | `callable` | | Shown on auto-deactivation and on a re-activation attempt. Empty by default. |
| `conflict_notice_message` | `callable` | | Used in all three places a conflict is reported — the merge notice, the still-active notice, and the rewritten activation-error screen. Each falls back to its own generic sentence naming the slug. |
| `dependency_notice_message` | `callable` | | Shown when `dependency_check` fails. Defaults to a generic, untranslated sentence naming the raw slug. |
| `activation_callback` | `callable( Sub_Plugin )` | | Runs **once, ever**, per slug, after a successful load. Make it idempotent. |
| `dependency_check` | `callable( Sub_Plugin ): bool` | | Skips the load and queues a notice when it returns false. |
Expand All @@ -100,11 +110,11 @@ at include time.
Register each slug exactly once. A slug also names the sub-plugin's notices and its once-ever
activation record, so a second registration under the same slug is refused with a
`Config_Exception` naming both bundled files rather than quietly dropping one of the two from the
load. Registrations are buffered and nothing reads them until `plugins_loaded` — the conflict pass at
priority 5 on an admin page view, the load pass at priority 6 on everything else — so that is where
the collision surfaces, not at the second `register()` call and not at `boot()`. Whichever pass
reads first reports it with `_doing_it_wrong()`, and that request resolves no conflict and loads no
sub-plugin at all, rather than throwing out of a core hook. A config array the library cannot use is still rejected on the spot.
load. Registrations are buffered and handed to the registrar at the first read — the conflict pass
at `plugins_loaded` priority 5, or the load pass at 6 — so that collision surfaces there rather than
from the second `register()` call, reported with `_doing_it_wrong()` instead of thrown out of a core
hook; a config array the library cannot use is still rejected on the spot, in the call you can see
in your own stack trace.
Register unconditionally and put anything you cannot decide up front — a licence that may not be
active, a setting the site owner can change — in `enabled`, which is re-evaluated on every load.

Expand Down Expand Up @@ -200,8 +210,50 @@ nothing to wait for.
`dependency_check`, `activation_callback` and `enabled` have nothing a string could collide with, so
they accept every callable form, a plain function name included.

Every key rejects a shape it cannot use at registration rather than at read time — including a
`[ class, method ]` pair naming a method that does not exist.
Every typed key rejects a shape it cannot use at registration rather than at read time — including a
`[ class, method ]` pair naming a method that does not exist. `enabled` is the exception: it is read
as a boolean if it is not callable, so an array or an object there passes registration and then
evaluates as enabled. Give it a `bool` or a `callable`, and nothing else.

The [filters](filters.md) are the other way in, and they run last — after the configured value and
any fallback, so they see the default text too.

## Complete example

```php
use Nexcess\PluginAbsorber\Config;
use Nexcess\PluginAbsorber\Conflict_Policy;
use Nexcess\PluginAbsorber\Absorber;
use Nexcess\PluginAbsorber\Sub_Plugin;

add_action( 'plugins_loaded', function () {
Config::set_hook_prefix( 'give' );
Config::set_container( give()->container );

Absorber::register( [
'slug' => 'give-recurring',
'bundled_plugin_file' => GIVE_PLUGIN_DIR . 'sub-plugins/give-recurring/give-recurring.php',
'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION',
'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
'enabled' => static fn( Sub_Plugin $sub_plugin ) => give_addon_is_licensed( $sub_plugin->get_slug() ),
'conflict_policy' => Conflict_Policy::DEACTIVATE,
'conflict_notice_message' => static fn() => __( 'Recurring Donations ships with Give now.', 'give' ),
'activation_callback' => static function ( Sub_Plugin $sub_plugin ) {
\Give\Recurring\Install::create_tables();
},
] );

Absorber::register( [
'slug' => 'give-stripe',
'bundled_plugin_file' => GIVE_PLUGIN_DIR . 'sub-plugins/give-stripe/give-stripe.php',
'plugin_loaded_constant' => 'GIVE_STRIPE_VERSION',
'standalone_plugin_basename' => 'give-stripe/give-stripe.php',
// The standalone is still ahead of the bundled copy, so let it win for now.
'conflict_policy' => Conflict_Policy::DEFER,
'dependency_check' => static fn() => function_exists( 'curl_init' ),
'dependency_notice_message' => static fn() => __( 'Stripe payments need the cURL extension.', 'give' ),
] );

Absorber::boot();
}, 0 );
```
47 changes: 28 additions & 19 deletions docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ When a sub-plugin's standalone counterpart is still active:

| Policy | Behavior |
|---|---|
| `Conflict_Policy::DEACTIVATE` | Deactivate the standalone, notify, and redirect; the bundled copy loads on the next request. **Default.** |
| `Conflict_Policy::DEACTIVATE` | Deactivate the standalone, notify, and usually redirect; the bundled copy loads on the next request. **Default.** |
| `Conflict_Policy::DEFER` | Leave the standalone active; the load guard stands the bundled copy down. |
| `Conflict_Policy::NOTICE_ONLY` | Leave it active and ask the user to deactivate it. |

Expand Down Expand Up @@ -59,18 +59,23 @@ every site on a network. The gate matters at all because `plugins_loaded` fires
`auth_redirect()`, so an unauthenticated GET of an admin URL reaches this code on its way to the
login screen. It applies to every policy rather than only to `deactivate`, which costs nothing — the
other policies just queue a notice, and a notice is neither shown nor cleared for a user without the
same capability, so nothing is consumed by waiting for one who has it.

Both gates live in `Conflict\Gatekeeper`, and the hook asks it rather than the resolver, so binding
your own `Conflict\Contracts\Resolver_Interface` cannot drop either by omission. They are asked in
two halves either side of `Conflict\Detector::has_conflict()`, which only reports: the request-shape
gate first, then the detector, then the capability. The capability check is last because
`current_user_can()` resolves and caches the current user, and at priority 5 that lands ahead of any
`determine_current_user` filter a plugin registers from its own `plugins_loaded` callback — an SSO or
JWT plugin hooked at the default priority would never be consulted, and its users would be treated as
signed out. Asking the detector rather than the resolver keeps detection off the contract a host
rebinds, and means the resolver is built only on a request that passes both gates and has something
to resolve.
same capability.

Both gates live in `Conflict\Gatekeeper`, along with a third that catches a host which reached
`plugins_loaded` without ever calling `Config::set_hook_prefix()` — that is reported through
`_doing_it_wrong()` and resolution stands down rather than throwing out of a core action. The hook
asks the gatekeeper *before* it resolves `Conflict\Contracts\Resolver_Interface` at all, so binding
your own resolver cannot drop any of them by omission: on a request that fails one, your
implementation is never built, let alone called. The capability is asked last, after
`Conflict\Detector::has_conflict()` has reported there is something to resolve — `current_user_can()`
resolves and caches the current user, and at priority 5 that would land ahead of any
`determine_current_user` filter an SSO or JWT plugin adds from its own `plugins_loaded` callback,
whose users would then be treated as signed out for the rest of the request.

The deactivation itself is silent, and covers both scopes on multisite. Silent because the
standalone's own deactivation hook has already been registered by the time we run: a routine
`flush_rewrite_rules()` in that callback, at `plugins_loaded`, regenerates the rules before `init`
has declared a single post type, and every custom permalink on the site starts 404ing.

## The redirect

Expand Down Expand Up @@ -136,11 +141,14 @@ So the library filters `wp_admin_notice_markup` and swaps that sentence for the
`conflict_notice_message`, falling back to a generic one naming the slug. This is what puts the
WordPress floor at 6.4: the filter does not exist before it.

It touches nothing else. The markup comes back unchanged unless all three hold — the screen is
`plugins`, or `plugins-network` in the network admin, where a super admin is the only one who can
reactivate anything; the `plugin` query arg names a standalone this library has registered; and
`_error_nonce` verifies against `plugin-activation-error_{basename}`. Another plugin's fatal is
another plugin's business.
It touches nothing else. The markup comes back untouched unless every one of these holds — the
screen is `plugins`, or `plugins-network` in the network admin, where a super admin is the only one
who can reactivate anything; the `plugin` query arg names a standalone this library has registered;
and `_error_nonce` verifies against `plugin-activation-error_{basename}`. Another plugin's fatal is
another plugin's business. (One exception, and it is not about this screen: a filter ahead of ours
that returned something other than a string is normalised to `''`, because a `string` type
declaration here would turn that plugin's mistake into a `TypeError` raised on the error screen
least able to afford a second one.)

The replacement runs through `wp_kses_post()`, so a knowledge-base link survives, and it is
sanitised *before* it is checked for emptiness: a message that filters down to nothing leaves core's
Expand All @@ -150,4 +158,5 @@ The filter is wired by `Boot\Scheduler` under `is_admin()`, as
`[ Absorber::class, 'filter_activation_error_markup' ]` — a named callback, so a host that would
rather keep core's wording can `remove_filter()` it. The rewriting itself is
`Conflict\Rewriter::rewrite()`, bound by class name like the rest of the conflict handling, so a host
rebinds this screen on its own — without having to supply a notice queue to get it.
can rebind this screen on its own — after `boot()`, as every class-name binding must be, and without
having to supply a notice writer to get it.
Loading
Loading