diff --git a/node-packages/wp-tooling/AGENTS.md b/node-packages/wp-tooling/AGENTS.md
index c955f0d..ee0d1fc 100644
--- a/node-packages/wp-tooling/AGENTS.md
+++ b/node-packages/wp-tooling/AGENTS.md
@@ -43,7 +43,7 @@ src/
bin/wp-tooling.js thin shim requiring ../src/cli/index.js
scaffolds/ top-level categories: ci/, lint/, setup/, wp/
docs/ ai-orchestration.md, authoring-scaffolds.md, editor-setup.md,
- examples.md, wp-framework-contract.md
+ examples.md, wp-primitives-contract.md
skills/ README.md + scaffold/ + setup/ + devtools-setup/ (each with SKILL.md + evals/);
*-workspace/ dirs are gitignored eval scratch, not shipped content
tests/ mirrors src/'s 8 dirs, plus fixtures/ and a skills/ test dir (10 total)
diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md
index 71389de..6e65458 100644
--- a/node-packages/wp-tooling/CHANGELOG.md
+++ b/node-packages/wp-tooling/CHANGELOG.md
@@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `wp-tooling coverage-gate` subcommand (`src/ci/coverage-gate/` — `diff`, `reports`, `gate`, `render`, `cli`; `computeGate`, `parseClover`, `parseLcov`, `parseDiffHunks`, `GateError`, `UNMEASURED_POLICIES`, `DEFAULT_UNMEASURED_POLICY` exported from `@rtcamp/wp-tooling/ci`). Diffs committed changes only (`...HEAD`, `--unified=0 --find-renames --diff-filter=AMR --relative`) from `--working-dir`, intersects the added/modified lines with a Clover (`--coverage-clover`) or LCOV report, and exits 1 when the covered share of changed executable lines is below `--threshold` (default 80; exactly at the threshold passes — compared in integer hundredths of a percent, so 8 of 10 at 80 and 147 of 1500 at 9.8 pass). Only Clover `stmt`/`cond` lines count — `type="method"` signature lines don't. Report paths are matched to repo paths by path-segment suffix, so wp-env container paths and absolute LCOV paths resolve. Changed source files missing from the report are listed as "not measured" and never counted as covered; `--unmeasured warn|fail|ignore` (default `warn`) decides whether they annotate a warning, fail the gate, or are only listed. Test and dependency paths are skipped (`--exclude ` replaces the pattern, `--no-exclude` disables it); other out-of-scope files are counted in the summary. Base resolves from `--base`, then the `pull_request`/`merge_group` base SHA in `$GITHUB_EVENT_PATH`, then `origin/$GITHUB_BASE_REF`; with none (push events) the gate is skipped with a notice. A missing or empty report, or a missing base commit, exits 2 rather than passing. `--output github` writes `changed-lines`, `covered-lines`, `percent`, `unmeasured-files`, `passed` step outputs, a `$GITHUB_STEP_SUMMARY` table with per-file missed line ranges, and per-file annotations. An empty flag value counts as not given, so workflows can pass unset inputs through. `--soft-fail` exits 0 and downgrades failure annotations to warnings; `--dry-run` previews the files and annotations without emitting workflow commands. `--threshold` accepts a plain decimal from 0 to 100 with at most two decimals. For rtCamp/wp-shared-workflows#36.
- `lint/i18n` scaffold — a standalone `phpcs.i18n.xml.dist` running only `WordPress.WP.I18n` against the project's text domain, plus a `lint:i18n` composer script. Standalone because the `text_domain` property is project-specific and the engine never edits an existing `phpcs.xml.dist`. The `text_domain` input is `required` (discovered from `.wp-tooling.json` `textDomain` when present) rather than defaulted, because the sniff silently loses its `MissingArgDomain` / `TextDomainMismatch` checks when the property is wrong or unset. The rendered ruleset deliberately omits `default` from the allowed domains — listing it downgrades a missing domain argument from the `MissingArgDomain` error to the `MissingArgDomainDefault` warning — with a commented opt-in for projects that intentionally reuse core strings. Companion to the i18n lens skill in `rtcamp/wp-devtools`.
-- `utility/*` package scaffolds — `utility/cache`, `utility/transients`, `utility/logger`, `utility/timer` and `utility/feature-selector` wire the `rtcamp/wp-framework` `Utils\*` classes with no template files. Each is `source: "package"`: zero files written, `rtcamp/wp-framework: ^1.0` reported under `developer.install.composer`, and one `ai.wiring` accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. `context_slug` is discovered from `composer.json:name` and normalised to snake_case (`rtcamp/acme-blog` → `rtcamp_acme_blog`); pass `--context_slug` to override. Nothing is installed and nothing is wired automatically. These utilities are neither `Registrable` nor `Shareable`, so the wiring targets the consumer's static helper rather than a module or `Main::CLASSES` — the `Loader` would otherwise construct them with a missing or empty constructor argument.
+- `utility/*` package scaffolds — `utility/cache`, `utility/transients`, `utility/logger`, `utility/timer` and `utility/feature-selector` wire the `rtcamp/wp-primitives` `Utils\*` classes with no template files. Each is `source: "package"`: zero files written, `rtcamp/wp-primitives: ^2.0` reported under `developer.install.composer`, and one `ai.wiring` accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. `context_slug` is discovered from `composer.json:name` and normalised to snake_case (`rtcamp/acme-blog` → `rtcamp_acme_blog`); pass `--context_slug` to override. Nothing is installed and nothing is wired automatically. These utilities are neither `Registrable` nor `Shareable`, so the wiring targets the consumer's static helper rather than a module or `Main::CLASSES` — the `Loader` would otherwise construct them with a missing or empty constructor argument.
- `wp-tooling a11y` subcommand + `@rtcamp/wp-tooling/a11y` library (`runA11y()` core, `runCli()` CLI adapter). Runs the consumer-installed `pa11y-ci` — resolved from the nearest local or hoisted `node_modules/pa11y-ci/package.json` and launched through its `bin` entry with Node; no shim execution, npx fallback, or network install — against the URLs in the project's pa11y config (`.pa11yci.json` by default, `--config ` to point elsewhere) and normalises the JSON into a stable report: `summary` counts plus per-URL `violations` carrying `id`, parsed `wcagCriterion`, `impact`, `runner`, `message`, `selector`, `context`, and grep-ready `domHints` (`tagName`, `classList`, `idAttr`, `attrs`). A URL that fails to load is a `scanError` counted in `summary.failedUrls`, never a violation. Exit codes: 0 clean · 1 run failure or unreachable URL · 2 usage/binary missing · 3 violations found. Supports `--output text|json` and `--dry-run`. The `setup/pa11y` scaffold's dependency pin is corrected to the published `pa11y-ci ^4.1.1`, and its config template now runs both engines (`"runners": ["axe", "htmlcs"]`) and scans project-owned URLs — the front page plus configurable page paths (`sample_page`, `search_page`, optional `extra_page`, each appended to `base_url`) — instead of `wp-admin`/`wp-login` (unauthenticated admin scans only ever audit the core-owned login chrome).
- `accessibility` Claude Code skill (`skills/accessibility/`) — a find → fix → re-check lens over `wp-tooling a11y`: triages violations by WCAG criterion and impact, maps each one to the theme/plugin source that rendered it via the report's `domHints`, proposes minimal fixes with consent, and re-verifies until clean; core/third-party markup is classified as upstream and reported, never patched. Installed into consumers by `setup/claude-skills` alongside the `scaffold` and `setup` skills.
- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (launched through its package `bin` entry with Node via the shared `src/a11y/resolve-bin.js` resolver, like `pa11y-ci` — no `.bin` shim or npx fallback; `--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits; it navigates in its own browser, so it still runs (and records or degrades on its own) after a puppeteer navigation failure. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD`, or `EBADCONFIG` for a known field with the wrong type or value, e.g. `"enabled": "false"` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. `engines.node` is `>=22.19` (Lighthouse 13's floor), with a matching root `.nvmrc`.
@@ -21,14 +21,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `wp/block-interactive` scaffold — generates a block driven by core's Interactivity API (core since WP 6.5): a `block.json` declaring `supports.interactivity` plus `viewScriptModule`, a `render.php` emitting the `data-wp-*` directives, a `view.js` store, the editor entry points, and a `Registrable` that registers the block from its build output. Deliberately not an `AbstractBlock` subclass — that abstract forces a `render_callback`, which would override `block.json`'s `render`. Registration no-ops before WP 6.5 and before a build has run. Shares the Blocks module and the `// scaffold:wp/block-dynamic:classes` anchor with `wp/block-dynamic`.
- `wp-api/block-bindings` scaffold — generates a `Registrable` that registers a Block Bindings source (core since WP 6.5) through `register_block_bindings_source()` on `init`, with core's exact `get_value_callback` signature, a `USES_CONTEXT` declaration so the callback can read the bound block's context, and a `function_exists()` guard for WP < 6.5.
- `wp-api/script-module` scaffold — generates a Script Module (core since WP 6.5): an ES module plus a `Registrable` that registers it under an `@vendor/name` id, reads dependencies and a cache-busting version from the build's generated `.asset.php`, normalises string dependencies to core's `[ 'id' => ... ]` form, and enqueues it on a hook constrained by `enum`. Stays on the WP 6.5 signature — `wp_register_script_module()`'s `$args` parameter arrived in 6.9.
+- `lint/phpcs/full` on `rtCampWP` and `lint/phpcs/core` on `rtCampWP-Basic`, both from `rtcamp/wp-phpcs`, the standards the v2 plugin and theme skeletons use. Both variants:
+ - fill `testVersion` from the `Requires PHP` header (PHPCompatibility aborts on the first line of every file without it), `minimum_wp_version` from `Requires at least`, the text domain from `Text Domain`, and the prefixes from the text domain and the PSR-4 namespace;
+ - accept PSR-4 class file names, and exempt block `render.php` files from the global-variable prefix and text-domain-mismatch checks, since core includes them inside a function and they translate with `block.json`'s text domain;
+ - lint `tests/`, exempting it from the prefix, VIP file-operation and remote-data, `ini_set` and global-override rules by sniff name. An exclude-pattern on a re-referenced standard such as `WordPress-Docs` has no effect, because its sniffs are already loaded;
+ - declare `rtcamp/wp-phpcs` as the only dev dependency, with two `composer.json` wiring entries without which `composer require` fails: the `rtCamp/wp-phpcs` VCS repository, since the package is not on Packagist, and `allow-plugins` for the Composer installer that registers the standards.
+
+ Every PHP file the bundled `wp/*`, `wp-api/*` and `setup/phpunit` scaffolds render (classes, tests and the PHPUnit bootstrap), and the five `utility/*` accessor snippets, passes both.
+- `automattic/vipwpcs` as `lint/phpcs/vip`'s dev dependency, with `allow-plugins` wiring for `dealerdirect/phpcodesniffer-composer-installer`, which WPCS and VIPCS depend on: without it a non-interactive `composer require` exits 1 and no standard is registered. The `setup` skill's install command and `docs/examples.md` use the same package.
+- VIP project detection in the `setup` skill, matching `WordPressVIPMinimum`, `WordPress-VIP-Go`, `automattic/vipwpcs`, `VIP_GO_APP_ENVIRONMENT` or `VIP_GO_ENV` across `*.php`, `*.xml`, `*.dist` (including `phpcs.xml.dist`), `*.json` and `*.yml`.
+- `tests_namespace` input for `setup/phpunit`, which names `tests/bootstrap.php`'s `@package` after the project's PSR-4 test namespace so the bootstrap passes `WordPress-Docs`.
+- `php-escape`, `js-string` and `php-project-root` input transforms. `php-escape` and `js-string` embed display text in PHP and JavaScript string literals, and `php-project-root` turns a project-relative directory into a PHP root expression (`inc/Blocks` → `dirname( __DIR__, 2 )`). Derived inputs use them via `discover_from: input:`.
+
+### Changed
+
+- **BREAKING:** PHP scaffolds now target `rtcamp/wp-primitives` `^2.0` and the `rtCamp\WPPrimitives\` namespace. Projects on `rtcamp/wp-framework` `^1.0` must [migrate to wp-primitives 2.0](https://github.com/rtCamp/wp-primitives/blob/main/docs/upgrading.md) before generating new classes.
### Fixed
+- Human-readable scaffold labels and the `wp/cli` command description now escape apostrophes and backslashes in PHP and JavaScript string literals, and quotes in block JSON metadata. Titles such as `Editor's "guide"` generate valid code while preserving the original display text.
+- Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly.
+- Generated block JavaScript follows the current shared ESLint formatting rules.
+- PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics.
- `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.
- Feature finalization rolls back successful hooks when detection or state persistence fails, and defers next-step messages until persistence succeeds. Setup/reinit identity and version writes share rollback protection. Rename batches reuse one plan, support chains and swaps, and detect case-insensitive destination collisions. Cleanup targets and explicit example markers reject malformed values before mutation.
- Init validates shared setup/manage selections before mutation, confirms setup choices before applying identity, and uses current persisted tokens for reinitialization. Version-only edits now update files and identity; reinit preserves one-shot example choices and refuses corrupt identity.
- Feature hooks are awaited inside their rollback journal. Cancellation/no-op manage calls leave persisted state unchanged; failed transitions stop, retain shared dependency ownership, and report rollback failures.
- Init rejects rename collisions, unsafe configured paths and symlink escapes, propagates filesystem and external-step failures, and reports partial setup instead of unconditional success. Identity edits roll back file changes on failure and preserve unrelated metadata. Fully rolled-back feature transitions leave saved state untouched, and dependency/script values must be strings. Git-step failures report that project setup completed; failures during project setup still require inspecting partial changes before retrying.
+- Scaffolded PHP now passes `rtCampWP` clean. Rendering all 12 `wp/*` scaffolds into a skeleton consumer produced **105 PHPCS errors** before this release and none after (`use`-ordering, the one thing no template can settle, is now sorted by the engine after rendering — see the entry below). The causes:
+ - 24 templates emitted `declare(strict_types=1);`, whose spacing `SlevomatCodingStandard.TypeHints.DeclareStrictTypes` rejects — one error in every file the older scaffolds wrote. `setup/phpunit`'s `tests/bootstrap.php` had no `declare` line at all.
+ - 35 bare `@return` annotations restated a native return type without adding a description or a generic shape, which `ReturnTypeHint.UselessAnnotation` rejects. Removed, matching how the hand-written skeleton classes document typed methods.
+ - 41 missing docblocks in the older test stubs: no `@package` in the file comment (12) and no doc comment on a test method (29).
+ - 5 `use` blocks ordered so `AlphabeticallySortedUses` rejected them under its case-sensitive setting, in the `wp/block-dynamic`, `wp/cli` and `wp/rest` classes and the `wp/cli` and `wp/registrable` test stubs.
+- Rendered PHP has its `use` block sorted by the engine, so generated code satisfies `AlphabeticallySortedUses` whatever the consuming project is called. The correct order depends on where the project's own namespace sorts against `PHPUnit\` and `WP_*`, which a Mustache template cannot know: the same test stub needs one order under `Inc\…` and the opposite under `rtCamp\…`. Rendering into a theme namespaced `rtCamp\Theme\Elementary` used to leave an error in **every** generated test file. The sort reproduces the sniff exactly — segment by segment, byte-wise, case sensitive — and is deliberately narrow: a group import, an alias, a `use function`/`use const`, a multi-line statement or a comment inside the block all leave the file untouched rather than risk a bad edit, and indented trait imports are never touched.
+- `discover_from: plugin-header:` is implemented. Seven `wp/*` scaffolds have always declared `plugin-header:text-domain`, but nothing resolved it — the prefix fell through to `undefined` and every scaffold silently emitted the manifest default `my-plugin`, which is a PHPCS `Mismatched text domain` error in any project not actually called that (twelve of them in a single skeleton run). The engine now reads the entry header, preferring a root `*.php` with `Plugin Name:` and falling back to a theme's `style.css` with `Theme Name:`, scanning only the header comment.
+- `discover_from: composer.json:autoload-dev.psr-4` is supported, and every scaffold that writes a test now uses it for `tests_path`. The selector was previously unrecognised — declaring it was a silent no-op — so test stubs went to the manifest default (`tests/`) while both first-party consumers autoload *and* collect tests from `tests/php/`. The generated test was not merely unloadable, it was never collected: PHPUnit's testsuite is a directory glob, so the case silently did not exist.
+- `validate` rejects a `discover_from` naming a source the engine cannot resolve, listing the ones it can. `code:*`, which the orchestrating skill resolves, is still accepted. This is the guard that would have caught the two entries above at authoring time rather than in generated output.
+- `release:bump`, `release:changelog` and `release:zip` work on themes. They resolved the project through a `Plugin Name:` header only, so every one of them failed outright in a theme with *no plugin entry file found*. They now fall back to a `style.css` carrying `Theme Name:`, taking the slug from the directory — which is what WordPress keys a theme on — and `release:bump` rewrites the `Version:` header there.
+- `release:zip` warns when the payload is far too large to be a release, attributing the bulk to the top-level directories responsible. Building the skeleton with dev dependencies installed plans a **2.6 GB** archive, because `vendor/` is not in the default ignore list — and it should not be, since a plugin that autoloads its Composer dependencies has to ship it. Dropping it by default would quietly produce a broken plugin, so the size is reported instead, pointing at `composer install --no-dev`.
+- `wp/rest` no longer generates a controller that fatals on load. `WP_REST_Controller::get_items()` and `get_items_permissions_check()` declare `$request` untyped, so the generated `WP_REST_Request $request` narrowed an inherited `mixed` — which PHP rejects outright: *"Declaration … must be compatible with …"*, taking down the whole request. Both now leave the parameter and return untyped, matching the parent; the real types live in `@param \WP_REST_Request` / `@return` PHPDoc, and `WP_REST_Response` is fully qualified so no docblock-only `use` is left for a formatter to strip.
+- Generated tests no longer fire a global action hook to trigger their own registration. `wp/shortcode` called `do_action( 'init' )` and `wp/settings-page` called `do_action( 'admin_init' )`, which re-run every *other* listener on those hooks — in a real project that means duplicate block registrations and headers sent after the harness flushed them, both of which `WP_UnitTestCase` fails on. They now call `register_shortcode()` and `register_settings()` directly. `wp/rest` had the mirror-image bug — it called `register_routes()` outside `rest_api_init`, the only context where `register_rest_route()` is legal — and now goes through the hook.
+- `wp/admin-page` no longer emits a comparison that is always true. The template rendered `$parent = '{{parent_slug}}'; return '' === $parent ? null : $parent;`, so leaving `parent_slug` at its default — every top-level page — produced `'' === ''` and a PHPStan `identical.alwaysTrue`. The template now emits a `get_parent_slug(): string` override only when `parent_slug` is set; a top-level page inherits the framework's `null` default, so neither variant returns a type wider than it needs (`checkTooWideReturnTypesInProtectedAndPublicMethods`, which `rtcamp/wp-phpstan` enables).
+- `wp/block-dynamic` emits `protected function get_block_dir(): string`. It was `public ... : ?string`, widening the visibility `AbstractBlock` declares (the same divergence fixed above for `get_classes()`) and keeping a nullable half the body never returns.
+- `wp/cpt`, `wp/taxonomy` and `wp/user-role` label accessors satisfy the framework's `@return non-empty-string`. `__()` is typed `string`, so at PHPStan level 8 — which `theme-elementary` runs — five accessors failed the inherited contract; they now fall back to the untranslated literal.
+- `wp-api/script-module` builds its dependency entries key by key instead of passing the caller's array through, so the result is exactly the `array{id: string, import?: string}` shape `wp_register_script_module()` documents rather than whatever else the array carried.
+- `wp/block-interactive`'s test docblock starts with a capital, which `Generic.Commenting.DocComment.ShortNotCapital` requires and consumers that lint `tests/` enforce.
+- `wp/module` now emits `protected function get_classes()`, matching the `abstract protected` the framework's `AbstractModule` declares and the visibility every hand-written module uses; scaffolded modules were the only ones widening it to `public`. Its generated test reaches the method through reflection, since the module is `final`.
+- `wp/module`'s generated test no longer fails a consumer suite on the run that creates the module. `test_each_listed_class_is_loadable` asserted only inside a `foreach` over the class list, which is empty in a freshly scaffolded module — no assertion recorded, so PHPUnit marked the case risky and consumers run with `failOnRisky`. It now compares the whole list against the loadable subset.
+- The scaffold skill now states outright that it must not wire a class into a spot a later `wp-tooling init` deletes. rtCamp skeletons wrap optional capabilities in `// wp:example:` … `:end` regions and `init` removes a dropped region body and all, but the insertion rule was "after the last sampled occurrence" — which in `Main::CLASSES` is the last line *inside* such a region. `SKILL.md` and `docs/ai-orchestration.md` now refuse that placement at every fall-through level, anchors included. Hardening rather than a repair: on the eval fixtures the previous wording already landed the snippet outside the region, so this makes a safe behaviour a stated rule instead of an inference.
+- `validate.js` derives its manifest key allow-lists from `schema.js` instead of restating them. The two carried independent copies of the same seven lists, so a key could be accepted by the schema and rejected by the validator that actually runs; adding `enum` needed both edits and nothing would have caught doing only one.
+- A `wiring` entry whose `target_file` still resolves outside the project after normalisation is dropped with a warning rather than handed to the AI to edit. `path.posix.normalize` collapses interior `..` but keeps a leading one, so a single-segment path input reached above the project root. A target that normalises to exactly `..` is dropped too.
+- `docs/wp-primitives-contract.md` documented `get_classes()` as `public` and described the wiring snippet as going *above* the module's marker comment, contradicting both the skill and `docs/ai-orchestration.md`.
## [1.0.0] - 2026-07-30
@@ -71,4 +114,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `eslint.config.js` — repo-local flat config extending `@rtcamp/eslint-config`, with Node globals and `n/no-restricted-require` banning runtime deps
> The shareable ESLint, Stylelint and Tailwind configs that previously lived here moved to the
-> standalone `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` packages.
\ No newline at end of file
+> standalone `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` packages.
diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md
index f6fc9b1..2fa1dfa 100644
--- a/node-packages/wp-tooling/docs/ai-orchestration.md
+++ b/node-packages/wp-tooling/docs/ai-orchestration.md
@@ -60,7 +60,7 @@ The engine commits to the following on a successful run. Skills can rely on thes
- `scaffold.kind` is `"package"` for `source: "package"` scaffolds (no files written, only deps and wiring; the bundled examples are `utility/*`) and `"template"` otherwise. Remote (sources) scaffolds also report `kind: "template"` — they render Mustache the same way as local ones; where the scaffold lives is an implementation detail the orchestrator does not need to branch on. Callers branch on `kind` rather than checking `engine.wrote.length === 0`.
- `wp-tooling list --json` entries carry an `origin` of `"default"`, `"project"`, or `"remote"`. Remote scaffolds come from a repo's cached index (`sources.json` → each repo's `index.json`), so their `counts` is `null` (unknown until `add`); local scaffolds carry real `counts`. `list` is online-preferred with a cache fallback: it reads the index (cached, ETag-validated), and a `warnings` array reports any source that was unreachable and uncached. The top-level `{ scaffolds, warnings }` shape carries those notes.
- The engine core has zero dependency on the TTY UI kit. AI orchestration mode never loads any terminal-UI primitive. Skills can rely on the engine being usable from any context, including non-TTY containers, CI runners, and headless test harnesses.
-- File-based `discover_from` (`composer.json:`, `package.json:`, `config:` from `.wp-tooling.json`) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` and `plugin-header:*` sources are **not** engine-resolved and remain the skill's responsibility (§6).
+- File-based `discover_from` (`composer.json:` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:`, `plugin-header:` from the plugin or theme entry header, `config:` from `.wp-tooling.json`) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` sources are **not** engine-resolved and remain the skill's responsibility (§6).
- A scaffold may carry an optional `feature` block, marking it a **toggleable feature** (the bundled example is `setup/tailwind`). This block is **invisible to `add`/`execute`**: it never appears in the result, and `add` behaves identically whether or not it is present. Turning features on and off is a separate surface (§13).
## 4. Skill responsibilities
@@ -222,8 +222,8 @@ The skill discovers project shape from existing artifacts. No config file is req
The engine now resolves the **file-based** `discover_from` sources itself, so the division of labour is:
-- **`composer.json:`, `package.json:`, `config:` → the engine resolves these.** The skill does not need to pre-read these files and pass the value; the engine reads them from `--cwd` and fills the input before applying the `default`. The most important case: `namespace` and `tests_namespace` on every `wp/*` scaffold declare `discover_from: composer.json:autoload.psr-4`, so they are **auto-filled from the project's first PSR-4 root** whenever a `composer.json` exists — with the kind sub-namespace preserved: the discovered root replaces only the *first segment* of the manifest default, so a default of `Inc\Cli` and a PSR-4 root of `Acme\Blog` yield `Acme\Blog\Cli` (and `Inc\Tests\Cli` yields `Acme\Blog\Tests\Cli`). A skill that previously always passed `--namespace` no longer has to — though doing so is still safe (see precedence below).
-- **`code:*` and `plugin-header:*` → still the skill's job.** The engine cannot read a bootstrap class, sample existing patterns, or parse a plugin header. For these the engine returns the manifest `default`; the skill must do the introspection in this section and pass the value in.
+- **`composer.json:`, `package.json:`, `plugin-header:`, `config:` → the engine resolves these.** The skill does not need to pre-read these files and pass the value; the engine reads them from `--cwd` and fills the input before applying the `default`. The most important case: `namespace` and `tests_namespace` on every `wp/*` scaffold declare `discover_from: composer.json:autoload.psr-4`, so they are **auto-filled from the project's first PSR-4 root** whenever a `composer.json` exists, with the kind sub-namespace preserved: the discovered root replaces only the *first segment* of the manifest default, so a default of `Inc\Cli` and a PSR-4 root of `Acme\Blog` yield `Acme\Blog\Cli` (and `Inc\Tests\Cli` yields `Acme\Blog\Tests\Cli`). A skill that previously always passed `--namespace` no longer has to, though doing so is still safe (see precedence below).
+- **`code:*` → still the skill's job.** The engine cannot read a bootstrap class or sample existing patterns. For these the engine returns the manifest `default`; the skill must do the introspection in this section and pass the value in.
Rules the skill can rely on:
@@ -265,6 +265,20 @@ Fall through these in order:
3. **No anchor and no clear pattern neighbourhood.** The bootstrap method exists (from §6) but there are no prior examples of this scaffold type. Insert at a sensible position in the bootstrap method (typically just before the closing brace), and explicitly tell the developer this is a best-effort placement.
4. **No bootstrap method either.** Skip wiring, surface the snippet as a manual instruction in the report, let the developer place it.
+Levels 2 and 3 place the snippet relative to code that may not survive. rtCamp
+skeletons wrap each optional capability in `// wp:example:` … `//
+wp:example::end` markers, and `wp-tooling init` deletes a marked region
+**including its body** when the developer drops that capability
+(`src/init/examples.js`). The last sampled occurrence of a pattern is very often
+the last line inside such a region, so appending after it puts the newly wired
+class inside a block that later disappears — silently, and long after the run
+that placed it.
+
+**Never place the snippet between a `// wp:example:` opener and its
+`:end`.** Insert after the last occurrence that sits outside every region
+instead, and say which line you chose and why. This holds at level 1 too: an
+anchor can itself sit inside a region.
+
At every level the skill **shows the developer the snippet, the target file, the chosen line range, and the reason for the choice** before applying. The developer can apply, redirect to a different location, edit the snippet, or skip.
### Anchor restoration (optional, never automatic)
diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md
index 698384e..04b48fa 100644
--- a/node-packages/wp-tooling/docs/authoring-scaffolds.md
+++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md
@@ -90,7 +90,7 @@ class {{class}} { }
```mustache
{{#with_logging}}use {{namespace}}\Services\Logger;
-{{/with_logging}}use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+{{/with_logging}}use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
```
`{{#key}}...{{/key}}` renders the inner block when `vars[key]` is truthy. Truthy means a non-empty string that is not one of `"false"`, `"no"`, `"0"` (case-insensitive).
@@ -139,8 +139,14 @@ 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** 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.
+- `composer.json:autoload-dev.psr-4` — the same graft against the **dev** autoload map, which is where a project declares its test tree. Use it for `tests_path`: a dev map of `Acme\Blog\Tests\` → `tests/php/` turns a default of `tests/Cli` into `tests/php/Cli`, so the generated test lands where the project's PHPUnit testsuite actually looks. Without it a test is written to the manifest default and is silently never collected.
+- `plugin-header:` — a value from the project's WordPress entry header, named lowercased with spaces hyphenated: `plugin-header:text-domain` reads `Text Domain:`. The engine looks for a root-level `*.php` carrying `Plugin Name:` and falls back to a theme's `style.css` carrying `Theme Name:`, so this resolves for plugins and themes alike. Only the header comment is scanned, so a `Foo: bar` in the code below is never mistaken for a header.
- `config:` — a string value from the project's `.wp-tooling.json` (e.g. `config:textDomain`).
+`validate` rejects a `discover_from` whose prefix is not one of the above. An unrecognised
+source used to resolve to nothing and fall through to the `default` in silence, which is how
+seven scaffolds shipped for months declaring a text-domain lookup that never ran.
+
Example — auto-fill the namespace from the consuming project's composer.json, falling back to a sensible default:
```json
@@ -163,6 +169,9 @@ Available via `transform`:
- `snake-case`: `qm-export` → `qm_export`
- `upper-snake-case`: `wporg-username` → `WPORG_USERNAME`
- `json-escape`: `Acme\Blog` → `Acme\\Blog` (embed a PHP namespace in a JSON snippet)
+- `php-escape`: escapes backslashes and apostrophes for a PHP string enclosed in single quotes.
+- `js-string`: emits a complete JavaScript string literal, escaping control characters and choosing the quote style used by the formatter. Templates do not add surrounding quotes.
+- `php-project-root`: derives a PHP expression from a project-relative directory (`inc/Blocks` → `dirname( __DIR__, 2 )`, `.` → `__DIR__`). Block and script module scaffolds use it to find their project root with custom source layouts.
- `shell-escape`: `my plugin` → `'my plugin'` (POSIX-quote one shell argument; safe values like `wp-content/plugins/x` stay bare)
Transforms are applied after the value is resolved. Add new transforms in `src/scaffolds/render.js` (`TRANSFORMS` map).
@@ -207,8 +216,8 @@ The engine never edits existing files. When a scaffold needs to register itself
Each entry:
-- `target_file`: path to the file. Placeholders allowed.
-- `anchor`: grep-able string that hints at insertion point (e.g. `// scaffold:cli-commands`). Anchors are useful but not load-bearing; the AI falls back to pattern sampling.
+- `target_file`: path to the file, relative to the project root. Placeholders allowed. The engine normalises the rendered path, so `{{base_path}}/../Modules/Cli.php` reaches the AI as `includes/Modules/Cli.php` — write the `..` form rather than duplicating a path input. An entry whose path still resolves outside the project after normalising is dropped with a warning.
+- `anchor`: grep-able string that hints at insertion point (e.g. `// scaffold:cli-commands`). Anchors are useful but not load-bearing; the AI falls back to pattern sampling. Either way it refuses to insert inside a `// wp:example:` … `:end` region, since `wp-tooling init` deletes those body and all.
- `snippet_template`: the snippet to insert. Mustache placeholders are rendered before the snippet is emitted.
- `description`: explains intent. The AI uses this when asking the developer for consent.
@@ -223,7 +232,7 @@ declares `files: []`, the `composer_dependencies` entry that provides the class,
snippet that constructs it. `execute()` reports `scaffold.kind: "package"` and writes nothing.
- `module_class`: the fully qualified PHP class the scaffold wires up (e.g.
- `rtCamp\\WPFramework\\Utils\\Cache` — doubled backslashes, since this is JSON). Manifest metadata
+ `rtCamp\\WPPrimitives\\Utils\\Cache` — doubled backslashes, since this is JSON). Manifest metadata
documenting which vendor class the `wiring[]` snippet constructs. Optional, but set it on every
package scaffold.
@@ -422,6 +431,6 @@ Copy the closest match, rename, adjust. Most scaffolds are 20-50 lines of JSON p
---
-## When to involve `wp-framework`
+## When to involve `wp-primitives`
-If your new scaffold extends or uses a class from `rtcamp/wp-framework` that does not yet exist, add the contract to [docs/wp-framework-contract.md](wp-framework-contract.md) and open an issue on `wp-framework`. Do not ship a scaffold that references an unreleased class. It will break for consumers running `composer install`.
+If your new scaffold extends or uses a class from `rtcamp/wp-primitives` that does not yet exist, add the contract to [docs/wp-primitives-contract.md](wp-primitives-contract.md) and open an issue on `rtCamp/wp-primitives`. Do not ship a scaffold that references an unreleased class. It will break for consumers running `composer install`.
diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md
index f6c39ac..f75969d 100644
--- a/node-packages/wp-tooling/docs/examples.md
+++ b/node-packages/wp-tooling/docs/examples.md
@@ -30,7 +30,7 @@ Each example shows: what the developer typed, the AI's clarifying questions and
Phase A — Project setup:
1. setup/editorconfig → .editorconfig
2. setup/psr4 → composer.json wiring (Acme\InventoryManager → includes/)
- 3. lint/phpcs/full → phpcs.xml.dist (rtCamp full standard)
+ 3. lint/phpcs/full → phpcs.xml.dist (rtCampWP) + composer.json wiring
4. lint/phpstan → phpstan.neon.dist (level 5)
5. lint/eslint → eslint.config.js
6. setup/phpunit → phpunit.xml.dist, tests/bootstrap.php
@@ -70,6 +70,12 @@ npx wp-tooling add wp/cli --non-interactive --json --cwd . --name=im
**Developer:** yes.
+> For `lint/phpcs/full`, `rtcamp/wp-phpcs` is not on Packagist, so I would add its GitHub repository to `repositories` and allow `dealerdirect/phpcodesniffer-composer-installer` under `config.allow-plugins` in `composer.json`.
+>
+> Apply? [yes / skip]
+
+**Developer:** yes.
+
(AI applies wirings for `Plugin.php` for the CPT, taxonomy, CLI registrations one at a time.)
**Final report:**
@@ -89,8 +95,8 @@ Developer actions:
composer dump-autoload --optimize
- composer require rtcamp/wp-framework:^1.0
- composer require --dev squizlabs/php_codesniffer:^3.7 wp-coding-standards/wpcs:^3.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6
+ composer require rtcamp/wp-primitives:^2.0
+ composer require --dev rtcamp/wp-phpcs:^1.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6
npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^0.1.0
Scripts to add to composer.json:
@@ -353,7 +359,7 @@ Tests:
> 2. wp/taxonomy → includes/Taxonomies/Category.php
> 3. wp/rest → includes/Rest/ProductController.php
>
-> All three pull `rtcamp/wp-framework:^1.0`. Each class extends the matching abstract from the framework and is wired through the consumer's module.
+> All three pull `rtcamp/wp-primitives:^2.0`. Each class extends the matching abstract from the framework and is wired through the consumer's module.
>
> Confirm?
@@ -390,7 +396,7 @@ Files written: 6 (3 production + 3 tests).
Wiring applied: 3 entries in includes/Plugin.php.
Developer actions:
- composer require rtcamp/wp-framework:^1.0
+ composer require rtcamp/wp-primitives:^2.0
composer dump-autoload --optimize
```
@@ -465,7 +471,7 @@ Files written (10), wiring applied (1 in Plugin.php for the CLI command).
Outstanding developer actions:
composer dump-autoload --optimize
- composer require --dev automattic/vip-coding-standards:^3.0 wp-coding-standards/wpcs:^3.0 squizlabs/php_codesniffer:^3.7 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6
+ composer require --dev automattic/vipwpcs:^3.0 wp-coding-standards/wpcs:^3.0 squizlabs/php_codesniffer:^3.7 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6
```
---
diff --git a/node-packages/wp-tooling/docs/wp-framework-contract.md b/node-packages/wp-tooling/docs/wp-primitives-contract.md
similarity index 89%
rename from node-packages/wp-tooling/docs/wp-framework-contract.md
rename to node-packages/wp-tooling/docs/wp-primitives-contract.md
index 00a5e69..37bf5c0 100644
--- a/node-packages/wp-tooling/docs/wp-framework-contract.md
+++ b/node-packages/wp-tooling/docs/wp-primitives-contract.md
@@ -1,10 +1,10 @@
-# wp-framework contract
+# wp-primitives contract
-The scaffolds under `scaffolds/wp/` and `scaffolds/block/` in `@rtcamp/wp-tooling` generate PHP code that extends abstract base classes shipped by `rtcamp/wp-framework`. This document specifies the class surface those scaffolds expect.
+The scaffolds under `scaffolds/wp/` and `scaffolds/block/` in `@rtcamp/wp-tooling` generate PHP code that extends abstract base classes shipped by `rtcamp/wp-primitives`. This document specifies the class surface those scaffolds expect.
-If you are working in `wp-framework`, treat this as the requirements list. If you are authoring a new `wp/...` or `block/...` scaffold here, treat it as the available API.
+If you are working in `wp-primitives`, treat this as the requirements list. If you are authoring a new `wp/...` or `block/...` scaffold here, treat it as the available API.
-The framework was previously published as `rtcamp/wp-php-toolkit` and renamed before its 1.0 release. References to the old name should be considered historical.
+The package shipped as `rtcamp/wp-framework` for 1.0.x and was renamed to `rtcamp/wp-primitives` in 2.0.0.
---
@@ -12,11 +12,11 @@ The framework was previously published as `rtcamp/wp-php-toolkit` and renamed be
```
"require": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
```
-Namespace root: `rtCamp\WPFramework`.
+Namespace root: `rtCamp\WPPrimitives`.
---
@@ -34,7 +34,7 @@ The `Loader` also recognises `ConditionallyRegistrable` (skip when `can_register
## Abstract classes the scaffolds target
-All paths below sit under `rtCamp\WPFramework\Contracts\Abstracts\`.
+All paths below sit under `rtCamp\WPPrimitives\Contracts\Abstracts\`.
### `AbstractPostType` (extended by `scaffolds/wp/cpt`)
@@ -148,7 +148,7 @@ Wiring: `register_hooks()` attaches `register_routes()` to `rest_api_init`.
### `AbstractModule` (extended by `scaffolds/wp/module`)
-Required: `public get_classes(): array` returning a list of class strings.
+Required: `protected get_classes(): array` returning a list of class strings. The base declares it `abstract protected`; scaffolded modules match that visibility, so tests reach it through reflection.
Wiring: the framework's `Loader` instantiates each listed class and calls `register_hooks()` if it implements `Registrable`.
@@ -156,7 +156,7 @@ Wiring: the framework's `Loader` instantiates each listed class and calls `regis
## Interfaces
-All paths below sit under `rtCamp\WPFramework\Contracts\Interfaces\`.
+All paths below sit under `rtCamp\WPPrimitives\Contracts\Interfaces\`.
### `Registrable` (implemented by `scaffolds/wp/cron`, `scaffolds/wp/registrable`)
@@ -192,7 +192,7 @@ interface CLICommand {
## Utility classes (targeted by `scaffolds/utility/*`)
-All under `rtCamp\WPFramework\Utils\`. These are **plain classes with public constructors** — not
+All under `rtCamp\WPPrimitives\Utils\`. These are **plain classes with public constructors** — not
`Registrable`, not `Shareable`, not singletons, and none needs the `Container`. That is why the
`utility/*` scaffolds wire them through an accessor on the consumer's static helper
(`/Helpers/Util.php`) rather than adding them to an `AbstractModule` or `Main::CLASSES`:
@@ -226,7 +226,7 @@ instance (laps; the flag registry), so they must be shared rather than construct
## Anchor convention
-Every scaffold that produces a class registers via the consumer's `AbstractModule` subclass. The module file contains a marker comment inside `get_classes()`; the scaffold's `wiring` block inserts the new class string immediately above the marker.
+Every scaffold that produces a class registers via the consumer's `AbstractModule` subclass. The module file contains a marker comment inside `get_classes()`; the scaffold's `wiring` block inserts the new class string immediately after the marker.
Marker pattern: `// scaffold::classes`. Examples:
diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json
index 7f7fda7..f914b8d 100644
--- a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json
@@ -1,18 +1,63 @@
{
"slug": "core",
"category": "lint/phpcs",
- "name": "PHPCS - WordPress",
- "description": "PHP_CodeSniffer with the WordPress combined standard (Core + Extra + Docs) from wp-coding-standards/wpcs.",
+ "name": "PHPCS - rtCampWP-Basic",
+ "description": "PHP_CodeSniffer with rtCampWP-Basic from rtcamp/wp-phpcs: WordPress-Extra, Docs, VIP-Go and PHP compatibility, without the strict-typing layer of rtCampWP. For adopting standards on existing code.",
"source": "template",
+ "inputs": [
+ {
+ "key": "text_domain",
+ "description": "Text domain every translatable string must use. Read from the plugin or theme header.",
+ "discover_from": "plugin-header:text-domain",
+ "default": "my-plugin"
+ },
+ {
+ "key": "prefix",
+ "description": "Prefix required on global functions, hooks, constants and variables. Derived from text_domain.",
+ "discover_from": "input:text_domain",
+ "transform": "snake-case"
+ },
+ {
+ "key": "namespace",
+ "description": "Root PHP namespace, which PHPCS also accepts as a prefix. Read from composer.json PSR-4 autoload; left out when the project has none.",
+ "discover_from": "composer.json:autoload.psr-4",
+ "default": ""
+ },
+ {
+ "key": "php_version",
+ "description": "Lowest PHP version the project supports, e.g. 8.2. Read from the Requires PHP header.",
+ "discover_from": "plugin-header:requires-php",
+ "default": "8.2"
+ },
+ {
+ "key": "wp_version",
+ "description": "Lowest WordPress version the project supports, e.g. 6.5. Read from the Requires at least header.",
+ "discover_from": "plugin-header:requires-at-least",
+ "default": "6.5"
+ }
+ ],
"files": [
{
"src": "templates/phpcs.xml.dist.mustache",
"dest": "phpcs.xml.dist"
}
],
+ "wiring": [
+ {
+ "target_file": "composer.json",
+ "anchor": "\"repositories\"",
+ "snippet_template": "\"repositories\": [\n {\n \"type\": \"vcs\",\n \"url\": \"https://github.com/rtCamp/wp-phpcs.git\",\n \"no-api\": true\n }\n]",
+ "description": "rtcamp/wp-phpcs is not on Packagist, so Composer only finds it through this repository. Add the entry to the existing repositories array unless the URL is already there, or create the array. Apply before installing the package."
+ },
+ {
+ "target_file": "composer.json",
+ "anchor": "\"allow-plugins\"",
+ "snippet_template": "\"config\": {\n \"allow-plugins\": {\n \"dealerdirect/phpcodesniffer-composer-installer\": true\n }\n}",
+ "description": "rtcamp/wp-phpcs registers its standards through this Composer plugin, which Composer will not run until it is allowed. Merge the entry into the existing config.allow-plugins, or create it."
+ }
+ ],
"composer_dev_dependencies": {
- "squizlabs/php_codesniffer": "^3.7",
- "wp-coding-standards/wpcs": "^3.0"
+ "rtcamp/wp-phpcs": "^1.0"
},
"scripts": {
"composer": {
diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache
index 0e736bc..85f1a9a 100644
--- a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache
+++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache
@@ -1,19 +1,88 @@
-
- WordPress combined standard: Core, Extra, and Docs rules from wp-coding-standards/wpcs.
+
+ Extends rtCampWP-Basic, rtCamp's WordPress standard without strict typing.
+
-
+
+
+
- ./
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{#namespace}}
+{{/namespace}}
+
+ tests/*
+
+
+
+
+
+
+
+
+
+
+ src/blocks/*/render.php
+
+
+ src/blocks/*/render.php
+
+
+
+
+ tests/*
+
+
+ tests/*
+
+
+ tests/*
+
+
+ tests/*
+
+
+
+
+
+
+
+ .*/vendor/**/node_modules/*
+
+ */.claude/**/build/*
- */tests/*
diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json
index 9e80508..80ade4d 100644
--- a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json
@@ -1,19 +1,63 @@
{
"slug": "full",
"category": "lint/phpcs",
- "name": "PHPCS - rtCamp full",
- "description": "PHP_CodeSniffer with rtCamp's shared standard: WordPress-Core, Extra, Docs, and VIP-Go rules via wp-framework.",
+ "name": "PHPCS - rtCampWP",
+ "description": "PHP_CodeSniffer with rtCampWP, rtCamp's strict standard from rtcamp/wp-phpcs: WordPress-Extra, Docs, VIP-Go and PHP compatibility, plus strict types, type hints and short arrays. The default for new code.",
"source": "template",
+ "inputs": [
+ {
+ "key": "text_domain",
+ "description": "Text domain every translatable string must use. Read from the plugin or theme header.",
+ "discover_from": "plugin-header:text-domain",
+ "default": "my-plugin"
+ },
+ {
+ "key": "prefix",
+ "description": "Prefix required on global functions, hooks, constants and variables. Derived from text_domain.",
+ "discover_from": "input:text_domain",
+ "transform": "snake-case"
+ },
+ {
+ "key": "namespace",
+ "description": "Root PHP namespace, which PHPCS also accepts as a prefix. Read from composer.json PSR-4 autoload; left out when the project has none.",
+ "discover_from": "composer.json:autoload.psr-4",
+ "default": ""
+ },
+ {
+ "key": "php_version",
+ "description": "Lowest PHP version the project supports, e.g. 8.2. Read from the Requires PHP header.",
+ "discover_from": "plugin-header:requires-php",
+ "default": "8.2"
+ },
+ {
+ "key": "wp_version",
+ "description": "Lowest WordPress version the project supports, e.g. 6.5. Read from the Requires at least header.",
+ "discover_from": "plugin-header:requires-at-least",
+ "default": "6.5"
+ }
+ ],
"files": [
{
"src": "templates/phpcs.xml.dist.mustache",
"dest": "phpcs.xml.dist"
}
],
+ "wiring": [
+ {
+ "target_file": "composer.json",
+ "anchor": "\"repositories\"",
+ "snippet_template": "\"repositories\": [\n {\n \"type\": \"vcs\",\n \"url\": \"https://github.com/rtCamp/wp-phpcs.git\",\n \"no-api\": true\n }\n]",
+ "description": "rtcamp/wp-phpcs is not on Packagist, so Composer only finds it through this repository. Add the entry to the existing repositories array unless the URL is already there, or create the array. Apply before installing the package."
+ },
+ {
+ "target_file": "composer.json",
+ "anchor": "\"allow-plugins\"",
+ "snippet_template": "\"config\": {\n \"allow-plugins\": {\n \"dealerdirect/phpcodesniffer-composer-installer\": true\n }\n}",
+ "description": "rtcamp/wp-phpcs registers its standards through this Composer plugin, which Composer will not run until it is allowed. Merge the entry into the existing config.allow-plugins, or create it."
+ }
+ ],
"composer_dev_dependencies": {
- "squizlabs/php_codesniffer": "^3.7",
- "wp-coding-standards/wpcs": "^3.0",
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-phpcs": "^1.0"
},
"scripts": {
"composer": {
diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache
index 45dd020..944a98d 100644
--- a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache
+++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache
@@ -1,32 +1,89 @@
-
- Extends rtCamp shared standard: WordPress-Core, Extra, Docs, and VIP-Go rules.
+
+ Extends rtCampWP, rtCamp's strict WordPress standard.
+
-
+
+
+
- ./
- */vendor/*
- */node_modules/*
- */build/*
- */tests/*
+
+
+
+
+
+
+
-
+
+
+
+
+
+{{#namespace}}
+{{/namespace}}
+
+ tests/*
+
+
+
+
+
+
+
-
-
-
-
+
+ src/blocks/*/render.php
+
+
+ src/blocks/*/render.php
+
+
+
+ tests/*
+
+
+ tests/*
+
+
+ tests/*
+
+
+ tests/*
+
+
+
+
+
+
+
+ .
+ */vendor/*
+ */node_modules/*
+
+ */.claude/*
+ */build/*
+
+
diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json
index fc09690..a7f2830 100644
--- a/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json
@@ -13,8 +13,16 @@
"composer_dev_dependencies": {
"squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0",
- "automattic/vip-coding-standards": "^3.0"
+ "automattic/vipwpcs": "^3.0"
},
+ "wiring": [
+ {
+ "target_file": "composer.json",
+ "anchor": "\"allow-plugins\"",
+ "snippet_template": "\"config\": {\n \"allow-plugins\": {\n \"dealerdirect/phpcodesniffer-composer-installer\": true\n }\n}",
+ "description": "WPCS and VIPCS depend on this Composer plugin to register their standards, and Composer will not install it until it is allowed. Merge the entry into the existing config.allow-plugins, or create it. Apply before installing the packages."
+ }
+ ],
"scripts": {
"composer": {
"lint:php": "phpcs --standard=phpcs.xml.dist",
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 876b9f6..4aed337 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
@@ -55,7 +55,7 @@ Read, in order:
- Block scaffolds: sample one `block.json` for vendor prefix and source dir.
- CI scaffolds: sample one `.github/workflows/*.yml` for filename and trigger style.
-Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) are hints, not ground truth. Sampled patterns win.
+Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) settle *where* a snippet goes, not *how* it is written - sampled patterns win on snippet shape. Note any `// wp:example:` ... `:end` regions in the files you sample; §6a will not insert inside one.
Confirm findings with the developer in one short message. Proceed on confirmation.
@@ -147,7 +147,7 @@ Result shape: `{ scaffold, engine, developer, ai, warnings }`.
For each `{ targetFile, anchor, snippet, description }`:
1. **Snippet** - use canonical if it matches the sampled project pattern; else translate using the sampled shape with the new class substituted. Show both. If patterns conflict or no samples exist, ask.
-2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction.
+2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction. Never land between a `// wp:example:` opener and its `:end`, anchors included: `wp-tooling init` deletes those regions body and all when the capability is dropped, taking the wired class with them. Fall back to the last occurrence outside every region and say so.
3. **Consent** - show targetFile + line range + description + rendered snippet. Ask `[apply / different location / edit snippet / skip]`. Never apply without consent.
4. **Idempotent** - search first, do not re-insert.
@@ -211,6 +211,7 @@ Escalation report format: **what you tried, what you observed, what's blocking,
- Never edit branch protection, repo settings, webhooks, or any GitHub admin surface.
- Never commit, push, open PRs, or comment on issues without explicit consent.
- Never apply wiring without showing the diff and getting consent.
+- Never place a wiring snippet inside a `// wp:example:` ... `:end` region.
- Never invent a third registration pattern when canonical and sampled disagree - ask.
- Never restore scaffold anchor comments without explicit consent.
- Never modify `composer.json`, `package.json`, or any lockfile beyond what the engine wrote.
diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json
index f3d6cf4..4a89d5d 100644
--- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json
+++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json
@@ -91,6 +91,22 @@
"The wiring snippet targets the Services module on the // scaffold:wp/registrable:classes anchor.",
"The skill does NOT run composer install, composer require or npm install."
]
+ },
+ {
+ "id": 7,
+ "prompt": "Add a service that warms the homepage cache on publish. Register it the way the other services are registered.",
+ "expected_output": "wp/registrable scaffolds the class and its PHPUnit test under the project's Services namespace. The wiring lands in Main::CLASSES, but NOT between a // wp:example: marker and its :end -- those regions are deleted body and all when the capability is dropped, which would silently take the new class with them. The skill inserts after the last entry that sits outside every region and says which line it chose and why.",
+ "files": [],
+ "expectations": [
+ "The skill picks the wp/registrable scaffold rather than hand-writing a Registrable class.",
+ "The wiring snippet is NOT inserted between a // wp:example: line and its matching // wp:example::end line, even though the last existing entry in the array sits inside one.",
+ "The chosen insertion point is after the last entry that is outside every wp:example region -- for Main::CLASSES that is the last line of the always-loaded core block.",
+ "The skill explains the placement, naming the example-region hazard rather than silently picking a different line than the developer would expect.",
+ "Consent is requested before the wiring edit is applied, showing the target file, the line, and the snippet.",
+ "The class lands under the project's Services namespace and base path, read from the PSR-4 roots.",
+ "The generated test is expanded rather than left as the stub, and no markTestIncomplete or markTestSkipped survives.",
+ "The skill does NOT run composer install, composer require or npm install."
+ ]
}
]
}
diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md
index 28f7453..a230cc0 100644
--- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md
+++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md
@@ -61,8 +61,9 @@ grep -rl "Plugin Name:\|Theme Name:" . --include="*.php" --exclude-dir=vendor --
**VIP indicators:**
```bash
-grep -rl "WordPress-VIP-Minimum\|automattic/vip-coding-standards\|VIP_GO_ENV" . \
- --include="*.{php,xml,json,yml}" --exclude-dir=vendor --exclude-dir=node_modules | head -3
+grep -rlE "WordPressVIPMinimum|WordPress-VIP-Go|automattic/vipwpcs|VIP_GO_APP_ENVIRONMENT|VIP_GO_ENV" . \
+ --include="*.php" --include="*.xml" --include="*.dist" --include="*.json" --include="*.yml" \
+ --exclude-dir=vendor --exclude-dir=node_modules | head -3
```
**Languages present:**
@@ -106,7 +107,7 @@ Construct the plan in two phases: **project setup** and **feature scaffolds**.
| PHP present, no PSR-4 in `composer.json` | `setup/psr4` | `autoload.psr-4` already set |
| VIP project | `lint/phpcs/vip` | `phpcs.xml.dist` exists |
| Non-VIP project | `lint/phpcs/full` | `phpcs.xml.dist` exists |
-| Developer explicitly chose core-only PHPCS | `lint/phpcs/core` | `phpcs.xml.dist` exists |
+| Non-VIP project whose existing PHP does not declare strict types | `lint/phpcs/core` | `phpcs.xml.dist` exists |
| PHP present | `lint/phpstan` | `phpstan.neon.dist` exists |
| JS present | `lint/eslint` | `eslint.config.js` exists |
| CSS or SCSS present | `lint/stylelint` | `.stylelintrc.js` exists |
@@ -132,7 +133,7 @@ Map each feature the developer mentioned to one or more scaffold IDs from the ca
| CI pipeline | `ci/cd-wporg` (or other CI scaffold) |
The `utility/*` scaffolds are `source: package`: they write no files and return the
-`rtcamp/wp-framework` dependency plus one accessor snippet for `/Helpers/Util.php`.
+`rtcamp/wp-primitives` dependency plus one accessor snippet for `/Helpers/Util.php`.
Check whether the project already exposes that accessor before applying the wiring — pasting in a
method name that already exists in `Helpers\Util` is a PHP fatal error (cannot redeclare method).
@@ -150,7 +151,7 @@ Here is what I will do. Please confirm or adjust before I start.
Phase A — Project setup:
1. setup/editorconfig → .editorconfig
2. setup/psr4 → wiring in composer.json (namespace: Acme\ImageOptimizer, path: includes/)
- 3. lint/phpcs/vip → phpcs.xml.dist (WordPress-VIP-Minimum + WordPress-Docs)
+ 3. lint/phpcs/vip → phpcs.xml.dist (WordPressVIPMinimum + WordPress-Docs)
4. lint/phpstan → phpstan.neon.dist (extends rtCamp wp-phpstan baseline)
5. lint/eslint → eslint.config.js
6. setup/phpunit → phpunit.xml.dist, tests/bootstrap.php
@@ -200,7 +201,7 @@ npx wp-tooling add setup/pa11y --non-interactive --json --cwd . --base-url=h
Process each result before running the next:
- Report files written and skipped.
-- Apply `setup/psr4` wiring to `composer.json` with explicit consent (see §Wiring below).
+- Apply `setup/psr4` and `lint/phpcs/*` wiring to `composer.json` with explicit consent (see §Wiring below).
- Accumulate `developer.install.*` and `developer.scripts.*` across all scaffolds.
### 5. Execute Phase B
@@ -262,6 +263,15 @@ composer.json not found. I can create a minimal one:
Create it? [yes / skip PSR-4 / give me the values to use]
```
+### Wiring: composer.json for PHPCS
+
+The `lint/phpcs/*` wiring entries must be in `composer.json` before the developer runs `composer require`, or the install fails:
+
+1. `repositories` (`full` and `core` only) — a `vcs` entry for `https://github.com/rtCamp/wp-phpcs.git`, since `rtcamp/wp-phpcs` is not on Packagist. Append it to an existing array; skip it if that URL is already listed. `vip` needs none: `automattic/vipwpcs` is on Packagist.
+2. `config.allow-plugins` (all three) — `dealerdirect/phpcodesniffer-composer-installer: true`, the plugin that registers the standards. Merge it into an existing `config` block.
+
+Show the changes together, ask once, and merge rather than replace existing keys.
+
### Wiring: feature scaffolds
Same as `skills/scaffold.md` §5 — `ai.wiring`. Show diff, get consent, apply.
@@ -295,7 +305,7 @@ Developer actions (run these yourself):
composer dump-autoload --optimize
composer require --dev \
- automattic/vip-coding-standards:^3.0 \
+ automattic/vipwpcs:^3.0 \
wp-coding-standards/wpcs:^3.0 \
squizlabs/php_codesniffer:^3.7 \
phpunit/phpunit:^12.0 \
@@ -335,11 +345,11 @@ Deduplicate packages. Sort alphabetically within each block. Pinned packages use
| Scaffold ID | When to use | Ruleset |
|---|---|---|
-| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `vendor/rtcamp/wp-framework/phpcs.xml.dist`, WordPress-Core + Extra + Docs + VIP-Go |
-| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPress-VIP-Minimum` + `WordPress-Docs` |
-| `lint/phpcs/core` | Projects explicitly opting out of VIP-Go rules | `WordPress` — Core + Extra + Docs only |
+| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `rtCampWP` from `rtcamp/wp-phpcs`: WordPress-Extra + Docs + VIP-Go + PHPCompatibilityWP, plus strict types, type hints and short arrays |
+| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPressVIPMinimum` + `WordPress-Docs` |
+| `lint/phpcs/core` | Existing code not yet ready for strict types | `rtCampWP-Basic` from `rtcamp/wp-phpcs`: the same rules without the strict layer |
-Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard.
+`full` and `core` read `testVersion` and `minimum_wp_version` from the `Requires PHP` and `Requires at least` headers, the text domain from `Text Domain`, and the namespace prefix from `composer.json` PSR-4 autoload. Cite the values in the plan. Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard.
## Test scaffolds reference
diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php
index 21d12c7..b8ede5d 100644
--- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php
+++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php
@@ -1,4 +1,4 @@
-] []' . PHP_EOL );
}
@@ -38,7 +44,7 @@
: ( function_exists( 'tideways_xhprof_enable' ) ? 'tideways' : 'none' );
if ( ! class_exists( \rtCamp\WPDevTools\Support\XHProfProfiler::class ) ) {
- echo wp_json_encode( array() ) . PHP_EOL;
+ echo wp_json_encode( [] ) . PHP_EOL;
fwrite(
STDERR,
sprintf(
diff --git a/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json
index feee7b1..7d1f8fa 100644
--- a/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json
@@ -19,6 +19,12 @@
"key": "source_dir",
"description": "Directory containing the PHP source to include in coverage (relative to project root, no trailing slash).",
"default": "includes"
+ },
+ {
+ "key": "tests_namespace",
+ "description": "PSR-4 namespace of the test suite, used as the bootstrap's @package. Default 'Inc\\Tests'.",
+ "discover_from": "composer.json:autoload.psr-4",
+ "default": "Inc\\Tests"
}
],
"composer_dev_dependencies": {
diff --git a/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache b/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache
index 1c5d38d..aaea32e 100644
--- a/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache
@@ -2,8 +2,12 @@
/**
* PHPUnit bootstrap: loads Composer autoloader and optionally the
* WordPress test suite when WP_TESTS_DIR is set.
+ *
+ * @package {{tests_namespace}}
*/
+declare( strict_types = 1 );
+
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
// Integration tests: load the WordPress test library.
@@ -14,8 +18,7 @@ if ( $wp_tests_dir ) {
tests_add_filter(
'muplugins_loaded',
static function () {
- // Load the plugin under test here.
- // require dirname( __DIR__ ) . '/plugin-name.php';
+ // Require the plugin under test here, e.g. dirname( __DIR__ ) . '/plugin-name.php'.
}
);
diff --git a/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json
index b035be8..356f814 100644
--- a/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json
@@ -2,9 +2,9 @@
"slug": "cache",
"category": "utility",
"name": "Cache utility",
- "description": "Wires up the framework Cache utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Cache wraps the WordPress object cache with a per-plugin group namespace, group flushing, and the remember() / remember_swr() compute-once helpers.",
+ "description": "Wires up the framework Cache utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Cache wraps the WordPress object cache with a per-plugin group namespace, group flushing, and the remember() / remember_swr() compute-once helpers.",
"source": "package",
- "module_class": "rtCamp\\WPFramework\\Utils\\Cache",
+ "module_class": "rtCamp\\WPPrimitives\\Utils\\Cache",
"files": [],
"inputs": [
{
@@ -24,11 +24,11 @@
{
"target_file": "{{base_path}}/Helpers/Util.php",
"anchor": "// scaffold:utility/cache",
- "snippet_template": "/**\n * The project's shared framework Cache, namespaced so its groups can never\n * collide with another consumer's.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Cache Shared cache.\n */\npublic static function cache(): \\rtCamp\\WPFramework\\Utils\\Cache {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Cache( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
- "description": "Add a shared accessor for the framework Cache to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::cache()->remember( ... ) works with nothing further to wire. Do not add the framework Cache to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context. If the project instead shares services through the framework Shareable contract (a Core\\Cache class extending the framework Cache, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern and add a one-line accessor there. If only one class needs the cache, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-framework to be installed first."
+ "snippet_template": "/**\n * The project's shared framework Cache, namespaced so its groups can never\n * collide with another consumer's.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Cache Shared cache.\n */\npublic static function cache(): \\rtCamp\\WPPrimitives\\Utils\\Cache {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Cache( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
+ "description": "Add a shared accessor for the framework Cache to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::cache()->remember( ... ) works with nothing further to wire. Do not add the framework Cache to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context. If the project instead shares services through the framework Shareable contract (a Core\\Cache class extending the framework Cache, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern and add a one-line accessor there. If only one class needs the cache, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-primitives to be installed first."
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json
index 8daf982..db5a914 100644
--- a/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json
@@ -2,9 +2,9 @@
"slug": "feature-selector",
"category": "utility",
"name": "Feature selector utility",
- "description": "Wires up the framework FeatureSelector utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. FeatureSelector registers named feature flags and resolves each one from a PHP constant or a shared option through register / is_enabled / enable / disable, failing closed for anything unregistered.",
+ "description": "Wires up the framework FeatureSelector utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. FeatureSelector registers named feature flags and resolves each one from a PHP constant or a shared option through register / is_enabled / enable / disable, failing closed for anything unregistered.",
"source": "package",
- "module_class": "rtCamp\\WPFramework\\Utils\\FeatureSelector",
+ "module_class": "rtCamp\\WPPrimitives\\Utils\\FeatureSelector",
"files": [],
"inputs": [
{
@@ -24,11 +24,11 @@
{
"target_file": "{{base_path}}/Helpers/Util.php",
"anchor": "// scaffold:utility/feature-selector",
- "snippet_template": "/**\n * The project's shared framework FeatureSelector. One instance holds the whole\n * flag registry, so every caller sees the same registered features.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\FeatureSelector Shared feature selector.\n */\npublic static function feature_selector(): \\rtCamp\\WPFramework\\Utils\\FeatureSelector {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\FeatureSelector( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
- "description": "Add a shared accessor for the framework FeatureSelector to the project's static helper class. The method is named feature_selector(); if that name is already taken in the helper class, rename it before pasting. The class is spelled FeatureSelector, not Feature_Selector. The snippet is self-contained: paste it inside the existing helper class body, register flags once during bootstrap with Util::feature_selector()->register( 'new-checkout' ), then read them with Util::feature_selector()->is_enabled( 'new-checkout' ). Sharing one instance is deliberate rather than incidental: the flag registry lives on the instance and unregistered flags fail closed, so a fresh instance per call would report every feature as disabled. Do not add the framework FeatureSelector to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context and derive the wrong option key. For an admin UI over these flags, subclass the framework FeatureSelectorSettingsPage and return this accessor from its get_selector(). Requires rtcamp/wp-framework to be installed first."
+ "snippet_template": "/**\n * The project's shared framework FeatureSelector. One instance holds the whole\n * flag registry, so every caller sees the same registered features.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\FeatureSelector Shared feature selector.\n */\npublic static function feature_selector(): \\rtCamp\\WPPrimitives\\Utils\\FeatureSelector {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\FeatureSelector( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
+ "description": "Add a shared accessor for the framework FeatureSelector to the project's static helper class. The method is named feature_selector(); if that name is already taken in the helper class, rename it before pasting. The class is spelled FeatureSelector, not Feature_Selector. The snippet is self-contained: paste it inside the existing helper class body, register flags once during bootstrap with Util::feature_selector()->register( 'new-checkout' ), then read them with Util::feature_selector()->is_enabled( 'new-checkout' ). Sharing one instance is deliberate rather than incidental: the flag registry lives on the instance and unregistered flags fail closed, so a fresh instance per call would report every feature as disabled. Do not add the framework FeatureSelector to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context and derive the wrong option key. For an admin UI over these flags, subclass the framework FeatureSelectorSettingsPage and return this accessor from its get_selector(). Requires rtcamp/wp-primitives to be installed first."
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json
index e3df50b..d6fc447 100644
--- a/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json
@@ -2,9 +2,9 @@
"slug": "logger",
"category": "utility",
"name": "Logger utility",
- "description": "Wires up the framework Logger utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Logger writes prefixed debug / info / warning / error lines and stays silent unless WP_DEBUG is on.",
+ "description": "Wires up the framework Logger utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Logger writes prefixed debug / info / warning / error lines and stays silent unless WP_DEBUG is on.",
"source": "package",
- "module_class": "rtCamp\\WPFramework\\Utils\\Logger",
+ "module_class": "rtCamp\\WPPrimitives\\Utils\\Logger",
"files": [],
"inputs": [
{
@@ -24,11 +24,11 @@
{
"target_file": "{{base_path}}/Helpers/Util.php",
"anchor": "// scaffold:utility/logger",
- "snippet_template": "/**\n * The project's shared framework Logger. Silent unless WP_DEBUG.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Logger Shared logger.\n */\npublic static function logger(): \\rtCamp\\WPFramework\\Utils\\Logger {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Logger( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
- "description": "Add a shared accessor for the framework Logger to the project's static helper class. Check for an existing logger accessor before applying, and skip this wiring if one is already there: the rtCamp skeleton ships a Core\\Logger class extending the framework Logger, registered in Main::CLASSES and reached through Util::logger(), so re-applying the snippet would redeclare the method and fatal. Otherwise the snippet is self-contained: paste it inside the existing helper class body and Util::logger()->info( 'Cache warmed', array( 'items' => 42 ) ) works with nothing further to wire. Do not add the framework Logger to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with the framework's own default prefix rather than this project's. Requires rtcamp/wp-framework to be installed first."
+ "snippet_template": "/**\n * The project's shared framework Logger. Silent unless WP_DEBUG.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Logger Shared logger.\n */\npublic static function logger(): \\rtCamp\\WPPrimitives\\Utils\\Logger {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Logger( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
+ "description": "Add a shared accessor for the framework Logger to the project's static helper class. Check for an existing logger accessor before applying, and skip this wiring if one is already there: the rtCamp skeleton ships a Core\\Logger class extending the framework Logger, registered in Main::CLASSES and reached through Util::logger(), so re-applying the snippet would redeclare the method and fatal. Otherwise the snippet is self-contained: paste it inside the existing helper class body and Util::logger()->info( 'Cache warmed', array( 'items' => 42 ) ) works with nothing further to wire. Do not add the framework Logger to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with the framework's own default prefix rather than this project's. Requires rtcamp/wp-primitives to be installed first."
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json
index 98a1c5a..d12e729 100644
--- a/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json
@@ -2,9 +2,9 @@
"slug": "timer",
"category": "utility",
"name": "Timer utility",
- "description": "Wires up the framework Timer utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Timer measures elapsed time with start / stop / lap and reports through get / get_all, warning via _doing_it_wrong() on misuse rather than throwing.",
+ "description": "Wires up the framework Timer utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Timer measures elapsed time with start / stop / lap and reports through get / get_all, warning via _doing_it_wrong() on misuse rather than throwing.",
"source": "package",
- "module_class": "rtCamp\\WPFramework\\Utils\\Timer",
+ "module_class": "rtCamp\\WPPrimitives\\Utils\\Timer",
"files": [],
"inputs": [
{
@@ -24,11 +24,11 @@
{
"target_file": "{{base_path}}/Helpers/Util.php",
"anchor": "// scaffold:utility/timer",
- "snippet_template": "/**\n * The project's shared framework Timer. One instance keeps every lap on a\n * single timeline, so callers in different classes can read each other's marks.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Timer Shared timer.\n */\npublic static function timer(): \\rtCamp\\WPFramework\\Utils\\Timer {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Timer();\n\t}\n\n\treturn $instance;\n}",
- "description": "Add a shared accessor for the framework Timer to the project's static helper class. The framework Timer takes no constructor arguments, so there is nothing to configure and the context slug is not used here. Sharing one instance is deliberate rather than incidental: labels and laps accumulate on the instance, so a fresh instance per call would lose every mark taken elsewhere. The snippet is self-contained: paste it inside the existing helper class body and Util::timer()->start( 'import' ) works with nothing further to wire. Do not add the framework Timer to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct an instance nothing can reach. If the project instead shares services through the framework Shareable contract (a Core class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. Requires rtcamp/wp-framework to be installed first."
+ "snippet_template": "/**\n * The project's shared framework Timer. One instance keeps every lap on a\n * single timeline, so callers in different classes can read each other's marks.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Timer Shared timer.\n */\npublic static function timer(): \\rtCamp\\WPPrimitives\\Utils\\Timer {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Timer();\n\t}\n\n\treturn $instance;\n}",
+ "description": "Add a shared accessor for the framework Timer to the project's static helper class. The framework Timer takes no constructor arguments, so there is nothing to configure and the context slug is not used here. Sharing one instance is deliberate rather than incidental: labels and laps accumulate on the instance, so a fresh instance per call would lose every mark taken elsewhere. The snippet is self-contained: paste it inside the existing helper class body and Util::timer()->start( 'import' ) works with nothing further to wire. Do not add the framework Timer to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct an instance nothing can reach. If the project instead shares services through the framework Shareable contract (a Core class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. Requires rtcamp/wp-primitives to be installed first."
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json
index 87c4eca..38a6560 100644
--- a/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json
@@ -2,9 +2,9 @@
"slug": "transients",
"category": "utility",
"name": "Transients utility",
- "description": "Wires up the framework Transients utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Transients wraps get_transient / set_transient / delete_transient behind a mandatory per-project key prefix.",
+ "description": "Wires up the framework Transients utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Transients wraps get_transient / set_transient / delete_transient behind a mandatory per-project key prefix.",
"source": "package",
- "module_class": "rtCamp\\WPFramework\\Utils\\Transients",
+ "module_class": "rtCamp\\WPPrimitives\\Utils\\Transients",
"files": [],
"inputs": [
{
@@ -24,11 +24,11 @@
{
"target_file": "{{base_path}}/Helpers/Util.php",
"anchor": "// scaffold:utility/transients",
- "snippet_template": "/**\n * The project's shared framework Transients store, prefixed so its keys can\n * never collide with another consumer's.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Transients Shared transients store.\n */\npublic static function transients(): \\rtCamp\\WPFramework\\Utils\\Transients {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Transients( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
- "description": "Add a shared accessor for the framework Transients store to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::transients()->get( 'key' ) works with nothing further to wire. The constructor prefix is mandatory and readonly — never pass an empty string. Do not add the framework Transients to a Modules class or to Main::CLASSES: it implements neither Registrable nor Shareable, so the framework Loader would try to construct it with no argument and fatal on the missing prefix. If the project instead shares services through the framework Shareable contract (a Core\\Transients class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. If only one class needs transients, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-framework to be installed first."
+ "snippet_template": "/**\n * The project's shared framework Transients store, prefixed so its keys can\n * never collide with another consumer's.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Transients Shared transients store.\n */\npublic static function transients(): \\rtCamp\\WPPrimitives\\Utils\\Transients {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Transients( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}",
+ "description": "Add a shared accessor for the framework Transients store to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::transients()->get( 'key' ) works with nothing further to wire. The constructor prefix is mandatory and readonly — never pass an empty string. Do not add the framework Transients to a Modules class or to Main::CLASSES: it implements neither Registrable nor Shareable, so the framework Loader would try to construct it with no argument and fatal on the missing prefix. If the project instead shares services through the framework Shareable contract (a Core\\Transients class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. If only one class needs transients, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-primitives to be installed first."
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json
index 3ccea71..8ab3391 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json
@@ -27,6 +27,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Services'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Services"
},
{
@@ -61,6 +62,12 @@
"description": "Text domain for the label string. The engine cannot read a plugin header, so pass it explicitly when it is not 'my-plugin'.",
"discover_from": "plugin-header:text-domain",
"default": "my-plugin"
+ },
+ {
+ "key": "label_php",
+ "description": "Escaped label for generated string literals. Derived from label.",
+ "discover_from": "input:label",
+ "transform": "php-escape"
}
],
"files": [
@@ -85,6 +92,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache
index dc8f741..d7621a6 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
* @link https://developer.wordpress.org/reference/functions/register_block_bindings_source/
*
* @package {{namespace}}
@@ -15,7 +15,7 @@ declare( strict_types = 1 );
namespace {{namespace}};
use WP_Block;
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Registers a Block Bindings source (core since WP 6.5).
@@ -67,7 +67,7 @@ final class {{class}} implements Registrable {
register_block_bindings_source(
self::SOURCE_NAME,
[
- 'label' => __( '{{label}}', '{{text_domain}}' ),
+ 'label' => __( '{{label_php}}', '{{text_domain}}' ),
'get_value_callback' => [ $this, 'get_value' ],
'uses_context' => self::USES_CONTEXT,
]
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache
index 23ec797..82ce067 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{tests_namespace}}
*/
@@ -18,7 +18,7 @@ use WP_Block;
use WP_Block_Bindings_Registry;
use WP_Block_Type_Registry;
use WP_UnitTestCase;
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Class {{class}}Test
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json
index 4bd5829..572237d 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json
@@ -14,7 +14,7 @@
},
{
"key": "base_path",
- "description": "Directory for the registration class. Default 'includes/Services'. The class derives the plugin root by walking two levels up from itself, so keep it two levels deep or adjust the dirname() depth it documents.",
+ "description": "Directory for the registration class. Default 'includes/Services'. The project root is derived from this path.",
"discover_from": "composer.json:autoload.psr-4",
"default": "includes/Services"
},
@@ -27,6 +27,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Services'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Services"
},
{
@@ -60,13 +61,22 @@
"key": "enqueue_hook",
"description": "Hook the module is enqueued on: 'wp_enqueue_scripts' for front-end requests, 'enqueue_block_assets' to cover the editor as well.",
"default": "wp_enqueue_scripts",
- "enum": ["wp_enqueue_scripts", "enqueue_block_assets"]
+ "enum": [
+ "wp_enqueue_scripts",
+ "enqueue_block_assets"
+ ]
},
{
"key": "text_domain",
"description": "Vendor half of the module id, conventionally the project text domain, producing '@my-plugin/lightbox'. The engine cannot read a plugin header, so pass it explicitly when it is not 'my-plugin'.",
"discover_from": "plugin-header:text-domain",
"default": "my-plugin"
+ },
+ {
+ "key": "project_root",
+ "description": "PHP expression for the project root, derived from base_path.",
+ "discover_from": "input:base_path",
+ "transform": "php-project-root"
}
],
"files": [
@@ -95,6 +105,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache
index 0ba8b5d..239b22c 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
* @link https://developer.wordpress.org/reference/functions/wp_register_script_module/
*
* @package {{namespace}}
@@ -14,7 +14,7 @@ declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Registers and enqueues a Script Module (core since WP 6.5).
@@ -94,24 +94,22 @@ final class {{class}} implements Registrable {
/**
* Filesystem path of the built module.
*
- * Assumes this class sits two levels below the plugin root (the default
- * '/includes/Services/' layout). Adjust the dirname() depths here
- * and in get_module_url() together if the class lives somewhere else.
+ * Resolved from this class's directory up to the project root, so update
+ * it here and in get_module_url() if the class moves.
*/
public function get_module_path(): string {
- return dirname( __DIR__, 2 ) . '/{{build_dir}}/{{module_slug}}.js';
+ return {{project_root}} . '/{{build_dir}}/{{module_slug}}.js';
}
/**
* Public URL of the built module.
*
- * The dirname() depth is one shallower than get_module_path()'s on
- * purpose: plugin_dir_url() takes a *file* and returns the URL of the
- * directory holding it, so passing '/includes' yields the plugin
- * root URL. Both therefore describe the same directory.
+ * The plugin_dir_url() helper returns the URL of a file's directory, so it
+ * gets a file name under the project root. The file is never read, so it
+ * need not exist.
*/
public function get_module_url(): string {
- return plugin_dir_url( dirname( __DIR__ ) ) . '{{build_dir}}/{{module_slug}}.js';
+ return plugin_dir_url( {{project_root}} . '/{{class}}.php' ) . '{{build_dir}}/{{module_slug}}.js';
}
/**
@@ -120,7 +118,7 @@ final class {{class}} implements Registrable {
* @return array
*/
private function get_asset_meta(): array {
- $asset_file = dirname( __DIR__, 2 ) . '/{{build_dir}}/{{module_slug}}.asset.php';
+ $asset_file = {{project_root}} . '/{{build_dir}}/{{module_slug}}.asset.php';
if ( ! is_readable( $asset_file ) ) {
return [];
@@ -139,7 +137,7 @@ final class {{class}} implements Registrable {
*
* @param array $dependencies Raw dependency list.
*
- * @return array>
+ * @return array
*/
private function normalise_dependencies( array $dependencies ): array {
$normalised = [];
@@ -150,9 +148,19 @@ final class {{class}} implements Registrable {
continue;
}
- if ( is_array( $dependency ) && isset( $dependency['id'] ) && is_string( $dependency['id'] ) ) {
- $normalised[] = $dependency;
+ if ( ! is_array( $dependency ) || ! isset( $dependency['id'] ) || ! is_string( $dependency['id'] ) ) {
+ continue;
}
+
+ // Rebuilt key by key rather than passed through, so the result is
+ // exactly the shape core documents -- anything else it would ignore.
+ $entry = [ 'id' => $dependency['id'] ];
+
+ if ( isset( $dependency['import'] ) && is_string( $dependency['import'] ) ) {
+ $entry['import'] = $dependency['import'];
+ }
+
+ $normalised[] = $entry;
}
return $normalised;
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache
index a317d9d..6de208b 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{tests_namespace}}
*/
@@ -15,7 +15,7 @@ namespace {{tests_namespace}};
use {{namespace}}\{{class}};
use WP_UnitTestCase;
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Class {{class}}Test
@@ -59,11 +59,10 @@ final class {{class}}Test extends WP_UnitTestCase {
/**
* The URL is the plugin root's, plus the build-relative path.
*
- * Path and URL are derived at different dirname() depths, because
- * plugin_dir_url() takes a file and returns its directory's URL, so this
- * asserts the two still land on the same directory. A depth that has
- * drifted resolves above the plugin, to /{{build_dir}}/..., and
- * 404s.
+ * The plugin_dir_url() helper takes a file and returns its directory's URL,
+ * so this asserts path and URL still land on the same directory. A root
+ * that has drifted resolves outside the plugin, to
+ * /{{build_dir}}/..., and 404s.
*/
public function test_module_url_resolves_inside_the_plugin_directory(): void {
$instance = new {{class}}();
diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json
index 0c39c93..d6feaa1 100644
--- a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json
@@ -27,6 +27,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Services'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Services"
},
{
@@ -84,6 +85,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.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
index b9f4571..ed2ee2c 100644
--- 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
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
* @link https://developer.wordpress.org/reference/functions/wp_get_speculation_rules/
*
* @package {{namespace}}
@@ -14,7 +14,7 @@ declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Customises WordPress core's Speculation Rules API (core since WP 6.8).
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
index 59685df..e685595 100644
--- 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
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{tests_namespace}}
*/
@@ -15,7 +15,7 @@ namespace {{tests_namespace}};
use {{namespace}}\{{class}};
use WP_UnitTestCase;
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Class {{class}}Test
diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json
index d2e7f1e..7ce0217 100644
--- a/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json
@@ -2,7 +2,7 @@
"slug": "admin-page",
"category": "wp",
"name": "Admin Page",
- "description": "Adds a custom admin menu page extending AbstractAdminPage from rtcamp/wp-framework. Top-level by default; pass --parent_slug for a submenu.",
+ "description": "Adds a custom admin menu page extending AbstractAdminPage from rtcamp/wp-primitives. Top-level by default; pass --parent_slug for a submenu.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Admin'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Admin"
},
{
@@ -59,6 +60,18 @@
"key": "parent_slug",
"description": "Parent menu slug for a submenu (e.g. 'options-general.php'). Leave empty for a top-level menu.",
"default": ""
+ },
+ {
+ "key": "menu_title_php",
+ "description": "Escaped menu_title for generated string literals. Derived from menu_title.",
+ "discover_from": "input:menu_title",
+ "transform": "php-escape"
+ },
+ {
+ "key": "page_title_php",
+ "description": "Escaped page_title for generated string literals. Derived from page_title.",
+ "discover_from": "input:page_title",
+ "transform": "php-escape"
}
],
"files": [
@@ -83,6 +96,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache
index 952231a..1cebbd3 100644
--- a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractAdminPage;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractAdminPage;
/**
* Registers the {{page_title}} admin menu page.
@@ -22,8 +22,6 @@ class {{class}} extends AbstractAdminPage {
/**
* Get the menu slug (page identifier).
- *
- * @return string
*/
public static function get_slug(): string {
return '{{slug}}';
@@ -31,20 +29,16 @@ class {{class}} extends AbstractAdminPage {
/**
* Get the page title (shown in the browser tab).
- *
- * @return string
*/
public function get_page_title(): string {
- return __( '{{page_title}}', '{{text_domain}}' );
+ return __( '{{page_title_php}}', '{{text_domain}}' );
}
/**
* Get the menu title (shown in the admin sidebar).
- *
- * @return string
*/
public function get_menu_title(): string {
- return __( '{{menu_title}}', '{{text_domain}}' );
+ return __( '{{menu_title_php}}', '{{text_domain}}' );
}
{{#parent_slug}}
/**
@@ -55,8 +49,6 @@ class {{class}} extends AbstractAdminPage {
* returning null) applies: returning a concrete value from a ?string
* override would make the return type too wide for PHPStan level 5's
* checkTooWideReturnTypesInProtectedAndPublicMethods rule.
- *
- * @return string
*/
public function get_parent_slug(): string {
return '{{parent_slug}}';
@@ -64,8 +56,6 @@ class {{class}} extends AbstractAdminPage {
{{/parent_slug}}
/**
* Capability required to view the page.
- *
- * @return string
*/
public function get_capability(): string {
return 'manage_options';
@@ -73,8 +63,6 @@ class {{class}} extends AbstractAdminPage {
/**
* Render the page body. Replace with your markup.
- *
- * @return void
*/
public function render(): void {
?>
diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache
index bcad0bf..2b94fc4 100644
--- a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,14 +17,22 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The slug the class exposes is the one the page is registered under.
+ */
public function test_slug_matches_manifest(): void {
$this->assertSame( '{{slug}}', {{class}}::get_slug() );
}
+ /**
+ * Registration is deferred to admin_menu, where add_menu_page() is available.
+ */
public function test_register_hooks_attaches_to_admin_menu(): void {
$instance = new {{class}}();
$instance->register_hooks();
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json
index 0d2c9f8..3140b2e 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json
@@ -2,7 +2,7 @@
"slug": "block-dynamic",
"category": "wp",
"name": "Dynamic block",
- "description": "Adds a server-rendered Gutenberg block: one PHP class extending AbstractBlock from rtcamp/wp-framework (handles registration and render), plus block.json + edit.js + index.js for the editor side.",
+ "description": "Adds a server-rendered Gutenberg block: one PHP class extending AbstractBlock from rtcamp/wp-primitives (handles registration and render), plus block.json + edit.js + index.js for the editor side.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Blocks'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Blocks"
},
{
@@ -58,6 +59,30 @@
"key": "vendor",
"description": "Vendor prefix for the block name, e.g. 'rtcamp' produces 'rtcamp/hero-banner'.",
"default": "rtcamp"
+ },
+ {
+ "key": "title_js",
+ "description": "Escaped title for generated string literals. Derived from title.",
+ "discover_from": "input:title",
+ "transform": "js-string"
+ },
+ {
+ "key": "title_json",
+ "description": "Escaped title for generated string literals. Derived from title.",
+ "discover_from": "input:title",
+ "transform": "json-escape"
+ },
+ {
+ "key": "title_php",
+ "description": "Escaped title for generated string literals. Derived from title.",
+ "discover_from": "input:title",
+ "transform": "php-escape"
+ },
+ {
+ "key": "project_root",
+ "description": "PHP expression for the project root, derived from base_path.",
+ "discover_from": "input:base_path",
+ "transform": "php-project-root"
}
],
"files": [
@@ -94,6 +119,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache
index 468edb5..a8bfcf4 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache
@@ -4,17 +4,17 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractBlock;
use WP_Block;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractBlock;
/**
* Registers the {{vendor}}/{{slug}} dynamic block.
@@ -23,8 +23,6 @@ class {{class}} extends AbstractBlock {
/**
* Get the block name (including namespace).
- *
- * @return string
*/
public static function get_name(): string {
return '{{vendor}}/{{slug}}';
@@ -36,14 +34,14 @@ class {{class}} extends AbstractBlock {
* so the editor receives the bundled assets and not raw ES modules.
* Run `npm run build` to populate this directory.
*
- * Narrowed to a non-null `string` (the parent declares `?string`): this
- * override always returns a concrete path, and an always-non-null `?string`
- * trips PHPStan level 5's checkTooWideReturnTypesInProtectedAndPublicMethods.
- *
- * @return string
+ * Protected to match AbstractBlock, which declares it protected and calls it
+ * internally. Narrowed to a non-null `string` (the parent declares
+ * `?string`): this override always returns a concrete path, and an
+ * always-non-null `?string` trips PHPStan's
+ * checkTooWideReturnTypesInProtectedAndPublicMethods.
*/
- public function get_block_dir(): string {
- return dirname( __DIR__, 2 ) . '/{{build_dir}}/{{slug}}';
+ protected function get_block_dir(): string {
+ return {{project_root}} . '/{{build_dir}}/{{slug}}';
}
/**
@@ -52,14 +50,12 @@ class {{class}} extends AbstractBlock {
* @param array $attributes Block attributes from block.json.
* @param string $content Inner content rendered by Gutenberg.
* @param WP_Block $block The block instance.
- *
- * @return string
*/
public function render( array $attributes, string $content, WP_Block $block ): string {
// Replace with your markup.
return sprintf(
'
);
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache
index 3c7ccb4..ad38839 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache
@@ -7,6 +7,6 @@ import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import metadata from './block.json';
-registerBlockType( metadata.name, {
+registerBlockType(metadata.name, {
edit: Edit,
-} );
+});
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache
index fa7e50c..2fce722 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,14 +17,22 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The block name carries the vendor prefix core matches against block.json.
+ */
public function test_block_name_matches_manifest(): void {
$this->assertSame( '{{vendor}}/{{slug}}', {{class}}::get_name() );
}
+ /**
+ * Registration is deferred to init, the earliest hook a block type may register on.
+ */
public function test_register_hooks_attaches_to_init(): void {
$instance = new {{class}}();
$instance->register_hooks();
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json
index 1a232bd..aaf70b2 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Blocks'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Blocks"
},
{
@@ -58,6 +59,24 @@
"key": "vendor",
"description": "Vendor prefix for the block name, e.g. 'rtcamp' produces 'rtcamp/faq-accordion'. Also the store namespace shared by render.php's data-wp-interactive and view.js's store().",
"default": "rtcamp"
+ },
+ {
+ "key": "title_js",
+ "description": "Escaped title for generated string literals. Derived from title.",
+ "discover_from": "input:title",
+ "transform": "js-string"
+ },
+ {
+ "key": "title_json",
+ "description": "Escaped title for generated string literals. Derived from title.",
+ "discover_from": "input:title",
+ "transform": "json-escape"
+ },
+ {
+ "key": "project_root",
+ "description": "PHP expression for the project root, derived from base_path.",
+ "discover_from": "input:base_path",
+ "transform": "php-project-root"
}
],
"files": [
@@ -102,7 +121,7 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
},
"npm_dev_dependencies": {
"@wordpress/block-editor": "^16.0.0",
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache
index 499bc6c..7b47df8 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
* @link https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/
*
* @package {{namespace}}
@@ -14,7 +14,7 @@ declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Registers the {{vendor}}/{{slug}} block from its build output.
@@ -45,7 +45,7 @@ final class {{class}} implements Registrable {
* (`npm run build:blocks` in the rtCamp skeleton) to populate it.
*/
public function get_block_dir(): string {
- return dirname( __DIR__, 2 ) . '/{{build_dir}}/{{slug}}';
+ return {{project_root}} . '/{{build_dir}}/{{slug}}';
}
/**
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache
index e4c61f6..6eb0471 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache
@@ -2,10 +2,10 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "{{vendor}}/{{slug}}",
- "title": "{{title}}",
+ "title": "{{title_json}}",
"category": "widgets",
"icon": "block-default",
- "description": "{{title}} block, made interactive with the Interactivity API.",
+ "description": "{{title_json}} block, made interactive with the Interactivity API.",
"keywords": ["{{slug}}"],
"textdomain": "{{vendor}}-{{slug}}",
"supports": {
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache
index 0b31f16..5cc7e1e 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache
@@ -12,8 +12,8 @@ import { __ } from '@wordpress/i18n';
export default function Edit() {
const blockProps = useBlockProps();
return (
-
-
{ __( '{{title}}', '{{vendor}}-{{slug}}' ) }
+
+
{__({{title_js}}, '{{vendor}}-{{slug}}')}
);
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache
index 3c7ccb4..ad38839 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache
@@ -7,6 +7,6 @@ import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import metadata from './block.json';
-registerBlockType( metadata.name, {
+registerBlockType(metadata.name, {
edit: Edit,
-} );
+});
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache
index 6a8d106..027f302 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache
@@ -4,7 +4,7 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{tests_namespace}}
*/
@@ -16,7 +16,7 @@ namespace {{tests_namespace}};
use {{namespace}}\{{class}};
use WP_Block_Type_Registry;
use WP_UnitTestCase;
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Class {{class}}Test
@@ -73,8 +73,8 @@ final class {{class}}Test extends WP_UnitTestCase {
}
/**
- * register_block() is safe to call whatever the environment: before WP 6.5
- * or before a build it registers nothing instead of failing.
+ * Registration is safe to call whatever the environment: before WP 6.5 or
+ * before a build, register_block() registers nothing instead of failing.
*/
public function test_register_block_is_a_no_op_without_build_output(): void {
$instance = new {{class}}();
diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache
index 8c65b34..04d7dca 100644
--- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache
@@ -19,11 +19,11 @@
*/
import { store, getContext } from '@wordpress/interactivity';
-store( '{{vendor}}/{{slug}}', {
+store('{{vendor}}/{{slug}}', {
actions: {
toggle() {
const context = getContext();
- context.isOpen = ! context.isOpen;
+ context.isOpen = !context.isOpen;
},
},
-} );
+});
diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json
index a665505..b470179 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json
@@ -2,7 +2,7 @@
"slug": "cli",
"category": "wp",
"name": "WP-CLI command",
- "description": "Adds a WP-CLI command class implementing the CLICommand interface from rtcamp/wp-framework. Static get_name / get_description / run; the consumer's Cli module wires it through WP_CLI::add_command().",
+ "description": "Adds a WP-CLI command class implementing the CLICommand interface from rtcamp/wp-primitives. Static get_name / get_description / run; the consumer's Cli module wires it through WP_CLI::add_command().",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Cli'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Cli"
},
{
@@ -43,6 +44,12 @@
"key": "description",
"description": "One-line command description shown in `wp help`.",
"default": "TODO: describe what this command does."
+ },
+ {
+ "key": "description_php",
+ "description": "Escaped description for the generated string literal. Derived from description.",
+ "discover_from": "input:description",
+ "transform": "php-escape"
}
],
"files": [
@@ -67,6 +74,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache
index cc11f79..2ace942 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache
@@ -4,17 +4,17 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Interfaces\CLICommand;
use WP_CLI;
+use rtCamp\WPPrimitives\Contracts\Interfaces\CLICommand;
/**
* Registers the {{name}} WP-CLI command.
@@ -23,8 +23,6 @@ final class {{class}} implements CLICommand {
/**
* Get the command name.
- *
- * @return string
*/
public static function get_name(): string {
return '{{name}}';
@@ -32,11 +30,9 @@ final class {{class}} implements CLICommand {
/**
* Get the command description.
- *
- * @return string
*/
public static function get_description(): string {
- return '{{description}}';
+ return '{{description_php}}';
}
/**
@@ -49,8 +45,6 @@ final class {{class}} implements CLICommand {
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
- *
- * @return void
*/
public static function run( array $args, array $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache
index f491f1b..739b3a7 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache
@@ -4,30 +4,43 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
use {{namespace}}\{{class}};
-use rtCamp\WPFramework\Contracts\Interfaces\CLICommand;
use PHPUnit\Framework\TestCase;
+use rtCamp\WPPrimitives\Contracts\Interfaces\CLICommand;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends TestCase {
+ /**
+ * The class satisfies the framework's CLICommand contract, so the CLI module can boot it.
+ */
public function test_implements_cli_command(): void {
$this->assertTrue( in_array( CLICommand::class, class_implements( {{class}}::class ), true ) );
}
+ /**
+ * The command name is the one WP_CLI dispatches on.
+ */
public function test_name_matches_manifest(): void {
$this->assertSame( '{{name}}', {{class}}::get_name() );
}
+ /**
+ * WP_CLI prints the description in `wp help`, so an empty one ships a blank entry.
+ */
public function test_description_is_non_empty(): void {
$this->assertNotEmpty( {{class}}::get_description() );
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json
index 679acaa..51cd4d2 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json
@@ -2,7 +2,7 @@
"slug": "cpt",
"category": "wp",
"name": "Custom Post Type",
- "description": "Adds a CPT class extending AbstractPostType from rtcamp/wp-framework. One class per post type; the consumer's PostTypes module instantiates it via the Loader.",
+ "description": "Adds a CPT class extending AbstractPostType from rtcamp/wp-primitives. One class per post type; the consumer's PostTypes module instantiates it via the Loader.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/PostTypes'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/PostTypes"
},
{
@@ -59,6 +60,18 @@
"key": "menu_icon",
"description": "Dashicon class or SVG URL for the admin sidebar.",
"default": "dashicons-admin-post"
+ },
+ {
+ "key": "singular_php",
+ "description": "Escaped singular for generated string literals. Derived from singular.",
+ "discover_from": "input:singular",
+ "transform": "php-escape"
+ },
+ {
+ "key": "plural_php",
+ "description": "Escaped plural for generated string literals. Derived from plural.",
+ "discover_from": "input:plural",
+ "transform": "php-escape"
}
],
"files": [
@@ -83,6 +96,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache
index 3253558..a0fbbbe 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractPostType;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractPostType;
/**
* Registers the {{singular}} custom post type.
@@ -22,8 +22,6 @@ class {{class}} extends AbstractPostType {
/**
* Get the post type slug.
- *
- * @return string
*/
public static function get_slug(): string {
return '{{slug}}';
@@ -31,26 +29,20 @@ class {{class}} extends AbstractPostType {
/**
* Get the singular label.
- *
- * @return string
*/
public function get_singular_label(): string {
- return __( '{{singular}}', '{{text_domain}}' );
+ return __( '{{singular_php}}', '{{text_domain}}' ) ?: '{{singular_php}}';
}
/**
* Get the plural label.
- *
- * @return string
*/
public function get_plural_label(): string {
- return __( '{{plural}}', '{{text_domain}}' );
+ return __( '{{plural_php}}', '{{text_domain}}' ) ?: '{{plural_php}}';
}
/**
* Get the menu icon (Dashicon class, SVG data URI, or 'none').
- *
- * @return string
*/
public function get_menu_icon(): string {
return '{{menu_icon}}';
diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache
index ee8864c..cf24b18 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,20 +17,31 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The post type key is the one the class registers under.
+ */
public function test_slug_matches_manifest(): void {
$this->assertSame( '{{slug}}', {{class}}::get_slug() );
}
+ /**
+ * Registration is deferred to init, where register_post_type() is available.
+ */
public function test_register_hooks_attaches_to_init(): void {
$instance = new {{class}}();
$instance->register_hooks();
$this->assertNotFalse( has_action( 'init', [ $instance, 'register' ] ) );
}
+ /**
+ * Registration actually lands in core's post type registry.
+ */
public function test_post_type_is_registered_after_init(): void {
( new {{class}}() )->register();
$this->assertTrue( post_type_exists( '{{slug}}' ) );
diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json
index a59783c..d941f0b 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json
@@ -2,7 +2,7 @@
"slug": "cron",
"category": "wp",
"name": "Cron job",
- "description": "Adds a cron job class implementing the Registrable interface from rtcamp/wp-framework. The class schedules a wp-cron event in register_hooks() and binds its handler to the chosen action.",
+ "description": "Adds a cron job class implementing the Registrable interface from rtcamp/wp-primitives. The class schedules a wp-cron event in register_hooks() and binds its handler to the chosen action.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Cron'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Cron"
},
{
@@ -73,6 +74,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache
index 3d1ce6f..ab0da98 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Schedules and handles the {{name}} cron job.
@@ -26,8 +26,6 @@ final class {{class}} implements Registrable {
/**
* Schedule the event (if not already scheduled) and bind the handler.
* Called by the framework Loader.
- *
- * @return void
*/
public function register_hooks(): void {
if ( ! wp_next_scheduled( self::HOOK ) ) {
@@ -38,8 +36,6 @@ final class {{class}} implements Registrable {
/**
* The job body. Called every time the hook fires.
- *
- * @return void
*/
public function run(): void {
// Replace with your job logic.
diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache
index c4c30eb..0572c55 100644
--- a/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,16 +17,24 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * Booting the class schedules its event, so the job runs unattended.
+ */
public function test_register_hooks_schedules_event(): void {
( new {{class}}() )->register_hooks();
$this->assertNotFalse( wp_next_scheduled( {{class}}::HOOK ) );
wp_clear_scheduled_hook( {{class}}::HOOK );
}
+ /**
+ * The scheduled hook resolves to this class's handler, not just to an empty event.
+ */
public function test_register_hooks_binds_run_handler(): void {
$instance = new {{class}}();
$instance->register_hooks();
diff --git a/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json
index e439357..c5f362c 100644
--- a/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Modules'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Modules"
},
{
@@ -67,6 +68,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache b/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache
index 7474bf4..a5fa963 100644
--- a/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractModule;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractModule;
/**
* Groups related Registrable classes for the framework Loader to boot.
@@ -26,7 +26,7 @@ final class {{class}} extends AbstractModule {
*
* @return array
*/
- public function get_classes(): array {
+ protected function get_classes(): array {
return [
// scaffold:wp/{{kind}}:classes (add classes here).
];
diff --git a/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache
index 5f63cfb..08e6ca5 100644
--- a/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache
@@ -4,28 +4,66 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
use {{namespace}}\{{class}};
+use ReflectionMethod;
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
- public function test_get_classes_returns_array(): void {
- $this->assertIsArray( ( new {{class}}() )->get_classes() );
+ /**
+ * The Loader walks the list positionally, so it must be a list.
+ */
+ public function test_get_classes_returns_a_list(): void {
+ $classes = $this->get_module_classes();
+
+ $this->assertSame( array_values( $classes ), $classes, 'get_classes() must return a list, not a keyed map.' );
}
- public function test_each_listed_class_is_loadable(): void {
- foreach ( ( new {{class}}() )->get_classes() as $class ) {
- $this->assertTrue( class_exists( $class ), "Class {$class} should be loadable" );
+ /**
+ * Every listed class is autoloadable, so the Loader can instantiate it.
+ *
+ * Compares whole arrays rather than asserting inside the loop: a freshly
+ * scaffolded module lists nothing, and a loop body that never runs leaves
+ * the case with no assertion at all -- risky, which consumers fail on.
+ */
+ public function test_every_listed_class_is_loadable(): void {
+ $classes = $this->get_module_classes();
+ $loadable = [];
+
+ foreach ( $classes as $class ) {
+ if ( class_exists( $class ) ) {
+ $loadable[] = $class;
+ }
}
+
+ $this->assertSame( $classes, $loadable, 'Every class listed by get_classes() must be autoloadable.' );
+ }
+
+ /**
+ * Read the module's class list.
+ *
+ * AbstractModule declares get_classes() protected and the generated module
+ * is final, so neither a direct call nor a test subclass can reach it.
+ *
+ * @return array
+ */
+ private function get_module_classes(): array {
+ $method = new ReflectionMethod( {{class}}::class, 'get_classes' );
+
+ return (array) $method->invoke( new {{class}}() );
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json
index b0fcda8..301e7e5 100644
--- a/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json
@@ -2,7 +2,7 @@
"slug": "registrable",
"category": "wp",
"name": "Registrable Service",
- "description": "Escape hatch for hook-registering classes that don't fit a specific abstract. Generates a class implementing the Registrable interface from rtcamp/wp-framework with an empty register_hooks() body.",
+ "description": "Escape hatch for hook-registering classes that don't fit a specific abstract. Generates a class implementing the Registrable interface from rtcamp/wp-primitives with an empty register_hooks() body.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Services'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Services"
},
{
@@ -62,6 +63,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache
index 5027f5c..2dba31e 100644
--- a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
+use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable;
/**
* Registers this service's WordPress hooks through the framework Loader.
@@ -22,8 +22,6 @@ final class {{class}} implements Registrable {
/**
* Register WordPress hooks. Called by the framework Loader during boot.
- *
- * @return void
*/
public function register_hooks(): void {
// Register actions and filters here, for example with add_action() or add_filter().
diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache
index 0352dbd..bba6620 100644
--- a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache
@@ -4,26 +4,38 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
use {{namespace}}\{{class}};
-use rtCamp\WPFramework\Contracts\Interfaces\Registrable;
use WP_UnitTestCase;
+use rtCamp\WPPrimitives\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}}() );
}
+ /**
+ * Booting the class is side-effect free while its hooks are still empty.
+ *
+ * Replace this with assertions on the hooks register_hooks() actually adds.
+ */
public function test_register_hooks_runs_without_error(): void {
( new {{class}}() )->register_hooks();
$this->addToAssertionCount( 1 );
diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json
index 1caf392..c94d1bf 100644
--- a/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json
@@ -2,7 +2,7 @@
"slug": "rest",
"category": "wp",
"name": "REST controller",
- "description": "Adds a REST API controller extending AbstractRESTController from rtcamp/wp-framework. The base extends WP_REST_Controller; the consumer's Rest module instantiates each controller via the Loader.",
+ "description": "Adds a REST API controller extending AbstractRESTController from rtcamp/wp-primitives. The base extends WP_REST_Controller; the consumer's Rest module instantiates each controller via the Loader.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Rest'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Rest"
},
{
@@ -72,6 +73,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache b/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache
index f6198a6..529490e 100644
--- a/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractRESTController;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractRESTController;
/**
* Registers REST routes for the {{name}} resource.
@@ -43,8 +43,6 @@ class {{class}}Controller extends AbstractRESTController {
/**
* Register REST routes. Called by the framework on rest_api_init.
- *
- * @return void
*/
public function register_routes(): void {
register_rest_route(
@@ -85,7 +83,7 @@ class {{class}}Controller extends AbstractRESTController {
*
* @param \WP_REST_Request $request The REST request.
*
- * @return bool
+ * @return bool True when the current user may read the collection.
*/
public function get_items_permissions_check( $request ) {
return current_user_can( 'read' );
diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache
index 75803df..01ceae5 100644
--- a/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,18 +17,31 @@ use {{namespace}}\{{class}}Controller;
use WP_UnitTestCase;
/**
+ * Class {{class}}ControllerTest
+ *
* @covers \{{namespace}}\{{class}}Controller
*/
final class {{class}}ControllerTest extends WP_UnitTestCase {
+ /**
+ * Routes are registered on rest_api_init, the only hook where register_rest_route() is safe.
+ */
public function test_register_hooks_attaches_to_rest_api_init(): void {
$instance = new {{class}}Controller();
$instance->register_hooks();
$this->assertNotFalse( has_action( 'rest_api_init', [ $instance, 'register_routes' ] ) );
}
+ /**
+ * The route lands in the REST server's table under its namespaced path.
+ *
+ * Goes through the hook rather than calling register_routes() directly:
+ * register_rest_route() emits a _doing_it_wrong notice outside rest_api_init,
+ * and WP_UnitTestCase fails the case on an unexpected notice.
+ */
public function test_route_is_registered_after_rest_api_init(): void {
- ( new {{class}}Controller() )->register_routes();
+ ( new {{class}}Controller() )->register_hooks();
+ do_action( 'rest_api_init' );
$routes = rest_get_server()->get_routes();
$this->assertArrayHasKey( '/{{rest_namespace}}/v{{rest_version}}/{{name}}', $routes );
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json
index 3d813c5..7d55b15 100644
--- a/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json
@@ -2,7 +2,7 @@
"slug": "settings-page",
"category": "wp",
"name": "Settings Page",
- "description": "Adds a Settings API page extending AbstractSettingsPage from rtcamp/wp-framework. Generates a page plus a worked example of register_setting() declarations.",
+ "description": "Adds a Settings API page extending AbstractSettingsPage from rtcamp/wp-primitives. Generates a page plus a worked example of register_setting() declarations.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Settings'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Settings"
},
{
@@ -59,6 +60,18 @@
"key": "option_name",
"description": "Sample option_name registered via register_setting(). Add more by editing get_settings().",
"required": true
+ },
+ {
+ "key": "menu_title_php",
+ "description": "Escaped menu_title for generated string literals. Derived from menu_title.",
+ "discover_from": "input:menu_title",
+ "transform": "php-escape"
+ },
+ {
+ "key": "page_title_php",
+ "description": "Escaped page_title for generated string literals. Derived from page_title.",
+ "discover_from": "input:page_title",
+ "transform": "php-escape"
}
],
"files": [
@@ -83,6 +96,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache
index 9576034..9002e06 100644
--- a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractSettingsPage;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractSettingsPage;
/**
* Registers the {{page_title}} settings page via the Settings API.
@@ -22,8 +22,6 @@ class {{class}} extends AbstractSettingsPage {
/**
* Get the menu/page slug.
- *
- * @return string
*/
public static function get_slug(): string {
return '{{slug}}';
@@ -31,20 +29,16 @@ class {{class}} extends AbstractSettingsPage {
/**
* Get the page title (shown in the browser tab).
- *
- * @return string
*/
public function get_page_title(): string {
- return __( '{{page_title}}', '{{text_domain}}' );
+ return __( '{{page_title_php}}', '{{text_domain}}' );
}
/**
* Get the menu title (shown in the admin sidebar).
- *
- * @return string
*/
public function get_menu_title(): string {
- return __( '{{menu_title}}', '{{text_domain}}' );
+ return __( '{{menu_title_php}}', '{{text_domain}}' );
}
/**
@@ -69,8 +63,6 @@ class {{class}} extends AbstractSettingsPage {
/**
* Render the page form. Uses Settings API helpers.
- *
- * @return void
*/
public function render(): void {
?>
diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache
index a74f2dc..d8baf31 100644
--- a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,21 +17,35 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The page slug is the one the settings page is registered under.
+ */
public function test_slug_matches_manifest(): void {
$this->assertSame( '{{slug}}', {{class}}::get_slug() );
}
+ /**
+ * The settings map is an array the registration loop can walk.
+ */
public function test_get_settings_returns_array(): void {
$this->assertIsArray( ( new {{class}}() )->get_settings() );
}
+ /**
+ * The option reaches core's settings registry, so the Settings API renders and sanitises it.
+ *
+ * Calls the registration callback directly. Firing do_action( 'admin_init' )
+ * would re-run core's own admin_init listeners, which send headers the test
+ * harness has already flushed.
+ */
public function test_sample_option_is_registered(): void {
- ( new {{class}}() )->register_hooks();
- do_action( 'admin_init' );
+ ( new {{class}}() )->register_settings();
$this->assertArrayHasKey( '{{option_name}}', get_registered_settings() );
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json
index 8520b38..87383e4 100644
--- a/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json
@@ -2,7 +2,7 @@
"slug": "shortcode",
"category": "wp",
"name": "Shortcode",
- "description": "Adds a shortcode class extending AbstractShortcode from rtcamp/wp-framework. One class per shortcode tag; the consumer's Shortcodes module instantiates it via the Loader.",
+ "description": "Adds a shortcode class extending AbstractShortcode from rtcamp/wp-primitives. One class per shortcode tag; the consumer's Shortcodes module instantiates it via the Loader.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Shortcodes'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Shortcodes"
},
{
@@ -62,6 +63,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache
index b332ff8..7058df3 100644
--- a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractShortcode;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractShortcode;
/**
* Registers the [{{tag}}] shortcode.
@@ -22,8 +22,6 @@ class {{class}} extends AbstractShortcode {
/**
* Get the shortcode tag.
- *
- * @return string
*/
public static function get_tag(): string {
return '{{tag}}';
@@ -43,8 +41,6 @@ class {{class}} extends AbstractShortcode {
*
* @param array $atts Parsed attributes.
* @param string|null $content Inner content for enclosing shortcodes.
- *
- * @return string
*/
public function render( array $atts, ?string $content ): string {
// Replace with your markup.
diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache
index b1270fa..ec39166 100644
--- a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,17 +17,29 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The tag is the one authors type in content.
+ */
public function test_tag_matches_manifest(): void {
$this->assertSame( '{{tag}}', {{class}}::get_tag() );
}
+ /**
+ * The tag lands in core's shortcode registry.
+ *
+ * Calls the registration callback directly. Firing do_action( 'init' ) would
+ * re-run every other listener on that hook -- in a real project that means
+ * duplicate block registrations and _doing_it_wrong notices, which
+ * WP_UnitTestCase fails on.
+ */
public function test_register_hooks_registers_shortcode(): void {
- ( new {{class}}() )->register_hooks();
- do_action( 'init' );
+ ( new {{class}}() )->register_shortcode();
$this->assertTrue( shortcode_exists( '{{tag}}' ) );
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json
index bfb9002..c9210c0 100644
--- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json
@@ -2,7 +2,7 @@
"slug": "taxonomy",
"category": "wp",
"name": "Taxonomy",
- "description": "Adds a taxonomy class extending AbstractTaxonomy from rtcamp/wp-framework. One class per taxonomy; the consumer's Taxonomies module instantiates it via the Loader.",
+ "description": "Adds a taxonomy class extending AbstractTaxonomy from rtcamp/wp-primitives. One class per taxonomy; the consumer's Taxonomies module instantiates it via the Loader.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Taxonomies'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Taxonomies"
},
{
@@ -59,6 +60,18 @@
"key": "object_type",
"description": "Post type slug this taxonomy attaches to. For multiple, comma-separate (e.g. 'product,page').",
"required": true
+ },
+ {
+ "key": "singular_php",
+ "description": "Escaped singular for generated string literals. Derived from singular.",
+ "discover_from": "input:singular",
+ "transform": "php-escape"
+ },
+ {
+ "key": "plural_php",
+ "description": "Escaped plural for generated string literals. Derived from plural.",
+ "discover_from": "input:plural",
+ "transform": "php-escape"
}
],
"files": [
@@ -83,6 +96,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache
index 37c1875..71aa9c8 100644
--- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache
@@ -4,16 +4,16 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractTaxonomy;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractTaxonomy;
/**
* Registers the {{singular}} taxonomy.
@@ -22,8 +22,6 @@ class {{class}} extends AbstractTaxonomy {
/**
* Get the taxonomy slug.
- *
- * @return string
*/
public static function get_slug(): string {
return '{{slug}}';
@@ -40,27 +38,21 @@ class {{class}} extends AbstractTaxonomy {
/**
* Get the singular label.
- *
- * @return string
*/
public function get_singular_label(): string {
- return __( '{{singular}}', '{{text_domain}}' );
+ return __( '{{singular_php}}', '{{text_domain}}' ) ?: '{{singular_php}}';
}
/**
* Get the plural label.
- *
- * @return string
*/
public function get_plural_label(): string {
- return __( '{{plural}}', '{{text_domain}}' );
+ return __( '{{plural_php}}', '{{text_domain}}' ) ?: '{{plural_php}}';
}
/**
* Hierarchical (true = like categories) or flat (false = like tags).
* Override to switch behaviour.
- *
- * @return bool
*/
public function is_hierarchical(): bool {
return true;
diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache
index 541fbf4..0a7dee2 100644
--- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,20 +17,31 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The taxonomy key is the one the class registers under.
+ */
public function test_slug_matches_manifest(): void {
$this->assertSame( '{{slug}}', {{class}}::get_slug() );
}
+ /**
+ * Registration is deferred to init, where register_taxonomy() is available.
+ */
public function test_register_hooks_attaches_to_init(): void {
$instance = new {{class}}();
$instance->register_hooks();
$this->assertNotFalse( has_action( 'init', [ $instance, 'register' ] ) );
}
+ /**
+ * Registration actually lands in core's taxonomy registry.
+ */
public function test_taxonomy_is_registered_after_init(): void {
( new {{class}}() )->register();
$this->assertTrue( taxonomy_exists( '{{slug}}' ) );
diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json
index 6760751..34ed22d 100644
--- a/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json
+++ b/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json
@@ -2,7 +2,7 @@
"slug": "user-role",
"category": "wp",
"name": "User Role",
- "description": "Adds a versioned custom user role class extending AbstractUserRole from rtcamp/wp-framework. The framework re-registers the role only when get_version() increases.",
+ "description": "Adds a versioned custom user role class extending AbstractUserRole from rtcamp/wp-primitives. The framework re-registers the role only when get_version() increases.",
"source": "template",
"inputs": [
{
@@ -26,6 +26,7 @@
{
"key": "tests_path",
"description": "Directory for the test file. Default 'tests/Roles'.",
+ "discover_from": "composer.json:autoload-dev.psr-4",
"default": "tests/Roles"
},
{
@@ -49,6 +50,12 @@
"key": "display_name",
"description": "Human-readable role name, e.g. 'Shop Manager'.",
"required": true
+ },
+ {
+ "key": "display_name_php",
+ "description": "Escaped display_name for generated string literals. Derived from display_name.",
+ "discover_from": "input:display_name",
+ "transform": "php-escape"
}
],
"files": [
@@ -73,6 +80,6 @@
}
],
"composer_dependencies": {
- "rtcamp/wp-framework": "^1.0"
+ "rtcamp/wp-primitives": "^2.0"
}
}
diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache
index 60b967c..9d69537 100644
--- a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache
@@ -4,10 +4,12 @@
*
* Scaffolded by @rtcamp/wp-tooling.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
+ *
+ * @package {{tests_namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{tests_namespace}};
@@ -15,18 +17,29 @@ use {{namespace}}\{{class}};
use WP_UnitTestCase;
/**
+ * Class {{class}}Test
+ *
* @covers \{{namespace}}\{{class}}
*/
final class {{class}}Test extends WP_UnitTestCase {
+ /**
+ * The role key is the one capabilities are granted against.
+ */
public function test_slug_matches_manifest(): void {
$this->assertSame( '{{slug}}', {{class}}::get_slug() );
}
+ /**
+ * The capability map is an array add_role() can consume.
+ */
public function test_capabilities_map_is_returned(): void {
$this->assertIsArray( ( new {{class}}() )->get_capabilities() );
}
+ /**
+ * The role is created in the database, not just described in code.
+ */
public function test_maybe_update_role_creates_role(): void {
$instance = new {{class}}();
$instance->maybe_update_role();
diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache
index f59aef1..f393620 100644
--- a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache
+++ b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache
@@ -7,16 +7,16 @@
* Bump get_version() whenever get_capabilities() changes so the framework
* re-applies the role on the next admin_init.
*
- * @link https://github.com/rtCamp/wp-framework
+ * @link https://github.com/rtCamp/wp-primitives
*
* @package {{namespace}}
*/
-declare(strict_types=1);
+declare( strict_types = 1 );
namespace {{namespace}};
-use rtCamp\WPFramework\Contracts\Abstracts\AbstractUserRole;
+use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractUserRole;
/**
* Registers the {{display_name}} user role.
@@ -25,8 +25,6 @@ class {{class}} extends AbstractUserRole {
/**
* Get the role slug (identifier).
- *
- * @return string
*/
public static function get_slug(): string {
return '{{slug}}';
@@ -34,11 +32,9 @@ class {{class}} extends AbstractUserRole {
/**
* Get the display name for the role.
- *
- * @return string
*/
public function get_display_name(): string {
- return __( '{{display_name}}', '{{text_domain}}' );
+ return __( '{{display_name_php}}', '{{text_domain}}' ) ?: '{{display_name_php}}';
}
/**
@@ -54,8 +50,6 @@ class {{class}} extends AbstractUserRole {
/**
* Role version. Increment when get_capabilities() changes.
- *
- * @return int
*/
public function get_version(): int {
return 1;
diff --git a/node-packages/wp-tooling/skills/scaffold/SKILL.md b/node-packages/wp-tooling/skills/scaffold/SKILL.md
index 876b9f6..4aed337 100644
--- a/node-packages/wp-tooling/skills/scaffold/SKILL.md
+++ b/node-packages/wp-tooling/skills/scaffold/SKILL.md
@@ -55,7 +55,7 @@ Read, in order:
- Block scaffolds: sample one `block.json` for vendor prefix and source dir.
- CI scaffolds: sample one `.github/workflows/*.yml` for filename and trigger style.
-Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) are hints, not ground truth. Sampled patterns win.
+Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) settle *where* a snippet goes, not *how* it is written - sampled patterns win on snippet shape. Note any `// wp:example:` ... `:end` regions in the files you sample; §6a will not insert inside one.
Confirm findings with the developer in one short message. Proceed on confirmation.
@@ -147,7 +147,7 @@ Result shape: `{ scaffold, engine, developer, ai, warnings }`.
For each `{ targetFile, anchor, snippet, description }`:
1. **Snippet** - use canonical if it matches the sampled project pattern; else translate using the sampled shape with the new class substituted. Show both. If patterns conflict or no samples exist, ask.
-2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction.
+2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction. Never land between a `// wp:example:` opener and its `:end`, anchors included: `wp-tooling init` deletes those regions body and all when the capability is dropped, taking the wired class with them. Fall back to the last occurrence outside every region and say so.
3. **Consent** - show targetFile + line range + description + rendered snippet. Ask `[apply / different location / edit snippet / skip]`. Never apply without consent.
4. **Idempotent** - search first, do not re-insert.
@@ -211,6 +211,7 @@ Escalation report format: **what you tried, what you observed, what's blocking,
- Never edit branch protection, repo settings, webhooks, or any GitHub admin surface.
- Never commit, push, open PRs, or comment on issues without explicit consent.
- Never apply wiring without showing the diff and getting consent.
+- Never place a wiring snippet inside a `// wp:example:` ... `:end` region.
- Never invent a third registration pattern when canonical and sampled disagree - ask.
- Never restore scaffold anchor comments without explicit consent.
- Never modify `composer.json`, `package.json`, or any lockfile beyond what the engine wrote.
diff --git a/node-packages/wp-tooling/skills/scaffold/evals/evals.json b/node-packages/wp-tooling/skills/scaffold/evals/evals.json
index f3d6cf4..4a89d5d 100644
--- a/node-packages/wp-tooling/skills/scaffold/evals/evals.json
+++ b/node-packages/wp-tooling/skills/scaffold/evals/evals.json
@@ -91,6 +91,22 @@
"The wiring snippet targets the Services module on the // scaffold:wp/registrable:classes anchor.",
"The skill does NOT run composer install, composer require or npm install."
]
+ },
+ {
+ "id": 7,
+ "prompt": "Add a service that warms the homepage cache on publish. Register it the way the other services are registered.",
+ "expected_output": "wp/registrable scaffolds the class and its PHPUnit test under the project's Services namespace. The wiring lands in Main::CLASSES, but NOT between a // wp:example: marker and its :end -- those regions are deleted body and all when the capability is dropped, which would silently take the new class with them. The skill inserts after the last entry that sits outside every region and says which line it chose and why.",
+ "files": [],
+ "expectations": [
+ "The skill picks the wp/registrable scaffold rather than hand-writing a Registrable class.",
+ "The wiring snippet is NOT inserted between a // wp:example: line and its matching // wp:example::end line, even though the last existing entry in the array sits inside one.",
+ "The chosen insertion point is after the last entry that is outside every wp:example region -- for Main::CLASSES that is the last line of the always-loaded core block.",
+ "The skill explains the placement, naming the example-region hazard rather than silently picking a different line than the developer would expect.",
+ "Consent is requested before the wiring edit is applied, showing the target file, the line, and the snippet.",
+ "The class lands under the project's Services namespace and base path, read from the PSR-4 roots.",
+ "The generated test is expanded rather than left as the stub, and no markTestIncomplete or markTestSkipped survives.",
+ "The skill does NOT run composer install, composer require or npm install."
+ ]
}
]
}
diff --git a/node-packages/wp-tooling/skills/setup/SKILL.md b/node-packages/wp-tooling/skills/setup/SKILL.md
index 28f7453..a230cc0 100644
--- a/node-packages/wp-tooling/skills/setup/SKILL.md
+++ b/node-packages/wp-tooling/skills/setup/SKILL.md
@@ -61,8 +61,9 @@ grep -rl "Plugin Name:\|Theme Name:" . --include="*.php" --exclude-dir=vendor --
**VIP indicators:**
```bash
-grep -rl "WordPress-VIP-Minimum\|automattic/vip-coding-standards\|VIP_GO_ENV" . \
- --include="*.{php,xml,json,yml}" --exclude-dir=vendor --exclude-dir=node_modules | head -3
+grep -rlE "WordPressVIPMinimum|WordPress-VIP-Go|automattic/vipwpcs|VIP_GO_APP_ENVIRONMENT|VIP_GO_ENV" . \
+ --include="*.php" --include="*.xml" --include="*.dist" --include="*.json" --include="*.yml" \
+ --exclude-dir=vendor --exclude-dir=node_modules | head -3
```
**Languages present:**
@@ -106,7 +107,7 @@ Construct the plan in two phases: **project setup** and **feature scaffolds**.
| PHP present, no PSR-4 in `composer.json` | `setup/psr4` | `autoload.psr-4` already set |
| VIP project | `lint/phpcs/vip` | `phpcs.xml.dist` exists |
| Non-VIP project | `lint/phpcs/full` | `phpcs.xml.dist` exists |
-| Developer explicitly chose core-only PHPCS | `lint/phpcs/core` | `phpcs.xml.dist` exists |
+| Non-VIP project whose existing PHP does not declare strict types | `lint/phpcs/core` | `phpcs.xml.dist` exists |
| PHP present | `lint/phpstan` | `phpstan.neon.dist` exists |
| JS present | `lint/eslint` | `eslint.config.js` exists |
| CSS or SCSS present | `lint/stylelint` | `.stylelintrc.js` exists |
@@ -132,7 +133,7 @@ Map each feature the developer mentioned to one or more scaffold IDs from the ca
| CI pipeline | `ci/cd-wporg` (or other CI scaffold) |
The `utility/*` scaffolds are `source: package`: they write no files and return the
-`rtcamp/wp-framework` dependency plus one accessor snippet for `/Helpers/Util.php`.
+`rtcamp/wp-primitives` dependency plus one accessor snippet for `/Helpers/Util.php`.
Check whether the project already exposes that accessor before applying the wiring — pasting in a
method name that already exists in `Helpers\Util` is a PHP fatal error (cannot redeclare method).
@@ -150,7 +151,7 @@ Here is what I will do. Please confirm or adjust before I start.
Phase A — Project setup:
1. setup/editorconfig → .editorconfig
2. setup/psr4 → wiring in composer.json (namespace: Acme\ImageOptimizer, path: includes/)
- 3. lint/phpcs/vip → phpcs.xml.dist (WordPress-VIP-Minimum + WordPress-Docs)
+ 3. lint/phpcs/vip → phpcs.xml.dist (WordPressVIPMinimum + WordPress-Docs)
4. lint/phpstan → phpstan.neon.dist (extends rtCamp wp-phpstan baseline)
5. lint/eslint → eslint.config.js
6. setup/phpunit → phpunit.xml.dist, tests/bootstrap.php
@@ -200,7 +201,7 @@ npx wp-tooling add setup/pa11y --non-interactive --json --cwd . --base-url=h
Process each result before running the next:
- Report files written and skipped.
-- Apply `setup/psr4` wiring to `composer.json` with explicit consent (see §Wiring below).
+- Apply `setup/psr4` and `lint/phpcs/*` wiring to `composer.json` with explicit consent (see §Wiring below).
- Accumulate `developer.install.*` and `developer.scripts.*` across all scaffolds.
### 5. Execute Phase B
@@ -262,6 +263,15 @@ composer.json not found. I can create a minimal one:
Create it? [yes / skip PSR-4 / give me the values to use]
```
+### Wiring: composer.json for PHPCS
+
+The `lint/phpcs/*` wiring entries must be in `composer.json` before the developer runs `composer require`, or the install fails:
+
+1. `repositories` (`full` and `core` only) — a `vcs` entry for `https://github.com/rtCamp/wp-phpcs.git`, since `rtcamp/wp-phpcs` is not on Packagist. Append it to an existing array; skip it if that URL is already listed. `vip` needs none: `automattic/vipwpcs` is on Packagist.
+2. `config.allow-plugins` (all three) — `dealerdirect/phpcodesniffer-composer-installer: true`, the plugin that registers the standards. Merge it into an existing `config` block.
+
+Show the changes together, ask once, and merge rather than replace existing keys.
+
### Wiring: feature scaffolds
Same as `skills/scaffold.md` §5 — `ai.wiring`. Show diff, get consent, apply.
@@ -295,7 +305,7 @@ Developer actions (run these yourself):
composer dump-autoload --optimize
composer require --dev \
- automattic/vip-coding-standards:^3.0 \
+ automattic/vipwpcs:^3.0 \
wp-coding-standards/wpcs:^3.0 \
squizlabs/php_codesniffer:^3.7 \
phpunit/phpunit:^12.0 \
@@ -335,11 +345,11 @@ Deduplicate packages. Sort alphabetically within each block. Pinned packages use
| Scaffold ID | When to use | Ruleset |
|---|---|---|
-| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `vendor/rtcamp/wp-framework/phpcs.xml.dist`, WordPress-Core + Extra + Docs + VIP-Go |
-| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPress-VIP-Minimum` + `WordPress-Docs` |
-| `lint/phpcs/core` | Projects explicitly opting out of VIP-Go rules | `WordPress` — Core + Extra + Docs only |
+| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `rtCampWP` from `rtcamp/wp-phpcs`: WordPress-Extra + Docs + VIP-Go + PHPCompatibilityWP, plus strict types, type hints and short arrays |
+| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPressVIPMinimum` + `WordPress-Docs` |
+| `lint/phpcs/core` | Existing code not yet ready for strict types | `rtCampWP-Basic` from `rtcamp/wp-phpcs`: the same rules without the strict layer |
-Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard.
+`full` and `core` read `testVersion` and `minimum_wp_version` from the `Requires PHP` and `Requires at least` headers, the text domain from `Text Domain`, and the namespace prefix from `composer.json` PSR-4 autoload. Cite the values in the plan. Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard.
## Test scaffolds reference
diff --git a/node-packages/wp-tooling/src/cli/commands/release-zip.js b/node-packages/wp-tooling/src/cli/commands/release-zip.js
index 0a93274..4ff1ff7 100644
--- a/node-packages/wp-tooling/src/cli/commands/release-zip.js
+++ b/node-packages/wp-tooling/src/cli/commands/release-zip.js
@@ -10,7 +10,7 @@
'use strict';
-const { spinner, CancelledError } = require('../../ui');
+const { spinner, warn, CancelledError } = require('../../ui');
const { zip } = require('../../release/zip');
function parseArgs(argv) {
@@ -95,6 +95,9 @@ function runCli(argv) {
s.succeed(
`${verb} ${result.outputPath} (${result.fileCount} files, ${formatSize(result.byteSize)})`
);
+ for (const warning of result.warnings || []) {
+ warn(warning);
+ }
return 0;
} catch (err) {
s.fail('Zip build failed');
diff --git a/node-packages/wp-tooling/src/release/context.js b/node-packages/wp-tooling/src/release/context.js
index 9b7b908..9730002 100644
--- a/node-packages/wp-tooling/src/release/context.js
+++ b/node-packages/wp-tooling/src/release/context.js
@@ -5,10 +5,11 @@
*
* - `package.json` (required)
* - `composer.json` (optional)
- * - The WordPress plugin entry file (a `*.php` at the package root
- * carrying a `Plugin Name:` header)
+ * - The WordPress entry file: a `*.php` at the package root carrying a
+ * `Plugin Name:` header, or a theme's `style.css` carrying `Theme Name:`
* - The current version (taken from `package.json`)
- * - The plugin slug (basename of the entry file, used for the zip name)
+ * - The slug used for the zip name — the entry file's basename for a plugin,
+ * the directory name for a theme, which is what WordPress keys a theme on
*
* Lives as a separate module so bump / changelog / zip don't each re-walk
* the directory or re-parse the same headers.
@@ -29,16 +30,22 @@ const path = require('path');
const HEADER_SCAN_BYTES = 8192;
/**
- * Walk `cwd` (root only -- no recursion) looking for a `.php` file whose
- * leading bytes contain a `Plugin Name:` header. WordPress' own
- * convention is exactly one such file per plugin; this helper enforces
- * that, throwing a clear error if zero or more than one is found.
+ * Find the project's WordPress entry file.
*
- * @param {string} cwd Project root.
- * @return {string} Absolute path to the entry file.
- * @throws {Error} When no entry file, or more than one, exists at `cwd`.
+ * Walks `cwd` (root only -- no recursion) for a `.php` file whose leading bytes
+ * carry a `Plugin Name:` header. WordPress' own convention is exactly one such
+ * file per plugin, so zero or more than one is an error. When none is found,
+ * falls back to a theme's `style.css` with a `Theme Name:` header before giving
+ * up, so the release commands work for themes as well as plugins.
+ *
+ * @param {string} cwd Project root.
+ * @param {Object} [options] Options.
+ * @param {boolean} [options.pluginOnly] Skip the theme fallback and report the
+ * plugin-only error. Used by {@link findPluginEntry}.
+ * @return {{ entry: string, kind: 'plugin'|'theme' }} Entry path and what it is.
+ * @throws {Error} When no matching entry exists at `cwd`.
*/
-function findPluginEntry(cwd) {
+function findEntry(cwd, { pluginOnly = false } = {}) {
let entries;
try {
entries = fs.readdirSync(cwd, { withFileTypes: true });
@@ -60,11 +67,6 @@ function findPluginEntry(cwd) {
}
}
- if (matches.length === 0) {
- throw new Error(
- `release: no plugin entry file found at "${cwd}" (expected a *.php with a "Plugin Name:" header)`
- );
- }
if (matches.length > 1) {
throw new Error(
`release: multiple plugin entry files found at "${cwd}": ${matches
@@ -72,15 +74,49 @@ function findPluginEntry(cwd) {
.join(', ')}`
);
}
- return matches[0];
+ if (matches.length === 1) {
+ return { entry: matches[0], kind: 'plugin' };
+ }
+
+ if (pluginOnly) {
+ throw new Error(
+ `release: no plugin entry file found at "${cwd}" (expected a *.php with a "Plugin Name:" header)`
+ );
+ }
+
+ const styleCss = path.join(cwd, 'style.css');
+ if (
+ fs.existsSync(styleCss) &&
+ /^[ \t*/#]*\s*Theme Name:\s*\S/m.test(readHeader(styleCss))
+ ) {
+ return { entry: styleCss, kind: 'theme' };
+ }
+
+ throw new Error(
+ `release: no plugin or theme entry file found at "${cwd}" (expected a *.php with a "Plugin Name:" header, or a style.css with a "Theme Name:" header)`
+ );
+}
+
+/**
+ * The plugin-only half of {@link findEntry}, kept as the original public API.
+ *
+ * Returns the entry path as a string and never falls back to a theme, so
+ * callers that predate theme support keep their exact contract.
+ *
+ * @param {string} cwd Project root.
+ * @return {string} Absolute path to the plugin entry file.
+ * @throws {Error} When no plugin entry file, or more than one, exists at `cwd`.
+ */
+function findPluginEntry(cwd) {
+ return findEntry(cwd, { pluginOnly: true }).entry;
}
/**
* Read project state for the release CLIs.
*
* @param {string} cwd Project root (typically `process.cwd()`).
- * @return {Object} Project state with packageJson, packageJsonPath, composerJson, composerJsonPath, pluginEntry, pluginSlug and currentVersion.
- * @throws {Error} When `package.json` is missing, malformed, lacks a `version`, or the plugin entry file cannot be found.
+ * @return {Object} Project state with packageJson, packageJsonPath, composerJson, composerJsonPath, pluginEntry, pluginSlug, kind and currentVersion.
+ * @throws {Error} When `package.json` is missing, malformed, lacks a `version`, or no plugin/theme entry file can be found.
*/
function loadContext(cwd) {
const packageJsonPath = path.join(cwd, 'package.json');
@@ -117,8 +153,13 @@ function loadContext(cwd) {
}
}
- const pluginEntry = findPluginEntry(cwd);
- const pluginSlug = path.basename(pluginEntry, '.php');
+ const { entry: pluginEntry, kind } = findEntry(cwd);
+ // A theme has no entry-file basename to key on -- WordPress identifies it by
+ // its directory -- so the slug comes from the folder instead.
+ const pluginSlug =
+ kind === 'theme'
+ ? path.basename(path.resolve(cwd))
+ : path.basename(pluginEntry, '.php');
return {
packageJson,
@@ -127,6 +168,7 @@ function loadContext(cwd) {
composerJsonPath: composerJson ? composerJsonPath : null,
pluginEntry,
pluginSlug,
+ kind,
currentVersion: packageJson.version,
};
}
@@ -156,5 +198,6 @@ function readHeader(file) {
module.exports = {
loadContext,
+ findEntry,
findPluginEntry,
};
diff --git a/node-packages/wp-tooling/src/release/index.js b/node-packages/wp-tooling/src/release/index.js
index ef8fafc..1a80159 100644
--- a/node-packages/wp-tooling/src/release/index.js
+++ b/node-packages/wp-tooling/src/release/index.js
@@ -10,12 +10,13 @@
const { bump } = require('./bump');
const { changelog } = require('./changelog');
const { zip } = require('./zip');
-const { loadContext, findPluginEntry } = require('./context');
+const { loadContext, findEntry, findPluginEntry } = require('./context');
module.exports = {
bump,
changelog,
zip,
loadContext,
+ findEntry,
findPluginEntry,
};
diff --git a/node-packages/wp-tooling/src/release/zip.js b/node-packages/wp-tooling/src/release/zip.js
index 0a1fa01..40e6e2a 100644
--- a/node-packages/wp-tooling/src/release/zip.js
+++ b/node-packages/wp-tooling/src/release/zip.js
@@ -52,6 +52,67 @@ const DEFAULT_IGNORE_PATTERNS = [
const ALWAYS_EXCLUDE = new Set(['.git', 'dist']);
+// Uncompressed payload above which a build is almost certainly shipping
+// something it should not. A production plugin -- code, assets, and the
+// no-dev vendor tree -- lands well under this; a vendor/ with dev requirements
+// in it (PHPUnit, PHP_CodeSniffer, the WordPress stubs) lands orders of
+// magnitude over. WordPress.org itself refuses uploads far smaller than this.
+const SIZE_WARN_BYTES = 25 * 1024 * 1024;
+
+/**
+ * Flag a payload that is too big to be a real release.
+ *
+ * `vendor/` is deliberately not in DEFAULT_IGNORE_PATTERNS: a plugin that
+ * autoloads its Composer dependencies has to ship it, so excluding it by
+ * default would quietly produce a broken plugin. The failure worth catching is
+ * the opposite one -- shipping a vendor tree that still has dev requirements
+ * installed -- and that shows up as size, which is what this reports.
+ *
+ * @param {Array<{relPath: string, size: number}>} files Files that would ship.
+ * @return {string[]} Warnings, empty when the payload is a sane size.
+ */
+function sizeWarnings(files) {
+ const total = files.reduce((sum, f) => sum + (f.size || 0), 0);
+ if (total <= SIZE_WARN_BYTES) {
+ return [];
+ }
+
+ // Attribute the bulk to top-level directories, which is the granularity a
+ // .distignore entry works at.
+ const byTop = new Map();
+ for (const f of files) {
+ const top = f.relPath.split('/')[0];
+ byTop.set(top, (byTop.get(top) || 0) + (f.size || 0));
+ }
+ const biggest = [...byTop.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 3)
+ .map(([name, bytes]) => `${name} (${formatBytes(bytes)})`)
+ .join(', ');
+
+ return [
+ `archive payload is ${formatBytes(total)} across ${files.length} files, which is far larger than a release should be. Largest: ${biggest}.`,
+ 'If vendor/ is listed above, run `composer install --no-dev` before building, or exclude what you do not ship in .distignore.',
+ ];
+}
+
+/**
+ * Render a byte count for a human.
+ *
+ * @param {number} bytes Byte count.
+ * @return {string} e.g. `2.3 GB`.
+ */
+function formatBytes(bytes) {
+ const units = ['B', 'KB', 'MB', 'GB'];
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
+}
+
/* -------------------------------------------------------------------------- */
/* CRC-32 */
/* -------------------------------------------------------------------------- */
@@ -344,12 +405,15 @@ function walkProject(root, patterns) {
continue;
}
let executable = false;
+ let size = 0;
try {
- executable = (fs.statSync(absPath).mode & 0o111) !== 0;
+ const st = fs.statSync(absPath);
+ executable = (st.mode & 0o111) !== 0;
+ size = st.size;
} catch {
executable = false;
}
- out.push({ relPath, absPath, executable });
+ out.push({ relPath, absPath, executable, size });
}
}
}
@@ -397,7 +461,7 @@ function resolveEpoch(cwd, options) {
* Build the zip plan (list of entries) without writing anything.
*
* @param {{ cwd?: string, epoch?: number }} options Plan options.
- * @return {Object} Plan describing slug, version, epoch, files, output path.
+ * @return {Object} Plan describing slug, version, epoch, files, output path, warnings.
*/
function plan(options = {}) {
const cwd = options.cwd || process.cwd();
@@ -417,6 +481,7 @@ function plan(options = {}) {
files,
excludedSample: [],
outputPath,
+ warnings: sizeWarnings(files),
};
}
@@ -424,7 +489,7 @@ function plan(options = {}) {
* Build and write the zip.
*
* @param {{ cwd?: string, force?: boolean, dryRun?: boolean, epoch?: number }} options Run options.
- * @return {Object} Summary with outputPath, slug, version, fileCount, byteSize, dryRun.
+ * @return {Object} Summary with outputPath, slug, version, fileCount, byteSize, warnings, dryRun.
*/
function zip(options = {}) {
const cwd = options.cwd || process.cwd();
@@ -458,6 +523,7 @@ function zip(options = {}) {
version: p.version,
fileCount: entries.length,
byteSize: archive.length,
+ warnings: p.warnings,
dryRun,
};
}
diff --git a/node-packages/wp-tooling/src/scaffolds/php-imports.js b/node-packages/wp-tooling/src/scaffolds/php-imports.js
new file mode 100644
index 0000000..20a9aa2
--- /dev/null
+++ b/node-packages/wp-tooling/src/scaffolds/php-imports.js
@@ -0,0 +1,156 @@
+'use strict';
+
+/**
+ * Sort the `use` block of rendered PHP.
+ *
+ * A Mustache template cannot emit a correctly ordered import block, because the
+ * order depends on a value only known at render time: the consuming project's
+ * own namespace. `use {{namespace}}\{{class}};` sorts before `use WP_UnitTestCase;`
+ * for a project namespaced `Inc\…` and after it for one namespaced `rtCamp\…`,
+ * and no fixed ordering in the template satisfies both. Sorting here — once the
+ * placeholders are filled — is the only place the right answer exists.
+ *
+ * The ordering reproduces `SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses`
+ * with `caseSensitive="true"`, which is what the `rtCampWP` standard enables:
+ * names are compared **segment by segment** (split on `\`) with a byte-wise
+ * comparison, and a prefix sorts before a longer name that extends it. Comparing
+ * whole strings instead would disagree wherever `\` (0x5C) falls between the
+ * bytes being compared.
+ *
+ * Deliberately conservative. This is line matching, not PHP parsing: the block
+ * is rewritten only when every line of it is a plain single-class import, and
+ * anything else — a group import, an alias, a `use function`/`use const`, a
+ * statement split over lines, a comment in the middle, a second block later in
+ * the file — leaves the source untouched rather than risking a bad edit. Every
+ * template in the catalogue emits the simple shape, so the bail-outs exist for
+ * templates that do not exist yet.
+ *
+ * Trait imports inside a class body are indented and never match, which is why
+ * the pattern is anchored to column zero.
+ */
+
+// One plain class import: `use Fully\Qualified\Name;` at column zero. A single
+// space after `use` and no trailing content, matching what every template emits
+// and what the sniff leaves alone.
+const USE_LINE_RE =
+ /^use ([A-Za-z_\x80-][A-Za-z0-9_\x80-]*(?:\\[A-Za-z_\x80-][A-Za-z0-9_\x80-]*)*);$/;
+
+// Any top-level `use` at all, including the shapes this module refuses to touch.
+// Used to detect an import the block scan did not cover, which forces a bail-out.
+const ANY_TOP_LEVEL_USE_RE = /^use\b/;
+
+/**
+ * Byte-wise string comparison, the semantics of PHP's `strcmp()`.
+ *
+ * Comparing JS strings with `<` uses UTF-16 code units, which agrees with a byte
+ * comparison for ASCII but not beyond it. Namespaces are ASCII in practice;
+ * going through Buffer keeps the two in step regardless.
+ *
+ * @param {string} a - Left operand.
+ * @param {string} b - Right operand.
+ * @return {number} Negative, zero or positive.
+ */
+function strcmp(a, b) {
+ return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
+}
+
+/**
+ * Order two fully-qualified names the way the sniff does.
+ *
+ * @param {string} a - Left fully-qualified name, no leading separator.
+ * @param {string} b - Right fully-qualified name, no leading separator.
+ * @return {number} Negative, zero or positive.
+ */
+function compareImports(a, b) {
+ const aParts = a.split('\\');
+ const bParts = b.split('\\');
+ const shared = Math.min(aParts.length, bParts.length);
+
+ for (let i = 0; i < shared; i++) {
+ const cmp = strcmp(aParts[i], bParts[i]);
+ if (cmp !== 0) {
+ return cmp;
+ }
+ }
+
+ // Every shared segment matched, so the shorter name is the prefix of the
+ // longer one and sorts first.
+ return aParts.length - bParts.length;
+}
+
+/**
+ * Return `source` with its import block sorted, or unchanged if it cannot be
+ * rewritten safely.
+ *
+ * @param {string} source - Rendered PHP source.
+ * @return {string} The source, with the import block ordered.
+ */
+function sortUseBlock(source) {
+ if (typeof source !== 'string' || !source.includes('\nuse ')) {
+ return source;
+ }
+
+ const lines = source.split('\n');
+
+ const start = lines.findIndex((line) => USE_LINE_RE.test(line));
+ if (start === -1) {
+ return source;
+ }
+
+ let end = start;
+ while (end + 1 < lines.length && USE_LINE_RE.test(lines[end + 1])) {
+ end++;
+ }
+
+ // A lone import is already sorted; rewriting would only risk churn.
+ if (end === start) {
+ return source;
+ }
+
+ // An import outside the run means the file has a shape this module does not
+ // model — a second block, or one of the forms USE_LINE_RE rejects. Leave it.
+ for (let i = 0; i < lines.length; i++) {
+ if (i >= start && i <= end) {
+ continue;
+ }
+ if (ANY_TOP_LEVEL_USE_RE.test(lines[i])) {
+ return source;
+ }
+ }
+
+ const block = lines.slice(start, end + 1);
+ const sorted = [...block].sort((a, b) =>
+ compareImports(a.match(USE_LINE_RE)[1], b.match(USE_LINE_RE)[1])
+ );
+
+ // Already in order: return the original string so callers can rely on
+ // identity when nothing needed doing.
+ if (sorted.every((line, i) => line === block[i])) {
+ return source;
+ }
+
+ lines.splice(start, block.length, ...sorted);
+ return lines.join('\n');
+}
+
+/**
+ * Apply the PHP-only normalisations to one rendered file.
+ *
+ * Non-PHP destinations pass through untouched, so callers can hand every
+ * rendered file to this without checking first.
+ *
+ * @param {string} source - Rendered file contents.
+ * @param {string} destRel - Project-relative destination path.
+ * @return {string} Contents to write.
+ */
+function normalisePhp(source, destRel) {
+ if (
+ typeof destRel !== 'string' ||
+ !destRel.toLowerCase().endsWith('.php')
+ ) {
+ return source;
+ }
+ return sortUseBlock(source);
+}
+
+module.exports = { compareImports, sortUseBlock, normalisePhp };
diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js
index c14ad10..66dc867 100644
--- a/node-packages/wp-tooling/src/scaffolds/registry.js
+++ b/node-packages/wp-tooling/src/scaffolds/registry.js
@@ -45,6 +45,7 @@ const {
validateIndex,
indexEntryToRecord,
} = require('./sources');
+const { normalisePhp } = require('./php-imports');
/**
* Render every value in a scripts map (npm or composer) through Mustache.
@@ -400,7 +401,12 @@ class ScaffoldRegistry {
}
if (!dryRun) {
const tpl = await loadTemplate(file.src);
- const out = file.raw === true ? tpl : render(tpl, resolved);
+ // `raw` files ship verbatim by contract, so they are never
+ // normalised — only rendered output is ours to reformat.
+ const out =
+ file.raw === true
+ ? tpl
+ : normalisePhp(render(tpl, resolved), destRel);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.writeFile(destAbs, out, 'utf8').catch((err) => {
throw new ScaffoldError(
@@ -440,14 +446,34 @@ class ScaffoldRegistry {
baseNestedInModules
? p.replace(/(^|\/)Modules\/Modules\//, '$1Modules/')
: p;
- const aiWiring = (scaffold.wiring || []).map((w) => ({
- targetFile: collapseModules(
+
+ // Normalising collapses interior `..` but keeps a leading one, so a
+ // short enough path input (`--base_path=.`) still resolves outside the
+ // project. Drop those instead of handing the AI a file to edit above
+ // the project root. Warn rather than throw: this runs after the files
+ // are on disk, and aborting here would leave a half-applied scaffold.
+ const aiWiring = [];
+ for (const w of scaffold.wiring || []) {
+ const targetFile = collapseModules(
path.posix.normalize(render(w.target_file, resolved))
- ),
- anchor: w.anchor,
- snippet: render(w.snippet_template, resolved),
- description: w.description || '',
- }));
+ );
+ if (
+ targetFile === '..' ||
+ targetFile.startsWith('../') ||
+ path.posix.isAbsolute(targetFile)
+ ) {
+ warnings.push(
+ `wiring target resolves outside the project, skipped: ${targetFile}`
+ );
+ continue;
+ }
+ aiWiring.push({
+ targetFile,
+ anchor: w.anchor,
+ snippet: render(w.snippet_template, resolved),
+ description: w.description || '',
+ });
+ }
// Declarative tests (lint-only entries like actionlint on the YAML the
// files-loop just wrote) reuse a files[].dest. Track those so we don't
@@ -467,7 +493,7 @@ class ScaffoldRegistry {
);
} else if (!dryRun) {
const tpl = await loadTemplate(t.src);
- const out = render(tpl, resolved);
+ const out = normalisePhp(render(tpl, resolved), destRel);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.writeFile(destAbs, out, 'utf8').catch((err) => {
throw new ScaffoldError(
@@ -848,14 +874,98 @@ async function hydrateRemote(record, fetchOpts) {
};
}
+// WordPress reads a plugin/theme header out of the first 8 KB of the entry
+// file; matching that bound keeps a large file cheap to scan and matches what
+// core would itself see.
+const HEADER_SCAN_BYTES = 8192;
+
+// One `Name: value` line inside a header comment, with the comment furniture
+// (` * `, `#`, `//`) stripped. Header names are letters and single spaces, which
+// is what keeps this from matching arbitrary colons in code.
+const HEADER_LINE_RE =
+ /^[ \t/*#]*([A-Za-z][A-Za-z ]*[A-Za-z]):[ \t]*(\S.*?)[ \t]*$/gm;
+
+/**
+ * Read the WordPress header block of a project's entry file.
+ *
+ * Looks for a plugin first — any root-level `*.php` carrying a `Plugin Name:`
+ * header — then falls back to a theme's `style.css` with `Theme Name:`. Keys are
+ * the header name lowercased with spaces hyphenated, so `Text Domain:` is read
+ * as `text-domain`, which is how a manifest names it.
+ *
+ * Only the header comment is scanned: parsing stops at its closing `*\/`, so a
+ * `Foo: bar` in the code below can never be mistaken for a header.
+ *
+ * @param {string} cwd - Target project directory.
+ * @return {Promise