diff --git a/inc/pattern-transformer.php b/inc/pattern-transformer.php index 9010fd4..b40eaa7 100644 --- a/inc/pattern-transformer.php +++ b/inc/pattern-transformer.php @@ -295,10 +295,16 @@ function apply_block_transformation( array $block, array $transformation ) : arr /** * Update just the text content of a block, preserving HTML structure. * - * Uses WP_HTML_Tag_Processor to safely update text while keeping all attributes. + * Finds the first text node in the block's innerHTML and replaces it using + * WP_HTML_Tag_Processor::set_modifiable_text(), which HTML-encodes the value. + * This preserves nested markup (e.g. the wrapping div and inner anchor of a + * core/button block) and prevents XSS by escaping special characters. * - * @param array $block Block to modify. - * @param string $new_text New text content. + * Use replace_html / the 'innerHTML' transformation key when you need to + * inject raw HTML rather than plain text. + * + * @param array $block Block to modify. + * @param string $new_text New plain-text content (will be HTML-encoded). * @return array Modified block. */ function update_block_text_content( array $block, string $new_text ) : array { @@ -310,32 +316,27 @@ function update_block_text_content( array $block, string $new_text ) : array { $processor = new WP_HTML_Tag_Processor( $html ); - // Find the first HTML tag and extract its details. - if ( ! $processor->next_tag() ) { - return $block; - } - - $tag_name = $processor->get_tag(); - $attributes = ''; + // Walk tokens until the first non-empty text node is found, then replace it. + while ( $processor->next_token() ) { + if ( '#text' !== $processor->get_token_type() ) { + continue; + } - // Collect all attributes. - foreach ( $processor->get_attribute_names_with_prefix( '' ) as $attr_name ) { - $attr_value = $processor->get_attribute( $attr_name ); - if ( is_string( $attr_value ) ) { - $attributes .= sprintf( ' %s="%s"', $attr_name, esc_attr( $attr_value ) ); - } else { - // Boolean attribute (true) or missing value (null). - $attributes .= sprintf( ' %s', $attr_name ); + // Skip whitespace-only text nodes so we land on the real content. + if ( '' === trim( $processor->get_modifiable_text() ) ) { + continue; } + + // set_modifiable_text() HTML-encodes the value, keeping surrounding + // markup (tags, attributes, sibling elements) intact. + $processor->set_modifiable_text( $new_text ); + break; } - // Rebuild the HTML with the new text inside the tag. - // Use lowercase tag names for consistency with WordPress standards. - $tag_name_lower = strtolower( $tag_name ); - $html = sprintf( '<%s%s>%s', $tag_name_lower, $attributes, $new_text, $tag_name_lower ); + $new_html = $processor->get_updated_html(); - $block['innerHTML'] = $html; - $block['innerContent'] = [ $html ]; + $block['innerHTML'] = $new_html; + $block['innerContent'] = [ $new_html ]; return $block; } diff --git a/tests/fixtures/patterns/button-block.html b/tests/fixtures/patterns/button-block.html new file mode 100644 index 0000000..111745a --- /dev/null +++ b/tests/fixtures/patterns/button-block.html @@ -0,0 +1,5 @@ + +
+
Click Here
+
+ diff --git a/tests/test-pattern-transformer.php b/tests/test-pattern-transformer.php index f1685e2..69d69e9 100644 --- a/tests/test-pattern-transformer.php +++ b/tests/test-pattern-transformer.php @@ -83,6 +83,63 @@ public function test_update_block_text_content() { $this->assertStringContainsString( ' with an inner ; only the link + * text should change — the outer div and inner anchor must be preserved. + */ + public function test_update_block_text_content_preserves_nested_markup() { + $button_html = '
Click Here
'; + + $block = [ + 'blockName' => 'core/button', + 'attrs' => [], + 'innerHTML' => $button_html, + 'innerContent' => [ $button_html ], + 'innerBlocks' => [], + ]; + + $updated = Pattern_Transformer\update_block_text_content( $block, 'Buy Now' ); + + // Outer wrapper div must still be present. + $this->assertStringContainsString( '
', $updated['innerHTML'] ); + // Inner anchor tag must be preserved with its attributes. + $this->assertStringContainsString( '', $updated['innerHTML'] ); + // New text should be set. + $this->assertStringContainsString( 'Buy Now', $updated['innerHTML'] ); + // Old text should be gone. + $this->assertStringNotContainsString( 'Click Here', $updated['innerHTML'] ); + } + + /** + * Test update_block_text_content HTML-encodes special characters. + * + * replace_text() delegates to this function, so the text value should be + * HTML-encoded (e.g. & → &) rather than inserted verbatim. + */ + public function test_update_block_text_content_escapes_special_chars() { + $content = $this->load_pattern( 'simple-heading-paragraph' ); + $blocks = parse_blocks( $content ); + + $heading = null; + foreach ( $blocks as $block ) { + if ( $block['blockName'] === 'core/heading' ) { + $heading = $block; + break; + } + } + + $this->assertNotNull( $heading ); + + $updated = Pattern_Transformer\update_block_text_content( $heading, 'Tom & Jerry' ); + + // The ampersand must be HTML-encoded in the stored markup. + $this->assertStringContainsString( 'Tom & Jerry', $updated['innerHTML'] ); + // Must not be double-encoded (set_modifiable_text should encode exactly once). + $this->assertStringNotContainsString( 'Tom &amp; Jerry', $updated['innerHTML'] ); + } + /** * Test rebuild_inner_content preserves structure. */ diff --git a/tests/test-template.php b/tests/test-template.php index edd3488..d462667 100644 --- a/tests/test-template.php +++ b/tests/test-template.php @@ -79,6 +79,14 @@ public function set_up() : void { 'content' => $this->load_pattern( 'simple-heading-paragraph' ), ] ); + + register_block_pattern( + 'test/button-block', + [ + 'title' => 'Button Block', + 'content' => $this->load_pattern( 'button-block' ), + ] + ); } /** @@ -89,6 +97,7 @@ public function tear_down() : void { unregister_block_pattern( 'test/footer-cta' ); unregister_block_pattern( 'test/template-article' ); unregister_block_pattern( 'test/simple-heading' ); + unregister_block_pattern( 'test/button-block' ); parent::tear_down(); } @@ -506,7 +515,12 @@ public function test_get_content_returns_wp_error_for_missing_pattern() { } /** - * Test that serialized output uses literal ampersands (not \u0026). + * Test that replace_text HTML-encodes special characters (& → &). + * + * Because replace_text() uses set_modifiable_text() under the hood, special + * HTML characters are encoded; the block markup will contain & rather + * than a bare & (which would be invalid HTML). The JSON serialisation must + * NOT further escape the ampersand as \u0026. */ public function test_get_content_serializes_ampersands_correctly() { $template = new Template( 'test/simple-heading' ); @@ -515,7 +529,32 @@ public function test_get_content_serializes_ampersands_correctly() { ->replace_text( 'test/simple-heading', 'core/heading', 0, 'Tom & Jerry' ) ->get_content(); - $this->assertStringContainsString( 'Tom & Jerry', $content ); - $this->assertStringNotContainsString( '\\u0026', $content ); + // The ampersand must be HTML-encoded in the stored markup. + $this->assertStringContainsString( 'Tom & Jerry', $content ); + // JSON serialisation must not double-escape it as \u0026. + $this->assertStringNotContainsString( '\u0026', $content ); + } + + /** + * Test replace_text on a button block preserves nested markup. + * + * core/button wraps the link text in a div > a structure. replace_text() + * must update only the link text and leave the outer div and anchor intact. + */ + public function test_replace_text_on_button_preserves_nested_markup() { + $template = new Template( 'test/button-block' ); + + $content = $template + ->replace_text( 'test/button-block', 'core/button', 0, 'Buy Now' ) + ->get_content(); + + // New text must appear. + $this->assertStringContainsString( 'Buy Now', $content ); + // Original text must be gone. + $this->assertStringNotContainsString( 'Click Here', $content ); + // Outer wrapper div must be preserved. + $this->assertStringContainsString( 'wp-block-button">', $content ); + // Inner anchor element must be preserved. + $this->assertStringContainsString( '