diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 1944c33..509077c 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -33,6 +33,7 @@ * - `core/quote` — `convert_quote_block()` * - `core/pullquote` — `convert_pullquote_block()` * - `core/embed` — `convert_embed_block()` + * - `core/columns` — `convert_columns_block_to_blocks()` (beehiiv `columns` block, up to 3 columns) * - `core/media-text` — `convert_media_text_block()` (beehiiv `columns` block) * - `core/buttons` / inner `core/button` — `convert_buttons_block()`, `convert_button_block()` * - `core/separator` — beehiiv `content_break` @@ -43,10 +44,10 @@ * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`; a Group * inside a Group is omitted because beehiiv sections cannot hold sections) * - * Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is - * `core/group` for posts first synced before section support. - * `convert_columns_block()` exists for internal reuse (e.g. `core/media-text`) and is - * not wired into the main block walk yet. Snippet mode stops at `core/more`. + * `core/group` is skipped at the top level for posts first synced before section support. + * A `core/columns` block with more than 3 columns is omitted. A `core/columns` block + * nested inside a column is not converted to a columns block; its inner blocks are + * emitted at the top level instead. Snippet mode stops at `core/more`. * * @since 1.0.0 */ @@ -148,6 +149,11 @@ private static function convert_blocks( continue; } + if ( 'core/columns' === $block_name ) { + $beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_columns_block_to_blocks( $wp_block ) ); + continue; + } + $beehiiv_block = self::convert_block( $wp_block ); if ( ! empty( $beehiiv_block ) ) { @@ -398,7 +404,7 @@ public static function convert_paragraph_block( array $wp_block ): array { $block_background_color = FormattedTextParser::resolve_paragraph_block_background_color( $inner_html, $attrs ); $parsed = FormattedTextParser::parse( $inline_html, $block_text_color ); - if ( '' === trim( $parsed['plaintext'] ) ) { + if ( '' === preg_replace( '/[\s\x{00A0}]+/u', '', $parsed['plaintext'] ) ) { return []; } @@ -583,64 +589,116 @@ public static function convert_list_block( array $wp_block ): array { /** * Convert a core/columns block to a beehiiv columns block. * - * Not included in the top-level newsletter block walk yet. Intended for - * internal reuse by `convert_media_text_block()` and future direct columns support. + * Returns an empty array when the block is not converted to a columns block, + * including when it has more columns than beehiiv accepts. * * @param array $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 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. + * @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 ( empty( $column_blocks ) ) { - continue; - } + if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { + return []; + } - $beehiiv_column = ColumnsBlockConverter::build_beehiiv_column( - $column_blocks, - $column_block, - $parent_vertical_align - ); + $kept_blocks = []; + $kept_blocks_by_column = []; - if ( ! empty( $beehiiv_column ) ) { - $beehiiv_columns[] = $beehiiv_column; + foreach ( $column_blocks as $index => $column_block ) { + if ( ! empty( $converted_columns[ $index ] ) ) { + $kept_blocks[] = $column_block; + $kept_blocks_by_column[] = $converted_columns[ $index ]; } } - return ColumnsBlockConverter::build_beehiiv_columns_block( $beehiiv_columns, $stack_on_mobile ); + $widths = ColumnsBlockConverter::resolve_row_widths( $kept_blocks ); + $columns = []; + + foreach ( $kept_blocks as $index => $column_block ) { + $columns[] = ColumnsBlockConverter::build_beehiiv_column_from_definition( + $kept_blocks_by_column[ $index ], + $widths[ $index ], + ColumnsBlockConverter::resolve_column_vertical_alignment( + $column_block['attrs'] ?? [], + (string) ( $column_block['innerHTML'] ?? '' ), + $parent_vertical_align + ) + ); + } + + $columns_block = ColumnsBlockConverter::build_beehiiv_columns_block( + $columns, + ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ) + ); + + return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); } /** * Convert nested inner blocks inside a layout block to beehiiv blocks. * * Used by layout blocks such as columns and media-text. Unsupported blocks are - * skipped; snippet-only blocks are omitted. + * skipped; snippet-only blocks are omitted. A nested core/columns block is never + * converted inside a column. When `$nested_blocks` is passed, its inner blocks are + * added to it for the caller to emit at the top level. Without it, the nested block + * is omitted. * - * @param array> $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 ) { @@ -650,6 +708,22 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a $block_name = (string) $wp_block['blockName']; + if ( 'core/columns' === $block_name ) { + if ( null !== $nested_blocks ) { + foreach ( $wp_block['innerBlocks'] ?? [] as $nested_column ) { + if ( 'core/column' === ( $nested_column['blockName'] ?? '' ) ) { + $nested_converted = self::convert_layout_block_inner_blocks( + $nested_column['innerBlocks'] ?? [], + $nested_blocks + ); + $nested_blocks = array_merge( $nested_blocks, $nested_converted ); + } + } + } + + continue; + } + if ( 'core/buttons' === $block_name ) { $beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_buttons_block( $wp_block ) ); continue; @@ -680,7 +754,8 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a * Maps attrs.head into rows[0] with headerRow when the Header section is enabled * in the block editor. attrs.body supplies remaining rows. When attrs are empty * (common for query-sourced table attributes in PHP parse_blocks), rows are read - * from innerHTML instead. The table is omitted when every cell is empty. + * from innerHTML instead. The table is omitted when every cell is empty. A block + * background colour is sent as `visual_settings.background_color`. * Footer rows are omitted; beehiiv has no table footer support. * * @param array $wp_block Parsed block. @@ -737,11 +812,21 @@ public static function convert_table_block( array $wp_block ): array { return []; } - return [ + $table_block = [ 'type' => 'table', 'rows' => $rows, 'headerRow' => $header_row, ]; + + $background_color = FormattedTextParser::resolve_table_block_background_color( $inner_html, $attrs ); + + if ( null !== $background_color && '' !== $background_color ) { + $table_block['visual_settings'] = [ + 'background_color' => $background_color, + ]; + } + + return $table_block; } /** @@ -1021,8 +1106,9 @@ public static function convert_embed_block( array $wp_block ): array { * Convert a core/media-text block to a beehiiv columns block. * * Maps the media side to an image column and the content side to converted - * inner blocks. Blocks with video media are omitted entirely. Respects media - * position, column widths, vertical alignment, and mobile stacking. + * inner blocks. Blocks with video media, or with no valid image, are omitted + * entirely. Respects media position, column widths, vertical alignment, and + * mobile stacking. * * @param array $wp_block Parsed block. * @return array @@ -1042,7 +1128,12 @@ public static function convert_media_text_block( array $wp_block ): array { $media_width = MediaTextBlockConverter::resolve_media_width( $attrs, $inner_html ); $content_width = max( 1, min( 100, 100 - $media_width ) ); - $media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html ); + $media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html ); + + if ( empty( $media_blocks ) ) { + return []; + } + $content_blocks = self::convert_layout_block_inner_blocks( $inner_blocks ); if ( empty( $content_blocks ) && '' !== $inner_html ) { 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/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php index 0cd7edb..2a41d5f 100644 --- a/includes/Newsletter/FormattedTextParser.php +++ b/includes/Newsletter/FormattedTextParser.php @@ -382,6 +382,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/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index 80ba90a..695c6cc 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -7,15 +7,18 @@ namespace Beehiiv\Newsletter; +use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; + defined( 'ABSPATH' ) || exit; /** * Single source of truth for which WordPress blocks are included in newsletters. * * `core/group` is supported as a beehiiv section for posts first synced with section - * support; see `BlockConverter::convert_group_block()`. Other layout blocks such as - * `core/columns` are not supported. They and all of their inner blocks are omitted - * from the newsletter payload. + * support; see `BlockConverter::convert_group_block()`. `core/columns` is supported, but a + * Columns block with more than 3 columns is omitted; see + * `BlockConverter::convert_columns_block_to_blocks()`. Other layout blocks are not + * supported. They and all of their inner blocks are omitted from the newsletter payload. * * @since 1.0.0 */ @@ -36,6 +39,7 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/columns', 'core/group', 'core/buttons', 'core/separator', @@ -49,6 +53,7 @@ final class SupportedBlocks { */ public const NESTED_ONLY = [ 'core/button', + 'core/column', 'core/list-item', ]; @@ -64,7 +69,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 +77,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/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 = `

