diff --git a/includes/Editor/Meta.php b/includes/Editor/Meta.php index 8dfc86a..ca9deb2 100644 --- a/includes/Editor/Meta.php +++ b/includes/Editor/Meta.php @@ -72,6 +72,15 @@ final class Meta { */ public const BEEHIIV_POST_ID = '_beehiiv_post_id'; + /** + * Set when the post was first synced with Group blocks converted to beehiiv sections. + * + * Posts synced before section support lack it and keep omitting Group blocks on re-sync. + * + * @since x.x.x + */ + public const BEEHIIV_GROUP_SECTIONS = '_beehiiv_group_sections'; + /** * UTC ISO 8601 datetime last synced to beehiiv `scheduled_at` for a linked post. * diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 32a1f94..1944c33 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\GroupBlockConverter; use Beehiiv\Newsletter\Converters\MediaTextBlockConverter; use Beehiiv\Newsletter\Converters\TableBlockConverter; use Beehiiv\Newsletter\Converters\ListBlockConverter; @@ -38,7 +39,12 @@ * - `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. + * - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner + * 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`. * @@ -49,11 +55,13 @@ final class BlockConverter { /** * Convert a WordPress post's blocks to beehiiv blocks. * - * @param WP_Post $post_object Post object. + * @param WP_Post $post_object Post object. + * @param bool $convert_groups Whether `core/group` blocks become beehiiv sections. When false, + * Group blocks are omitted. * @return array> * @since 1.0.0 */ - public static function convert_supported_blocks( WP_Post $post_object ): array { + public static function convert_supported_blocks( WP_Post $post_object, bool $convert_groups = false ): array { $beehiiv_blocks = []; $wp_blocks = parse_blocks( $post_object->post_content ); @@ -73,7 +81,8 @@ public static function convert_supported_blocks( WP_Post $post_object ): array { $converted_blocks = self::convert_blocks( $wp_blocks, $post_object, - $is_send_newsletter_snippet + $is_send_newsletter_snippet, + $convert_groups ); return array_values( @@ -89,13 +98,15 @@ public static function convert_supported_blocks( WP_Post $post_object ): array { * @param array> $wp_blocks Parsed blocks. * @param WP_Post $post_object Post object. * @param bool $is_send_newsletter_snippet Whether snippet mode is enabled. + * @param bool $convert_groups Whether Group blocks become sections. * @return array> * @since 1.0.0 */ private static function convert_blocks( array $wp_blocks, WP_Post $post_object, - bool $is_send_newsletter_snippet + bool $is_send_newsletter_snippet, + bool $convert_groups = false ): array { $beehiiv_blocks = []; @@ -111,6 +122,16 @@ private static function convert_blocks( break; } + if ( 'core/group' === $block_name ) { + $section_block = $convert_groups ? self::convert_group_block( $wp_block, $post_object ) : []; + + if ( ! empty( $section_block ) ) { + $beehiiv_blocks[] = $section_block; + } + + continue; + } + if ( ! SupportedBlocks::is_supported( $block_name, $is_send_newsletter_snippet ) ) { continue; } @@ -137,6 +158,48 @@ private static function convert_blocks( return $beehiiv_blocks; } + /** + * Convert a core/group block to a beehiiv section block. + * + * The Group's inner blocks are converted with the same rules as top-level blocks and + * placed in the section in their original order. A Group nested inside a Group is + * omitted with its inner blocks, because a beehiiv section cannot hold another section + * and sending one makes beehiiv reject the whole post. The Group's background colour, text + * colour, padding, margin and border go in the section's `visual_settings`; every other setting is + * left at beehiiv defaults. Returns an empty array when no inner block converts, so no empty section + * is sent. Snippet-only blocks are omitted inside a section. + * + * @param array $wp_block Parsed core/group block. + * @param WP_Post $post_object Post object. + * @return array + * @since x.x.x + */ + private static function convert_group_block( array $wp_block, WP_Post $post_object ): array { + $inner_blocks = self::convert_blocks( + $wp_block['innerBlocks'] ?? [], + $post_object, + false, + false + ); + + if ( empty( $inner_blocks ) ) { + return []; + } + + $section = [ + 'type' => 'section', + 'blocks' => $inner_blocks, + ]; + + $visual_settings = GroupBlockConverter::build_visual_settings( $wp_block ); + + if ( ! empty( $visual_settings ) ) { + $section['visual_settings'] = $visual_settings; + } + + return $section; + } + /** * Convert a single supported WordPress block. * diff --git a/includes/Newsletter/Converters/GroupBlockConverter.php b/includes/Newsletter/Converters/GroupBlockConverter.php new file mode 100644 index 0000000..7b4ea85 --- /dev/null +++ b/includes/Newsletter/Converters/GroupBlockConverter.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 section `visual_settings` for a Group block. + * + * @param array $wp_block Parsed core/group block. + * @return array Empty when the Group sets no mapped styling. + * @since x.x.x + */ + 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_group_block_background_color( $inner_html, $attrs ); + + if ( null !== $background_color && '' !== $background_color ) { + $settings['background_color'] = $background_color; + } + + $text_color = FormattedTextParser::resolve_group_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 Group 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 Group 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_group_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 Group `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 Group `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 Group `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 Group `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 Group 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 389f675..0cd7edb 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 Group block's background colour from attrs and the `
` wrapper. + * + * @param string $inner_html Saved Group HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since x.x.x + */ + public static function resolve_group_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 Group block's text colour from attrs and the `
` wrapper. + * + * @param string $inner_html Saved Group HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since x.x.x + */ + public static function resolve_group_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 x.x.x + */ + 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 Group 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 Group HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since x.x.x + */ + public static function resolve_group_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. * @@ -1083,7 +1196,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/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php index df74b14..de73b5f 100644 --- a/includes/Newsletter/PostSettingsBuilder.php +++ b/includes/Newsletter/PostSettingsBuilder.php @@ -94,7 +94,10 @@ public static function get_post_settings( int $post_id, bool $for_update = false ); } - $beehiiv_blocks = BlockConverter::convert_supported_blocks( $post_object ); + $beehiiv_blocks = BlockConverter::convert_supported_blocks( + $post_object, + self::should_convert_groups( $post_id ) + ); if ( empty( $beehiiv_blocks ) ) { return new WP_Error( @@ -264,6 +267,27 @@ public static function get_update_payload( int $post_id ) { return $update['payload']; } + /** + * Whether Group blocks become beehiiv sections for this post. + * + * A post that has no beehiiv post yet is on its first sync, so Groups convert. After + * that, only a post whose first sync converted them keeps converting them; a post + * synced before section support keeps omitting Groups so its beehiiv layout does not change. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + private static function should_convert_groups( int $post_id ): bool { + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + + if ( ! is_string( $beehiiv_post_id ) || '' === trim( $beehiiv_post_id ) ) { + return true; + } + + return (bool) get_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true ); + } + /** * Whether a non-empty subtitle was last synced to the linked beehiiv post. * diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index accbe8a..3a45795 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -536,6 +536,8 @@ public static function send( int $post_id ): void { (string) ( $beehiiv_post_data['post_template_id'] ?? '' ) ); update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); + // This first sync converted Group blocks to sections; later syncs of this post must too. + update_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, false ); delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); self::persist_synced_subtitle( $post_id, $beehiiv_post_data ); diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index ba93a96..80ba90a 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -12,8 +12,10 @@ /** * 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. + * `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. * * @since 1.0.0 */ @@ -34,6 +36,7 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/group', 'core/buttons', 'core/separator', 'beehiiv/advertisement', 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..0e707c2 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -34,7 +34,11 @@ align-items: flex-start; display: flex; gap: 6px; - margin-top: 10px !important; + } + + // 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 { 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/hooks/use-omitted-blocks.js b/src/js/editor/post-settings/hooks/use-omitted-blocks.js index 5edd154..4251333 100644 --- a/src/js/editor/post-settings/hooks/use-omitted-blocks.js +++ b/src/js/editor/post-settings/hooks/use-omitted-blocks.js @@ -33,6 +33,22 @@ function resolveOmittedBlockTarget( ancestors, blockClientId ) { return blockClientId; } +/** + * Whether a Group block sits inside another Group block. + * + * beehiiv sections cannot hold sections, so the inner Group is omitted. + * + * @param {WPBlock} block Block being visited. + * @param {BlockAncestor[]} ancestors Root-to-parent chain for the block. + * @return {boolean} True when the block is a Group nested in a Group. + */ +function isGroupInsideGroup( block, ancestors ) { + return ( + 'core/group' === block.name && + ancestors.some( ( ancestor ) => 'core/group' === ancestor.name ) + ); +} + /** * Collect client IDs for blocks omitted from the newsletter. * @@ -49,7 +65,10 @@ export function collectOmittedBlockClientIds( blocks ) { */ function visit( blockList, ancestors = [] ) { for ( const block of blockList ) { - if ( isOmittedFromNewsletter( block ) ) { + if ( + isOmittedFromNewsletter( block ) || + isGroupInsideGroup( block, ancestors ) + ) { targets.add( resolveOmittedBlockTarget( ancestors, block.clientId ) ); 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 so no quoting issues. + * + * @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 }'` + ); +} + +/** + * Resets beehiiv seams and seeds a "ready to send" site with an active ad list. + */ +function seed() { + const mocks = [ + [ + '/advertisement_opportunities', + { + body: { + data: [ + { + id: ACTIVE_AD, + advertiser_name: 'QA Advertiser', + payout_rate: '1.00', + advertisement_kind: 'cpc', + }, + ], + }, + }, + ], + [ '/posts', { body: { data: { id: MOCK_CREATED_ID } } } ], + ]; + wpEvalWith( + { mocks }, + `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" ] ] ); + foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); } + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a draft post fixture in the DB and empties the request log. + * + * @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 {number} [options.thumb] Featured image attachment ID. + * @param {string} [options.status] Post status. + * @return {string} Post ID. + */ +function createPost( { + title, + content, + meta = {}, + thumb = 0, + status = 'draft', +} ) { + const post = { + post_type: 'post', + post_title: title, + post_content: content, + post_status: status, + post_author: 1, + }; + const id = wpEvalWith( + { post, meta, thumb }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + if ( $a["thumb"] ) { set_post_thumbnail( $id, $a["thumb"] ); } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +/** + * Creates an image attachment in the tests env's media library. + * + * @return {string} Attachment ID. + */ +function createAttachment() { + const id = wpEvalWith( + { png: PNG_B64 }, + `$up = wp_upload_bits( "qa-e2e-featured.png", null, base64_decode( $a["png"] ) ); + $att = wp_insert_attachment( [ "post_mime_type" => "image/png", "post_title" => "QA featured", "post_status" => "inherit" ], $up["file"] ); + require_once ABSPATH . "wp-admin/includes/image.php"; + wp_update_attachment_metadata( $att, wp_generate_attachment_metadata( $att, $up["file"] ) ); + echo $att . "\\n";` + ); + createdAttachments.push( id ); + return id; +} + +/** + * All meta for a post, single values. + * + * @param {string} id Post ID. + * @return {Object} Meta keyed by meta key. + */ +function getMeta( id ) { + const raw = wpCli( + `eval '$m = get_post_meta( ${ id } ); $o = []; foreach ( $m as $k => $v ) { $o[ $k ] = $v[0]; } echo wp_json_encode( (object) $o ) . "\\n";'` + ); + return JSON.parse( raw ); +} + +/** + * Returns the recorded outbound beehiiv requests. + * + * @return {Array} Request log entries. + */ +function getLog() { + return JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); +} + +/** + * Clears the recorded request log. + */ +function clearLog() { + wpCli( `eval 'beehiiv_e2e_clear_http_log();'` ); +} + +const creates = ( log ) => + log.filter( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); +const updates = ( log ) => log.filter( ( r ) => r.method === 'PATCH' ); + +/** + * Logs in as admin and returns a REST caller bound to that session (cookie + nonce). + * + * @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 new post with "Send to Newsletter" on and returns the `blocks` + * beehiiv received in the create request. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} content Post content (block markup). + * @param {Object} [options] Extra fixture options. + * @return {Promise<{id: string, blocks: Array, rest: Function}>} Result. + */ +async function sendNew( page, content, options = {} ) { + seed(); + const id = createPost( { + title: 'QA E2E block conversion', + content, + meta: { [ META.send ]: '1', ...( options.meta || {} ) }, + thumb: options.thumb || 0, + } ); + const rest = await restAs( page ); + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + const created = creates( getLog() ); + expect( created ).toHaveLength( 1 ); + return { id, blocks: created[ 0 ].body.blocks, rest }; +} + +/** + * 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(); + } +} + +const p = ( text ) => + `

    ${ text }

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

    ${ text }

    `; +const group = ( inner ) => + `
    ${ inner }
    `; +const types = ( blocks ) => blocks.map( ( b ) => b.type ); +const text = ( blocks ) => JSON.stringify( blocks ); + +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 ); } + foreach ( array( ${ createdAttachments.join( + ', ' + ) } ) as $id ) { wp_delete_attachment( $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 image = + '
    QA alt
    '; + const { blocks } = await sendNew( + page, + h( 'QA heading text' ) + p( 'QA paragraph text.' ) + image + ); + expect( types( blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'image', + ] ); + expect( blocks[ 0 ].text ).toBe( 'QA heading text' ); + expect( blocks[ 1 ].plaintext ).toBe( 'QA paragraph text.' ); + expect( blocks[ 2 ].imageUrl ).toBe( + 'https://example.com/qa-photo.jpg' + ); + expect( blocks[ 2 ].alt_text ).toBe( 'QA alt' ); + } ); + + test( 'AC-002: separator blocks convert to a content break', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + p( 'Before' ) + + '
    ' + + p( 'After' ) + ); + expect( types( blocks ) ).toEqual( [ + 'paragraph', + 'content_break', + 'paragraph', + ] ); + } ); + + test( 'AC-003: embed blocks convert to a link to the embedded content', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + '
    https://www.youtube.com/watch?v=qa123
    ' + + p( 'Anchor paragraph' ) + ); + const embed = blocks.find( ( b ) => b.type === 'embed_link' ); + expect( embed ).toBeTruthy(); + expect( embed.url ).toBe( 'https://www.youtube.com/watch?v=qa123' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: skip unsupported blocks', () => { + test( 'AC-004: blocks without beehiiv equivalents are silently omitted', async ( { + page, + } ) => { + const { id, blocks } = await sendNew( + page, + p( 'Kept paragraph' ) + + '
    QA_UNSUPPORTED_CODE
    ' + + '
    QA_UNSUPPORTED_VERSE
    ' + + '' + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + expect( text( blocks ) ).not.toContain( 'QA_UNSUPPORTED' ); + // The send still succeeds without an error recorded. + expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID ); + expect( getMeta( id )._beehiiv_newsletter_error ).toBeUndefined(); + } ); + + test( 'AC-005: empty and whitespace-only blocks are omitted', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + p( 'Real content' ) + + '

    ' + + '

    ' + + '

    ' + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + expect( blocks[ 0 ].plaintext ).toBe( 'Real content' ); + } ); + + // Known gap (QA finding): a block holding only a non-breaking space + // ( , which the editor produces for "blank" lines) is converted to a + // paragraph/heading with a nbsp instead of being omitted. test.fail() + // records the gap without breaking CI and starts failing once it is fixed. + test( 'AC-005: blocks holding only a non-breaking space are omitted', async ( { + page, + } ) => { + test.fail( + true, + 'Known gap:  -only paragraph and heading blocks are still sent.' + ); + const { blocks } = await sendNew( + page, + p( 'Real content' ) + + '

     

    ' + + '

     

    ' + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: snippet mode with Read More button', () => { + test( 'AC-006: snippet mode stops at core/more and adds a Read More button to the permalink in a new tab', async ( { + page, + } ) => { + const content = + p( 'Teaser paragraph' ) + + '' + + p( 'HIDDEN_AFTER_MORE' ); + const { id, blocks } = await sendNew( page, content, { + meta: { [ META.snippet ]: '1' }, + } ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'button' ] ); + expect( text( blocks ) ).not.toContain( 'HIDDEN_AFTER_MORE' ); + const button = blocks[ 1 ]; + expect( button.text ).toBe( 'Read More' ); + expect( button.target ).toBe( '_blank' ); + const permalink = wpCli( `eval 'echo get_permalink( ${ id } );'` ); + expect( button.href ).toBe( permalink ); + expect( button.href ).toContain( 'qa-e2e-block-conversion' ); + + // Edge case: snippet mode with no core/more converts every block. + const noMore = await sendNew( page, p( 'One' ) + p( 'Two' ), { + meta: { [ META.snippet ]: '1' }, + } ); + expect( types( noMore.blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: featured image', () => { + test( 'AC-007: the featured image leads the newsletter and is configured for email only', async ( { + page, + } ) => { + const thumb = createAttachment(); + const { blocks } = await sendNew( page, p( 'Body text' ), { thumb } ); + expect( types( blocks ) ).toEqual( [ 'image', 'paragraph' ] ); + expect( blocks[ 0 ].imageUrl ).toContain( 'qa-e2e-featured' ); + expect( blocks[ 0 ].visibility_settings ).toEqual( { + show_on_email: true, + show_on_web: false, + } ); + + // Edge case: no featured image is omitted without error. + const none = await sendNew( page, p( 'No image' ) ); + expect( types( none.blocks ) ).toEqual( [ 'paragraph' ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-005: advertisement validation', () => { + test( 'AC-008: advertisement blocks are included only for a valid, active ad', async ( { + page, + } ) => { + const ad = ( attrs ) => + ``; + + // Active ad: included with its opportunity id. + const active = await sendNew( + page, + p( 'Intro' ) + ad( { adId: ACTIVE_AD } ) + ); + const ads = active.blocks.filter( ( b ) => b.type === 'advertisement' ); + expect( ads ).toHaveLength( 1 ); + expect( ads[ 0 ].opportunity_id ).toBe( ACTIVE_AD ); + + // Expired/unknown ad: never reaches beehiiv. (The sender rejects the + // whole send with an error instead of silently dropping the block, so + // no create request is made at all.) + seed(); + const id = createPost( { + title: 'QA E2E block conversion expired ad', + content: p( 'Intro' ) + ad( { adId: EXPIRED_AD } ), + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + const log = getLog(); + expect( JSON.stringify( log ) ).not.toContain( + `"opportunity_id":"${ EXPIRED_AD }"` + ); + expect( creates( log ) ).toHaveLength( 0 ); + expect( getMeta( id )._beehiiv_newsletter_error ).toContain( + 'no longer available' + ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-006: Group blocks become sections', () => { + test( 'AC-009: a Group becomes one section with its inner blocks in original order', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + p( 'Outside before' ) + + group( + h( 'Inside heading' ) + + p( 'Inside one' ) + + p( 'Inside two' ) + ) + + p( 'Outside after' ) + ); + expect( types( blocks ) ).toEqual( [ + 'paragraph', + 'section', + 'paragraph', + ] ); + const section = blocks[ 1 ]; + expect( types( section.blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'paragraph', + ] ); + expect( section.blocks[ 0 ].text ).toBe( 'Inside heading' ); + expect( section.blocks[ 1 ].plaintext ).toBe( 'Inside one' ); + expect( section.blocks[ 2 ].plaintext ).toBe( 'Inside two' ); + } ); + + test( 'AC-010: a Group nested in a Group is omitted, so no section holds a section', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + group( + p( 'Outer text' ) + + group( p( 'Nested text' ) ) + + p( 'Last text' ) + ) + ); + expect( types( blocks ) ).toEqual( [ 'section' ] ); + expect( types( blocks[ 0 ].blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Nested text' ); + } ); + + test( 'AC-010: the editor notice counts a nested Group as omitted and marks it', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E nested group notice', + content: group( p( 'Outer text' ) + group( p( 'Nested text' ) ) ), + 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"]' ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + // The class sits on the Group block itself; no wrapper element is added. + await expect( + canvas.locator( + '.wp-block-group .wp-block-group.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 canvas + .locator( '.beehiiv-omitted-block' ) + .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( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + await setBeehiivSidebar( page, true ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + } ); + + test( 'AC-010: a scheduled, linked post shows the omitted notice for a nested Group, and a sent one does not', async ( { + page, + } ) => { + seed(); + const linked = ( scheduledAt ) => ( { + [ META.postId ]: 'post_qa_e2e_scheduled', + _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 content = group( + p( 'Outer text' ) + group( p( 'Nested text' ) ) + ); + const scheduledId = createPost( { + title: 'QA E2E scheduled nested group', + content, + status: 'publish', + meta: linked( '2031-01-01T10:00:00Z' ), + } ); + const sentId = createPost( { + title: 'QA E2E sent nested group', + content, + status: 'publish', + meta: linked( '2020-01-01T10:00:00Z' ), + } ); + await loginAsAdmin( page ); + + 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' ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + + await openPostEditor( page, sentId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-content' ) + ).toBeVisible(); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + } ); + + test( 'AC-010: the editor shows no omitted notice for a single, non-nested Group', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E single group notice', + content: group( p( 'Only 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 ); + } ); + + test( 'AC-011: Group background color, text color, padding and margin become the section visual settings', async ( { + page, + } ) => { + const styled = + '
    ' + + p( 'Styled group text' ) + + '
    '; + const { blocks } = await sendNew( page, styled ); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ].type ).toBe( 'section' ); + expect( Object.keys( blocks[ 0 ] ).sort() ).toEqual( [ + 'blocks', + 'type', + 'visual_settings', + ] ); + // Padding and margin become inner and outer spacing. rem converts to pixels; a percentage + // cannot, so that side is left out. + expect( blocks[ 0 ].visual_settings ).toEqual( { + background_color: '#ff0000', + text_color: '#ffffff', + inner_spacing_top: 40, + inner_spacing_bottom: 40, + inner_spacing_left: 20, + outer_spacing_top: 12, + outer_spacing_bottom: 24, + } ); + } ); + + test( 'AC-015: Group border color, width, style and radius become the section visual settings', async ( { + page, + } ) => { + const bordered = + '
    ' + + p( 'Bordered group text' ) + + '
    '; + const { blocks } = await sendNew( page, bordered ); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ].type ).toBe( 'section' ); + // A radius in percent cannot become pixels, so that corner is left out. + expect( blocks[ 0 ].visual_settings ).toEqual( { + border_color: '#00aa55', + border_width: 3, + border_style: 'dashed', + border_radius_top_left: 4, + border_radius_top_right: 8, + border_radius_bottom_left: 16, + } ); + } ); + + test( 'AC-015: a border with a different width on each side still sends its color', async ( { + page, + } ) => { + // 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 }"}`; + const split = + `
    ` + + p( 'Split border group text' ) + + '
    '; + const { blocks } = await sendNew( page, split ); + const settings = blocks[ 0 ].visual_settings; + expect( settings.border_color ).toMatch( /^#[0-9a-f]{3,8}$/i ); + expect( settings.border_style ).toBe( 'solid' ); + expect( [ + settings.border_width_top, + settings.border_width_right, + settings.border_width_bottom, + settings.border_width_left, + ] ).toEqual( [ 1, 3, 5, 7 ] ); + } ); + + test( 'AC-011: a Group with no styling sends a section without visual settings', async ( { + page, + } ) => { + const { blocks } = await sendNew( page, group( p( 'Plain text' ) ) ); + expect( Object.keys( blocks[ 0 ] ).sort() ).toEqual( [ + 'blocks', + 'type', + ] ); + } ); + + test( 'AC-012: an empty or unconvertible Group produces no section; a mixed Group keeps only convertible blocks', async ( { + page, + } ) => { + const empty = await sendNew( + page, + p( 'Kept outside' ) + + group( '' ) + + group( + '
    QA_CODE_ONLY
    ' + ) + ); + expect( types( empty.blocks ) ).toEqual( [ 'paragraph' ] ); + + const mixed = await sendNew( + page, + group( + p( 'Mixed one' ) + + '
    QA_CODE_DROPPED
    ' + + p( 'Mixed two' ) + ) + ); + expect( types( mixed.blocks ) ).toEqual( [ 'section' ] ); + expect( types( mixed.blocks[ 0 ].blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + expect( text( mixed.blocks ) ).not.toContain( 'QA_CODE_DROPPED' ); + } ); + + // The "shows as one grouped section in the beehiiv editor" half of this AC + // depends on beehiiv's own editor and cannot be driven here; the payload + // half (one section, three children) is what the plugin controls. + test( 'AC-013: a Group with a heading and two paragraphs produces one section with those three children', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + group( + h( 'Group heading' ) + p( 'Group para A' ) + p( 'Group para B' ) + ) + ); + const sections = blocks.filter( ( b ) => b.type === 'section' ); + expect( blocks ).toHaveLength( 1 ); + expect( sections ).toHaveLength( 1 ); + expect( types( sections[ 0 ].blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'paragraph', + ] ); + } ); + + test( 'AC-014: Group conversion applies to posts first synced after the change, including re-syncs; older posts keep omitting Group content', async ( { + page, + } ) => { + const content = + p( 'Top level text' ) + group( p( 'GROUPED_CONTENT_TEXT' ) ); + + // Post first synced now: sections on create, marker stored, kept on re-sync. + const fresh = await sendNew( page, content ); + expect( types( fresh.blocks ) ).toEqual( [ 'paragraph', 'section' ] ); + expect( getMeta( fresh.id )[ META.groupSections ] ).toBeTruthy(); + clearLog(); + await fresh.rest( 'POST', `/wp/v2/posts/${ fresh.id }`, { + content: content + p( 'Edited later' ), + } ); + const resynced = updates( getLog() ); + expect( resynced ).toHaveLength( 1 ); + expect( types( resynced[ 0 ].body.blocks ) ).toEqual( [ + 'paragraph', + 'section', + 'paragraph', + ] ); + expect( text( resynced[ 0 ].body.blocks ) ).toContain( + 'GROUPED_CONTENT_TEXT' + ); + + // Post first synced before the change (linked, no marker): Group stays omitted. + seed(); + const legacy = createPost( { + title: 'QA E2E block conversion legacy', + content, + status: 'publish', + meta: { [ META.postId ]: LEGACY_ID }, + } ); + const rest = await restAs( page ); + await rest( 'POST', `/wp/v2/posts/${ legacy }`, { + content: content + p( 'Edited legacy' ), + } ); + const legacyUpdates = updates( getLog() ); + expect( legacyUpdates ).toHaveLength( 1 ); + expect( types( legacyUpdates[ 0 ].body.blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + expect( text( legacyUpdates[ 0 ].body.blocks ) ).not.toContain( + 'GROUPED_CONTENT_TEXT' + ); + expect( getMeta( legacy )[ META.groupSections ] ).toBeUndefined(); + } ); +} ); diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php new file mode 100644 index 0000000..3ae5568 --- /dev/null +++ b/tests/phpunit/BlockConverterGroupTest.php @@ -0,0 +1,982 @@ + 'pub_test', + 'post_template_id' => 'tmpl_test', + ) + ); + } + + /** + * Build a paragraph block's markup. + * + * @param string $text Paragraph text. + * @return string + */ + private function paragraph( string $text ): string { + return "\n

    {$text}

    \n"; + } + + /** + * Wrap block markup in a Group block. + * + * @param string $inner Inner block markup. + * @return string + */ + private function group( string $inner ): string { + return "\n
    {$inner}
    \n"; + } + + /** + * Create a published post with the given content. + * + * @param string $content Block markup. + * @return WP_Post + */ + private function create_post( string $content ): WP_Post { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Group post', + 'post_status' => 'publish', + 'post_content' => $content, + ) + ); + + return get_post( $post_id ); + } + + /** + * AC-009 and AC-013: a Group with a heading and two paragraphs is one section with three children. + */ + public function test_group_becomes_one_section_with_children_in_order(): void { + $heading = "\n

    Title

    \n"; + $post = $this->create_post( + $this->group( $heading . $this->paragraph( 'First' ) . $this->paragraph( 'Second' ) ) + ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertCount( 3, $blocks[0]['blocks'] ); + $this->assertSame( + array( 'heading', 'paragraph', 'paragraph' ), + array_column( $blocks[0]['blocks'], 'type' ) + ); + } + + /** + * AC-009: blocks around a Group stay in place and the section keeps their order. + */ + public function test_section_sits_between_sibling_blocks(): void { + $post = $this->create_post( + $this->paragraph( 'Before' ) . $this->group( $this->paragraph( 'Inside' ) ) . $this->paragraph( 'After' ) + ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertSame( + array( 'paragraph', 'section', 'paragraph' ), + array_column( $blocks, 'type' ) + ); + } + + /** + * AC-010: a Group inside a Group is omitted, because a beehiiv section cannot hold a section. + */ + public function test_nested_group_is_omitted_with_its_content(): void { + $inner = $this->group( $this->paragraph( 'Deep' ) ); + $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner . $this->paragraph( 'Last' ) ) ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertSame( + array( 'paragraph', 'paragraph' ), + array_column( $blocks[0]['blocks'], 'type' ) + ); + $this->assertStringNotContainsString( 'Deep', wp_json_encode( $blocks ) ); + } + + /** + * AC-010: no section is nested at any depth, and a Group holding only a nested Group sends nothing. + */ + public function test_no_section_is_nested_at_any_depth(): void { + $deep = $this->group( $this->group( $this->group( $this->paragraph( 'Deep' ) ) ) ); + $post = $this->create_post( $this->paragraph( 'Kept' ) . $deep ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertSame( array( 'paragraph' ), array_column( $blocks, 'type' ) ); + } + + /** + * Build a styled Group block's markup from block attributes and the wrapper class and style. + * + * @param array $attrs Group block attributes. + * @param string $class Extra wrapper classes. + * @param string $style Wrapper inline style. + * @return string + */ + private function styled_group( array $attrs, string $class = '', string $style = '' ): string { + $style_attr = '' === $style ? '' : ' style="' . $style . '"'; + + return '\n" + . '
    ' + . $this->paragraph( 'Styled' ) + . "
    \n"; + } + + /** + * Convert one styled Group and return its section block. + * + * @param string $content Group block markup. + * @return array + */ + private function convert_to_section( string $content ): array { + $blocks = BlockConverter::convert_supported_blocks( $this->create_post( $content ), true ); + + $this->assertSame( 'section', $blocks[0]['type'] ); + + return $blocks[0]; + } + + /** + * AC-011: an unstyled Group sends a section without visual settings. + */ + public function test_unstyled_group_has_no_visual_settings(): void { + $section = $this->convert_to_section( $this->group( $this->paragraph( 'Plain' ) ) ); + + $this->assertSame( array( 'type', 'blocks' ), array_keys( $section ) ); + } + + /** + * AC-011: a custom background colour and text colour become the section's colours. + */ + public function test_custom_colors_become_visual_settings(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'color' => array( + 'background' => '#112233', + 'text' => '#ffeedd', + ), + ), + ), + ' has-text-color has-background', + 'color:#ffeedd;background-color:#112233' + ) + ); + + $this->assertSame( + array( + 'background_color' => '#112233', + 'text_color' => '#ffeedd', + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: a palette colour is sent as its colour value, not its slug. + */ + public function test_palette_colors_are_sent_as_color_values(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'backgroundColor' => 'vivid-red', + 'textColor' => 'white', + ), + ' has-white-color has-vivid-red-background-color has-text-color has-background' + ) + ); + + $this->assertMatchesRegularExpression( '/^#[0-9a-f]{3,8}$/i', $section['visual_settings']['background_color'] ); + $this->assertMatchesRegularExpression( '/^#[0-9a-f]{3,8}$/i', $section['visual_settings']['text_color'] ); + } + + /** + * AC-011: padding on each side becomes whole-pixel inner spacing, converting rem and em. + */ + public function test_padding_becomes_inner_spacing_in_pixels(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '40px', + 'bottom' => '2.5rem', + 'left' => '1em', + 'right' => '0', + ), + ), + ), + ) + ) + ); + + $this->assertSame( + array( + 'inner_spacing_top' => 40, + 'inner_spacing_bottom' => 40, + 'inner_spacing_left' => 16, + 'inner_spacing_right' => 0, + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: a single padding value applies to every side. + */ + public function test_single_padding_value_applies_to_every_side(): void { + $section = $this->convert_to_section( + $this->styled_group( array( 'style' => array( 'spacing' => array( 'padding' => '24px' ) ) ) ) + ); + + $this->assertSame( + array( + 'inner_spacing_top' => 24, + 'inner_spacing_bottom' => 24, + 'inner_spacing_left' => 24, + 'inner_spacing_right' => 24, + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: padding that cannot become pixels is left out, so beehiiv keeps its default for that side. + * + * A fluid `clamp()` is not left out: it is sent at its maximum, see the fluid size test. + */ + public function test_padding_that_is_not_in_pixels_is_left_out(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '10%', + 'bottom' => '5vw', + 'left' => 'clamp(1rem, 2vw, 3rem)', + 'right' => '30px', + ), + ), + ), + ) + ) + ); + + $this->assertSame( + array( + 'inner_spacing_left' => 48, + 'inner_spacing_right' => 30, + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: margin on each side becomes whole-pixel outer spacing, converting rem and em. + */ + public function test_margin_becomes_outer_spacing_in_pixels(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'margin' => array( + 'top' => '12px', + 'bottom' => '1.5rem', + 'left' => '1em', + 'right' => '0', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'outer_spacing_top' => 12, + 'outer_spacing_bottom' => 24, + 'outer_spacing_left' => 16, + 'outer_spacing_right' => 0, + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: padding and margin are sent together as inner and outer spacing. + */ + public function test_padding_and_margin_are_sent_as_inner_and_outer_spacing(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '20px', + 'bottom' => '20px', + ), + 'margin' => array( + 'top' => '40px', + 'bottom' => '8px', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'inner_spacing_top' => 20, + 'inner_spacing_bottom' => 20, + 'outer_spacing_top' => 40, + 'outer_spacing_bottom' => 8, + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: a spacing preset margin is sent as pixels, and a margin that cannot become pixels is left out. + */ + public function test_margin_presets_convert_and_unusable_margins_are_left_out(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'margin' => array( + 'top' => 'auto', + 'bottom' => '-10px', + 'left' => '5%', + 'right' => '16px', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( array( 'outer_spacing_right' => 16 ), $section['visual_settings'] ); + } + + /** + * Make the theme define its own spacing sizes and palette colours. + * + * The theme turns WordPress's default spacing sizes and palette off, redefines the `40` + * spacing size and the `vivid-red` colour, and adds sizes and a colour with its own names. + * While the defaults are on, WordPress ignores a theme entry that reuses a default slug. + * The cached preset maps are cleared so the converter reads the new settings. + */ + private function use_theme_presets(): void { + $filter = static function ( $theme_json ) { + return $theme_json->update_with( + array( + 'version' => 3, + 'settings' => array( + 'spacing' => array( + 'defaultSpacingSizes' => false, + 'spacingSizes' => array( + array( + 'name' => 'Small', + 'slug' => '40', + 'size' => '2rem', + ), + array( + 'name' => 'Small', + 'slug' => 'small', + 'size' => '10px', + ), + array( + 'name' => 'Large', + 'slug' => 'large', + 'size' => '1.5em', + ), + array( + 'name' => 'Fluid', + 'slug' => 'fluid', + 'size' => 'clamp(30px, 5vw, 50px)', + ), + array( + 'name' => 'Capped', + 'slug' => 'capped', + 'size' => 'min(1.5rem, 2vw)', + ), + ), + ), + 'color' => array( + 'defaultPalette' => false, + 'palette' => array( + array( + 'name' => 'Vivid red', + 'slug' => 'vivid-red', + 'color' => '#aa0000', + ), + array( + 'name' => 'Brand', + 'slug' => 'brand', + 'color' => '#123456', + ), + ), + ), + ), + ) + ); + }; + + add_filter( 'wp_theme_json_data_theme', $filter ); + $this->theme_json_filter = $filter; + + WP_Theme_JSON_Resolver::clean_cached_data(); + $this->reset_preset_caches(); + } + + /** + * Clear the converters' cached spacing and colour preset maps. + */ + private function reset_preset_caches(): void { + foreach ( + array( + \Beehiiv\Newsletter\Converters\GroupBlockConverter::class => 'spacing_preset_map', + \Beehiiv\Newsletter\FormattedTextParser::class => 'color_preset_map', + ) as $class => $property + ) { + $reflection = new ReflectionProperty( $class, $property ); + $reflection->setAccessible( true ); + $reflection->setValue( null, null ); + } + } + + /** + * Remove the theme preset filter and the cached maps after a test. + */ + public function tear_down(): void { + if ( null !== $this->theme_json_filter ) { + remove_filter( 'wp_theme_json_data_theme', $this->theme_json_filter ); + $this->theme_json_filter = null; + } + + WP_Theme_JSON_Resolver::clean_cached_data(); + $this->reset_preset_caches(); + + parent::tear_down(); + } + + /** + * AC-011: a theme spacing size named in a Group's padding and margin is sent as that theme's size. + * + * The padding on the right names `50`, which this theme no longer defines, so it is left out. + */ + public function test_theme_spacing_sizes_are_used_by_slug(): void { + $this->use_theme_presets(); + + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => 'var:preset|spacing|small', + 'bottom' => 'var:preset|spacing|large', + 'left' => 'var:preset|spacing|40', + 'right' => 'var:preset|spacing|50', + ), + 'margin' => array( + 'top' => 'var(--wp--preset--spacing--small)', + 'bottom' => 'var:preset|spacing|40', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'inner_spacing_top' => 10, + 'inner_spacing_bottom' => 24, + 'inner_spacing_left' => 32, + 'outer_spacing_top' => 10, + 'outer_spacing_bottom' => 32, + ), + $section['visual_settings'] + ); + } + + /** + * AC-011: a theme colour named in a Group's colours and border is sent as that theme's colour value. + */ + public function test_theme_colors_are_used_by_slug(): void { + $this->use_theme_presets(); + + $section = $this->convert_to_section( + $this->styled_group( + array( + 'backgroundColor' => 'brand', + 'textColor' => 'vivid-red', + 'borderColor' => 'vivid-red', + 'style' => array( 'border' => array( 'width' => '1px' ) ), + ) + ) + ); + + $this->assertSame( '#123456', $section['visual_settings']['background_color'] ); + $this->assertSame( '#aa0000', $section['visual_settings']['text_color'] ); + $this->assertSame( '#aa0000', $section['visual_settings']['border_color'] ); + } + + /** + * AC-011: a fluid size is sent at its widest value, because a newsletter has no responsive spacing. + */ + public function test_fluid_sizes_are_sent_at_their_widest_value(): void { + $cases = array( + 'clamp(30px, 5vw, 50px)' => 50, + 'clamp(1.8rem, 1.8rem + ((1vw - 0.48rem) * 2.885), 3rem)' => 48, + 'min(1.5rem, 2vw)' => 24, + 'min(2rem, 3rem)' => 32, + 'max(10px, 2rem)' => 32, + 'MIN( 20px , 4vw )' => 20, + ); + + foreach ( $cases as $value => $expected ) { + $section = $this->convert_to_section( + $this->styled_group( array( 'style' => array( 'spacing' => array( 'padding' => array( 'top' => $value ) ) ) ) ) + ); + + $this->assertSame( array( 'inner_spacing_top' => $expected ), $section['visual_settings'], $value ); + } + } + + /** + * AC-011: a size with no fixed length in it cannot be sent, so beehiiv keeps its default. + */ + public function test_sizes_without_a_fixed_length_are_left_out(): void { + foreach ( array( 'calc(1rem + 2vw)', 'min(2vw, 3vw)', 'clamp(1rem, 2vw)', 'var(--custom-gap)' ) as $value ) { + $section = $this->convert_to_section( + $this->styled_group( array( 'style' => array( 'spacing' => array( 'padding' => array( 'top' => $value ) ) ) ) ) + ); + + $this->assertSame( array( 'type', 'blocks' ), array_keys( $section ), $value ); + } + } + + /** + * AC-011: a theme's fluid spacing sizes are sent at their widest value. + */ + public function test_theme_fluid_spacing_sizes_are_sent_at_their_widest_value(): void { + $this->use_theme_presets(); + + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'spacing' => array( + 'padding' => array( + 'top' => 'var:preset|spacing|fluid', + 'bottom' => 'var:preset|spacing|capped', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'inner_spacing_top' => 50, + 'inner_spacing_bottom' => 24, + ), + $section['visual_settings'] + ); + } + + /** + * AC-015: border colour, width, style and radius become the section's border settings. + */ + public function test_border_becomes_visual_settings(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'color' => '#112233', + 'width' => '3px', + 'style' => 'dashed', + 'radius' => '8px', + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'border_color' => '#112233', + 'border_width' => 3, + 'border_style' => 'dashed', + 'border_radius' => 8, + ), + $section['visual_settings'] + ); + } + + /** + * AC-015: a border with a width or colour but no style is sent as solid, as WordPress draws it. + */ + public function test_border_without_a_style_is_solid(): void { + $section = $this->convert_to_section( + $this->styled_group( array( 'style' => array( 'border' => array( 'width' => '2px' ) ) ) ) + ); + + $this->assertEquals( + array( + 'border_width' => 2, + 'border_style' => 'solid', + ), + $section['visual_settings'] + ); + } + + /** + * AC-015: a border style beehiiv does not accept is not sent, and does not default to solid. + */ + public function test_unsupported_border_style_is_not_sent(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'width' => '4px', + 'style' => 'double', + ), + ), + ) + ) + ); + + $this->assertEquals( array( 'border_width' => 4 ), $section['visual_settings'] ); + } + + /** + * AC-015: widths set per side and radii set per corner are sent per side and corner. + */ + public function test_border_widths_and_radii_can_be_set_per_side_and_corner(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'top' => array( + 'width' => '2px', + 'color' => '#445566', + 'style' => 'dotted', + ), + 'bottom' => array( + 'width' => '1rem', + 'color' => '#445566', + 'style' => 'dotted', + ), + 'radius' => array( + 'topLeft' => '4px', + 'topRight' => '8px', + 'bottomLeft' => '1rem', + 'bottomRight' => '10%', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'border_width_top' => 2, + 'border_width_bottom' => 16, + 'border_color' => '#445566', + 'border_style' => 'dotted', + 'border_radius_top_left' => 4, + 'border_radius_top_right' => 8, + 'border_radius_bottom_left' => 16, + ), + $section['visual_settings'] + ); + } + + /** + * AC-015: sides with different colours or styles cannot be sent, so beehiiv keeps its defaults. + */ + public function test_border_colors_and_styles_that_differ_by_side_are_not_sent(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'top' => array( + 'width' => '2px', + 'color' => '#111111', + 'style' => 'solid', + ), + 'bottom' => array( + 'width' => '2px', + 'color' => '#222222', + 'style' => 'dashed', + ), + ), + ), + ) + ) + ); + + $this->assertEquals( + array( + 'border_width_top' => 2, + 'border_width_bottom' => 2, + 'border_style' => 'solid', + ), + $section['visual_settings'] + ); + } + + /** + * AC-015: a palette border colour is sent as its colour value, not its slug. + */ + public function test_palette_border_color_is_sent_as_a_color_value(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'borderColor' => 'vivid-red', + 'style' => array( 'border' => array( 'width' => '1px' ) ), + ), + ' has-border-color has-vivid-red-border-color' + ) + ); + + $this->assertMatchesRegularExpression( '/^#[0-9a-f]{3,8}$/i', $section['visual_settings']['border_color'] ); + $this->assertSame( 'solid', $section['visual_settings']['border_style'] ); + } + + /** + * AC-015: a split border (a different width on each side) with palette colours sends the colour value. + * + * The editor stores palette colours on a split border as `var:preset|color|slug` on each side. + */ + public function test_split_border_with_palette_colors_sends_the_color_value(): void { + $side = static function ( string $width ): array { + return array( + 'color' => 'var:preset|color|vivid-red', + 'width' => $width, + ); + }; + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'top' => $side( '1px' ), + 'right' => $side( '3px' ), + 'bottom' => $side( '5px' ), + 'left' => $side( '7px' ), + ), + ), + ) + ) + ); + + $settings = $section['visual_settings']; + + $this->assertMatchesRegularExpression( '/^#[0-9a-f]{3,8}$/i', $settings['border_color'] ); + $this->assertSame( 1, $settings['border_width_top'] ); + $this->assertSame( 3, $settings['border_width_right'] ); + $this->assertSame( 5, $settings['border_width_bottom'] ); + $this->assertSame( 7, $settings['border_width_left'] ); + $this->assertSame( 'solid', $settings['border_style'] ); + } + + /** + * AC-015: a split border with the same custom colour on every side sends that colour. + */ + public function test_split_border_with_the_same_custom_color_sends_it(): void { + $side = static function ( string $width ): array { + return array( + 'color' => '#0a0b0c', + 'width' => $width, + ); + }; + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'top' => $side( '2px' ), + 'bottom' => $side( '6px' ), + ), + ), + ) + ) + ); + + $this->assertSame( '#0a0b0c', $section['visual_settings']['border_color'] ); + $this->assertSame( 2, $section['visual_settings']['border_width_top'] ); + $this->assertSame( 6, $section['visual_settings']['border_width_bottom'] ); + } + + /** + * AC-015: a palette variable on the whole border is sent as its colour value. + */ + public function test_palette_variable_on_the_whole_border_is_resolved(): void { + $section = $this->convert_to_section( + $this->styled_group( + array( + 'style' => array( + 'border' => array( + 'color' => 'var:preset|color|vivid-red', + 'width' => '2px', + ), + ), + ) + ) + ); + + $this->assertMatchesRegularExpression( '/^#[0-9a-f]{3,8}$/i', $section['visual_settings']['border_color'] ); + } + + /** + * AC-015: a radius in percent cannot become pixels, so it is not sent. + */ + public function test_border_radius_in_percent_is_not_sent(): void { + $section = $this->convert_to_section( + $this->styled_group( array( 'style' => array( 'border' => array( 'radius' => '50%' ) ) ) ) + ); + + $this->assertSame( array( 'type', 'blocks' ), array_keys( $section ) ); + } + + /** + * AC-012: a Group with no blocks, or no convertible blocks, produces no section. + */ + public function test_group_without_convertible_blocks_produces_no_section(): void { + $unsupported = "\n
    x
    \n"; + + $empty = BlockConverter::convert_supported_blocks( + $this->create_post( $this->paragraph( 'Kept' ) . $this->group( '' ) ), + true + ); + $only_unsupported = BlockConverter::convert_supported_blocks( + $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $unsupported ) ), + true + ); + + $this->assertSame( array( 'paragraph' ), array_column( $empty, 'type' ) ); + $this->assertSame( array( 'paragraph' ), array_column( $only_unsupported, 'type' ) ); + } + + /** + * AC-012: a Group that mixes convertible and unconvertible blocks keeps only the convertible ones. + */ + public function test_mixed_group_keeps_only_convertible_blocks(): void { + $unsupported = "\n
    x
    \n"; + $post = $this->create_post( + $this->group( $this->paragraph( 'One' ) . $unsupported . $this->paragraph( 'Two' ) ) + ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertCount( 2, $blocks[0]['blocks'] ); + } + + /** + * Without the flag a Group and its inner blocks are omitted, as before section support. + */ + public function test_group_is_omitted_when_conversion_is_off(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Lost' ) ) ); + + $blocks = BlockConverter::convert_supported_blocks( $post ); + + $this->assertSame( array( 'paragraph' ), array_column( $blocks, 'type' ) ); + } + + /** + * AC-014: a post with no beehiiv post yet converts Groups. + */ + public function test_first_sync_converts_groups(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) ); + $settings = PostSettingsBuilder::get_post_settings( $post->ID ); + + $this->assertIsArray( $settings ); + $this->assertSame( array( 'paragraph', 'section' ), array_column( $settings['blocks'], 'type' ) ); + } + + /** + * AC-014: a post first synced before section support keeps omitting Groups on re-sync. + */ + public function test_post_synced_before_section_support_keeps_omitting_groups(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) ); + update_post_meta( $post->ID, Meta::BEEHIIV_POST_ID, 'post_legacy' ); + + $settings = PostSettingsBuilder::get_post_settings( $post->ID, true ); + + $this->assertIsArray( $settings ); + $this->assertSame( array( 'paragraph' ), array_column( $settings['blocks'], 'type' ) ); + } + + /** + * AC-014: a post first synced with sections keeps its sections when it is synced again. + */ + public function test_post_first_synced_with_sections_keeps_them_on_resync(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) ); + update_post_meta( $post->ID, Meta::BEEHIIV_POST_ID, 'post_new' ); + update_post_meta( $post->ID, Meta::BEEHIIV_GROUP_SECTIONS, true ); + + $settings = PostSettingsBuilder::get_post_settings( $post->ID, true ); + + $this->assertIsArray( $settings ); + $this->assertSame( array( 'paragraph', 'section' ), array_column( $settings['blocks'], 'type' ) ); + } +}