From 45285345dca012f5cf9865908318f34bef23ea75 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Mon, 2 Nov 2026 21:28:32 +0800
Subject: [PATCH 01/11] feat(newsletter-publishing): convert Group blocks to
beehiiv sections
Feature: newsletter-publishing/block-conversion/block-conversion-framework
Implements:
- US-006: A Group block becomes a beehiiv section holding its converted inner blocks
Posts first synced with this change keep sections on later syncs. Posts synced before it keep omitting Group blocks.
---
includes/Editor/Meta.php | 9 +
includes/Newsletter/BlockConverter.php | 61 +++++-
includes/Newsletter/PostSettingsBuilder.php | 26 ++-
includes/Newsletter/Sender.php | 2 +
includes/Newsletter/SupportedBlocks.php | 7 +-
tests/phpunit/BlockConverterGroupTest.php | 228 ++++++++++++++++++++
6 files changed, 325 insertions(+), 8 deletions(-)
create mode 100644 tests/phpunit/BlockConverterGroupTest.php
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..86d68d2 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -38,7 +38,11 @@
* - `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`)
+ *
+ * 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 +53,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 +79,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 +96,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 +120,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 +156,38 @@ 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 nested Group becomes a nested
+ * section. Section settings are 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,
+ true
+ );
+
+ if ( empty( $inner_blocks ) ) {
+ return [];
+ }
+
+ return [
+ 'type' => 'section',
+ 'blocks' => $inner_blocks,
+ ];
+ }
+
/**
* Convert a single supported WordPress block.
*
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/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
new file mode 100644
index 0000000..78b3a78
--- /dev/null
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -0,0 +1,228 @@
+ '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 = "\nTitle \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 becomes a nested section.
+ */
+ public function test_nested_group_becomes_nested_section(): void {
+ $inner = $this->group( $this->paragraph( 'Deep' ) );
+ $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner ) );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post, true );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'section', $blocks[0]['type'] );
+ $this->assertSame(
+ array( 'paragraph', 'section' ),
+ array_column( $blocks[0]['blocks'], 'type' )
+ );
+ $this->assertCount( 1, $blocks[0]['blocks'][1]['blocks'] );
+ }
+
+ /**
+ * AC-011: a section carries only its type and blocks, never Group styling.
+ */
+ public function test_section_uses_default_settings(): void {
+ $attrs = '{"backgroundColor":"vivid-red","style":{"spacing":{"padding":{"top":"40px"}}}}';
+ $content = "\n"
+ . ''
+ . $this->paragraph( 'Styled' )
+ . "
\n";
+
+ $blocks = BlockConverter::convert_supported_blocks( $this->create_post( $content ), true );
+
+ $this->assertSame( array( 'type', 'blocks' ), array_keys( $blocks[0] ) );
+ }
+
+ /**
+ * 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 = "\nx \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 = "\nx \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' ) );
+ }
+}
From f65f06fd82f824932974ece51bdabbf18f9b79ce Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Mon, 2 Nov 2026 21:43:28 +0800
Subject: [PATCH 02/11] test(e2e): cover block conversion framework acceptance
criteria
---
.../specs/block-conversion-framework.spec.js | 643 ++++++++++++++++++
1 file changed, 643 insertions(+)
create mode 100644 tests/e2e/specs/block-conversion-framework.spec.js
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
new file mode 100644
index 0000000..948136e
--- /dev/null
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -0,0 +1,643 @@
+const { test, expect } = require( '@playwright/test' );
+const { loginAsAdmin } = require( '../utils/auth' );
+const {
+ wpCli,
+ wpCliSafe,
+ ensurePluginActive,
+ ensurePrettyPermalinks,
+} = require( '../utils/wp-cli' );
+
+/**
+ * PRD: requirements/07-newsletter-publishing/2-block-conversion/block-conversion-framework.prd.md (v1.0)
+ *
+ * Independent behavioral coverage (qa-e2e-author), written from the PRD's
+ * Acceptance Criteria plus the real code at:
+ * - includes/Newsletter/BlockConverter.php -- block routing, Group to section.
+ * - includes/Newsletter/SupportedBlocks.php -- supported block registry.
+ * - includes/Newsletter/PostSettingsBuilder.php -- Group gate (first sync marker).
+ * - includes/Newsletter/Sender.php -- sets the first-sync marker on create.
+ * - includes/Editor/Meta.php -- post meta keys.
+ *
+ * Every save goes through the REST endpoint the block editor uses
+ * (POST /wp/v2/posts/{id}, cookie + nonce), which fires the newsletter sync.
+ * Every outbound beehiiv request is answered by the test-only
+ * `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php and
+ * recorded in its request log, so specs assert on the exact `blocks` payload
+ * the plugin sent to beehiiv. Nothing reaches the live API.
+ */
+
+const META = {
+ send: '_beehiiv_send_to_newsletter',
+ snippet: '_beehiiv_send_to_newsletter_snippet',
+ postId: '_beehiiv_post_id',
+ groupSections: '_beehiiv_group_sections',
+};
+
+const PUBLICATION_ID = 'pub_qa_e2e_blocks';
+const TEMPLATE_ID = 'tpl_qa_e2e_blocks';
+const MOCK_CREATED_ID = 'post_qa_e2e_blocks_created';
+const LEGACY_ID = 'post_qa_e2e_blocks_legacy';
+const ACTIVE_AD = 'ad_qa_active';
+const EXPIRED_AD = 'ad_qa_expired';
+const PNG_B64 =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
+
+const createdPosts = [];
+const createdAttachments = [];
+
+const b64 = ( value ) =>
+ 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 };
+}
+
+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 =
+ ' ';
+ 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 becomes a nested section', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ group( p( 'Outer text' ) + group( p( 'Nested text' ) ) )
+ );
+ expect( types( blocks ) ).toEqual( [ 'section' ] );
+ expect( types( blocks[ 0 ].blocks ) ).toEqual( [
+ 'paragraph',
+ 'section',
+ ] );
+ const nested = blocks[ 0 ].blocks[ 1 ];
+ expect( types( nested.blocks ) ).toEqual( [ 'paragraph' ] );
+ expect( nested.blocks[ 0 ].plaintext ).toBe( 'Nested text' );
+ } );
+
+ test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
+ page,
+ } ) => {
+ const styled =
+ '' +
+ p( 'Styled group text' ) +
+ '
';
+ const { blocks } = await sendNew( page, styled );
+ expect( blocks ).toHaveLength( 1 );
+ expect( Object.keys( blocks[ 0 ] ).sort() ).toEqual( [
+ 'blocks',
+ 'type',
+ ] );
+ expect( blocks[ 0 ].type ).toBe( 'section' );
+ expect( text( blocks ) ).not.toContain( '#ff0000' );
+ expect( text( blocks ) ).not.toContain( '40px' );
+ } );
+
+ 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();
+ } );
+} );
From 8ccbab905688a5a0d61f78d72cc6a3885d22a60d Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 18:50:03 +0800
Subject: [PATCH 03/11] fix(newsletter-publishing): omit Groups nested in a
Group from the beehiiv payload
beehiiv rejects a section inside a section, so the post failed to sync.
A Group inside a Group is now omitted with its inner blocks, and the editor
counts it in the omitted-block notice and marks it with the hidden icon.
---
includes/Newsletter/BlockConverter.php | 14 +++++++-----
.../post-settings/hooks/use-omitted-blocks.js | 21 +++++++++++++++++-
.../specs/block-conversion-framework.spec.js | 14 +++++++-----
tests/phpunit/BlockConverterGroupTest.php | 22 ++++++++++++++-----
4 files changed, 53 insertions(+), 18 deletions(-)
diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php
index 86d68d2..6be6a24 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -39,7 +39,8 @@
* - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()`
*
* - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner
- * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`)
+ * 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.
@@ -160,10 +161,11 @@ private static function convert_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 nested Group becomes a nested
- * section. Section settings are 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.
+ * 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. Section settings are 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.
@@ -175,7 +177,7 @@ private static function convert_group_block( array $wp_block, WP_Post $post_obje
$wp_block['innerBlocks'] ?? [],
$post_object,
false,
- true
+ false
);
if ( empty( $inner_blocks ) ) {
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/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index 948136e..f0cf344 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -506,21 +506,23 @@ test.describe( 'US-006: Group blocks become sections', () => {
expect( section.blocks[ 2 ].plaintext ).toBe( 'Inside two' );
} );
- test( 'AC-010: a Group nested in a Group becomes a nested section', async ( {
+ 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' ) ) )
+ group(
+ p( 'Outer text' ) +
+ group( p( 'Nested text' ) ) +
+ p( 'Last text' )
+ )
);
expect( types( blocks ) ).toEqual( [ 'section' ] );
expect( types( blocks[ 0 ].blocks ) ).toEqual( [
'paragraph',
- 'section',
+ 'paragraph',
] );
- const nested = blocks[ 0 ].blocks[ 1 ];
- expect( types( nested.blocks ) ).toEqual( [ 'paragraph' ] );
- expect( nested.blocks[ 0 ].plaintext ).toBe( 'Nested text' );
+ expect( JSON.stringify( blocks ) ).not.toContain( 'Nested text' );
} );
test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
index 78b3a78..e3b8b96 100644
--- a/tests/phpunit/BlockConverterGroupTest.php
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -111,21 +111,33 @@ public function test_section_sits_between_sibling_blocks(): void {
}
/**
- * AC-010: a Group inside a Group becomes a nested section.
+ * AC-010: a Group inside a Group is omitted, because a beehiiv section cannot hold a section.
*/
- public function test_nested_group_becomes_nested_section(): void {
+ 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 ) );
+ $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', 'section' ),
+ array( 'paragraph', 'paragraph' ),
array_column( $blocks[0]['blocks'], 'type' )
);
- $this->assertCount( 1, $blocks[0]['blocks'][1]['blocks'] );
+ $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' ) );
}
/**
From 54d4cb89d89e3cd9bc9609c006777be1bbe16070 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 18:57:58 +0800
Subject: [PATCH 04/11] test(e2e): cover the editor notice for a Group nested
in a Group
---
.../specs/block-conversion-framework.spec.js | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index f0cf344..e0dc8ad 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -1,5 +1,6 @@
const { test, expect } = require( '@playwright/test' );
const { loginAsAdmin } = require( '../utils/auth' );
+const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' );
const {
wpCli,
wpCliSafe,
@@ -525,6 +526,49 @@ test.describe( 'US-006: Group blocks become sections', () => {
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 openBeehiivSidebar( page );
+
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toContainText( '1 block will be omitted from this newsletter.' );
+ const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 1
+ );
+ } );
+
+ 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 openBeehiivSidebar( page );
+
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__text' )
+ ).toBeVisible();
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toHaveCount( 0 );
+ } );
+
test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
page,
} ) => {
From 2f27426f4cbbe710ff086d714381178f06cae268 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 19:00:56 +0800
Subject: [PATCH 05/11] fix(editor): show the omitted-block notice on scheduled
posts linked to beehiiv
A linked post turns Send to newsletter off, which hid the notice and the
hidden-block icon while its newsletter was still scheduled and syncing edits.
---
.../components/omitted-blocks-notice.js | 23 ++++++++
.../filters/with-omitted-block-indicator.js | 14 +++--
src/js/editor/post-settings/index.js | 6 ++-
.../specs/block-conversion-framework.spec.js | 53 +++++++++++++++++++
4 files changed, 90 insertions(+), 6 deletions(-)
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/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
index e81ef86..61f3a85 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
@@ -13,11 +13,15 @@ function useShowOmittedBlockIndicators( clientId ) {
const beehiivMeta = useBeehiivPostMeta();
const isOmittedBlock = useIsOmittedBlock( clientId );
- 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;
+
+ return ( isQueued || isScheduled ) && isOmittedBlock;
}
// Higher order component to add a visual indicator to blocks that are omitted from the newsletter.
diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js
index 636895f..44c1337 100644
--- a/src/js/editor/post-settings/index.js
+++ b/src/js/editor/post-settings/index.js
@@ -26,7 +26,10 @@ 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';
@@ -214,6 +217,7 @@ function BeehiivPostSettingsPanel() {
{ newsletterAlreadySent && isNewsletterReady && (
<>
+ { ! newsletterPublished && }
{
);
} );
+ 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 openBeehiivSidebar( page );
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toContainText( '1 block will be omitted from this newsletter.' );
+ const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 1
+ );
+
+ await openPostEditor( page, sentId );
+ await openBeehiivSidebar( page );
+ 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,
} ) => {
From 9351fd66ec50640fd74b4fdb9a5e61241996042f Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 19:02:56 +0800
Subject: [PATCH 06/11] style(editor): remove the top margin on the
omitted-block notice
---
src/js/editor/post-settings/css/_sidebar.scss | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss
index 7a46f98..ed10cb8 100644
--- a/src/js/editor/post-settings/css/_sidebar.scss
+++ b/src/js/editor/post-settings/css/_sidebar.scss
@@ -34,7 +34,6 @@
align-items: flex-start;
display: flex;
gap: 6px;
- margin-top: 10px !important;
}
.beehiiv-post-settings-notice__omitted-icon {
From 718b6dac485b24af6ca5ff6b5342c62cf7a68641 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 19:12:27 +0800
Subject: [PATCH 07/11] fix(editor): mark omitted blocks on the block element
and only while the beehiiv sidebar is open
The marker no longer wraps the block in an extra element. The hidden-eye icon is
drawn on the block itself and stays visible without hovering.
---
src/js/editor/post-settings/constants.js | 11 ++++
.../post-settings/css/_omitted-blocks.scss | 44 ++++---------
.../filters/with-omitted-block-indicator.js | 66 +++++++++++--------
.../hooks/use-is-sidebar-open.js | 18 +++++
src/js/editor/post-settings/index.js | 3 +-
.../specs/block-conversion-framework.spec.js | 63 ++++++++++++++++--
6 files changed, 137 insertions(+), 68 deletions(-)
create mode 100644 src/js/editor/post-settings/constants.js
create mode 100644 src/js/editor/post-settings/hooks/use-is-sidebar-open.js
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/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
index 61f3a85..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,12 +6,14 @@ 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();
// A linked newsletter that is still scheduled keeps syncing edits, so its
// omitted blocks are marked too. A sent newsletter no longer changes.
@@ -21,40 +23,46 @@ function useShowOmittedBlockIndicators( clientId ) {
!! beehiivMeta?.newsletterAlreadySent &&
! beehiivMeta.newsletterPublished;
- return ( isQueued || isScheduled ) && isOmittedBlock;
+ // The marks only show while the beehiiv sidebar is open.
+ return isSidebarOpen && ( isQueued || isScheduled ) && isOmittedBlock;
}
-// Higher order component to add a visual indicator to blocks that are omitted from the newsletter.
-const withOmittedBlockIndicator = createHigherOrderComponent( ( BlockEdit ) => {
- return function BeehiivOmittedBlockEdit( props ) {
- const showIndicator = useShowOmittedBlockIndicators( props.clientId );
-
- if ( ! showIndicator ) {
- return ;
- }
-
- return (
-
-
-
- {
+ return function BeehiivOmittedBlockListBlock( props ) {
+ const showIndicator = useShowOmittedBlockIndicators(
+ props.clientId
+ );
+
+ if ( ! showIndicator ) {
+ return ;
+ }
+
+ return (
+
-
- );
- };
-}, 'withOmittedBlockIndicator' );
+ );
+ };
+ },
+ 'withOmittedBlockIndicator'
+);
addFilter(
- 'editor.BlockEdit',
+ 'editor.BlockListBlock',
'beehiiv/omitted-block-indicator',
withOmittedBlockIndicator
);
diff --git a/src/js/editor/post-settings/hooks/use-is-sidebar-open.js b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js
new file mode 100644
index 0000000..54ccf13
--- /dev/null
+++ b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js
@@ -0,0 +1,18 @@
+/**
+ * Whether the beehiiv sidebar panel is the open editor sidebar.
+ */
+import { useSelect } from '@wordpress/data';
+
+import { SIDEBAR_AREA } from '../constants';
+
+/**
+ * @return {boolean} True while the beehiiv sidebar is open.
+ */
+export function useIsBeehiivSidebarOpen() {
+ return useSelect(
+ ( select ) =>
+ SIDEBAR_AREA ===
+ select( 'core/interface' )?.getActiveComplementaryArea( 'core' ),
+ []
+ );
+}
diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js
index 44c1337..7d1659d 100644
--- a/src/js/editor/post-settings/index.js
+++ b/src/js/editor/post-settings/index.js
@@ -36,11 +36,10 @@ 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() {
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index fb82429..e849e57 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -1,6 +1,6 @@
const { test, expect } = require( '@playwright/test' );
const { loginAsAdmin } = require( '../utils/auth' );
-const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' );
+const { openPostEditor } = require( '../utils/editor' );
const {
wpCli,
wpCliSafe,
@@ -247,6 +247,31 @@ async function sendNew( page, content, options = {} ) {
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 ) =>
@@ -537,7 +562,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
} );
await loginAsAdmin( page );
await openPostEditor( page, id );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-notice__omitted' )
@@ -546,6 +571,34 @@ test.describe( 'US-006: Group blocks become sections', () => {
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 ( {
@@ -579,7 +632,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
await loginAsAdmin( page );
await openPostEditor( page, scheduledId );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-notice__omitted' )
).toContainText( '1 block will be omitted from this newsletter.' );
@@ -589,7 +642,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
);
await openPostEditor( page, sentId );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-content' )
).toBeVisible();
@@ -612,7 +665,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
} );
await loginAsAdmin( page );
await openPostEditor( page, id );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-notice__text' )
From f69d8b8a0c71847e7452a5ffc30bbf2dc847dc9a Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 20:17:02 +0800
Subject: [PATCH 08/11] style(editor): add a gap above the omitted-blocks line
only when it follows the send warning text
---
src/js/editor/post-settings/css/_sidebar.scss | 5 +++++
tests/e2e/specs/block-conversion-framework.spec.js | 8 ++++++++
2 files changed, 13 insertions(+)
diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss
index ed10cb8..0e707c2 100644
--- a/src/js/editor/post-settings/css/_sidebar.scss
+++ b/src/js/editor/post-settings/css/_sidebar.scss
@@ -36,6 +36,11 @@
gap: 6px;
}
+ // Gap only when the omitted-blocks line follows the send warning text.
+ .beehiiv-post-settings-notice__text + .beehiiv-post-settings-notice__omitted {
+ margin-top: 10px;
+ }
+
.beehiiv-post-settings-notice__omitted-icon {
flex-shrink: 0;
font-size: 18px;
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index e849e57..e215f96 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -567,6 +567,10 @@ test.describe( 'US-006: Group blocks become sections', () => {
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
@@ -636,6 +640,10 @@ test.describe( 'US-006: Group blocks become sections', () => {
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
From 593098ac2b801f3f1757b0b297b8f2bf5982f396 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Tue, 10 Nov 2026 19:04:06 +0800
Subject: [PATCH 09/11] Send a Group's background color, text color and padding
as the section's visual settings
Colors come from custom values or the theme palette. Padding is sent in whole pixels
per side; rem and em convert at 16 pixels, and sides in percent or fluid units keep
beehiiv's default. Borders and other Group styling are not sent yet.
---
includes/Newsletter/BlockConverter.php | 18 +-
.../Converters/GroupBlockConverter.php | 218 ++++++++++++++++++
includes/Newsletter/FormattedTextParser.php | 36 +++
.../specs/block-conversion-framework.spec.js | 27 ++-
tests/phpunit/BlockConverterGroupTest.php | 179 +++++++++++++-
5 files changed, 463 insertions(+), 15 deletions(-)
create mode 100644 includes/Newsletter/Converters/GroupBlockConverter.php
diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php
index 6be6a24..447c5df 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;
@@ -163,9 +164,10 @@ private static function convert_blocks(
* 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. Section settings are 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.
+ * and sending one makes beehiiv reject the whole post. The Group's background colour, text
+ * colour and padding 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.
@@ -184,10 +186,18 @@ private static function convert_group_block( array $wp_block, WP_Post $post_obje
return [];
}
- 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;
}
/**
diff --git a/includes/Newsletter/Converters/GroupBlockConverter.php b/includes/Newsletter/Converters/GroupBlockConverter.php
new file mode 100644
index 0000000..83d2944
--- /dev/null
+++ b/includes/Newsletter/Converters/GroupBlockConverter.php
@@ -0,0 +1,218 @@
+
+ */
+ 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_padding( $attrs ) as $side => $pixels ) {
+ $settings[ 'inner_spacing_' . $side ] = $pixels;
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Resolve each padding side of a Group block to whole pixels.
+ *
+ * A side whose value cannot be turned into pixels (percentages, viewport units,
+ * `clamp()` presets) is left out so beehiiv keeps its default for that side.
+ *
+ * @param array $attrs Parsed block attributes.
+ * @return array Pixels keyed by side.
+ */
+ private static function resolve_padding( array $attrs ): array {
+ $padding = $attrs['style']['spacing']['padding'] ?? null;
+
+ if ( is_string( $padding ) || is_int( $padding ) ) {
+ $padding = array_fill_keys( self::SIDES, $padding );
+ }
+
+ if ( ! is_array( $padding ) ) {
+ return [];
+ }
+
+ $resolved = [];
+
+ foreach ( self::SIDES as $side ) {
+ if ( ! isset( $padding[ $side ] ) ) {
+ continue;
+ }
+
+ $pixels = self::to_pixels( $padding[ $side ] );
+
+ if ( null !== $pixels ) {
+ $resolved[ $side ] = $pixels;
+ }
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * Convert one padding value to whole pixels.
+ *
+ * @param mixed $value Padding 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 plain CSS length (`40px`, `2.5rem`, `1em`, `12`) to whole pixels.
+ *
+ * @param string $length CSS length.
+ * @return int|null Pixels, or null for any other unit or function.
+ */
+ private static function css_length_to_pixels( string $length ): ?int {
+ 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 );
+ }
+
+ /**
+ * 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 : [];
+
+ foreach ( array( 'theme', 'default', 'custom' ) 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..b65307b 100644
--- a/includes/Newsletter/FormattedTextParser.php
+++ b/includes/Newsletter/FormattedTextParser.php
@@ -212,6 +212,42 @@ 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 block-level list-item text colour from attrs and the `` tag.
*
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index e215f96..72d2937 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -683,22 +683,39 @@ test.describe( 'US-006: Group blocks become sections', () => {
).toHaveCount( 0 );
} );
- test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
+ test( 'AC-011: Group background color, text color and padding 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 in 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,
+ } );
+ } );
+
+ 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',
] );
- expect( blocks[ 0 ].type ).toBe( 'section' );
- expect( text( blocks ) ).not.toContain( '#ff0000' );
- expect( text( blocks ) ).not.toContain( '40px' );
} );
test( 'AC-012: an empty or unconvertible Group produces no section; a mixed Group keeps only convertible blocks', async ( {
diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
index e3b8b96..3496f93 100644
--- a/tests/phpunit/BlockConverterGroupTest.php
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -141,18 +141,185 @@ public function test_no_section_is_nested_at_any_depth(): void {
}
/**
- * AC-011: a section carries only its type and blocks, never Group styling.
+ * 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
*/
- public function test_section_uses_default_settings(): void {
- $attrs = '{"backgroundColor":"vivid-red","style":{"spacing":{"padding":{"top":"40px"}}}}';
- $content = "\n"
- . ''
+ 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( array( 'type', 'blocks' ), array_keys( $blocks[0] ) );
+ $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.
+ */
+ 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_right' => 30 ), $section['visual_settings'] );
+ }
+
+ /**
+ * AC-011: borders and other Group styling are not sent.
+ */
+ public function test_borders_are_not_sent(): void {
+ $section = $this->convert_to_section(
+ $this->styled_group(
+ array(
+ 'style' => array(
+ 'border' => array(
+ 'width' => '3px',
+ 'color' => '#000000',
+ 'radius' => '8px',
+ ),
+ ),
+ )
+ )
+ );
+
+ $this->assertSame( array( 'type', 'blocks' ), array_keys( $section ) );
}
/**
From 451c2a8982fc0a92990997bde1417bf97f08b25b Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Tue, 10 Nov 2026 19:21:27 +0800
Subject: [PATCH 10/11] Send a Group's border color, width, style and radius as
the section's visual settings
A border with a color or width and no style is sent as solid. Palette colors, including
the var:preset|color|slug values the editor stores on a border with a different width
on each side, are sent as their color value. Styles beehiiv does not accept, colors and
styles that differ by side, and widths or radii in percent are not sent.
---
includes/Newsletter/BlockConverter.php | 4 +-
.../Converters/GroupBlockConverter.php | 197 +++++++++++++-
includes/Newsletter/FormattedTextParser.php | 77 ++++++
.../specs/block-conversion-framework.spec.js | 49 ++++
tests/phpunit/BlockConverterGroupTest.php | 246 +++++++++++++++++-
5 files changed, 563 insertions(+), 10 deletions(-)
diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php
index 447c5df..8724131 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -165,8 +165,8 @@ private static function convert_blocks(
* 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 and padding 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
+ * colour, padding 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.
diff --git a/includes/Newsletter/Converters/GroupBlockConverter.php b/includes/Newsletter/Converters/GroupBlockConverter.php
index 83d2944..2721e3a 100644
--- a/includes/Newsletter/Converters/GroupBlockConverter.php
+++ b/includes/Newsletter/Converters/GroupBlockConverter.php
@@ -12,16 +12,35 @@
defined( 'ABSPATH' ) || exit;
/**
- * Helpers for mapping a core/group block's colours and padding to the `visual_settings`
- * of a beehiiv `section` block.
+ * Helpers for mapping a core/group block's colours, padding and border to the
+ * `visual_settings` of a beehiiv `section` block.
*
- * Only background colour, text colour and padding are mapped. Borders and other Group
- * styling stay at beehiiv defaults.
+ * Background colour, text colour, padding, and the border's colour, width, style and
+ * radius are mapped. Other Group styling stays at beehiiv defaults.
*
* @since x.x.x
*/
final class GroupBlockConverter {
+ /**
+ * Border styles beehiiv accepts for a section.
+ *
+ * @var array
+ */
+ 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.
*
@@ -71,7 +90,175 @@ public static function build_visual_settings( array $wp_block ): array {
$settings[ 'inner_spacing_' . $side ] = $pixels;
}
- return $settings;
+ 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;
}
/**
diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php
index b65307b..72bb4d0 100644
--- a/includes/Newsletter/FormattedTextParser.php
+++ b/includes/Newsletter/FormattedTextParser.php
@@ -248,6 +248,83 @@ public static function resolve_group_block_text_color( string $inner_html, array
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.
*
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index 72d2937..12c52be 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -708,6 +708,55 @@ test.describe( 'US-006: Group blocks become sections', () => {
} );
} );
+ 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,
} ) => {
diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
index 3496f93..3e16108 100644
--- a/tests/phpunit/BlockConverterGroupTest.php
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -302,16 +302,17 @@ public function test_padding_that_is_not_in_pixels_is_left_out(): void {
}
/**
- * AC-011: borders and other Group styling are not sent.
+ * AC-015: border colour, width, style and radius become the section's border settings.
*/
- public function test_borders_are_not_sent(): void {
+ 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',
- 'color' => '#000000',
+ 'style' => 'dashed',
'radius' => '8px',
),
),
@@ -319,6 +320,245 @@ public function test_borders_are_not_sent(): void {
)
);
+ $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 ) );
}
From 63c4f5e88cf8f272ab0cd09357228930af4730ae Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Tue, 10 Nov 2026 19:40:54 +0800
Subject: [PATCH 11/11] Send a Group's margin as outer spacing and resolve
theme and fluid spacing sizes
A Group's margin is sent as the section's outer spacing, next to padding as inner
spacing. A size chosen by name from the theme is read from the theme's own definition:
presets are applied in WordPress's order (default, theme, custom), and the default sizes
and colors are skipped when the theme turns them off, so a theme that redefines a default
name no longer gets WordPress's default value. A fluid size (clamp, min or max) is sent
at its widest fixed value.
---
includes/Newsletter/BlockConverter.php | 2 +-
.../Converters/GroupBlockConverter.php | 116 +++++-
includes/Newsletter/FormattedTextParser.php | 6 +-
.../specs/block-conversion-framework.spec.js | 9 +-
tests/phpunit/BlockConverterGroupTest.php | 337 +++++++++++++++++-
5 files changed, 446 insertions(+), 24 deletions(-)
diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php
index 8724131..1944c33 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -165,7 +165,7 @@ private static function convert_blocks(
* 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 and border go in the section's `visual_settings`; every other setting is
+ * 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.
*
diff --git a/includes/Newsletter/Converters/GroupBlockConverter.php b/includes/Newsletter/Converters/GroupBlockConverter.php
index 2721e3a..7b4ea85 100644
--- a/includes/Newsletter/Converters/GroupBlockConverter.php
+++ b/includes/Newsletter/Converters/GroupBlockConverter.php
@@ -12,10 +12,10 @@
defined( 'ABSPATH' ) || exit;
/**
- * Helpers for mapping a core/group block's colours, padding and border to the
+ * Helpers for mapping a core/group block's colours, padding, margin and border to the
* `visual_settings` of a beehiiv `section` block.
*
- * Background colour, text colour, padding, and the border's colour, width, style and
+ * Background colour, text colour, padding, margin, and the border's colour, width, style and
* radius are mapped. Other Group styling stays at beehiiv defaults.
*
* @since x.x.x
@@ -86,10 +86,14 @@ public static function build_visual_settings( array $wp_block ): array {
$settings['text_color'] = $text_color;
}
- foreach ( self::resolve_padding( $attrs ) as $side => $pixels ) {
+ 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 ) );
}
@@ -262,33 +266,35 @@ private static function shared_side_value( array $border, string $key ): ?string
}
/**
- * Resolve each padding side of a Group block to whole pixels.
+ * 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,
- * `clamp()` presets) is left out so beehiiv keeps its default for that side.
+ * `auto`, negative values, `clamp()` presets) is left out so beehiiv keeps its default
+ * for that side.
*
- * @param array $attrs Parsed block attributes.
+ * @param array $attrs Parsed block attributes.
+ * @param string $property Spacing property, `padding` or `margin`.
* @return array Pixels keyed by side.
*/
- private static function resolve_padding( array $attrs ): array {
- $padding = $attrs['style']['spacing']['padding'] ?? null;
+ private static function resolve_spacing( array $attrs, string $property ): array {
+ $spacing = $attrs['style']['spacing'][ $property ] ?? null;
- if ( is_string( $padding ) || is_int( $padding ) ) {
- $padding = array_fill_keys( self::SIDES, $padding );
+ if ( is_string( $spacing ) || is_int( $spacing ) ) {
+ $spacing = array_fill_keys( self::SIDES, $spacing );
}
- if ( ! is_array( $padding ) ) {
+ if ( ! is_array( $spacing ) ) {
return [];
}
$resolved = [];
foreach ( self::SIDES as $side ) {
- if ( ! isset( $padding[ $side ] ) ) {
+ if ( ! isset( $spacing[ $side ] ) ) {
continue;
}
- $pixels = self::to_pixels( $padding[ $side ] );
+ $pixels = self::to_pixels( $spacing[ $side ] );
if ( null !== $pixels ) {
$resolved[ $side ] = $pixels;
@@ -299,9 +305,9 @@ private static function resolve_padding( array $attrs ): array {
}
/**
- * Convert one padding value to whole pixels.
+ * Convert one padding or margin value to whole pixels.
*
- * @param mixed $value Padding value: a number, a CSS length, or a spacing preset.
+ * @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 {
@@ -326,12 +332,23 @@ private static function to_pixels( $value ): ?int {
}
/**
- * Convert a plain CSS length (`40px`, `2.5rem`, `1em`, `12`) to whole pixels.
+ * Convert a CSS length (`40px`, `2.5rem`, `1em`, `12`) or a fluid size to whole pixels.
*
- * @param string $length CSS length.
+ * 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;
}
@@ -346,6 +363,63 @@ private static function css_length_to_pixels( string $length ): ?int {
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)`.
*
@@ -378,7 +452,13 @@ private static function get_spacing_preset_map(): array {
$sizes = wp_get_global_settings( array( 'spacing', 'spacingSizes' ) );
$sizes = is_array( $sizes ) ? $sizes : [];
- foreach ( array( 'theme', 'default', 'custom' ) as $source ) {
+ // 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;
}
diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php
index 72bb4d0..0cd7edb 100644
--- a/includes/Newsletter/FormattedTextParser.php
+++ b/includes/Newsletter/FormattedTextParser.php
@@ -1196,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/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index 12c52be..a2c4737 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -683,11 +683,11 @@ test.describe( 'US-006: Group blocks become sections', () => {
).toHaveCount( 0 );
} );
- test( 'AC-011: Group background color, text color and padding become the section visual settings', async ( {
+ 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 );
@@ -698,13 +698,16 @@ test.describe( 'US-006: Group blocks become sections', () => {
'type',
'visual_settings',
] );
- // Padding in rem converts to pixels; a percentage cannot, so that side is left out.
+ // 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,
} );
} );
diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
index 3e16108..3ae5568 100644
--- a/tests/phpunit/BlockConverterGroupTest.php
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -21,6 +21,13 @@
*/
class BlockConverterGroupTest extends WP_UnitTestCase {
+ /**
+ * Theme preset filter added by a test, removed in tear_down.
+ *
+ * @var callable|null
+ */
+ private $theme_json_filter = null;
+
/**
* Configure a site default post template so the builder needs no API call.
*/
@@ -279,6 +286,8 @@ public function test_single_padding_value_applies_to_every_side(): void {
/**
* 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(
@@ -298,7 +307,333 @@ public function test_padding_that_is_not_in_pixels_is_left_out(): void {
)
);
- $this->assertSame( array( 'inner_spacing_right' => 30 ), $section['visual_settings'] );
+ $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']
+ );
}
/**