@@ -167,10 +167,10 @@ public function test_handle_multi_byte_html_encoding() {
167167 MARKUP
168168 );
169169 $ dom_expected = <<<RESULT
170- <p data-wp-block='{"dropCap":false}' data-wp-block-name="core/paragraph" >The temperature is 23°C ☀️ (sun emoji) and © (copyright symbol). HTML entity for Degrees: °.</p>
170+ <p data-wp-block-name="core/paragraph" data-wp-block='{"dropCap":false}' >The temperature is 23°C ☀️ (sun emoji) and © (copyright symbol). HTML entity for Degrees: °.</p>
171171 RESULT ;
172172 $ html_tag_api_expected = <<<RESULT
173- <p data-wp-block=" {"dropCap":false}" data-wp-block-name="core/paragraph ">The temperature is 23°C ☀️ (sun emoji) and © (copyright symbol). HTML entity for Degrees: °.</p>
173+ <p data-wp-block-name="core/paragraph" data-wp-block=" {"dropCap":false}">The temperature is 23°C ☀️ (sun emoji) and © (copyright symbol). HTML entity for Degrees: °.</p>
174174 RESULT ;
175175
176176 $ dom_output = $ this ->parser ->render_block ( $ html , $ block , $ instance );
@@ -309,6 +309,212 @@ public function test_render_html_tag_api( array $incoming, array $block_structur
309309 remove_filter ( 'tenup_headless_wp_render_block_use_tag_processor ' , '__return_true ' );
310310 }
311311
312+ /**
313+ * Tests that HTML entities in block attributes are preserved correctly with tag processor
314+ *
315+ * @return void
316+ */
317+ public function test_html_entities_are_double_encoded () {
318+ // Test with content containing HTML entities
319+ // (and a ' to ensure that it is not serialized as a single-quote string
320+ // by WP_HTML_Tag_Processor)
321+ $ markup = '<!-- wp:heading {"content":"<script>alert('xss')</script> \'","level":2} -->content<!-- /wp:heading --> ' ;
322+ $ block = $ this ->core_render_block_from_markup ( $ markup );
323+ $ enhanced_block = $ this ->parser ->render_block ( $ block ['html ' ], $ block ['parsed_block ' ], $ block ['instance ' ] );
324+
325+ // Any HTML entities in JSON strings should be double-encoded
326+ $ this ->assertStringContainsString (
327+ 'data-wp-block="{"content":"&lt;script&gt;alert(&#039;xss&#039;)&lt;\/script&gt; ' ,
328+ $ enhanced_block
329+ );
330+ }
331+
332+ /**
333+ * Tests that HTML entities in block attributes are preserved correctly with tag processor
334+ *
335+ * @return void
336+ */
337+ public function test_html_entities_are_double_encoded_using_WP_HTML_Tag_Processor () {
338+ add_filter ( 'tenup_headless_wp_render_block_use_tag_processor ' , '__return_true ' );
339+ $ this ->test_html_entities_are_double_encoded ();
340+ remove_filter ( 'tenup_headless_wp_render_block_use_tag_processor ' , '__return_true ' );
341+ }
342+
343+ /**
344+ * Data provider for block roundtrip tests
345+ *
346+ * @return array
347+ */
348+ public function block_roundtrip_data_provider () {
349+ $ test_cases = [
350+ 'block value containing no special characters ' => [
351+ 'core/heading ' ,
352+ [
353+ 'x ' => 'hi ' ,
354+ 'level ' => 2 ,
355+ ],
356+ '<!-- wp:heading {"x":"hi"} --> <h2></h2> <!-- /wp:heading --> ' ,
357+ ],
358+ 'block value containing named character reference ' ' => [
359+ 'core/heading ' ,
360+ [
361+ 'x ' => '' ' ,
362+ 'level ' => 2 ,
363+ ],
364+ '<!-- wp:heading {"x":"'"} --> <h2></h2> <!-- /wp:heading --> ' ,
365+ ],
366+ 'block value containing lone apostrophe \' (from ENT_HTML5) ' => [
367+ 'core/heading ' ,
368+ [
369+ 'x ' => '\'' ,
370+ 'level ' => 2 ,
371+ ],
372+ '<!-- wp:heading {"x":" \'"} --> <h2></h2> <!-- /wp:heading --> ' ,
373+ ],
374+ 'block value containing lone quote " (from ENT_COMPAT) ' => [
375+ 'core/heading ' ,
376+ [
377+ 'x ' => '" ' ,
378+ 'level ' => 2 ,
379+ ],
380+ '<!-- wp:heading {"x":" \\""} --> <h2></h2> <!-- /wp:heading --> ' ,
381+ ],
382+ 'block value containing named character reference " ' => [
383+ 'core/heading ' ,
384+ [
385+ 'x ' => '" ' ,
386+ 'level ' => 2 ,
387+ ],
388+ '<!-- wp:heading {"x":"""} --> <h2></h2> <!-- /wp:heading --> ' ,
389+ ],
390+ 'block value containing lone ampersand & ' => [
391+ 'core/heading ' ,
392+ [
393+ 'x ' => '& ' ,
394+ 'level ' => 2 ,
395+ ],
396+ '<!-- wp:heading {"x":"&"} --> <h2></h2> <!-- /wp:heading --> ' ,
397+ ],
398+ 'block value containing named character reference & ' => [
399+ 'core/heading ' ,
400+ [
401+ 'x ' => '& ' ,
402+ 'level ' => 2 ,
403+ ],
404+ '<!-- wp:heading {"x":"&"} --> <h2></h2> <!-- /wp:heading --> ' ,
405+ ],
406+ 'block value containing hexadecimal numeric character reference & (should not be converted to &) ' => [
407+ 'core/heading ' ,
408+ [
409+ 'x ' => '& ' ,
410+ 'level ' => 2 ,
411+ ],
412+ '<!-- wp:heading {"x":"&"} --> <h2></h2> <!-- /wp:heading --> ' ,
413+ ],
414+ 'block value containing leading zero hexadecimal numeric character reference & (should not be converted to &) ' => [
415+ 'core/heading ' ,
416+ [
417+ 'x ' => '& ' ,
418+ 'level ' => 2 ,
419+ ],
420+ '<!-- wp:heading {"x":"&"} --> <h2></h2> <!-- /wp:heading --> ' ,
421+ ],
422+ 'block value containing decimal numeric character reference & (should not be converted to &) ' => [
423+ 'core/heading ' ,
424+ [
425+ 'x ' => '& ' ,
426+ 'level ' => 2 ,
427+ ],
428+ '<!-- wp:heading {"x":"&"} --> <h2></h2> <!-- /wp:heading --> ' ,
429+ ],
430+ 'block value containing leading zero decimal numeric character reference & (should not be converted to &) ' => [
431+ 'core/heading ' ,
432+ [
433+ 'x ' => '& ' ,
434+ 'level ' => 2 ,
435+ ],
436+ '<!-- wp:heading {"x":"&"} --> <h2></h2> <!-- /wp:heading --> ' ,
437+ ],
438+ 'html_entities ' => [
439+ 'core/heading ' ,
440+ [
441+ 'content ' => '<script>alert('xss')</script> ' ,
442+ 'level ' => 2 ,
443+ ],
444+ '<!-- wp:heading {"content":"<script>alert('xss')</script>","level":2} --> <h2><script>alert( \'xss \')</script></h2><!-- /wp:heading --> ' ,
445+ ],
446+ 'complex_attributes ' => [
447+ 'core/image ' ,
448+ [
449+ 'id ' => 28 ,
450+ 'sizeSlug ' => 'large ' ,
451+ 'linkDestination ' => 'none ' ,
452+ 'alt ' => '' ,
453+ ],
454+ '<!-- wp:image {"id":28,"sizeSlug":"large","linkDestination":"none"} --> <figure class="wp-block-image size-large"><img src="http://example.com/image.jpg" alt="" class="wp-image-28"/></figure><!-- /wp:image --> ' ,
455+ ],
456+ 'special_characters ' => [
457+ 'core/quote ' ,
458+ [
459+ 'citation ' => 'Author "Name" & Co. ' ,
460+ 'value ' => '<p>Quote with "quotes" & ampersands</p> ' ,
461+ ],
462+ '<!-- wp:quote {"citation":"Author \"Name\" & Co.","value":"<p>Quote with \"quotes\" & ampersands</p>"} --> <blockquote><p>Quote with "quotes" & ampersands</p><cite>Author "Name" & Co.</cite></blockquote><!-- /wp:quote --> ' ,
463+ ],
464+ ];
465+ $ test_cases_with_or_without_tag_processor = [];
466+ foreach ( $ test_cases as $ name => $ case ) {
467+ $ test_cases_with_or_without_tag_processor [ "$ name with WP_HTML_Tag_Processor " ] = array_merge ( $ case , [ true ] );
468+ $ test_cases_with_or_without_tag_processor [ "$ name with DomDocument " ] = array_merge ( $ case , [ false ] );
469+ }
470+ return $ test_cases_with_or_without_tag_processor ;
471+ }
472+
473+ /**
474+ * Tests that block attributes can be round-tripped correctly
475+ *
476+ * @dataProvider block_roundtrip_data_provider
477+ *
478+ * @param string $expected_block_name The expected block name
479+ * @param array $expected_attributes The expected block attributes
480+ * @param string $markup The block markup to test
481+ * @param bool $use_tag_processor Whether to use the tag processor
482+ * @return void
483+ */
484+ public function test_block_attributes_roundtrip ( $ expected_block_name , $ expected_attributes , $ markup , $ use_tag_processor ) {
485+ $ block = $ this ->core_render_block_from_markup ( $ markup );
486+ $ tag_processor_function = $ use_tag_processor ? '__return_true ' : '__return_false ' ;
487+ add_filter ( 'tenup_headless_wp_render_block_use_tag_processor ' , $ tag_processor_function );
488+ try {
489+ $ enhanced_block = $ this ->parser ->render_block ( $ block ['html ' ], $ block ['parsed_block ' ], $ block ['instance ' ] );
490+ } finally {
491+ remove_filter ( 'tenup_headless_wp_render_block_use_tag_processor ' , $ tag_processor_function );
492+ }
493+
494+ // Parse the enhanced block using DOMDocument to extract data-wp-block and data-wp-block-name
495+ $ doc = new \DOMDocument ();
496+ $ success = $ doc ->loadHTML ( $ enhanced_block , LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
497+
498+ $ this ->assertTrue ( $ success , 'DOMDocument should successfully parse the enhanced block HTML ' );
499+
500+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
501+ $ root_element = $ doc ->documentElement ;
502+ $ this ->assertNotNull ( $ root_element , 'Should have a root element ' );
503+
504+ $ block_name_attr = $ root_element ->getAttribute ( 'data-wp-block-name ' );
505+ $ block_data_attr = $ root_element ->getAttribute ( 'data-wp-block ' );
506+
507+ $ this ->assertNotEmpty ( $ block_name_attr , 'data-wp-block-name attribute should be present ' );
508+ $ this ->assertNotEmpty ( $ block_data_attr , 'data-wp-block attribute should be present ' );
509+
510+ // Parse JSON - DOMDocument should have already handled HTML entity decoding
511+ $ parsed_attributes = json_decode ( $ block_data_attr , true );
512+
513+ $ this ->assertIsArray ( $ parsed_attributes , 'Block data should decode to valid JSON array ' );
514+ $ this ->assertEquals ( $ expected_block_name , $ block_name_attr , 'Block name should match expected ' );
515+ $ this ->assertEquals ( $ expected_attributes , $ parsed_attributes , 'Block attributes should match expected (encoded: ' . $ enhanced_block . ') ' );
516+ }
517+
312518 /**
313519 * Tests block's rendering Synced Patterns which use another post to store the patterns content
314520 * - Run separate to hook the Parser filter on all render_block processing, required for nested blocks
0 commit comments