|
| 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` |
0 commit comments