Skip to content
Open
102 changes: 92 additions & 10 deletions includes/class-newspack-newsletters-renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Comment thread
jason10lee marked this conversation as resolved.
} 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'] ) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 );
Expand Down
91 changes: 91 additions & 0 deletions tests/test-renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => "<!-- wp:paragraph -->\n<p>Nested Hello</p>\n<!-- /wp:paragraph -->",
]
);
$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' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:block {"ref":' . $reusable_block_post_id . '} /--></div><!-- /wp:group -->',
]
);
$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' => '<!-- wp:block {"ref":999999} /-->',
]
);
$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' => '<!-- wp:paragraph --><p>A</p><!-- /wp:paragraph -->',
]
);
$block_b_id = self::factory()->post->create(
[
'post_type' => 'wp_block',
'post_title' => 'Block B',
'post_content' => '<!-- wp:block {"ref":' . $block_a_id . '} /-->',
]
);
// Update Block A to reference Block B, creating a cycle.
wp_update_post(
[
'ID' => $block_a_id,
'post_content' => '<!-- wp:block {"ref":' . $block_b_id . '} /-->',
]
);

$newsletter_post = self::factory()->post->create(
[
'post_type' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT,
'post_title' => 'A newsletter with circular reusable blocks.',
'post_content' => '<!-- wp:block {"ref":' . $block_a_id . '} /-->',
]
);
// 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.
*/
Expand Down
Loading