Skip to content

Commit 6eaf3c0

Browse files
committed
Add an AGENTS/CLAUDE file for AI agents to develop with
1 parent ea2d9e9 commit 6eaf3c0

2 files changed

Lines changed: 321 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# AGENTS.md — VIP Block Data API
2+
3+
## Project Overview
4+
5+
VIP Block Data API is a WordPress plugin that converts Gutenberg block editor content into structured JSON data. It provides both a REST API and a WPGraphQL integration. Primarily designed for decoupled/headless WordPress on the WordPress VIP platform. See `vip-block-data-api.php` for the current version.
6+
7+
- **Language:** PHP (8.1+)
8+
- **WordPress:** 6.0+
9+
- **Namespace:** `WPCOMVIP\BlockDataApi`
10+
- **License:** GPL-2.0-or-later per `composer.json` (note: the plugin header in `vip-block-data-api.php` says GPL-3)
11+
- **Repository:** https://github.com/Automattic/vip-block-data-api
12+
13+
## Directory Structure
14+
15+
```
16+
vip-block-data-api.php # Plugin entry point (constants, requires)
17+
src/
18+
parser/
19+
content-parser.php # ContentParser — core parsing engine
20+
block-additions/
21+
core-block.php # CoreBlock — synced pattern / reusable block support
22+
core-image.php # CoreImage — adds width/height to image blocks
23+
rest/
24+
rest-api.php # RestApi — REST endpoint registration
25+
graphql/
26+
graphql-api-v1.php # GraphQLApiV1 — blocksData field (deprecated)
27+
graphql-api-v2.php # GraphQLApiV2 — blocksDataV2 field (current)
28+
analytics/
29+
analytics.php # Analytics — VIP-only usage/error tracking
30+
tests/
31+
bootstrap.php # PHPUnit bootstrap (loads WP test env)
32+
registry-test-case.php # RegistryTestCase base class (auto-cleans block registry)
33+
parser/ # Parser integration tests
34+
sources/ # Per-attribute-source-type tests (11 files)
35+
blocks/ # Per-block-type tests
36+
graphql/ # GraphQL API tests
37+
rest/ # REST API tests
38+
mocks/ # Test mocks (e.g. GraphQLRelay)
39+
data/ # Test fixture files
40+
vendor/ # Composer deps (only production deps committed)
41+
```
42+
43+
## Architecture
44+
45+
### How Block Parsing Works
46+
47+
1. `ContentParser::parse()` fires `vip_block_data_api__before_parse_post_content`, then calls WordPress core `parse_blocks()` on post content
48+
2. `render_parsed_block()` creates a `WP_Block` instance and calls `->render()` to resolve block bindings and synced patterns
49+
3. The block tree is walked recursively via `source_block()`, reading sourced attributes from block HTML using Symfony DomCrawler
50+
4. Supports all Gutenberg attribute source types: `attribute`, `rich-text`, `html`, `text`, `tag`, `raw`, `query`, `meta`, `node`, `children`
51+
5. Returns structured array of `{ name, attributes, innerBlocks? }`
52+
53+
### REST Request Flow
54+
55+
When a request hits `GET /wp-json/vip-block-data-api/v1/posts/{id}/blocks`:
56+
57+
1. **`RestApi::permission_callback()`** — fires `vip_block_data_api__rest_permission_callback` filter
58+
2. **`RestApi::get_block_content()`** — validates post ID (fires `vip_block_data_api__rest_validate_post_id`), creates `new ContentParser()`, calls `->parse($post->post_content, $post_id, $filter_options)`
59+
3. **`ContentParser::parse()`** — runs the parsing flow described above, fires `vip_block_data_api__after_parse_blocks` on the result before returning
60+
4. **`RestApi`** measures parse time and logs `vip-block-data-api-parser-time` analytics error if it exceeds 500ms (configurable via `WPCOMVIP__BLOCK_DATA_API__PARSE_TIME_ERROR_MS`)
61+
62+
### APIs
63+
64+
**REST API:**
65+
- Endpoint: `GET /wp-json/vip-block-data-api/v1/posts/{id}/blocks`
66+
- Query params: `include` (allowlist block types), `exclude` (denylist block types)
67+
- `include` and `exclude` are mutually exclusive
68+
69+
**GraphQL API (requires WPGraphQL):**
70+
- Field: `blocksDataV2` on `NodeWithContentEditor` types (posts, pages, etc.)
71+
- Returns a flattened block list with `id` and `parentId` for hierarchy reconstruction
72+
- Attributes are `name`/`value` string pairs; complex values are JSON-encoded with `isValueJsonEncoded: true`
73+
- Legacy field `blocksData` (v1) is deprecated
74+
75+
### Key Classes
76+
77+
| Class | File | Purpose |
78+
|---|---|---|
79+
| `ContentParser` | `src/parser/content-parser.php` | Core parsing engine |
80+
| `RestApi` | `src/rest/rest-api.php` | REST endpoint |
81+
| `GraphQLApiV2` | `src/graphql/graphql-api-v2.php` | GraphQL integration (current) |
82+
| `GraphQLApiV1` | `src/graphql/graphql-api-v1.php` | GraphQL integration (deprecated) |
83+
| `CoreBlock` | `src/parser/block-additions/core-block.php` | Synced pattern support |
84+
| `CoreImage` | `src/parser/block-additions/core-image.php` | Image width/height metadata |
85+
| `Analytics` | `src/analytics/analytics.php` | VIP-only analytics |
86+
87+
Each class calls its own static `init()` at the bottom of its file, hooking into WordPress actions/filters upon include.
88+
89+
### Runtime Dependencies
90+
91+
- `masterminds/html5` (^2.8) — HTML5 parser
92+
- `symfony/dom-crawler` (^6.0) — DOM traversal for sourced attributes
93+
- `symfony/css-selector` (^6.0) — CSS selector support for DomCrawler
94+
95+
Only production dependencies are committed to `vendor/`.
96+
97+
## Filters and Actions
98+
99+
These are the plugin's extension points:
100+
101+
### Block Filtering
102+
- **`vip_block_data_api__allow_block`** — Filter blocks in/out of output. Receives `(bool $is_included, string $block_name, array $parsed_block)`. The `$parsed_block` is the raw array from `parse_blocks()` with keys `blockName`, `attrs`, `innerHTML`, `innerBlocks`. Evaluated after `include`/`exclude` query params.
103+
104+
### Block Result Modification
105+
- **`vip_block_data_api__sourced_block_result`** — Modify block attributes after parsing. Receives `(array $sourced_block, string $block_name, int $post_id, array $parsed_block)`. The `$parsed_block` is the raw array from `parse_blocks()`.
106+
- **`vip_block_data_api__sourced_block_inner_blocks`** — Modify inner blocks before recursive iteration. Receives `(array $inner_blocks, string $block_name, int $post_id, array $parsed_block)`. The `$inner_blocks` are `WP_Block` instances, not raw arrays.
107+
108+
### Content Preprocessing
109+
- **`vip_block_data_api__before_parse_post_content`** — Modify raw post content before parsing. Receives `($post_content, $post_id)`. Use with extreme care.
110+
111+
### API Result
112+
- **`vip_block_data_api__after_parse_blocks`** — Modify REST endpoint response before returning. Receives `($result, $post_id)`.
113+
114+
### Render Hooks
115+
- **`vip_block_data_api__before_block_render`** (action) — Fires before blocks are rendered by ContentParser.
116+
- **`vip_block_data_api__after_block_render`** (action) — Fires after blocks are rendered.
117+
118+
### Access Control
119+
- **`vip_block_data_api__rest_validate_post_id`** — Control which post IDs are queryable. Receives `($is_valid, $post_id)`.
120+
- **`vip_block_data_api__rest_permission_callback`** — Control API access (e.g. require authentication). Receives `($is_permitted)`.
121+
122+
### GraphQL Toggle
123+
- **`vip_block_data_api__is_graphql_enabled`** — Enable/disable GraphQL integration. Returns boolean.
124+
125+
## Development Setup
126+
127+
### Prerequisites
128+
- Node.js + npm (for `@wordpress/env`)
129+
- Docker (for `wp-env`)
130+
- PHP 8.1+
131+
- Composer
132+
133+
### Local Environment
134+
135+
```bash
136+
npm -g install @wordpress/env
137+
composer install
138+
wp-env start
139+
```
140+
141+
### Running Tests
142+
143+
```bash
144+
composer test # Run PHPUnit tests
145+
composer test-multisite # Run tests in multisite mode
146+
composer test-watch # Watch mode (requires nodemon)
147+
```
148+
149+
Tests use PHPUnit 9.5 inside a WordPress environment via `@wordpress/env`. The base test class `RegistryTestCase` (in `tests/registry-test-case.php`) extends `WP_UnitTestCase` and auto-unregisters non-core blocks after each test.
150+
151+
### Linting
152+
153+
```bash
154+
composer phpcs # Run PHP CodeSniffer
155+
composer phpcs-fix # Auto-fix with phpcbf
156+
```
157+
158+
**Coding standards:** WordPress-Extra, WordPress-VIP-Go, WordPress-Docs (docs excluded from tests/), PHPCompatibilityWP (PHP 8.1+).
159+
160+
## CI/CD
161+
162+
GitHub Actions workflows (trigger on PRs):
163+
164+
- **`phpcs.yml`** — Runs `composer phpcs` on PHP 8.1
165+
- **`phpunit.yml`** — Test matrix: PHP 8.1 + WP 6.0, PHP 8.1 + WP latest, PHP 8.3 + WP latest. Runs both standard and multisite tests.
166+
- **`release.yml`** — On push to `trunk`: detects version changes, validates version consistency between plugin header and `WPCOMVIP__BLOCK_DATA_API__PLUGIN_VERSION` constant, creates GitHub Release with ZIP.
167+
168+
## Release Process
169+
170+
1. Bump version in **both** the plugin header (`Version: X.Y.Z`) and the `WPCOMVIP__BLOCK_DATA_API__PLUGIN_VERSION` constant in `vip-block-data-api.php`
171+
2. Submit as a PR titled "Release X.Y.Z" and merge to `trunk`
172+
3. The `release.yml` workflow auto-generates the tag, ZIP, and GitHub Release
173+
174+
## Key Limitations
175+
176+
- **Client-side-only blocks** that lack server-side registration via `register_block_type()` + `block.json` will have incomplete attributes (only delimiter-stored attrs available)
177+
- **Deprecated blocks** may return different data shapes for the same block type depending on when the post was authored
178+
- **Rich text attributes** (`html`-sourced) may contain inline HTML markup and must be rendered with `innerHTML`/`dangerouslySetInnerHTML`
179+
- **Classic editor content** (pre-Gutenberg) is not supported and returns a `vip-block-data-api-no-blocks` error
180+
181+
## Writing Tests
182+
183+
Tests extend `RegistryTestCase` (`tests/registry-test-case.php`), which provides helper methods and automatic cleanup of custom block registrations after each test.
184+
185+
### Pattern
186+
187+
Every parser test follows the same structure:
188+
189+
1. **Register a test block** with its attribute definitions using `$this->register_block_with_attributes()`
190+
2. **Define block HTML** as a string with WordPress block delimiters (`<!-- wp:test/block-name -->...<!-- /wp:test/block-name -->`)
191+
3. **Define expected output** as an array of `[ 'name' => ..., 'attributes' => [...] ]`
192+
4. **Parse and assert** using `ContentParser::parse()` and `assertArraySubset()` (from the `dms/phpunit-arraysubset-asserts` package, included via the `ArraySubsetAsserts` trait in `RegistryTestCase`)
193+
194+
### Example: Basic block test
195+
196+
```php
197+
<?php
198+
199+
namespace WPCOMVIP\BlockDataApi;
200+
201+
class MyNewBlockTest extends RegistryTestCase {
202+
public function test_parse_custom_block() {
203+
// 1. Register a test block with attribute definitions
204+
$this->register_block_with_attributes( 'test/my-block', [
205+
'title' => [
206+
'type' => 'string',
207+
'source' => 'html',
208+
'selector' => 'h2',
209+
],
210+
'url' => [
211+
'type' => 'string',
212+
'source' => 'attribute',
213+
'selector' => 'a',
214+
'attribute' => 'href',
215+
],
216+
] );
217+
218+
// 2. Define block HTML
219+
$html = '
220+
<!-- wp:test/my-block -->
221+
<div>
222+
<h2>My Title</h2>
223+
<a href="https://example.com">Link</a>
224+
</div>
225+
<!-- /wp:test/my-block -->
226+
';
227+
228+
// 3. Define expected output
229+
$expected_blocks = [
230+
[
231+
'name' => 'test/my-block',
232+
'attributes' => [
233+
'title' => 'My Title',
234+
'url' => 'https://example.com',
235+
],
236+
],
237+
];
238+
239+
// 4. Parse and assert
240+
// In tests, pass the block registry explicitly. In production (RestApi),
241+
// ContentParser is instantiated with no args and uses the global registry.
242+
$content_parser = new ContentParser( $this->get_block_registry() );
243+
$blocks = $content_parser->parse( $html );
244+
$this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) );
245+
$this->assertArraySubset( $expected_blocks, $blocks['blocks'], true );
246+
}
247+
}
248+
```
249+
250+
### Example: Testing a filter
251+
252+
When testing filters, add the filter before parsing and remove it after:
253+
254+
```php
255+
public function test_my_filter() {
256+
$this->register_block_with_attributes( 'test/block', [ /* ... */ ] );
257+
258+
$html = '<!-- wp:test/block -->...<!-- /wp:test/block -->';
259+
260+
$filter_fn = function ( $sourced_block, $block_name ) {
261+
$sourced_block['attributes']['extra'] = 'value';
262+
return $sourced_block;
263+
};
264+
265+
add_filter( 'vip_block_data_api__sourced_block_result', $filter_fn, 10, 2 );
266+
$content_parser = new ContentParser( $this->get_block_registry() );
267+
$result = $content_parser->parse( $html );
268+
remove_filter( 'vip_block_data_api__sourced_block_result', $filter_fn, 10, 2 );
269+
270+
// Assert on $result...
271+
}
272+
```
273+
274+
### Key helpers from `RegistryTestCase`
275+
276+
- `$this->register_block_with_attributes( string $block_name, array $attributes, array $additional_args = [] )` — Registers a block type for testing
277+
- `$this->get_block_registry()` — Returns `WP_Block_Type_Registry` instance
278+
- `$this->register_block_bindings_source( string $source, array $args )` — Registers a block bindings source for testing
279+
- `tearDown()` automatically unregisters all non-`core/` blocks and block binding sources
280+
281+
### Test file placement
282+
283+
- Source-type tests → `tests/parser/sources/test-source-{type}.php`
284+
- Block-specific tests → `tests/parser/blocks/test-{block-name}.php`
285+
- General parser tests → `tests/parser/test-{feature}.php`
286+
- GraphQL tests → `tests/graphql/test-graphql-api-{version}.php`
287+
- REST tests → `tests/rest/test-rest-api.php`
288+
289+
### Naming conventions
290+
291+
- Test files: `test-{descriptive-name}.php` — the `test-` prefix is **required** by `phpunit.xml.dist` (`<directory prefix="test-" suffix=".php">`); files without it won't be discovered
292+
- Test classes: `{DescriptiveName}Test extends RegistryTestCase`
293+
- Test methods: `test_{what_is_being_tested}` (use double underscores `__` to separate variants, e.g. `test_parse_attribute_source__with_default_value`)
294+
- Block names in tests: use `test/` namespace (e.g. `test/my-block`) — these are auto-cleaned by `tearDown()`
295+
296+
## Error Codes
297+
298+
The plugin returns `WP_Error` instances with these codes:
299+
300+
| Code | HTTP Status | When |
301+
|---|---|---|
302+
| `vip-block-data-api-no-blocks` | 400 | Post content has no block data (classic editor or pre-Gutenberg content) |
303+
| `vip-block-data-api-parser-error` | 500 | Unexpected exception during block parsing (stack trace logged server-side) |
304+
| `vip-block-data-api-invalid-params` | 400 | Both `include` and `exclude` query params provided simultaneously |
305+
| `vip-block-data-api-parser-time` || Parse time exceeded threshold (500ms); logged as analytics error, does **not** fail the request |
306+
307+
## Debugging
308+
309+
Define the constant `VIP_BLOCK_DATA_API__PARSE_DEBUG` as `true` to include raw parsed blocks and post content in the API output. This adds extra data to the response for debugging purposes.
310+
311+
## Coding Conventions
312+
313+
- PHP files use tabs for indentation, LF line endings, UTF-8 encoding
314+
- YAML files use 2-space indentation
315+
- Follow WordPress coding standards (WordPress-Extra, WordPress-VIP-Go)
316+
- Short array syntax `[]` is allowed
317+
- All classes are in the `WPCOMVIP\BlockDataApi` namespace
318+
- Block additions use the sub-namespace `WPCOMVIP\BlockDataApi\ContentParser\BlockAdditions`

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# CLAUDE.md
2+
3+
See [AGENTS.md](./AGENTS.md) for full project context, architecture, development setup, and coding conventions.

0 commit comments

Comments
 (0)