Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.19.0
6 changes: 6 additions & 0 deletions node-packages/eslint-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/eslint-config` are documented in this file.

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

## [Unreleased]

### Changed

- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13).

## [1.0.0] - 2026-07-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion node-packages/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"access": "restricted"
},
"engines": {
"node": ">=22"
"node": ">=22.19"
},
"main": "index.js",
"files": [
Expand Down
6 changes: 6 additions & 0 deletions node-packages/stylelint-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/stylelint-config` are documented in this file.

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

## [Unreleased]

### Changed

- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13).

## [1.0.0] - 2026-07-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion node-packages/stylelint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"access": "restricted"
},
"engines": {
"node": ">=22"
"node": ">=22.19"
},
"main": "index.js",
"files": [
Expand Down
6 changes: 6 additions & 0 deletions node-packages/tailwind-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/tailwind-config` are documented in this file.

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

## [Unreleased]

### Changed

- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13).

## [1.0.0] - 2026-07-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion node-packages/tailwind-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"access": "restricted"
},
"engines": {
"node": ">=22"
"node": ">=22.19"
},
"main": "index.js",
"exports": {
Expand Down
3 changes: 2 additions & 1 deletion node-packages/wp-tooling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `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`.
- `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 <path>` 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 <path>` (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`.
- `setup/perf` scaffold — renders `.perfrc.json` (project-owned URL slots: `sample_page`/`search_page`/optional `extra_page`, same pattern as `setup/pa11y`) and ships a hardened `server-profile.php` shim (`raw: true`, copied verbatim) plus `web-vitals`/`lighthouse`/`puppeteer` dev-dependency pins and `test:perf` / `profile:server` npm scripts. The shim removes `template_redirect`'s `redirect_canonical` before rendering (a canonical redirect ends the request with `exit()`, which bypasses `finally` and would otherwise kill the process before the profiler stops or the JSON is echoed), profiles with `start()`/`stop()` plus a `register_shutdown_function` fallback that drains open output buffers, copies the target's query string into `$_GET` before `wp()` (`WP::parse_request()` reads `$_GET`, not `REQUEST_URI`), and emits a STDERR route + backend diagnostic. Consumes `rtCamp\WPDevTools\Support\XHProfProfiler` (require-dev `rtcamp/wp-dev-tools`) — never reimplements xhprof; degrades to `[]` + a STDERR note when the class isn't installed.

## [1.0.0] - 2026-07-30

Expand Down
1 change: 1 addition & 0 deletions node-packages/wp-tooling/docs/authoring-scaffolds.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ 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)
- `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).

Expand Down
3 changes: 2 additions & 1 deletion node-packages/wp-tooling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"jest": "^29.7.0"
},
"engines": {
"node": ">=22"
"node": ">=22.19"
},
"exports": {
"./ui": "./src/ui/index.js",
Expand All @@ -28,6 +28,7 @@
"./hooks": "./src/hooks/index.js",
"./ci": "./src/ci/index.js",
"./a11y": "./src/a11y/index.js",
"./perf": "./src/perf/index.js",
"./version-monitor": "./src/version-monitor/index.js"
},
"files": [
Expand Down
77 changes: 77 additions & 0 deletions node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"slug": "perf",
"category": "setup",
"name": "perf (web-vitals + lighthouse + server xhprof)",
"description": "Adds .perfrc.json and server-profile.php for two-layer performance testing against a running WordPress environment: lab Core Web Vitals (web-vitals attribution build under headless Chromium) + Lighthouse performance scores, and optional server-side xhprof function profiling via WP-CLI. The server layer needs `composer require --dev rtcamp/wp-dev-tools` (not on Packagist — add it via a path or VCS repository) and the xhprof or tideways_xhprof PHP extension in the WP-CLI environment; without either it degrades gracefully rather than erroring. The server command defaults to wp-env; edit `server.command` in .perfrc.json if your WordPress environment runs WP-CLI some other way (Local, DDEV, a remote SSH host, etc.).",
"source": "template",
"files": [
{
"src": "templates/.perfrc.json.mustache",
"dest": ".perfrc.json"
},
{
"src": "templates/server-profile.php",
"dest": "server-profile.php",
"raw": true
}
],
"inputs": [
{
"key": "base_url",
"description": "Base URL of the WordPress environment to test against (e.g. http://localhost:8888).",
"required": true,
"transform": "json-escape"
},
{
"key": "sample_page",
"description": "Path of a post or page to test, appended to base_url (e.g. /hello-world/ or a permalink path).",
"default": "/?p=1",
"transform": "json-escape"
},
{
"key": "search_page",
"description": "Path of the search-results page to test, appended to base_url.",
"default": "/?s=hello",
"transform": "json-escape"
},
{
"key": "extra_page",
"description": "Optional path of one more page to test, appended to base_url. Omitted when empty; add further URLs directly in .perfrc.json.",
"default": "",
"transform": "json-escape"
},
{
"key": "server_enabled",
"description": "Enable the server-side xhprof layer (needs rtcamp/wp-dev-tools and the xhprof/tideways_xhprof PHP extension in the WP-CLI environment). One of true/false/yes/no.",
"default": "false"
},
{
"key": "server_env_cwd",
"description": "Project path inside the WP-CLI environment, as `wp-env run cli --env-cwd` expects it. Must match where this scaffold just installed server-profile.php — e.g. wp-content/plugins/my-plugin for a plugin, wp-content/themes/my-theme for a theme — or `.` only if this project IS the WordPress root. There's no safe default: guessing wrong means the server layer can't find its own shim.",
"required": true
Comment thread
Copilot marked this conversation as resolved.
},
{
"key": "server_env_cwd_json",
"description": "server_env_cwd, JSON-escaped for .perfrc.json.",
"discover_from": "input:server_env_cwd",
"transform": "json-escape"
},
{
"key": "server_env_cwd_shell",
"description": "server_env_cwd, shell-quoted for the profile:server script.",
"discover_from": "input:server_env_cwd",
"transform": "shell-escape"
}
],
"npm_dev_dependencies": {
"web-vitals": "^5.3.0",
"lighthouse": "^13.4.0",
Comment thread
Adi-ty marked this conversation as resolved.
"puppeteer": "^25.3.0"
Comment thread
Adi-ty marked this conversation as resolved.
},
"scripts": {
"npm": {
"test:perf": "wp-tooling perf",
"profile:server": "wp-env run cli --env-cwd={{server_env_cwd_shell}} -- wp eval-file server-profile.php"
Comment thread
Adi-ty marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"urls": [
"{{base_url}}/",
"{{base_url}}{{sample_page}}",
"{{base_url}}{{search_page}}"{{#extra_page}},
"{{base_url}}{{extra_page}}"{{/extra_page}}
],
"server": {
"enabled": {{#server_enabled}}true{{/server_enabled}}{{^server_enabled}}false{{/server_enabled}},
"command": ["npx", "--no-install", "wp-env", "run", "cli", "--env-cwd={{server_env_cwd_json}}", "--", "wp"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php
/**
* Server-side XHProf profile of a front-end render path, for `wp eval-file`.
*
* Usage:
* npm run profile:server -- [<path>] [<top>]
* # or directly:
* wp eval-file server-profile.php [<path>] [<top>] [--url=<url>]
*
* Profiles the WordPress render path for <path> (default "/") with
* rtCamp\WPDevTools\Support\XHProfProfiler and prints the top-<top>
* (default 15) functions by wall time as JSON: { "fn": {ct,wt,cpu,mu,pmu} }.
* Prints [] when no xhprof/tideways_xhprof backend is loaded, or when
* rtcamp/wp-dev-tools is not installed (`composer require --dev
* rtcamp/wp-dev-tools`). A route diagnostic goes to STDERR so a
* mis-resolved path — or a missing profiler — is visible next to the data.
* A CLI render approximates but does not equal a web-server request
* (routing/superglobals and opcache warmth differ).
*
* Hardening: redirect_canonical() ends the request with exit(), and exit()
* bypasses finally — so canonical redirects are unhooked up front, profiling
* uses start()/stop() rather than profile(), and a shutdown handler drains
* the output buffer and emits the JSON if some other exit() still terminates
* the render early.
*
* NOTE: no declare(strict_types) here — `wp eval-file` runs the file through
* eval(), where a declare() is no longer the first statement of the script.
*/

if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
exit( 'Run via: wp eval-file server-profile.php [<path>] [<top>]' . PHP_EOL );
}

$server_profile_path = isset( $args[0] ) ? (string) $args[0] : '/';
$server_profile_top = isset( $args[1] ) ? max( 1, (int) $args[1] ) : 15;
$server_profile_backend = function_exists( 'xhprof_enable' )
? 'xhprof'
: ( function_exists( 'tideways_xhprof_enable' ) ? 'tideways' : 'none' );

if ( ! class_exists( \rtCamp\WPDevTools\Support\XHProfProfiler::class ) ) {
echo wp_json_encode( array() ) . PHP_EOL;
fwrite(
STDERR,
sprintf(
'[server-profile] path=%s backend=%s profiler=missing — install rtcamp/wp-dev-tools (composer require --dev rtcamp/wp-dev-tools)%s',
$server_profile_path,
$server_profile_backend,
PHP_EOL
)
);
exit( 0 );
}

$server_profile_profiler = new \rtCamp\WPDevTools\Support\XHProfProfiler();

// A canonical redirect would exit() before stop() runs or the JSON is echoed.
remove_action( 'template_redirect', 'redirect_canonical' );

// Fallback emitter: if the render exit()s anyway, still stop the session and print
// JSON. Open buffers are discarded first — shutdown output would otherwise flush
// behind them and partial render HTML would corrupt the JSON on stdout.
register_shutdown_function(
static function () use ( $server_profile_profiler, $server_profile_top ): void {
if ( ! $server_profile_profiler->is_running() ) {
return;
}

while ( ob_get_level() > 0 ) {
ob_end_clean();
}

echo wp_json_encode( $server_profile_profiler->stop( $server_profile_top, 'server-profile' ) ) . PHP_EOL;
}
);

// Simulate the front-end request inside this CLI process. Query-string args must land
// in $_GET too: WP::parse_request() reads query vars from $_GET, not REQUEST_URI —
// without this, "/?p=123"-style paths silently profile the homepage.
$_SERVER['REQUEST_URI'] = $server_profile_path;
parse_str( (string) wp_parse_url( $server_profile_path, PHP_URL_QUERY ), $_GET );
$_REQUEST = array_merge( $_REQUEST, $_GET );

$server_profile_profiler->start();

// wp()/template-loader.php may open their own nested buffers; unwind to
// the level recorded here rather than assuming only one was opened.
$server_profile_ob_level = ob_get_level();
ob_start();
wp();
if ( ! defined( 'WP_USE_THEMES' ) ) {
define( 'WP_USE_THEMES', true );
}
require ABSPATH . WPINC . '/template-loader.php';
while ( ob_get_level() > $server_profile_ob_level ) {
ob_end_clean();
}

echo wp_json_encode( $server_profile_profiler->stop( $server_profile_top, 'server-profile' ) ) . PHP_EOL;

// Route diagnostic (STDERR): makes a silently mis-routed path -- or a missing
// profiling backend -- visible next to the JSON.
$server_profile_query = $GLOBALS['wp_query'];

$server_profile_route_type = static function ( \WP_Query $query ): string {
if ( $query->is_singular() ) {
return $query->is_page() ? 'page' : 'singular';
}
if ( $query->is_home() ) {
return 'home';
}
if ( $query->is_archive() ) {
return 'archive';
}
if ( $query->is_404() ) {
return '404';
}
return 'other';
};

fwrite(
STDERR,
sprintf(
'[server-profile] path=%s backend=%s resolved=%s object_id=%d%s',
$server_profile_path,
$server_profile_backend,
$server_profile_route_type( $server_profile_query ),
(int) get_queried_object_id(),
PHP_EOL
)
);
1 change: 1 addition & 0 deletions node-packages/wp-tooling/src/a11y/resolve-bin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Resolve the consumer's installed Node CLI, including workspace-hoisted copies.
* Shared by the a11y (pa11y-ci) and perf (lighthouse) runners.
* Launch its package.json bin entry with Node on every platform: npm's .bin
* shims are platform-specific and Windows .cmd files cannot use execFileSync.
* No npx fallback: a cached/global package is not the consumer's dependency.
Expand Down
17 changes: 17 additions & 0 deletions node-packages/wp-tooling/src/cli/commands/perf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* perf subcommand registration.
*
* The dispatcher (`src/cli/index.js`) auto-discovers every `*.js` file in
* this directory. Each module must export `{ name, summary, run }`.
* `run` is required lazily so cold-start cost stays close to a single
* subcommand's footprint.
*/

'use strict';

module.exports = {
name: 'perf',
summary:
'Run web-vitals + Lighthouse (and optional server xhprof) and emit a normalized performance report',
run: (argv) => require('../../perf/run').runCli(argv),
};
Loading
Loading