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
48 changes: 39 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ Four interface-backed collaborators, each with a default implementation:
| Interface | Default | Responsibility |
|---|---|---|
| `Contracts\Registrar_Interface` | `Registrar` | holds registered `Sub_Plugin` objects |
| `Contracts\Notices_Interface` | `Notices` | notice queue + activation-error rewrite |
| `Conflict\Resolver_Interface` | `Conflict\Resolver` | standalone detection, deactivation, redirect |
| `Notices\Contracts\Queue_Interface` | `Notices\Queue` | notice queue + activation-error rewrite |
| `Conflict\Contracts\Resolver_Interface` | `Conflict\Resolver` | standalone detection, deactivation, redirect |
| `Contracts\Activation_Interface` | `Activation` | run-once activation-callback tracking |

An interface belonging to a folder-scoped concern lives in that folder's `Contracts\`, not beside its
implementation and not in the top-level `src/Contracts/`. `src/Contracts/` is for the interfaces whose
implementations sit at the root — `Registrar`, `Plugin_State`, `Activation`.

All four come through one generic helper — `Loader::resolve( string $interface, string
$default_class ): object` — which returns the container binding when `$container->has()`, otherwise
`new $default_class()`, memoized either way. Collaborators reach each other through the accessors
Expand All @@ -86,15 +90,17 @@ config predicates there is what lets collaborators stay thin and lets them be te

### What exists today

`Loader` and the four collaborators above are not built yet. Currently:
`Loader`, `Conflict\Resolver` and `Activation` are not built yet. Currently:

| Path | What |
|---|---|
| `src/Config.php` | Static facade: hook prefix + optional container. |
| `src/Sub_Plugin.php` | Value object; validates config and answers everything config alone decides. |
| `src/Conflict_Policy.php` | The three policy constants, `default()`, `is_valid()`. |
| `src/Plugin_State.php` | The only file that touches WordPress plugin functions. |
| `src/Contracts/`, `src/Exceptions/` | `Plugin_State_Interface`, `Config_Exception`. |
| `src/Registrar.php` | Holds registered `Sub_Plugin` objects. |
| `src/Notices/` | `Queue` (what a notice says, who may consume it), `Store` (keeps it), `Renderer` (draws it), `Contracts\Queue_Interface`. |
| `src/Contracts/`, `src/Exceptions/` | `Plugin_State_Interface`, `Registrar_Interface`, `Config_Exception`. |

### Boot lifecycle

Expand All @@ -106,7 +112,7 @@ Loader::boot(); // idempotent

