Skip to content
Draft
154 changes: 123 additions & 31 deletions includes/Newsletter/BlockConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@
* - `core/quote` — `convert_quote_block()`
* - `core/pullquote` — `convert_pullquote_block()`
* - `core/embed` — `convert_embed_block()`
* - `core/columns` — `convert_columns_block_to_blocks()` (beehiiv `columns` block, up to 3 columns)
* - `core/media-text` — `convert_media_text_block()` (beehiiv `columns` block)
* - `core/buttons` / inner `core/button` — `convert_buttons_block()`, `convert_button_block()`
* - `core/separator` — beehiiv `content_break`
* - `core/more` — snippet newsletters only; beehiiv Read More `button` via `convert_more_block()`
* - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()`
*
* Layout blocks (`core/group`, `core/columns`, etc.) are skipped at the top level.
* `convert_columns_block()` exists for internal reuse (e.g. `core/media-text`) and is
* not wired into the main block walk yet. Snippet mode stops at `core/more`.
* Layout blocks such as `core/group` are skipped at the top level. A `core/columns`
* block with more than 3 columns is omitted. A `core/columns` block nested inside a
* column is not converted to a columns block; its inner blocks are emitted at the top
* level instead. Snippet mode stops at `core/more`.
*
* @since 1.0.0
*/
Expand Down Expand Up @@ -127,6 +129,11 @@ private static function convert_blocks(
continue;
}

if ( 'core/columns' === $block_name ) {
$beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_columns_block_to_blocks( $wp_block ) );
continue;
}

$beehiiv_block = self::convert_block( $wp_block );

if ( ! empty( $beehiiv_block ) ) {
Expand Down Expand Up @@ -335,7 +342,7 @@ public static function convert_paragraph_block( array $wp_block ): array {
$block_background_color = FormattedTextParser::resolve_paragraph_block_background_color( $inner_html, $attrs );
$parsed = FormattedTextParser::parse( $inline_html, $block_text_color );

if ( '' === trim( $parsed['plaintext'] ) ) {
if ( '' === preg_replace( '/[\s\x{00A0}]+/u', '', $parsed['plaintext'] ) ) {
return [];
}

Expand Down Expand Up @@ -520,64 +527,116 @@ public static function convert_list_block( array $wp_block ): array {
/**
* Convert a core/columns block to a beehiiv columns block.
*
* Not included in the top-level newsletter block walk yet. Intended for
* internal reuse by `convert_media_text_block()` and future direct columns support.
* Returns an empty array when the block is not converted to a columns block,
* including when it has more columns than beehiiv accepts.
*
* @param array<string, mixed> $wp_block Parsed block.
* @return array<string, mixed>
* @since 1.0.0
*/
public static function convert_columns_block( array $wp_block ): array {
foreach ( self::convert_columns_block_to_blocks( $wp_block ) as $beehiiv_block ) {
if ( 'columns' === ( $beehiiv_block['type'] ?? '' ) ) {
return $beehiiv_block;
}
}

return [];
}

/**
* Convert a core/columns block to beehiiv blocks.
*
* A block with up to 3 columns becomes one beehiiv columns block. A block with
* more columns is omitted entirely, and the row is never split. A core/columns
* block nested inside a column is not converted to a columns block: its inner
* blocks follow the outer columns block at the top level.
*
* @param array<string, mixed> $wp_block Parsed block.
* @return array<int, array<string, mixed>>
* @since 1.2.0
*/
public static function convert_columns_block_to_blocks( array $wp_block ): array {
if ( 'core/columns' !== ( $wp_block['blockName'] ?? '' ) ) {
return [];
}

$attrs = $wp_block['attrs'] ?? [];
$inner_html = (string) ( $wp_block['innerHTML'] ?? '' );
$inner_blocks = $wp_block['innerBlocks'] ?? [];
$stack_on_mobile = ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html );
$parent_vertical_align = isset( $attrs['verticalAlignment'] ) && is_string( $attrs['verticalAlignment'] )
? $attrs['verticalAlignment']
: null;
$beehiiv_columns = [];
$column_blocks = [];
$converted_columns = [];
$nested_blocks = [];

foreach ( $inner_blocks as $column_block ) {
foreach ( $wp_block['innerBlocks'] ?? [] as $column_block ) {
if ( 'core/column' !== ( $column_block['blockName'] ?? '' ) ) {
continue;
}

$column_inner_blocks = $column_block['innerBlocks'] ?? [];
$column_blocks = self::convert_layout_block_inner_blocks( $column_inner_blocks );
$column_blocks[] = $column_block;
$converted_columns[] = self::convert_layout_block_inner_blocks(
$column_block['innerBlocks'] ?? [],
$nested_blocks
);
}

if ( empty( $column_blocks ) ) {
continue;
}
if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) {
return [];
}

$beehiiv_column = ColumnsBlockConverter::build_beehiiv_column(
$column_blocks,
$column_block,
$parent_vertical_align
);
$kept_blocks = [];
$kept_blocks_by_column = [];

if ( ! empty( $beehiiv_column ) ) {
$beehiiv_columns[] = $beehiiv_column;
foreach ( $column_blocks as $index => $column_block ) {
if ( ! empty( $converted_columns[ $index ] ) ) {
$kept_blocks[] = $column_block;
$kept_blocks_by_column[] = $converted_columns[ $index ];
}
}

return ColumnsBlockConverter::build_beehiiv_columns_block( $beehiiv_columns, $stack_on_mobile );
$widths = ColumnsBlockConverter::resolve_row_widths( $kept_blocks );
$columns = [];

foreach ( $kept_blocks as $index => $column_block ) {
$columns[] = ColumnsBlockConverter::build_beehiiv_column_from_definition(
$kept_blocks_by_column[ $index ],
$widths[ $index ],
ColumnsBlockConverter::resolve_column_vertical_alignment(
$column_block['attrs'] ?? [],
(string) ( $column_block['innerHTML'] ?? '' ),
$parent_vertical_align
)
);
}

$columns_block = ColumnsBlockConverter::build_beehiiv_columns_block(
$columns,
ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html )
);

return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) );
}

/**
* Convert nested inner blocks inside a layout block to beehiiv blocks.
*
* Used by layout blocks such as columns and media-text. Unsupported blocks are
* skipped; snippet-only blocks are omitted.
* skipped; snippet-only blocks are omitted. A nested core/columns block is never
* converted inside a column. When `$nested_blocks` is passed, its inner blocks are
* added to it for the caller to emit at the top level. Without it, the nested block
* is omitted.
*
* @param array<int, array<string, mixed>> $wp_blocks Parsed inner blocks.
* @param array<int, array<string, mixed>> $wp_blocks Parsed inner blocks.
* @param array<int, array<string, mixed>>|null $nested_blocks Receives the inner blocks of nested columns.
* @return array<int, array<string, mixed>>
* @since 1.0.0
*/
private static function convert_layout_block_inner_blocks( array $wp_blocks ): array {
private static function convert_layout_block_inner_blocks(
array $wp_blocks,
?array &$nested_blocks = null
): array {
$beehiiv_blocks = [];

foreach ( $wp_blocks as $wp_block ) {
Expand All @@ -587,6 +646,22 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a

$block_name = (string) $wp_block['blockName'];

if ( 'core/columns' === $block_name ) {
if ( null !== $nested_blocks ) {
foreach ( $wp_block['innerBlocks'] ?? [] as $nested_column ) {
if ( 'core/column' === ( $nested_column['blockName'] ?? '' ) ) {
$nested_converted = self::convert_layout_block_inner_blocks(
$nested_column['innerBlocks'] ?? [],
$nested_blocks
);
$nested_blocks = array_merge( $nested_blocks, $nested_converted );
}
}
}

continue;
}

if ( 'core/buttons' === $block_name ) {
$beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_buttons_block( $wp_block ) );
continue;
Expand Down Expand Up @@ -617,7 +692,8 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a
* Maps attrs.head into rows[0] with headerRow when the Header section is enabled
* in the block editor. attrs.body supplies remaining rows. When attrs are empty
* (common for query-sourced table attributes in PHP parse_blocks), rows are read
* from innerHTML instead. The table is omitted when every cell is empty.
* from innerHTML instead. The table is omitted when every cell is empty. A block
* background colour is sent as `visual_settings.background_color`.
* Footer rows are omitted; beehiiv has no table footer support.
*
* @param array<string, mixed> $wp_block Parsed block.
Expand Down Expand Up @@ -674,11 +750,21 @@ public static function convert_table_block( array $wp_block ): array {
return [];
}

return [
$table_block = [
'type' => 'table',
'rows' => $rows,
'headerRow' => $header_row,
];

$background_color = FormattedTextParser::resolve_table_block_background_color( $inner_html, $attrs );

if ( null !== $background_color && '' !== $background_color ) {
$table_block['visual_settings'] = [
'background_color' => $background_color,
];
}

return $table_block;
}

/**
Expand Down Expand Up @@ -958,8 +1044,9 @@ public static function convert_embed_block( array $wp_block ): array {
* Convert a core/media-text block to a beehiiv columns block.
*
* Maps the media side to an image column and the content side to converted
* inner blocks. Blocks with video media are omitted entirely. Respects media
* position, column widths, vertical alignment, and mobile stacking.
* inner blocks. Blocks with video media, or with no valid image, are omitted
* entirely. Respects media position, column widths, vertical alignment, and
* mobile stacking.
*
* @param array<string, mixed> $wp_block Parsed block.
* @return array<string, mixed>
Expand All @@ -979,7 +1066,12 @@ public static function convert_media_text_block( array $wp_block ): array {
$media_width = MediaTextBlockConverter::resolve_media_width( $attrs, $inner_html );
$content_width = max( 1, min( 100, 100 - $media_width ) );

$media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html );
$media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html );

if ( empty( $media_blocks ) ) {
return [];
}

$content_blocks = self::convert_layout_block_inner_blocks( $inner_blocks );

if ( empty( $content_blocks ) && '' !== $inner_html ) {
Expand Down
119 changes: 119 additions & 0 deletions includes/Newsletter/Converters/ColumnsBlockConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
*/
final class ColumnsBlockConverter {

/**
* Most columns a single beehiiv columns block accepts.
*
* @var int
*/
public const MAX_COLUMNS = 3;

/**
* Build a beehiiv columns block from converted column definitions.
*
Expand Down Expand Up @@ -219,6 +226,118 @@ public static function resolve_column_width( array $attrs, string $inner_html ):
return null;
}

/**
* Resolve the width of every column in one row.
*
* Percent (or unitless) widths are rounded to a whole percent one column at a
* time, and a column with no width stays unset. When the widths use px, em, rem
* or another non-percent unit, each width becomes that column's share of the
* combined widths. If any column in such a row has no usable width, or the row
* mixes units, no column gets a width.
*
* @param array<int, array<string, mixed>> $column_blocks Parsed core/column blocks in the row.
* @return array<int, int|null> Width per column, in the same order.
* @since 1.2.0
*/
public static function resolve_row_widths( array $column_blocks ): array {
$parsed = [];

foreach ( $column_blocks as $column_block ) {
$parsed[] = self::parse_raw_width(
self::resolve_column_raw_width(
$column_block['attrs'] ?? [],
(string) ( $column_block['innerHTML'] ?? '' )
)
);
}

$units = [];

foreach ( $parsed as $width ) {
if ( null !== $width ) {
$units[ $width['unit'] ] = true;
}
}

$only_percent = array_diff_key( $units, [ '%' => true ] );

if ( empty( $only_percent ) ) {
return array_map(
static function ( $width ): ?int {
return null === $width ? null : self::parse_width_percentage( $width['value'] );
},
$parsed
);
}

$none = array_fill( 0, count( $parsed ), null );

if ( 1 !== count( $units ) || in_array( null, $parsed, true ) ) {
return $none;
}

$total = array_sum( array_column( $parsed, 'value' ) );

if ( $total <= 0 ) {
return $none;
}

return array_map(
static function ( array $width ) use ( $total ): ?int {
$percentage = (int) round( $width['value'] / $total * 100 );

return self::is_valid_width_percentage( $percentage ) ? $percentage : null;
},
$parsed
);
}

/**
* Read a column's raw width text from attrs or saved HTML.
*
* @param array<string, mixed> $attrs Parsed core/column attrs.
* @param string $inner_html Saved column HTML.
* @return string|null Raw width such as 33.33% or 200px, or null when unset.
* @since 1.2.0
*/
private static function resolve_column_raw_width( array $attrs, string $inner_html ): ?string {
if ( isset( $attrs['width'] ) && ( is_string( $attrs['width'] ) || is_numeric( $attrs['width'] ) ) ) {
return trim( (string) $attrs['width'] );
}

$processor = new \WP_HTML_Tag_Processor( $inner_html );

if ( $processor->next_tag( 'DIV' ) ) {
$style = $processor->get_attribute( 'style' );

if ( is_string( $style ) && preg_match( '/flex-basis\s*:\s*([^;]+)/i', $style, $matches ) ) {
return trim( $matches[1] );
}
}

return null;
}

/**
* Split a raw width into its number and unit.
*
* @param string|null $raw Raw width text.
* @return array{value: float, unit: string}|null Null when empty or not a number with an optional unit.
* @since 1.2.0
*/
private static function parse_raw_width( ?string $raw ): ?array {
if ( null === $raw || ! preg_match( '/^(\d*\.?\d+)\s*([a-z%]*)$/i', trim( $raw ), $matches ) ) {
return null;
}

$unit = strtolower( $matches[2] );

return [
'value' => (float) $matches[1],
'unit' => '' === $unit ? '%' : $unit,
];
}

/**
* Parse a width value into an integer percentage.
*
Expand Down
Loading
Loading