Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/instructions/php.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Framework-development rules for rtcamp/wp-framework PHP."
## Layout & contracts

- `inc/Contracts/Interfaces/`: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`.
- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`.
- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`, `AbstractFeature`, `AbstractAbility`, `AbstractAbilityRegistrar`.
- `inc/Contracts/Traits/`: `Loader`, `Singleton`.
- `inc/` root: `Container`, `AssetLoader`, `ComponentLoader`, `TemplateLoader`; `inc/Utils/`: utilities (e.g. `Encryptor`).

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md — wp-framework

Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Codex). `rtcamp/wp-framework`: shared base contracts (interfaces, abstracts, traits) and small utilities consumed via Composer by every rtCamp plugin/theme skeleton. **Zero runtime dependencies.** PHP 8.2+, WordPress 6.5+ — the floor is set by the Script Modules API (`wp_register_script_module()`, new in 6.5). The only API used above 6.5 is `wp_register_block_types_from_metadata_collection()` (6.8+), and `AssetLoader::register_block_manifest()` guards it with a per-block fallback for 6.5–6.7.
Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Codex). `rtcamp/wp-framework`: shared base contracts (interfaces, abstracts, traits) and small utilities consumed via Composer by every rtCamp plugin/theme skeleton. **Zero runtime dependencies.** PHP 8.2+, WordPress 6.5+ — the floor is set by the Script Modules API (`wp_register_script_module()`, new in 6.5). The APIs used above 6.5 are `wp_register_block_types_from_metadata_collection()` (6.8+), which `AssetLoader::register_block_manifest()` guards with a per-block fallback for 6.5–6.7, and the Abilities API (6.9+), which the ability abstracts reach only through `wp_abilities_api_*` hooks that never fire on older cores.

## Authoritative rules

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`.
interfaces
- the `Loader` trait (instantiate a list of classes, register their hooks,
cache the shared ones) and the `Container` it stores instances in
- **Ten `Abstract*` base classes** — one per WordPress registration chore, so a
consumer writes intent instead of boilerplate: `AbstractModule`,
- **Twelve `Abstract*` base classes** — one per WordPress registration chore, so
a consumer writes intent instead of boilerplate: `AbstractModule`,
`AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`,
`AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`,
`AbstractUserRole`, `AbstractFeature`
`AbstractUserRole`, `AbstractFeature`, `AbstractAbility`,
`AbstractAbilityRegistrar`
- **Asset & render loaders** — `AssetLoader` (scripts/styles/modules +
`*.asset.php` manifests), `ComponentLoader` and `TemplateLoader` (resolve
components/templates across the child-theme → parent-theme → package hierarchy)
Expand Down
4 changes: 2 additions & 2 deletions ai/framework-php.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Decision order for a new class, **do NOT default to Singleton**:
2. **`Registrable` + `Shareable`**: only if another class must retrieve it via `get_shared()`.
3. **`Singleton`**: only the `Main` bootstrap.

Extend the framework abstracts; never hand-roll their job: `AbstractModule` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole}`.
Extend the framework abstracts; never hand-roll their job: `AbstractModule` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole,Feature,Ability,AbilityRegistrar}`.

Flag genuine contract/security violations, not style. Allow any correct implementation.

Expand Down Expand Up @@ -48,7 +48,7 @@ Flag genuine contract/security violations, not style. Allow any correct implemen
2. 🚩 `Singleton`/`::get_instance()` outside `Main` → `Loader`+`Registrable` (or `Shareable`+`get_shared()` if retrieval is genuinely needed). Never a service locator.
3. 🚩 `Shareable` with no real later-retrieval need → plain `Registrable`.
4. 🚩 WP-hooking class not implementing `Registrable` / not loaded via the `Loader`.
5. 🚩 A class calling `register_post_type`/`register_taxonomy`/`register_rest_route`/`add_menu_page`/`add_shortcode`/`register_block_type` directly instead of extending the matching `Abstract*` (`AbstractPostType`, `AbstractRESTController`, `AbstractAdminPage`, …). Name the abstract to extend.
5. 🚩 A class calling `register_post_type`/`register_taxonomy`/`register_rest_route`/`add_menu_page`/`add_shortcode`/`register_block_type`/`wp_register_ability` directly instead of extending the matching `Abstract*` (`AbstractPostType`, `AbstractRESTController`, `AbstractAdminPage`, …). Name the abstract to extend.
6. 🚩 Missing `strict_types`/types/docblocks; PSR-4 mismatch; `self::` for LSB.
7. 🚩 Missing escape/sanitize/nonce/capability; raw `$wpdb` without `prepare()`; REST without a real `permission_callback`; inline assets; wrong/absent text domain.
8. 🚩 Edit under `vendor/rtcamp/wp-framework` or WordPress core.
96 changes: 92 additions & 4 deletions docs/abstracts.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Abstracts — the base-class cookbook

