Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'children' and 'node' attribute sources #10

Merged
merged 8 commits into from
Mar 9, 2023
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
125 changes: 123 additions & 2 deletions parser/content-parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ protected function source_block( $block, $registered_blocks ) {
}
}

$crawler = new Crawler( $block['innerHTML'] );
// Enter the automatically-inserted <body> tag from parser
// Specify a manual doctype so that the parser will use the HTML5 parser
$crawler = new Crawler( sprintf( '<!doctype html><html><body>%s</body></html>', $block['innerHTML'] ) );

// Enter the <body> tag for block parsing
$crawler = $crawler->filter( 'body' );

$attribute_value = $this->source_attribute( $crawler, $block_attribute_definition );
Expand Down Expand Up @@ -182,6 +184,10 @@ protected function source_attribute( $crawler, $block_attribute_definition ) {
$attribute_value = $this->source_block_query( $crawler, $block_attribute_definition );
} elseif ( 'meta' === $attribute_source ) {
$attribute_value = $this->source_block_meta( $block_attribute_definition );
} elseif ( 'node' === $attribute_source ) {
$attribute_value = $this->source_block_node( $crawler, $block_attribute_definition );
} elseif ( 'children' === $attribute_source ) {
$attribute_value = $this->source_block_children( $crawler, $block_attribute_definition );
}

if ( null === $attribute_value ) {
Expand Down Expand Up @@ -380,6 +386,121 @@ protected function source_block_meta( $block_attribute_definition ) {
}
}

/**
* @param Symfony\Component\DomCrawler\Crawler $crawler
* @param array $block_attribute_definition
*
* @return string|null
*/
protected function source_block_children( $crawler, $block_attribute_definition ) {
// 'children' attribute usage was removed from core in 2018, but not officically deprecated until WordPress 6.1:
// https://github.com/WordPress/gutenberg/pull/44265
// Parsing code for 'children' sources can be found here:
// https://github.com/WordPress/gutenberg/blob/dd0504b/packages/blocks/src/api/children.js#L149

$attribute_values = [];
$selector = $block_attribute_definition['selector'] ?? null;

if ( null !== $selector ) {
$crawler = $crawler->filter( $selector );
}

if ( $crawler->count() === 0 ) {
// If the selector doesn't exist, return a default empty array
return $attribute_values;
}

$children = $crawler->children();

if ( $children->count() === 0 ) {
// 'children' attributes can be a single element. In this case, return the element value in an array.
$attribute_values = [
$crawler->getNode( 0 )->nodeValue,
];
} else {
// Use DOMDocument childNodes directly to preserve text nodes. $crawler->children() will return only
// element nodes and omit text content.
$children_nodes = $crawler->getNode( 0 )->childNodes;

foreach ( $children_nodes as $node ) {
$node_value = $this->from_dom_node( $node );

if ( $node_value ) {
$attribute_values[] = $node_value;
}
}
}

return $attribute_values;
}

/**
* @param Symfony\Component\DomCrawler\Crawler $crawler
* @param array $block_attribute_definition
*
* @return string|null
*/
protected function source_block_node( $crawler, $block_attribute_definition ) {
// 'node' attribute usage was removed from core in 2018, but not officically deprecated until WordPress 6.1:
// https://github.com/WordPress/gutenberg/pull/44265
// Parsing code for 'node' sources can be found here:
// https://github.com/WordPress/gutenberg/blob/dd0504bd34c29b5b2824d82c8d2bb3a8d0f071ec/packages/blocks/src/api/node.js#L125

$attribute_value = null;
$selector = $block_attribute_definition['selector'] ?? null;

if ( null !== $selector ) {
$crawler = $crawler->filter( $selector );
}

$node = $crawler->getNode( 0 );
$node_value = null;

if ( $node ) {
$node_value = $this->from_dom_node( $node );
}

if ( null !== $node_value ) {
$attribute_value = $node_value;
}

return $attribute_value;
}

