diff --git a/CLAUDE.md b/CLAUDE.md index 94223d0..f2f44e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -642,7 +642,10 @@ under a `Nexcess\SubPluginLoader\` namespace, with a `Config::set_version()` tha `ob_start()` approach the `wp_admin_notice_markup` filter replaced. Human-facing docs are `README.md` plus `docs/installing.md`, `docs/configuration.md`, -`docs/conflict-handling.md`, `docs/filters.md`, and `docs/notices.md`. Keep them short and keep -rationale here or in code comments — do not grow the README back. `docs/` is `export-ignore`d and +`docs/recipes.md`, `docs/conflict-handling.md`, `docs/filters.md`, `docs/notices.md` and +`docs/extending.md`. Keep them short and keep rationale here or in code comments — do not grow the +README back. They are written for a host developer integrating the library, not for a maintainer: +`docs/extending.md` is the only one that names internal classes, and every other file describes +behaviour instead. `docs/` is `export-ignore`d and `README.md` is not, so a link from the README into `docs/` must be an absolute repository URL; links *between* files inside `docs/` stay relative. diff --git a/README.md b/README.md index 5be435a..0b9a0fe 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ 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][installing] for the one prefixing rule you must not get wrong. +plugins shipping different versions of this library will collide otherwise. [Installing][installing] +has the one prefixing rule you must not get wrong. ## Quick start @@ -23,7 +23,7 @@ use Nexcess\PluginAbsorber\Absorber; add_action( 'plugins_loaded', function () { Config::set_hook_prefix( 'give' ); // required — keys the hooks and options - Config::set_container( give()->container ); // required — every collaborator resolves from it + Config::set_container( give()->container ); // required — every part of the library comes from it Absorber::register( [ 'slug' => 'give-recurring', @@ -36,22 +36,14 @@ add_action( 'plugins_loaded', function () { }, 0 ); ``` -The container is required — any StellarWP `ContainerInterface` implementation, the one you already -hand to Telemetry or Uplink. Every collaborator comes from it. +The container is required, and any StellarWP `ContainerInterface` implementation will do — the one +you already hand to Telemetry or Uplink. -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. +**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. ## Docs @@ -62,6 +54,7 @@ configuration doc. - [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. +- [Extending][extending] — swapping out a piece of the library. - [Tests][tests] — running the suite, the fixtures and traits it offers, and every scenario it drives the library through. @@ -75,6 +68,7 @@ source. [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 +[extending]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/extending.md [tests]: https://github.com/stellarwp/plugin-absorber/blob/main/tests/README.md ## License diff --git a/docs/configuration.md b/docs/configuration.md index e9ca994..1a1d2d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,12 +1,12 @@ # Configuration -## Host configuration +## Setting up ```php use Nexcess\PluginAbsorber\Config; Config::set_hook_prefix( 'give' ); // required — keys hooks and options -Config::set_container( give()->container ); // required — every collaborator resolves from it +Config::set_container( give()->container ); // required — everything is resolved from it ``` The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws @@ -14,115 +14,82 @@ The hook prefix accepts letters, numbers, hyphens, and underscores. Anything els 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 +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). -Both calls are required, and both belong at `plugins_loaded` priority 0, in your own container -block rather than in a service provider. +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: -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. +- 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. -Priority matters twice, for two unrelated reasons. Conflict resolution runs 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 boot has to land before 5, which leaves 0 through 4. 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. It is that second one that picks 0 -out of the five, so if your container is already built by then, anywhere below 5 works. +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()`. -## Rebinding a collaborator - -`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; - -$container->singleton( Registrar_Interface::class, My_Registrar::class ); -``` - -| Interface | Default | Responsibility | -|---|---|---| -| `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. | -| `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. | -| `Contracts\Activator_Interface` | `Activator` | Runs a sub-plugin's activation callback once, ever. | - -`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. - -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 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. - -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 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 +## 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. Both copies must define the *same* name, **at file scope**. See [Conflict handling](conflict-handling.md). **Load guard only.** | -| `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.** | +| `plugin_loaded_constant` | `string` | ✔ | A constant the plugin defines when it loads. Both copies must define the *same* name, **at file scope**. **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.** | | `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_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. | | `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. | -The load guard and the standalone basename are deliberately two separate keys. No constant does -double duty as both a guard and a path resolver. - -## Registration +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 the exception that surfaces later, on `plugins_loaded`, since +registrations are buffered until the first read. + +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 +[Toggle a sub-plugin from a setting](recipes.md#toggle-a-sub-plugin-from-a-setting). + +## How a sub-plugin loads + +Each sub-plugin passes five gates, in order, and is skipped on the first failure: + +```mermaid +flowchart TD + A["enabled"] -->|false| S1["skipped, silently"] + A -->|true| B["plugin_loaded_constant already defined?"] + B -->|yes| S2["skipped: a copy is already running"] + B -->|no| C["dependency_check"] + C -->|false| S3["skipped, dependency notice queued"] + C -->|true| D["bundled file is readable?"] + D -->|no| S4["skipped, reported with _doing_it_wrong"] + D -->|yes| E["should_load filter"] + E -->|false| S5["skipped, silently"] + E -->|true| F["require_once"] + F --> G["activation_callback, once ever"] +``` -Sub-plugins load in **registration order**, so register a dependency before anything that extends it -at include time. +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. -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 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. +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. ## Activation -A bundled plugin is `require_once`d, not activated, so `register_activation_hook()` never fires for -it — whatever that hook would have done, creating a table or seeding options, would otherwise never -happen at all. [`activation_callback`](#sub-plugin-keys) fills that gap: +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: ```php 'activation_callback' => static function ( Sub_Plugin $sub_plugin ) { @@ -130,34 +97,24 @@ happen at all. [`activation_callback`](#sub-plugin-keys) fills that gap: }, ``` -It runs once ever per slug, is passed the `Sub_Plugin`, and runs only after a require that actually -happened — never for a sub-plugin whose load was skipped, because a schema appearing for a plugin -that is not loaded is worse than no schema at all. +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 requests arriving together on a site that has never -run it can both pass the check, and a callback that fails is deliberately left unrecorded to be -retried. A `dbDelta()` migration or a `CREATE TABLE IF NOT EXISTS` already survives both; a blind -`INSERT` of seed rows does not. - -The record lives in the `{option_prefix}_plugin_absorber_activations` option, a network option on -multisite for the same reason the [notice queue](notices.md) is one: `deactivate_plugins()` is -network-wide, so a merge that happened network-wide must not re-run the callback on every site. The -slug is recorded *after* the callback returns, so a callback that fails is retried on the next -request rather than marked done and silently skipped forever. A callback that throws cannot take the -site down with it: the load pass reports it with `_doing_it_wrong()`, loads the sub-plugins behind it -as usual, and leaves the record unwritten. - -One record for the network also means one *run* for the network, in whichever site's request reached -the load pass first. Per-site work — a `$wpdb->prefix` table, a per-site option — is the callback's -own job to loop over `get_sites()` for, or bind `Activator_Interface` and record "once, ever" -somewhere else: your own migration table, or a per-site option. - -## 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: +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. Per-site work — a `$wpdb->prefix` table, a per-site option — is +yours to loop over: 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: ```php // In the bundled plugin's main file. @@ -165,16 +122,14 @@ $my_plugin = new My_Plugin(); // Not a global. `global $my_plugin;` $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. +Everything else — function and class declarations, `define()`, hook registration, `__FILE__` — +is unaffected. ## Messages are callables, never strings -Your config array is built at plugin load — before `init`, and before your textdomain. Calling -`__()` there is what raises WordPress's `_load_textdomain_just_in_time` notice. So the two message -keys take something to call, and refuse a string outright: +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: ```php 'conflict_notice_message' => static fn() => __( 'Recurring ships with Give now.', 'give' ), @@ -183,40 +138,33 @@ keys take something to call, and refuse a string outright: ``` ```php -// Config_Exception at registration. Translated or not, a string here can only have been produced -// too early -- and nothing in the value says which it was. +// Config_Exception at registration: translated or not, a string here was produced too early. 'conflict_notice_message' => __( 'Recurring ships with Give now.', 'give' ), ``` -Each callable is passed the `Sub_Plugin` and called on every read, so nothing is resolved at -registration. A return that will not cast to a string is treated as though nothing were configured. +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. That bars both -string spellings of a callable — `'give_recurring_conflict_message'` and -`'Give_Recurring::get_conflict_message'` — in favour of the array and closure forms above. +**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'`. -`conflict_policy` is the one key that takes either. A policy is usually a `Conflict_Policy` -constant with nothing to defer, and it is never text a user reads: +`conflict_policy` is the one key that 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, so there is -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 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. +`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 +accept every callable form, a plain function name included. -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. +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. ## Complete example diff --git a/docs/conflict-handling.md b/docs/conflict-handling.md index 986580d..0d59600 100644 --- a/docs/conflict-handling.md +++ b/docs/conflict-handling.md @@ -2,7 +2,8 @@ ## Policies -When a sub-plugin's standalone counterpart is still active: +A policy is only reached for a sub-plugin that is enabled, names a `standalone_plugin_basename`, and +whose standalone is active right now. Everything else is skipped before any policy is read. | Policy | Behavior | |---|---| @@ -25,82 +26,56 @@ add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $ }, 10, 2 ); ``` -**An unrecognised policy is treated as `NOTICE_ONLY`**, never as consent to deactivate. -`Conflict_Policy::is_valid()` decides, so a typo like `'defered'` — in a policy a host persisted in -an option, or in whatever that filter returned — only produces a notice. A value nobody chose must -not turn off a plugin somebody chose. +**An unrecognised policy is treated as `NOTICE_ONLY`**, never as consent to deactivate — a typo +like `'defered'`, from a persisted option or from the filter, only produces a notice. -A policy is only reached for a sub-plugin that is enabled, names a `standalone_plugin_basename`, and -whose standalone is active right now; everything else is skipped before any policy is read. +For what the site owner sees under each policy, see +[the recipe](recipes.md#choose-a-policy-and-know-what-the-site-owner-sees). ## When resolution runs -At `plugins_loaded` priority 5, one ahead of the load pass at 6: a standalone that survives the -conflict defines the guard constant as it loads, and the load pass has to see that. Priority 5 is -also the deadline for `Absorber::boot()`, since this is the first step it has to wire. - -It runs **only on an interactive admin `GET`** — not WP-CLI, not cron, not ajax, not a form POST — -because resolving can deactivate a plugin and end the request with a redirect. Ungated, a visitor's -checkout POST would come back as a 302 that discards what was submitted and drops the order, and a -WP-CLI command would exit having printed nothing, because `header()` is a no-op under the CLI SAPI. -Waiting costs nothing: the standalone is still there to detect on the next page view. - -**A `GET` that carries an action is skipped too.** `update.php?action=upgrade-plugin`, -`plugins.php?action=activate` and the `admin-post.php` links are all admin `GET`s that *do* something, -and a redirect discards their work exactly as it would a POST's — the user clicks Update and lands on -a list screen with nothing updated. Anything naming an `action` or `action2`, and the endpoints that -exist only to perform work, wait for the next plain page view. This is deliberately blunt: a -read-only `post.php?action=edit` waits as well. - -It also requires the capability that matches what deactivation actually does. Deactivating a -standalone is network-wide wherever a network exists, so the check is `manage_network_plugins` on -multisite and `activate_plugins` otherwise — `activate_plugins` alone does not imply authority over -every site on a network. The gate matters at all because `plugins_loaded` fires well before -`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. - -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. +At `plugins_loaded` priority 5, one ahead of the load at priority 6: a standalone that survives the +conflict defines the guard constant as it loads, and the load has to see that. Priority 5 is also +the deadline for `Absorber::boot()`, since this is the first step it wires. + +Resolution runs **only on an interactive admin `GET`** — not WP-CLI, cron, ajax, or a form +POST — because resolving can deactivate a plugin and end the request with a redirect, and a 302 +would discard whatever a POST submitted. Waiting costs nothing: the standalone is still there to +detect on the next page view. + +**A `GET` carrying an action is skipped too.** `update.php?action=upgrade-plugin` and +`plugins.php?action=activate` are admin `GET`s that *do* something, and a redirect discards their +work exactly as it would a POST's. Anything naming `action` or `action2` waits for the next plain +page view — deliberately blunt, so a read-only `post.php?action=edit` waits as well. + +Resolution also requires the capability matching what deactivation does: `manage_network_plugins` on +multisite and `activate_plugins` otherwise, since deactivating a standalone is network-wide wherever +a network exists. The check matters because `plugins_loaded` fires well before `auth_redirect()`, so +an unauthenticated GET of an admin URL reaches this code on its way to the login screen. + +These gates apply whatever the policy is — the non-destructive policies only queue a notice, and a +notice is neither shown nor cleared for a user without the same capability. 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. +standalone's own deactivation hook would otherwise run this early: a routine `flush_rewrite_rules()` +in it would regenerate the rules before `init` declared a single post type, and every custom +permalink on the site would start 404ing. ## The redirect -The standalone's code is already in memory by the time the conflict is resolved — WordPress included -it before `plugins_loaded` — so the redirect is how the request sheds it. The destination is **the -screen being requested**, not the one the user came from: it re-renders without the standalone, and -the admin stays where they asked to be. `/wp-admin/` and the network and user admin roots mean the -dashboard. The update screens (`update.php`, `update-core.php`) go to `plugins.php` instead, because -reloading one of those would re-run an update, and anything that names no usable admin screen falls -back to `plugins.php`. - -The destination is assembled from the screen name and query string through `admin_url()` — or -`network_admin_url()` and `user_admin_url()` in the network and user admins, so a request resolved -in one of those comes back to it — never from the request URI itself, so nothing in the URI decides -the host. There is no redirect loop: the next request has no active standalone, so nothing +The standalone's code is already in memory by the time the conflict is resolved — WordPress +included it before `plugins_loaded` — so the redirect is how the request sheds it. The destination +is **the screen being requested**, not the one the user came from: it re-renders without the +standalone, and the admin stays where they asked to be, network or user admin included. `/wp-admin/` +and the admin roots mean the dashboard; `update.php` and `update-core.php` go to `plugins.php` +instead, because reloading either would re-run an update, and so does anything naming no usable +admin screen. There is no redirect loop: the next request has no active standalone, so nothing resolves. With several sub-plugins in conflict, all of them are resolved before the one redirect at the end, -and the redirect is skipped entirely once headers have been sent — which is what a host booting too -late produces, since the `_doing_it_wrong()` notice is output. The request then finishes rendering -instead of dying blank. - -`Conflict\Redirector` makes that decision and returns it; the redirect itself is the resolver's. The -merge notice is queued before either, so the explanation survives whether or not the request ends in -a redirect. +and the redirect is skipped entirely once headers have been sent — the request then finishes +rendering instead of dying blank. The [merge notice](notices.md) is queued first either way, so the +explanation survives whether or not the request ends in a redirect. ## The load guard @@ -119,19 +94,7 @@ if ( ! defined( 'GIVE_RECURRING_VERSION' ) ) { 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 — -[the recipe](recipes.md#defer-to-a-newer-standalone) is ten lines. - -**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 +## Reactivating the standalone The guard cannot help on the request that *activates* the standalone: WordPress includes the plugin being activated **after** the bundled copy has already loaded, so that re-declaration is a real @@ -143,21 +106,20 @@ So the library filters `wp_admin_notice_markup` and swaps that sentence for the WordPress floor at 6.4: the filter does not exist before it. 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 -wording in place rather than blanking the notice. - -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 -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. +screen is `plugins`, or `plugins-network` in the network admin; the `plugin` query arg names a +standalone this library has registered; and `_error_nonce` verifies. + +The replacement runs through `wp_kses_post()`, so a knowledge-base link survives, and a message that +filters down to nothing leaves core's wording in place rather than blanking the notice. A host that +would rather keep core's wording throughout can remove the filter — see [Extending](extending.md). + +## 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 — [the recipe](recipes.md#defer-to-a-newer-standalone) is ten lines. + +**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 deriving the path +from the load guard: one key is the guard, the other is the path, and no constant does both jobs. diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..ba8c382 --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,87 @@ +# Extending + +Everything the library does past registration is a small object resolved from your container, so any +one piece can be swapped without replacing the rest. This is the one doc that names those classes; +nothing else in the docs asks you to know them. + +## The seams + +Bind any of these ids before or after `Absorber::boot()` — boot binds the defaults and skips an +*interface* your container already answers for, so your binding wins either way: + +```php +use Nexcess\PluginAbsorber\Contracts\Registrar_Interface; + +$container->singleton( Registrar_Interface::class, My_Registrar::class ); +``` + +| Interface | Default | Responsibility | +|---|---|---| +| `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. | +| `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` | Applies the policy to a conflict. | +| `Contracts\Activator_Interface` | `Activator` | Runs a sub-plugin's activation callback once, ever. | + +**Rebind `Plugin_Checker_Interface` 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. + +**Rebind `Activator_Interface` to record "once, ever" somewhere else**: your own migration table, or +a per-site option on a large multisite network where one run for the whole network is not what you +want. See [the recipe](recipes.md#do-per-site-work-on-multisite). + +## Class-name bindings must come after boot + +Everything without an interface is bound by class name — `Notices\Store`, `Notices\Renderer`, +`Notices\Presenter`, `Conflict\Detector`, `Conflict\Gatekeeper`, `Conflict\Redirector`, +`Conflict\Rewriter`, `Loader`, `Registry_Reader`, `Boot\Scheduler`. Bind one of those **after** +`Absorber::boot()`: di52 reports `has()` true for any class that exists, bound or not, so boot cannot +tell your binding from the container's own willingness to build the class, and replaces it. + +## What rebinding does not buy you + +Binding your own `Conflict\Contracts\Resolver_Interface` does not put you in charge of *when* +resolution may run. The request and capability gates — [an interactive admin `GET` carrying no +action, and the capability to deactivate across the +network](conflict-handling.md#when-resolution-runs) — are asked before your resolver is built 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. + +## The notice queue + +Four objects, so you can replace the part you have an opinion about: + +- `Notices\Writer` decides what a notice says. The one behind an interface, and the seam for a host + already running its own notices library. +- `Notices\Store` keeps the queue. Rebind to store it elsewhere. +- `Notices\Renderer` draws it. Rebind to change the markup and leave the storage alone. +- `Notices\Presenter` decides who may consume it, and does the render-then-clear. + +Rendering the queue yourself needs none of this — read +[Notices](notices.md#rendering-them-yourself) instead. + +## Removing the built-in admin hooks + +Both admin-side hooks are named callbacks, so `remove_filter()` reaches them: + +```php +// Keep core's wording on the plugin activation error screen. +remove_filter( 'wp_admin_notice_markup', [ Absorber::class, 'filter_activation_error_markup' ] ); + +// Render the notice queue yourself, and nowhere else. +remove_action( 'all_admin_notices', [ Absorber::class, 'render_notices' ] ); +``` + +## When a binding is wrong + +`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, rather than letting a +`TypeError` blame this library for your typo inside `plugins_loaded`. Whatever your container raises +for a binding it cannot build at all comes through unwrapped. `Absorber::all()` drops anything a +rebound registrar returns that is not a `Sub_Plugin`. + +Nothing is built at boot beyond the two objects that do the booting: each hook resolves its +collaborator when it fires, so a request that reaches none of them builds none of them, and you may +rebind right up until the hook runs. diff --git a/docs/filters.md b/docs/filters.md index 14e8da2..403b21e 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -8,12 +8,10 @@ | `{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. | -Each runs last, after the configured value and any fallback. Because they fire when the value is -asked for rather than when the sub-plugin is registered, they are also the place to call `__()` — -by then the textdomain is loaded. - -A filter returning a non-scalar yields an empty string rather than a fatal cast. A `conflict_policy` -return that is not one of the three constants is treated as [`NOTICE_ONLY`, never as consent to +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 +non-scalar return yields an empty string rather than a fatal cast, and a `conflict_policy` return +that is not one of the three constants is treated as [`NOTICE_ONLY`, never as consent to deactivate](conflict-handling.md#policies). ## The load gate @@ -29,11 +27,11 @@ add_filter( 'give/plugin_absorber/should_load', function ( $should_load, $sub_pl ``` 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. +the guard constant, the dependency check and the file check. Returning `true` cannot force a load +past the guard constant; 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 +load*. A host filter named for the opposite — LearnDash's `learndash_module_{x}_disabled`, where +true means *do not load* — inverts the gate if passed through unnegated, and the failure is silent in the direction that loads a plugin the site turned off. diff --git a/docs/installing.md b/docs/installing.md index 49cbee3..b533d0a 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -11,7 +11,7 @@ here rather than enforced in `require`. ## Strauss Prefix this library with [Strauss](https://github.com/stellarwp/global-docs/blob/main/docs/strauss-setup.md). -Two or more plugins shipping different versions of it will collide otherwise. +Two plugins shipping different versions of it will collide otherwise. > **Nothing may rewrite a sub-plugin's `plugin_loaded_constant`.** Those are real, shared runtime > constants: the whole safety mechanism depends on the bundled copy and the standalone defining the diff --git a/docs/notices.md b/docs/notices.md index d9d0554..97ca05a 100644 --- a/docs/notices.md +++ b/docs/notices.md @@ -3,21 +3,18 @@ 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 `{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 +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 -across every site on the network. - -An option and not a transient, on purpose. With a persistent object cache a transient never reaches -the database, so a `wp_cache_flush()` from a deploy script or a "purge cache" button would destroy -the queue. The deactivation notice is raised exactly once and never re-queued, so losing it means -the site owner is never told their plugin was turned off. +across every site on the network. It is an option and not a transient because a persistent object +cache keeps transients out of the database entirely, and a `wp_cache_flush()` would destroy a notice +that is raised exactly once and never re-queued. ## Who sees them -`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. +Rendering prints the queue and then clears it, 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. On multisite that is usually a network administrator rather than the site administrator who installed the plugin: core maps `activate_plugins` through `manage_network_plugins` unless the @@ -25,27 +22,23 @@ network has enabled the plugins menu for individual sites. Conflict resolution d mapping and asks for `manage_network_plugins` by name — see [conflict handling](conflict-handling.md#when-resolution-runs). -## `conflict_notice_message` is used twice +## One message, two places -The same message backs the queued notice raised when the standalone is deactivated *and* the -activation-error screen a user meets if they try to re-activate it — the latter is rewritten by -`Conflict\Rewriter`, not by anything in this layer; see -[conflict handling](conflict-handling.md#what-the-guard-cannot-do). 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. +`conflict_notice_message` backs both the queued notice raised when the standalone is deactivated and +the [activation-error screen](conflict-handling.md#reactivating-the-standalone) a user meets if they +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. ## Rendering them yourself `Absorber::notices()->option_name()` tells you where the queue is kept, so you can render it -yourself without replacing anything — and it answers for whichever writer the site is running, so a -rebound implementation keeping its notices elsewhere still gives you the right name. The value is an -`array` 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 default 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 `

