Skip to content
Merged
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
205 changes: 161 additions & 44 deletions CLAUDE.md

Large diffs are not rendered by default.

36 changes: 25 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,36 @@ plugins shipping different versions of this library will collide otherwise. See

```php
use Nexcess\PluginAbsorber\Config;
use Nexcess\PluginAbsorber\Loader;

Config::set_hook_prefix( 'give' ); // required — keys the hooks and options
Config::set_container( give()->container ); // optionallets you rebind collaborators
```
add_action( 'plugins_loaded', function () {
Config::set_hook_prefix( 'give' ); // requiredkeys the hooks and options
Config::set_container( give()->container ); // required — every collaborator resolves from it

Each sub-plugin is then described by a config array:
Loader::register( [
'slug' => 'give-recurring',
'bundled_plugin_file' => __DIR__ . '/sub-plugins/recurring/give-recurring.php',
'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION',
'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
] );

```php
[
'slug' => 'give-stripe',
'bundled_plugin_file' => __DIR__ . '/sub-plugins/give-stripe/give-stripe.php',
'plugin_loaded_constant' => 'GIVE_STRIPE_VERSION',
'standalone_plugin_basename' => 'give-stripe/give-stripe.php',
]
Loader::boot();
}, 0 );
```

The container is required — any StellarWP `ContainerInterface` implementation, the one you already
hand to Telemetry or Uplink. Every collaborator comes from it.

Keep the `, 0`. `boot()` wires the load at `plugins_loaded` priority 2, and WordPress silently
ignores a callback added at or past the priority it is already dispatching — so configuring the
library from a provider that itself runs at priority 2 or later races the library it is configuring.
Booting later is reported through `_doing_it_wrong()` and loaded inline, but the ordering guarantees
are weaker.

Put this in the block that owns your container, not in a service provider, and pass the container you
intend to keep: a host that builds one lazily and replaces it later leaves us holding an orphan whose
bindings were discarded.

## Docs

- [Installing](docs/installing.md) — Composer, Strauss, and the constants Strauss must leave alone.
Expand Down
65 changes: 52 additions & 13 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,63 @@
use Nexcess\PluginAbsorber\Config;

Config::set_hook_prefix( 'give' ); // required — keys hooks and options
Config::set_container( give()->container ); // optionallets you rebind collaborators
Config::set_container( give()->container ); // requiredevery collaborator resolves from it
```

The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws
`Config_Exception`, as does reading the prefix before it is set. Hook names repeat it verbatim;
option names lowercase it and turn hyphens into underscores, so `Give-Core` hooks
`Give-Core/plugin_absorber/should_load` and stores `give_core_plugin_absorber_notices`.

The container is optional. Without one, the library instantiates its own collaborators; with one,
a host can rebind them.
## The container

Both calls are required, and both belong at `plugins_loaded` priority 0, in your own container
block rather than in a service provider.

Any implementation of StellarWP's `ContainerInterface` will do — the one your plugin already hands
to Telemetry, Uplink or Harbor. `Config::get_container()` throws `Config_Exception` when none is
set; `Config::has_container()` is the probe if you need to ask.

Priority matters twice. Conflict resolution runs at `plugins_loaded` priority 1 and the load at
priority 2, and WordPress silently ignores a callback added at or past the priority it is already
dispatching — so configuring us from a provider that itself runs at priority 1 races us. And a host
that builds its container lazily may *replace* it at priority 0; hand us the container before that
happens and we hold an orphan whose bindings were discarded.

## Rebinding a collaborator

Every collaborator is interface-backed. With a container set, bind one to override the library
globally; with no container, the defaults are used and nothing is required.
`Loader::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 1 in any case:

```php
use Nexcess\PluginAbsorber\Contracts\Registrar_Interface;

$container->singleton( Registrar_Interface::class, My_Registrar::class );
Config::set_container( $container );
```

| Interface | Default | Responsibility |
|---|---|---|
| `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. |
| `Notices\Contracts\Queue_Interface` | `Notices\Queue` | Queues and renders the admin notices. |
| `Contracts\Plugin_Deactivator_Interface` | `Plugin_Deactivator` | Deactivates the standalone. |
| `Contracts\Plugin_Checker_Interface` | `Plugin_Checker` | Answers whether a plugin is active. |

`set_container()` is a configuration call like `set_hook_prefix()`, and order does not matter: it may
come before or after your `Loader::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.
`Plugin_Checker_Interface` is the seam to rebind when your plugin filters `option_active_plugins` or
`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.

`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 `Loader::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 container is **not** used to wire hooks — those stay plain static callbacks, so the container
stays genuinely optional.
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.

## Sub-plugin keys

Expand Down Expand Up @@ -70,11 +90,30 @@ 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. Because registrations are buffered until boot, that collision is reported at boot rather than
at the second `register()` call; a config array the library cannot use is still rejected on the spot.
load. Registrations are buffered and nothing reads them until the load pass at `plugins_loaded`
priority 2, so that is where the collision surfaces — not at the second `register()` call and not at
`boot()`. It is reported with `_doing_it_wrong()` and that request loads no sub-plugin at all, rather
than thrown out of a core hook. A config array the library cannot use is still rejected on the spot.
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.

## The bundled file is included from a function, not from global scope

WordPress includes plugins from `wp-settings.php` at global scope; this library includes them from
inside a method. Variables assigned at the top level of the bundled file are therefore function-local
and do not become globals:

```php
// In the bundled plugin's main file.
$my_plugin = new My_Plugin(); // Not a global. `global $my_plugin;` elsewhere sees null.
$GLOBALS['my_plugin'] = new My_Plugin(); // Works.
```

Everything else — function and class declarations, `define()`, hook registration, `__FILE__` — is
unaffected. Bundle a plugin that publishes its instance through `$GLOBALS`, a singleton or a
container, which is what plugins written in the last decade do anyway. No amount of wrapping on this
side can hand a required file the global scope it would have had.

## Messages are callables, never strings

Your config array is built at plugin load — before `init`, and before your textdomain. Calling
Expand Down
25 changes: 22 additions & 3 deletions docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,28 @@ Set one per sub-plugin with the `conflict_policy` key, or decide it at runtime w
Before loading a bundled plugin, the library checks whether `plugin_loaded_constant` is already
defined. `defined()` ⇒ skip, which is what prevents the re-declaration fatal.

**The constant must be defined at file scope.** A standalone that defines it from a bootstrap
hooked at `plugins_loaded` or later has not defined it yet at the moment the guard is read, and the
bundled copy would load on top of it.
**The constant must be defined at file scope**, inside a `defined()` check so whichever copy loads
first wins:

```php
if ( ! defined( 'GIVE_RECURRING_VERSION' ) ) {
define( 'GIVE_RECURRING_VERSION', '2.4.0' );
}
```

A standalone that defines it from a bootstrap hooked at `plugins_loaded` or later has not defined it
yet at the moment the guard is read, and the bundled copy would load on top of it.

## What is deliberately out of scope

**Version negotiation.** The library never compares versions, so it will not spare a standalone that
is newer than the bundled copy. Express that yourself: check the version and return
`Conflict_Policy::DEFER` from the `conflict_policy` [filter](filters.md), which has the final say.

**Renamed standalone directories.** `standalone_plugin_basename` is the path as installed. A site
that renamed the standalone's directory is not detected, and there is no fallback that derives the
path from the load guard: one key is the guard and the other is the path, and no constant does both
jobs. The cost is a missed detection; the alternative costs the guarantee the guard exists for.

## What the guard cannot do

Expand Down
22 changes: 22 additions & 0 deletions docs/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,25 @@ asked for rather than when the sub-plugin is registered, they are also the place
by then the textdomain is loaded.

A filter returning a non-scalar yields an empty string rather than a fatal cast.

## The load gate

| Filter | Arguments | Purpose |
|---|---|---|
| `{prefix}/plugin_absorber/should_load` | `bool $should_load`, `Sub_Plugin $sub_plugin` | Last word before `require_once`. |

```php
add_filter( 'give/plugin_absorber/should_load', function ( $should_load, $sub_plugin ) {
return $sub_plugin->get_slug() === 'give-recurring' ? false : $should_load;
}, 10, 2 );
```

It is consulted only for a sub-plugin that would otherwise have loaded — after the enabled check,
the guard constant, the dependency check and the file check, in that order. So returning `true`
cannot force a load past the guard constant: nothing overrides that. Anything other than a truthy
return skips the load, which is the safe direction.

**Watch the polarity when you wire an existing gate to this one.** `should_load` is true means *do
load*. A host filter named for the opposite — LearnDash's `learndash_module_{x}_disabled`, where true
means *do not load* — inverts the gate if it is passed through unnegated, and the failure is silent
in the direction that loads a plugin the site turned off.
35 changes: 32 additions & 3 deletions docs/notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,36 @@ example — and the messages may contain markup; the default rendering passes th
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.

```php
use Nexcess\PluginAbsorber\Notices\Queue;

add_action( 'admin_init', function () {
// Gates the read, not just the delete: `admin_init` fires for every logged-in user, and
// draining the queue for one who cannot act on it destroys the only warning an
// administrator was going to get.
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}

$notices = get_site_option( Queue::option_name(), [] );

if ( ! is_array( $notices ) || ! $notices ) {
return;
}

foreach ( $notices as $key => $message ) {
my_plugin_enqueue_notice( $key, $message );
}

delete_site_option( Queue::option_name() );
} );
```

`admin_init` runs before `all_admin_notices`, where the built-in rendering happens, so deleting the
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. Both collaborators are constructor arguments,
so `new Queue( null, $renderer )` keeps the queue and replaces only the markup, and
`new Queue( $store )` does the reverse. Replacing either one leaves the other alone.
`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.
Loading
Loading