Skip to content
Merged
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ you already hand to Telemetry or Uplink.

If your plugin can run on multisite, add
`Config::set_host_plugin_basename( plugin_basename( __FILE__ ) )`. It is a no-op off a network, so set
it unconditionally: where it matters is the one topology the library must not deactivate a standalone
in — a network-active standalone whose host plugin is not itself network-activated, where a
network-wide deactivation would leave the network's other sites with no copy of it at all.
it unconditionally: it stops the library deactivating a network-active standalone when your host
plugin is not itself network-activated, which would leave the network's other sites with no copy of
it at all.

**Keep the `, 0`.** Anything below `plugins_loaded` priority 5 wires cleanly, but priority 0 is the
recommendation, in the block that owns your container rather than in a service provider. Booting at 5
or later still works and is reported through `_doing_it_wrong()`, with the whole sequence running
inline instead. [Configuration][configuration] explains both, and closes with a complete bootstrap —
two sub-plugins, every optional key.
**Keep the `, 0`.** Anything below `plugins_loaded` priority 5 wires cleanly; priority 0, in the
block that owns your container rather than a service provider, is the recommendation. Booting at 5
or later still works, with the whole sequence running inline and reported through
`_doing_it_wrong()`. [Configuration][configuration] explains both, and closes with a complete
bootstrap — two sub-plugins, every optional key.

## Docs