${ text }

`; +// A Code block has no beehiiv equivalent, so the newsletter leaves it out. +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 () => { + 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.' ); + // 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 ); + + // 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.' ); + // 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 + ); + + 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 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, + } ) => { + 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 ); + } ); +} ); 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..62db450 --- /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":"accent"}', '' ) + ); + 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":"accent"}', + ' {"textColor":"accent-2"}' + ) + ); + 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 = '' ) => + `
NameQty
Apple3
Total3
`; + + 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 is omitted', async ( { + page, + } ) => { + const four = await convert( + page, + 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( [ + 'Before', + 'After', + ] ); + + // 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' ] ); + } ); +} ); diff --git a/tests/phpunit/BlockConverterColumnsTest.php b/tests/phpunit/BlockConverterColumnsTest.php new file mode 100644 index 0000000..011c956 --- /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 are omitted entirely, with no columns block and no top-level blocks. + */ + public function test_more_than_three_columns_are_omitted(): void { + $blocks = $this->convert( + $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( 2, $blocks ); + $this->assertSame( 'Before', $blocks[0]['plaintext'] ); + $this->assertSame( 'After', $blocks[1]['plaintext'] ); + } + + /** + * 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'] ); + } +} 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'] ); + } +} 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'] ); + } +} 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 ); + } +}