diff --git a/includes/class-newspack-newsletters-renderer.php b/includes/class-newspack-newsletters-renderer.php index df7647088..5fdfc79f5 100644 --- a/includes/class-newspack-newsletters-renderer.php +++ b/includes/class-newspack-newsletters-renderer.php @@ -53,6 +53,14 @@ final class Newspack_Newsletters_Renderer { */ protected static $post_permalink = null; + /** + * Stack of reusable block ref IDs currently being rendered, + * used to detect and prevent circular references. + * + * @var int[] + */ + private static $rendering_refs = []; + /** * Inline tags that are allowed to be rendered in a text block. * @@ -466,6 +474,7 @@ public static function process_links( $html, $post = null ) { */ public static function is_empty_block( $block ) { $blocks_without_inner_html = [ + 'core/block', 'core/site-logo', 'core/site-title', 'core/site-tagline', @@ -1438,6 +1447,19 @@ function ( $block ) { break; + /** + * Reusable block (synced pattern). + * Resolve the referenced wp_block post and render as a group block. + */ + case 'core/block': + $resolved = self::resolve_reusable_block( $block ); + if ( null === $resolved ) { + return ''; + } + $block_mjml_markup = self::render_mjml_component( $resolved, $is_in_column, $is_in_group, $default_attrs, $is_in_list_or_quote ); + self::release_reusable_block_ref(); + return $block_mjml_markup; + /** * Group block. */ @@ -1672,6 +1694,60 @@ function ( $b ) use ( $remote_data ) { return $block_mjml_markup; } + /** + * Resolve a core/block (synced pattern / reusable block) into a core/group block. + * + * Fetches the referenced wp_block post, validates it, guards against circular + * references, and returns a block array with blockName changed to core/group + * and innerBlocks populated from the reusable block's content. + * + * @param array $block The core/block block array. + * @return array|null The resolved group block, or null if unresolvable. + */ + private static function resolve_reusable_block( array $block ): ?array { + if ( 'core/block' !== $block['blockName'] || ! isset( $block['attrs']['ref'] ) ) { + return null; + } + + $ref = (int) $block['attrs']['ref']; + + // Guard against circular references. + if ( in_array( $ref, self::$rendering_refs, true ) ) { + return null; + } + + $reusable_block_post = get_post( $ref ); + + // Validate post type and status. + if ( + empty( $reusable_block_post ) + || 'wp_block' !== $reusable_block_post->post_type + || 'publish' !== $reusable_block_post->post_status + ) { + return null; + } + + // Push ref onto the stack. Callers must call release_reusable_block_ref() + // after they are done rendering the resolved block. + self::$rendering_refs[] = $ref; + + $block['blockName'] = 'core/group'; + $block['innerBlocks'] = self::get_valid_post_blocks( $reusable_block_post ); + $block['innerHTML'] = $reusable_block_post->post_content; + $block['innerContent'] = [ $reusable_block_post->post_content ]; + + return $block; + } + + /** + * Release a reusable block ref from the rendering stack. + * + * Must be called after rendering a block resolved by resolve_reusable_block(). + */ + private static function release_reusable_block_ref(): void { + array_pop( self::$rendering_refs ); + } + /** Convert a WP post to an array of non-empty blocks. * * @param WP_Post $post The post. @@ -1711,15 +1787,16 @@ public static function post_to_mjml_components( $post ) { $block_content = ''; // Convert reusable block to group block. - // Reusable blocks are CPTs, where the block's ref attribute is the post ID. - if ( 'core/block' === $block['blockName'] && isset( $block['attrs']['ref'] ) ) { - $reusable_block_post = get_post( $block['attrs']['ref'] ); - if ( ! empty( $reusable_block_post ) ) { - $block['blockName'] = 'core/group'; - $block['innerBlocks'] = self::get_valid_post_blocks( $reusable_block_post ); - $block['innerHTML'] = $reusable_block_post->post_content; - $block['innerContent'] = $reusable_block_post->post_content; - } + $is_resolved_ref = false; + $resolved = self::resolve_reusable_block( $block ); + if ( null !== $resolved ) { + $block = $resolved; + $is_resolved_ref = true; + } elseif ( 'core/block' === $block['blockName'] ) { + // Unresolvable reusable block (stale or circular ref). Skip rather + // than falling through to render_mjml_component, which would attempt + // the same resolution again. + continue; } if ( 'core/group' === $block['blockName'] ) { @@ -1748,6 +1825,10 @@ public static function post_to_mjml_components( $post ) { $block_content = self::render_mjml_component( $block ); } + if ( $is_resolved_ref ) { + self::release_reusable_block_ref(); + } + $body .= $block_content; } @@ -1761,7 +1842,8 @@ public static function post_to_mjml_components( $post ) { * @return string MJML markup. */ public static function render_post_to_mjml( $post ) { - self::$newsletter_id = $post->ID; + self::$rendering_refs = []; + self::$newsletter_id = $post->ID; self::$color_palette = json_decode( get_option( Newspack_Newsletters::NEWSPACK_NEWSLETTERS_PALETTE_META, false ), true ); self::$font_header = get_post_meta( $post->ID, 'font_header', true ); self::$font_body = get_post_meta( $post->ID, 'font_body', true ); diff --git a/tests/test-renderer.php b/tests/test-renderer.php index d8ffdce7c..76cdcabe8 100755 --- a/tests/test-renderer.php +++ b/tests/test-renderer.php @@ -435,6 +435,97 @@ public function test_reusable_block() { ); } + /** + * Rendering a reusable block nested inside a group block. + */ + public function test_reusable_block_in_group() { + $reusable_block_post_id = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_title' => 'Reusable block in group.', + 'post_content' => "\n
Nested Hello
\n", + ] + ); + $newsletter_post = self::factory()->post->create( + [ + 'post_type' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, + 'post_title' => 'A newsletter with a reusable block inside a group.', + 'post_content' => '', + ] + ); + $result = Newspack_Newsletters_Renderer::post_to_mjml_components( + get_post( $newsletter_post ) + ); + $this->assertStringContainsString( + 'Nested Hello', + $result, + 'Reusable block content inside a group block is rendered' + ); + } + + /** + * A stale reusable block reference (deleted post) should not emit empty markup. + */ + public function test_stale_reusable_block_ref() { + $newsletter_post = self::factory()->post->create( + [ + 'post_type' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, + 'post_title' => 'A newsletter with a stale reusable block ref.', + 'post_content' => '', + ] + ); + $result = Newspack_Newsletters_Renderer::post_to_mjml_components( + get_post( $newsletter_post ) + ); + $this->assertEmpty( + $result, + 'Stale reusable block reference produces no output' + ); + } + + /** + * Circular reusable block references should not cause infinite recursion. + */ + public function test_circular_reusable_block_ref() { + // Create two wp_block posts that reference each other. + $block_a_id = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_title' => 'Block A', + 'post_content' => 'A
', + ] + ); + $block_b_id = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_title' => 'Block B', + 'post_content' => '', + ] + ); + // Update Block A to reference Block B, creating a cycle. + wp_update_post( + [ + 'ID' => $block_a_id, + 'post_content' => '', + ] + ); + + $newsletter_post = self::factory()->post->create( + [ + 'post_type' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, + 'post_title' => 'A newsletter with circular reusable blocks.', + 'post_content' => '', + ] + ); + // Should not fatal — the circular ref is silently dropped. + $result = Newspack_Newsletters_Renderer::post_to_mjml_components( + get_post( $newsletter_post ) + ); + $this->assertIsString( $result, 'Circular reusable block references do not cause fatal errors' ); + // The cycle is broken: output contains only empty wrapper shells, not unbounded content. + $this->assertLessThan( 200, strlen( $result ), 'Circular reusable block output is bounded' ); + } + /** * Rendering with custom CSS. */