` of your own is left as it is -rather than nested inside another. +yourself without replacing anything. The value is an `array` 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 +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 `

` of your own is left as it is rather than nested inside another. ```php use Nexcess\PluginAbsorber\Absorber; @@ -77,14 +70,5 @@ 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 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 only one behind an interface, -`Notices\Contracts\Writer_Interface` — the seam for a host that already runs its own notices library -and wants to reword rather than replace the plumbing, and the id to bind rather than -`Notices\Writer`. `Presenter` takes `Store` and `Renderer` and is bound by class name like both of -them, since nothing in the library dispatches on it: the trampoline on `all_admin_notices` is its -only caller. Rebinding `Notices\Renderer` replaces the markup and leaves the storage alone, and -rebinding `Notices\Store` does the reverse — but bind either of them, or `Presenter`, *after* -`Absorber::boot()`, since the provider cannot tell a host's binding of a class it could build itself -from no binding at all and replaces it. The interface may be bound on either side of boot. +To reword the notices or replace the markup rather than render them alongside, see +[Extending](extending.md). diff --git a/docs/recipes.md b/docs/recipes.md index e6a8912..d2ed5b9 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -18,37 +18,21 @@ Absorber::register( [ ] ); ``` -`enabled` is the first of five gates, and the only one ahead of the guard constant: +`enabled` is the first gate, ahead of the guard constant — +[how a sub-plugin loads](configuration.md#how-a-sub-plugin-loads) has the rest of the chain. Three +things follow from where it sits. -```mermaid -flowchart TD - A["enabled"] -->|false| S1["skipped, silently"] - A -->|true| B["plugin_loaded_constant already defined?"] - B -->|yes| S2["skipped: a copy is already running"] - B -->|no| C["dependency_check"] - C -->|false| S3["skipped, dependency notice queued"] - C -->|true| D["bundled file is readable?"] - D -->|no| S4["skipped, reported with _doing_it_wrong"] - D -->|yes| E["should_load filter"] - E -->|false| S5["skipped, silently"] - E -->|true| F["require_once"] - F --> G["activation_callback, once ever"] -``` - -Three things follow from where it sits. +**Switching the toggle off unloads nothing.** The `require_once` on this request already happened; +the next request is the one that skips it. Anything that has to stop immediately is the sub-plugin's +own business to gate. -**Switching the toggle off unloads nothing.** The `require_once` on the current request already -happened; the next request is the one that skips it. Anything that has to stop immediately — a -feature, an endpoint — is the sub-plugin's own business to gate. +**A disabled sub-plugin is invisible to conflict resolution too.** The toggle is checked first there +as well, so an off toggle also stops the standalone being deactivated: off means this library leaves +the plugin alone, standalone included. -**A disabled sub-plugin is invisible to conflict resolution as well.** `Conflict\Detector` asks -`is_enabled()` before it asks anything else, so an off toggle also stops the standalone being -deactivated. That is the intent: off means this library leaves the plugin alone, standalone -included, and a site running the standalone keeps running it. - -**Keep the callable cheap.** It is called on the conflict pass and again on the load pass, so at -least twice on an admin page view. An option read is fine; a remote licence check belongs behind a -cached value. +**Keep the callable cheap.** It runs on the conflict pass and again on the load pass, so at least +twice on an admin page view. An option read is fine; a remote licence check belongs behind a value +you have already cached. ## Register several add-ons from one manifest @@ -57,36 +41,25 @@ registration order and anything extended at include time has to be registered be ```php $sub_plugins = [ - [ - 'slug' => 'give-recurring', - 'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION', - 'standalone_plugin_basename' => 'give-recurring/give-recurring.php', - ], - [ - 'slug' => 'give-stripe', - 'plugin_loaded_constant' => 'GIVE_STRIPE_VERSION', - 'standalone_plugin_basename' => 'give-stripe/give-stripe.php', - ], + 'give-recurring' => 'GIVE_RECURRING_VERSION', + 'give-stripe' => 'GIVE_STRIPE_VERSION', ]; -foreach ( $sub_plugins as $sub_plugin ) { - Absorber::register( - $sub_plugin + [ - 'bundled_plugin_file' => sprintf( - '%1$ssub-plugins/%2$s/%2$s.php', - GIVE_PLUGIN_DIR, - $sub_plugin['slug'] - ), - 'enabled' => static fn( Sub_Plugin $sub ) => give_addon_is_enabled( $sub->get_slug() ), - ] - ); +foreach ( $sub_plugins as $slug => $constant ) { + Absorber::register( [ + 'slug' => $slug, + 'bundled_plugin_file' => GIVE_PLUGIN_DIR . "sub-plugins/{$slug}/{$slug}.php", + 'plugin_loaded_constant' => $constant, + 'standalone_plugin_basename' => "{$slug}/{$slug}.php", + 'enabled' => static fn() => give_addon_is_enabled( $slug ), + ] ); } ``` An entry the library cannot use throws `Config_Exception` out of the `Absorber::register()` call it -is in, so a typo names itself in a stack trace pointing at your loop rather than surfacing later -from inside a core hook. A duplicate `slug` is the one that does surface later — the registrations -are buffered and collide at the first read, on `plugins_loaded`. +is in, so a typo names itself in a stack trace pointing at your loop. A duplicate `slug` surfaces +later: registrations are buffered, and the collision is raised at the first read on +`plugins_loaded`. ## Choose a policy, and know what the site owner sees @@ -111,15 +84,14 @@ flowchart TD | `NOTICE_ONLY` | left running | the bundled copy stands down | unchanged until someone acts on the notice | | `DEFER` | left running | the bundled copy stands down | unchanged, and nothing is said | -The redirect under `DEACTIVATE` is why the deactivation is not silent to the user: it re-renders the -screen with the standalone's code gone. It happens once per request no matter how many standalones -were turned off, and only on an interactive admin `GET` that carries no `action` — -[conflict handling](conflict-handling.md#when-resolution-runs) has the full gate list. +The redirect under `DEACTIVATE` re-renders the screen with the standalone's code gone. It happens +once per request however many standalones were turned off, and only on an interactive admin `GET` +carrying no `action` — [conflict handling](conflict-handling.md#when-resolution-runs) has the gates. ## Ship the absorption over several releases -Bundling the code and taking over from the standalone do not have to be the same release. Moving -the `conflict_policy` one step per release lets a site be warned before anything of theirs is turned +Bundling the code and taking over from the standalone do not have to be the same release. Moving the +`conflict_policy` one step per release lets a site be warned before anything of theirs is turned off: ```mermaid @@ -130,12 +102,9 @@ flowchart LR ``` Release 1 is the safe one to leave in place for a while: the bundled copy ships dormant on every -site that has the standalone, which is exactly the population you are least sure about, and the -guard constant is doing the work rather than any decision of yours. Release 3 is the only one that -touches a site's active plugins. - -Nothing here needs a code change per release beyond the constant — or none at all, if the policy -comes from a callable reading a value you can move without shipping: +site that has the standalone, which is the population you are least sure about. Release 3 is the +only one that touches a site's active plugins. Each step is a one-constant change — or none at all, +if the policy comes from a callable reading a value you can move without shipping: ```php 'conflict_policy' => static fn() => get_option( 'give_absorption_stage', Conflict_Policy::DEFER ), @@ -146,10 +115,9 @@ misspelt option cannot turn a plugin off. ## Defer to a newer standalone -The library never compares versions — deliberately, since "newer" is a question only the host can -answer. Express it as a `conflict_policy` filter, which runs last and has the final say. The -standalone's code is already loaded by the time this is asked, so its own version constant is there -to read: +The library never compares versions — "newer" is a question only the host can answer. Express it as +a `conflict_policy` filter, which runs last and has the final say. The standalone's code is loaded +by the time this is asked, so its own version constant is there to read: ```php add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $sub_plugin ) { @@ -167,14 +135,14 @@ add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $ ``` `DEFER` leaves the standalone active, and the guard constant then stands the bundled copy down on -its own. That is the whole mechanism: no version is stored anywhere, and the site converges the -moment the bundled copy catches up. +its own. No version is stored anywhere, and the site converges the moment the bundled copy catches +up. ## Do per-site work on multisite -Deactivation is network-wide, the notice queue is a network option, and so is the activation -record — so `activation_callback` runs **once for the network**, in whichever site's request -reached the load pass first. Per-site work loops: +Deactivation is network-wide, the notice queue is a network option, and so is the activation record, +so `activation_callback` runs **once for the network** — in whichever site's request reached the +load pass first. Per-site work loops: ```php 'activation_callback' => static function ( Sub_Plugin $sub_plugin ) { @@ -192,11 +160,8 @@ reached the load pass first. Per-site work loops: }, ``` -That is the right shape for a handful of sites and the wrong one for a large network, where the -loop runs inside `plugins_loaded` on one unlucky request. Bind `Activator_Interface` instead and -record "once, ever" per site — a per-site option, or your own migration table — so each site pays -only for itself. - -Write the callback to be idempotent either way. "Once, ever" is bookkeeping rather than a lock: the -record is written after the callback returns, so a failure is retried, and two first requests -arriving together can both run it. +That is the right shape for a handful of sites and the wrong one for a large network, where the loop +runs inside `plugins_loaded` on one unlucky request. Replace the once-ever bookkeeping — see +[Extending](extending.md) — and record it per site, so each site pays only for itself. Either way, +write the callback to be idempotent: "once, ever" is bookkeeping rather than a lock, and +[activation](configuration.md#activation) has the retry and concurrency detail.