From f8fae93c54772c4e7730a10ec9a7ebba8cbf8c89 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 12:54:41 +0200 Subject: [PATCH] feat: skills and guidelines for typed meta, roles and Block Bindings --- CHANGELOG.md | 15 ++ README.md | 7 +- resources/boost/guidelines/core.blade.php | 31 +++- .../boost/guidelines/framework.blade.php | 10 +- .../skills/pollora-block-bindings/SKILL.md | 101 +++++++++++++ .../boost/skills/pollora-post-types/SKILL.md | 2 + .../boost/skills/pollora-rest-api/SKILL.md | 17 ++- resources/boost/skills/pollora-roles/SKILL.md | 121 ++++++++++++++++ .../boost/skills/pollora-typed-meta/SKILL.md | 135 ++++++++++++++++++ .../upgrade-pollora-v13-32.blade.php | 9 +- .../Feature/Guidelines/CoreGuidelinesTest.php | 40 ++++++ 11 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 resources/boost/skills/pollora-block-bindings/SKILL.md create mode 100644 resources/boost/skills/pollora-roles/SKILL.md create mode 100644 resources/boost/skills/pollora-typed-meta/SKILL.md create mode 100644 tests/Feature/Guidelines/CoreGuidelinesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d395d5..36660d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Skills** `pollora-typed-meta`, `pollora-roles` and `pollora-block-bindings`: `#[Meta]` and `Meta::of()`, `#[Role]` / `#[ModifyRole]` / `#[CapabilitySet]` and Laravel's Gate, `#[BlockBinding]` and the `pollora/*` sources, with their commands and doctor checks (framework v13.34.4 to v13.35) +- **Core guidelines**: the three features in the attribute snippet and the conventions, the `Meta` facade, and the commands `pollora:make:role`, `pollora:make:binding`, `pollora:meta:list|audit`, `pollora:roles:list|show|prune|import|dump`, `pollora:binding:list` +- **13.x guidelines**: typed meta, roles and Block Bindings; the request lifecycle (`template_redirect` after every provider has booted, since v13.35.1) + +### Changed + +- **Upgrade prompt** and 13.x guidelines: `^13.35` (Laravel 13.35) +- **REST skill**: the `Can` permission (a capability, a `#[CapabilitySet]` case, a meta capability on a request parameter) + +### Fixed + +- **REST skill**: the built-in permissions are imported from `Pollora\WpRest\Permissions` and the contract from `Pollora\Attributes\WpRestRoute\Permission`; the namespaces it gave did not exist + ## [1.3.4] - 2026-10-07 ### Changed diff --git a/README.md b/README.md index 982b0e2..8cb1aad 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ php artisan boost:update | Feature | Description | |---------|-------------| | **AI Guidelines** | Pollora architecture, PHP attributes, WordPress routing, Blade theming — injected into your agent's context via Boost | -| **9 Agent Skills** | On-demand knowledge for post types, taxonomies, theming, hooks, blocks, REST API and AJAX, scheduling, modules, and abilities | +| **12 Agent Skills** | On-demand knowledge for post types, taxonomies, typed meta, roles, theming, hooks, blocks and Block Bindings, REST API and AJAX, scheduling, modules, and abilities | | **10 MCP Tools** | Live introspection of your WordPress & Pollora environment directly from your AI agent | -| **Upgrade Prompts** | Step-by-step MCP prompts for upgrading between Pollora versions (12 → 13, 13.x → 13.32) | +| **Upgrade Prompts** | Step-by-step MCP prompts for upgrading between Pollora versions (12 → 13, 13.x → 13.32 and later) | ## MCP Server @@ -115,6 +115,9 @@ Skills are activated on-demand when working on specific tasks: | `pollora-rest-api` | REST API endpoints with `#[WpRestRoute]`, AJAX handlers with `#[Ajax]` | | `pollora-scheduling` | Scheduled tasks with `#[Schedule]` | | `pollora-abilities` | WordPress Abilities API with `#[Ability]` and the `Ability` facade | +| `pollora-typed-meta` | Post, term, user and comment meta as typed properties with `#[Meta]`, `Meta::of()`, `pollora:meta:audit` | +| `pollora-roles` | Capabilities through Laravel's Gate, roles declared with `#[Role]`, `#[ModifyRole]`, `#[CapabilitySet]`, `pollora:roles:*` | +| `pollora-block-bindings` | `#[BlockBinding]` sources, `pollora/post-meta` and the other sources for core blocks, bindable Blade blocks | | `pollora-modules` | Laravel Modules with auto-discovery | ## Upgrade Assistance diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 068594c..b4f782a 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -69,6 +69,27 @@ public function loadMore(): void {} #[Ability(name: 'acme/create-post', description: 'Creates a post.', category: 'acme-content')] final class CreatePost implements AbilityHandler {} +// Typed meta (v13.34.4+, experimental) — a typed property, on the post type or taxonomy class +#[PostType('event')] +class Event +{ + #[Meta(showInRest: true)] + public int $capacity = 0; // key: capacity, read with Meta::of(Event::class, $id)->capacity +} + +// Roles declared in code (v13.34.4+, experimental) — injected on every request, never stored +#[Role('event_manager', label: 'Event manager', inherits: 'author')] +#[GrantsPostType(Event::class, Access::Editor)] +final class EventManager {} + +// Block Bindings sources (v13.34.6+, experimental) — fields a core block's attribute binds to +#[BlockBinding('acme/event', postTypes: 'event')] +final class EventBinding +{ + #[BindingField(label: 'Remaining seats')] + public function remainingSeats(BindingContext $context): string {} +} + // Keep a class out of discovery entirely (or all but some: except: [...]) #[SkipDiscovery] class InternalHelper {} @@ -102,10 +123,11 @@ class InternalHelper {} - **Never call WordPress registration functions directly** — use attributes and discovery - **Use Blade directives** from Sage Directives: `@posts`, `@title`, `@content`, `@permalink`, `@published` - **WordPress objects** (`WP_Post`, `WP_Query`, `WP_User`) are auto-injected via type hints in controller methods -- **Facades**: `Action`, `Filter`, `Ajax`, `Asset`, `Theme`, `PostType`, `Taxonomy`, `Option`, `Ability`, `PostQuery`, `MetaQuery`, `TaxQuery`, `Mail`, `Constant`. There is no `Loop` or `Query` facade: use Sage Directives in Blade, WordPress functions in PHP +- **Facades**: `Action`, `Filter`, `Ajax`, `Asset`, `Theme`, `PostType`, `Taxonomy`, `Option`, `Meta`, `Ability`, `PostQuery`, `MetaQuery`, `TaxQuery`, `Mail`, `Constant`. There is no `Loop` or `Query` facade: use Sage Directives in Blade, WordPress functions in PHP - **Translations**: `__('Text', 'my-domain')` goes to WordPress's catalogues; `__('key', ['name' => $x])` goes to Laravel; `__('Text')` tries Laravel, then WordPress's `default` domain - **Blocks**: live in `resources/views/blocks/{slug}`, render with `render.blade.php` by default, and are registered by Pollora — never write a `BlocksServiceProvider`; `` in `render.blade.php` marks where inner blocks go, edited in place in the editor (see the `pollora-blocks` skill) - **Which template answered?** With `WP_DEBUG` on, every page carries `` (hierarchy responses only, not `Route::wp()` or Laravel routes) +- **Meta, roles, bindings**: declare a meta with `#[Meta]` on a typed property rather than calling `register_meta()` and casting `get_post_meta()` by hand (`pollora-typed-meta` skill); declare roles with `#[Role]` / `#[ModifyRole]` rather than `add_role()`, and check a capability (`can:`, `@can`) rather than a role (`pollora-roles` skill); bind core blocks to a `#[BlockBinding]` source or `pollora/post-meta` before writing a custom block (`pollora-block-bindings` skill) - **CSRF**: WordPress endpoints are excluded from Laravel CSRF — WordPress uses its own nonce system - **Modules**: Use `nwidart/laravel-modules` for large projects — discovery works inside modules automatically @@ -119,9 +141,14 @@ class InternalHelper {} - `pollora:make:post-type` / `pollora:make:taxonomy` — Generate post type and taxonomy classes - `pollora:make:action` / `pollora:make:filter` / `pollora:make:hook` — Generate hook classes - `pollora:make:model` / `pollora:make:wp-cli` — Generate an Eloquent model or a WP-CLI command class +- `pollora:make:role` / `pollora:make:binding` — Generate a `#[Role]` class or a `#[BlockBinding]` source +- `pollora:meta:list` / `pollora:meta:audit` — List typed meta; read every stored value and name those that cannot be read as their type (exits 1, for CI) +- `pollora:roles:list` / `pollora:roles:show {role}` — Roles with their origin; the effective capabilities of one role and where each comes from +- `pollora:roles:prune` / `pollora:roles:import {role}` / `pollora:roles:dump` — Clean up roles removed from the code, turn a stored role into a `#[Role]` class, write the code's roles to the database; dry run unless `--force` +- `pollora:binding:list` — Binding sources, their fields, and the blocks WordPress lets bind - `discovery:run` / `discovery:clear` — Manage component discovery cache - `pollora:status` — Show framework status -- `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each; the same checks are in Tools › Site Health. **Run it first when something fails without an error** +- `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each, including bindings that can never show a value, typed meta that cannot register and users left with a removed role; the same checks are in Tools › Site Health. **Run it first when something fails without an error** Commands use the colon convention since v13.32; the former dashed names (`pollora:make-theme`…) still work as aliases. diff --git a/resources/boost/guidelines/framework.blade.php b/resources/boost/guidelines/framework.blade.php index 2243de7..dc3ccf8 100644 --- a/resources/boost/guidelines/framework.blade.php +++ b/resources/boost/guidelines/framework.blade.php @@ -100,7 +100,7 @@ ### Laravel 13 Compatibility -- Requires `illuminate/*` `^13.34`; v13.34.0 is the first stable release on it +- Requires `illuminate/*` `^13.35` since v13.35.0 (`^13.34` before); v13.34.0 is the first stable release - Supports Pest 3.x for testing - PHPStan level 5 with WordPress and Laravel extensions - Rector with Laravel-specific rules @@ -113,6 +113,14 @@ - Gutenberg blocks with Vite + JSX/TSX + Tailwind CSS v4, rendered with Blade - Abilities API through `pollora/abilities` (WordPress 6.9+) +### Typed Meta, Roles and Block Bindings + +Experimental, from v13.34.4 (meta, roles) and v13.34.6 (Block Bindings): `#[Meta]` typed properties read through `Meta::of()` and the `Pollora\Models` models, roles declared with `#[Role]` / `#[ModifyRole]` / `#[CapabilitySet]` and injected on every request, and `#[BlockBinding]` sources plus `pollora/post-meta`, `term-meta`, `author-meta` and `option` for core blocks. See the `pollora-typed-meta`, `pollora-roles` and `pollora-block-bindings` skills; `pollora:doctor` checks all three. + +### Request Lifecycle + +WordPress is loaded from a service provider's `boot()`; `wp()` runs there. Since v13.35.1, `template_redirect` (then `pollora_loaded`) fires once every provider has booted, still before routing, so a plugin or theme rendering a view from `template_redirect` gets the theme's view composers and shared data. Code that relied on `template_redirect` having fired during the providers' boot must move to a later hook. + ### Extracted Packages Some modules now live in their own packages, installed with the framework: `pollora/hook` (hook domain and adapters), `pollora/option`, `pollora/ajax`, `pollora/abilities`. diff --git a/resources/boost/skills/pollora-block-bindings/SKILL.md b/resources/boost/skills/pollora-block-bindings/SKILL.md new file mode 100644 index 0000000..0d82887 --- /dev/null +++ b/resources/boost/skills/pollora-block-bindings/SKILL.md @@ -0,0 +1,101 @@ +--- +name: pollora-block-bindings +description: Fill core block attributes with server-side values using Pollora's #[BlockBinding] sources, the pollora/post-meta, term-meta, author-meta and option sources for typed meta, and bindable Blade blocks. +--- + +# Pollora Block Bindings + +## When to use this skill +Use this skill when a page built from core blocks (paragraph, heading, button, image) must show a value computed on the server — a meta, a count, a link — or when a Blade block's attribute should be bindable. Requires Pollora v13.34.6+ and WordPress 6.9+ (Pollora installs WordPress 7); **experimental**. + +Prefer a binding on a core block to writing a custom block that only displays a value. + +## Declaring a source + +```php +use Pollora\Attributes\BlockBinding; +use Pollora\Attributes\BlockBinding\BindingField; +use Pollora\BlockBinding\Domain\Models\BindingContext; + +#[BlockBinding('acme/event', label: 'Event', postTypes: 'event')] +final class EventBinding +{ + #[BindingField(label: 'Remaining seats')] + public function remainingSeats(BindingContext $context, BookingRepository $bookings): string + { + $event = $context->meta(Event::class); // typed meta of the block's post + + return (string) max(0, $event->capacity - $bookings->countFor($context->postId)); + } + + #[BindingField(label: 'Booking link', type: 'url')] + public function bookingUrl(BindingContext $context): string + { + return route('events.book', ['event' => $context->postId]); + } +} +``` + +`php artisan pollora:make:binding EventBinding` generates one (`--theme`, `--plugin`, `--module`). Discovered everywhere; the container injects dependencies into the constructor and each field. + +| Parameter | Default | Effect | +|---|---|---| +| `#[BlockBinding(name)]` | required | `namespace/name`, lowercase | +| `label` | class name | Shown in the editor | +| `usesContext` | `['postId', 'postType']` | Block context read | +| `postTypes` | all | Elsewhere the block keeps its content | +| `#[BindingField(name)]` | method in snake_case | The `field` argument | +| `type` | `text` | `url` or `image` values are sanitized as URLs | + +A class without fields defines `__invoke(BindingContext $context)`. + +`BindingContext`: `postId`, `postType` (right inside a query loop), `termId`, `taxonomy`, `arg('name', $default)`, `attribute`, `block`, `post()` (a `Pollora\Models\Post`), `meta(Class::class)`. + +A field declares its return type: `string`, `int`, `float`, `bool`, `Stringable` or `null` (an array or no return type is refused at discovery). `null` keeps the block's saved text. Pollora escapes for the place the value lands in (text escaped, URLs sanitized, `HtmlString` through `wp_kses_post()`): do not escape in the field. A field that throws is logged and reads as `null` (thrown in debug mode); in debug mode a field slower than 50 ms is logged. + +## Binding in markup + +```html + +