Expand All @@ -65,15 +65,15 @@ two sub-plugins, every optional key.
- [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.
`docs/` and `tests/` are both `export-ignore`d, so neither ships in a vendored copy — these links
point at the repository rather than at paths 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
[recipes]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/recipes.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
[actions]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/actions.md
[notices]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/notices.md
[extending]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/extending.md
[tests]: https://github.com/stellarwp/plugin-absorber/blob/main/tests/README.md
Expand Down
154 changes: 60 additions & 94 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,65 +9,47 @@ Config::set_hook_prefix( 'give' ); // required — keys hooks and optio
Config::set_container( give()->container ); // required — everything is resolved 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 hook prefix accepts letters, numbers, hyphens, and underscores; anything else throws
`Config_Exception`, as does reading it before it is set. Hook names repeat it verbatim; option
names lowercase it and fold hyphens to underscores.

Any implementation of StellarWP's `ContainerInterface` will do — the one your plugin already
hands to Telemetry, Uplink or Harbor. It is required: `Config::get_container()` throws
`Config_Exception` when none is set, and `Config::has_container()` is the probe. To replace one
of the library's own pieces, see [Extending](extending.md).
Any implementation of StellarWP's `ContainerInterface` will do. `Config::get_container()` throws
`Config_Exception` when none is set; `Config::has_container()` is the probe. To replace one of the
library's own pieces, see [Extending](extending.md).

`Config::set_host_plugin_basename( plugin_basename( __FILE__ ) )` is optional and matters only on
multisite. It lets the library compare your plugin's activation scope against a standalone's, so it
declines to deactivate a network-active standalone when your plugin is not itself network-activated
— a deactivation that would otherwise pull the standalone from the network's other sites, where your
bundled copy never loads. Left unset, that guard stays off and deactivation behaves as it always has.
multisite: it stops the library deactivating a network-active standalone when your plugin is not
itself network-activated, stranding the network's other sites. Left unset, nothing changes.

Both calls belong at `plugins_loaded` priority 0, in the block that owns your container rather
than in a service provider. Priority matters twice, for unrelated reasons:

- Conflict resolution runs at `plugins_loaded` priority 5 and the load at 6, and WordPress
ignores a callback added at or past the priority it is already dispatching. Boot after that
and the whole sequence runs inline, reported with `_doing_it_wrong()`.
- A host that builds its container lazily may *replace* it at priority 0. Hand this library the
container before that happens and it holds an orphan whose bindings were discarded.

Only the second reason picks 0 out of 0 through 4; if your container is already built by then,
anywhere below 5 works. Order among the configuration calls does not matter, so long as they all
precede `Absorber::boot()`.

`Absorber::boot()` is idempotent, so calling it from more than one code path is safe — but the
first call is the one that counts, and the container it saw is the container the library keeps.
Setting a different one afterwards binds nothing into it, and leaves the accessors asking a
container that was never taught about this library. Set the container once, before booting.
Both calls belong at `plugins_loaded` priority 0, in the block that owns your container rather than
in a service provider, since a host that builds its container lazily may replace it at priority 0.
Anything below 5 works for a container already built — conflict resolution runs at
`plugins_loaded` priority 5 and the load at 6, and booting at 5 or later runs the whole sequence
inline, reported with `_doing_it_wrong()`. `Absorber::boot()` is idempotent but keeps the container
its first call saw.

## Registering a sub-plugin

| Key | Type | Required | Meaning |
|---|---|:--:|---|
| `slug` | `string` | ✔ | Unique id — registry key, notice id, activation-tracking key. |
| `bundled_plugin_file` | `string` | ✔ | Absolute path to the **bundled** plugin's main file. This is what gets `require_once`d. |
| `plugin_loaded_constant` | `string` | ✔ | A constant the plugin defines when it loads, **at file scope**. Both copies normally define the *same* name, which is what stands one of them down. **Load guard only** — see [Conflict handling](conflict-handling.md#the-load-guard). |
| `standalone_plugin_basename` | `string` | | The standalone's `dir/file.php` basename, used to detect and deactivate it. Omit when there is no standalone. **Detection only.** |
| `bundled_plugin_file` | `string` | ✔ | Absolute path to the **bundled** plugin's main file what gets `require_once`d. |
| `plugin_loaded_constant` | `string` | ✔ | A constant the plugin defines **at file scope** when it loads; both copies normally define the *same* name. **Load guard only** — see [Conflict handling](conflict-handling.md#the-load-guard). |
| `standalone_plugin_basename` | `string` | | The standalone's `dir/file.php` basename, used to detect and deactivate it; omit when there is none. **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. See [Conflict handling](conflict-handling.md#policies). |
| `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. |
| `conflict_notice_message` | `callable` | | Words all three conflict reports: the merge notice, the still-active notice, and the rewritten activation-error screen. Each falls back to a 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. |

Sub-plugins load in **registration order**, so register a dependency before anything that
extends it at include time, and register each slug exactly once. A config array the library
cannot use throws `Config_Exception` on the spot, in the call you can see in your own stack
trace. A duplicate slug is found later, at the first read of the registry — normally on
`plugins_loaded` — since registrations are buffered until then: it is refused there and reported
through `_doing_it_wrong()`, the first registration under the slug stands, and the second is
discarded.
Sub-plugins load in **registration order**, so register a dependency before anything that extends
it at include time, and register each slug exactly once. An unusable config array throws
`Config_Exception` from `Absorber::register()`; a duplicate slug is caught later, at the first read
of the registry on `plugins_loaded`, where the second registration is discarded and reported through
`_doing_it_wrong()`.

Register unconditionally and put anything you cannot decide up front — a licence, a setting the
site owner can change — in `enabled`, which is re-evaluated on every load. See
Register unconditionally: anything you cannot decide up front belongs in `enabled`. See
[Toggle a sub-plugin from a setting](recipes.md#toggle-a-sub-plugin-from-a-setting).

## How a sub-plugin loads
Expand All @@ -89,55 +71,44 @@ flowchart TD
F --> G["activation_callback, once ever"]
```

Only the dependency gate says anything to the site owner — see [Notices](notices.md). An
unreadable `bundled_plugin_file` is a broken build in your plugin, so it is reported with
`_doing_it_wrong()` instead.

The guard constant is checked **before** the dependency check, so a plugin the admin can watch
working is never reported as missing its requirements. The
[`should_load` filter](filters.md#the-load-gate) sits last and can only veto: it cannot force a
load past a copy already in memory.
Only the dependency gate says anything to the site owner — see [Notices](notices.md). The guard
constant is deliberately checked before the dependency check, and the
[`should_load` filter](filters.md#the-load-gate) can only veto.

## Activation

A bundled plugin is `require_once`d, not activated, so `register_activation_hook()` never fires
for it. Whatever that hook would have done — create a table, seed options — goes in
`activation_callback` instead:
A bundled plugin is `require_once`d, not activated, so `register_activation_hook()` never fires for
it. Whatever that hook would have done goes in `activation_callback` instead:

```php
'activation_callback' => static function ( Sub_Plugin $sub_plugin ) {
\Give\Recurring\Install::create_tables();
},
```

**The wrapper is load-bearing, not decoration.** Like every callable key, `activation_callback` is
checked with `is_callable()` at registration — which happens before the bundled plugin has been
`require_once`d, so before `Give\Recurring\Install` exists. `[ Install::class, 'create_tables' ]`
fails that check and throws `Config_Exception`, on a class that would have been perfectly loadable by
the time the callback ran. A closure is callable the moment it is written and names the class only
when the load pass calls it, with the plugin's code already in memory.

It runs once ever per slug, is passed the `Sub_Plugin`, and only after a require that actually
happened — never for a sub-plugin whose load was skipped. The record lives in the
`{option_prefix}_plugin_absorber_activations` option, a network option on multisite, and is
written *after* the callback returns, so a callback that throws is reported with
`_doing_it_wrong()` and retried next request rather than marked done for good.

**Write it to be idempotent.** "Once, ever" is bookkeeping, not a lock: the record is read, the
callback runs, and the record is written, so two first requests arriving together can both pass
the check. A `dbDelta()` migration survives that; a blind `INSERT` of seed rows does not.

One record for the network is also one *run* for the network, in whichever site's request
reached the load pass first. A site created on the network **after** that run never gets the
callback at all: the record is already set, and nothing re-reads it per site. Per-site work — a
`$wpdb->prefix` table, a per-site option — is yours to loop over, and a site created later is yours
to catch on `wp_initialize_site`: see
**The wrapper is load-bearing.** `activation_callback` is `is_callable()`-checked at registration,
before the bundled plugin is loaded, so `[ Install::class, 'create_tables' ]` throws
`Config_Exception` on a class that does not exist yet. A closure names it only when the load pass
calls it.

It runs only after a require that actually happened. The record lives in the
`{option_prefix}_plugin_absorber_activations` option — a network option on multisite — and is
written *after* the callback returns, so one that throws is reported with `_doing_it_wrong()` and
retried next request.

**Write it to be idempotent.** "Once, ever" is bookkeeping, not a lock: two first requests arriving
together can both run the callback. A `dbDelta()` migration survives that; a blind `INSERT` of seed
rows does not.

One record for the network is one *run* for the network, in whichever site's request reached the
load pass first; a site created afterwards never gets it. Per-site work is yours to loop over, and
a later site yours to catch on `wp_initialize_site`: see
[Do per-site work on multisite](recipes.md#do-per-site-work-on-multisite).

## What changes for the bundled plugin

WordPress includes plugins at global scope; this library includes them from inside a method, so
variables assigned at the top level of the bundled file are function-local, not globals:
This library includes bundled plugins from inside a method, not at global scope, so variables
assigned at the top level of the bundled file are function-local, not globals:

```php
// In the bundled plugin's main file.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -150,9 +121,9 @@ is unaffected.

## Messages are callables, never strings

Your config array is built at plugin load — before `init`, and before your textdomain, so
calling `__()` there raises WordPress's `_load_textdomain_just_in_time` notice. The two message
keys therefore take something to call, and refuse a string outright:
Your config array is built before `init` and before your textdomain, so `__()` there raises
WordPress's `_load_textdomain_just_in_time` notice. The two message keys therefore take something
to call, and refuse a string outright:

```php
'conflict_notice_message' => static fn() => __( 'Recurring ships with Give now.', 'give' ),
Expand All @@ -165,29 +136,24 @@ keys therefore take something to call, and refuse a string outright:
'conflict_notice_message' => __( 'Recurring ships with Give now.', 'give' ),
```

Each callable is passed the `Sub_Plugin` and called on every read; a return that will not cast
to a string is treated as though nothing were configured.

**A plain function name is text, not a call.** `date`, `flush` and `key` are all real functions
and all plausible values, so wherever a string *is* accepted it is the value itself — which bars
`'Give_Recurring::get_conflict_message'` as much as `'give_recurring_conflict_message'`.
Each callable is passed the `Sub_Plugin` and called on every read; a return that will not cast to a
string is treated as though nothing were configured. Wherever a string *is* accepted it is the
value itself and never a function name to call.

`conflict_policy` is the one key that takes either, since a policy is never text a user reads:
`conflict_policy` takes either, since a policy is never text a user reads:

```php
'conflict_policy' => Conflict_Policy::DEFER,
'conflict_policy' => static fn( Sub_Plugin $sub_plugin ) => give_conflict_policy_for( $sub_plugin ),
```

`standalone_plugin_basename` takes a string only: it names a file already on disk.
`dependency_check` and `activation_callback` have nothing a string could collide with, so they
`standalone_plugin_basename` takes a string only. `dependency_check` and `activation_callback`
accept every callable form, a plain function name included.

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 evaluates as enabled. Give it a `bool` or a `callable`, and nothing else. The
[filters](filters.md) are the other way in, and run last, after the configured value.
Every typed key rejects a shape it cannot use at registration rather than at read time, including a
`[ class, method ]` pair whose method does not exist. `enabled` is the exception: not callable means
it is read as a boolean, so an array or an object there evaluates as enabled — give it a `bool` or
a `callable`. The [filters](filters.md) are the other way in, and run last.

## Complete example

Expand Down
Loading
Loading