diff --git a/README.md b/README.md index 56930f1..5be435a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ configuration doc. - [Installing][installing] — Composer, Strauss, and the constants Strauss must leave alone. - [Configuration][configuration] — the hook prefix, the container, every sub-plugin key. +- [Recipes][recipes] — a settings toggle, a manifest of add-ons, and staging the absorption across + releases. - [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. @@ -69,6 +71,7 @@ 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 [notices]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/notices.md diff --git a/docs/conflict-handling.md b/docs/conflict-handling.md index 78c993b..986580d 100644 --- a/docs/conflict-handling.md +++ b/docs/conflict-handling.md @@ -123,7 +123,8 @@ yet at the moment the guard is read, and the bundled copy would load on top of i **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. +`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 diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..e6a8912 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,202 @@ +# Recipes + +The shapes hosts actually write. [Configuration](configuration.md) is the key-by-key reference +behind them. + +## Toggle a sub-plugin from a setting + +Register unconditionally and put the condition in `enabled`. It is re-read on every request rather +than resolved at registration, so the settings screen saves an option and does nothing else: + +```php +Absorber::register( [ + 'slug' => 'give-recurring', + 'bundled_plugin_file' => GIVE_PLUGIN_DIR . 'sub-plugins/give-recurring/give-recurring.php', + 'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION', + 'standalone_plugin_basename' => 'give-recurring/give-recurring.php', + 'enabled' => static fn() => (bool) get_option( 'give_recurring_enabled', true ), +] ); +``` + +`enabled` is the first of five gates, and the only one ahead of the guard constant: + +```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 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 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. + +## Register several add-ons from one manifest + +One array, one loop — which is also how you control the order, since sub-plugins load in +registration order and anything extended at include time has to be registered before its extender: + +```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', + ], +]; + +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() ), + ] + ); +} +``` + +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`. + +## Choose a policy, and know what the site owner sees + +The policy is only reached for a sub-plugin that is enabled, names a `standalone_plugin_basename`, +and whose standalone is active right now: + +```mermaid +flowchart TD + A["plugins_loaded 5: is the standalone active?"] -->|no| Z["nothing to resolve"] + A -->|yes| P{"conflict_policy"} + P -->|DEACTIVATE| D1["deactivate silently, queue the merge notice, redirect"] + P -->|NOTICE_ONLY| N1["queue a notice, leave the standalone running"] + P -->|DEFER| F1["do nothing at all"] + D1 --> D2["next request: the bundled copy loads"] + N1 --> G["this request: the bundled copy stands down on the guard constant"] + F1 --> G +``` + +| Policy | The standalone | This request | Afterwards | +|---|---|---|---| +| `DEACTIVATE` | turned off, silently, network-wide | its code is still in memory, so the bundled copy stands down; the user is redirected back to the screen they asked for | the bundled copy loads, and a merge notice explains the swap | +| `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. + +## 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 +off: + +```mermaid +flowchart LR + R1["Release 1
bundle it, DEFER
standalone sites unchanged,
everyone else gets the bundled copy"] --> R2 + R2["Release 2
NOTICE_ONLY
standalone sites are asked
to deactivate it"] --> R3 + R3["Release 3
DEACTIVATE
the remainder are merged,
and told so"] +``` + +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: + +```php +'conflict_policy' => static fn() => get_option( 'give_absorption_stage', Conflict_Policy::DEFER ), +``` + +An unrecognised value is treated as `NOTICE_ONLY`, never as consent to deactivate, so a stale or +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: + +```php +add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $sub_plugin ) { + if ( $sub_plugin->get_slug() !== 'give-recurring' ) { + return $policy; + } + + $standalone = defined( 'GIVE_RECURRING_VERSION' ) ? GIVE_RECURRING_VERSION : '0'; + + // Let a standalone that is ahead of the bundled copy keep the site. + return version_compare( $standalone, GIVE_RECURRING_BUNDLED_VERSION, '>' ) + ? Conflict_Policy::DEFER + : $policy; +}, 10, 2 ); +``` + +`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. + +## 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: + +```php +'activation_callback' => static function ( Sub_Plugin $sub_plugin ) { + if ( ! is_multisite() ) { + \Give\Recurring\Install::create_tables(); + + return; + } + + foreach ( get_sites( [ 'fields' => 'ids', 'number' => 0 ] ) as $site_id ) { + switch_to_blog( $site_id ); + \Give\Recurring\Install::create_tables(); + restore_current_blog(); + } +}, +``` + +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.