plugins_loaded @1 → Conflict\Resolver::resolve_all()
plugins_loaded @2 → Loader::load_all()
admin_notices → Loader::render_notices() [is_admin() only]
all_admin_notices → Loader::render_notices() [is_admin() only]
wp_admin_notice_markup → Loader::filter_activation_error_markup() [is_admin() only]
```

Expand All @@ -126,8 +132,18 @@ owner deliberately turned on.
### Keys

- Filters: `{$hook_prefix}/plugin_absorber/should_load`, `{$hook_prefix}/plugin_absorber/conflict_policy`
- Option: `{$hook_prefix}_plugin_absorber_activations`
- Transient: `{$hook_prefix}_plugin_absorber_notices`
- Options: `{$option_prefix}_plugin_absorber_activations`, `{$option_prefix}_plugin_absorber_notices`

Both are built in `Config` — `get_hook_name()` and `get_option_name()` — so nothing else assembles
the segment between the host's prefix and the key's own name. The two differ in one respect:
`{$option_prefix}` is the hook prefix lowercased with hyphens folded to underscores, because the
prefix validator admits `A-Z` and `-` and a hook-naming value should not reach a storage key
verbatim. Hook names keep the host's casing exactly as it passed it.

The notice queue is an option, not a transient: with a persistent object cache a transient never
reaches the database, so a `wp_cache_flush()` would destroy a merge notice that is raised exactly
once and never re-queued. It is read and written through `get_site_option()`/`update_site_option()`,
so it is a network option on multisite — matching `deactivate_plugins()`, which is network-wide.

## Conventions

Expand Down Expand Up @@ -197,6 +213,13 @@ treatment. Any older sketch showing `Config::reset()` or `Loader::reset()` means

- **The guard constant and the standalone basename are two separate keys.** No constant does double
duty as both a load guard and a path resolver.
- **`get_hook_name()` and `get_option_name()` do not share a normalisation.** Folding case into the
hook prefix itself would silently rename the host's filters; leaving the raw prefix in an option
name puts `A-Z` and `-` into a storage key. Only the option side normalises, and collapsing the two
code paths breaks whichever end it is collapsed toward.
- **Notice messages are rendered through `wp_kses_post()`, not escaped.** They come from the host's
own config or filter, never from user input, so a knowledge-base link survives. Tightening this to
`esc_html()` after 1.0 would break every host that shipped one.
- **A configured string is never called; every other callable form is.** A string function name is
indistinguishable from a string value, so honouring it would make the result depend on what else
the site loaded — `date`, `flush` and `key` are all real functions and plausible values. Closures,
Expand Down Expand Up @@ -265,6 +288,13 @@ namespace, a `Config::set_version()` that was removed, and an `ob_start()` appro
`wp_admin_notice_markup` filter. `docs/superpowers/plans/2026-07-31-plugin-absorber.md` holds the
task-by-task breakdown.

Once a task's PR merges to `main`, delete that task's section from the plan in the next branch that
touches the file; git history keeps it. A shipped task's plan describes code that already exists in
`src/`, so all it can still do is make an agent read past it to reach what is unbuilt — and since
the plan is edited on every branch of a stacked series, an oversized one is a standing
merge-conflict surface. Never renumber what survives: the numbers map 1:1 to branch names. The spec
is the durable document; the plan is scaffolding and should shrink toward empty as the series lands.

Human-facing docs are `README.md` plus `docs/installing.md`, `docs/configuration.md`,
`docs/conflict-handling.md`, and `docs/filters.md`. Keep them short and keep rationale here or in
code comments — do not grow the README back.
`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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ plugins shipping different versions of this library will collide otherwise. See
```php
use Nexcess\PluginAbsorber\Config;

Config::set_hook_prefix( 'give' ); // required — keys hooks, transients, options
Config::set_hook_prefix( 'give' ); // required — keys the hooks and options
Config::set_container( give()->container ); // optional — lets you rebind collaborators
```

Expand All @@ -41,6 +41,7 @@ Each sub-plugin is then described by a config array:
- [Configuration](docs/configuration.md) — the hook prefix, the container, every sub-plugin key.
- [Conflict handling](docs/conflict-handling.md) — the policies, the load guard, and its limits.
- [Filters](docs/filters.md) — the runtime overrides for policies and notice text.
- [Notices](docs/notices.md) — where the queue lives, who may see it, and how to render it yourself.

## License

Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
```php
use Nexcess\PluginAbsorber\Config;

Config::set_hook_prefix( 'give' ); // required — keys hooks, transients, options
Config::set_hook_prefix( 'give' ); // required — keys hooks and options
Config::set_container( give()->container ); // optional — lets you rebind collaborators
```

The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws
`Config_Exception`, as does reading the prefix before it is set.
`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.
Expand Down
36 changes: 36 additions & 0 deletions docs/notices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Notices

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
`{prefix}_plugin_absorber_notices`, where `{prefix}` is the value passed to
`Config::set_hook_prefix()`. 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.

## Who sees them

`Notices\Queue::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.

On multisite `activate_plugins` maps through `manage_network_plugins`, so it is a network
administrator, not the site administrator who installed the plugin, who sees these.

## Rendering them yourself

`Notices\Queue::option_name()` is public, so you can render the queue yourself without replacing
anything. The value is an `array<string,string>` keyed `slug:type` — `give-recurring:merge`, for
example — and 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 `<p>` of your own is left as it is rather than nested inside another.

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.
Loading
Loading