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
11 changes: 11 additions & 0 deletions node-packages/wp-tooling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to `@rtcamp/wp-tooling` are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## Unreleased

### Added

- `wp-api/speculation` scaffold — generates a `Registrable` that customises WordPress core's Speculation Rules API (core since WP 6.8). Pins the prefetch/prerender `MODE` and `EAGERNESS` through the `wp_speculation_rules_configuration` filter (passing a `null` config straight through, so speculative loading that another filter disabled is never revived) and merges an `EXCLUDE_PATHS` constant into `wp_speculation_rules_href_exclude_paths`. `register_hooks()` no-ops when the API is absent, so the class is safe on WP < 6.8. Wires into the same module and anchor as `wp/registrable`.
- New `wp-api` category for scaffolds that customise a modern WordPress core API, and the first scaffold to use the reserved `"wizard_step": "wp-apis"`.

### Fixed

- `discover_from: composer.json:autoload.psr-4` now resolves **path** inputs (`base_path`, `*_path`, `*_dir`) from the map entry's directory instead of skipping them, grafted the same way namespaces already were — with a root of `Acme\Blog\` → `inc/`, a `base_path` default of `includes/Services` resolves to `inc/Services`. Previously the namespace was grafted but the directory kept the manifest default, so any project not laid out under `includes/` got a correctly-namespaced class written outside its autoload root, where it never loaded. Affects the 12 bundled scaffolds that declare PSR-4 discovery on `base_path`. Projects already using `includes/`, projects without a `composer.json`, and callers that pass `--base_path` explicitly are unaffected.

## [1.0.0] - 2026-07-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion node-packages/wp-tooling/docs/ai-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Rules the skill can rely on:

- **Precedence is `supplied → discovered → default`.** An explicit `--namespace=...` (or any supplied input) always wins over engine discovery, which always wins over the manifest `default`. Passing values explicitly is therefore always authoritative and safe.
- **Fail-safe.** A missing or malformed `composer.json` / `package.json` / `.wp-tooling.json` is ignored — the input falls back to its `default`, identical to behaviour before this feature existed. The engine never throws because a project file is absent or unparsable.
- **Path inputs are not overwritten by `autoload.psr-4`.** Inputs whose key ends in `_path` or `_dir` (e.g. `base_path`) keep their `default`; only namespace-style inputs receive the PSR-4 root. This prevents a directory input from being set to a namespace string.
- **Path inputs receive the PSR-4 *directory*, not the namespace.** Inputs whose key ends in `_path` or `_dir` (e.g. `base_path`) resolve from the same map entry's value, so a root of `Acme\Blog\` → `inc/` yields `namespace` `Acme\Blog\Services` **and** `base_path` `inc/Services`. Both come from one entry, so a class is never namespaced into the autoload root while being written outside it — and a directory input is never set to a namespace string.
- **`autoload.psr-4` uses the first declared root, grafted onto the default's sub-namespace.** When a project declares multiple PSR-4 roots, the engine takes the first key (trailing `\` stripped) and substitutes it for the first segment of the manifest `default` (`Inc\Cli` + root `Acme\Blog` → `Acme\Blog\Cli`). If that is not the intended namespace for this class, pass `--namespace=...` explicitly, or — per §6 — ask the developer rather than letting the heuristic guess.

**Confirm with the developer once per session.** Present discovered values as a single block:
Expand Down
5 changes: 4 additions & 1 deletion node-packages/wp-tooling/docs/authoring-scaffolds.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ or key is absent the input falls through to its `default` (so a project without
exactly as if `discover_from` were not set).

