diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 0648c52..5108a43 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -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 diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md index ca56ba1..8e426a6 100644 --- a/node-packages/wp-tooling/docs/ai-orchestration.md +++ b/node-packages/wp-tooling/docs/ai-orchestration.md @@ -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: diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index c37a5f5..fb4e912 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -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:` — derive from another resolved input (e.g. `class` from `name`, with a `pascal-case` transform). -- `composer.json:` / `package.json:` — 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:` / `package.json:` — 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:` — 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: @@ -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 @@ -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` | diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md index e0c1784..1a45055 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md @@ -76,6 +76,7 @@ Files group by **kind**, never by feature. `` = project's autoload root (e | `wp/cli` | `includes/Cli/` | `\Cli` | `tests/Cli/` | `\Tests\Cli` | `\Modules\Cli` | | `wp/cron` | `includes/Cron/` | `\Cron` | `tests/Cron/` | `\Tests\Cron` | `\Modules\Cron` | | `wp/registrable` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | +| `wp-api/speculation` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | **Modules host one kind each. No `Modules//...`.** A multi-kind feature (e.g. Testimonials = CPT + taxonomy + block + REST) spans the per-kind directories and wires into each kind's module. @@ -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. @@ -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 | diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json new file mode 100644 index 0000000..af25187 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json @@ -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", + "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" + } +} diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache new file mode 100644 index 0000000..b9f4571 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache @@ -0,0 +1,98 @@ +|null $config Core configuration, or null when disabled. + * @return array|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 ) ) ); + } +} diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache new file mode 100644 index 0000000..59685df --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache @@ -0,0 +1,105 @@ +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 ); + } + } +} diff --git a/node-packages/wp-tooling/skills/scaffold/SKILL.md b/node-packages/wp-tooling/skills/scaffold/SKILL.md index e0c1784..1a45055 100644 --- a/node-packages/wp-tooling/skills/scaffold/SKILL.md +++ b/node-packages/wp-tooling/skills/scaffold/SKILL.md @@ -76,6 +76,7 @@ Files group by **kind**, never by feature. `` = project's autoload root (e | `wp/cli` | `includes/Cli/` | `\Cli` | `tests/Cli/` | `\Tests\Cli` | `\Modules\Cli` | | `wp/cron` | `includes/Cron/` | `\Cron` | `tests/Cron/` | `\Tests\Cron` | `\Modules\Cron` | | `wp/registrable` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | +| `wp-api/speculation` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | **Modules host one kind each. No `Modules//...`.** A multi-kind feature (e.g. Testimonials = CPT + taxonomy + block + REST) spans the per-kind directories and wires into each kind's module. @@ -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. @@ -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 | diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 6102469..4a39a15 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -842,10 +842,11 @@ async function loadDiscovery(cwd) { * * Supported sources: * - `composer.json:` / `package.json:` — dotted lookup of - * a string value. Special case: `autoload.psr-4` / `autoload.psr-0` yields - * the root **namespace** (first map key, trailing `\\` stripped) — but only - * for non-path inputs, since the PSR-4 root directory is rarely a scaffold's - * target sub-path; path inputs (e.g. `base_path`) keep their default. + * a string value. Special case: `autoload.psr-4` / `autoload.psr-0` reads the + * first map entry — its **namespace** (key, trailing `\\` stripped) for + * ordinary inputs, its **directory** (value) for path inputs — and grafts + * that root onto the input's `default`, keeping the default's sub-namespace + * or sub-directory. * - `config:` — string value from `.wp-tooling.json`. * * Unknown sources (e.g. `plugin-header:`, `input:` handled by the caller) @@ -870,9 +871,6 @@ function discoverFromSource(decl, discovery) { } const selector = spec.slice(colon + 1); if (selector === 'autoload.psr-4' || selector === 'autoload.psr-0') { - if (isPathInput(decl.key)) { - return undefined; // dir inputs keep their (more specific) default - } const map = getByPath(obj, selector); const firstKey = map && typeof map === 'object' && !Array.isArray(map) @@ -881,6 +879,11 @@ function discoverFromSource(decl, discovery) { if (!firstKey) { return undefined; } + if (isPathInput(decl.key)) { + const def = + typeof decl.default === 'string' ? decl.default : ''; + return graftPath(map[firstKey], def); + } const root = firstKey.replace(/\\+$/, ''); // Graft the discovered root onto the default's sub-namespace: a // default of `Inc\Cli` means "PSR-4 root + `\Cli`", so a project @@ -907,6 +910,33 @@ function isPathInput(key) { return /(^|_)(path|dir)$/.test(key) || key === 'base_path'; } +// The directory counterpart of the namespace graft in discoverFromSource(): a +// PSR-4 map's *value* is the autoload root directory, so a default of +// `includes/Cli` means "root dir + `/Cli`". Grafting keeps the scaffold's +// sub-directory while following the project's own layout — without it, a project +// mapping its root to `inc/` or `src/` gets a correctly-namespaced class written +// outside the autoload root, where it never loads. +function graftPath(value, def) { + // A PSR-4 target may be a list of directories; the first is the canonical one. + const raw = Array.isArray(value) ? value[0] : value; + if (typeof raw !== 'string') { + return undefined; + } + let dir = raw.trim().replace(/\/+$/, ''); + if (dir === '.') { + dir = ''; + } else if (dir.startsWith('./')) { + dir = dir.slice(2); + } + const slash = def.indexOf('/'); + const tail = slash === -1 ? '' : def.slice(slash + 1); + if (!dir) { + // Root-level autoload: the sub-directory alone is the whole path. + return tail || undefined; + } + return tail ? `${dir}/${tail}` : dir; +} + // Resolve a dotted path (`a.b.c`) within a plain object; undefined if absent. function getByPath(obj, dotted) { let cur = obj; diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index 2c44225..14f9895 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -6,6 +6,8 @@ * - wp/cli namespace + tests_namespace discovery grafts the project's * PSR-4 root onto the kind sub-namespace * - wiring targetFile paths are normalised (no `..` segments) + * - wp-api/speculation renders into the registrable layout, reuses the + * registrable wiring anchor, and follows a non-`includes/` PSR-4 root */ 'use strict'; @@ -88,3 +90,67 @@ describe('wiring targetFile normalisation', () => { expect(target).not.toContain('..'); }); }); + +describe('wp-api/speculation', () => { + it('renders into the Services layout and reuses the registrable anchor', async () => { + const r = registry; + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': 'includes/' } }, + }), + 'utf8' + ); + const result = await r.execute( + 'wp-api/speculation', + { name: 'speculative-loading' }, + { dryRun: true, cwd: target } + ); + expect(result.engine.inputs.namespace).toBe('Acme\\Blog\\Services'); + expect(result.engine.inputs.mode).toBe('prerender'); + expect(result.engine.inputs.eagerness).toBe('moderate'); + expect(result.engine.wrote).toEqual([ + 'includes/Services/SpeculativeLoading.php', + ]); + expect(result.ai.tests[0].path).toBe( + 'tests/Services/SpeculativeLoadingTest.php' + ); + // The generated class IS a Registrable, so it wires into the same + // module (and the same anchor) as wp/registrable. + const wiring = result.ai.wiring[0]; + expect(wiring.targetFile).toBe('includes/Modules/Services.php'); + expect(wiring.targetFile).not.toContain('..'); + expect(wiring.anchor).toBe('// scaffold:wp/registrable:classes'); + expect(wiring.snippet).toBe( + '\\Acme\\Blog\\Services\\SpeculativeLoading::class,' + ); + }); + + it('follows the project PSR-4 root when it is not includes/', async () => { + // Both consuming repos map their root to `inc/`, so the namespace and + // the directory have to be grafted from the same map entry — otherwise + // the class is namespaced `\Services` but written to + // `includes/Services`, outside the autoload root. + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': 'inc/' } }, + }), + 'utf8' + ); + const result = await registry.execute( + 'wp-api/speculation', + { name: 'speculative-loading' }, + { dryRun: true, cwd: target } + ); + expect(result.engine.inputs.namespace).toBe('Acme\\Blog\\Services'); + expect(result.engine.wrote).toEqual([ + 'inc/Services/SpeculativeLoading.php', + ]); + const wiringTarget = result.ai.wiring[0].targetFile; + expect(wiringTarget).toBe('inc/Modules/Services.php'); + expect(wiringTarget).not.toContain('..'); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/discover-from.test.js b/node-packages/wp-tooling/tests/scaffolds/discover-from.test.js index c648b3d..3a88d24 100644 --- a/node-packages/wp-tooling/tests/scaffolds/discover-from.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/discover-from.test.js @@ -1,6 +1,7 @@ /** * Tests for the file-based `discover_from` resolvers added to registry.js: - * - composer.json:autoload.psr-4 -> root namespace (non-path inputs only) + * - composer.json:autoload.psr-4 -> root namespace, or root directory for + * path inputs (both grafted onto the default) * - composer.json: -> string value * - package.json: -> string value * - config: -> value from .wp-tooling.json @@ -55,6 +56,12 @@ const SCAFFOLD = { discover_from: 'composer.json:autoload.psr-4', default: 'Inc', }, + { + key: 'root_dir', + description: 'Bare-root directory (no sub-directory in default)', + discover_from: 'composer.json:autoload.psr-4', + default: 'includes', + }, { key: 'tests_namespace', description: 'Deep default, grafted onto the discovered root', @@ -68,7 +75,7 @@ const SCAFFOLD = { { target_file: 'wire.php', snippet_template: - '{{namespace}}::{{text_domain}}::{{root_ns}}::{{tests_namespace}}', + '{{namespace}}::{{text_domain}}::{{root_ns}}::{{tests_namespace}}::{{root_dir}}', }, ], }; @@ -88,12 +95,12 @@ async function run(targetDir, supplied = { name: 'demo' }) { dryRun: true, cwd: targetDir, }); - // snippet is "{{namespace}}::{{text_domain}}::{{root_ns}}::{{tests_namespace}}" - const [namespace, textDomain, rootNs, testsNamespace] = + // snippet is "{{namespace}}::{{text_domain}}::{{root_ns}}::{{tests_namespace}}::{{root_dir}}" + const [namespace, textDomain, rootNs, testsNamespace, rootDir] = result.ai.wiring[0].snippet.split('::'); const basePath = result.engine.wrote[0].replace(/\/demo\.php$/, ''); fs.rmSync(projectDir, { recursive: true, force: true }); - return { namespace, textDomain, rootNs, testsNamespace, basePath }; + return { namespace, textDomain, rootNs, testsNamespace, rootDir, basePath }; } describe('discover_from: composer.json:autoload.psr-4', () => { @@ -116,7 +123,7 @@ describe('discover_from: composer.json:autoload.psr-4', () => { fs.rmSync(target, { recursive: true, force: true }); }); - it('does NOT override a path input (base_path keeps its default)', async () => { + it('grafts a path input onto the PSR-4 directory, keeping its sub-directory', async () => { const target = makeTmpDir(); fs.writeFileSync( path.join(target, 'composer.json'), @@ -124,8 +131,39 @@ describe('discover_from: composer.json:autoload.psr-4', () => { autoload: { 'psr-4': { 'rtCamp\\Theme\\MyTheme\\': 'inc/' } }, }) ); + const { basePath, rootDir } = await run(target); + // `inc/` is the autoload root here, so the default `includes/Cli` must + // become `inc/Cli`; a class written to `includes/Cli` would carry the + // grafted namespace but sit outside the autoload root and never load. + expect(basePath).toBe('inc/Cli'); + // A default with no sub-directory resolves to the root directory itself. + expect(rootDir).toBe('inc'); + fs.rmSync(target, { recursive: true, force: true }); + }); + + it('grafts from the first entry of a list-valued PSR-4 target', async () => { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': ['src/', 'lib/'] } }, + }) + ); const { basePath } = await run(target); - expect(basePath).toBe('includes/Cli'); // default, not 'inc' from psr-4 + expect(basePath).toBe('src/Cli'); + fs.rmSync(target, { recursive: true, force: true }); + }); + + it('uses the sub-directory alone when PSR-4 maps to the project root', async () => { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ autoload: { 'psr-4': { 'Acme\\Blog\\': './' } } }) + ); + const { basePath, rootDir } = await run(target); + expect(basePath).toBe('Cli'); + // Nothing to graft onto for a bare default: fall back to it. + expect(rootDir).toBe('includes'); fs.rmSync(target, { recursive: true, force: true }); }); @@ -153,11 +191,13 @@ describe('discover_from: composer.json:autoload.psr-4', () => { autoload: { 'psr-4': { 'rtCamp\\Theme\\MyTheme\\': 'inc/' } }, }) ); - const { namespace } = await run(target, { + const { namespace, basePath } = await run(target, { name: 'demo', namespace: 'Acme\\Custom', + base_path: 'custom/dir', }); expect(namespace).toBe('Acme\\Custom'); + expect(basePath).toBe('custom/dir'); fs.rmSync(target, { recursive: true, force: true }); }); });