Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
155 changes: 123 additions & 32 deletions includes/Newsletter/BlockConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
* - `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`
Expand All @@ -43,10 +44,10 @@
* blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`; a Group
* inside a Group is omitted because beehiiv sections cannot hold sections)
*
* Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is
* `core/group` for posts first synced before section support.
* `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`.
* `core/group` is skipped at the top level for posts first synced before section support.
* 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 @@ -148,6 +149,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 @@ -398,7 +404,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 @@ -583,64 +589,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 @@ -650,6 +708,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 @@ -680,7 +754,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 @@ -737,11 +812,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 @@ -1021,8 +1106,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 @@ -1042,7 +1128,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