- `input:<other-key>` — derive from another resolved input (e.g. `class` from `name`, with a `pascal-case` transform).
- `composer.json:<dot.path>` / `package.json:<dot.path>` — a string value at a dotted path. The special selector `autoload.psr-4` (or `autoload.psr-0`) yields the **root namespace** (first map key, trailing `\` stripped) — but only for non-path inputs; inputs whose key looks like a path (`base_path`, `*_path`, `*_dir`) keep their own `default`, since the PSR-4 root directory is rarely a scaffold's target sub-path.
- `composer.json:<dot.path>` / `package.json:<dot.path>` — a string value at a dotted path. The special selector `autoload.psr-4` (or `autoload.psr-0`) yields the **root namespace** for ordinary inputs (first map key, trailing `\` stripped) and the **root directory** for path inputs (that same entry's value; the first element if it is a list). Either way the discovered root replaces only the *first segment* of the input's `default`, keeping the scaffold's sub-namespace or sub-directory: with a map of `Acme\Blog\` → `inc/`, a `namespace` default of `Inc\Cli` yields `Acme\Blog\Cli` and a `base_path` default of `includes/Cli` yields `inc/Cli`. Both come from the same map entry, so a class is never namespaced into the autoload root while being written outside it. A path input whose `default` has no sub-directory resolves to the root directory itself, and a PSR-4 target of `./` leaves just the sub-directory.
- `config:<dot.path>` — a string value from the project's `.wp-tooling.json` (e.g. `config:textDomain`).

Example — auto-fill the namespace from the consuming project's composer.json, falling back to a sensible default:
Expand Down Expand Up @@ -264,6 +264,8 @@ The engine merges all dependency maps from selected scaffolds (via `collectDepen

Use nesting when a scaffold has multiple variants of the same concept (PHPCS standard choice). Use a flat category when scaffolds are independent (`setup/editorconfig`, `setup/psr4`, `setup/phpunit`).

`wp` holds the framework-shaped kinds (a CPT, a REST controller, a CLI command). `wp-api` holds scaffolds that customise a **modern WordPress core API** — code whose shape is dictated by core's own hooks and which must be guarded against the WordPress version that introduced them (`wp-api/speculation`, Speculation Rules, WP 6.8). Those pair with `"wizard_step": "wp-apis"`.

---

## Remote scaffolds via per-repo sources + an upstream index
Expand Down Expand Up @@ -363,6 +365,7 @@ Look at these existing scaffolds when authoring a new one:
| Plain class implementing `CLICommand` with PHPUnit stub | `wp/cli` |
| PHP class extending a framework abstract | `wp/cpt`, `wp/taxonomy`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role` |
| Cron handler implementing `Registrable` directly | `wp/cron` |
| Version-guarded customisation of a core WP API | `wp-api/speculation` |
| Module that hosts other Registrable classes | `wp/module` |
| Static config file (no inputs) | `setup/editorconfig` |
| Wiring into an existing JSON file | `setup/psr4` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Files group by **kind**, never by feature. `<Root>` = project's autoload root (e
| `wp/cli` | `includes/Cli/` | `<Root>\Cli` | `tests/Cli/` | `<Root>\Tests\Cli` | `<Root>\Modules\Cli` |
| `wp/cron` | `includes/Cron/` | `<Root>\Cron` | `tests/Cron/` | `<Root>\Tests\Cron` | `<Root>\Modules\Cron` |
| `wp/registrable` | `includes/Services/` | `<Root>\Services` | `tests/Services/` | `<Root>\Tests\Services` | `<Root>\Modules\Services` |
| `wp-api/speculation` | `includes/Services/` | `<Root>\Services` | `tests/Services/` | `<Root>\Tests\Services` | `<Root>\Modules\Services` |

**Modules host one kind each. No `Modules/<Feature>/...`.** A multi-kind feature (e.g. Testimonials = CPT + taxonomy + block + REST) spans the per-kind directories and wires into each kind's module.

Expand All @@ -95,6 +96,7 @@ Write a test-case checklist covering:
- `wp/block-dynamic`: block name, `register_hooks` action, `render()` markup with `WP_Query` fixture, empty state, count cap, attribute filters.
- `wp/cron`: `wp_next_scheduled()`, callback fires, unschedule works.
- `wp/cli`: `WP_CLI::add_command` registered, `__invoke` behaviour, dry-run flag.
- `wp-api/speculation`: both filters bound, the `MODE`/`EAGERNESS` constants hold values core accepts, `wp_speculation_rules_configuration` returns the scaffolded mode/eagerness, a `null` config stays `null`, exclusions merge without dropping other callers' paths, and `register_hooks()` no-ops on WP < 6.8.

Show the checklist to the developer. Ask: confirm, add, remove? Resolve before scaffolding. This is the cheapest place to catch a misread requirement.

Expand Down Expand Up @@ -159,7 +161,7 @@ Frameworks per kind:

| Kind | Framework |
|---|---|
| `wp/cpt`, `wp/taxonomy`, `wp/cron`, `wp/cli`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role`, `wp/registrable` | PHPUnit |
| `wp/cpt`, `wp/taxonomy`, `wp/cron`, `wp/cli`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role`, `wp/registrable`, `wp-api/speculation` | PHPUnit |
| `wp/block-dynamic` | Jest (edit.js) + PHPUnit (render method) |
| `block/interactive` | Jest + Playwright |
| `ci/*` | actionlint + yaml-parse |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"slug": "speculation",
"category": "wp-api",
"name": "Speculative Loading",
"description": "Tunes WordPress core's Speculation Rules API (core since WP 6.8). Generates a Registrable that pins the prefetch/prerender mode and eagerness through the 'wp_speculation_rules_configuration' filter and excludes paths through 'wp_speculation_rules_href_exclude_paths', guarded so it no-ops on WP < 6.8.",
"source": "template",
"wizard_step": "wp-apis",
"inputs": [
{
"key": "namespace",
"description": "PSR-4 namespace for the service class. Default 'Inc\\Services' matches the rtCamp skeleton.",
"discover_from": "composer.json:autoload.psr-4",
"default": "Inc\\Services"
},
{
"key": "base_path",
"description": "Directory for the service file. Default 'includes/Services'.",
"discover_from": "composer.json:autoload.psr-4",
Comment thread
Adi-ty marked this conversation as resolved.
"default": "includes/Services"
},
{
"key": "tests_namespace",
"description": "PSR-4 namespace for the test class. Default 'Inc\\Tests\\Services'.",
"discover_from": "composer.json:autoload.psr-4",
"default": "Inc\\Tests\\Services"
},
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Services'.",
"default": "tests/Services"
},
{
"key": "name",
"description": "Logical service name. Normalised to a PascalCase class name, so 'speculative-loading' and 'SpeculativeLoading' both yield class SpeculativeLoading.",
"required": true
},
{
"key": "class",
"description": "PascalCase class name, derived from name.",
"discover_from": "input:name",
"transform": "pascal-case"
},
{
"key": "mode",
"description": "Speculative loading mode written into the MODE constant. One of 'prefetch' (fetch the document early) or 'prerender' (render it in a hidden tab). 'auto' hands the choice back to core, which resolves it to 'prefetch'.",
"default": "prerender"
},
{
"key": "eagerness",
"description": "How keenly the browser acts on the rules, written into the EAGERNESS constant. One of 'conservative' (on pointer/touch down), 'moderate' (on hover, this scaffold's default) or 'eager' (as soon as the link is known). 'auto' hands the choice back to core, which resolves it to 'conservative'.",
"default": "moderate"
}
],
"files": [
{
"src": "templates/speculation.php.mustache",
"dest": "{{base_path}}/{{class}}.php"
}
],
"wiring": [
{
"target_file": "{{base_path}}/../Modules/Services.php",
"anchor": "// scaffold:wp/registrable:classes",
"snippet_template": "\\{{namespace}}\\{{class}}::class,",
"description": "Add the class to a Services (or performance-specific) module so the framework Loader instantiates it and calls register_hooks(). If no suitable module exists, scaffold one first via `wp-tooling add wp/module --name=Services --kind=registrable`."
}
],
"tests": [
{
"src": "templates/test.php.mustache",
"dest": "{{tests_path}}/{{class}}Test.php",
"framework": "phpunit"
}
],
"composer_dependencies": {
"rtcamp/wp-framework": "^1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php
/**
* {{class}}: speculative loading rules (mode: {{mode}}, eagerness: {{eagerness}}).
*
* Scaffolded by @rtcamp/wp-tooling.
*
* @link https://github.com/rtCamp/wp-framework
* @link https://developer.wordpress.org/reference/functions/wp_get_speculation_rules/
*
* @package {{namespace}}
*/

declare( strict_types = 1 );

namespace {{namespace}};

use rtCamp\WPFramework\Contracts\Interfaces\Registrable;

/**
* Customises WordPress core's Speculation Rules API (core since WP 6.8).
*
* Core defaults both settings to 'auto', which it resolves to prefetch with
* conservative eagerness. This class pins them instead, and keeps state-changing
* or personalised URLs out of the rules. The three constants are the tunable
* surface; the filter bodies rarely change.
*/
final class {{class}} implements Registrable {

/**
* Speculation mode: 'prefetch', 'prerender', or 'auto' to defer to core.
*/
public const MODE = '{{mode}}';

/**
* Eagerness: 'conservative', 'moderate', 'eager', or 'auto' to defer to core.
*/
public const EAGERNESS = '{{eagerness}}';

/**
* Paths never prefetched or prerendered, as URL path patterns.
*
* Prerendering executes the target page, so exclude anything that mutates
* state or is personalised, for example:
* [ '/cart/', '/checkout/', '/my-account/*' ].
*
* @var string[]
*/
public const EXCLUDE_PATHS = [];

/**
* Bind the speculation rules filters. Called by the framework Loader.
*
* No-ops before WP 6.8, where the Speculation Rules API does not exist.
*/
public function register_hooks(): void {
if ( ! function_exists( 'wp_get_speculation_rules' ) ) {
return;
}

add_filter( 'wp_speculation_rules_configuration', [ $this, 'filter_configuration' ] );
add_filter( 'wp_speculation_rules_href_exclude_paths', [ $this, 'filter_href_exclude_paths' ] );
}

/**
* Pin the speculation mode and eagerness.
*
* A null $config means another filter switched speculative loading off, so it
* is returned untouched rather than revived. Any other non-array value falls
* outside the filter's contract and is likewise left alone.
*
* @param array<string, string>|null $config Core configuration, or null when disabled.
* @return array<string, string>|null Filtered configuration.
*/
public function filter_configuration( $config ) {
if ( ! is_array( $config ) ) {
return $config;
}

return [
'mode' => self::MODE,
'eagerness' => self::EAGERNESS,
];
}

/**
* Add this project's paths to the href exclusion list.
*
* Core passes the current mode as a second argument; add it to the
* signature (and bump add_filter()'s accepted_args) to vary the
* exclusions between prefetch and prerender.
*
* @param string[] $paths Path patterns already excluded.
* @return string[] Path patterns with this project's exclusions merged in.
*/
public function filter_href_exclude_paths( array $paths ): array {
return array_values( array_unique( array_merge( $paths, self::EXCLUDE_PATHS ) ) );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php
/**
* Tests for {{namespace}}\{{class}}.
*
* Scaffolded by @rtcamp/wp-tooling.
*
* @link https://github.com/rtCamp/wp-framework
*
* @package {{tests_namespace}}
*/

declare( strict_types = 1 );

namespace {{tests_namespace}};

use {{namespace}}\{{class}};
use WP_UnitTestCase;
use rtCamp\WPFramework\Contracts\Interfaces\Registrable;

/**
* Class {{class}}Test
*
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {

/**
* The class is a Registrable, so the framework Loader boots it.
*/
public function test_implements_registrable(): void {
$this->assertInstanceOf( Registrable::class, new {{class}}() );
}

/**
* The filters are bound if, and only if, the Speculation Rules API exists.
*
* Before WP 6.8 there is nothing to customise, so register_hooks() must
* bind nothing rather than fatal.
*/
public function test_register_hooks_binds_the_filters_only_when_the_api_exists(): void {
( new {{class}}() )->register_hooks();

$api_exists = function_exists( 'wp_get_speculation_rules' );
$config_bound = false !== has_filter( 'wp_speculation_rules_configuration' );
$exclusion_bound = false !== has_filter( 'wp_speculation_rules_href_exclude_paths' );

$this->assertSame( $api_exists, $config_bound );
$this->assertSame( $api_exists, $exclusion_bound );
}

/**
* The scaffolded constants are values core actually accepts.
*
* Core silently replaces an unknown mode or eagerness with its own default,
* so a typo would pin nothing at all. 'immediate' is left out on purpose:
* core rejects it for document-level rules.
*/
public function test_constants_are_valid_core_values(): void {
$this->assertContains( {{class}}::MODE, [ 'auto', 'prefetch', 'prerender' ] );
$this->assertContains(
{{class}}::EAGERNESS,
[ 'auto', 'conservative', 'moderate', 'eager' ]
);
}

/**
* The configuration filter pins the scaffolded mode and eagerness.
*/
public function test_configuration_is_pinned_to_the_scaffolded_values(): void {
$config = ( new {{class}}() )->filter_configuration(
[
'mode' => 'auto',
'eagerness' => 'auto',
]
);

$this->assertSame(
[
'mode' => {{class}}::MODE,
'eagerness' => {{class}}::EAGERNESS,
],
$config
);
}

/**
* A null configuration means speculative loading is off, and stays off.
*/
public function test_configuration_stays_disabled_when_it_is_null(): void {
$this->assertNull( ( new {{class}}() )->filter_configuration( null ) );
}

/**
* The exclusion filter adds this project's paths without dropping others'.
*/
public function test_href_exclude_paths_keeps_paths_added_by_others(): void {
$paths = ( new {{class}}() )->filter_href_exclude_paths( [ '/existing/' ] );

$this->assertContains( '/existing/', $paths );

foreach ( {{class}}::EXCLUDE_PATHS as $excluded ) {
$this->assertContains( $excluded, $paths );
}
}
}
Loading