From a11f0c0098e4ea1f9f94fee0db77dbcda78b12a2 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 26 Oct 2026 23:29:26 +0800 Subject: [PATCH 01/13] feat(newsletter-publishing): convert top-level Columns blocks to beehiiv columns Feature: newsletter-publishing/block-conversion/structured-content-blocks Implements: - US-006: A Columns block in a post becomes one beehiiv columns block Verified: - AC-008, AC-011 to AC-019 covered by PHPUnit tests - Up to 3 columns per block; more columns, or columns nested in a column, put their inner blocks at the top level - px/em/rem widths become a share of the row; percent widths round per column --- includes/Newsletter/BlockConverter.php | 127 ++++++-- .../Converters/ColumnsBlockConverter.php | 119 +++++++ includes/Newsletter/SupportedBlocks.php | 7 +- tests/phpunit/BlockConverterColumnsTest.php | 303 ++++++++++++++++++ 4 files changed, 529 insertions(+), 27 deletions(-) create mode 100644 tests/phpunit/BlockConverterColumnsTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 32a1f94..1b28485 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -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, or one 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 */ @@ -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 ) ) { @@ -520,64 +527,118 @@ 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. Use + * `convert_columns_block_to_blocks()` to also get the fallback blocks. * * @param array $wp_block Parsed block. * @return array * @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 not converted to a columns block. Each column's inner blocks + * are emitted at the top level in order instead, and the row is never split. A + * core/columns block nested inside a column is handled the same way: its inner + * blocks follow the outer columns block at the top level. + * + * @param array $wp_block Parsed block. + * @return array> + * @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 ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { + return array_merge( array_merge( ...$converted_columns ), $nested_blocks ); + } - if ( empty( $column_blocks ) ) { - continue; + $kept_blocks = []; + $kept_blocks_by_column = []; + + foreach ( $column_blocks as $index => $column_block ) { + if ( ! empty( $converted_columns[ $index ] ) ) { + $kept_blocks[] = $column_block; + $kept_blocks_by_column[] = $converted_columns[ $index ]; } + } - $beehiiv_column = ColumnsBlockConverter::build_beehiiv_column( - $column_blocks, - $column_block, - $parent_vertical_align - ); + $widths = ColumnsBlockConverter::resolve_row_widths( $kept_blocks ); + $columns = []; - if ( ! empty( $beehiiv_column ) ) { - $beehiiv_columns[] = $beehiiv_column; - } + 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 + ) + ); } - return ColumnsBlockConverter::build_beehiiv_columns_block( $beehiiv_columns, $stack_on_mobile ); + $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> $wp_blocks Parsed inner blocks. + * @param array> $wp_blocks Parsed inner blocks. + * @param array>|null $nested_blocks Receives the inner blocks of nested columns. * @return array> * @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 ) { @@ -587,6 +648,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; diff --git a/includes/Newsletter/Converters/ColumnsBlockConverter.php b/includes/Newsletter/Converters/ColumnsBlockConverter.php index 1c8f510..9ddf1c1 100644 --- a/includes/Newsletter/Converters/ColumnsBlockConverter.php +++ b/includes/Newsletter/Converters/ColumnsBlockConverter.php @@ -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. * @@ -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> $column_blocks Parsed core/column blocks in the row. + * @return array 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 $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. * diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index ba93a96..9bd2a63 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -12,8 +12,9 @@ /** * Single source of truth for which WordPress blocks are included in newsletters. * - * Layout blocks such as `core/group` and `core/columns` are not supported. They - * and all of their inner blocks are omitted from the newsletter payload. + * Layout blocks such as `core/group` are not supported. They and all of their + * inner blocks are omitted from the newsletter payload. `core/columns` is supported, + * but only up to 3 columns; see `BlockConverter::convert_columns_block_to_blocks()`. * * @since 1.0.0 */ @@ -34,6 +35,7 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/columns', 'core/buttons', 'core/separator', 'beehiiv/advertisement', @@ -46,6 +48,7 @@ final class SupportedBlocks { */ public const NESTED_ONLY = [ 'core/button', + 'core/column', 'core/list-item', ]; diff --git a/tests/phpunit/BlockConverterColumnsTest.php b/tests/phpunit/BlockConverterColumnsTest.php new file mode 100644 index 0000000..bd125bd --- /dev/null +++ b/tests/phpunit/BlockConverterColumnsTest.php @@ -0,0 +1,303 @@ +\n
{$inner}
\n\n"; + } + + /** + * Build the saved markup for a core/columns block. + * + * @param array $columns Column markup. + * @param array $attrs Columns block attributes. + * @return string + */ + private function columns( array $columns, array $attrs = array() ): string { + $json = empty( $attrs ) ? '' : ' ' . wp_json_encode( $attrs ); + + $open = "\n
"; + $close = "
\n\n"; + + return $open . implode( '', $columns ) . $close; + } + + /** + * Saved markup for a paragraph block. + * + * @param string $text Paragraph text. + * @return string + */ + private function paragraph( string $text ): string { + return "\n

{$text}

\n"; + } + + /** + * Convert post content through the full newsletter conversion. + * + * @param string $content Post content. + * @return array> + */ + private function convert( string $content ): array { + $post = self::factory()->post->create_and_get( array( 'post_content' => $content ) ); + + return BlockConverter::convert_supported_blocks( $post ); + } + + /** + * AC-011, AC-012: one columns block, one column per column, inner blocks in order. + */ + public function test_columns_block_converts_to_one_columns_block_in_order(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'One' ) . $this->paragraph( 'Two' ) ), + $this->column( $this->paragraph( 'Three' ) ), + ) + ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'columns', $blocks[0]['type'] ); + $this->assertCount( 2, $blocks[0]['columns'] ); + $this->assertCount( 2, $blocks[0]['columns'][0]['blocks'] ); + $this->assertCount( 1, $blocks[0]['columns'][1]['blocks'] ); + $this->assertSame( 'paragraph', $blocks[0]['columns'][0]['blocks'][0]['type'] ); + } + + /** + * AC-012: blocks without a beehiiv equivalent are omitted from the column. + */ + public function test_unsupported_inner_block_is_omitted_from_column(): void { + $spacer = "\n
\n"; + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'Kept' ) . $spacer ), + ) + ) + ); + + $this->assertCount( 1, $blocks[0]['columns'][0]['blocks'] ); + } + + /** + * AC-013: percent widths become whole percents, and a missing width stays unset. + */ + public function test_percent_widths_round_per_column_and_missing_width_stays_unset(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), '33.33%' ), + $this->column( $this->paragraph( 'B' ), '33.33%' ), + $this->column( $this->paragraph( 'C' ) ), + ) + ) + ); + + $columns = $blocks[0]['columns']; + + $this->assertSame( 33, $columns[0]['width'] ); + $this->assertSame( 33, $columns[1]['width'] ); + $this->assertArrayNotHasKey( 'width', $columns[2] ); + } + + /** + * AC-014: px widths become each column's share of the row. + */ + public function test_pixel_widths_become_share_of_the_row(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), '100px' ), + $this->column( $this->paragraph( 'B' ), '300px' ), + ) + ) + ); + + $this->assertSame( 25, $blocks[0]['columns'][0]['width'] ); + $this->assertSame( 75, $blocks[0]['columns'][1]['width'] ); + } + + /** + * AC-014: with a non-percent width and one column missing a width, no column gets a width. + */ + public function test_pixel_widths_with_a_missing_width_leave_the_row_unset(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), '100px' ), + $this->column( $this->paragraph( 'B' ) ), + ) + ) + ); + + $this->assertArrayNotHasKey( 'width', $blocks[0]['columns'][0] ); + $this->assertArrayNotHasKey( 'width', $blocks[0]['columns'][1] ); + } + + /** + * AC-015: vertical alignment on a column or the Columns block is carried over. + */ + public function test_vertical_alignment_is_carried_over(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), null, 'center' ), + $this->column( $this->paragraph( 'B' ) ), + ), + array( 'verticalAlignment' => 'bottom' ) + ) + ); + + $this->assertSame( 'middle', $blocks[0]['columns'][0]['verticalAlign'] ); + $this->assertSame( 'bottom', $blocks[0]['columns'][1]['verticalAlign'] ); + } + + /** + * AC-016: columns stack on mobile by default and not when the block says so. + */ + public function test_stack_on_mobile_defaults_to_true(): void { + $stacked = $this->convert( $this->columns( array( $this->column( $this->paragraph( 'A' ) ) ) ) ); + $unstacked = $this->convert( + $this->columns( + array( $this->column( $this->paragraph( 'A' ) ) ), + array( 'isStackedOnMobile' => false ) + ) + ); + + $this->assertTrue( $stacked[0]['stackOnMobile'] ); + $this->assertFalse( $unstacked[0]['stackOnMobile'] ); + } + + /** + * AC-017: more than 3 columns fall back to top-level blocks, in order, with no columns block. + */ + public function test_more_than_three_columns_fall_back_to_top_level_blocks(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ) ), + $this->column( $this->paragraph( 'B' ) ), + $this->column( $this->paragraph( 'C' ) ), + $this->column( $this->paragraph( 'D' ) ), + ) + ) + ); + + $this->assertCount( 4, $blocks ); + + foreach ( $blocks as $block ) { + $this->assertSame( 'paragraph', $block['type'] ); + } + } + + /** + * AC-017: exactly three columns still convert to a columns block. + */ + public function test_three_columns_convert_to_a_columns_block(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ) ), + $this->column( $this->paragraph( 'B' ) ), + $this->column( $this->paragraph( 'C' ) ), + ) + ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertCount( 3, $blocks[0]['columns'] ); + } + + /** + * AC-018: nested columns put their inner blocks at the top level after the outer block. + */ + public function test_nested_columns_fall_back_to_top_level_blocks(): void { + $nested = $this->columns( + array( + $this->column( $this->paragraph( 'Nested one' ) ), + $this->column( $this->paragraph( 'Nested two' ) ), + ) + ); + + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'Outer' ) . $nested ), + ) + ) + ); + + $this->assertCount( 3, $blocks ); + $this->assertSame( 'columns', $blocks[0]['type'] ); + $this->assertCount( 1, $blocks[0]['columns'][0]['blocks'] ); + $this->assertSame( 'paragraph', $blocks[1]['type'] ); + $this->assertSame( 'paragraph', $blocks[2]['type'] ); + } + + /** + * AC-019: a three-column layout with an image, a heading plus paragraph, and a button. + */ + public function test_three_column_layout_with_image_heading_paragraph_and_button(): void { + $image = "\n
" + . "\"\"/
\n"; + $heading = "\n

Title

\n"; + $button = "\n
\n" + . '\n
\n"; + + $blocks = $this->convert( + $this->columns( + array( + $this->column( $image, '33.33%' ), + $this->column( $heading . $this->paragraph( 'Body' ), '33.33%' ), + $this->column( $button, '33.33%' ), + ) + ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertCount( 3, $blocks[0]['columns'] ); + $this->assertSame( 'image', $blocks[0]['columns'][0]['blocks'][0]['type'] ); + $this->assertSame( 'heading', $blocks[0]['columns'][1]['blocks'][0]['type'] ); + $this->assertSame( 'paragraph', $blocks[0]['columns'][1]['blocks'][1]['type'] ); + $this->assertSame( 'button', $blocks[0]['columns'][2]['blocks'][0]['type'] ); + } +} From caa9ecd88367387ef3f5f9163898976432375319 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 26 Oct 2026 23:46:06 +0800 Subject: [PATCH 02/13] test(newsletter-publishing): add E2E specs for block conversion PRDs Covers structured-content-blocks (AC-001 to AC-019) and block-conversion-framework (AC-001 to AC-008). Two specs assert known gaps and currently fail: table background color (AC-010) and a non-breaking-space paragraph (AC-005). --- .../specs/block-conversion-framework.spec.js | 351 ++++++++++ .../specs/structured-content-blocks.spec.js | 645 ++++++++++++++++++ 2 files changed, 996 insertions(+) create mode 100644 tests/e2e/specs/block-conversion-framework.spec.js create mode 100644 tests/e2e/specs/structured-content-blocks.spec.js diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js new file mode 100644 index 0000000..0288340 --- /dev/null +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -0,0 +1,351 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/07-newsletter-publishing/2-block-conversion/block-conversion-framework.prd.md (v1.0) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at: + * - includes/Newsletter/BlockConverter.php -- block walk, snippet mode, + * featured image, advertisement validation. + * - includes/Newsletter/SupportedBlocks.php -- which blocks are converted. + * + * Each test publishes a post through the REST endpoint the block editor uses + * (POST /wp/v2/posts/{id}, cookie + nonce), with "Send to Newsletter" on. + * The test-only `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php + * answers the beehiiv calls and records them, so each test asserts on the + * exact `blocks` payload the plugin sent. Nothing reaches the live API. + */ + +const META_SEND = '_beehiiv_send_to_newsletter'; +const META_SNIPPET = '_beehiiv_send_to_newsletter_snippet'; +const PUBLICATION_ID = 'pub_qa_e2e_bcf'; +const TEMPLATE_ID = 'tpl_qa_e2e_bcf'; +const IMG = 'https://example.com/qa-image.jpg'; + +const createdPosts = []; + +const b64 = ( value ) => + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded. + * + * @param {Object} payload Values exposed to the PHP snippet as `$a`. + * @param {string} php PHP code to evaluate. + * @return {string} wp-cli output. + */ +function wpEvalWith( payload, php ) { + return wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ php }'` + ); +} + +/** + * Seeds a connected, ready-to-send site and mocks the beehiiv endpoints. + * + * @param {string[]} activeAdIds Ad opportunity ids the mock reports as active. + */ +function seed( activeAdIds = [] ) { + wpEvalWith( + { ads: activeAdIds.map( ( id ) => ( { id } ) ) }, + `beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ "publication_id" => "${ PUBLICATION_ID }", "post_template_id" => "${ TEMPLATE_ID }" ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] ); + beehiiv_e2e_mock_http( "/advertisement_opportunities", [ "body" => [ "data" => $a["ads"] ] ] ); + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_bcf" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Logs in as admin and returns a REST caller bound to that session. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @return {Promise} `( method, path, data ) => { status, body }`. + */ +async function restAs( page ) { + await loginAsAdmin( page ); + const nonceRes = await page.request.get( + '/wp-admin/admin-ajax.php?action=rest-nonce' + ); + const nonce = ( await nonceRes.text() ).trim(); + return async ( method, path, data ) => { + const res = await page.request.fetch( `/wp-json${ path }`, { + method, + headers: { 'X-WP-Nonce': nonce }, + data, + } ); + let body = null; + try { + body = await res.json(); + } catch { + // Non-JSON response; leave body null. + } + return { status: res.status(), body }; + }; +} + +/** + * Publishes a post and returns the blocks sent to beehiiv. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} content Serialized block markup. + * @param {Object} [options] Post options. + * @param {boolean} [options.snippet] Turn snippet mode on. + * @param {boolean} [options.thumbnail] Attach a featured image. + * @param {string[]} [options.activeAdIds] Ad ids the mock reports as active. + * @param {boolean} [options.allowNoSend] Return null instead of failing when no create request is sent. + * @return {Promise} The `blocks` array of the create-post request. + */ +async function convert( + page, + content, + { + snippet = false, + thumbnail = false, + activeAdIds = [], + allowNoSend = false, + } = {} +) { + seed( activeAdIds ); + const id = wpEvalWith( + { content, snippet, thumbnail }, + `$id = wp_insert_post( [ "post_type" => "post", "post_title" => "QA E2E block conversion", "post_content" => $a["content"], "post_status" => "draft", "post_author" => 1 ] ); + update_post_meta( $id, "${ META_SEND }", "1" ); + if ( $a["snippet"] ) { update_post_meta( $id, "${ META_SNIPPET }", "1" ); } + if ( $a["thumbnail"] ) { + require_once ABSPATH . "wp-admin/includes/image.php"; + $upload = wp_upload_bits( "qa-e2e-bcf-" . $id . ".png", null, base64_decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ) ); + $att = wp_insert_attachment( [ "post_mime_type" => "image/png", "post_title" => "QA featured", "post_status" => "inherit" ], $upload["file"], $id ); + wp_update_attachment_metadata( $att, wp_generate_attachment_metadata( $att, $upload["file"] ) ); + set_post_thumbnail( $id, $att ); + } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + const rest = await restAs( page ); + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + const log = JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); + const create = log.find( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); + if ( allowNoSend && ! create ) { + return null; + } + expect( create, 'beehiiv create-post request was sent' ).toBeTruthy(); + return create.body.blocks || []; +} + +// --------------------------------------------------------------------------- +// Block markup builders. +// --------------------------------------------------------------------------- + +const p = ( text ) => + `

${ text }

`; +const h = ( text ) => + `

${ text }

`; +const img = () => + `
`; +const group = ( inner ) => + `
${ inner }
`; +const more = () => ``; +const ad = ( adId ) => + ``; + +const types = ( blocks ) => blocks.map( ( b ) => b.type ); +const textOf = ( block ) => + block.text || + ( block.formattedText || [] ).map( ( r ) => r.text ).join( '' ) || + block.plaintext || + ''; + +test.describe.configure( { timeout: 150 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); +} ); + +test.afterAll( async () => { + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { delete_post_meta( $id, "_beehiiv_post_id" ); wp_delete_post( $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: convert post blocks', () => { + test( 'AC-001: heading, paragraph and image blocks convert without content loss', async ( { + page, + } ) => { + const blocks = await convert( + page, + h( 'QA Heading Text' ) + p( 'QA paragraph body text.' ) + img() + ); + expect( types( blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'image', + ] ); + expect( blocks[ 0 ].text ).toBe( 'QA Heading Text' ); + expect( textOf( blocks[ 1 ] ) ).toBe( 'QA paragraph body text.' ); + expect( blocks[ 2 ].imageUrl ).toBe( IMG ); + } ); + + test( 'AC-002: separator block converts to a content break', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Before' ) + + '
' + + p( 'After' ) + ); + expect( types( blocks ) ).toEqual( [ + 'paragraph', + 'content_break', + 'paragraph', + ] ); + } ); + + test( 'AC-003: embed block converts to a link to the embedded content', async ( { + page, + } ) => { + const url = 'https://www.youtube.com/watch?v=qa-e2e'; + const blocks = await convert( + page, + `
\n${ url }\n
` + ); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ].type ).toBe( 'embed_link' ); + expect( blocks[ 0 ].url ).toBe( url ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: skip unsupported blocks', () => { + test( 'AC-004: blocks without a beehiiv equivalent are silently omitted', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Kept before' ) + + group( p( 'Inside group' ) + h( 'Group heading' ) ) + + '' + + p( 'Kept after' ) + ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'paragraph' ] ); + expect( blocks.map( textOf ) ).toEqual( [ + 'Kept before', + 'Kept after', + ] ); + } ); + + test( 'AC-005: empty and whitespace-only blocks are omitted', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Real text' ) + + '

' + + '

 

' + + '

' + + p( 'More text' ) + ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'paragraph' ] ); + expect( blocks.map( textOf ) ).toEqual( [ 'Real text', 'More text' ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: snippet mode', () => { + test( 'AC-006: snippet mode stops at the more block and adds a Read More button to the permalink in a new tab', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Teaser' ) + more() + p( 'Hidden after more' ), + { snippet: true } + ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'button' ] ); + expect( blocks[ 1 ].text ).toBe( 'Read More' ); + expect( blocks[ 1 ].target ).toBe( '_blank' ); + const id = createdPosts[ createdPosts.length - 1 ]; + const permalink = wpCli( `post get ${ id } --field=url` ); + expect( blocks[ 1 ].href ).toBe( permalink ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Hidden after more' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: featured image', () => { + test( 'AC-007: featured image is prepended and set to show in email only', async ( { + page, + } ) => { + const blocks = await convert( page, p( 'Body' ), { thumbnail: true } ); + expect( types( blocks ) ).toEqual( [ 'image', 'paragraph' ] ); + expect( blocks[ 0 ].imageUrl ).toContain( 'qa-e2e-bcf-' ); + expect( blocks[ 0 ].visibility_settings ).toEqual( { + show_on_email: true, + show_on_web: false, + } ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-005: advertisement validation', () => { + test( 'AC-008: advertisement blocks are included only for a valid, active ad', async ( { + page, + } ) => { + // Active ad: kept, with its opportunity id. + let blocks = await convert( page, p( 'Body' ) + ad( 'ad_active' ), { + activeAdIds: [ 'ad_active' ], + } ); + const kept = blocks.filter( ( b ) => b.type === 'advertisement' ); + expect( kept ).toHaveLength( 1 ); + expect( kept[ 0 ].opportunity_id ).toBe( 'ad_active' ); + + // Ad no longer active (deleted or expired): the block is never + // included. The send path rejects the post before conversion, so no + // create-post request goes out at all. + blocks = await convert( page, p( 'Body' ) + ad( 'ad_gone' ), { + activeAdIds: [ 'ad_other' ], + allowNoSend: true, + } ); + expect( blocks ).toBeNull(); + + // No ad selected: the block is never included either. + blocks = await convert( page, p( 'Body' ) + ad( '' ), { + activeAdIds: [ 'ad_active' ], + allowNoSend: true, + } ); + expect( blocks ).toBeNull(); + } ); +} ); diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js new file mode 100644 index 0000000..d69d075 --- /dev/null +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -0,0 +1,645 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/07-newsletter-publishing/2-block-conversion/structured-content-blocks.prd.md (v1.0) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at: + * - includes/Newsletter/BlockConverter.php -- block walk, columns routing. + * - includes/Newsletter/Converters/ColumnsBlockConverter.php -- widths, + * vertical alignment, mobile stacking. + * - includes/Newsletter/SupportedBlocks.php -- which blocks are converted. + * + * Each test publishes a post through the REST endpoint the block editor uses + * (POST /wp/v2/posts/{id}, cookie + nonce), with "Send to Newsletter" on. + * The test-only `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php + * answers the beehiiv call and records it, so each test asserts on the exact + * `blocks` payload the plugin sent. Nothing reaches the live API. + */ + +const META_SEND = '_beehiiv_send_to_newsletter'; +const PUBLICATION_ID = 'pub_qa_e2e_scb'; +const TEMPLATE_ID = 'tpl_qa_e2e_scb'; +const IMG = 'https://example.com/qa-image.jpg'; + +const createdPosts = []; + +const b64 = ( value ) => + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded. + * + * @param {Object} payload Values exposed to the PHP snippet as `$a`. + * @param {string} php PHP code to evaluate. + * @return {string} wp-cli output. + */ +function wpEvalWith( payload, php ) { + return wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ php }'` + ); +} + +/** + * Seeds a connected, ready-to-send site and mocks the beehiiv posts endpoint. + */ +function seed() { + wpEvalWith( + {}, + `beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ "publication_id" => "${ PUBLICATION_ID }", "post_template_id" => "${ TEMPLATE_ID }" ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] ); + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_scb" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Logs in as admin and returns a REST caller bound to that session. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @return {Promise} `( method, path, data ) => { status, body }`. + */ +async function restAs( page ) { + await loginAsAdmin( page ); + const nonceRes = await page.request.get( + '/wp-admin/admin-ajax.php?action=rest-nonce' + ); + const nonce = ( await nonceRes.text() ).trim(); + return async ( method, path, data ) => { + const res = await page.request.fetch( `/wp-json${ path }`, { + method, + headers: { 'X-WP-Nonce': nonce }, + data, + } ); + let body = null; + try { + body = await res.json(); + } catch { + // Non-JSON response; leave body null. + } + return { status: res.status(), body }; + }; +} + +/** + * Publishes a post with the given content and returns the blocks sent to beehiiv. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} content Serialized block markup. + * @return {Promise} The `blocks` array of the create-post request. + */ +async function convert( page, content ) { + seed(); + const id = wpEvalWith( + { content }, + `$id = wp_insert_post( [ "post_type" => "post", "post_title" => "QA E2E structured blocks", "post_content" => $a["content"], "post_status" => "draft", "post_author" => 1 ] ); + update_post_meta( $id, "${ META_SEND }", "1" ); + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + const rest = await restAs( page ); + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + const log = JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); + const create = log.find( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); + expect( create, 'beehiiv create-post request was sent' ).toBeTruthy(); + return create.body.blocks || []; +} + +// --------------------------------------------------------------------------- +// Block markup builders. +// --------------------------------------------------------------------------- + +const p = ( text ) => + `

${ text }

`; +const h = ( text ) => + `

${ text }

`; +const img = () => + `
`; +const btn = ( text ) => + ``; +const group = ( inner ) => + `
${ inner }
`; + +/** + * Builds one core/column block. + * + * @param {string} inner Inner block markup. + * @param {Object} [options] Column options. + * @param {string} [options.width] Width attribute and flex-basis, e.g. "20%". + * @param {string} [options.valign] verticalAlignment attribute value. + * @return {string} Serialized column. + */ +function col( inner, { width, valign } = {} ) { + const attrs = {}; + if ( width ) { + attrs.width = width; + } + if ( valign ) { + attrs.verticalAlignment = valign; + } + const json = Object.keys( attrs ).length + ? ` ${ JSON.stringify( attrs ) }` + : ''; + const cls = valign + ? `wp-block-column is-vertically-aligned-${ valign }` + : 'wp-block-column'; + const style = width ? ` style="flex-basis:${ width }"` : ''; + return `
${ inner }
`; +} + +/** + * Builds one core/columns block. + * + * @param {string[]} cols Serialized columns. + * @param {Object} [options] Columns options. + * @param {string} [options.valign] Block-level verticalAlignment. + * @param {boolean} [options.stacked] isStackedOnMobile value. + * @return {string} Serialized columns block. + */ +function columns( cols, { valign, stacked } = {} ) { + const attrs = {}; + if ( valign ) { + attrs.verticalAlignment = valign; + } + if ( stacked !== undefined ) { + attrs.isStackedOnMobile = stacked; + } + const json = Object.keys( attrs ).length + ? ` ${ JSON.stringify( attrs ) }` + : ''; + const cls = + 'wp-block-columns' + + ( stacked === false ? ' is-not-stacked-on-mobile' : '' ); + return `
${ cols.join( + '' + ) }
`; +} + +const types = ( blocks ) => blocks.map( ( b ) => b.type ); +const columnsBlocks = ( blocks ) => + blocks.filter( ( b ) => b.type === 'columns' ); + +/** + * Flattens the plain text of a list item's formatted text runs. + * + * @param {Object} item List item from the payload. + * @return {string} Item text. + */ +const itemText = ( item ) => + typeof item === 'string' + ? item + : ( item.formattedText || [] ).map( ( r ) => r.text ).join( '' ); + +test.describe.configure( { timeout: 150 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); +} ); + +test.afterAll( async () => { + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { delete_post_meta( $id, "_beehiiv_post_id" ); wp_delete_post( $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: nested lists', () => { + const nested = ( parentAttrs, childAttrs ) => + `
  • Parent
    • Child
      • Grand
`; + + test( 'AC-001: nested lists flatten into one list with indentation per level', async ( { + page, + } ) => { + const blocks = await convert( page, nested( '', '' ) ); + const lists = blocks.filter( ( b ) => b.type === 'list' ); + expect( lists ).toHaveLength( 1 ); + const texts = lists[ 0 ].items.map( itemText ); + expect( texts ).toHaveLength( 3 ); + expect( texts[ 0 ] ).toBe( 'Parent' ); + const indent = ( s ) => s.match( /^(\s*-\s*)*/ )[ 0 ].length; + expect( texts[ 1 ] ).toContain( 'Child' ); + expect( texts[ 2 ] ).toContain( 'Grand' ); + expect( indent( texts[ 1 ] ) ).toBeGreaterThan( indent( texts[ 0 ] ) ); + expect( indent( texts[ 2 ] ) ).toBeGreaterThan( indent( texts[ 1 ] ) ); + } ); + + test( 'AC-002: parent text color cascades to children unless a child overrides it', async ( { + page, + } ) => { + const cascade = await convert( + page, + nested( ' {"textColor":"vivid-red"}', '' ) + ); + const colors = cascade[ 0 ].items.map( + ( i ) => i.formattedText[ 0 ].text_color + ); + expect( colors[ 0 ] ).toBeTruthy(); + expect( colors[ 1 ] ).toBe( colors[ 0 ] ); + expect( colors[ 2 ] ).toBe( colors[ 0 ] ); + + const override = await convert( + page, + nested( + ' {"textColor":"vivid-red"}', + ' {"textColor":"luminous-vivid-amber"}' + ) + ); + const oc = override[ 0 ].items.map( + ( i ) => i.formattedText[ 0 ].text_color + ); + expect( oc[ 1 ] ).toBeTruthy(); + expect( oc[ 1 ] ).not.toBe( oc[ 0 ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: tables', () => { + const table = ( extraAttrs = '', extraStyle = '' ) => + `
NameQtyApple3Total3
`; + + test( 'AC-003: header rows are marked and the footer is omitted', async ( { + page, + } ) => { + const blocks = await convert( page, table() ); + const t = blocks.find( ( b ) => b.type === 'table' ); + expect( t ).toBeTruthy(); + expect( t.headerRow ).toBe( true ); + expect( t.rows[ 0 ].map( ( c ) => c.text ) ).toEqual( [ + 'Name', + 'Qty', + ] ); + const all = JSON.stringify( t.rows ); + expect( all ).not.toContain( 'Total' ); + expect( t.rows ).toHaveLength( 2 ); + } ); + + test( 'AC-004: table columns and cell alignment are preserved', async ( { + page, + } ) => { + const blocks = await convert( page, table() ); + const t = blocks.find( ( b ) => b.type === 'table' ); + expect( t.rows.every( ( r ) => r.length === 2 ) ).toBe( true ); + expect( t.rows[ 0 ][ 0 ].alignment ).toBe( 'left' ); + expect( t.rows[ 0 ][ 1 ].alignment ).toBe( 'right' ); + expect( t.rows[ 1 ][ 1 ].alignment ).toBe( 'right' ); + } ); + + test( 'AC-010: block background colors are applied to lists and tables', async ( { + page, + } ) => { + const listBlocks = await convert( + page, + `
  • Item
` + ); + const list = listBlocks.find( ( b ) => b.type === 'list' ); + expect( JSON.stringify( list ).toLowerCase() ).toContain( '#ffeeaa' ); + + const tableBlocks = await convert( + page, + table( + ' {"style":{"color":{"background":"#eeeeee"}}}', + ' class="has-background" style="background-color:#eeeeee"' + ) + ); + const t = tableBlocks.find( ( b ) => b.type === 'table' ); + expect( t ).toBeTruthy(); + expect( JSON.stringify( t ).toLowerCase() ).toContain( '#eeeeee' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: media-text', () => { + const mediaText = ( { + position = 'left', + width = 30, + type = 'image', + src = IMG, + inner = p( 'Side text' ), + } = {} ) => { + const attrs = JSON.stringify( { + mediaPosition: position, + mediaId: 1, + mediaType: type, + mediaWidth: width, + } ); + let mediaInner = ''; + if ( src && type === 'video' ) { + mediaInner = ``; + } else if ( src ) { + mediaInner = `a`; + } + const media = `
${ mediaInner }
`; + const content = `
${ inner }
`; + const right = position === 'right'; + return `
${ + right ? content + media : media + content + }
`; + }; + + test( 'AC-005: media-text converts to two columns with the configured widths', async ( { + page, + } ) => { + const blocks = await convert( page, mediaText( { width: 30 } ) ); + const cols = columnsBlocks( blocks ); + expect( cols ).toHaveLength( 1 ); + expect( cols[ 0 ].columns ).toHaveLength( 2 ); + expect( cols[ 0 ].columns.map( ( c ) => c.width ) ).toEqual( [ + 30, 70, + ] ); + expect( cols[ 0 ].columns[ 0 ].blocks[ 0 ].type ).toBe( 'image' ); + expect( cols[ 0 ].columns[ 1 ].blocks[ 0 ].type ).toBe( 'paragraph' ); + } ); + + test( 'AC-006: media on the right reorders the columns', async ( { + page, + } ) => { + const blocks = await convert( + page, + mediaText( { position: 'right', width: 30 } ) + ); + const cols = columnsBlocks( blocks )[ 0 ].columns; + expect( cols[ 0 ].blocks[ 0 ].type ).toBe( 'paragraph' ); + expect( cols[ 1 ].blocks[ 0 ].type ).toBe( 'image' ); + expect( cols.map( ( c ) => c.width ) ).toEqual( [ 70, 30 ] ); + } ); + + test( 'AC-007: video media-text is omitted entirely', async ( { + page, + } ) => { + const blocks = await convert( + page, + mediaText( { type: 'video', src: 'https://example.com/v.mp4' } ) + + p( 'After video' ) + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Side text' ); + } ); + + test( 'AC-009: media-text with no valid image URL is omitted', async ( { + page, + } ) => { + const blocks = await convert( + page, + mediaText( { src: '' } ) + p( 'After media-text' ) + ); + expect( columnsBlocks( blocks ) ).toHaveLength( 0 ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Side text' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: columns block widths, alignment and stacking', () => { + test( 'AC-008: columns keep widths, vertical alignment and the stacking setting', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( + [ + col( p( 'A' ), { width: '30%', valign: 'bottom' } ), + col( p( 'B' ), { width: '70%', valign: 'top' } ), + ], + { stacked: false } + ) + ); + const block = columnsBlocks( blocks )[ 0 ]; + expect( block.columns.map( ( c ) => c.width ) ).toEqual( [ 30, 70 ] ); + expect( block.columns.map( ( c ) => c.verticalAlign ) ).toEqual( [ + 'bottom', + 'top', + ] ); + expect( block.stackOnMobile ).toBe( false ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-006: top-level Columns blocks', () => { + test( 'AC-011: a top-level Columns block becomes one columns block, one column each, in order', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ col( p( 'One' ) ), col( p( 'Two' ) ) ] ) + ); + expect( types( blocks ) ).toEqual( [ 'columns' ] ); + const cols = blocks[ 0 ].columns; + expect( cols ).toHaveLength( 2 ); + expect( cols[ 0 ].blocks[ 0 ].plaintext ).toBe( 'One' ); + expect( cols[ 1 ].blocks[ 0 ].plaintext ).toBe( 'Two' ); + } ); + + test( 'AC-012: a column keeps its inner blocks in order and omits blocks beehiiv does not accept', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ + col( h( 'Title' ) + p( 'Body' ) + btn( 'Go' ) ), + col( group( p( 'Inside group' ) ) ), + col( p( 'Kept' ) ), + ] ) + ); + const cols = columnsBlocks( blocks )[ 0 ].columns; + expect( types( cols[ 0 ].blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'button', + ] ); + // The column holding only an unsupported group is omitted. + expect( cols ).toHaveLength( 2 ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Inside group' ); + expect( cols[ 1 ].blocks[ 0 ].plaintext ).toBe( 'Kept' ); + } ); + + test( 'AC-013: percent widths become whole percents and a missing width stays unset', async ( { + page, + } ) => { + const withWidths = await convert( + page, + columns( [ + col( p( 'A' ), { width: '33.33%' } ), + col( p( 'B' ), { width: '66.67%' } ), + ] ) + ); + expect( + columnsBlocks( withWidths )[ 0 ].columns.map( ( c ) => c.width ) + ).toEqual( [ 33, 67 ] ); + + const noWidths = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) + ); + for ( const c of columnsBlocks( noWidths )[ 0 ].columns ) { + expect( c ).not.toHaveProperty( 'width' ); + } + } ); + + test( "AC-014: px widths become each column's share of the row, and one missing width clears the row", async ( { + page, + } ) => { + const px = await convert( + page, + columns( [ + col( p( 'A' ), { width: '100px' } ), + col( p( 'B' ), { width: '300px' } ), + ] ) + ); + expect( + columnsBlocks( px )[ 0 ].columns.map( ( c ) => c.width ) + ).toEqual( [ 25, 75 ] ); + + const rem = await convert( + page, + columns( [ + col( p( 'A' ), { width: '10rem' } ), + col( p( 'B' ), { width: '10rem' } ), + col( p( 'C' ), { width: '20rem' } ), + ] ) + ); + expect( + columnsBlocks( rem )[ 0 ].columns.map( ( c ) => c.width ) + ).toEqual( [ 25, 25, 50 ] ); + + const partial = await convert( + page, + columns( [ col( p( 'A' ), { width: '100px' } ), col( p( 'B' ) ) ] ) + ); + for ( const c of columnsBlocks( partial )[ 0 ].columns ) { + expect( c ).not.toHaveProperty( 'width' ); + } + } ); + + test( 'AC-015: vertical alignment from the column or the Columns block becomes top, middle or bottom', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( + [ + col( p( 'A' ), { valign: 'center' } ), + col( p( 'B' ), { valign: 'bottom' } ), + col( p( 'C' ) ), + ], + { valign: 'top' } + ) + ); + expect( + columnsBlocks( blocks )[ 0 ].columns.map( ( c ) => c.verticalAlign ) + ).toEqual( [ 'middle', 'bottom', 'top' ] ); + } ); + + test( 'AC-016: columns stack on mobile by default and not when the block opts out', async ( { + page, + } ) => { + const def = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) + ); + expect( columnsBlocks( def )[ 0 ].stackOnMobile ).toBe( true ); + + const off = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { stacked: false } ) + ); + expect( columnsBlocks( off )[ 0 ].stackOnMobile ).toBe( false ); + } ); + + test( 'AC-017: a Columns block with more than 3 columns becomes top-level blocks', async ( { + page, + } ) => { + const four = await convert( + page, + columns( [ + col( p( 'A' ) ), + col( p( 'B' ) ), + col( p( 'C' ) ), + col( p( 'D' ) ), + ] ) + ); + expect( columnsBlocks( four ) ).toHaveLength( 0 ); + expect( four.map( ( b ) => b.plaintext ) ).toEqual( [ + 'A', + 'B', + 'C', + 'D', + ] ); + + // Control: exactly 3 columns still converts to one columns block. + const three = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ), col( p( 'C' ) ) ] ) + ); + expect( columnsBlocks( three ) ).toHaveLength( 1 ); + expect( columnsBlocks( three )[ 0 ].columns ).toHaveLength( 3 ); + } ); + + test( 'AC-018: a Columns block nested in a column becomes top-level blocks', async ( { + page, + } ) => { + const nested = columns( [ col( p( 'N1' ) ), col( p( 'N2' ) ) ] ); + const blocks = await convert( + page, + columns( [ col( p( 'Outer A' ) + nested ), col( p( 'Outer B' ) ) ] ) + ); + const cols = columnsBlocks( blocks ); + expect( cols ).toHaveLength( 1 ); + expect( JSON.stringify( cols[ 0 ].columns ) ).not.toContain( 'N1' ); + expect( JSON.stringify( cols[ 0 ].columns ) ).not.toContain( + '"columns":[{"blocks":[{"type":"paragraph","plaintext":"N1"' + ); + const top = blocks.slice( 1 ); + expect( top.map( ( b ) => b.plaintext ) ).toEqual( [ 'N1', 'N2' ] ); + } ); + + test( 'AC-019: a 3-column layout with an image, heading plus paragraph, and a button converts to one columns block', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ + col( img() ), + col( h( 'Head' ) + p( 'Para' ) ), + col( btn( 'Click' ) ), + ] ) + ); + expect( types( blocks ) ).toEqual( [ 'columns' ] ); + const cols = blocks[ 0 ].columns; + expect( cols ).toHaveLength( 3 ); + expect( types( cols[ 0 ].blocks ) ).toEqual( [ 'image' ] ); + expect( types( cols[ 1 ].blocks ) ).toEqual( [ + 'heading', + 'paragraph', + ] ); + expect( types( cols[ 2 ].blocks ) ).toEqual( [ 'button' ] ); + } ); +} ); From eb847c0ab39ddbdc23cbac285407d0242c344c74 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 01:18:13 +0800 Subject: [PATCH 03/13] fix(newsletter-publishing): send table block background color to beehiiv A table with a block background color sent no color. The color is now read from the block attributes, the table tag, or the figure wrapper, and sent as visual settings the same way lists and paragraphs send it. --- includes/Newsletter/BlockConverter.php | 15 +++- includes/Newsletter/FormattedTextParser.php | 26 ++++++ .../BlockConverterTableBackgroundTest.php | 87 +++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/phpunit/BlockConverterTableBackgroundTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 1b28485..123cedb 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -694,7 +694,8 @@ private static function convert_layout_block_inner_blocks( * 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 $wp_block Parsed block. @@ -751,11 +752,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; } /** diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php index 389f675..07b208b 100644 --- a/includes/Newsletter/FormattedTextParser.php +++ b/includes/Newsletter/FormattedTextParser.php @@ -269,6 +269,32 @@ public static function resolve_list_block_background_color( string $inner_html, return self::resolve_list_wrapper_color_from_html( $inner_html, $attrs, 'background' ); } + /** + * Resolve a table block's background colour from attrs or saved HTML. + * + * Reads the `` tag first, then the `
` wrapper. + * + * @param string $inner_html Saved table HTML. + * @param array $attrs Parsed block attrs. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_table_block_background_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_background_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + $from_table = self::resolve_wrapper_color_from_html_tag( $inner_html, 'table', 'background' ); + + if ( null !== $from_table ) { + return $from_table; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'figure', 'background' ); + } + /** * Walk HTML and collect formatted text segments. * diff --git a/tests/phpunit/BlockConverterTableBackgroundTest.php b/tests/phpunit/BlockConverterTableBackgroundTest.php new file mode 100644 index 0000000..21247b3 --- /dev/null +++ b/tests/phpunit/BlockConverterTableBackgroundTest.php @@ -0,0 +1,87 @@ + $attrs Block attributes. + * @param string $table_tag Opening table tag. + * @param string $figure_tag Opening figure tag. + * @return array + */ + private function table_block( + array $attrs, + string $table_tag = '
', + string $figure_tag = '
' + ): array { + return array( + 'blockName' => 'core/table', + 'attrs' => $attrs, + 'innerBlocks' => array(), + 'innerHTML' => $figure_tag . $table_tag + . '
Apple3
', + ); + } + + /** + * A colour set in the block attributes is sent. + */ + public function test_background_color_from_block_attributes(): void { + $block = BlockConverter::convert_table_block( + $this->table_block( array( 'style' => array( 'color' => array( 'background' => '#eeeeee' ) ) ) ) + ); + + $this->assertSame( '#eeeeee', $block['visual_settings']['background_color'] ); + } + + /** + * A colour on the table tag is sent. + */ + public function test_background_color_from_table_tag(): void { + $block = BlockConverter::convert_table_block( + $this->table_block( array(), '' ) + ); + + $this->assertSame( '#ffeeaa', $block['visual_settings']['background_color'] ); + } + + /** + * A colour on the figure wrapper is sent. + */ + public function test_background_color_from_figure_wrapper(): void { + $block = BlockConverter::convert_table_block( + $this->table_block( + array(), + '
', + '
' + ) + ); + + $this->assertSame( '#112233', $block['visual_settings']['background_color'] ); + } + + /** + * A table without a background colour sends no visual settings. + */ + public function test_no_background_color_sends_no_visual_settings(): void { + $block = BlockConverter::convert_table_block( $this->table_block( array() ) ); + + $this->assertArrayNotHasKey( 'visual_settings', $block ); + } +} From 711d803162c9f9fac15732cbcd3c5a99f5086e06 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 01:21:13 +0800 Subject: [PATCH 04/13] fix(newsletter-publishing): omit paragraphs that hold only a non-breaking space A paragraph with only a non-breaking space was still sent as a text block. The check now treats non-breaking spaces as blank. --- includes/Newsletter/BlockConverter.php | 2 +- tests/phpunit/BlockConverterParagraphTest.php | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/BlockConverterParagraphTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 123cedb..fe92532 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -342,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 []; } diff --git a/tests/phpunit/BlockConverterParagraphTest.php b/tests/phpunit/BlockConverterParagraphTest.php new file mode 100644 index 0000000..62e3edc --- /dev/null +++ b/tests/phpunit/BlockConverterParagraphTest.php @@ -0,0 +1,68 @@ + + */ + private function paragraph_block( string $inner_html ): array { + return array( + 'blockName' => 'core/paragraph', + 'attrs' => array(), + 'innerBlocks' => array(), + 'innerHTML' => $inner_html, + ); + } + + /** + * Paragraphs with no visible text are omitted. + * + * @return array> + */ + public function blank_paragraph_provider(): array { + return array( + 'empty' => array( '

' ), + 'spaces' => array( '

' ), + 'non-breaking space' => array( '

 

' ), + 'spaces and nbsp' => array( '

 

' ), + 'several nbsp' => array( '

  

' ), + ); + } + + /** + * A paragraph with no visible text is omitted. + * + * @dataProvider blank_paragraph_provider + * + * @param string $inner_html Saved paragraph HTML. + */ + public function test_blank_paragraph_is_omitted( string $inner_html ): void { + $this->assertSame( array(), BlockConverter::convert_paragraph_block( $this->paragraph_block( $inner_html ) ) ); + } + + /** + * A paragraph with real text is kept. + */ + public function test_paragraph_with_text_is_kept(): void { + $block = BlockConverter::convert_paragraph_block( $this->paragraph_block( '

Real text

' ) ); + + $this->assertSame( 'paragraph', $block['type'] ); + } +} From 238514a7a0735fba658b57df4a60d0951db5f61a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 01:24:46 +0800 Subject: [PATCH 05/13] fix(newsletter-publishing): omit Media & Text blocks that have no valid image The PRD says a Media & Text block with no valid image URL is omitted. The converter kept the text column instead. The whole block is now omitted. --- includes/Newsletter/BlockConverter.php | 12 +++- tests/phpunit/BlockConverterMediaTextTest.php | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/BlockConverterMediaTextTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index fe92532..6df79c2 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -1046,8 +1046,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 $wp_block Parsed block. * @return array @@ -1067,7 +1068,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 ) { diff --git a/tests/phpunit/BlockConverterMediaTextTest.php b/tests/phpunit/BlockConverterMediaTextTest.php new file mode 100644 index 0000000..ee91fcd --- /dev/null +++ b/tests/phpunit/BlockConverterMediaTextTest.php @@ -0,0 +1,65 @@ + + */ + private function media_text_block( string $media_html ): array { + $html = '
' + . $media_html . '
'; + + return array( + 'blockName' => 'core/media-text', + 'attrs' => array( + 'mediaType' => 'image', + 'mediaWidth' => 30, + ), + 'innerBlocks' => array( + array( + 'blockName' => 'core/paragraph', + 'attrs' => array(), + 'innerBlocks' => array(), + 'innerHTML' => '

Side text

', + ), + ), + 'innerHTML' => $html, + ); + } + + /** + * With no image URL the whole block is omitted, text included. + */ + public function test_media_text_without_image_url_is_omitted(): void { + $this->assertSame( array(), BlockConverter::convert_media_text_block( $this->media_text_block( '' ) ) ); + } + + /** + * With a valid image URL the block converts to a columns block. + */ + public function test_media_text_with_image_url_converts(): void { + $block = BlockConverter::convert_media_text_block( + $this->media_text_block( '' ) + ); + + $this->assertSame( 'columns', $block['type'] ); + $this->assertCount( 2, $block['columns'] ); + } +} From 1ad7097e6d9b3f5989234581ccfae9d4ac2a0f86 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 19:17:14 +0800 Subject: [PATCH 06/13] feat(editor): mark omitted blocks on the block itself while the beehiiv sidebar is open The marker adds a class and tooltip to the block's own element instead of wrapping the block, shows only while the beehiiv sidebar is open, keeps the hidden-eye icon visible, and also covers posts linked to a scheduled newsletter. --- .../components/omitted-blocks-notice.js | 23 ++ src/js/editor/post-settings/constants.js | 11 + .../post-settings/css/_omitted-blocks.scss | 44 +-- src/js/editor/post-settings/css/_sidebar.scss | 1 - .../filters/with-omitted-block-indicator.js | 78 ++--- .../hooks/use-is-sidebar-open.js | 18 ++ src/js/editor/post-settings/index.js | 9 +- .../e2e/specs/omitted-block-indicator.spec.js | 267 ++++++++++++++++++ 8 files changed, 382 insertions(+), 69 deletions(-) create mode 100644 src/js/editor/post-settings/constants.js create mode 100644 src/js/editor/post-settings/hooks/use-is-sidebar-open.js create mode 100644 tests/e2e/specs/omitted-block-indicator.spec.js diff --git a/src/js/editor/post-settings/components/omitted-blocks-notice.js b/src/js/editor/post-settings/components/omitted-blocks-notice.js index 0eaf0bd..bbf2948 100644 --- a/src/js/editor/post-settings/components/omitted-blocks-notice.js +++ b/src/js/editor/post-settings/components/omitted-blocks-notice.js @@ -3,6 +3,7 @@ */ import { _n, sprintf } from '@wordpress/i18n'; +import PostSettingsNotice from './post-settings-notice'; import { useOmittedBlockCount } from '../hooks/use-omitted-blocks'; /** @@ -35,3 +36,25 @@ export function OmittedBlocksNoticeMessage() {

); } + +/** + * Standalone warning for a linked post whose newsletter is still scheduled. + * + * A linked post has no send warning to hold the message, so it gets its own + * notice, shown only when a block will be omitted. + * + * @return {import('react').JSX.Element|null} Warning notice or null. + */ +export function OmittedBlocksNotice() { + const omittedBlockCount = useOmittedBlockCount(); + + if ( omittedBlockCount < 1 ) { + return null; + } + + return ( + + + + ); +} diff --git a/src/js/editor/post-settings/constants.js b/src/js/editor/post-settings/constants.js new file mode 100644 index 0000000..77ca223 --- /dev/null +++ b/src/js/editor/post-settings/constants.js @@ -0,0 +1,11 @@ +/** + * Names of the beehiiv editor plugin and its sidebar. + */ + +export const PLUGIN_NAME = 'beehiiv-post-settings'; +export const SIDEBAR_NAME = 'beehiiv-post-settings'; + +/** + * Complementary area id WordPress reports while the beehiiv sidebar is open. + */ +export const SIDEBAR_AREA = `${ PLUGIN_NAME }/${ SIDEBAR_NAME }`; diff --git a/src/js/editor/post-settings/css/_omitted-blocks.scss b/src/js/editor/post-settings/css/_omitted-blocks.scss index 9062b92..9ed1697 100644 --- a/src/js/editor/post-settings/css/_omitted-blocks.scss +++ b/src/js/editor/post-settings/css/_omitted-blocks.scss @@ -12,41 +12,21 @@ box-sizing: border-box; min-height: 24px; position: relative; - width: 100%; - // Invisible hit area so the icon above the block stays hoverable. - &::before { + // Hidden-eye icon above the block's top right corner, drawn on the block itself. + &::after { bottom: 100%; - content: ""; - height: 24px; + color: beehiiv.$beehiiv-status-disconnected; + content: "\f530"; + font-family: dashicons, sans-serif; + font-size: 16px; + height: 20px; + line-height: 18px; + pointer-events: none; position: absolute; - right: 0; - width: 28px; + right: 2px; + width: 20px; + z-index: 30; } } - -.editor-styles-wrapper .beehiiv-omitted-block__icon { - bottom: 100%; - color: beehiiv.$beehiiv-status-disconnected; - cursor: default; - display: block; - font-family: dashicons, sans-serif; - font-size: 16px; - height: 20px; - line-height: 18px; - opacity: 0; - pointer-events: auto; - position: absolute; - right: 2px; - transition: opacity 0.15s ease; - width: 20px; - z-index: 30; -} - -.editor-styles-wrapper .beehiiv-omitted-block__icon:hover, -.editor-styles-wrapper - .beehiiv-omitted-block:has(:hover) - .beehiiv-omitted-block__icon { - opacity: 1; -} /* stylelint-enable selector-class-pattern */ diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss index 7a46f98..ed10cb8 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -34,7 +34,6 @@ align-items: flex-start; display: flex; gap: 6px; - margin-top: 10px !important; } .beehiiv-post-settings-notice__omitted-icon { diff --git a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js index e81ef86..10b8275 100644 --- a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js +++ b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js @@ -6,51 +6,63 @@ import { createHigherOrderComponent } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; import { useBeehiivPostMeta } from '../hooks/use-beehiiv-post-meta'; +import { useIsBeehiivSidebarOpen } from '../hooks/use-is-sidebar-open'; import { useIsOmittedBlock } from '../hooks/use-omitted-blocks'; // Custom hook to determine if a block should show an omitted block indicator. function useShowOmittedBlockIndicators( clientId ) { const beehiivMeta = useBeehiivPostMeta(); const isOmittedBlock = useIsOmittedBlock( clientId ); + const isSidebarOpen = useIsBeehiivSidebarOpen(); - return ( - !! beehiivMeta?.sendToNewsletter && - ! beehiivMeta.newsletterAlreadySent && - isOmittedBlock - ); + // A linked newsletter that is still scheduled keeps syncing edits, so its + // omitted blocks are marked too. A sent newsletter no longer changes. + const isQueued = + !! beehiivMeta?.sendToNewsletter && ! beehiivMeta.newsletterAlreadySent; + const isScheduled = + !! beehiivMeta?.newsletterAlreadySent && + ! beehiivMeta.newsletterPublished; + + // The marks only show while the beehiiv sidebar is open. + return isSidebarOpen && ( isQueued || isScheduled ) && isOmittedBlock; } -// Higher order component to add a visual indicator to blocks that are omitted from the newsletter. -const withOmittedBlockIndicator = createHigherOrderComponent( ( BlockEdit ) => { - return function BeehiivOmittedBlockEdit( props ) { - const showIndicator = useShowOmittedBlockIndicators( props.clientId ); - - if ( ! showIndicator ) { - return ; - } - - return ( -
- - - { + return function BeehiivOmittedBlockListBlock( props ) { + const showIndicator = useShowOmittedBlockIndicators( + props.clientId + ); + + if ( ! showIndicator ) { + return ; + } + + return ( + -
- ); - }; -}, 'withOmittedBlockIndicator' ); + ); + }; + }, + 'withOmittedBlockIndicator' +); addFilter( - 'editor.BlockEdit', + 'editor.BlockListBlock', 'beehiiv/omitted-block-indicator', withOmittedBlockIndicator ); diff --git a/src/js/editor/post-settings/hooks/use-is-sidebar-open.js b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js new file mode 100644 index 0000000..54ccf13 --- /dev/null +++ b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js @@ -0,0 +1,18 @@ +/** + * Whether the beehiiv sidebar panel is the open editor sidebar. + */ +import { useSelect } from '@wordpress/data'; + +import { SIDEBAR_AREA } from '../constants'; + +/** + * @return {boolean} True while the beehiiv sidebar is open. + */ +export function useIsBeehiivSidebarOpen() { + return useSelect( + ( select ) => + SIDEBAR_AREA === + select( 'core/interface' )?.getActiveComplementaryArea( 'core' ), + [] + ); +} diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index 636895f..7d1659d 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -26,18 +26,20 @@ import PostSettingsNotice from './components/post-settings-notice'; import SendNewsletterToggle from './components/send-newsletter-toggle'; import TestEmailSend from './components/test-email-send'; import IncompleteAdvertisementNotice from './components/incomplete-advertisement-notice'; -import { OmittedBlocksNoticeMessage } from './components/omitted-blocks-notice'; +import { + OmittedBlocksNotice, + OmittedBlocksNoticeMessage, +} from './components/omitted-blocks-notice'; import { useBeehiivEditorConfig } from './hooks/use-beehiiv-editor-config'; import { useBeehiivPostMeta } from './hooks/use-beehiiv-post-meta'; import { usePostPublication } from './hooks/use-post-publication'; import './filters/with-omitted-block-indicator'; import './editor.scss'; +import { PLUGIN_NAME, SIDEBAR_NAME } from './constants'; const sidebarIcon = ; -const PLUGIN_NAME = 'beehiiv-post-settings'; -const SIDEBAR_NAME = 'beehiiv-post-settings'; const PRE_PUBLISH_PANEL_NAME = 'beehiiv-send-newsletter'; function usePostPublishDate() { @@ -214,6 +216,7 @@ function BeehiivPostSettingsPanel() { { newsletterAlreadySent && isNewsletterReady && ( <> + { ! newsletterPublished && } + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded. + * + * @param {Object} payload Values exposed to the PHP snippet as `$a`. + * @param {string} php PHP code to evaluate. + * @return {string} wp-cli output. + */ +function wpEvalWith( payload, php ) { + return wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ php }'` + ); +} + +/** + * Seeds a connected site that is ready to send. + */ +function seed() { + wpEvalWith( + {}, + `beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ "publication_id" => "${ PUBLICATION_ID }", "post_template_id" => "${ TEMPLATE_ID }" ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a post fixture in the DB. + * + * @param {Object} options Post fields. + * @param {string} options.title Post title. + * @param {string} options.content Post content (block markup). + * @param {Object} [options.meta] Post meta to set. + * @param {string} [options.status] Post status. + * @return {string} Post ID. + */ +function createPost( { title, content, meta = {}, status = 'draft' } ) { + const post = { + post_type: 'post', + post_title: title, + post_content: content, + post_status: status, + post_author: 1, + }; + const id = wpEvalWith( + { post, meta }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +/** + * Opens or closes the beehiiv sidebar, whatever state the editor remembered. + * + * @param {import('@playwright/test').Page} page Editor page. + * @param {boolean} open Whether the sidebar should be open. + */ +async function setBeehiivSidebar( page, open ) { + const content = page.locator( '.beehiiv-post-settings-content' ); + const remembered = await content + .waitFor( { state: 'visible', timeout: 3000 } ) + .then( () => true ) + .catch( () => false ); + if ( remembered !== open ) { + await page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first() + .click(); + } + if ( open ) { + await expect( content ).toBeVisible(); + } else { + await expect( content ).toBeHidden(); + } +} + +/** + * Meta for a post linked to a beehiiv newsletter that sends at the given time. + * + * @param {string} scheduledAt UTC ISO 8601 send time. + * @return {Object} Post meta. + */ +function linkedMeta( scheduledAt ) { + return { + [ META.postId ]: 'post_qa_e2e_omitted', + _beehiiv_publication_id: PUBLICATION_ID, + _beehiiv_post_template_id: TEMPLATE_ID, + _beehiiv_linked_publication_id: PUBLICATION_ID, + _beehiiv_linked_post_template_id: TEMPLATE_ID, + [ META.send ]: '', + _beehiiv_scheduled_at: scheduledAt, + }; +} + +const p = ( text ) => + `

${ text }

`; +// A Code block has no beehiiv equivalent, so the newsletter leaves it out. +const code = ( text ) => + `
${ text }
`; + +test.describe.configure( { timeout: 150 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); +} ); + +test.afterAll( async () => { + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { delete_post_meta( $id, "${ + META.postId + }" ); wp_delete_post( $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +test.describe( 'Omitted block marks in the editor', () => { + test( 'a queued post marks the omitted block on the block itself, only while the sidebar is open', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E omitted block', + content: p( 'Kept text' ) + code( 'Left out' ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + await setBeehiivSidebar( page, true ); + + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + const marked = canvas.locator( '.beehiiv-omitted-block' ); + await expect( marked ).toHaveCount( 1 ); + + // The class sits on the Code block's own element; no wrapper element is added. + await expect( + canvas.locator( '.wp-block-code.beehiiv-omitted-block' ) + ).toHaveCount( 1 ); + await expect( + canvas.locator( '.beehiiv-omitted-block:not(.wp-block)' ) + ).toHaveCount( 0 ); + + // The hidden-eye icon is always drawn, without hovering the block. + const icon = await marked.evaluate( ( el ) => { + const style = window.getComputedStyle( el, '::after' ); + return { content: style.content, opacity: style.opacity }; + } ); + expect( icon.content ).not.toBe( 'none' ); + expect( icon.opacity ).toBe( '1' ); + + // Closing the beehiiv sidebar removes the marks from the canvas. + await setBeehiivSidebar( page, false ); + await expect( marked ).toHaveCount( 0 ); + await setBeehiivSidebar( page, true ); + await expect( marked ).toHaveCount( 1 ); + } ); + + test( 'a scheduled, linked post shows the notice and marks, and a sent one does not', async ( { + page, + } ) => { + seed(); + const content = p( 'Kept text' ) + code( 'Left out' ); + const scheduledId = createPost( { + title: 'QA E2E scheduled omitted block', + content, + status: 'publish', + meta: linkedMeta( '2031-01-01T10:00:00Z' ), + } ); + const sentId = createPost( { + title: 'QA E2E sent omitted block', + content, + status: 'publish', + meta: linkedMeta( '2020-01-01T10:00:00Z' ), + } ); + await loginAsAdmin( page ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + await openPostEditor( page, scheduledId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + + await openPostEditor( page, sentId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + } ); + + test( 'a post whose blocks are all supported shows no notice or marks', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E no omitted blocks', + content: p( 'Only supported text' ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + await setBeehiivSidebar( page, true ); + + await expect( + page.locator( '.beehiiv-post-settings-notice__text' ) + ).toBeVisible(); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( + page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.beehiiv-omitted-block' ) + ).toHaveCount( 0 ); + } ); +} ); From f76e0540a41e87f95690bda6776d4369f235b29a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 20:18:30 +0800 Subject: [PATCH 07/13] style(editor): add a gap above the omitted-blocks line only when it follows the send warning text --- src/js/editor/post-settings/css/_sidebar.scss | 5 +++++ tests/e2e/specs/omitted-block-indicator.spec.js | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss index ed10cb8..0e707c2 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -36,6 +36,11 @@ gap: 6px; } + // Gap only when the omitted-blocks line follows the send warning text. + .beehiiv-post-settings-notice__text + .beehiiv-post-settings-notice__omitted { + margin-top: 10px; + } + .beehiiv-post-settings-notice__omitted-icon { flex-shrink: 0; font-size: 18px; diff --git a/tests/e2e/specs/omitted-block-indicator.spec.js b/tests/e2e/specs/omitted-block-indicator.spec.js index 6240228..99aa03d 100644 --- a/tests/e2e/specs/omitted-block-indicator.spec.js +++ b/tests/e2e/specs/omitted-block-indicator.spec.js @@ -173,6 +173,10 @@ test.describe( 'Omitted block marks in the editor', () => { await expect( page.locator( '.beehiiv-post-settings-notice__omitted' ) ).toContainText( '1 block will be omitted from this newsletter.' ); + // It follows the send warning text, so there is a gap above it. + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCSS( 'margin-top', '10px' ); const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); const marked = canvas.locator( '.beehiiv-omitted-block' ); await expect( marked ).toHaveCount( 1 ); @@ -225,6 +229,10 @@ test.describe( 'Omitted block marks in the editor', () => { await expect( page.locator( '.beehiiv-post-settings-notice__omitted' ) ).toContainText( '1 block will be omitted from this newsletter.' ); + // Alone in its notice, so no gap above it. + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCSS( 'margin-top', '0px' ); await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( 1 ); From 20aea9033d1b8a305222d1e7a70b2207fa0e7cab Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 20:40:20 +0800 Subject: [PATCH 08/13] feat(newsletter-publishing): omit Columns blocks with more than 3 columns and mark them in the editor A Columns block with more than 3 columns was sent as top-level blocks. It is now omitted from the newsletter, and the editor marks it and counts it in the omitted blocks notice, the same as other blocks the newsletter leaves out. The column limit is shared with the editor through the block support config. --- includes/Newsletter/BlockConverter.php | 16 +++--- includes/Newsletter/SupportedBlocks.php | 8 ++- src/js/shared/supported-blocks.js | 32 +++++++++++- .../e2e/specs/omitted-block-indicator.spec.js | 50 +++++++++++++++++++ .../specs/structured-content-blocks.spec.js | 22 ++++---- tests/phpunit/BlockConverterColumnsTest.php | 28 +++++------ 6 files changed, 119 insertions(+), 37 deletions(-) diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 6df79c2..9ed2efa 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -40,9 +40,9 @@ * - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()` * * Layout blocks such as `core/group` are skipped at the top level. A `core/columns` - * block with more than 3 columns, or one 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`. + * 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 */ @@ -528,8 +528,7 @@ public static function convert_list_block( array $wp_block ): array { * Convert a core/columns block to a beehiiv columns block. * * Returns an empty array when the block is not converted to a columns block, - * including when it has more columns than beehiiv accepts. Use - * `convert_columns_block_to_blocks()` to also get the fallback blocks. + * including when it has more columns than beehiiv accepts. * * @param array $wp_block Parsed block. * @return array @@ -549,9 +548,8 @@ public static function convert_columns_block( array $wp_block ): array { * 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 not converted to a columns block. Each column's inner blocks - * are emitted at the top level in order instead, and the row is never split. A - * core/columns block nested inside a column is handled the same way: its inner + * 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 $wp_block Parsed block. @@ -585,7 +583,7 @@ public static function convert_columns_block_to_blocks( array $wp_block ): array } if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { - return array_merge( array_merge( ...$converted_columns ), $nested_blocks ); + return []; } $kept_blocks = []; diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index 9bd2a63..9007365 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -7,6 +7,8 @@ namespace Beehiiv\Newsletter; +use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; + defined( 'ABSPATH' ) || exit; /** @@ -14,7 +16,8 @@ * * Layout blocks such as `core/group` are not supported. They and all of their * inner blocks are omitted from the newsletter payload. `core/columns` is supported, - * but only up to 3 columns; see `BlockConverter::convert_columns_block_to_blocks()`. + * but a Columns block with more than 3 columns is omitted; see + * `BlockConverter::convert_columns_block_to_blocks()`. * * @since 1.0.0 */ @@ -64,7 +67,7 @@ final class SupportedBlocks { /** * Block support config for the block editor (localized to JavaScript). * - * @return array> + * @return array|int> * @since 1.0.0 */ public static function get_editor_config(): array { @@ -72,6 +75,7 @@ public static function get_editor_config(): array { 'supported' => self::BLOCKS, 'nestedOnly' => self::NESTED_ONLY, 'snippetOnly' => self::SNIPPET_ONLY, + 'maxColumns' => ColumnsBlockConverter::MAX_COLUMNS, ]; } diff --git a/src/js/shared/supported-blocks.js b/src/js/shared/supported-blocks.js index 62b175f..c2268dd 100644 --- a/src/js/shared/supported-blocks.js +++ b/src/js/shared/supported-blocks.js @@ -9,6 +9,7 @@ * @property {string[]} supported Blocks converted to beehiiv newsletter blocks. * @property {string[]} nestedOnly Inner blocks handled by a parent. * @property {string[]} snippetOnly Blocks only used in snippet newsletter mode. + * @property {number} maxColumns Most columns a Columns block can hold before it is omitted. */ /** @type {BeehiivBlockSupportConfig} */ @@ -16,6 +17,7 @@ const DEFAULT_CONFIG = { supported: [], nestedOnly: [], snippetOnly: [], + maxColumns: 3, }; /** @@ -92,10 +94,34 @@ export function isMediaTextWithUnsupportedVideo( block ) { return 'video' === block.attributes?.mediaType; } +const COLUMNS_BLOCK = 'core/columns'; +const COLUMN_BLOCK = 'core/column'; + +/** + * Whether a Columns block has more columns than beehiiv accepts. + * + * The entire block is omitted from the beehiiv payload when it has. + * + * @param {{ name?: string, innerBlocks?: Array<{ name?: string }> }} block Parsed block. + * @return {boolean} True when the block has too many columns. + */ +export function isColumnsWithTooManyColumns( block ) { + if ( block?.name !== COLUMNS_BLOCK ) { + return false; + } + + const { maxColumns } = getConfig(); + const columns = ( block.innerBlocks ?? [] ).filter( + ( inner ) => inner?.name === COLUMN_BLOCK + ); + + return columns.length > ( maxColumns ?? DEFAULT_CONFIG.maxColumns ); +} + /** * Whether a block is omitted from the beehiiv newsletter payload. * - * @param {{ name?: string, attributes?: { mediaType?: string } }} block Parsed block. + * @param {{ name?: string, attributes?: { mediaType?: string }, innerBlocks?: Array<{ name?: string }> }} block Parsed block. * @return {boolean} True when the block will not be sent. */ export function isOmittedFromNewsletter( block ) { @@ -107,5 +133,9 @@ export function isOmittedFromNewsletter( block ) { return true; } + if ( isColumnsWithTooManyColumns( block ) ) { + return true; + } + return shouldWarnUnsupportedBlock( block.name ); } diff --git a/tests/e2e/specs/omitted-block-indicator.spec.js b/tests/e2e/specs/omitted-block-indicator.spec.js index 99aa03d..3acc6e7 100644 --- a/tests/e2e/specs/omitted-block-indicator.spec.js +++ b/tests/e2e/specs/omitted-block-indicator.spec.js @@ -138,6 +138,16 @@ const p = ( text ) => const code = ( text ) => `
${ text }
`; +// A Columns block holds at most 3 columns; beehiiv leaves out a wider one. +const columns = ( count ) => + `
${ Array.from( + { length: count }, + ( _, i ) => + `
${ p( + `Column ${ i + 1 }` + ) }
` + ).join( '' ) }
`; + test.describe.configure( { timeout: 150 * 1000 } ); test.beforeAll( async () => { @@ -247,6 +257,46 @@ test.describe( 'Omitted block marks in the editor', () => { ); } ); + test( 'a Columns block with more than 3 columns is marked as omitted, and one with 3 is not', async ( { + page, + } ) => { + seed(); + const wideId = createPost( { + title: 'QA E2E four columns', + content: p( 'Kept text' ) + columns( 4 ), + meta: { [ META.send ]: '1' }, + } ); + const narrowId = createPost( { + title: 'QA E2E three columns', + content: p( 'Kept text' ) + columns( 3 ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + await openPostEditor( page, wideId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + // Only the Columns block itself is marked, not its columns or their content. + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + await expect( + canvas.locator( '.wp-block-columns.beehiiv-omitted-block' ) + ).toHaveCount( 1 ); + + await openPostEditor( page, narrowId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + } ); + test( 'a post whose blocks are all supported shows no notice or marks', async ( { page, } ) => { diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js index d69d075..faf68de 100644 --- a/tests/e2e/specs/structured-content-blocks.spec.js +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -574,24 +574,24 @@ test.describe( 'US-006: top-level Columns blocks', () => { expect( columnsBlocks( off )[ 0 ].stackOnMobile ).toBe( false ); } ); - test( 'AC-017: a Columns block with more than 3 columns becomes top-level blocks', async ( { + test( 'AC-017: a Columns block with more than 3 columns is omitted', async ( { page, } ) => { const four = await convert( page, - columns( [ - col( p( 'A' ) ), - col( p( 'B' ) ), - col( p( 'C' ) ), - col( p( 'D' ) ), - ] ) + p( 'Before' ) + + columns( [ + col( p( 'A' ) ), + col( p( 'B' ) ), + col( p( 'C' ) ), + col( p( 'D' ) ), + ] ) + + p( 'After' ) ); expect( columnsBlocks( four ) ).toHaveLength( 0 ); expect( four.map( ( b ) => b.plaintext ) ).toEqual( [ - 'A', - 'B', - 'C', - 'D', + 'Before', + 'After', ] ); // Control: exactly 3 columns still converts to one columns block. diff --git a/tests/phpunit/BlockConverterColumnsTest.php b/tests/phpunit/BlockConverterColumnsTest.php index bd125bd..011c956 100644 --- a/tests/phpunit/BlockConverterColumnsTest.php +++ b/tests/phpunit/BlockConverterColumnsTest.php @@ -207,25 +207,25 @@ public function test_stack_on_mobile_defaults_to_true(): void { } /** - * AC-017: more than 3 columns fall back to top-level blocks, in order, with no columns block. + * AC-017: more than 3 columns are omitted entirely, with no columns block and no top-level blocks. */ - public function test_more_than_three_columns_fall_back_to_top_level_blocks(): void { + public function test_more_than_three_columns_are_omitted(): void { $blocks = $this->convert( - $this->columns( - array( - $this->column( $this->paragraph( 'A' ) ), - $this->column( $this->paragraph( 'B' ) ), - $this->column( $this->paragraph( 'C' ) ), - $this->column( $this->paragraph( 'D' ) ), + $this->paragraph( 'Before' ) + . $this->columns( + array( + $this->column( $this->paragraph( 'A' ) ), + $this->column( $this->paragraph( 'B' ) ), + $this->column( $this->paragraph( 'C' ) ), + $this->column( $this->paragraph( 'D' ) ), + ) ) - ) + . $this->paragraph( 'After' ) ); - $this->assertCount( 4, $blocks ); - - foreach ( $blocks as $block ) { - $this->assertSame( 'paragraph', $block['type'] ); - } + $this->assertCount( 2, $blocks ); + $this->assertSame( 'Before', $blocks[0]['plaintext'] ); + $this->assertSame( 'After', $blocks[1]['plaintext'] ); } /** From 2a5305b28b901482077485faad54ff7586d8f6ab Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 10 Nov 2026 21:18:23 +0800 Subject: [PATCH 09/13] Send a Columns block's colors, padding, margin and border as the columns block's visual settings A Columns block's background color, text color, padding, margin and border are now sent as the visual settings of the beehiiv columns block, the same way a Group's styling is sent for its section. Colors come from custom values or the theme palette. Padding and margin are sent in whole pixels per side as inner and outer spacing, and rem and em convert at 16 pixels. The border sends its color, width, style and radius, and a border with a color or width and no style is sent as solid. Sides, styles and units beehiiv cannot take are left out so it keeps its default. The styling code is a self-contained copy of the Group version, so this branch does not depend on the Group branch. The two can share one class after both land. --- includes/Newsletter/BlockConverter.php | 12 +- .../Converters/ColumnsStyleConverter.php | 485 ++++++++++++++++++ includes/Newsletter/FormattedTextParser.php | 119 ++++- .../specs/structured-content-blocks.spec.js | 89 +++- .../BlockConverterColumnsStyleTest.php | 362 +++++++++++++ 5 files changed, 1061 insertions(+), 6 deletions(-) create mode 100644 includes/Newsletter/Converters/ColumnsStyleConverter.php create mode 100644 tests/phpunit/BlockConverterColumnsStyleTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 9ed2efa..db5ec12 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -11,6 +11,7 @@ use Beehiiv\API\Resources\AdvertisementOpportunities; use Beehiiv\Editor\Meta; use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; +use Beehiiv\Newsletter\Converters\ColumnsStyleConverter; use Beehiiv\Newsletter\Converters\MediaTextBlockConverter; use Beehiiv\Newsletter\Converters\TableBlockConverter; use Beehiiv\Newsletter\Converters\ListBlockConverter; @@ -547,8 +548,9 @@ public static function convert_columns_block( array $wp_block ): array { /** * 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 + * A block with up to 3 columns becomes one beehiiv columns block, carrying the + * block's background colour, text colour, padding, margin and border as visual + * settings. 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. * @@ -616,6 +618,12 @@ public static function convert_columns_block_to_blocks( array $wp_block ): array ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ) ); + $visual_settings = ColumnsStyleConverter::build_visual_settings( $wp_block ); + + if ( ! empty( $columns_block ) && ! empty( $visual_settings ) ) { + $columns_block['visual_settings'] = $visual_settings; + } + return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); } diff --git a/includes/Newsletter/Converters/ColumnsStyleConverter.php b/includes/Newsletter/Converters/ColumnsStyleConverter.php new file mode 100644 index 0000000..929ea51 --- /dev/null +++ b/includes/Newsletter/Converters/ColumnsStyleConverter.php @@ -0,0 +1,485 @@ + + */ + private const BORDER_STYLES = [ 'solid', 'dashed', 'dotted' ]; + + /** + * Corner names in WordPress and the matching beehiiv setting suffix. + * + * @var array + */ + private const CORNERS = [ + 'topLeft' => 'top_left', + 'topRight' => 'top_right', + 'bottomLeft' => 'bottom_left', + 'bottomRight' => 'bottom_right', + ]; + + /** + * Pixels in one `rem` or `em`, used to turn relative padding into whole pixels. + * + * @var int + */ + private const PIXELS_PER_REM = 16; + + /** + * Padding sides, in the order beehiiv names them. + * + * @var array + */ + private const SIDES = [ 'top', 'bottom', 'left', 'right' ]; + + /** + * Cached slug-to-size map for theme spacing presets. + * + * @var array|null + */ + private static ?array $spacing_preset_map = null; + + /** + * Build the beehiiv columns block `visual_settings` for a Columns block. + * + * @param array $wp_block Parsed core/columns block. + * @return array Empty when the Columns block sets no mapped styling. + * @since 1.2.0 + */ + public static function build_visual_settings( array $wp_block ): array { + $attrs = is_array( $wp_block['attrs'] ?? null ) ? $wp_block['attrs'] : []; + $inner_html = (string) ( $wp_block['innerHTML'] ?? '' ); + $settings = []; + + $background_color = FormattedTextParser::resolve_columns_block_background_color( $inner_html, $attrs ); + + if ( null !== $background_color && '' !== $background_color ) { + $settings['background_color'] = $background_color; + } + + $text_color = FormattedTextParser::resolve_columns_block_text_color( $inner_html, $attrs ); + + if ( null !== $text_color && '' !== $text_color ) { + $settings['text_color'] = $text_color; + } + + foreach ( self::resolve_spacing( $attrs, 'padding' ) as $side => $pixels ) { + $settings[ 'inner_spacing_' . $side ] = $pixels; + } + + foreach ( self::resolve_spacing( $attrs, 'margin' ) as $side => $pixels ) { + $settings[ 'outer_spacing_' . $side ] = $pixels; + } + + return array_merge( $settings, self::resolve_border( $attrs, $inner_html ) ); + } + + /** + * Resolve a Columns block's border to beehiiv border settings. + * + * A border colour, style or width that differs between sides cannot be sent, because + * beehiiv takes one colour and one style; widths and radii can be sent per side or + * corner. A border with a colour or width but no style is sent as `solid`, as WordPress + * draws it. + * + * @param array $attrs Parsed block attributes. + * @param string $inner_html Saved Columns HTML including the `
` wrapper. + * @return array + */ + private static function resolve_border( array $attrs, string $inner_html ): array { + $border = $attrs['style']['border'] ?? []; + $border = is_array( $border ) ? $border : []; + + $settings = self::resolve_border_widths( $border ); + $color = FormattedTextParser::resolve_columns_block_border_color( $inner_html, $attrs ) + ?? self::shared_side_value( $border, 'color' ); + + if ( null !== $color && '' !== $color ) { + $settings['border_color'] = $color; + } + + $style = self::resolve_border_style( $border ); + + if ( null === $style && isset( $border['style'] ) ) { + $style = false; + } + + if ( null === $style && ( isset( $settings['border_color'] ) || self::has_border_width( $settings ) ) ) { + $style = 'solid'; + } + + if ( is_string( $style ) ) { + $settings['border_style'] = $style; + } + + return array_merge( $settings, self::resolve_border_radius( $border ) ); + } + + /** + * Resolve border widths, one value for every side or one per side. + * + * @param array $border Columns `style.border` attributes. + * @return array + */ + private static function resolve_border_widths( array $border ): array { + if ( isset( $border['width'] ) ) { + $pixels = self::to_pixels( $border['width'] ); + + return null === $pixels ? [] : [ 'border_width' => $pixels ]; + } + + $widths = []; + + foreach ( self::SIDES as $side ) { + if ( ! isset( $border[ $side ]['width'] ) ) { + continue; + } + + $pixels = self::to_pixels( $border[ $side ]['width'] ); + + if ( null !== $pixels ) { + $widths[ 'border_width_' . $side ] = $pixels; + } + } + + return $widths; + } + + /** + * Resolve the border style, from one value or from sides that all agree. + * + * @param array $border Columns `style.border` attributes. + * @return string|null A style beehiiv accepts, or null when none is set or it is not accepted. + */ + private static function resolve_border_style( array $border ): ?string { + $style = isset( $border['style'] ) && is_string( $border['style'] ) + ? $border['style'] + : self::shared_side_value( $border, 'style' ); + + $style = null === $style ? null : strtolower( trim( $style ) ); + + return in_array( $style, self::BORDER_STYLES, true ) ? $style : null; + } + + /** + * Resolve border radius, one value for every corner or one per corner. + * + * @param array $border Columns `style.border` attributes. + * @return array + */ + private static function resolve_border_radius( array $border ): array { + $radius = $border['radius'] ?? null; + + if ( is_string( $radius ) || is_int( $radius ) || is_float( $radius ) ) { + $pixels = self::to_pixels( $radius ); + + return null === $pixels ? [] : [ 'border_radius' => $pixels ]; + } + + if ( ! is_array( $radius ) ) { + return []; + } + + $corners = []; + + foreach ( self::CORNERS as $corner => $suffix ) { + if ( ! isset( $radius[ $corner ] ) ) { + continue; + } + + $pixels = self::to_pixels( $radius[ $corner ] ); + + if ( null !== $pixels ) { + $corners[ 'border_radius_' . $suffix ] = $pixels; + } + } + + return $corners; + } + + /** + * Whether any border width was resolved. + * + * @param array $settings Border settings resolved so far. + * @return bool + */ + private static function has_border_width( array $settings ): bool { + foreach ( array_keys( $settings ) as $key ) { + if ( 0 === strpos( (string) $key, 'border_width' ) ) { + return true; + } + } + + return false; + } + + /** + * Read a per-side value that every set side shares. + * + * @param array $border Columns `style.border` attributes. + * @param string $key Per-side key, `color` or `style`. + * @return string|null The shared value, or null when no side sets it or the sides differ. + */ + private static function shared_side_value( array $border, string $key ): ?string { + $values = []; + + foreach ( self::SIDES as $side ) { + $value = $border[ $side ][ $key ] ?? null; + + if ( ! is_string( $value ) ) { + continue; + } + + $value = 'color' === $key ? FormattedTextParser::resolve_color_reference( $value ) : trim( $value ); + + if ( null !== $value && '' !== $value ) { + $values[] = $value; + } + } + + $values = array_unique( $values ); + + return 1 === count( $values ) ? $values[0] : null; + } + + /** + * Resolve each side of a Columns block's padding or margin to whole pixels. + * + * A side whose value cannot be turned into pixels (percentages, viewport units, + * `auto`, negative values, `clamp()` presets) is left out so beehiiv keeps its default + * for that side. + * + * @param array $attrs Parsed block attributes. + * @param string $property Spacing property, `padding` or `margin`. + * @return array Pixels keyed by side. + */ + private static function resolve_spacing( array $attrs, string $property ): array { + $spacing = $attrs['style']['spacing'][ $property ] ?? null; + + if ( is_string( $spacing ) || is_int( $spacing ) ) { + $spacing = array_fill_keys( self::SIDES, $spacing ); + } + + if ( ! is_array( $spacing ) ) { + return []; + } + + $resolved = []; + + foreach ( self::SIDES as $side ) { + if ( ! isset( $spacing[ $side ] ) ) { + continue; + } + + $pixels = self::to_pixels( $spacing[ $side ] ); + + if ( null !== $pixels ) { + $resolved[ $side ] = $pixels; + } + } + + return $resolved; + } + + /** + * Convert one padding or margin value to whole pixels. + * + * @param mixed $value Spacing value: a number, a CSS length, or a spacing preset. + * @return int|null Pixels, or null when the value cannot be converted. + */ + private static function to_pixels( $value ): ?int { + if ( is_int( $value ) || is_float( $value ) ) { + return max( 0, (int) round( $value ) ); + } + + if ( ! is_string( $value ) ) { + return null; + } + + $value = trim( $value ); + $slug = self::extract_spacing_preset_slug( $value ); + + if ( null !== $slug ) { + $size = self::get_spacing_preset_map()[ $slug ] ?? null; + + return null === $size ? null : self::css_length_to_pixels( $size ); + } + + return self::css_length_to_pixels( $value ); + } + + /** + * Convert a CSS length (`40px`, `2.5rem`, `1em`, `12`) or a fluid size to whole pixels. + * + * Themes often define spacing as `clamp()`, `min()` or `max()`. A newsletter has no + * responsive spacing, so a fluid size is sent at its widest value: `clamp()` at its + * maximum, `min()` at its smallest fixed argument, `max()` at its largest. An argument + * that is not a fixed length (a `vw` unit, `calc()`) is ignored. + * + * @param string $length CSS length or fluid size. + * @return int|null Pixels, or null for any other unit or function. + */ + private static function css_length_to_pixels( string $length ): ?int { + $length = trim( $length ); + + if ( preg_match( '/^(clamp|min|max)\((.*)\)$/is', $length, $call ) ) { + return self::fluid_size_to_pixels( strtolower( $call[1] ), self::split_arguments( $call[2] ) ); + } + + if ( ! preg_match( '/^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i', trim( $length ), $matches ) ) { + return null; + } + + $number = (float) $matches[1]; + $unit = strtolower( $matches[2] ?? 'px' ); + + if ( 'px' !== $unit ) { + $number *= self::PIXELS_PER_REM; + } + + return (int) round( $number ); + } + + /** + * Resolve a `clamp()`, `min()` or `max()` size to whole pixels. + * + * @param string $name Function name: `clamp`, `min` or `max`. + * @param array $arguments Top-level arguments of the function. + * @return int|null Pixels, or null when no argument is a fixed length. + */ + private static function fluid_size_to_pixels( string $name, array $arguments ): ?int { + if ( 'clamp' === $name ) { + // clamp( minimum, preferred, maximum ): the maximum is the widest size. + return 3 === count( $arguments ) ? self::css_length_to_pixels( $arguments[2] ) : null; + } + + $values = array_filter( + array_map( array( self::class, 'css_length_to_pixels' ), $arguments ), + static function ( $pixels ) { + return null !== $pixels; + } + ); + + if ( empty( $values ) ) { + return null; + } + + return 'min' === $name ? min( $values ) : max( $values ); + } + + /** + * Split a CSS function's arguments on top-level commas. + * + * @param string $arguments Text between the function's outer parentheses. + * @return array + */ + private static function split_arguments( string $arguments ): array { + $parts = []; + $depth = 0; + $start = 0; + $total = strlen( $arguments ); + + for ( $index = 0; $index < $total; $index++ ) { + $char = $arguments[ $index ]; + + if ( '(' === $char ) { + ++$depth; + } elseif ( ')' === $char ) { + --$depth; + } elseif ( ',' === $char && 0 === $depth ) { + $parts[] = trim( substr( $arguments, $start, $index - $start ) ); + $start = $index + 1; + } + } + + $parts[] = trim( substr( $arguments, $start ) ); + + return $parts; + } + + /** + * Read the preset slug from `var:preset|spacing|40` or `var(--wp--preset--spacing--40)`. + * + * @param string $value Padding value. + * @return string|null Preset slug, or null when the value is not a spacing preset. + */ + private static function extract_spacing_preset_slug( string $value ): ?string { + if ( preg_match( '/^var:preset\|spacing\|([a-z0-9-]+)$/i', $value, $matches ) ) { + return $matches[1]; + } + + if ( preg_match( '/^var\(--wp--preset--spacing--([a-z0-9-]+)\)$/i', $value, $matches ) ) { + return $matches[1]; + } + + return null; + } + + /** + * Build a cached slug-to-size map from theme spacing presets. + * + * @return array + */ + private static function get_spacing_preset_map(): array { + if ( null !== self::$spacing_preset_map ) { + return self::$spacing_preset_map; + } + + $map = []; + $sizes = wp_get_global_settings( array( 'spacing', 'spacingSizes' ) ); + $sizes = is_array( $sizes ) ? $sizes : []; + + // Apply origins in WordPress's own order, so a later origin overrides an earlier one with the same slug. + // A theme can turn WordPress's default sizes off, and then they are not offered at all. + $sources = false === wp_get_global_settings( array( 'spacing', 'defaultSpacingSizes' ) ) + ? array( 'theme', 'custom' ) + : array( 'default', 'theme', 'custom' ); + + foreach ( $sources as $source ) { + if ( empty( $sizes[ $source ] ) || ! is_array( $sizes[ $source ] ) ) { + continue; + } + + foreach ( $sizes[ $source ] as $entry ) { + if ( + ! is_array( $entry ) + || empty( $entry['slug'] ) + || ! isset( $entry['size'] ) + || ! is_string( $entry['slug'] ) + || ! is_string( $entry['size'] ) + ) { + continue; + } + + $map[ $entry['slug'] ] = $entry['size']; + } + } + + self::$spacing_preset_map = $map; + + return self::$spacing_preset_map; + } +} diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php index 07b208b..10e5a87 100644 --- a/includes/Newsletter/FormattedTextParser.php +++ b/includes/Newsletter/FormattedTextParser.php @@ -212,6 +212,119 @@ public static function resolve_paragraph_block_background_color( string $inner_h return self::resolve_wrapper_color_from_html_tag( $inner_html, 'p', 'background' ); } + /** + * Resolve a Columns block's background colour from attrs and the `
` wrapper. + * + * @param string $inner_html Saved Columns HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_columns_block_background_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_background_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'div', 'background' ); + } + + /** + * Resolve a Columns block's text colour from attrs and the `
` wrapper. + * + * @param string $inner_html Saved Columns HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_columns_block_text_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_text_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'div', 'text' ); + } + + /** + * Resolve a colour reference to a colour value. + * + * Palette variables (`var:preset|color|slug`, as the block editor stores them on a split + * border, or `var(--wp--preset--color--slug)`) become the palette colour's value. Any other + * value is returned as given. + * + * @param string $value Colour value or palette variable. + * @return string|null Null when the value is empty, transparent, or names an unknown palette colour. + * @since 1.2.0 + */ + public static function resolve_color_reference( string $value ): ?string { + $value = trim( $value ); + + if ( '' === $value || self::is_transparent_color( $value ) ) { + return null; + } + + if ( + preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/i', $value, $matches ) + || preg_match( '/^var\(--wp--preset--color--([a-z0-9-]+)\)$/i', $value, $matches ) + ) { + return self::get_color_preset_map()[ $matches[1] ] ?? null; + } + + return $value; + } + + /** + * Resolve a Columns block's border colour from attrs and the `
` wrapper. + * + * Reads a custom colour, then a palette slug, then the saved wrapper's inline style and + * `has-{slug}-border-color` class. + * + * @param string $inner_html Saved Columns HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_columns_block_border_color( string $inner_html, array $attrs ): ?string { + $custom = $attrs['style']['border']['color'] ?? null; + + if ( is_string( $custom ) ) { + $resolved = self::resolve_color_reference( $custom ); + + if ( null !== $resolved ) { + return $resolved; + } + } + + if ( ! empty( $attrs['borderColor'] ) && is_string( $attrs['borderColor'] ) ) { + $from_slug = self::get_color_preset_map()[ trim( $attrs['borderColor'] ) ] ?? null; + + if ( null !== $from_slug ) { + return $from_slug; + } + } + + $attributes = self::get_first_tag_attributes( $inner_html, 'div' ); + + if ( null === $attributes ) { + return null; + } + + if ( '' !== $attributes['style'] ) { + $color = self::extract_css_color( html_entity_decode( $attributes['style'], ENT_QUOTES ), 'border-color' ); + + if ( null !== $color && ! self::is_transparent_color( $color ) ) { + return $color; + } + } + + $slug = self::extract_preset_slug( $attributes['class'], 'border-color' ); + + return null === $slug ? null : ( self::get_color_preset_map()[ $slug ] ?? null ); + } + /** * Resolve block-level list-item text colour from attrs and the `
  • ` tag. * @@ -1109,7 +1222,11 @@ private static function get_color_preset_map(): array { $map = []; $palette = wp_get_global_settings( array( 'color', 'palette' ) ); - $sources = array( 'theme', 'default', 'custom' ); + // Apply origins in WordPress's own order, so a later origin overrides an earlier one with the same slug. + // A theme can turn WordPress's default palette off, and then its colours are not offered at all. + $sources = false === wp_get_global_settings( array( 'color', 'defaultPalette' ) ) + ? array( 'theme', 'custom' ) + : array( 'default', 'theme', 'custom' ); $palette = is_array( $palette ) ? $palette : []; foreach ( $sources as $source ) { diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js index faf68de..3fce0c1 100644 --- a/tests/e2e/specs/structured-content-blocks.spec.js +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -176,10 +176,12 @@ function col( inner, { width, valign } = {} ) { * @param {Object} [options] Columns options. * @param {string} [options.valign] Block-level verticalAlignment. * @param {boolean} [options.stacked] isStackedOnMobile value. + * @param {Object} [options.attrs] Extra block attributes, such as `style`. + * @param {string} [options.style] Inline style saved on the wrapper element. * @return {string} Serialized columns block. */ -function columns( cols, { valign, stacked } = {} ) { - const attrs = {}; +function columns( cols, { valign, stacked, attrs: extraAttrs, style } = {} ) { + const attrs = { ...extraAttrs }; if ( valign ) { attrs.verticalAlignment = valign; } @@ -192,7 +194,8 @@ function columns( cols, { valign, stacked } = {} ) { const cls = 'wp-block-columns' + ( stacked === false ? ' is-not-stacked-on-mobile' : '' ); - return `
    ${ cols.join( + const styleAttr = style ? ` style="${ style }"` : ''; + return `
    ${ cols.join( '' ) }
    `; } @@ -642,4 +645,84 @@ test.describe( 'US-006: top-level Columns blocks', () => { ] ); expect( types( cols[ 2 ].blocks ) ).toEqual( [ 'button' ] ); } ); + + test( 'AC-021: a Columns block sends its colors, padding and margin as visual settings', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { + attrs: { + style: { + color: { background: '#ffeeaa', text: '#112233' }, + spacing: { + padding: { top: '20px', bottom: '1.5rem' }, + margin: { top: '30px' }, + }, + }, + }, + style: 'background-color:#ffeeaa;color:#112233', + } ) + ); + const settings = columnsBlocks( blocks )[ 0 ].visual_settings; + expect( settings.background_color ).toBe( '#ffeeaa' ); + expect( settings.text_color ).toBe( '#112233' ); + expect( settings.inner_spacing_top ).toBe( 20 ); + expect( settings.inner_spacing_bottom ).toBe( 24 ); + expect( settings.outer_spacing_top ).toBe( 30 ); + expect( settings ).not.toHaveProperty( 'inner_spacing_left' ); + + // Control: a Columns block without styling sends no visual settings. + const plain = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) + ); + expect( columnsBlocks( plain )[ 0 ] ).not.toHaveProperty( + 'visual_settings' + ); + } ); + + test( 'AC-022: a Columns block sends its border color, width, style and radius', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { + attrs: { + style: { + border: { + color: '#ff0000', + width: '2px', + radius: '6px', + }, + }, + }, + } ) + ); + const settings = columnsBlocks( blocks )[ 0 ].visual_settings; + expect( settings.border_color ).toBe( '#ff0000' ); + expect( settings.border_width ).toBe( 2 ); + // A border with a color or width and no style is sent as solid. + expect( settings.border_style ).toBe( 'solid' ); + expect( settings.border_radius ).toBe( 6 ); + + const dashed = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { + attrs: { + style: { + border: { + width: '1px', + style: 'double', + radius: '50%', + }, + }, + }, + } ) + ); + const rejected = columnsBlocks( dashed )[ 0 ].visual_settings; + // A style beehiiv does not accept, and a radius in percent, are not sent. + expect( rejected ).not.toHaveProperty( 'border_style' ); + expect( rejected ).not.toHaveProperty( 'border_radius' ); + } ); } ); diff --git a/tests/phpunit/BlockConverterColumnsStyleTest.php b/tests/phpunit/BlockConverterColumnsStyleTest.php new file mode 100644 index 0000000..1f831ef --- /dev/null +++ b/tests/phpunit/BlockConverterColumnsStyleTest.php @@ -0,0 +1,362 @@ + $attrs Columns block attributes. + * @param int $columns Number of columns. + * @param string $style Inline style on the wrapper. + * @param string $classes Extra classes on the wrapper. + * @return string + */ + private function columns_markup( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): string { + $json = empty( $attrs ) ? '' : ' ' . wp_json_encode( $attrs ); + $style = '' === $style ? '' : ' style="' . $style . '"'; + $classes = '' === $classes ? '' : ' ' . $classes; + $inside = ''; + + for ( $i = 1; $i <= $columns; $i++ ) { + $inside .= "\n
    " + . "\n

    Column {$i}

    \n" + . "
    \n\n"; + } + + $open = "\n
    "; + $close = "
    \n"; + + return $open . $inside . $close; + } + + /** + * Convert a Columns block's markup to beehiiv blocks. + * + * @param array $attrs Columns block attributes. + * @param int $columns Number of columns. + * @param string $style Inline style on the wrapper. + * @param string $classes Extra classes on the wrapper. + * @return array> + */ + private function convert( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): array { + $markup = $this->columns_markup( $attrs, $columns, $style, $classes ); + $blocks = parse_blocks( $markup ); + + return BlockConverter::convert_columns_block_to_blocks( $blocks[0] ); + } + + /** + * The visual settings of the converted columns block. + * + * @param array $attrs Columns block attributes. + * @param int $columns Number of columns. + * @param string $style Inline style on the wrapper. + * @param string $classes Extra classes on the wrapper. + * @return array + */ + private function visual_settings( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): array { + $blocks = $this->convert( $attrs, $columns, $style, $classes ); + + $this->assertSame( 'columns', $blocks[0]['type'] ); + + return $blocks[0]['visual_settings'] ?? array(); + } + + /** + * A Columns block with no styling sends no visual settings. + */ + public function test_unstyled_columns_have_no_visual_settings(): void { + $blocks = $this->convert( array() ); + + $this->assertSame( 'columns', $blocks[0]['type'] ); + $this->assertArrayNotHasKey( 'visual_settings', $blocks[0] ); + } + + /** + * Custom background and text colors become visual settings. + */ + public function test_custom_colors_become_visual_settings(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'color' => array( + 'background' => '#ffeeaa', + 'text' => '#112233', + ), + ), + ) + ); + + $this->assertSame( '#ffeeaa', $settings['background_color'] ); + $this->assertSame( '#112233', $settings['text_color'] ); + } + + /** + * Colors saved only in the wrapper's inline style are still read. + */ + public function test_colors_from_the_wrapper_style_are_read(): void { + $settings = $this->visual_settings( + array(), + 2, + 'background-color:#abcdef;color:#102030', + 'has-background has-text-color' + ); + + $this->assertSame( '#abcdef', $settings['background_color'] ); + $this->assertSame( '#102030', $settings['text_color'] ); + } + + /** + * Padding becomes inner spacing in whole pixels, side by side. + */ + public function test_padding_becomes_inner_spacing_in_pixels(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '20px', + 'bottom' => '1.5rem', + 'left' => '10px', + 'right' => '2em', + ), + ), + ), + ) + ); + + $this->assertSame( 20, $settings['inner_spacing_top'] ); + $this->assertSame( 24, $settings['inner_spacing_bottom'] ); + $this->assertSame( 10, $settings['inner_spacing_left'] ); + $this->assertSame( 32, $settings['inner_spacing_right'] ); + } + + /** + * A single padding value applies to every side. + */ + public function test_single_padding_value_applies_to_every_side(): void { + $settings = $this->visual_settings( + array( 'style' => array( 'spacing' => array( 'padding' => '12px' ) ) ) + ); + + foreach ( array( 'top', 'bottom', 'left', 'right' ) as $side ) { + $this->assertSame( 12, $settings[ 'inner_spacing_' . $side ] ); + } + } + + /** + * A side that cannot be turned into pixels is left out. + */ + public function test_padding_that_is_not_in_pixels_is_left_out(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '10%', + 'bottom' => '20px', + 'left' => '5vw', + 'right' => 'auto', + ), + ), + ), + ) + ); + + $this->assertSame( 20, $settings['inner_spacing_bottom'] ); + $this->assertArrayNotHasKey( 'inner_spacing_top', $settings ); + $this->assertArrayNotHasKey( 'inner_spacing_left', $settings ); + $this->assertArrayNotHasKey( 'inner_spacing_right', $settings ); + } + + /** + * Margin becomes outer spacing, next to padding as inner spacing. + */ + public function test_margin_becomes_outer_spacing_in_pixels(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( 'top' => '8px' ), + 'margin' => array( + 'top' => '30px', + 'bottom' => '2rem', + ), + ), + ), + ) + ); + + $this->assertSame( 8, $settings['inner_spacing_top'] ); + $this->assertSame( 30, $settings['outer_spacing_top'] ); + $this->assertSame( 32, $settings['outer_spacing_bottom'] ); + $this->assertArrayNotHasKey( 'outer_spacing_left', $settings ); + } + + /** + * A border sends its color, width, style and radius. + */ + public function test_border_becomes_visual_settings(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'border' => array( + 'color' => '#ff0000', + 'width' => '2px', + 'style' => 'dashed', + 'radius' => '6px', + ), + ), + ) + ); + + $this->assertSame( '#ff0000', $settings['border_color'] ); + $this->assertSame( 2, $settings['border_width'] ); + $this->assertSame( 'dashed', $settings['border_style'] ); + $this->assertSame( 6, $settings['border_radius'] ); + } + + /** + * A border with a color or width and no style is sent as solid. + */ + public function test_border_without_a_style_is_solid(): void { + $settings = $this->visual_settings( + array( 'style' => array( 'border' => array( 'width' => '1px' ) ) ) + ); + + $this->assertSame( 'solid', $settings['border_style'] ); + $this->assertSame( 1, $settings['border_width'] ); + } + + /** + * A border style beehiiv does not accept is not sent. + */ + public function test_unsupported_border_style_is_not_sent(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'border' => array( + 'width' => '2px', + 'style' => 'double', + ), + ), + ) + ); + + $this->assertArrayNotHasKey( 'border_style', $settings ); + } + + /** + * Widths and radii can be set per side and per corner. + */ + public function test_border_widths_and_radii_can_be_set_per_side_and_corner(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'border' => array( + 'top' => array( 'width' => '1px' ), + 'right' => array( 'width' => '2px' ), + 'radius' => array( + 'topLeft' => '4px', + 'bottomRight' => '8px', + ), + ), + ), + ) + ); + + $this->assertSame( 1, $settings['border_width_top'] ); + $this->assertSame( 2, $settings['border_width_right'] ); + $this->assertSame( 4, $settings['border_radius_top_left'] ); + $this->assertSame( 8, $settings['border_radius_bottom_right'] ); + $this->assertArrayNotHasKey( 'border_radius_top_right', $settings ); + } + + /** + * Border colors and styles that differ by side are not sent. + */ + public function test_border_colors_and_styles_that_differ_by_side_are_not_sent(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'border' => array( + 'top' => array( + 'width' => '1px', + 'color' => '#ff0000', + 'style' => 'solid', + ), + 'bottom' => array( + 'width' => '1px', + 'color' => '#00ff00', + 'style' => 'dotted', + ), + ), + ), + ) + ); + + $this->assertArrayNotHasKey( 'border_color', $settings ); + $this->assertSame( 'solid', $settings['border_style'] ); + } + + /** + * A border radius in percent is not sent. + */ + public function test_border_radius_in_percent_is_not_sent(): void { + $settings = $this->visual_settings( + array( + 'style' => array( + 'border' => array( + 'width' => '1px', + 'radius' => '50%', + ), + ), + ) + ); + + $this->assertArrayNotHasKey( 'border_radius', $settings ); + } + + /** + * Styling does not change how the columns convert. + */ + public function test_styling_does_not_change_the_columns(): void { + $blocks = $this->convert( + array( 'style' => array( 'color' => array( 'background' => '#ffeeaa' ) ) ), + 3 + ); + + $this->assertCount( 1, $blocks ); + $this->assertCount( 3, $blocks[0]['columns'] ); + $this->assertTrue( $blocks[0]['stackOnMobile'] ); + } + + /** + * A styled Columns block with more than 3 columns is still omitted. + */ + public function test_styled_columns_with_more_than_three_columns_are_omitted(): void { + $blocks = $this->convert( + array( 'style' => array( 'color' => array( 'background' => '#ffeeaa' ) ) ), + 4 + ); + + $this->assertSame( array(), $blocks ); + } +} From 34dd1c4e8c0ae16850563a0a7190a5cbc7e8a639 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 10 Nov 2026 22:18:01 +0800 Subject: [PATCH 10/13] Wrap a styled Columns block in a beehiiv section that carries its styling beehiiv holds a container's styling on a section, so a Columns block with a background color, text color, padding, margin or border that beehiiv can take is now sent as a section with those visual settings, holding the plain columns block. A Columns block with no styling that converts stays a plain columns block, and a Columns block with more than 3 columns, or with nothing convertible inside, is still not sent. --- includes/Newsletter/BlockConverter.php | 18 ++++++-- .../specs/structured-content-blocks.spec.js | 30 ++++++++++--- .../BlockConverterColumnsStyleTest.php | 44 ++++++++++++++++--- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index db5ec12..283d7bc 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -537,6 +537,10 @@ public static function convert_list_block( array $wp_block ): array { */ public static function convert_columns_block( array $wp_block ): array { foreach ( self::convert_columns_block_to_blocks( $wp_block ) as $beehiiv_block ) { + if ( 'section' === ( $beehiiv_block['type'] ?? '' ) ) { + $beehiiv_block = $beehiiv_block['blocks'][0] ?? []; + } + if ( 'columns' === ( $beehiiv_block['type'] ?? '' ) ) { return $beehiiv_block; } @@ -548,9 +552,11 @@ public static function convert_columns_block( array $wp_block ): array { /** * Convert a core/columns block to beehiiv blocks. * - * A block with up to 3 columns becomes one beehiiv columns block, carrying the - * block's background colour, text colour, padding, margin and border as visual - * settings. A block with more columns is omitted entirely, and the row is never split. A core/columns + * A block with up to 3 columns becomes one beehiiv columns block. When the block sets a + * background colour, text colour, padding, margin or border that beehiiv can take, the + * columns block is wrapped in a beehiiv section that carries them as visual settings, + * because that is where beehiiv holds a container's styling. 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. * @@ -621,7 +627,11 @@ public static function convert_columns_block_to_blocks( array $wp_block ): array $visual_settings = ColumnsStyleConverter::build_visual_settings( $wp_block ); if ( ! empty( $columns_block ) && ! empty( $visual_settings ) ) { - $columns_block['visual_settings'] = $visual_settings; + $columns_block = [ + 'type' => 'section', + 'blocks' => [ $columns_block ], + 'visual_settings' => $visual_settings, + ]; } return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js index 3fce0c1..20c670f 100644 --- a/tests/e2e/specs/structured-content-blocks.spec.js +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -200,6 +200,18 @@ function columns( cols, { valign, stacked, attrs: extraAttrs, style } = {} ) { ) }
    `; } +/** + * The section a styled Columns block is wrapped in. + * + * @param {Array} blocks Blocks sent to beehiiv. + * @return {Object} The section block. + */ +function styledSection( blocks ) { + const section = blocks.find( ( b ) => b.type === 'section' ); + expect( section, 'a section wraps the styled Columns block' ).toBeTruthy(); + return section; +} + const types = ( blocks ) => blocks.map( ( b ) => b.type ); const columnsBlocks = ( blocks ) => blocks.filter( ( b ) => b.type === 'columns' ); @@ -664,7 +676,7 @@ test.describe( 'US-006: top-level Columns blocks', () => { style: 'background-color:#ffeeaa;color:#112233', } ) ); - const settings = columnsBlocks( blocks )[ 0 ].visual_settings; + const settings = styledSection( blocks ).visual_settings; expect( settings.background_color ).toBe( '#ffeeaa' ); expect( settings.text_color ).toBe( '#112233' ); expect( settings.inner_spacing_top ).toBe( 20 ); @@ -672,14 +684,18 @@ test.describe( 'US-006: top-level Columns blocks', () => { expect( settings.outer_spacing_top ).toBe( 30 ); expect( settings ).not.toHaveProperty( 'inner_spacing_left' ); - // Control: a Columns block without styling sends no visual settings. + // The section holds the columns block, which carries no styling itself. + const section = styledSection( blocks ); + expect( section.blocks ).toHaveLength( 1 ); + expect( section.blocks[ 0 ].type ).toBe( 'columns' ); + expect( section.blocks[ 0 ] ).not.toHaveProperty( 'visual_settings' ); + + // Control: a Columns block without styling is a plain columns block, with no section. const plain = await convert( page, columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) ); - expect( columnsBlocks( plain )[ 0 ] ).not.toHaveProperty( - 'visual_settings' - ); + expect( types( plain ) ).toEqual( [ 'columns' ] ); } ); test( 'AC-022: a Columns block sends its border color, width, style and radius', async ( { @@ -699,7 +715,7 @@ test.describe( 'US-006: top-level Columns blocks', () => { }, } ) ); - const settings = columnsBlocks( blocks )[ 0 ].visual_settings; + const settings = styledSection( blocks ).visual_settings; expect( settings.border_color ).toBe( '#ff0000' ); expect( settings.border_width ).toBe( 2 ); // A border with a color or width and no style is sent as solid. @@ -720,7 +736,7 @@ test.describe( 'US-006: top-level Columns blocks', () => { }, } ) ); - const rejected = columnsBlocks( dashed )[ 0 ].visual_settings; + const rejected = styledSection( dashed ).visual_settings; // A style beehiiv does not accept, and a radius in percent, are not sent. expect( rejected ).not.toHaveProperty( 'border_style' ); expect( rejected ).not.toHaveProperty( 'border_radius' ); diff --git a/tests/phpunit/BlockConverterColumnsStyleTest.php b/tests/phpunit/BlockConverterColumnsStyleTest.php index 1f831ef..dc85742 100644 --- a/tests/phpunit/BlockConverterColumnsStyleTest.php +++ b/tests/phpunit/BlockConverterColumnsStyleTest.php @@ -63,7 +63,7 @@ private function convert( array $attrs, int $columns = 2, string $style = '', st } /** - * The visual settings of the converted columns block. + * The visual settings of the section that wraps the converted columns block. * * @param array $attrs Columns block attributes. * @param int $columns Number of columns. @@ -74,13 +74,16 @@ private function convert( array $attrs, int $columns = 2, string $style = '', st private function visual_settings( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): array { $blocks = $this->convert( $attrs, $columns, $style, $classes ); - $this->assertSame( 'columns', $blocks[0]['type'] ); + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertCount( 1, $blocks[0]['blocks'] ); + $this->assertSame( 'columns', $blocks[0]['blocks'][0]['type'] ); + $this->assertArrayNotHasKey( 'visual_settings', $blocks[0]['blocks'][0] ); - return $blocks[0]['visual_settings'] ?? array(); + return $blocks[0]['visual_settings']; } /** - * A Columns block with no styling sends no visual settings. + * A Columns block with no styling is sent as a plain columns block, with no section. */ public function test_unstyled_columns_have_no_visual_settings(): void { $blocks = $this->convert( array() ); @@ -335,7 +338,7 @@ public function test_border_radius_in_percent_is_not_sent(): void { } /** - * Styling does not change how the columns convert. + * Styling does not change how the columns inside the section convert. */ public function test_styling_does_not_change_the_columns(): void { $blocks = $this->convert( @@ -344,8 +347,10 @@ public function test_styling_does_not_change_the_columns(): void { ); $this->assertCount( 1, $blocks ); - $this->assertCount( 3, $blocks[0]['columns'] ); - $this->assertTrue( $blocks[0]['stackOnMobile'] ); + $this->assertSame( 'section', $blocks[0]['type'] ); + $columns = $blocks[0]['blocks'][0]; + $this->assertCount( 3, $columns['columns'] ); + $this->assertTrue( $columns['stackOnMobile'] ); } /** @@ -359,4 +364,29 @@ public function test_styled_columns_with_more_than_three_columns_are_omitted(): $this->assertSame( array(), $blocks ); } + + /** + * Styling that cannot be converted leaves a plain columns block, with no empty section. + */ + public function test_styling_that_cannot_be_converted_sends_no_section(): void { + $blocks = $this->convert( + array( 'style' => array( 'spacing' => array( 'padding' => '10%' ) ) ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'columns', $blocks[0]['type'] ); + } + + /** + * A styled Columns block with nothing convertible inside sends no section and no columns block. + */ + public function test_styled_columns_with_nothing_convertible_send_nothing(): void { + $markup = '' + . '
    ' + . '
    ' + . '
    '; + $blocks = parse_blocks( $markup ); + + $this->assertSame( array(), BlockConverter::convert_columns_block_to_blocks( $blocks[0] ) ); + } } From 80a02354175fd863e7df5d89b952460ed0a8aa62 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 10 Nov 2026 22:19:49 +0800 Subject: [PATCH 11/13] Revert "Wrap a styled Columns block in a beehiiv section that carries its styling" This reverts commit 34dd1c4e8c0ae16850563a0a7190a5cbc7e8a639. --- includes/Newsletter/BlockConverter.php | 18 ++------ .../specs/structured-content-blocks.spec.js | 30 +++---------- .../BlockConverterColumnsStyleTest.php | 44 +++---------------- 3 files changed, 18 insertions(+), 74 deletions(-) diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 283d7bc..db5ec12 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -537,10 +537,6 @@ public static function convert_list_block( array $wp_block ): array { */ public static function convert_columns_block( array $wp_block ): array { foreach ( self::convert_columns_block_to_blocks( $wp_block ) as $beehiiv_block ) { - if ( 'section' === ( $beehiiv_block['type'] ?? '' ) ) { - $beehiiv_block = $beehiiv_block['blocks'][0] ?? []; - } - if ( 'columns' === ( $beehiiv_block['type'] ?? '' ) ) { return $beehiiv_block; } @@ -552,11 +548,9 @@ public static function convert_columns_block( array $wp_block ): array { /** * Convert a core/columns block to beehiiv blocks. * - * A block with up to 3 columns becomes one beehiiv columns block. When the block sets a - * background colour, text colour, padding, margin or border that beehiiv can take, the - * columns block is wrapped in a beehiiv section that carries them as visual settings, - * because that is where beehiiv holds a container's styling. A block with more columns - * is omitted entirely, and the row is never split. A core/columns + * A block with up to 3 columns becomes one beehiiv columns block, carrying the + * block's background colour, text colour, padding, margin and border as visual + * settings. 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. * @@ -627,11 +621,7 @@ public static function convert_columns_block_to_blocks( array $wp_block ): array $visual_settings = ColumnsStyleConverter::build_visual_settings( $wp_block ); if ( ! empty( $columns_block ) && ! empty( $visual_settings ) ) { - $columns_block = [ - 'type' => 'section', - 'blocks' => [ $columns_block ], - 'visual_settings' => $visual_settings, - ]; + $columns_block['visual_settings'] = $visual_settings; } return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js index 20c670f..3fce0c1 100644 --- a/tests/e2e/specs/structured-content-blocks.spec.js +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -200,18 +200,6 @@ function columns( cols, { valign, stacked, attrs: extraAttrs, style } = {} ) { ) }
  • `; } -/** - * The section a styled Columns block is wrapped in. - * - * @param {Array} blocks Blocks sent to beehiiv. - * @return {Object} The section block. - */ -function styledSection( blocks ) { - const section = blocks.find( ( b ) => b.type === 'section' ); - expect( section, 'a section wraps the styled Columns block' ).toBeTruthy(); - return section; -} - const types = ( blocks ) => blocks.map( ( b ) => b.type ); const columnsBlocks = ( blocks ) => blocks.filter( ( b ) => b.type === 'columns' ); @@ -676,7 +664,7 @@ test.describe( 'US-006: top-level Columns blocks', () => { style: 'background-color:#ffeeaa;color:#112233', } ) ); - const settings = styledSection( blocks ).visual_settings; + const settings = columnsBlocks( blocks )[ 0 ].visual_settings; expect( settings.background_color ).toBe( '#ffeeaa' ); expect( settings.text_color ).toBe( '#112233' ); expect( settings.inner_spacing_top ).toBe( 20 ); @@ -684,18 +672,14 @@ test.describe( 'US-006: top-level Columns blocks', () => { expect( settings.outer_spacing_top ).toBe( 30 ); expect( settings ).not.toHaveProperty( 'inner_spacing_left' ); - // The section holds the columns block, which carries no styling itself. - const section = styledSection( blocks ); - expect( section.blocks ).toHaveLength( 1 ); - expect( section.blocks[ 0 ].type ).toBe( 'columns' ); - expect( section.blocks[ 0 ] ).not.toHaveProperty( 'visual_settings' ); - - // Control: a Columns block without styling is a plain columns block, with no section. + // Control: a Columns block without styling sends no visual settings. const plain = await convert( page, columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) ); - expect( types( plain ) ).toEqual( [ 'columns' ] ); + expect( columnsBlocks( plain )[ 0 ] ).not.toHaveProperty( + 'visual_settings' + ); } ); test( 'AC-022: a Columns block sends its border color, width, style and radius', async ( { @@ -715,7 +699,7 @@ test.describe( 'US-006: top-level Columns blocks', () => { }, } ) ); - const settings = styledSection( blocks ).visual_settings; + const settings = columnsBlocks( blocks )[ 0 ].visual_settings; expect( settings.border_color ).toBe( '#ff0000' ); expect( settings.border_width ).toBe( 2 ); // A border with a color or width and no style is sent as solid. @@ -736,7 +720,7 @@ test.describe( 'US-006: top-level Columns blocks', () => { }, } ) ); - const rejected = styledSection( dashed ).visual_settings; + const rejected = columnsBlocks( dashed )[ 0 ].visual_settings; // A style beehiiv does not accept, and a radius in percent, are not sent. expect( rejected ).not.toHaveProperty( 'border_style' ); expect( rejected ).not.toHaveProperty( 'border_radius' ); diff --git a/tests/phpunit/BlockConverterColumnsStyleTest.php b/tests/phpunit/BlockConverterColumnsStyleTest.php index dc85742..1f831ef 100644 --- a/tests/phpunit/BlockConverterColumnsStyleTest.php +++ b/tests/phpunit/BlockConverterColumnsStyleTest.php @@ -63,7 +63,7 @@ private function convert( array $attrs, int $columns = 2, string $style = '', st } /** - * The visual settings of the section that wraps the converted columns block. + * The visual settings of the converted columns block. * * @param array $attrs Columns block attributes. * @param int $columns Number of columns. @@ -74,16 +74,13 @@ private function convert( array $attrs, int $columns = 2, string $style = '', st private function visual_settings( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): array { $blocks = $this->convert( $attrs, $columns, $style, $classes ); - $this->assertSame( 'section', $blocks[0]['type'] ); - $this->assertCount( 1, $blocks[0]['blocks'] ); - $this->assertSame( 'columns', $blocks[0]['blocks'][0]['type'] ); - $this->assertArrayNotHasKey( 'visual_settings', $blocks[0]['blocks'][0] ); + $this->assertSame( 'columns', $blocks[0]['type'] ); - return $blocks[0]['visual_settings']; + return $blocks[0]['visual_settings'] ?? array(); } /** - * A Columns block with no styling is sent as a plain columns block, with no section. + * A Columns block with no styling sends no visual settings. */ public function test_unstyled_columns_have_no_visual_settings(): void { $blocks = $this->convert( array() ); @@ -338,7 +335,7 @@ public function test_border_radius_in_percent_is_not_sent(): void { } /** - * Styling does not change how the columns inside the section convert. + * Styling does not change how the columns convert. */ public function test_styling_does_not_change_the_columns(): void { $blocks = $this->convert( @@ -347,10 +344,8 @@ public function test_styling_does_not_change_the_columns(): void { ); $this->assertCount( 1, $blocks ); - $this->assertSame( 'section', $blocks[0]['type'] ); - $columns = $blocks[0]['blocks'][0]; - $this->assertCount( 3, $columns['columns'] ); - $this->assertTrue( $columns['stackOnMobile'] ); + $this->assertCount( 3, $blocks[0]['columns'] ); + $this->assertTrue( $blocks[0]['stackOnMobile'] ); } /** @@ -364,29 +359,4 @@ public function test_styled_columns_with_more_than_three_columns_are_omitted(): $this->assertSame( array(), $blocks ); } - - /** - * Styling that cannot be converted leaves a plain columns block, with no empty section. - */ - public function test_styling_that_cannot_be_converted_sends_no_section(): void { - $blocks = $this->convert( - array( 'style' => array( 'spacing' => array( 'padding' => '10%' ) ) ) - ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'columns', $blocks[0]['type'] ); - } - - /** - * A styled Columns block with nothing convertible inside sends no section and no columns block. - */ - public function test_styled_columns_with_nothing_convertible_send_nothing(): void { - $markup = '' - . '
    ' - . '
    ' - . '
    '; - $blocks = parse_blocks( $markup ); - - $this->assertSame( array(), BlockConverter::convert_columns_block_to_blocks( $blocks[0] ) ); - } } From 36a53714d2a450745b2998050eabdb8cb5d4e4f1 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 10 Nov 2026 22:21:16 +0800 Subject: [PATCH 12/13] Revert "Send a Columns block's colors, padding, margin and border as the columns block's visual settings" This reverts commit 2a5305b28b901482077485faad54ff7586d8f6ab. --- includes/Newsletter/BlockConverter.php | 12 +- .../Converters/ColumnsStyleConverter.php | 485 ------------------ includes/Newsletter/FormattedTextParser.php | 119 +---- .../specs/structured-content-blocks.spec.js | 89 +--- .../BlockConverterColumnsStyleTest.php | 362 ------------- 5 files changed, 6 insertions(+), 1061 deletions(-) delete mode 100644 includes/Newsletter/Converters/ColumnsStyleConverter.php delete mode 100644 tests/phpunit/BlockConverterColumnsStyleTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index db5ec12..9ed2efa 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -11,7 +11,6 @@ use Beehiiv\API\Resources\AdvertisementOpportunities; use Beehiiv\Editor\Meta; use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; -use Beehiiv\Newsletter\Converters\ColumnsStyleConverter; use Beehiiv\Newsletter\Converters\MediaTextBlockConverter; use Beehiiv\Newsletter\Converters\TableBlockConverter; use Beehiiv\Newsletter\Converters\ListBlockConverter; @@ -548,9 +547,8 @@ public static function convert_columns_block( array $wp_block ): array { /** * Convert a core/columns block to beehiiv blocks. * - * A block with up to 3 columns becomes one beehiiv columns block, carrying the - * block's background colour, text colour, padding, margin and border as visual - * settings. A block with more columns is omitted entirely, and the row is never split. A core/columns + * 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. * @@ -618,12 +616,6 @@ public static function convert_columns_block_to_blocks( array $wp_block ): array ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ) ); - $visual_settings = ColumnsStyleConverter::build_visual_settings( $wp_block ); - - if ( ! empty( $columns_block ) && ! empty( $visual_settings ) ) { - $columns_block['visual_settings'] = $visual_settings; - } - return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); } diff --git a/includes/Newsletter/Converters/ColumnsStyleConverter.php b/includes/Newsletter/Converters/ColumnsStyleConverter.php deleted file mode 100644 index 929ea51..0000000 --- a/includes/Newsletter/Converters/ColumnsStyleConverter.php +++ /dev/null @@ -1,485 +0,0 @@ - - */ - private const BORDER_STYLES = [ 'solid', 'dashed', 'dotted' ]; - - /** - * Corner names in WordPress and the matching beehiiv setting suffix. - * - * @var array - */ - private const CORNERS = [ - 'topLeft' => 'top_left', - 'topRight' => 'top_right', - 'bottomLeft' => 'bottom_left', - 'bottomRight' => 'bottom_right', - ]; - - /** - * Pixels in one `rem` or `em`, used to turn relative padding into whole pixels. - * - * @var int - */ - private const PIXELS_PER_REM = 16; - - /** - * Padding sides, in the order beehiiv names them. - * - * @var array - */ - private const SIDES = [ 'top', 'bottom', 'left', 'right' ]; - - /** - * Cached slug-to-size map for theme spacing presets. - * - * @var array|null - */ - private static ?array $spacing_preset_map = null; - - /** - * Build the beehiiv columns block `visual_settings` for a Columns block. - * - * @param array $wp_block Parsed core/columns block. - * @return array Empty when the Columns block sets no mapped styling. - * @since 1.2.0 - */ - public static function build_visual_settings( array $wp_block ): array { - $attrs = is_array( $wp_block['attrs'] ?? null ) ? $wp_block['attrs'] : []; - $inner_html = (string) ( $wp_block['innerHTML'] ?? '' ); - $settings = []; - - $background_color = FormattedTextParser::resolve_columns_block_background_color( $inner_html, $attrs ); - - if ( null !== $background_color && '' !== $background_color ) { - $settings['background_color'] = $background_color; - } - - $text_color = FormattedTextParser::resolve_columns_block_text_color( $inner_html, $attrs ); - - if ( null !== $text_color && '' !== $text_color ) { - $settings['text_color'] = $text_color; - } - - foreach ( self::resolve_spacing( $attrs, 'padding' ) as $side => $pixels ) { - $settings[ 'inner_spacing_' . $side ] = $pixels; - } - - foreach ( self::resolve_spacing( $attrs, 'margin' ) as $side => $pixels ) { - $settings[ 'outer_spacing_' . $side ] = $pixels; - } - - return array_merge( $settings, self::resolve_border( $attrs, $inner_html ) ); - } - - /** - * Resolve a Columns block's border to beehiiv border settings. - * - * A border colour, style or width that differs between sides cannot be sent, because - * beehiiv takes one colour and one style; widths and radii can be sent per side or - * corner. A border with a colour or width but no style is sent as `solid`, as WordPress - * draws it. - * - * @param array $attrs Parsed block attributes. - * @param string $inner_html Saved Columns HTML including the `
    ` wrapper. - * @return array - */ - private static function resolve_border( array $attrs, string $inner_html ): array { - $border = $attrs['style']['border'] ?? []; - $border = is_array( $border ) ? $border : []; - - $settings = self::resolve_border_widths( $border ); - $color = FormattedTextParser::resolve_columns_block_border_color( $inner_html, $attrs ) - ?? self::shared_side_value( $border, 'color' ); - - if ( null !== $color && '' !== $color ) { - $settings['border_color'] = $color; - } - - $style = self::resolve_border_style( $border ); - - if ( null === $style && isset( $border['style'] ) ) { - $style = false; - } - - if ( null === $style && ( isset( $settings['border_color'] ) || self::has_border_width( $settings ) ) ) { - $style = 'solid'; - } - - if ( is_string( $style ) ) { - $settings['border_style'] = $style; - } - - return array_merge( $settings, self::resolve_border_radius( $border ) ); - } - - /** - * Resolve border widths, one value for every side or one per side. - * - * @param array $border Columns `style.border` attributes. - * @return array - */ - private static function resolve_border_widths( array $border ): array { - if ( isset( $border['width'] ) ) { - $pixels = self::to_pixels( $border['width'] ); - - return null === $pixels ? [] : [ 'border_width' => $pixels ]; - } - - $widths = []; - - foreach ( self::SIDES as $side ) { - if ( ! isset( $border[ $side ]['width'] ) ) { - continue; - } - - $pixels = self::to_pixels( $border[ $side ]['width'] ); - - if ( null !== $pixels ) { - $widths[ 'border_width_' . $side ] = $pixels; - } - } - - return $widths; - } - - /** - * Resolve the border style, from one value or from sides that all agree. - * - * @param array $border Columns `style.border` attributes. - * @return string|null A style beehiiv accepts, or null when none is set or it is not accepted. - */ - private static function resolve_border_style( array $border ): ?string { - $style = isset( $border['style'] ) && is_string( $border['style'] ) - ? $border['style'] - : self::shared_side_value( $border, 'style' ); - - $style = null === $style ? null : strtolower( trim( $style ) ); - - return in_array( $style, self::BORDER_STYLES, true ) ? $style : null; - } - - /** - * Resolve border radius, one value for every corner or one per corner. - * - * @param array $border Columns `style.border` attributes. - * @return array - */ - private static function resolve_border_radius( array $border ): array { - $radius = $border['radius'] ?? null; - - if ( is_string( $radius ) || is_int( $radius ) || is_float( $radius ) ) { - $pixels = self::to_pixels( $radius ); - - return null === $pixels ? [] : [ 'border_radius' => $pixels ]; - } - - if ( ! is_array( $radius ) ) { - return []; - } - - $corners = []; - - foreach ( self::CORNERS as $corner => $suffix ) { - if ( ! isset( $radius[ $corner ] ) ) { - continue; - } - - $pixels = self::to_pixels( $radius[ $corner ] ); - - if ( null !== $pixels ) { - $corners[ 'border_radius_' . $suffix ] = $pixels; - } - } - - return $corners; - } - - /** - * Whether any border width was resolved. - * - * @param array $settings Border settings resolved so far. - * @return bool - */ - private static function has_border_width( array $settings ): bool { - foreach ( array_keys( $settings ) as $key ) { - if ( 0 === strpos( (string) $key, 'border_width' ) ) { - return true; - } - } - - return false; - } - - /** - * Read a per-side value that every set side shares. - * - * @param array $border Columns `style.border` attributes. - * @param string $key Per-side key, `color` or `style`. - * @return string|null The shared value, or null when no side sets it or the sides differ. - */ - private static function shared_side_value( array $border, string $key ): ?string { - $values = []; - - foreach ( self::SIDES as $side ) { - $value = $border[ $side ][ $key ] ?? null; - - if ( ! is_string( $value ) ) { - continue; - } - - $value = 'color' === $key ? FormattedTextParser::resolve_color_reference( $value ) : trim( $value ); - - if ( null !== $value && '' !== $value ) { - $values[] = $value; - } - } - - $values = array_unique( $values ); - - return 1 === count( $values ) ? $values[0] : null; - } - - /** - * Resolve each side of a Columns block's padding or margin to whole pixels. - * - * A side whose value cannot be turned into pixels (percentages, viewport units, - * `auto`, negative values, `clamp()` presets) is left out so beehiiv keeps its default - * for that side. - * - * @param array $attrs Parsed block attributes. - * @param string $property Spacing property, `padding` or `margin`. - * @return array Pixels keyed by side. - */ - private static function resolve_spacing( array $attrs, string $property ): array { - $spacing = $attrs['style']['spacing'][ $property ] ?? null; - - if ( is_string( $spacing ) || is_int( $spacing ) ) { - $spacing = array_fill_keys( self::SIDES, $spacing ); - } - - if ( ! is_array( $spacing ) ) { - return []; - } - - $resolved = []; - - foreach ( self::SIDES as $side ) { - if ( ! isset( $spacing[ $side ] ) ) { - continue; - } - - $pixels = self::to_pixels( $spacing[ $side ] ); - - if ( null !== $pixels ) { - $resolved[ $side ] = $pixels; - } - } - - return $resolved; - } - - /** - * Convert one padding or margin value to whole pixels. - * - * @param mixed $value Spacing value: a number, a CSS length, or a spacing preset. - * @return int|null Pixels, or null when the value cannot be converted. - */ - private static function to_pixels( $value ): ?int { - if ( is_int( $value ) || is_float( $value ) ) { - return max( 0, (int) round( $value ) ); - } - - if ( ! is_string( $value ) ) { - return null; - } - - $value = trim( $value ); - $slug = self::extract_spacing_preset_slug( $value ); - - if ( null !== $slug ) { - $size = self::get_spacing_preset_map()[ $slug ] ?? null; - - return null === $size ? null : self::css_length_to_pixels( $size ); - } - - return self::css_length_to_pixels( $value ); - } - - /** - * Convert a CSS length (`40px`, `2.5rem`, `1em`, `12`) or a fluid size to whole pixels. - * - * Themes often define spacing as `clamp()`, `min()` or `max()`. A newsletter has no - * responsive spacing, so a fluid size is sent at its widest value: `clamp()` at its - * maximum, `min()` at its smallest fixed argument, `max()` at its largest. An argument - * that is not a fixed length (a `vw` unit, `calc()`) is ignored. - * - * @param string $length CSS length or fluid size. - * @return int|null Pixels, or null for any other unit or function. - */ - private static function css_length_to_pixels( string $length ): ?int { - $length = trim( $length ); - - if ( preg_match( '/^(clamp|min|max)\((.*)\)$/is', $length, $call ) ) { - return self::fluid_size_to_pixels( strtolower( $call[1] ), self::split_arguments( $call[2] ) ); - } - - if ( ! preg_match( '/^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i', trim( $length ), $matches ) ) { - return null; - } - - $number = (float) $matches[1]; - $unit = strtolower( $matches[2] ?? 'px' ); - - if ( 'px' !== $unit ) { - $number *= self::PIXELS_PER_REM; - } - - return (int) round( $number ); - } - - /** - * Resolve a `clamp()`, `min()` or `max()` size to whole pixels. - * - * @param string $name Function name: `clamp`, `min` or `max`. - * @param array $arguments Top-level arguments of the function. - * @return int|null Pixels, or null when no argument is a fixed length. - */ - private static function fluid_size_to_pixels( string $name, array $arguments ): ?int { - if ( 'clamp' === $name ) { - // clamp( minimum, preferred, maximum ): the maximum is the widest size. - return 3 === count( $arguments ) ? self::css_length_to_pixels( $arguments[2] ) : null; - } - - $values = array_filter( - array_map( array( self::class, 'css_length_to_pixels' ), $arguments ), - static function ( $pixels ) { - return null !== $pixels; - } - ); - - if ( empty( $values ) ) { - return null; - } - - return 'min' === $name ? min( $values ) : max( $values ); - } - - /** - * Split a CSS function's arguments on top-level commas. - * - * @param string $arguments Text between the function's outer parentheses. - * @return array - */ - private static function split_arguments( string $arguments ): array { - $parts = []; - $depth = 0; - $start = 0; - $total = strlen( $arguments ); - - for ( $index = 0; $index < $total; $index++ ) { - $char = $arguments[ $index ]; - - if ( '(' === $char ) { - ++$depth; - } elseif ( ')' === $char ) { - --$depth; - } elseif ( ',' === $char && 0 === $depth ) { - $parts[] = trim( substr( $arguments, $start, $index - $start ) ); - $start = $index + 1; - } - } - - $parts[] = trim( substr( $arguments, $start ) ); - - return $parts; - } - - /** - * Read the preset slug from `var:preset|spacing|40` or `var(--wp--preset--spacing--40)`. - * - * @param string $value Padding value. - * @return string|null Preset slug, or null when the value is not a spacing preset. - */ - private static function extract_spacing_preset_slug( string $value ): ?string { - if ( preg_match( '/^var:preset\|spacing\|([a-z0-9-]+)$/i', $value, $matches ) ) { - return $matches[1]; - } - - if ( preg_match( '/^var\(--wp--preset--spacing--([a-z0-9-]+)\)$/i', $value, $matches ) ) { - return $matches[1]; - } - - return null; - } - - /** - * Build a cached slug-to-size map from theme spacing presets. - * - * @return array - */ - private static function get_spacing_preset_map(): array { - if ( null !== self::$spacing_preset_map ) { - return self::$spacing_preset_map; - } - - $map = []; - $sizes = wp_get_global_settings( array( 'spacing', 'spacingSizes' ) ); - $sizes = is_array( $sizes ) ? $sizes : []; - - // Apply origins in WordPress's own order, so a later origin overrides an earlier one with the same slug. - // A theme can turn WordPress's default sizes off, and then they are not offered at all. - $sources = false === wp_get_global_settings( array( 'spacing', 'defaultSpacingSizes' ) ) - ? array( 'theme', 'custom' ) - : array( 'default', 'theme', 'custom' ); - - foreach ( $sources as $source ) { - if ( empty( $sizes[ $source ] ) || ! is_array( $sizes[ $source ] ) ) { - continue; - } - - foreach ( $sizes[ $source ] as $entry ) { - if ( - ! is_array( $entry ) - || empty( $entry['slug'] ) - || ! isset( $entry['size'] ) - || ! is_string( $entry['slug'] ) - || ! is_string( $entry['size'] ) - ) { - continue; - } - - $map[ $entry['slug'] ] = $entry['size']; - } - } - - self::$spacing_preset_map = $map; - - return self::$spacing_preset_map; - } -} diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php index 10e5a87..07b208b 100644 --- a/includes/Newsletter/FormattedTextParser.php +++ b/includes/Newsletter/FormattedTextParser.php @@ -212,119 +212,6 @@ public static function resolve_paragraph_block_background_color( string $inner_h return self::resolve_wrapper_color_from_html_tag( $inner_html, 'p', 'background' ); } - /** - * Resolve a Columns block's background colour from attrs and the `
    ` wrapper. - * - * @param string $inner_html Saved Columns HTML including the `
    ` wrapper. - * @param array $attrs Parsed block attributes. - * @return string|null - * @since 1.2.0 - */ - public static function resolve_columns_block_background_color( string $inner_html, array $attrs ): ?string { - $from_attrs = self::resolve_block_background_color_from_attrs( $attrs ); - - if ( null !== $from_attrs ) { - return $from_attrs; - } - - return self::resolve_wrapper_color_from_html_tag( $inner_html, 'div', 'background' ); - } - - /** - * Resolve a Columns block's text colour from attrs and the `
    ` wrapper. - * - * @param string $inner_html Saved Columns HTML including the `
    ` wrapper. - * @param array $attrs Parsed block attributes. - * @return string|null - * @since 1.2.0 - */ - public static function resolve_columns_block_text_color( string $inner_html, array $attrs ): ?string { - $from_attrs = self::resolve_block_text_color_from_attrs( $attrs ); - - if ( null !== $from_attrs ) { - return $from_attrs; - } - - return self::resolve_wrapper_color_from_html_tag( $inner_html, 'div', 'text' ); - } - - /** - * Resolve a colour reference to a colour value. - * - * Palette variables (`var:preset|color|slug`, as the block editor stores them on a split - * border, or `var(--wp--preset--color--slug)`) become the palette colour's value. Any other - * value is returned as given. - * - * @param string $value Colour value or palette variable. - * @return string|null Null when the value is empty, transparent, or names an unknown palette colour. - * @since 1.2.0 - */ - public static function resolve_color_reference( string $value ): ?string { - $value = trim( $value ); - - if ( '' === $value || self::is_transparent_color( $value ) ) { - return null; - } - - if ( - preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/i', $value, $matches ) - || preg_match( '/^var\(--wp--preset--color--([a-z0-9-]+)\)$/i', $value, $matches ) - ) { - return self::get_color_preset_map()[ $matches[1] ] ?? null; - } - - return $value; - } - - /** - * Resolve a Columns block's border colour from attrs and the `
    ` wrapper. - * - * Reads a custom colour, then a palette slug, then the saved wrapper's inline style and - * `has-{slug}-border-color` class. - * - * @param string $inner_html Saved Columns HTML including the `
    ` wrapper. - * @param array $attrs Parsed block attributes. - * @return string|null - * @since 1.2.0 - */ - public static function resolve_columns_block_border_color( string $inner_html, array $attrs ): ?string { - $custom = $attrs['style']['border']['color'] ?? null; - - if ( is_string( $custom ) ) { - $resolved = self::resolve_color_reference( $custom ); - - if ( null !== $resolved ) { - return $resolved; - } - } - - if ( ! empty( $attrs['borderColor'] ) && is_string( $attrs['borderColor'] ) ) { - $from_slug = self::get_color_preset_map()[ trim( $attrs['borderColor'] ) ] ?? null; - - if ( null !== $from_slug ) { - return $from_slug; - } - } - - $attributes = self::get_first_tag_attributes( $inner_html, 'div' ); - - if ( null === $attributes ) { - return null; - } - - if ( '' !== $attributes['style'] ) { - $color = self::extract_css_color( html_entity_decode( $attributes['style'], ENT_QUOTES ), 'border-color' ); - - if ( null !== $color && ! self::is_transparent_color( $color ) ) { - return $color; - } - } - - $slug = self::extract_preset_slug( $attributes['class'], 'border-color' ); - - return null === $slug ? null : ( self::get_color_preset_map()[ $slug ] ?? null ); - } - /** * Resolve block-level list-item text colour from attrs and the `
  • ` tag. * @@ -1222,11 +1109,7 @@ private static function get_color_preset_map(): array { $map = []; $palette = wp_get_global_settings( array( 'color', 'palette' ) ); - // Apply origins in WordPress's own order, so a later origin overrides an earlier one with the same slug. - // A theme can turn WordPress's default palette off, and then its colours are not offered at all. - $sources = false === wp_get_global_settings( array( 'color', 'defaultPalette' ) ) - ? array( 'theme', 'custom' ) - : array( 'default', 'theme', 'custom' ); + $sources = array( 'theme', 'default', 'custom' ); $palette = is_array( $palette ) ? $palette : []; foreach ( $sources as $source ) { diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js index 3fce0c1..faf68de 100644 --- a/tests/e2e/specs/structured-content-blocks.spec.js +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -176,12 +176,10 @@ function col( inner, { width, valign } = {} ) { * @param {Object} [options] Columns options. * @param {string} [options.valign] Block-level verticalAlignment. * @param {boolean} [options.stacked] isStackedOnMobile value. - * @param {Object} [options.attrs] Extra block attributes, such as `style`. - * @param {string} [options.style] Inline style saved on the wrapper element. * @return {string} Serialized columns block. */ -function columns( cols, { valign, stacked, attrs: extraAttrs, style } = {} ) { - const attrs = { ...extraAttrs }; +function columns( cols, { valign, stacked } = {} ) { + const attrs = {}; if ( valign ) { attrs.verticalAlignment = valign; } @@ -194,8 +192,7 @@ function columns( cols, { valign, stacked, attrs: extraAttrs, style } = {} ) { const cls = 'wp-block-columns' + ( stacked === false ? ' is-not-stacked-on-mobile' : '' ); - const styleAttr = style ? ` style="${ style }"` : ''; - return `
    ${ cols.join( + return `
    ${ cols.join( '' ) }
    `; } @@ -645,84 +642,4 @@ test.describe( 'US-006: top-level Columns blocks', () => { ] ); expect( types( cols[ 2 ].blocks ) ).toEqual( [ 'button' ] ); } ); - - test( 'AC-021: a Columns block sends its colors, padding and margin as visual settings', async ( { - page, - } ) => { - const blocks = await convert( - page, - columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { - attrs: { - style: { - color: { background: '#ffeeaa', text: '#112233' }, - spacing: { - padding: { top: '20px', bottom: '1.5rem' }, - margin: { top: '30px' }, - }, - }, - }, - style: 'background-color:#ffeeaa;color:#112233', - } ) - ); - const settings = columnsBlocks( blocks )[ 0 ].visual_settings; - expect( settings.background_color ).toBe( '#ffeeaa' ); - expect( settings.text_color ).toBe( '#112233' ); - expect( settings.inner_spacing_top ).toBe( 20 ); - expect( settings.inner_spacing_bottom ).toBe( 24 ); - expect( settings.outer_spacing_top ).toBe( 30 ); - expect( settings ).not.toHaveProperty( 'inner_spacing_left' ); - - // Control: a Columns block without styling sends no visual settings. - const plain = await convert( - page, - columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) - ); - expect( columnsBlocks( plain )[ 0 ] ).not.toHaveProperty( - 'visual_settings' - ); - } ); - - test( 'AC-022: a Columns block sends its border color, width, style and radius', async ( { - page, - } ) => { - const blocks = await convert( - page, - columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { - attrs: { - style: { - border: { - color: '#ff0000', - width: '2px', - radius: '6px', - }, - }, - }, - } ) - ); - const settings = columnsBlocks( blocks )[ 0 ].visual_settings; - expect( settings.border_color ).toBe( '#ff0000' ); - expect( settings.border_width ).toBe( 2 ); - // A border with a color or width and no style is sent as solid. - expect( settings.border_style ).toBe( 'solid' ); - expect( settings.border_radius ).toBe( 6 ); - - const dashed = await convert( - page, - columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { - attrs: { - style: { - border: { - width: '1px', - style: 'double', - radius: '50%', - }, - }, - }, - } ) - ); - const rejected = columnsBlocks( dashed )[ 0 ].visual_settings; - // A style beehiiv does not accept, and a radius in percent, are not sent. - expect( rejected ).not.toHaveProperty( 'border_style' ); - expect( rejected ).not.toHaveProperty( 'border_radius' ); - } ); } ); diff --git a/tests/phpunit/BlockConverterColumnsStyleTest.php b/tests/phpunit/BlockConverterColumnsStyleTest.php deleted file mode 100644 index 1f831ef..0000000 --- a/tests/phpunit/BlockConverterColumnsStyleTest.php +++ /dev/null @@ -1,362 +0,0 @@ - $attrs Columns block attributes. - * @param int $columns Number of columns. - * @param string $style Inline style on the wrapper. - * @param string $classes Extra classes on the wrapper. - * @return string - */ - private function columns_markup( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): string { - $json = empty( $attrs ) ? '' : ' ' . wp_json_encode( $attrs ); - $style = '' === $style ? '' : ' style="' . $style . '"'; - $classes = '' === $classes ? '' : ' ' . $classes; - $inside = ''; - - for ( $i = 1; $i <= $columns; $i++ ) { - $inside .= "\n
    " - . "\n

    Column {$i}

    \n" - . "
    \n\n"; - } - - $open = "\n
    "; - $close = "
    \n"; - - return $open . $inside . $close; - } - - /** - * Convert a Columns block's markup to beehiiv blocks. - * - * @param array $attrs Columns block attributes. - * @param int $columns Number of columns. - * @param string $style Inline style on the wrapper. - * @param string $classes Extra classes on the wrapper. - * @return array> - */ - private function convert( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): array { - $markup = $this->columns_markup( $attrs, $columns, $style, $classes ); - $blocks = parse_blocks( $markup ); - - return BlockConverter::convert_columns_block_to_blocks( $blocks[0] ); - } - - /** - * The visual settings of the converted columns block. - * - * @param array $attrs Columns block attributes. - * @param int $columns Number of columns. - * @param string $style Inline style on the wrapper. - * @param string $classes Extra classes on the wrapper. - * @return array - */ - private function visual_settings( array $attrs, int $columns = 2, string $style = '', string $classes = '' ): array { - $blocks = $this->convert( $attrs, $columns, $style, $classes ); - - $this->assertSame( 'columns', $blocks[0]['type'] ); - - return $blocks[0]['visual_settings'] ?? array(); - } - - /** - * A Columns block with no styling sends no visual settings. - */ - public function test_unstyled_columns_have_no_visual_settings(): void { - $blocks = $this->convert( array() ); - - $this->assertSame( 'columns', $blocks[0]['type'] ); - $this->assertArrayNotHasKey( 'visual_settings', $blocks[0] ); - } - - /** - * Custom background and text colors become visual settings. - */ - public function test_custom_colors_become_visual_settings(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'color' => array( - 'background' => '#ffeeaa', - 'text' => '#112233', - ), - ), - ) - ); - - $this->assertSame( '#ffeeaa', $settings['background_color'] ); - $this->assertSame( '#112233', $settings['text_color'] ); - } - - /** - * Colors saved only in the wrapper's inline style are still read. - */ - public function test_colors_from_the_wrapper_style_are_read(): void { - $settings = $this->visual_settings( - array(), - 2, - 'background-color:#abcdef;color:#102030', - 'has-background has-text-color' - ); - - $this->assertSame( '#abcdef', $settings['background_color'] ); - $this->assertSame( '#102030', $settings['text_color'] ); - } - - /** - * Padding becomes inner spacing in whole pixels, side by side. - */ - public function test_padding_becomes_inner_spacing_in_pixels(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'spacing' => array( - 'padding' => array( - 'top' => '20px', - 'bottom' => '1.5rem', - 'left' => '10px', - 'right' => '2em', - ), - ), - ), - ) - ); - - $this->assertSame( 20, $settings['inner_spacing_top'] ); - $this->assertSame( 24, $settings['inner_spacing_bottom'] ); - $this->assertSame( 10, $settings['inner_spacing_left'] ); - $this->assertSame( 32, $settings['inner_spacing_right'] ); - } - - /** - * A single padding value applies to every side. - */ - public function test_single_padding_value_applies_to_every_side(): void { - $settings = $this->visual_settings( - array( 'style' => array( 'spacing' => array( 'padding' => '12px' ) ) ) - ); - - foreach ( array( 'top', 'bottom', 'left', 'right' ) as $side ) { - $this->assertSame( 12, $settings[ 'inner_spacing_' . $side ] ); - } - } - - /** - * A side that cannot be turned into pixels is left out. - */ - public function test_padding_that_is_not_in_pixels_is_left_out(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'spacing' => array( - 'padding' => array( - 'top' => '10%', - 'bottom' => '20px', - 'left' => '5vw', - 'right' => 'auto', - ), - ), - ), - ) - ); - - $this->assertSame( 20, $settings['inner_spacing_bottom'] ); - $this->assertArrayNotHasKey( 'inner_spacing_top', $settings ); - $this->assertArrayNotHasKey( 'inner_spacing_left', $settings ); - $this->assertArrayNotHasKey( 'inner_spacing_right', $settings ); - } - - /** - * Margin becomes outer spacing, next to padding as inner spacing. - */ - public function test_margin_becomes_outer_spacing_in_pixels(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'spacing' => array( - 'padding' => array( 'top' => '8px' ), - 'margin' => array( - 'top' => '30px', - 'bottom' => '2rem', - ), - ), - ), - ) - ); - - $this->assertSame( 8, $settings['inner_spacing_top'] ); - $this->assertSame( 30, $settings['outer_spacing_top'] ); - $this->assertSame( 32, $settings['outer_spacing_bottom'] ); - $this->assertArrayNotHasKey( 'outer_spacing_left', $settings ); - } - - /** - * A border sends its color, width, style and radius. - */ - public function test_border_becomes_visual_settings(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'border' => array( - 'color' => '#ff0000', - 'width' => '2px', - 'style' => 'dashed', - 'radius' => '6px', - ), - ), - ) - ); - - $this->assertSame( '#ff0000', $settings['border_color'] ); - $this->assertSame( 2, $settings['border_width'] ); - $this->assertSame( 'dashed', $settings['border_style'] ); - $this->assertSame( 6, $settings['border_radius'] ); - } - - /** - * A border with a color or width and no style is sent as solid. - */ - public function test_border_without_a_style_is_solid(): void { - $settings = $this->visual_settings( - array( 'style' => array( 'border' => array( 'width' => '1px' ) ) ) - ); - - $this->assertSame( 'solid', $settings['border_style'] ); - $this->assertSame( 1, $settings['border_width'] ); - } - - /** - * A border style beehiiv does not accept is not sent. - */ - public function test_unsupported_border_style_is_not_sent(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'border' => array( - 'width' => '2px', - 'style' => 'double', - ), - ), - ) - ); - - $this->assertArrayNotHasKey( 'border_style', $settings ); - } - - /** - * Widths and radii can be set per side and per corner. - */ - public function test_border_widths_and_radii_can_be_set_per_side_and_corner(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'border' => array( - 'top' => array( 'width' => '1px' ), - 'right' => array( 'width' => '2px' ), - 'radius' => array( - 'topLeft' => '4px', - 'bottomRight' => '8px', - ), - ), - ), - ) - ); - - $this->assertSame( 1, $settings['border_width_top'] ); - $this->assertSame( 2, $settings['border_width_right'] ); - $this->assertSame( 4, $settings['border_radius_top_left'] ); - $this->assertSame( 8, $settings['border_radius_bottom_right'] ); - $this->assertArrayNotHasKey( 'border_radius_top_right', $settings ); - } - - /** - * Border colors and styles that differ by side are not sent. - */ - public function test_border_colors_and_styles_that_differ_by_side_are_not_sent(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'border' => array( - 'top' => array( - 'width' => '1px', - 'color' => '#ff0000', - 'style' => 'solid', - ), - 'bottom' => array( - 'width' => '1px', - 'color' => '#00ff00', - 'style' => 'dotted', - ), - ), - ), - ) - ); - - $this->assertArrayNotHasKey( 'border_color', $settings ); - $this->assertSame( 'solid', $settings['border_style'] ); - } - - /** - * A border radius in percent is not sent. - */ - public function test_border_radius_in_percent_is_not_sent(): void { - $settings = $this->visual_settings( - array( - 'style' => array( - 'border' => array( - 'width' => '1px', - 'radius' => '50%', - ), - ), - ) - ); - - $this->assertArrayNotHasKey( 'border_radius', $settings ); - } - - /** - * Styling does not change how the columns convert. - */ - public function test_styling_does_not_change_the_columns(): void { - $blocks = $this->convert( - array( 'style' => array( 'color' => array( 'background' => '#ffeeaa' ) ) ), - 3 - ); - - $this->assertCount( 1, $blocks ); - $this->assertCount( 3, $blocks[0]['columns'] ); - $this->assertTrue( $blocks[0]['stackOnMobile'] ); - } - - /** - * A styled Columns block with more than 3 columns is still omitted. - */ - public function test_styled_columns_with_more_than_three_columns_are_omitted(): void { - $blocks = $this->convert( - array( 'style' => array( 'color' => array( 'background' => '#ffeeaa' ) ) ), - 4 - ); - - $this->assertSame( array(), $blocks ); - } -} From 063b012e9a9e0ce6abde9a301bdd4e808bda9089 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Thu, 12 Nov 2026 22:42:22 +0800 Subject: [PATCH 13/13] Use theme palette colors and a full title replace in the e2e specs after the release merge --- tests/e2e/specs/block-conversion-framework.spec.js | 2 +- tests/e2e/specs/email-preview.spec.js | 4 ++-- tests/e2e/specs/structured-content-blocks.spec.js | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js index a2c4737..a76711b 100644 --- a/tests/e2e/specs/block-conversion-framework.spec.js +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -737,7 +737,7 @@ test.describe( 'US-006: Group blocks become sections', () => { } ) => { // The editor stores a palette color on a split border as var:preset|color|slug on each side. const side = ( width ) => - `{"color":"var:preset|color|vivid-red","width":"${ width }"}`; + `{"color":"var:preset|color|accent","width":"${ width }"}`; const split = `