/**
* Helper function to process markup used by the deprecated 'node' and 'children' sources.
* These sources can return a representation of the DOM tree and bypass the $crawler to access DOMNodes directly.
*
* @param \DOMNode $node
*
* @return array|string|null
*/
protected function from_dom_node( $node ) {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- external API calls

if ( XML_TEXT_NODE === $node->nodeType ) {
// For plain text nodes, return the text directly
$text = trim( $node->nodeValue );

// Exclude whitespace-only nodes
if ( ! empty( $text ) ) {
return $text;
}
} elseif ( XML_ELEMENT_NODE === $node->nodeType ) {
$children = array_map( [ $this, 'from_dom_node' ], iterator_to_array( $node->childNodes ) );

// For element nodes, recurse and return an array of child nodes
return [
'type' => $node->nodeName,
'children' => array_filter( $children ),
];
} else {
return null;
}

// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}

protected function add_missing_block_warning( $block_name ) {
$warning_message = sprintf( 'Block type "%s" is not server-side registered. Sourced block attributes will not be available.', $block_name );

Expand Down
16 changes: 12 additions & 4 deletions rest/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

namespace WPCOMVIP\ContentApi;

use Error;
use Exception;
use WP_Error;

defined( 'ABSPATH' ) || die();

defined( 'WPCOMVIP__CONTENT_API__PARSE_TIME_ERROR_THRESHOLD_MS' ) || define( 'WPCOMVIP__CONTENT_API__PARSE_TIME_ERROR_THRESHOLD_MS', 1000 );
defined( 'WPCOMVIP__CONTENT_API__PARSE_TIME_ERROR_THRESHOLD_MS' ) || define( 'WPCOMVIP__CONTENT_API__PARSE_TIME_ERROR_THRESHOLD_MS', 500 );

class RestApi {
public static function init() {
Expand Down Expand Up @@ -38,26 +39,33 @@ public static function get_block_content( $params ) {

Analytics::record_usage();

$parser_error = false;
$parse_time_start = microtime( true );

try {
$content_parser = new ContentParser();
$parser_results = $content_parser->parse( $post->post_content, $post_id );
} catch ( Exception $exception ) {
$error_message = sprintf( 'Error parsing post ID %d: %s', $post_id, $exception );
$parser_error = $exception;
} catch ( Error $error ) {
$parser_error = $error;
}

if ( $parser_error ) {
$error_message = sprintf( 'Error parsing post ID %d: %s', $post_id, $parser_error );
Analytics::record_error( $error_message );

$exception_data = '';
$is_production_site = defined( 'VIP_GO_APP_ENVIRONMENT' ) && 'production' === VIP_GO_APP_ENVIRONMENT;

if ( ! $is_production_site && true === WP_DEBUG ) {
$exception_data = [
'stack_trace' => explode( "\n", $exception->getTraceAsString() ),
'stack_trace' => explode( "\n", $parser_error->getTraceAsString() ),
];
}

// Early return to skip parse time check
return new WP_Error( 'vip-content-api-parser-error', $exception->getMessage(), $exception_data );
return new WP_Error( 'vip-content-api-parser-error', $parser_error->getMessage(), $exception_data );
}

$parse_time = microtime( true ) - $parse_time_start;
Expand Down
165 changes: 165 additions & 0 deletions tests/parser/sources/test-source-children.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php
/**
* Class ChildrenSourceTest
*
* @package vip-content-api
*/

namespace WPCOMVIP\ContentApi;

/**
* Test sourced attributes with the deprecated 'children' type:
* https://github.com/WordPress/gutenberg/pull/44265
*/
class ChildrenSourceTest extends RegistryTestCase {
public function test_parse_children__with_list_elements() {
$this->register_block_with_attributes( 'test/custom-list-children', [
'steps' => [
'type' => 'array',
'source' => 'children',
'selector' => '.steps',
],
] );

$html = '
<!-- wp:test/custom-list-children -->
<ul class="steps">
<li>Step 1</li>
<li>Step 2</li>
</ul>
<!-- /wp:test/custom-list-children -->
';

$expected_blocks = [
[
'name' => 'test/custom-list-children',
'attributes' => [
'steps' => [
[
'type' => 'li',
'children' => [
'Step 1',
],
],
[
'type' => 'li',
'children' => [
'Step 2',
],
],
],
],
],
];

$content_parser = new ContentParser( $this->registry );
$blocks = $content_parser->parse( $html );
$this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) );
$this->assertArraySubset( $expected_blocks, $blocks['blocks'], true );
}

public function test_parse_children__with_single_child() {
$this->register_block_with_attributes( 'test/custom-block-with-title', [
'title' => [
'type' => 'array',
'source' => 'children',
'selector' => 'h2',
],
] );

$html = '
<!-- wp:test/custom-block-with-title -->
<div>
<h2>Block title</h2>
</div>
<!-- /wp:test/custom-block-with-title -->
';

$expected_blocks = [
[
'name' => 'test/custom-block-with-title',
'attributes' => [
'title' => [
'Block title',
],
],
],
];

$content_parser = new ContentParser( $this->registry );
$blocks = $content_parser->parse( $html );
$this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) );
$this->assertArraySubset( $expected_blocks, $blocks['blocks'], true );
}

public function test_parse_children__with_mixed_nodes_and_text() {
$this->register_block_with_attributes( 'test/custom-block', [
'instructions' => [
'type' => 'array',
'source' => 'children',
'selector' => '.instructions',
],
] );

$html = '
<!-- wp:test/custom-block -->
<div>
<div class="instructions">Preheat oven to <strong>200 degrees</strong></div>
</div>
<!-- /wp:test/custom-block -->
';

$expected_blocks = [
[
'name' => 'test/custom-block',
'attributes' => [
'instructions' => [
'Preheat oven to',
[
'type' => 'strong',
'children' => [
'200 degrees',
],
],
],
],
],
];

$content_parser = new ContentParser( $this->registry );
$blocks = $content_parser->parse( $html );
$this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) );
$this->assertArraySubset( $expected_blocks, $blocks['blocks'], true );
}

public function test_parse_children__with_default_value() {
$this->register_block_with_attributes( 'test/custom-block', [
'unused-value' => [
'type' => 'array',
'default' => [],
'source' => 'children',
'selector' => '.unused-class',
],
] );

$html = '
<!-- wp:test/custom-block -->
<p>Unrelated content</p>
<!-- /wp:test/custom-block -->
';

$expected_blocks = [
[
'name' => 'test/custom-block',
'attributes' => [
'unused-value' => [],
],
],
];

$content_parser = new ContentParser( $this->registry );
$blocks = $content_parser->parse( $html );
$this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) );
$this->assertArraySubset( $expected_blocks, $blocks['blocks'], true );
}
}
Loading