Skip to content
Draft
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
49 changes: 25 additions & 24 deletions inc/pattern-transformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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</%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;
}
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/patterns/button-block.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">Click Here</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->
57 changes: 57 additions & 0 deletions tests/test-pattern-transformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,63 @@ public function test_update_block_text_content() {
$this->assertStringContainsString( '<h2', $updated['innerHTML'] );
}

/**
* Test update_block_text_content preserves nested markup in wrapper blocks.
*
* core/button renders a wrapping <div> with an inner <a>; 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 = '<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">Click Here</a></div>';

$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( '<div class="wp-block-button">', $updated['innerHTML'] );
// Inner anchor tag must be preserved with its attributes.
$this->assertStringContainsString( '<a class="wp-block-button__link wp-element-button" href="#">', $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. & → &amp;) 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 &amp; Jerry', $updated['innerHTML'] );
// Must not be double-encoded (set_modifiable_text should encode exactly once).
$this->assertStringNotContainsString( 'Tom &amp;amp; Jerry', $updated['innerHTML'] );
}

/**
* Test rebuild_inner_content preserves structure.
*/
Expand Down
45 changes: 42 additions & 3 deletions tests/test-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
]
);
}

/**
Expand All @@ -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();
}
Expand Down Expand Up @@ -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 (& → &amp;).
*
* Because replace_text() uses set_modifiable_text() under the hood, special
* HTML characters are encoded; the block markup will contain &amp; 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' );
Expand All @@ -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 &amp; 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( '<a ', $content );
}
}