Seats available

+ +``` + +In the editor, **Attributes** in the block settings offers the fields; a bound block previews its value (read-only) through `POST /wp-json/pollora/v1/block-bindings/resolve`. No JavaScript to write. + +## Typed meta in core blocks + +| Source | Reads | Arguments | +|---|---|---| +| `pollora/post-meta` | a typed meta of the block's post | `key`, `format`, `decimals`, `true`, `false`, `size`, `fallback` | +| `pollora/term-meta` | a typed meta of the block's term, or the queried term | same | +| `pollora/author-meta` | a typed meta of the post's author, only `#[Meta(public: true)]` | same | +| `pollora/option` | a site option listed in `config/block-bindings.php` → `options` (none by default) | `name`, `fallback` | + +Values are formatted by type: dates in the site's format and language (`format`), numbers with its separators (`decimals`), booleans as Yes/No, enums by `label()`, arrays as a list; `format: raw` gives the stored value. A `#[Meta(media: true)]` attachment ID bound to an image gives its URL (`size`), `alt`, `title` or `caption` according to the attribute. Post title, link and date come from WordPress's `core/post-data`. + +Post and term meta are only shown with `showInRest: true` and a key not starting with `_`. To let editors **edit** a meta from a block, bind `core/post-meta` instead: Pollora's sources are read-only. + +## Bindable Blade blocks + +```json +{ + "name": "acme/event-card", + "attributes": { "title": { "type": "string" }, "ctaUrl": { "type": "string" } }, + "pollora": { "bindings": ["title", "ctaUrl"] }, + "render": "file:./render.blade.php" +} +``` + +The view reads `$attributes['title']` as usual and escapes it (`{{ }}`). Only a block with a `render` can be bound. + +## Checking + +- `php artisan pollora:doctor` — every binding in the theme's, plugins' and modules' templates, parts and patterns that can never show a value (unknown source or field, undeclared or non-public meta, unlisted option, attribute not bindable), with file, block and reason. Bindings saved in posts are not read +- `php artisan pollora:binding:list` (`--json`) — sources, fields, the meta and options each may show, bindable blocks + +## Important Notes + +- Never read the logged-in visitor in a source: the value would leak through the page cache +- A field runs once per post, attribute and arguments per request, but in a query loop of 20 posts with 4 bound blocks that is 80 calls: no uncached query in a field +- Run `php artisan discovery:clear` after adding a source in development diff --git a/resources/boost/skills/pollora-post-types/SKILL.md b/resources/boost/skills/pollora-post-types/SKILL.md index d278c44..fbd402d 100644 --- a/resources/boost/skills/pollora-post-types/SKILL.md +++ b/resources/boost/skills/pollora-post-types/SKILL.md @@ -123,4 +123,6 @@ public function show(\WP_Post $post): \Illuminate\View\View - Run `php artisan discovery:clear` after creating a new post type class during development - Labels are auto-generated from the class name if not specified - The slug is derived from the `#[PostType('slug')]` attribute parameter +- Custom fields: declare them as `#[Meta]` typed properties on the same class (see the `pollora-typed-meta` skill), not with `register_meta()` +- `#[CapabilityType]` + `#[MapMetaCap]` give the post type its own capabilities, which no role has except the super roles: grant them with `#[GrantsPostType]` (see the `pollora-roles` skill) - Generate a post type with `php artisan pollora:make:post-type` \ No newline at end of file diff --git a/resources/boost/skills/pollora-rest-api/SKILL.md b/resources/boost/skills/pollora-rest-api/SKILL.md index 641fe7d..f2775f3 100644 --- a/resources/boost/skills/pollora-rest-api/SKILL.md +++ b/resources/boost/skills/pollora-rest-api/SKILL.md @@ -91,8 +91,9 @@ Endpoint: `GET /wp-json/my-theme/v1/documents/18` Use built-in permission classes or create custom ones: ```php -use Pollora\Attributes\WpRestRoute\Permissions\IsAdmin; -use Pollora\Attributes\WpRestRoute\Permissions\IsLoggedIn; +use Pollora\WpRest\Permissions\Can; +use Pollora\WpRest\Permissions\IsAdmin; +use Pollora\WpRest\Permissions\IsLoggedIn; // Built-in permissions #[WpRestRoute( @@ -102,8 +103,16 @@ use Pollora\Attributes\WpRestRoute\Permissions\IsLoggedIn; )] class SettingsAPI {} +// A capability (v13.34.5+): a string or a #[CapabilitySet] enum case +#[WpRestRoute(namespace: 'my-theme/v1', route: 'reports', permissionCallback: new Can('export_attendees'))] +class ReportsAPI {} + +// A meta capability on the object in the request: edit_post with the `id` parameter +#[Method('PUT', permissionCallback: new Can('edit_post', parameter: 'id'))] +public function update(int $id): array {} + // Custom permission -use Pollora\WpRest\Domain\Contracts\Permission; +use Pollora\Attributes\WpRestRoute\Permission; class CanManageBooks implements Permission { @@ -123,6 +132,8 @@ class CanManageBooks implements Permission class BooksAPI {} ``` +A `Can` refusal answers 401 to a guest and 403 to a logged-in user. Prefer `Can` to a custom class that only calls `current_user_can()` (see the `pollora-roles` skill). + ## Theme API Routes (Lightweight Mode) For performance-critical API endpoints, themes can define routes that load WordPress **without plugins** (~100ms vs ~1.3s): diff --git a/resources/boost/skills/pollora-roles/SKILL.md b/resources/boost/skills/pollora-roles/SKILL.md new file mode 100644 index 0000000..31932b3 --- /dev/null +++ b/resources/boost/skills/pollora-roles/SKILL.md @@ -0,0 +1,121 @@ +--- +name: pollora-roles +description: Check WordPress capabilities through Laravel authorization, and declare roles in code with Pollora's #[Role], #[ModifyRole] and #[CapabilitySet] attributes instead of storing them in the database. +--- + +# Pollora Roles & Capabilities + +## When to use this skill +Use this skill when restricting who can do something (routes, Blade, REST, PHP), giving a post type its own capabilities, creating or changing a role, or cleaning up roles. Checking capabilities is stable; declaring roles and checking role classes is **experimental** (v13.34.4+; `hasRole()`, `role:`, `@role` with classes since v13.34.5). + +Do not call `add_role()` / `add_cap()`: they write to the database once and drift between environments. Declare roles in code. + +## Checking capabilities + +Laravel's authorization answers with WordPress capabilities: + +```php +$user = auth()->user(); // Pollora\Models\User +$user->can('edit_posts'); +$user->can('edit_post', $post); // meta capability, map_meta_cap() +Gate::authorize('manage_options'); // 403 otherwise + +Route::get('/reports', ReportController::class)->middleware('can:manage_options'); +``` + +```blade +@can('edit_post', $post) … @endcan +@canany(['edit_posts', 'moderate_comments']) … @endcanany +@role('editor', EventManager::class) … @endrole +``` + +In a `#[WpRestRoute]`, use `permissionCallback: new Can('edit_posts')` (or `new Can('edit_post', parameter: 'id')`). + +**Check a capability, not a role**: `can:export_attendees` stays right when a second role gets the capability; `role:event_manager` does not. + +## A post type with its own capabilities + +```php +use Pollora\Attributes\PostType; +use Pollora\Attributes\PostType\CapabilityType; +use Pollora\Attributes\PostType\MapMetaCap; + +#[PostType('event')] +#[CapabilityType('event')] +#[MapMetaCap] +class Event {} +``` + +WordPress then expects `edit_events`, `publish_events`… that no role has: Pollora gives them to the super roles (`administrator` by default, `config/roles.php` → `super_roles`); grant them to other roles with `#[GrantsPostType]`. + +## Declaring a role + +`php artisan pollora:make:role EventManager` generates one in `app/Cms/Roles`. + +```php +use Pollora\Attributes\Role; +use Pollora\Attributes\Role\{Grants, GrantsPostType, Without}; +use Pollora\Role\Domain\Enums\Access; + +#[Role('event_manager', label: 'Event manager', inherits: 'author', textDomain: 'my-theme')] +#[GrantsPostType(Event::class, Access::Editor)] // Contributor < Author < Editor +#[Grants(EventCap::ExportAttendees, 'moderate_comments')] +#[Without('publish_posts')] +final class EventManager {} +``` + +| Attribute | Effect | +|---|---| +| `#[Role(slug, label, inherits, allowSensitive, textDomain)]` | Declares the role; `inherits` is a slug or another `#[Role]` class, followed live | +| `#[Grants(...)]` / `#[Without(...)]` | Adds / removes capabilities (strings or enum cases); repeatable | +| `#[GrantsPostType(class or slug, Access)]` | Capabilities of a post type with `#[CapabilityType]` | +| `#[GrantsTaxonomy(class or slug)]` | Term capabilities of a taxonomy with its own `#[Capabilities]` | +| `#[ModifyRole('editor')]` | Adjusts a role the project does not own (core, WooCommerce, a plugin) with the same `Grants`/`Without` attributes | + +Project capabilities go in a backed enum: + +```php +#[CapabilitySet(label: 'Events')] +enum EventCap: string +{ + case ExportAttendees = 'export_attendees'; +} +// Gate::allows(EventCap::ExportAttendees); super roles receive every case +``` + +### How it works + +Declared roles are injected into WordPress's role registry **in memory on every request** (`wp_roles_init`), never written to `{prefix}user_roles`. The code is the source of truth: deploy and the role follows; remove the class and the role is gone (Pollora undoes what a plugin's `add_cap()` wrote back). Role editor plugins cannot change a declared role. + +### Safety rules (enforced at discovery, the declaration is refused and logged) + +- Sensitive capabilities (`manage_options`, `edit_users`, `promote_users`, `unfiltered_html`, `install_plugins`, `edit_themes`, `update_core`…) need `allowSensitive: true` +- Core roles cannot be redeclared with `#[Role]`: use `#[ModifyRole]` +- No inheritance from a super role, no loop, no slug declared twice, no capability both granted and removed +- `#[Without]` removes a capability; it never stores `false` + +## Checking roles in code + +```php +$user->roles(); // ['author', 'event_manager'] +$user->hasRole(EventManager::class); // or slugs: hasRole('editor', 'administrator') +$user->assignRole(EventManager::class); // through WP_User; refuses an unknown role +$user->removeRole('event_manager'); + +Route::middleware('role:event_manager,editor'); +Route::get('/scan', ScanController::class)->middleware(EnsureUserHasRole::using(EventManager::class)); +``` + +`assignRole()` / `removeRole()` do not check the caller's rights: check `promote_users` where a user can trigger them. Your own user model gets them with the `Pollora\Models\Concerns\HasRoles` trait (needs `toWpUser()`). + +## Inspecting and migrating + +- `pollora:roles:list` / `pollora:roles:show event_manager` (`--json`) — roles with their origin and users; effective capabilities and where each comes from +- `pollora:doctor` — users still carrying a removed role, a `default_role` that no longer exists, capabilities given to users one by one, declarations that could not apply +- `pollora:roles:prune --reassign=subscriber` — takes removed roles off users; `pollora:roles:import venue_staff --inherits=author` — turns a stored role into a `#[Role]` class (review it); `pollora:roles:dump` — writes the code's roles to the option for tools that read the database. All three are a dry run unless `--force` + +## Important Notes + +- A tool reading `user_roles` directly, without loading the site, does not see declared roles (use `pollora:roles:dump`) +- Roles from plugins and themes are injected after WordPress built its roles, and the current user's capabilities are recomputed +- Run `php artisan discovery:clear` after adding a role class in development diff --git a/resources/boost/skills/pollora-typed-meta/SKILL.md b/resources/boost/skills/pollora-typed-meta/SKILL.md new file mode 100644 index 0000000..df3cd95 --- /dev/null +++ b/resources/boost/skills/pollora-typed-meta/SKILL.md @@ -0,0 +1,135 @@ +--- +name: pollora-typed-meta +description: Declare WordPress post, term, user and comment meta as typed PHP properties with Pollora's #[Meta] attribute, then read and write them with their PHP type through Meta::of() or the Pollora models. +--- + +# Pollora Typed Meta + +## When to use this skill +Use this skill when a post type, taxonomy, user or comment needs custom fields: declaring them, reading or writing them, exposing them in REST, validating them, or checking stored values. Requires Pollora v13.34.4+; **experimental** (the API may change). + +Do not call `register_meta()`, cast `get_post_meta()` by hand or repeat a key string: declare the meta once. + +## Declaring meta + +A meta is a public typed property marked `#[Meta]` on the class that already carries `#[PostType]` or `#[Taxonomy]`. The type gives the meta type, the initial value the default, the name the key in snake_case: + +```php +use App\Enums\EventStatus; +use Carbon\CarbonImmutable; +use Pollora\Attributes\Meta; +use Pollora\Attributes\PostType; + +#[PostType('event')] +class Event +{ + #[Meta(showInRest: true, label: 'Start')] + public ?CarbonImmutable $startsAt = null; // key: starts_at + + #[Meta(showInRest: true, rules: ['min:0', 'max:5000'])] + public int $capacity = 0; + + #[Meta(showInRest: true)] + public EventStatus $status = EventStatus::Draft; // a backed enum + + #[Meta(key: '_event_internal_ref', capability: 'manage_options')] + public ?string $internalRef = null; +} +``` + +Every property needs a default or a nullable type: that is what an absent meta reads as. Run `php artisan discovery:clear` after adding a class in development. + +### Objects the project does not declare + +```php +use Pollora\Attributes\{CommentMeta, Meta, PostMeta, TermMeta, UserMeta}; + +#[PostMeta('product')] // a plugin's post type; ['post', 'page'] for several +class ProductExtras { #[Meta(showInRest: true)] public ?string $warrantyNotice = null; } + +#[TermMeta('category')] +class CategoryExtras { #[Meta(showInRest: true)] public ?string $color = null; } + +#[UserMeta] +class MemberProfile { #[Meta(showInRest: true)] public bool $newsletterOptIn = false; } + +#[CommentMeta] // every comment type: WordPress has no per-type comment meta +class ReviewMeta { #[Meta] public int $rating = 5; } +``` + +Several classes may target the same objects if their keys differ. + +### `#[Meta]` parameters + +| Parameter | Default | Effect | +|---|---|---| +| `key` | property in snake_case | Database key | +| `showInRest` | `false` | Exposes the meta in REST with a schema from its type | +| `label`, `description` | none | Editor label; `register_meta()` description | +| `sanitize` | from the type | A callable replacing it (`'wp_kses_post'` for HTML) | +| `capability` | edit right on the object | Required to write through REST; required to expose a `_` key | +| `revisions` | `false` | Versioned with post revisions (posts only) | +| `rules` | none | Laravel validation rules, the type rule implied | +| `items` | `@var list<…>` docblock | Item type of an `array`: `'string'`, `'int'`, a class… | +| `single` | `true` | On an `array`, `false` stores one row per item | +| `media` | `false` | On an `int`: an attachment ID (Block Bindings give its URL, alt, caption) | +| `public` | `false` | May be shown to anyone; required for `pollora/author-meta` | +| `control`, `group`, `hints` | from the type | Input field description for a UI driver (`config/meta.php` → `ui`) | + +### Types + +`string`, `int`, `float`, `bool`, dates (`DateTimeInterface`, Carbon — stored as ISO 8601 UTC), backed enums, `array` (with an item type), and a class with public typed scalar/date/enum properties (stored as an array, read back as an instance). Refused at discovery, with the class and property logged: union or missing types, an array without item type or of arrays, a non-backed enum, a non-nullable property without default, a `_` key in REST without `capability`, a key declared twice for the same objects. + +## Reading and writing + +```php +use Pollora\Support\Facades\Meta; + +$event = Meta::of(Event::class, $postId); // post, term, user or comment ID +$event->capacity; // int +$event->startsAt?->isFuture(); // CarbonImmutable|null +$event->starts_at; // the same meta, by its key + +$event->capacity = 250; // checked at once +$event->fill(['status' => EventStatus::Published])->save(); // update_metadata() +$event->set('startsAt', null)->save(); // null deletes a nullable meta +``` + +A wrong type throws `InvalidMetaValueException`, a broken rule `MetaValidationException`, before anything is written. `get_post_meta()` keeps returning the stored string. + +### On the models + +```php +use Pollora\Models\Post; + +class Event extends Post +{ + protected $postType = 'event'; // Post::find() then returns an Event +} + +$event->capacity; // typed attribute +Event::whereMeta('capacity', '>=', 100)->get(); // numbers compared as numbers +Event::whereMeta('subtitle', null)->get(); // meta absent +auth()->user()->newsletterOptIn; // a #[UserMeta] +``` + +Typed meta are not in `toArray()`. A collection of such models loads its meta in one query through WordPress's cache. + +## REST and validation + +With `showInRest: true` the meta appears under `meta` in the object's REST response, typed; enums list their values, dates use `date-time`. Pollora adds `custom-fields` support to a `#[PostType]` that exposes meta; for a plugin's post type targeted by `#[PostMeta]`, check it supports `custom-fields`. A REST write breaking `rules` answers 400 with the rule's message under `data.params["meta."]`. `update_post_meta()` only sanitizes. + +## Checking + +- `php artisan pollora:meta:list` (`--json`) — every typed meta by class, with key, type and options +- `php artisan pollora:doctor` — refused declarations, `#[PostMeta]`/`#[TermMeta]` naming a missing post type or taxonomy, `showInRest` on an object not in REST, unreadable stored values (sampled) +- `php artisan pollora:meta:audit` (`--limit`, `--json`) — reads every stored value, names those that cannot be read as their type with the object IDs, exits 1 (for CI against a copy of production), and lists keys no `#[Meta]` declares + +A stored value that does not match its type throws in debug mode and reads as the default (logged) in production. + +## Important Notes + +- A meta exposed in REST is public data: never put a secret in a `showInRest` meta +- Renaming a property changes its key: old values stay under the old key (`pollora:meta:audit` lists it) +- Changing a property's type can make stored values unreadable: run `pollora:meta:audit` before deploying +- Bind typed meta to core blocks with `pollora/post-meta` (see the `pollora-block-bindings` skill) diff --git a/src/Mcp/Prompts/UpgradePolloraV1332/upgrade-pollora-v13-32.blade.php b/src/Mcp/Prompts/UpgradePolloraV1332/upgrade-pollora-v13-32.blade.php index 7974979..835168b 100644 --- a/src/Mcp/Prompts/UpgradePolloraV1332/upgrade-pollora-v13-32.blade.php +++ b/src/Mcp/Prompts/UpgradePolloraV1332/upgrade-pollora-v13-32.blade.php @@ -38,13 +38,14 @@ ```json "require": { "johnpbloch/wordpress": "^7.1", - "laravel/framework": "^13.34", - "pollora/framework": "^13.34" + "laravel/framework": "^13.35", + "pollora/framework": "^13.35" } ``` -- The framework's version tracks Laravel's: from v13.34.0-beta it requires `illuminate/*` `^13.34` (the 13.32 betas required `^13.32`). Upgrade Laravel first if needed -- v13.34.0 is a stable release: the constraint needs no `@beta` flag +- The framework's version tracks Laravel's: v13.35 requires `illuminate/*` `^13.35` (v13.34 required `^13.34`, the 13.32 betas `^13.32`). Upgrade Laravel first if needed +- Since v13.35.1, `template_redirect` fires once every service provider has booted, still before routing: code that checked `did_action('template_redirect')` from a provider's `boot()` must move to a later hook +- Since v13.34.0 the releases are stable: the constraint needs no `@beta` flag - WordPress plugins and themes come from wp-packages instead of wpackagist. Replace the repository and rename the packages (`wpackagist-plugin/x` → `wp-plugin/x`, `wpackagist-theme/x` → `wp-theme/x`): ```json diff --git a/tests/Feature/Guidelines/CoreGuidelinesTest.php b/tests/Feature/Guidelines/CoreGuidelinesTest.php new file mode 100644 index 0000000..9ca4e32 --- /dev/null +++ b/tests/Feature/Guidelines/CoreGuidelinesTest.php @@ -0,0 +1,40 @@ +renderBladeFile($path, []); + } + }; + + return $renderer->render(dirname(__DIR__, 3).'/resources/boost/guidelines/core.blade.php'); +} + +it('keeps Blade directives quoted in the text as text', function () { + expect(renderCoreGuidelines()) + ->toContain('`@can`') + ->toContain('#[Meta(showInRest: true)]') + ->not->toContain('toContain('pollora-typed-meta') + ->toContain('pollora-roles') + ->toContain('pollora-block-bindings'); +}); + +it('names every skill after its directory, with a description', function (string $skill) { + $content = (string) file_get_contents($skill.'/SKILL.md'); + + expect($content)->toStartWith("---\nname: ".basename($skill)."\ndescription: "); +})->with(fn (): array => glob(dirname(__DIR__, 3).'/resources/boost/skills/*', GLOB_ONLYDIR) ?: []);