diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml index aabb18b..00c7744 100644 --- a/.github/workflows/phpcs.yml +++ b/.github/workflows/phpcs.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: php-versions: - - 8.1 + - 8.2 steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 58747c0..fe1c69d 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -11,24 +11,16 @@ jobs: # Alias 'master' to 'latest' name: WP ${{ matrix.wp == 'master' && 'latest' || matrix.wp }} and PHP ${{ matrix.php }} runs-on: ubuntu-latest - continue-on-error: ${{ matrix.allowed_failure }} strategy: fail-fast: false matrix: - include: - # Check lowest supported WP version, with the lowest supported PHP. - - php: "8.1" - wp: "6.0" - allowed_failure: false - # Check latest WP with the lowest supported PHP. - - php: "8.1" - wp: "master" - allowed_failure: false - # Check latest WP with the highest supported PHP. - - php: "8.3" - wp: "master" - allowed_failure: false + wp: [ "6.8", "6.9", "7.0", "7.1", "master" ] + php: [ "8.2", "8.3", "8.4", "8.5" ] + exclude: + # WordPress 6.8 does not support PHP 8.5. + - wp: "6.8" + php: "8.5" steps: - name: Checkout code diff --git a/AGENTS.md b/AGENTS.md index 155724d..3bf970e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ 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. -- **Language:** PHP (8.1+) -- **WordPress:** 6.0+ +- **Language:** PHP (8.2+) +- **WordPress:** 6.8+ - **Namespace:** `WPCOMVIP\BlockDataApi` - **License:** GPL-2.0-or-later per `composer.json` (note: the plugin header in `vip-block-data-api.php` says GPL-3) - **Repository:** https://github.com/Automattic/vip-block-data-api @@ -127,7 +127,7 @@ These are the plugin's extension points: ### Prerequisites - Node.js + npm (for `@wordpress/env`) - Docker (for `wp-env`) -- PHP 8.1+ +- PHP 8.2+ - Composer ### Local Environment @@ -155,14 +155,14 @@ composer phpcs # Run PHP CodeSniffer composer phpcs-fix # Auto-fix with phpcbf ``` -**Coding standards:** WordPress-Extra, WordPress-VIP-Go, WordPress-Docs (docs excluded from tests/), PHPCompatibilityWP (PHP 8.1+). +**Coding standards:** WordPress-Extra, WordPress-VIP-Go, WordPress-Docs (docs excluded from tests/), PHPCompatibilityWP (PHP 8.2+). ## CI/CD GitHub Actions workflows (trigger on PRs): -- **`phpcs.yml`** — Runs `composer phpcs` on PHP 8.1 -- **`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. +- **`phpcs.yml`** — Runs `composer phpcs` on PHP 8.2 +- **`phpunit.yml`** — Tests WordPress 6.8, 6.9, 7.0, 7.1, and master against supported PHP versions 8.2 through 8.5 (excluding WP 6.8 with PHP 8.5). Runs both standard and multisite tests. - **`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. ## Release Process diff --git a/README.md b/README.md index e833d57..7fac48e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ This plugin is currently developed for use on WordPress sites hosted on the VIP ## Installation +Requires WordPress 6.8 or later and PHP 8.2 or later. + ### Install on WordPress VIP The Block Data API plugin is authored and maintained by [WordPress VIP][wpvip], and made available to all WordPress sites by the [VIP Integrations Center][vip-ic]. Customers who host on WordPress VIP or use [`vip dev-env`](https://docs.wpvip.com/how-tos/local-development/use-the-vip-local-development-environment/) to develop locally have access to the Block Data API automatically. We recommend this activation method for WordPress VIP customers. diff --git a/composer.json b/composer.json index 0ba31b2..dc70e34 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,14 @@ ] }, "require": { - "php": ">=8.1", + "php": ">=8.2", "masterminds/html5": "^2.8", "symfony/dom-crawler": "^6.0", "symfony/css-selector": "^6.0" }, "require-dev": { "phpunit/phpunit": "^9.5", - "wp-phpunit/wp-phpunit": "^6.3", + "wp-phpunit/wp-phpunit": "^6.8", "phpcompatibility/phpcompatibility-wp": "^2.1", "automattic/vipwpcs": "^3.0", "yoast/phpunit-polyfills": "^2.0", diff --git a/composer.lock b/composer.lock index 6716b23..fc62e62 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6c55e96eae0472666466689b8b782260", + "content-hash": "028d823335c0bfc1f678f0b27e4f124b", "packages": [ { "name": "masterminds/html5", @@ -3078,7 +3078,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1" + "php": ">=8.2" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f307934..4aab095 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -34,7 +34,7 @@ - + /tests @@ -46,7 +46,7 @@ - + diff --git a/src/parser/block-additions/core-block.php b/src/parser/block-additions/core-block.php index 1b410a5..028a803 100644 --- a/src/parser/block-additions/core-block.php +++ b/src/parser/block-additions/core-block.php @@ -103,11 +103,11 @@ public static function get_inner_blocks( array $inner_blocks, string|null $block * phpcs:disable Generic.Commenting.DocComment.LongNotCapital * https://github.com/WordPress/WordPress/blob/6.6.1/wp-includes/blocks/block.php#L73 * - * @param array $sourced_block Sourced block result. - * @param string $block_name Block name. + * @param array $sourced_block Sourced block result. + * @param string|null $block_name Block name, or null for parsed non-block content. * @return array */ - public static function remove_content_array( array $sourced_block, string $block_name ): array { + public static function remove_content_array( array $sourced_block, string|null $block_name ): array { if ( self::$block_name !== $block_name ) { return $sourced_block; } diff --git a/src/parser/content-parser.php b/src/parser/content-parser.php index e24f521..44185ec 100644 --- a/src/parser/content-parser.php +++ b/src/parser/content-parser.php @@ -96,7 +96,8 @@ protected function should_block_be_included( WP_Block $block, array $filter_opti * Filter out blocks from the blocks output * * @param bool $is_block_included True if the block should be included, or false to filter it out. - * @param string $block_name Name of the parsed block, e.g. 'core/paragraph'. + * @param string|null $block_name Name of the parsed block, or null when parse_blocks() returns + * content found outside block delimiters. * @param array $block Result of parse_blocks() for this block. * Contains 'blockName', 'attrs', 'innerHTML', and 'innerBlocks' keys. */ @@ -291,7 +292,7 @@ protected function source_block( WP_Block $block, array $filter_options ): array return null; } - if ( ! $this->block_registry->is_registered( $block_name ) ) { + if ( null !== $block_name && ! $this->block_registry->is_registered( $block_name ) ) { $this->add_missing_block_warning( $block_name ); } @@ -311,7 +312,7 @@ protected function source_block( WP_Block $block, array $filter_options ): array * Filters a block's inner blocks before recursive iteration. * * @param array $inner_blocks An array of inner block (WP_Block) instances. - * @param string $block_name Name of the parsed block, e.g. 'core/paragraph'. + * @param string|null $block_name Name of the parsed block, or null for non-block content. * @param int $post_id Post ID associated with the parsed block. * @param array $parsed_block Result of parse_blocks() for this block. */ @@ -331,7 +332,7 @@ protected function source_block( WP_Block $block, array $filter_options ): array * Filters a block when parsing is complete. * * @param array $sourced_block An associative array of parsed block data with keys 'name' and 'attribute'. - * @param string $block_name Name of the parsed block, e.g. 'core/paragraph'. + * @param string|null $block_name Name of the parsed block, or null for non-block content. * @param int $post_id Post ID associated with the parsed block. * @param array $block Result of parse_blocks() for this block. Contains 'blockName', 'attrs', 'innerHTML', and 'innerBlocks' keys. */ @@ -371,6 +372,11 @@ protected function apply_sourced_attributes( WP_Block $block ): array { if ( isset( $block_attributes[ $block_attribute_name ] ) ) { // Attribute is already set in the block's delimiter attributes, skip. continue; + } elseif ( 'core/html' === $block->name && 'content' === $block_attribute_name && 'local' === ( $block_attribute_definition['role'] ?? null ) ) { + // WordPress 6.8 uses the 'raw' source and follows the sourced-attribute path below. + // Newer core/html definitions use role 'local' and keep the value in inner HTML. + $block_attributes[ $block_attribute_name ] = trim( $block->inner_html ); + continue; } elseif ( null !== $attribute_default_value ) { // Attribute is unset and has a default value, use default value. $block_attributes[ $block_attribute_name ] = $attribute_default_value; @@ -501,6 +507,12 @@ protected function source_block_attribute( $crawler, $block_attribute_definition if ( $crawler->count() > 0 ) { $attribute_value = $crawler->attr( $attribute ); + if ( 'boolean' === ( $block_attribute_definition['type'] ?? null ) ) { + // HTML boolean attributes are true when present, even if written as + // controls="false". Omit the attribute to mean false; preserve that false + // instead of replacing it with the block's default. + $attribute_value = null !== $attribute_value; + } } return $attribute_value; @@ -679,8 +691,9 @@ protected function source_block_tag( $crawler, $block_attribute_definition ) { * @access private */ protected function source_block_raw( $crawler ) { - // The only current usage of the 'raw' attribute in Gutenberg core is the 'core/html' block: - // https://github.com/WordPress/gutenberg/blob/6517008/packages/block-library/src/html/block.json#L13 + // Older core/html definitions, including WordPress 6.8, use the 'raw' source. + // Newer core/html definitions use role 'local', handled in apply_sourced_attributes(). + // core/shortcode, core/freeform, and core/missing also use this raw parser. // Also see tag attribute parsing in Gutenberg: // https://github.com/WordPress/gutenberg/blob/6517008/packages/blocks/src/api/parser/get-block-attributes.js#L131 diff --git a/tests/parser/blocks/test-html-block.php b/tests/parser/blocks/test-html-block.php new file mode 100644 index 0000000..c121486 --- /dev/null +++ b/tests/parser/blocks/test-html-block.php @@ -0,0 +1,49 @@ +get_block_registry() ); + $result = $parser->parse( '
Custom HTML
' ); + + $this->assertIsArray( $result ); + $this->assertSame( '
Custom HTML
', $result['blocks'][0]['attributes']['content'] ); + } + + public function test_parse_local_content_without_source() { + $registry = $this->get_block_registry(); + $original_block = $registry->get_registered( 'core/html' ); + $this->assertNotNull( $original_block ); + + $registry->unregister( 'core/html' ); + $registry->register( 'core/html', [ + 'attributes' => [ + 'content' => [ + 'type' => 'string', + 'role' => 'local', + ], + ], + ] ); + + try { + $html = "\n
First
Second
\n"; + $parser = new ContentParser( $registry ); + $result = $parser->parse( $html ); + + $this->assertIsArray( $result, sprintf( 'Unexpected parser output: %s', wp_json_encode( $result ) ) ); + $this->assertSame( '
First
Second
', $result['blocks'][0]['attributes']['content'] ); + } finally { + $registry->unregister( 'core/html' ); + $registry->register( $original_block ); + } + } +} diff --git a/tests/parser/blocks/test-video-block.php b/tests/parser/blocks/test-video-block.php new file mode 100644 index 0000000..a7b8be9 --- /dev/null +++ b/tests/parser/blocks/test-video-block.php @@ -0,0 +1,24 @@ +get_block_registry() ); + $off = $parser->parse( '
' ); + $on = $parser->parse( '
' ); + + $this->assertIsArray( $off ); + $this->assertIsArray( $on ); + $this->assertFalse( $off['blocks'][0]['attributes']['controls'] ); + $this->assertTrue( $on['blocks'][0]['attributes']['controls'] ); + } +} diff --git a/tests/parser/sources/test-source-attribute.php b/tests/parser/sources/test-source-attribute.php index 2273127..b539928 100644 --- a/tests/parser/sources/test-source-attribute.php +++ b/tests/parser/sources/test-source-attribute.php @@ -105,4 +105,31 @@ public function test_parse_attribute_source__with_asterisk_selector() { $this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) ); $this->assertArraySubset( $expected_blocks, $blocks['blocks'], true ); } + + public function test_parse_boolean_attribute_source_with_true_default() { + $this->register_block_with_attributes( 'test/video', [ + 'controls' => [ + 'type' => 'boolean', + 'source' => 'attribute', + 'selector' => 'video', + 'attribute' => 'controls', + 'default' => true, + ], + ] ); + + $content_parser = new ContentParser( $this->get_block_registry() ); + $without = $content_parser->parse( '' ); + $with = $content_parser->parse( '' ); + $with_false_text = $content_parser->parse( '' ); + $missing_video = $content_parser->parse( '

No video

' ); + + $this->assertIsArray( $without ); + $this->assertIsArray( $with ); + $this->assertIsArray( $with_false_text ); + $this->assertIsArray( $missing_video ); + $this->assertFalse( $without['blocks'][0]['attributes']['controls'] ); + $this->assertTrue( $with['blocks'][0]['attributes']['controls'] ); + $this->assertTrue( $with_false_text['blocks'][0]['attributes']['controls'] ); + $this->assertTrue( $missing_video['blocks'][0]['attributes']['controls'] ); + } } diff --git a/tests/parser/test-content-parser.php b/tests/parser/test-content-parser.php index dc157d6..a75db3a 100644 --- a/tests/parser/test-content-parser.php +++ b/tests/parser/test-content-parser.php @@ -237,6 +237,29 @@ public function test_parse_classic_editor_content() { ); } + public function test_parse_blocks_with_classic_content_between_them() { + $this->register_block_with_attributes( 'test/paragraph', [ + 'content' => [ + 'type' => 'string', + 'source' => 'html', + 'selector' => 'p', + ], + ] ); + + $html = '

First block

' + . '

Imported classic content

' + . '

Second block

'; + + $content_parser = new ContentParser( $this->get_block_registry() ); + $result = $content_parser->parse( $html ); + + $this->assertIsArray( $result, sprintf( 'Unexpected parser output: %s', wp_json_encode( $result ) ) ); + $this->assertSame( [ 'test/paragraph', null, 'test/paragraph' ], array_column( $result['blocks'], 'name' ) ); + $this->assertSame( 'First block', $result['blocks'][0]['attributes']['content'] ); + $this->assertSame( 'Second block', $result['blocks'][2]['attributes']['content'] ); + $this->assertArrayNotHasKey( 'warnings', $result ); + } + /* Empty content */ public function test_parse_empty_content() { diff --git a/vendor/composer/platform_check.php b/vendor/composer/platform_check.php index 4c3a5d6..d32d90c 100644 --- a/vendor/composer/platform_check.php +++ b/vendor/composer/platform_check.php @@ -4,8 +4,8 @@ $issues = array(); -if (!(PHP_VERSION_ID >= 80100)) { - $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.'; +if (!(PHP_VERSION_ID >= 80200)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.'; } if ($issues) { diff --git a/vip-block-data-api.php b/vip-block-data-api.php index 50ed22c..4ef6e3d 100644 --- a/vip-block-data-api.php +++ b/vip-block-data-api.php @@ -6,9 +6,9 @@ * Author: WordPress VIP * Text Domain: vip-block-data-api * Version: 1.4.10 - * Requires at least: 6.0 - * Tested up to: 6.8 - * Requires PHP: 8.1 + * Requires at least: 6.8 + * Tested up to: 7.1 + * Requires PHP: 8.2 * License: GPL-3 * License URI: https://www.gnu.org/licenses/gpl-3.0.html * @@ -20,13 +20,13 @@ if ( ! defined( 'VIP_BLOCK_DATA_API_LOADED' ) ) { define( 'VIP_BLOCK_DATA_API_LOADED', true ); - // ToDo: When 6.4 is our min version, switch to wp_admin_notice. global $wp_version; - if ( version_compare( phpversion(), '8.1', '<' ) || version_compare( $wp_version, '6.0', '<' ) ) { + if ( version_compare( phpversion(), '8.2', '<' ) || version_compare( $wp_version, '6.8', '<' ) ) { + // This callback must also work on unsupported WordPress versions before 6.4. add_action( 'admin_notices', function () { ?>
-

+