Skip to content
9 changes: 9 additions & 0 deletions includes/Editor/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
63 changes: 58 additions & 5 deletions includes/Newsletter/BlockConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand All @@ -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<int, array<string, mixed>>
* @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 );

Expand All @@ -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(
Expand All @@ -89,13 +97,15 @@ public static function convert_supported_blocks( WP_Post $post_object ): array {
* @param array<int, array<string, mixed>> $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<int, array<string, mixed>>
* @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 = [];

Expand All @@ -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;
}
Expand All @@ -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<string, mixed> $wp_block Parsed core/group block.
* @param WP_Post $post_object Post object.
* @return array<string, mixed>
* @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.
*
Expand Down
26 changes: 25 additions & 1 deletion includes/Newsletter/PostSettingsBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions includes/Newsletter/Sender.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
7 changes: 5 additions & 2 deletions includes/Newsletter/SupportedBlocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -34,6 +36,7 @@ final class SupportedBlocks {
'core/pullquote',
'core/embed',
'core/media-text',
'core/group',
'core/buttons',
'core/separator',
'beehiiv/advertisement',
Expand Down
23 changes: 23 additions & 0 deletions src/js/editor/post-settings/components/omitted-blocks-notice.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { _n, sprintf } from '@wordpress/i18n';

import PostSettingsNotice from './post-settings-notice';
import { useOmittedBlockCount } from '../hooks/use-omitted-blocks';

/**
Expand Down Expand Up @@ -35,3 +36,25 @@ export function OmittedBlocksNoticeMessage() {
</p>
);
}

/**
* 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 (
<PostSettingsNotice status="warning">
<OmittedBlocksNoticeMessage />
</PostSettingsNotice>
);
}
11 changes: 11 additions & 0 deletions src/js/editor/post-settings/constants.js
Original file line number Diff line number Diff line change
@@ -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 }`;
44 changes: 12 additions & 32 deletions src/js/editor/post-settings/css/_omitted-blocks.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
6 changes: 5 additions & 1 deletion src/js/editor/post-settings/css/_sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading