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..6be6a24 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -38,7 +38,12 @@
* - `core/more` — snippet newsletters only; beehiiv Read More `button` via `convert_more_block()`
* - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()`
*
- * Layout blocks (`core/group`, `core/columns`, etc.) are skipped at the top level.
+ * - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner
+ * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`; a Group
+ * inside a Group is omitted because beehiiv sections cannot hold sections)
+ *
+ * Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is
+ * `core/group` for posts first synced before section support.
* `convert_columns_block()` exists for internal reuse (e.g. `core/media-text`) and is
* not wired into the main block walk yet. Snippet mode stops at `core/more`.
*
@@ -49,11 +54,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 +80,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 +97,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 +121,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 +157,39 @@ private static function convert_blocks(
return $beehiiv_blocks;
}
+ /**
+ * Convert a core/group block to a beehiiv section block.
+ *
+ * The Group's inner blocks are converted with the same rules as top-level blocks and
+ * placed in the section in their original order. A Group nested inside a Group is
+ * omitted with its inner blocks, because a beehiiv section cannot hold another section
+ * and sending one makes beehiiv reject the whole post. 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,
+ false
+ );
+
+ 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/src/js/editor/post-settings/components/omitted-blocks-notice.js b/src/js/editor/post-settings/components/omitted-blocks-notice.js
index 0eaf0bd..bbf2948 100644
--- a/src/js/editor/post-settings/components/omitted-blocks-notice.js
+++ b/src/js/editor/post-settings/components/omitted-blocks-notice.js
@@ -3,6 +3,7 @@
*/
import { _n, sprintf } from '@wordpress/i18n';
+import PostSettingsNotice from './post-settings-notice';
import { useOmittedBlockCount } from '../hooks/use-omitted-blocks';
/**
@@ -35,3 +36,25 @@ export function OmittedBlocksNoticeMessage() {
);
}
+
+/**
+ * Standalone warning for a linked post whose newsletter is still scheduled.
+ *
+ * A linked post has no send warning to hold the message, so it gets its own
+ * notice, shown only when a block will be omitted.
+ *
+ * @return {import('react').JSX.Element|null} Warning notice or null.
+ */
+export function OmittedBlocksNotice() {
+ const omittedBlockCount = useOmittedBlockCount();
+
+ if ( omittedBlockCount < 1 ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/js/editor/post-settings/constants.js b/src/js/editor/post-settings/constants.js
new file mode 100644
index 0000000..77ca223
--- /dev/null
+++ b/src/js/editor/post-settings/constants.js
@@ -0,0 +1,11 @@
+/**
+ * Names of the beehiiv editor plugin and its sidebar.
+ */
+
+export const PLUGIN_NAME = 'beehiiv-post-settings';
+export const SIDEBAR_NAME = 'beehiiv-post-settings';
+
+/**
+ * Complementary area id WordPress reports while the beehiiv sidebar is open.
+ */
+export const SIDEBAR_AREA = `${ PLUGIN_NAME }/${ SIDEBAR_NAME }`;
diff --git a/src/js/editor/post-settings/css/_omitted-blocks.scss b/src/js/editor/post-settings/css/_omitted-blocks.scss
index 9062b92..9ed1697 100644
--- a/src/js/editor/post-settings/css/_omitted-blocks.scss
+++ b/src/js/editor/post-settings/css/_omitted-blocks.scss
@@ -12,41 +12,21 @@
box-sizing: border-box;
min-height: 24px;
position: relative;
- width: 100%;
- // Invisible hit area so the icon above the block stays hoverable.
- &::before {
+ // Hidden-eye icon above the block's top right corner, drawn on the block itself.
+ &::after {
bottom: 100%;
- content: "";
- height: 24px;
+ color: beehiiv.$beehiiv-status-disconnected;
+ content: "\f530";
+ font-family: dashicons, sans-serif;
+ font-size: 16px;
+ height: 20px;
+ line-height: 18px;
+ pointer-events: none;
position: absolute;
- right: 0;
- width: 28px;
+ right: 2px;
+ width: 20px;
+ z-index: 30;
}
}
-
-.editor-styles-wrapper .beehiiv-omitted-block__icon {
- bottom: 100%;
- color: beehiiv.$beehiiv-status-disconnected;
- cursor: default;
- display: block;
- font-family: dashicons, sans-serif;
- font-size: 16px;
- height: 20px;
- line-height: 18px;
- opacity: 0;
- pointer-events: auto;
- position: absolute;
- right: 2px;
- transition: opacity 0.15s ease;
- width: 20px;
- z-index: 30;
-}
-
-.editor-styles-wrapper .beehiiv-omitted-block__icon:hover,
-.editor-styles-wrapper
- .beehiiv-omitted-block:has(:hover)
- .beehiiv-omitted-block__icon {
- opacity: 1;
-}
/* stylelint-enable selector-class-pattern */
diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss
index 7a46f98..0e707c2 100644
--- a/src/js/editor/post-settings/css/_sidebar.scss
+++ b/src/js/editor/post-settings/css/_sidebar.scss
@@ -34,7 +34,11 @@
align-items: flex-start;
display: flex;
gap: 6px;
- margin-top: 10px !important;
+ }
+
+ // Gap only when the omitted-blocks line follows the send warning text.
+ .beehiiv-post-settings-notice__text + .beehiiv-post-settings-notice__omitted {
+ margin-top: 10px;
}
.beehiiv-post-settings-notice__omitted-icon {
diff --git a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
index e81ef86..10b8275 100644
--- a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
+++ b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
@@ -6,51 +6,63 @@ import { createHigherOrderComponent } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import { useBeehiivPostMeta } from '../hooks/use-beehiiv-post-meta';
+import { useIsBeehiivSidebarOpen } from '../hooks/use-is-sidebar-open';
import { useIsOmittedBlock } from '../hooks/use-omitted-blocks';
// Custom hook to determine if a block should show an omitted block indicator.
function useShowOmittedBlockIndicators( clientId ) {
const beehiivMeta = useBeehiivPostMeta();
const isOmittedBlock = useIsOmittedBlock( clientId );
+ const isSidebarOpen = useIsBeehiivSidebarOpen();
- return (
- !! beehiivMeta?.sendToNewsletter &&
- ! beehiivMeta.newsletterAlreadySent &&
- isOmittedBlock
- );
+ // A linked newsletter that is still scheduled keeps syncing edits, so its
+ // omitted blocks are marked too. A sent newsletter no longer changes.
+ const isQueued =
+ !! beehiivMeta?.sendToNewsletter && ! beehiivMeta.newsletterAlreadySent;
+ const isScheduled =
+ !! beehiivMeta?.newsletterAlreadySent &&
+ ! beehiivMeta.newsletterPublished;
+
+ // The marks only show while the beehiiv sidebar is open.
+ return isSidebarOpen && ( isQueued || isScheduled ) && isOmittedBlock;
}
-// Higher order component to add a visual indicator to blocks that are omitted from the newsletter.
-const withOmittedBlockIndicator = createHigherOrderComponent( ( BlockEdit ) => {
- return function BeehiivOmittedBlockEdit( props ) {
- const showIndicator = useShowOmittedBlockIndicators( props.clientId );
-
- if ( ! showIndicator ) {
- return ;
- }
-
- return (
-
'
+ );
+ 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' ) +
+ '