The ten `Abstract*` classes in
The twelve `Abstract*` classes in
[`inc/Contracts/Abstracts/`](../inc/Contracts/Abstracts/) are the part of the
framework a service author touches most. Each one wraps a single WordPress
registration chore so the subclass writes *what* it is, not *how* to register it.

Every abstract here (except `AbstractModule`, which is structural, and
`AbstractFeature`, which gates another service behind a flag) follows the same
shape:
Every abstract here (except `AbstractModule`, which is structural;
`AbstractFeature`, which gates another service behind a flag; and
`AbstractAbility`, which describes an ability that its paired
`AbstractAbilityRegistrar` registers) follows the same shape:

- it `implements Registrable`, so the [`Loader`](architecture.md) drives it;
- its `register_hooks()` attaches **one** WordPress hook;
Expand All @@ -33,6 +34,8 @@ familiar yet.
| `AbstractAdminPage` | `admin_menu` | `get_slug()`, `get_page_title()`, `get_menu_title()`, `render()` |
| `AbstractUserRole` | `admin_init` | `get_slug()`, `get_display_name()`, `get_capabilities()`, `get_version()` |
| `AbstractFeature` | — (gates the subclass's own hooks) | `get_slug()`, `get_feature_registry()`, plus the subclass's `register_hooks()` |
| `AbstractAbility` | — (registered by its `AbstractAbilityRegistrar`) | `name()`, `label()`, `description()`, `category()`, `input_schema()`, `output_schema()`, `execute()` |
| `AbstractAbilityRegistrar` | `wp_abilities_api_categories_init` + `wp_abilities_api_init` | `category_slug()`, `category_description()`, `abilities()` |

---

Expand Down Expand Up @@ -396,6 +399,91 @@ with that description, and its `the_content` filter attaches only while the flag
is enabled. See [utilities.md](utilities.md#featureselector) for the registry and
its settings page.

## Abilities (WordPress 6.9+)

### AbstractAbility

[`AbstractAbility.php`](../inc/Contracts/Abstracts/AbstractAbility.php) — one
WordPress Abilities API ability. The subclass declares *what* the ability is —
name, label, description, category, schemas, and `execute()` — and `args()`
maps those to the argument array `wp_register_ability()` expects. The class is
deliberately **not** `Registrable`: abilities may only be registered inside the
API's own init hook, so the paired registrar (below) owns that timing and an
ability stays a plain describable object.

**Must implement:** `name()` (the full `"my-plugin/do-thing"` identifier —
lowercase, one slash), `label()`, `description()`, `category()` (the slug of a
category the registrar registers), `input_schema()` / `output_schema()` (JSON
Schema arrays; return `[]` to omit the key), and
`execute( mixed $input ): array|\WP_Error`.

Overridable seams:

- `permission( mixed $input = null )` — the permission gate. Defaults to
`current_user_can( 'manage_options' )`: fail-closed, administrators only.
- `meta()` — defaults to `[]`, which keeps the API defaults: not exposed over
REST, no MCP flag. Exposure is always an explicit opt-in.

Both callbacks in `args()` are closure-wrapped with a defaulted parameter
because core invokes them with **no arguments** when the ability declares no
input schema.

### AbstractAbilityRegistrar

[`AbstractAbilityRegistrar.php`](../inc/Contracts/Abstracts/AbstractAbilityRegistrar.php)
— the `Registrable` that registers a group of abilities. Its `register_hooks()`
attaches two actions — `wp_abilities_api_categories_init` (registers the shared
category) and `wp_abilities_api_init` (registers each ability) — the only hooks
those registrations are legal on. Core fires both lazily on first registry
access. On cores older than 6.9 the hooks never fire, so the registrar is
simply inert and the package's 6.5 floor is unchanged.

**Must implement:** `category_slug()`, `category_description()` (the API
rejects a category without a non-empty description), and `abilities()` — the
`AbstractAbility[]` to register.

Overridable: `category_label()` (defaults to the title-cased slug) and
`before_register()` (an empty hook that runs once before the abilities
register — ensure storage exists, prime options). Category registration is
idempotent — a registrar skips the call when the slug already exists — so
several registrars can share one category and load in any order.

The usual shape mirrors `AbstractFeature`: a thin consumer-side base supplies
the shared category slug, one class per ability, one registrar naming them:

```php
// One shared category for the whole plugin.
abstract class Ability extends AbstractAbility {
protected function category(): string { return 'my-plugin'; }
}

final class SiteSummary extends Ability {
public function name(): string { return 'my-plugin/site-summary'; }
protected function label(): string { return 'Site Summary'; }
protected function description(): string {
return 'Returns published post counts for the site.';
}
protected function input_schema(): array { return []; }
protected function output_schema(): array { return [ 'type' => 'object' ]; }

public function execute( mixed $input ): array|\WP_Error {
return [ 'posts' => (int) wp_count_posts()->publish ];
}
}

final class Registrar extends AbstractAbilityRegistrar {
protected function category_slug(): string { return 'my-plugin'; }
protected function category_description(): string {
return 'Read-only insights about the site.';
}
protected function abilities(): array { return [ new SiteSummary() ]; }
}
```

Load `Registrar` like any other `Registrable` (usually from a module's
`get_classes()`); the ability is then retrievable via
`wp_get_ability( 'my-plugin/site-summary' )` and executable by administrators.

---

Next: [loaders.md](loaders.md) for the asset and template machinery, or back to
Expand Down
9 changes: 5 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ Two rules define the whole package:
1. **A registration system.** A predictable way to turn a list of classes into
live WordPress hooks — `Registrable`, the `Loader` trait, and the
`Container`. This is the spine; read [architecture.md](architecture.md) first.
2. **A library of base classes.** Ten `Abstract*` classes — most wrap one
2. **A library of base classes.** Twelve `Abstract*` classes — most wrap one
WordPress registration chore (a post type, a taxonomy, a block, a settings
page, …); two are structural: `AbstractModule` groups services and
`AbstractFeature` gates one behind a flag. See [abstracts.md](abstracts.md).
page, an ability, …); two are structural: `AbstractModule` groups services
and `AbstractFeature` gates one behind a flag. See
[abstracts.md](abstracts.md).
3. **Asset & render plumbing.** `AssetLoader`, `ComponentLoader`, and
`TemplateLoader` — enqueue built assets and resolve component/template files
across the child-theme → parent-theme → package hierarchy. See
Expand All @@ -36,7 +37,7 @@ Two rules define the whole package:
|---|---|
| [architecture.md](architecture.md) | The mental model: how a class becomes a live hook. The `Registrable` → `Loader` → `Container` flow and where `Module` fits. Start here. |
| [contracts.md](contracts.md) | Reference for the interfaces and traits: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`, `Loader`, `Singleton`. |
| [abstracts.md](abstracts.md) | Cookbook for the ten `Abstract*` base classes — what each is for, the methods to implement, the hook it wires, a minimal subclass. |
| [abstracts.md](abstracts.md) | Cookbook for the twelve `Abstract*` base classes — what each is for, the methods to implement, the hook it wires, a minimal subclass. |
| [loaders.md](loaders.md) | `AssetLoader`, `ComponentLoader`, `TemplateLoader` — the asset/render subsystem and the theme-override hierarchy they share. |
| [utilities.md](utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, `FeatureSelectorSettingsPage`, `XHProf_Profiler`, and `Container`. |
| [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. |
Expand Down
163 changes: 163 additions & 0 deletions inc/Contracts/Abstracts/AbstractAbility.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php
/**
* Abstract Ability.
*
* @package rtCamp\WPFramework\Contracts\Abstracts
* @since 1.0.0
*/

declare( strict_types = 1 );

namespace rtCamp\WPFramework\Contracts\Abstracts;

/**
* Class AbstractAbility
*
* Base class for a WordPress Abilities API ability (WordPress 6.9+). A
* subclass declares the pieces of an ability — name, label, description,
* category, schemas, and the execute callback — and args() maps them to the
* argument array wp_register_ability() expects. Registration itself is
* performed by an {@see AbstractAbilityRegistrar}, which registers the shared
* category and loops its abilities on the Abilities API init hooks.
*
* Exposure is opt-in: meta() defaults to empty, so an ability is not exposed
* over REST and carries no MCP flag unless the subclass says so. The
* permission gate defaults to `manage_options` (fail-closed).
*
* @since 1.0.0
*/
abstract class AbstractAbility {

/**
* Return the fully-qualified ability name, e.g. "my-plugin/do-thing".
*
* Must match the Abilities API name pattern `^[a-z0-9-]+/[a-z0-9-]+$`
* (namespace prefix, a slash, then the ability slug).
*
* @return string Ability name.
*/
abstract public function name(): string;

/**
* Return the human-readable label.
*
* @return string Label.
*/
abstract protected function label(): string;

/**
* Return the description of what the ability does.
*
* Written for the caller deciding whether to invoke the ability — for an
* MCP-exposed ability that caller is an AI agent choosing a tool.
*
* @return string Description.
*/
abstract protected function description(): string;

/**
* Return the slug of the category this ability belongs to.
*
* The Abilities API requires every ability to name a registered category.
* The {@see AbstractAbilityRegistrar} that registers this ability
* registers the category first, so the two normally return the same slug.
*
* @return string Category slug.
*/
abstract protected function category(): string;

/**
* Return the JSON Schema describing the ability input.
*
* Return an empty array for an ability that takes no input; args() then
* omits the key entirely.
*
* @return array<string, mixed> Input schema.
*/
abstract protected function input_schema(): array;

/**
* Return the JSON Schema describing the ability output.
*
* Return an empty array to leave the output unspecified; args() then
* omits the key entirely.
*
* @return array<string, mixed> Output schema.
*/
abstract protected function output_schema(): array;

/**
* Execute the ability.
*
* @param mixed $input Input validated against the input schema.
*
* @return array<string, mixed>|\WP_Error Result data, or an error.
*/
abstract public function execute( mixed $input ): array|\WP_Error;
Comment on lines +94 to +96

/**
* Decide whether the current user may execute the ability.
*
* Defaults to `manage_options` — fail-closed, administrators only.
* Override to gate on a different capability or on the input.
*
* @param mixed $input Input the ability is being called with.
*
* @return bool|\WP_Error True to allow; false or a WP_Error to deny.
*/
protected function permission( mixed $input = null ): bool|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $input is the override seam; the default gate ignores it.
return current_user_can( 'manage_options' );
}

/**
* Return the ability meta.
*
* Defaults to empty, which keeps the Abilities API defaults — not exposed
* over REST, no MCP flag. Override to opt in, e.g.
* `[ 'show_in_rest' => true ]`.
*
* @return array<string, mixed> Meta.
*/
protected function meta(): array {
return [];
}

/**
* Build the registration arguments for wp_register_ability().
*
* The schema and meta keys are included only when non-empty, so the
* Abilities API applies its own defaults otherwise. Both callbacks are
* wrapped in closures with a defaulted parameter: when an ability
* declares no input schema, core invokes its callbacks with no arguments
* at all, and the wrappers keep that call safe for the required
* execute() signature (and keep permission() protected).
*
* @return array<string, mixed> Registration args.
*/
public function args(): array {
$args = [
'label' => $this->label(),
'description' => $this->description(),
'category' => $this->category(),
'execute_callback' => fn ( mixed $input = null ): array|\WP_Error => $this->execute( $input ),
'permission_callback' => fn ( mixed $input = null ): bool|\WP_Error => $this->permission( $input ),
];

$input_schema = $this->input_schema();
if ( [] !== $input_schema ) {
$args['input_schema'] = $input_schema;
}

$output_schema = $this->output_schema();
if ( [] !== $output_schema ) {
$args['output_schema'] = $output_schema;
}

$meta = $this->meta();
if ( [] !== $meta ) {
$args['meta'] = $meta;
}

return $args;
}
}
Loading
Loading