Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ jobs:
node-version: [22, 24]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}

- run: npm install -g npm@11.9.0
- run: npm install -g npm@12.0.2

- run: npm ci --no-audit

Expand Down Expand Up @@ -60,13 +60,13 @@ jobs:
shard: [1, 2, 3]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 22

- run: npm install -g npm@11.9.0
- run: npm install -g npm@12.0.2

- name: Set up PHP
uses: shivammathur/setup-php@v2
Expand All @@ -88,7 +88,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Set up PHP
uses: shivammathur/setup-php@v2
Expand All @@ -114,16 +114,16 @@ jobs:
needs: [plugin-lint-php]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache-dependency-path: |
package-lock.json
wordpress-plugin/package-lock.json

- run: npm install -g npm@11.9.0
- run: npm install -g npm@12.0.2

- name: Install root npm dependencies
run: npm ci --no-audit
Expand All @@ -141,6 +141,24 @@ jobs:
- name: Run PHPUnit with coverage
run: npm run test:plugin-php:coverage

# PHPUnit only warns when Xdebug is loaded without coverage mode, so
# a run with no coverage at all still exits 0 and produces an empty
# report. Fail here instead of silently reporting 0%.
- name: Assert coverage was collected
run: |
file=wordpress-plugin/coverage/clover.xml
if [ ! -f "$file" ]; then
echo "::error::$file was not produced — the coverage run did not complete."
exit 1
fi
metrics=$(grep -o '<metrics files="[^>]*>' "$file" | tail -1)
covered=$(printf '%s' "$metrics" | grep -o 'coveredstatements="[0-9]*"' | grep -o '[0-9]*')
echo "Covered statements: ${covered:-0}"
if [ "${covered:-0}" -le 0 ]; then
echo "::error::$file reports no covered statements — Xdebug was not in coverage mode. See playground/xdebug-coverage.ini."
exit 1
fi

- name: Convert Clover XML to LCOV
uses: andstor/clover2lcov-action@v1
with:
Expand All @@ -167,13 +185,15 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache-dependency-path: wordpress-plugin/package-lock.json

- run: npm install -g npm@12.0.2

- name: Install plugin npm dependencies
run: npm ci --no-audit
working-directory: wordpress-plugin
Expand Down
42 changes: 30 additions & 12 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: |
package-lock.json
wordpress-plugin/package-lock.json
registry-url: https://registry.npmjs.org

- run: npm install -g npm@12.0.2

- run: npm ci
- run: npm --prefix wordpress-plugin ci

Expand Down Expand Up @@ -55,22 +60,27 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: |
package-lock.json
wordpress-plugin/package-lock.json
registry-url: https://registry.npmjs.org

- run: npm install -g npm@12.0.2

- run: npm ci
- run: npm --prefix wordpress-plugin ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- run: npm publish
- run: npm pack
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: npm-tarball
path: claudaborative-editing-*.tgz
Expand All @@ -82,13 +92,17 @@ jobs:
needs: [publish]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: wordpress-plugin/package-lock.json
cache-dependency-path: |
package-lock.json
wordpress-plugin/package-lock.json

- run: npm install -g npm@12.0.2

- name: Install root npm dependencies
run: npm ci
Expand Down Expand Up @@ -129,13 +143,17 @@ jobs:
contents: write

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: wordpress-plugin/package-lock.json
cache-dependency-path: |
package-lock.json
wordpress-plugin/package-lock.json

- run: npm install -g npm@12.0.2

- name: Install root npm dependencies
run: npm ci
Expand Down Expand Up @@ -174,7 +192,7 @@ jobs:
echo "$NOTES" > release-notes.txt
cat release-notes.txt

- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
name: npm-tarball

Expand Down
48 changes: 44 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,33 @@ npm run lint # ESLint + stylelint + markdownlint + Prettier + knip check
npm run lint:fix # Auto-fix all lint and formatting issues
npm run knip # Find unused files, dependencies & exports (knip.jsonc)
npm run dev # Watch mode build
npm run dev:wp # Start a local WordPress on http://127.0.0.1:9400 via wp-playground-cli (ephemeral, Gutenberg latest, collab enabled)
npm run dev:wp # Start a local WordPress on http://127.0.0.1:9400 via wp-playground-cli (ephemeral, Gutenberg latest, RTC experiment enabled)
```

## Package Manager

The repo pins `"packageManager": "npm@12.0.2"` (corepack requires an exact version) and expresses the real floor as a range in `devEngines.packageManager` (`>=12.0.0`). `devEngines` is dev-only — unlike `engines`, it is not applied to people installing the published package. npm 11 and npm 12 build different trees from the same lockfile, so the floor matters.

**`onFail` must stay `warn`, not `error`.** `actions/setup-node` enables npm caching automatically when `devEngines.packageManager` names npm, and implements it by running `npm config get cache` in the workspace — with the runner's _bundled_ npm, before any `npm install -g npm@...` step can run. With `onFail: "error"` that command exits `EBADDEVENGINES` and the whole `setup-node` step fails, taking every npm job with it. This is not specific to a `setup-node` major: any caching setup has to probe npm before the pinned version is installed. A contributor on the wrong npm still gets a loud `npm warn EBADDEVENGINES`, and a lockfile generated by the wrong npm is caught by `npm ci` in CI anyway.

GitHub Actions ships its own bundled npm and `corepack enable` does not override it (Node's bundled `npm` shadows the corepack shim), so every CI job that runs `npm ci` must first `npm install -g npm@12.0.2`. When bumping the pin, update `packageManager`, `devEngines`, and every `npm install -g npm@...` line in `.github/workflows/` together.

`wordpress-plugin` carries its own `devEngines.packageManager` because it is installed with a separate `npm ci` (its own lockfile, its own CI steps) — the root entry does not apply to it.

npm 12 blocks dependency lifecycle scripts by default. Both workspaces record explicit decisions in an `allowScripts` map in `package.json`. Most entries are denied, for one of two reasons: `esbuild`, `fsevents`, `unrs-resolver` and `@parcel/watcher` are fallbacks for packages that ship prebuilt platform binaries as optional dependencies, so the fallback is dead weight; `core-js` and `core-js-pure` only print a funding banner.

**`fs-ext-extra-prebuilt` is approved**, and must stay approved. Despite the name it does not ship a prebuilt for every platform (there is no `prebuilds/` directory) — its `install` script is what fetches or builds the binary. Denying it makes `wp-playground-cli` fail to start with `Failed to load fs-ext native module`, which breaks `npm run dev:wp` and both PHPUnit scripts. It surfaces only on platforms lacking a prebuilt, so CI can stay green while local development is broken.

**The same package also caps the Node version for Playground-backed scripts.** `fs-ext-extra-prebuilt` is a native addon, so its prebuilts are ABI-locked to a Node major, and it currently ships none above **node-25** (check `node_modules/fs-ext-extra-prebuilt/binaries/`). On a newer Node the install script has nothing to fetch and falls back to building from source, which needs `node-gyp`; without it `wp-playground-cli` dies at startup with `No prebuilt binary found for <platform> (Node vNN), and no local build available`. That takes out `npm run dev:wp`, `npm run test:plugin-php` and `npm run test:e2e` (its global setup spawns the same CLI), while unit tests, lint and typecheck are unaffected — so the failure looks unrelated to Node at first glance. Until upstream ships a newer prebuilt, run those three scripts on a Node that has one **and** satisfies `devEngines.runtime` — currently the 22 line from 22.22.2, or the 24 and 25 lines from 24.15.0. Note the two constraints fail in opposite directions, so check both: 24.14.0 has a prebuilt but is below the runtime floor, while 26.x clears the floor and has no prebuilt. CI pins its own Node and is unaffected.

Review new entries with `npm install-scripts ls` and record the decision with `npm install-scripts approve|deny <pkg>`. Two traps when deciding: a blocked script only surfaces when it is actually reached, so re-check after every lockfile refresh (`npm install --package-lock-only` prints the uncovered packages); and **verify a denial against a clean `rm -rf node_modules` install**, because a tree built by npm 11 already contains the artifacts those scripts produce and will mask a denial that actually breaks something.

`engines.node` (`>=22.13`) is the floor for people _installing the published package_; it is deliberately lower than the dev toolchain's floor, which is recorded separately in `devEngines.runtime`. Bump `devEngines.runtime` when a dev dependency raises its own `engines.node` — currently `eslint-plugin-package-json` (`^22.22.2 || >=24.15.0`) sets it.

### Prettier must not skew across workspaces

Root and `wordpress-plugin` have separate lockfiles, so they can resolve different Prettier builds and fight over formatting. `wordpress-plugin` pins `overrides.prettier` to an **exact** version (which also evicts the stale `wp-prettier` fork that `@wordpress/scripts` pulls in). That version must match what the root lockfile resolves — bump both together, and re-run `npm install --package-lock-only` in `wordpress-plugin` afterwards.

## Git Hooks

Pre-commit hook (via husky + lint-staged) auto-formats and lints staged files — Prettier, ESLint, and markdownlint on JS/TS/Markdown/JSON; stylelint on `wordpress-plugin/src/**/*.scss`; and phpcbf + PHPStan on `wordpress-plugin/**/*.php` — then runs `knip` (a full-project unused files/dependencies/exports check). Hooks are installed automatically by `npm install` (husky's `prepare` script).
Expand Down Expand Up @@ -58,8 +82,8 @@ disconnected ──connect──→ connected ──openPost/createPost──→

### Key Design Decisions

- **yjs pinned to 13.6.29**: Must match the version Gutenberg uses. Different versions produce incompatible binary updates.
- **Mixed V1/V2 encoding**: Sync step1/step2 use y-protocols V1 encoding. Regular updates and compactions use V2 encoding. This split exists because Gutenberg switched updates/compactions to V2 (PR #76304) but still uses y-protocols for the sync handshake. Minimum compatible Gutenberg version: 22.8.
- **yjs and lib0 pinned to exact versions** (currently `yjs` 13.6.32, `lib0` 0.2.117): the binary update format must stay compatible with what Gutenberg ships. Gutenberg declares carets (`yjs: ^13.6.29`, `lib0: ^0.2.99`) and currently resolves to yjs 13.6.29 / lib0 0.2.117. Before bumping either, check the diff for changes to the encoder/decoder, struct `read`/`write`, or the sync protocol — patch releases so far have only been behavioural bugfixes, which is why running a slightly newer yjs than Gutenberg is safe.
- **Mixed V1/V2 encoding**: Sync step1/step2 use y-protocols V1 encoding. Regular updates and compactions use V2 encoding. This split exists because Gutenberg switched updates/compactions to V2 (PR #76304) but still uses y-protocols for the sync handshake. Minimum compatible Gutenberg version: 23.8 (the RTC experiment gate; the V1/V2 split itself dates from 22.8).
- **Room format**: `postType/{type}:{id}` (e.g., `postType/post:123`)
- **Block-level editing**: Claude edits at block granularity to preserve CRDT merge semantics. Full-content replacement would lose concurrent edits.
- **Rich-text attributes**: Block attributes with `type === "rich-text"` OR `source === "rich-text"` OR `source === "html"` are stored as `Y.Text`. Others are plain values. Handled by `BlockTypeRegistry`.
Expand All @@ -71,6 +95,8 @@ disconnected ──connect──→ connected ──openPost/createPost──→

Endpoint: `POST /wp-sync/v1/updates`. Each request sends local updates + awareness, receives remote updates + awareness + end_cursor. Update types: `sync_step1`, `sync_step2`, `update`, `compaction`.

**Enablement**: RTC is a Gutenberg experiment since 23.8 (PR #80658): option `gutenberg-experiments['gutenberg-real-time-collaboration']`, admin page `options-general.php?page=experiments-wp-admin` (Settings → Experiments; the old `admin.php?page=gutenberg-experiments` only redirects there), settable by administrators via `POST /wp/v2/settings` (the setting replaces the whole `gutenberg-experiments` object, so GET-merge-POST). When off, `rest_api_init` skips `wp-sync/v1` (our probe gets 404) and Gutenberg emits no `window.__experimentalEnableRealTimeCollaboration`, so `@wordpress/sync` creates no providers and `getSyncManager()` is undefined. Gutenberg's upgrade routine deletes the pre-23.8 options (`wp_collaboration_enabled` etc.) and migrates nothing; WordPress core has never shipped the endpoint. Gutenberg also registers `/wp-sync/v1/save` (CRDT repair, unused by us) and disables collaboration for post types without `custom-fields` support (filter `wp_is_post_type_collaboration_disabled`). The companion plugin reports enablement via `/wpce/v1/status.collaboration` and `wpceInitialState.collaboration`; the MCP's `wp_status` shows the last connect error while disconnected.

### Portable Tool & Prompt Definitions

Tool and prompt definitions are decoupled from the MCP SDK so they can be consumed by both the MCP server and an external hosted orchestrator.
Expand Down Expand Up @@ -109,7 +135,7 @@ Tool and prompt definitions are decoupled from the MCP SDK so they can be consum
- `package.json` has `"files": ["dist"]` — only `dist/` ships in the tarball
- `prepublishOnly` runs typecheck, tests, and build
- GitHub Actions: `.github/workflows/publish.yml` publishes on GitHub release creation
- CI: `.github/workflows/ci.yml` runs on push/PR with Node 20+22 matrix
- CI: `.github/workflows/ci.yml` runs on push/PR with Node 22+24 matrix

## Environment Variables

Expand All @@ -125,6 +151,20 @@ The MCP server reconnects to WordPress automatically on restart using stored cre

The `wordpress-plugin/` directory contains a companion WordPress plugin that adds AI action controls to the Gutenberg editor. It has its own `@wordpress/scripts` build chain.

### `@wordpress/*` version policy

`wp-scripts` marks the runtime `@wordpress/*` packages as webpack externals mapped to the `wp.*` globals WordPress provides, so they are **never bundled**. Their installed versions only drive TypeScript types and Jest — which means installing newer than the target WordPress lets code typecheck green against APIs that do not exist at runtime.

The 14 runtime packages (`api-fetch`, `components`, `compose`, `core-data`, `data`, `editor`, `element`, `hooks`, `i18n`, `icons`, `interface`, `notices`, `plugins`, `sync`) are therefore declared with a caret at the version set that **WordPress 7.1** ships, i.e. Gutenberg 23.6. Find the set for a given WP release via Gutenberg's `docs/contributors/versions-in-wordpress.md`, then read `packages/<name>/package.json` at that Gutenberg tag.

This constrains the **top-level** resolution, which is what the plugin's own `import` statements resolve against — each package stays on WP 7.1's major and takes only later minors. It does **not** give a WP-7.1-exact type graph, and no top-level pinning can: Gutenberg's packages depend on their own siblings with carets, so `core-data@7.51.0` pulls `block-editor` → `dataviews` → `@wordpress/components@40`, and `node_modules` ends up with nested copies several majors ahead. Verified: switching all 14 from caret to exact WP 7.1 pins changes the number of nested `components@40` copies from 19 to 16 — i.e. it is not the caret that causes them. Treat the version policy as governing direct imports only; for anything reached through a re-exported type, check the API against WP 7.1 by hand.

`react`/`react-dom` are pinned to `^18.3.1` because WordPress 7.1 ships React 18.3 — and that pin is now the _only_ thing holding the tree there. `@wordpress/element` 8.3.0 (the WP 7.1 version) carried `react`/`react-dom` as regular dependencies at `^18.3.1`, but later 8.x minors moved them to peer dependencies with a `^18 || ^19` range, so the caret has already drifted past the version that enforced React 18. Relaxing the `react`/`react-dom` pin would silently resolve React 19 against a React 18 runtime. `@types/react`/`@types/react-dom` must stay on the matching React major.

`@wordpress/scripts` and `@wordpress/stylelint-config` are **build** tooling, not runtime externals, so they deliberately run ahead of the WP 7.1 set.

Note that WP 7.1's own `@wordpress/data` 10.51 / `core-data` 7.51 have a typings regression that resolves `select(storeDescriptor)` to `unknown`; it is fixed later in the same major, which the caret picks up. The underlying runtime APIs exist in 7.1 either way.

### Plugin Build & Test

```bash
Expand Down
Loading
Loading