Move EmailEditor PHP files into a packages directory

[MAILPOET-6216]
This commit is contained in:
Jan Lysý
2024-09-10 15:54:11 +02:00
committed by Jan Lysý
parent fe4eeb0049
commit ea71b6c967
61 changed files with 0 additions and 0 deletions

View File

@ -0,0 +1,40 @@
# MailPoet Email Editor
This folder contains the code for the MailPoet Email Editor.
We aim to extract the engine as an independent library, so it can be used in other projects.
As we are still in an exploration phase we keep it together with the MailPoet codebase.
## Development
Both **PHP** **and** JS codes are divided into `engine` and `integrations` subdirectories.
Anything **MailPoet** specific is in the `integrations/MailPoet` folder.
For the core stuff that goes to the engine folder, avoid using other MailPoet-specific services and modules. The code in the Engine folder should work only with WP code or other stuff from the engine.
## Known rendering issues
- In some (not all) Outlook versions the width of columns is not respected. The columns will be rendered with the full width.
## Actions and Filters
These actions and filters are currently Work-in-progress.
We may add, update and delete any of them.
Please use with caution.
### Actions
| Name | Argument | Description |
| -------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| `mailpoet_email_editor_initialized` | `null` | Called when the Email Editor is initialized |
| `mailpoet_blocks_renderer_initialized` | `BlocksRegistry` | Called when the block content renderer is initialized. You may use this to add a new BlockRenderer |
### Filters
| Name | Argument | Return | Description |
| ---------------------------------------- | ----------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
| `mailpoet_email_editor_post_types` | `Array` $postTypes | `Array` EmailPostType | Applied to the list of post types used by the `getPostTypes` method |
| `mailpoet_email_editor_theme_json` | `WP_Theme_JSON` $coreThemeData | `WP_Theme_JSON` $themeJson | Applied to the theme json data. This theme json data is created from the merging of the `WP_Theme_JSON_Resolver::get_core_data` and MailPoet owns `theme.json` file |
| `mailpoet_email_renderer_styles` | `string` $templateStyles, `WP_Post` $post | `string` $templateStyles | Applied to the email editor template styles. |
| `mailpoet_blocks_renderer_parsed_blocks` | `WP_Block_Parser_Block[]` $output | `WP_Block_Parser_Block[]` $output | Applied to the result of parsed blocks created by the BlocksParser. | |
| `mailpoet_email_content_renderer_styles` | `string` $contentStyles, `WP_Post` $post | `string` $contentStyles | Applied to the inline content styles prior to use by the CSS Inliner. |

View File

@ -0,0 +1,26 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine;
use MailPoet\Validator\Builder;
class EmailApiController {
/**
* @return array - Email specific data such styles.
*/
public function getEmailData(): array {
// Here comes code getting Email specific data that will be passed on 'email_data' attribute
return [];
}
/**
* Update Email specific data we store.
*/
public function saveEmailData(array $data, \WP_Post $emailPost): void {
// Here comes code saving of Email specific data that will be passed on 'email_data' attribute
}
public function getEmailDataSchema(): array {
return Builder::object()->toArray();
}
}

View File

@ -0,0 +1,129 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine;
use MailPoet\EmailEditor\Engine\Patterns\Patterns;
use MailPoet\EmailEditor\Engine\Templates\TemplatePreview;
use MailPoet\EmailEditor\Engine\Templates\Templates;
use MailPoet\Entities\NewsletterEntity;
use WP_Post;
use WP_Theme_JSON;
/**
* @phpstan-type EmailPostType array{name: string, args: array, meta: array{key: string, args: array}[]}
* See register_post_type for details about EmailPostType args.
*/
class EmailEditor {
public const MAILPOET_EMAIL_META_THEME_TYPE = 'mailpoet_email_theme';
private EmailApiController $emailApiController;
private Templates $templates;
private TemplatePreview $templatePreview;
private Patterns $patterns;
private SettingsController $settingsController;
public function __construct(
EmailApiController $emailApiController,
Templates $templates,
TemplatePreview $templatePreview,
Patterns $patterns,
SettingsController $settingsController
) {
$this->emailApiController = $emailApiController;
$this->templates = $templates;
$this->templatePreview = $templatePreview;
$this->patterns = $patterns;
$this->settingsController = $settingsController;
}
public function initialize(): void {
do_action('mailpoet_email_editor_initialized');
add_filter('mailpoet_email_editor_rendering_theme_styles', [$this, 'extendEmailThemeStyles'], 10, 2);
$this->registerBlockTemplates();
$this->registerBlockPatterns();
$this->registerEmailPostTypes();
$this->registerEmailPostSendStatus();
$isEditorPage = apply_filters('mailpoet_is_email_editor_page', false);
if ($isEditorPage) {
$this->extendEmailPostApi();
$this->settingsController->init();
}
}
private function registerBlockTemplates(): void {
// Since we cannot currently disable blocks in the editor for specific templates, disable templates when viewing site editor. @see https://github.com/WordPress/gutenberg/issues/41062
if (strstr(wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 'site-editor.php') === false) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$this->templates->initialize();
$this->templatePreview->initialize();
}
}
private function registerBlockPatterns(): void {
$this->patterns->initialize();
}
/**
* Register all custom post types that should be edited via the email editor
* The post types are added via mailpoet_email_editor_post_types filter.
*/
private function registerEmailPostTypes(): void {
foreach ($this->getPostTypes() as $postType) {
register_post_type(
$postType['name'],
array_merge($this->getDefaultEmailPostArgs(), $postType['args'])
);
}
}
/**
* @phpstan-return EmailPostType[]
*/
private function getPostTypes(): array {
$postTypes = [];
return apply_filters('mailpoet_email_editor_post_types', $postTypes);
}
private function getDefaultEmailPostArgs(): array {
return [
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'supports' => ['editor', 'title', 'custom-fields'], // 'custom-fields' is required for loading meta fields via API
'has_archive' => true,
'show_in_rest' => true, // Important to enable Gutenberg editor
];
}
private function registerEmailPostSendStatus(): void {
register_post_status(NewsletterEntity::STATUS_SENT, [
'public' => false,
'exclude_from_search' => true,
'internal' => true, // for now, we hide it, if we use the status in the listings we may flip this and following values
'show_in_admin_all_list' => false,
'show_in_admin_status_list' => false,
]);
}
public function extendEmailPostApi() {
$emailPostTypes = array_column($this->getPostTypes(), 'name');
register_rest_field($emailPostTypes, 'email_data', [
'get_callback' => [$this->emailApiController, 'getEmailData'],
'update_callback' => [$this->emailApiController, 'saveEmailData'],
'schema' => $this->emailApiController->getEmailDataSchema(),
]);
}
public function getEmailThemeDataSchema(): array {
return (new EmailStylesSchema())->getSchema();
}
public function extendEmailThemeStyles(WP_Theme_JSON $theme, WP_Post $post): WP_Theme_JSON {
$emailTheme = get_post_meta($post->ID, EmailEditor::MAILPOET_EMAIL_META_THEME_TYPE, true);
if ($emailTheme && is_array($emailTheme)) {
$theme->merge(new WP_Theme_JSON($emailTheme));
}
return $theme;
}
}

View File

@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine;
use MailPoet\Validator\Builder;
class EmailStylesSchema {
public function getSchema(): array {
$typographyProps = Builder::object([
'fontFamily' => Builder::string()->nullable(),
'fontSize' => Builder::string()->nullable(),
'fontStyle' => Builder::string()->nullable(),
'fontWeight' => Builder::string()->nullable(),
'letterSpacing' => Builder::string()->nullable(),
'lineHeight' => Builder::string()->nullable(),
'textTransform' => Builder::string()->nullable(),
'textDecoration' => Builder::string()->nullable(),
])->nullable();
return Builder::object([
'version' => Builder::integer(),
'styles' => Builder::object([
'spacing' => Builder::object([
'padding' => Builder::object([
'top' => Builder::string(),
'right' => Builder::string(),
'bottom' => Builder::string(),
'left' => Builder::string(),
])->nullable(),
'blockGap' => Builder::string()->nullable(),
])->nullable(),
'color' => Builder::object([
'background' => Builder::string()->nullable(),
'text' => Builder::string()->nullable(),
])->nullable(),
'typography' => $typographyProps,
'elements' => Builder::object([
'heading' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'button' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'link' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'h1' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'h2' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'h3' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'h4' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'h5' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
'h6' => Builder::object([
'typography' => $typographyProps,
])->nullable(),
])->nullable(),
])->nullable(),
])->toArray();
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Patterns\Library;
use MailPoet\Util\CdnAssetUrl;
abstract class AbstractPattern {
protected $cdnAssetUrl;
protected $blockTypes = [];
protected $inserter = true;
protected $source = 'plugin';
protected $categories = ['mailpoet'];
protected $viewportWidth = 620;
public function __construct(
CdnAssetUrl $cdnAssetUrl
) {
$this->cdnAssetUrl = $cdnAssetUrl;
}
public function getProperties() {
return [
'title' => $this->getTitle(),
'content' => $this->getContent(),
'description' => $this->getDescription(),
'categories' => $this->categories,
'inserter' => $this->inserter,
'blockTypes' => $this->blockTypes,
'source' => $this->source,
'viewportWidth' => $this->viewportWidth,
];
}
abstract protected function getContent(): string;
abstract protected function getTitle(): string;
protected function getDescription(): string {
return '';
}
}

View File

@ -0,0 +1,43 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Patterns\Library;
class DefaultContent extends AbstractPattern {
protected $blockTypes = [
'core/post-content',
];
protected function getContent(): string {
return '
<!-- wp:columns {"backgroundColor":"white","style":{"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20","left":"var:preset|spacing|20","right":"var:preset|spacing|20"},"margin":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20"}}}} -->
<div class="wp-block-columns has-white-background-color has-background" style="margin-top:var(--wp--preset--spacing--20);margin-bottom:var(--wp--preset--spacing--20);padding-top:var(--wp--preset--spacing--20);padding-right:var(--wp--preset--spacing--20);padding-bottom:var(--wp--preset--spacing--20);padding-left:var(--wp--preset--spacing--20)"><!-- wp:column -->
<div class="wp-block-column">
<!-- wp:image {"width":"130px","sizeSlug":"large"} -->
<figure class="wp-block-image size-large is-resized"><img src="' . esc_url($this->cdnAssetUrl->generateCdnUrl("email-editor/your-logo-placeholder.png")) . '" alt="Your Logo" style="width:130px"/></figure>
<!-- /wp:image -->
<!-- wp:heading {"fontSize":"medium","style":{"spacing":{"padding":{"top":"var:preset|spacing|10","bottom":"var:preset|spacing|10"}}}} -->
<h2 class="wp-block-heading has-medium-font-size" style="padding-top:var(--wp--preset--spacing--10);padding-bottom:var(--wp--preset--spacing--10)">' . __('One column layout', 'mailpoet') . '</h2>
<!-- /wp:heading -->
<!-- wp:image {"width":"620px","sizeSlug":"large"} -->
<figure class="wp-block-image"><img src="' . esc_url($this->cdnAssetUrl->generateCdnUrl("newsletter/congratulation-page-illustration-transparent-LQ.20181121-1440.png")) . '" alt="Banner Image"/></figure>
<!-- /wp:image -->
<!-- wp:paragraph -->
<p>' . esc_html__('A one-column layout is great for simplified and concise content, like announcements or newsletters with brief updates. Drag blocks to add content and customize your styles from the styles panel on the top right.', 'mailpoet') . '</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size">' . esc_html__('You received this email because you are subscribed to the [site:title]', 'mailpoet') . '</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size"><a href="[link:subscription_unsubscribe_url]">' . esc_html__('Unsubscribe', 'mailpoet') . '</a> | <a href="[link:subscription_manage_url]">' . esc_html__('Manage subscription', 'mailpoet') . '</a></p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
';
}
protected function getTitle(): string {
return __('Default Email Content', 'mailpoet');
}
}

View File

@ -0,0 +1,39 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Patterns;
use MailPoet\Util\CdnAssetUrl;
class Patterns {
private $namespace = 'mailpoet';
protected $cdnAssetUrl;
public function __construct(
CdnAssetUrl $cdnAssetUrl
) {
$this->cdnAssetUrl = $cdnAssetUrl;
}
public function initialize(): void {
$this->registerBlockPatternCategory();
$this->registerPatterns();
}
private function registerBlockPatternCategory() {
register_block_pattern_category(
'mailpoet',
[
'label' => _x('MailPoet', 'Block pattern category', 'mailpoet'),
'description' => __('A collection of email template layouts.', 'mailpoet'),
]
);
}
private function registerPatterns() {
$this->registerPattern('default', new Library\DefaultContent($this->cdnAssetUrl));
}
private function registerPattern($name, $pattern) {
register_block_pattern($this->namespace . '/' . $name, $pattern->getProperties());
}
}

View File

@ -0,0 +1,9 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer;
use MailPoet\EmailEditor\Engine\SettingsController;
interface BlockRenderer {
public function render(string $blockContent, array $parsedBlock, SettingsController $settingsController): string;
}

View File

@ -0,0 +1,19 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer;
use WP_Block_Parser;
class BlocksParser extends WP_Block_Parser {
/**
* List of parsed blocks
*
* @var \WP_Block_Parser_Block[]
*/
public $output;
public function parse($document) {
parent::parse($document);
return apply_filters('mailpoet_blocks_renderer_parsed_blocks', $this->output);
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer;
class BlocksRegistry {
/** @var BlockRenderer[] */
private $blockRenderersMap = [];
public function addBlockRenderer(string $blockName, BlockRenderer $renderer): void {
$this->blockRenderersMap[$blockName] = $renderer;
}
public function hasBlockRenderer(string $blockName): bool {
return isset($this->blockRenderersMap[$blockName]);
}
public function getBlockRenderer(string $blockName): ?BlockRenderer {
return $this->blockRenderersMap[$blockName] ?? null;
}
public function removeAllBlockRenderers(): void {
foreach (array_keys($this->blockRenderersMap) as $blockName) {
$this->removeBlockRenderer($blockName);
}
}
private function removeBlockRenderer(string $blockName): void {
unset($this->blockRenderersMap[$blockName]);
}
}

View File

@ -0,0 +1,144 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer;
use MailPoet\EmailEditor\Engine\SettingsController;
use MailPoet\EmailEditor\Engine\ThemeController;
use MailPoetVendor\Pelago\Emogrifier\CssInliner;
use WP_Block_Template;
use WP_Post;
class ContentRenderer {
private BlocksRegistry $blocksRegistry;
private ProcessManager $processManager;
private SettingsController $settingsController;
private ThemeController $themeController;
private $post = null;
private $template = null;
const CONTENT_STYLES_FILE = 'content.css';
public function __construct(
ProcessManager $preprocessManager,
BlocksRegistry $blocksRegistry,
SettingsController $settingsController,
ThemeController $themeController
) {
$this->processManager = $preprocessManager;
$this->blocksRegistry = $blocksRegistry;
$this->settingsController = $settingsController;
$this->themeController = $themeController;
}
private function initialize() {
add_filter('render_block', [$this, 'renderBlock'], 10, 2);
add_filter('block_parser_class', [$this, 'blockParser']);
add_filter('mailpoet_blocks_renderer_parsed_blocks', [$this, 'preprocessParsedBlocks']);
do_action('mailpoet_blocks_renderer_initialized', $this->blocksRegistry);
}
public function render(WP_Post $post, WP_Block_Template $template): string {
$this->post = $post;
$this->template = $template;
$this->setTemplateGlobals($post, $template);
$this->initialize();
$renderedHtml = get_the_block_template_html();
$this->reset();
return $this->processManager->postprocess($this->inlineStyles($renderedHtml, $post, $template));
}
public function blockParser() {
return 'MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\BlocksParser';
}
public function preprocessParsedBlocks(array $parsedBlocks): array {
return $this->processManager->preprocess($parsedBlocks, $this->settingsController->getLayout(), $this->themeController->getStyles($this->post, $this->template));
}
public function renderBlock($blockContent, $parsedBlock) {
if (!$this->blocksRegistry->hasBlockRenderer($parsedBlock['blockName'])) {
return $blockContent;
}
$renderer = $this->blocksRegistry->getBlockRenderer($parsedBlock['blockName']);
return $renderer ? $renderer->render($blockContent, $parsedBlock, $this->settingsController) : $blockContent;
}
private function setTemplateGlobals(WP_Post $post, WP_Block_Template $template) {
global $_wp_current_template_content, $_wp_current_template_id;
$_wp_current_template_id = $template->id;
$_wp_current_template_content = $template->content;
$GLOBALS['post'] = $post;
}
/**
* As we use default WordPress filters, we need to remove them after email rendering
* so that we don't interfere with possible post rendering that might happen later.
*/
private function reset() {
$this->blocksRegistry->removeAllBlockRenderers();
remove_filter('render_block', [$this, 'renderBlock']);
remove_filter('block_parser_class', [$this, 'blockParser']);
remove_filter('mailpoet_blocks_renderer_parsed_blocks', [$this, 'preprocessParsedBlocks']);
}
/**
* @param string $html
* @return string
*/
private function inlineStyles($html, WP_Post $post, $template = null) {
$styles = (string)file_get_contents(dirname(__FILE__) . '/' . self::CONTENT_STYLES_FILE);
// Apply default contentWidth to constrained blocks.
$layout = $this->settingsController->getLayout();
$styles .= sprintf(
'
.is-layout-constrained > *:not(.alignleft):not(.alignright):not(.alignfull) {
max-width: %1$s;
margin-left: auto !important;
margin-right: auto !important;
}
.is-layout-constrained > .alignwide {
max-width: %2$s;
margin-left: auto !important;
margin-right: auto !important;
}
',
$layout['contentSize'],
$layout['wideSize']
);
// Get styles from theme.
$styles .= $this->themeController->getStylesheetForRendering($post, $template);
$blockSupportStyles = $this->themeController->getStylesheetFromContext('block-supports', []);
// Get styles from block-supports stylesheet. This includes rules such as layout (contentWidth) that some blocks use.
// @see https://github.com/WordPress/WordPress/blob/3c5da9c74344aaf5bf8097f2e2c6a1a781600e03/wp-includes/script-loader.php#L3134
// @internal :where is not supported by emogrifier, so we need to replace it with *.
$blockSupportStyles = str_replace(
':where(:not(.alignleft):not(.alignright):not(.alignfull))',
'*:not(.alignleft):not(.alignright):not(.alignfull)',
$blockSupportStyles
);
// Layout CSS assumes the top level block will have a single DIV wrapper with children. Since our blocks use tables,
// we need to adjust this to look for children in the TD element. This may requires more advanced replacement but
// this works in the current version of Gutenberg.
// Example rule we're targetting: .wp-container-core-group-is-layout-1.wp-container-core-group-is-layout-1 > *
$blockSupportStyles = preg_replace(
'/group-is-layout-(\d+) >/',
'group-is-layout-$1 > tbody tr td >',
$blockSupportStyles
);
$styles .= $blockSupportStyles;
// Debugging for content styles. Remember these get inlined.
//echo '<pre>';
//var_dump($styles);
//echo '</pre>';
$styles = '<style>' . wp_strip_all_tags((string)apply_filters('mailpoet_email_content_renderer_styles', $styles, $post)) . '</style>';
return CssInliner::fromHtml($styles . $html)->inlineCss()->render();
}
}

View File

@ -0,0 +1,92 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Layout;
use MailPoet\EmailEditor\Engine\SettingsController;
/**
* This class provides functionality to render inner blocks of a block that supports reduced flex layout.
*/
class FlexLayoutRenderer {
public function renderInnerBlocksInLayout(array $parsedBlock, SettingsController $settingsController): string {
$themeStyles = $settingsController->getEmailStyles();
$flexGap = $themeStyles['spacing']['blockGap'] ?? '0px';
$flexGapNumber = $settingsController->parseNumberFromStringWithPixels($flexGap);
$marginTop = $parsedBlock['email_attrs']['margin-top'] ?? '0px';
$justify = $parsedBlock['attrs']['layout']['justifyContent'] ?? 'left';
$styles = wp_style_engine_get_styles($parsedBlock['attrs']['style'] ?? [])['css'] ?? '';
$styles .= 'margin-top: ' . $marginTop . ';';
$styles .= 'text-align: ' . $justify;
// MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles
$outputHtml = sprintf(
'<!--[if mso | IE]><table align="%2$s" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%"><tr><td style="%1$s" ><![endif]-->
<div style="%1$s"><table class="layout-flex-wrapper" style="display:inline-block"><tbody><tr>',
esc_attr($styles),
esc_attr($justify)
);
$innerBlocks = $this->computeWidthsForFlexLayout($parsedBlock, $settingsController, $flexGapNumber);
foreach ($innerBlocks as $key => $block) {
$styles = [];
if ($block['email_attrs']['layout_width'] ?? null) {
$styles['width'] = $block['email_attrs']['layout_width'];
}
if ($key > 0) {
$styles['padding-left'] = $flexGap;
}
$outputHtml .= '<td class="layout-flex-item" style="' . esc_attr(\WP_Style_Engine::compile_css($styles, '')) . '">' . render_block($block) . '</td>';
}
$outputHtml .= '</tr></table></div>
<!--[if mso | IE]></td></tr></table><![endif]-->';
return $outputHtml;
}
private function computeWidthsForFlexLayout(array $parsedBlock, SettingsController $settingsController, float $flexGap): array {
$blocksCount = count($parsedBlock['innerBlocks']);
$totalUsedWidth = 0; // Total width assuming items without set width would consume proportional width
$parentWidth = $settingsController->parseNumberFromStringWithPixels($parsedBlock['email_attrs']['width'] ?? SettingsController::EMAIL_WIDTH);
$innerBlocks = $parsedBlock['innerBlocks'] ?? [];
foreach ($innerBlocks as $key => $block) {
$blockWidthPercent = ($block['attrs']['width'] ?? 0) ? intval($block['attrs']['width']) : 0;
$blockWidth = floor($parentWidth * ($blockWidthPercent / 100));
// If width is not set, we assume it's 25% of the parent width
$totalUsedWidth += $blockWidth ?: floor($parentWidth * (25 / 100));
if (!$blockWidth) {
$innerBlocks[$key]['email_attrs']['layout_width'] = null; // Will be rendered as auto
continue;
}
$innerBlocks[$key]['email_attrs']['layout_width'] = $this->getWidthWithoutGap($blockWidth, $flexGap, $blockWidthPercent) . 'px';
}
// When there is only one block, or percentage is set reasonably we don't need to adjust and just render as set by user
if ($blocksCount <= 1 || ($totalUsedWidth <= $parentWidth)) {
return $innerBlocks;
}
foreach ($innerBlocks as $key => $block) {
$proportionalSpaceOverflow = $parentWidth / $totalUsedWidth;
$blockWidth = $block['email_attrs']['layout_width'] ? $settingsController->parseNumberFromStringWithPixels($block['email_attrs']['layout_width']) : 0;
$blockProportionalWidth = $blockWidth * $proportionalSpaceOverflow;
$blockProportionalPercentage = ($blockProportionalWidth / $parentWidth) * 100;
$innerBlocks[$key]['email_attrs']['layout_width'] = $blockWidth ? $this->getWidthWithoutGap($blockProportionalWidth, $flexGap, $blockProportionalPercentage) . 'px' : null;
}
return $innerBlocks;
}
/**
* How much of width we will strip to keep some space for the gap
* This is computed based on CSS rule used in the editor:
* For block with width set to X percent
* width: calc(X% - (var(--wp--style--block-gap) * (100 - X)/100)));
*/
private function getWidthWithoutGap(float $blockWidth, float $flexGap, float $blockWidthPercent): int {
$widthGapReduction = $flexGap * ((100 - $blockWidthPercent) / 100);
return intval(floor($blockWidth - $widthGapReduction));
}
}

View File

@ -0,0 +1,16 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
/**
* This postprocessor replaces <mark> tags with <span> tags because mark tags are not supported across all email clients
*/
class HighlightingPostprocessor implements Postprocessor {
public function postprocess(string $html): string {
return str_replace(
['<mark', '</mark>'],
['<span', '</span>'],
$html
);
}
}

View File

@ -0,0 +1,7 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
interface Postprocessor {
public function postprocess(string $html): string;
}

View File

@ -0,0 +1,46 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
use MailPoet\EmailEditor\Engine\ThemeController;
/**
* In some case the blocks HTML contains CSS variables.
* For example when spacing is set from a preset the inline styles contain var(--wp--preset--spacing--10), var(--wp--preset--spacing--20) etc.
* This postprocessor uses variables from theme.json and replaces the CSS variables with their values in final email HTML.
*/
class VariablesPostprocessor implements Postprocessor {
private ThemeController $themeController;
public function __construct(
ThemeController $themeController
) {
$this->themeController = $themeController;
}
public function postprocess(string $html): string {
$variables = $this->themeController->getVariablesValuesMap();
$replacements = [];
foreach ($variables as $varName => $varValue) {
$varPattern = '/' . preg_quote('var(' . $varName . ')', '/') . '/i';
$replacements[$varPattern] = $varValue;
}
// Pattern to match style attributes and their values.
$callback = function ($matches) use ($replacements) {
// For each match, replace CSS variables with their values
$style = $matches[1];
$style = preg_replace(array_keys($replacements), array_values($replacements), $style);
return 'style="' . esc_attr($style) . '"';
};
// We want to replace the CSS variables only in the style attributes to avoid replacing the actual content.
$stylePattern = '/style="(.*?)"/i';
$stylePatternAlt = "/style='(.*?)'/i";
$html = (string)preg_replace_callback($stylePattern, $callback, $html);
$html = (string)preg_replace_callback($stylePatternAlt, $callback, $html);
return $html;
}
}

View File

@ -0,0 +1,104 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* This class sets the width of the blocks based on the layout width or column count.
* The final width in pixels is stored in the email_attrs array because we would like to avoid changing the original attributes.
*/
class BlocksWidthPreprocessor implements Preprocessor {
public function preprocess(array $parsedBlocks, array $layout, array $styles): array {
foreach ($parsedBlocks as $key => $block) {
// Layout width is recalculated for each block because full-width blocks don't exclude padding
$layoutWidth = $this->parseNumberFromStringWithPixels($layout['contentSize']);
$alignment = $block['attrs']['align'] ?? null;
// Subtract padding from the block width if it's not full-width
if ($alignment !== 'full') {
$layoutWidth -= $this->parseNumberFromStringWithPixels($styles['spacing']['padding']['left'] ?? '0px');
$layoutWidth -= $this->parseNumberFromStringWithPixels($styles['spacing']['padding']['right'] ?? '0px');
}
$widthInput = $block['attrs']['width'] ?? '100%';
// Currently we support only % and px units in case only the number is provided we assume it's %
// because editor saves percent values as a number.
$widthInput = is_numeric($widthInput) ? "$widthInput%" : $widthInput;
$width = $this->convertWidthToPixels($widthInput, $layoutWidth);
if ($block['blockName'] === 'core/columns') {
// Calculate width of the columns based on the layout width and padding
$columnsWidth = $layoutWidth;
$columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['left'] ?? '0px');
$columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['right'] ?? '0px');
$borderWidth = $block['attrs']['style']['border']['width'] ?? '0px';
$columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['border']['left']['width'] ?? $borderWidth);
$columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['border']['right']['width'] ?? $borderWidth);
$block['innerBlocks'] = $this->addMissingColumnWidths($block['innerBlocks'], $columnsWidth);
}
// Copy layout styles and update width and padding
$modifiedLayout = $layout;
$modifiedLayout['contentSize'] = "{$width}px";
$modifiedStyles = $styles;
$modifiedStyles['spacing']['padding']['left'] = $block['attrs']['style']['spacing']['padding']['left'] ?? '0px';
$modifiedStyles['spacing']['padding']['right'] = $block['attrs']['style']['spacing']['padding']['right'] ?? '0px';
$block['email_attrs']['width'] = "{$width}px";
$block['innerBlocks'] = $this->preprocess($block['innerBlocks'], $modifiedLayout, $modifiedStyles);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
// TODO: We could add support for other units like em, rem, etc.
private function convertWidthToPixels(string $currentWidth, float $layoutWidth): float {
$width = $layoutWidth;
if (strpos($currentWidth, '%') !== false) {
$width = (float)str_replace('%', '', $currentWidth);
$width = round($width / 100 * $layoutWidth);
} elseif (strpos($currentWidth, 'px') !== false) {
$width = $this->parseNumberFromStringWithPixels($currentWidth);
}
return $width;
}
private function parseNumberFromStringWithPixels(string $string): float {
return (float)str_replace('px', '', $string);
}
private function addMissingColumnWidths(array $columns, float $columnsWidth): array {
$columnsCountWithDefinedWidth = 0;
$definedColumnWidth = 0;
$columnsCount = count($columns);
foreach ($columns as $column) {
if (isset($column['attrs']['width']) && !empty($column['attrs']['width'])) {
$columnsCountWithDefinedWidth++;
$definedColumnWidth += $this->convertWidthToPixels($column['attrs']['width'], $columnsWidth);
} else {
// When width is not set we need to add padding to the defined column width for better ratio accuracy
$definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px');
$definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px');
$borderWidth = $column['attrs']['style']['border']['width'] ?? '0px';
$definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['border']['left']['width'] ?? $borderWidth);
$definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['border']['right']['width'] ?? $borderWidth);
}
}
if ($columnsCount - $columnsCountWithDefinedWidth > 0) {
$defaultColumnsWidth = round(($columnsWidth - $definedColumnWidth) / ($columnsCount - $columnsCountWithDefinedWidth), 2);
foreach ($columns as $key => $column) {
if (!isset($column['attrs']['width']) || empty($column['attrs']['width'])) {
// Add padding to the specific column width because it's not included in the default width
$columnWidth = $defaultColumnsWidth;
$columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px');
$columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px');
$borderWidth = $column['attrs']['style']['border']['width'] ?? '0px';
$columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['border']['left']['width'] ?? $borderWidth);
$columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['border']['right']['width'] ?? $borderWidth);
$columns[$key]['attrs']['width'] = "{$columnWidth}px";
}
}
}
return $columns;
}
}

View File

@ -0,0 +1,17 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
class CleanupPreprocessor implements Preprocessor {
public function preprocess(array $parsedBlocks, array $layout, array $styles): array {
foreach ($parsedBlocks as $key => $block) {
// https://core.trac.wordpress.org/ticket/45312
// \WP_Block_Parser::parse_blocks() sometimes add a block with name null that can cause unexpected spaces in rendered content
// This behavior was reported as an issue, but it was closed as won't fix
if ($block['blockName'] === null) {
unset($parsedBlocks[$key]);
}
}
return array_values($parsedBlocks);
}
}

View File

@ -0,0 +1,11 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
interface Preprocessor {
/**
* @param array{contentSize: string} $layout
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles
*/
public function preprocess(array $parsedBlocks, array $layout, array $styles): array;
}

View File

@ -0,0 +1,60 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* This preprocessor is responsible for setting default spacing values for blocks.
* In the early development phase, we are setting only margin-top for blocks that are not first or last in the columns block.
*/
class SpacingPreprocessor implements Preprocessor {
public function preprocess(array $parsedBlocks, array $layout, array $styles): array {
$parsedBlocks = $this->addBlockGaps($parsedBlocks, $styles['spacing']['blockGap'] ?? '', null);
$parsedBlocks = $this->addBlockPadding(
$parsedBlocks,
$styles['spacing']['padding']['left'] ?? '',
$styles['spacing']['padding']['right'] ?? '',
);
return $parsedBlocks;
}
private function addBlockGaps(array $parsedBlocks, string $gap = '', $parentBlock = null): array {
foreach ($parsedBlocks as $key => $block) {
$parentBlockName = $parentBlock['blockName'] ?? '';
// Do not add a gap to the first child, or if the parent block is a buttons block (where buttons are side by side).
if ($key !== 0 && $gap && $parentBlockName !== 'core/buttons') {
$block['email_attrs']['margin-top'] = $gap;
}
$block['innerBlocks'] = $this->addBlockGaps($block['innerBlocks'] ?? [], $gap, $block);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
/**
* Add padding to child blocks. Blocks that are not aligned full width will use this value.
*/
private function addBlockPadding(array $parsedBlocks, string $parentPaddingLeft, string $parentPaddingRight): array {
foreach ($parsedBlocks as $key => $block) {
$align = $block['attrs']['align'] ?? '';
if ($align !== 'full') {
$block['email_attrs']['padding-left'] = $parentPaddingLeft;
$block['email_attrs']['padding-right'] = $parentPaddingRight;
}
$block['innerBlocks'] = $this->addBlockPadding(
$block['innerBlocks'] ?? [],
$block['attrs']['style']['spacing']['padding']['padding-left'] ?? '',
$block['attrs']['style']['spacing']['padding']['padding-right'] ?? ''
);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
}

View File

@ -0,0 +1,86 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
use MailPoet\EmailEditor\Engine\SettingsController;
class TypographyPreprocessor implements Preprocessor {
/**
* List of styles that should be copied from parent to children.
* @var string[]
*/
private const TYPOGRAPHY_STYLES = [
'color',
'font-size',
'text-decoration',
];
/** @var SettingsController */
private $settingsController;
public function __construct(
SettingsController $settingsController
) {
$this->settingsController = $settingsController;
}
public function preprocess(array $parsedBlocks, array $layout, array $styles): array {
foreach ($parsedBlocks as $key => $block) {
$block = $this->preprocessParent($block);
// Set defaults from theme - this needs to be done on top level blocks only
$block = $this->setDefaultsFromTheme($block);
$block['innerBlocks'] = $this->copyTypographyFromParent($block['innerBlocks'], $block);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
private function copyTypographyFromParent(array $children, array $parent): array {
foreach ($children as $key => $child) {
$child = $this->preprocessParent($child);
$child['email_attrs'] = array_merge($this->filterStyles($parent['email_attrs']), $child['email_attrs']);
$child['innerBlocks'] = $this->copyTypographyFromParent($child['innerBlocks'] ?? [], $child);
$children[$key] = $child;
}
return $children;
}
private function preprocessParent(array $block): array {
// Build styles that should be copied to children
$emailAttrs = [];
if (isset($block['attrs']['style']['color']['text'])) {
$emailAttrs['color'] = $block['attrs']['style']['color']['text'];
}
// In case the fontSize is set via a slug (small, medium, large, etc.) we translate it to a number
// The font size slug is set in $block['attrs']['fontSize'] and value in $block['attrs']['style']['typography']['fontSize']
if (isset($block['attrs']['fontSize'])) {
$block['attrs']['style']['typography']['fontSize'] = $this->settingsController->translateSlugToFontSize($block['attrs']['fontSize']);
}
// Pass font size to email_attrs
if (isset($block['attrs']['style']['typography']['fontSize'])) {
$emailAttrs['font-size'] = $block['attrs']['style']['typography']['fontSize'];
}
if (isset($block['attrs']['style']['typography']['textDecoration'])) {
$emailAttrs['text-decoration'] = $block['attrs']['style']['typography']['textDecoration'];
}
$block['email_attrs'] = array_merge($emailAttrs, $block['email_attrs'] ?? []);
return $block;
}
private function filterStyles(array $styles): array {
return array_intersect_key($styles, array_flip(self::TYPOGRAPHY_STYLES));
}
private function setDefaultsFromTheme(array $block): array {
$themeData = $this->settingsController->getTheme()->get_data();
if (!($block['email_attrs']['color'] ?? '')) {
$block['email_attrs']['color'] = $themeData['styles']['color']['text'] ?? null;
}
if (!($block['email_attrs']['font-size'] ?? '')) {
$block['email_attrs']['font-size'] = $themeData['styles']['typography']['fontSize'];
}
return $block;
}
}

View File

@ -0,0 +1,64 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer\ContentRenderer;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\HighlightingPostprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\Postprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\VariablesPostprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\BlocksWidthPreprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\CleanupPreprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Preprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\SpacingPreprocessor;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\TypographyPreprocessor;
class ProcessManager {
/** @var Preprocessor[] */
private $preprocessors = [];
/** @var Postprocessor[] */
private $postprocessors = [];
public function __construct(
CleanupPreprocessor $cleanupPreprocessor,
BlocksWidthPreprocessor $blocksWidthPreprocessor,
TypographyPreprocessor $typographyPreprocessor,
SpacingPreprocessor $spacingPreprocessor,
HighlightingPostprocessor $highlightingPostprocessor,
VariablesPostprocessor $variablesPostprocessor
) {
$this->registerPreprocessor($cleanupPreprocessor);
$this->registerPreprocessor($blocksWidthPreprocessor);
$this->registerPreprocessor($typographyPreprocessor);
$this->registerPreprocessor($spacingPreprocessor);
$this->registerPostprocessor($highlightingPostprocessor);
$this->registerPostprocessor($variablesPostprocessor);
}
/**
* @param array $parsedBlocks
* @param array{contentSize: string} $layout
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles
* @return array
*/
public function preprocess(array $parsedBlocks, array $layout, array $styles): array {
foreach ($this->preprocessors as $preprocessor) {
$parsedBlocks = $preprocessor->preprocess($parsedBlocks, $layout, $styles);
}
return $parsedBlocks;
}
public function postprocess(string $html): string {
foreach ($this->postprocessors as $postprocessor) {
$html = $postprocessor->postprocess($html);
}
return $html;
}
public function registerPreprocessor(Preprocessor $preprocessor): void {
$this->preprocessors[] = $preprocessor;
}
public function registerPostprocessor(Postprocessor $postprocessor): void {
$this->postprocessors[] = $postprocessor;
}
}

View File

@ -0,0 +1,55 @@
/**
CSS reset for email clients for elements used in email content
StyleLint is disabled because some rules contain properties that linter marks as unknown (e.g. mso- prefix), but they are valid for email rendering
*/
/* stylelint-disable property-no-unknown */
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
img {
border: 0;
height: auto;
-ms-interpolation-mode: bicubic;
line-height: 100%;
max-width: 100%;
outline: none;
text-decoration: none;
}
p {
display: block;
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0;
margin-top: 0;
}
/* Ensure border style is set when a block has a border */
.has-border-color {
border-style: solid;
}
/* We want ensure the same design for all email clients */
ul,
ol {
/* When margin attribute is set to zero, Outlook doesn't render the list properly. As a possible workaround, we can reset only margin for top and bottom */
margin-bottom: 0;
margin-top: 0;
padding: 0 0 0 40px;
}
/* Outlook was adding weird spaces around lists in some versions. Resetting vertical margin for list items solved it */
li {
margin-bottom: 0;
margin-top: 0;
}

View File

@ -0,0 +1,100 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Renderer;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\ContentRenderer;
use MailPoet\EmailEditor\Engine\Templates\Templates;
use MailPoet\EmailEditor\Engine\ThemeController;
use MailPoetVendor\Html2Text\Html2Text;
use MailPoetVendor\Pelago\Emogrifier\CssInliner;
use WP_Style_Engine;
use WP_Theme_JSON;
class Renderer {
private ThemeController $themeController;
private ContentRenderer $contentRenderer;
private Templates $templates;
/** @var WP_Theme_JSON|null */
private static $theme = null;
const TEMPLATE_FILE = 'template-canvas.php';
const TEMPLATE_STYLES_FILE = 'template-canvas.css';
public function __construct(
ContentRenderer $contentRenderer,
Templates $templates,
ThemeController $themeController
) {
$this->contentRenderer = $contentRenderer;
$this->templates = $templates;
$this->themeController = $themeController;
}
/**
* During rendering, this stores the theme data for the template being rendered.
*/
public static function getTheme() {
return self::$theme;
}
public function render(\WP_Post $post, string $subject, string $preHeader, string $language, $metaRobots = ''): array {
$templateId = 'mailpoet/mailpoet//' . (get_page_template_slug($post) ?: 'email-general');
$template = $this->templates->getBlockTemplate($templateId);
$theme = $this->templates->getBlockTemplateTheme($templateId, $template->wp_id); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
// Set the theme for the template. This is merged with base theme.json and core json before rendering.
self::$theme = new WP_Theme_JSON($theme, 'default');
$emailStyles = $this->themeController->getStyles($post, $template, true);
$templateHtml = $this->contentRenderer->render($post, $template);
ob_start();
include self::TEMPLATE_FILE;
$renderedTemplate = (string)ob_get_clean();
$templateStyles =
WP_Style_Engine::compile_css(
[
'background-color' => $emailStyles['color']['background'] ?? 'inherit',
'color' => $emailStyles['color']['text'] ?? 'inherit',
'padding-top' => $emailStyles['spacing']['padding']['top'] ?? '0px',
'padding-bottom' => $emailStyles['spacing']['padding']['bottom'] ?? '0px',
'font-family' => $emailStyles['typography']['fontFamily'] ?? 'inherit',
'line-height' => $emailStyles['typography']['lineHeight'] ?? '1.5',
'font-size' => $emailStyles['typography']['fontSize'] ?? 'inherit',
],
'body, .email_layout_wrapper'
);
$templateStyles .= file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_STYLES_FILE);
$templateStyles = '<style>' . wp_strip_all_tags((string)apply_filters('mailpoet_email_renderer_styles', $templateStyles, $post)) . '</style>';
$renderedTemplate = $this->inlineCSSStyles($templateStyles . $renderedTemplate);
// This is a workaround to support link :hover in some clients. Ideally we would remove the ability to set :hover
// however this is not possible using the color panel from Gutenberg.
if (isset($emailStyles['elements']['link'][':hover']['color']['text'])) {
$renderedTemplate = str_replace('<!-- Forced Styles -->', '<style>a:hover { color: ' . esc_attr($emailStyles['elements']['link'][':hover']['color']['text']) . ' !important; }</style>', $renderedTemplate);
}
return [
'html' => $renderedTemplate,
'text' => $this->renderTextVersion($renderedTemplate),
];
}
/**
* @param string $template
* @return string
*/
private function inlineCSSStyles($template) {
return CssInliner::fromHtml($template)->inlineCss()->render();
}
/**
* @param string $template
* @return string
*/
private function renderTextVersion($template) {
$template = (mb_detect_encoding($template, 'UTF-8', true)) ? $template : mb_convert_encoding($template, 'UTF-8', mb_list_encodings());
return @Html2Text::convert($template);
}
}

View File

@ -0,0 +1,53 @@
# MailPoet Email Renderer
The renderer is WIP and so is the API for adding support email rendering for new blocks.
## Adding support for a core block
1. Add block into `ALLOWED_BLOCK_TYPES` in `mailpoet/lib/EmailEditor/Engine/Renderer/SettingsController.php`.
2. Make sure the block is registered in the editor. Currently all core blocks are registered in the editor.
3. Add BlockRender class (e.g. Heading) into `mailpoet/lib/EmailEditor/Integration/Core/Renderer/Blocks` folder. <br />
```php
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\BlockRenderer;use MailPoet\EmailEditor\Engine\SettingsController;
class Heading implements BlockRenderer {
public function render($blockContent, array $parsedBlock, SettingsController $settingsController): string {
return 'HEADING_BLOCK'; // here comes your rendering logic;
}
}
```
4. Register the renderer
```php
<?php
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\BlocksRegistry;
add_action('mailpoet_blocks_renderer_initialized', 'register_my_block_email_renderer');
function register_my_block_email_renderer(BlocksRegistry $blocksRegistry): void {
$blocksRegistry->addBlockRenderer('core/heading', new Renderer\Blocks\Heading());
}
```
Note: For core blocks this is currently done in `MailPoet\EmailEditor\Integrations\Core\Initializer`.
5. Implement the rendering logic in the renderer class.
## Tips for adding support for block
- You can take inspiration on block rendering from MJML in the https://mjml.io/try-it-live
- Test the block in different clients [Litmus](https://litmus.com/)
- You can take some inspirations from the HTML renderer by the old email editor
## TODO
- add universal/fallback renderer for rendering blocks that are not covered by specialized renderers
- add support for all core blocks
- move the renderer to separate package

View File

@ -0,0 +1,82 @@
/* Base CSS rules to be applied to all emails */
/* Created based on original MailPoet template for rendering emails */
/* StyleLint is disabled because some rules contain properties that linter marks as unknown (e.g. mso- prefix), but they are valid for email rendering */
/* stylelint-disable property-no-unknown */
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
-ms-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
word-spacing: normal;
}
a {
text-decoration: none;
}
.email_layout_wrapper {
margin: 0 auto;
padding: 20px 0;
width: 100%;
}
.email_content_wrapper {
direction: ltr;
font-size: inherit;
text-align: left;
}
.email_footer {
direction: ltr;
text-align: center;
}
/* https://www.emailonacid.com/blog/article/email-development/tips-for-coding-email-preheaders */
.email_preheader,
.email_preheader * {
color: #fff;
display: none;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
mso-hide: all;
opacity: 0;
overflow: hidden;
-webkit-text-size-adjust: none;
visibility: hidden;
}
@media screen and (max-width: 660px) {
.email-block-column-content {
max-width: 100% !important;
}
.block {
display: block;
width: 100% !important;
}
/* Flex Layout */
.layout-flex-wrapper,
.layout-flex-wrapper tbody,
.layout-flex-wrapper tr {
display: block !important;
width: 100% !important;
}
.layout-flex-item {
display: block !important;
padding-bottom: 8px !important; /* Half of the flex gap between blocks */
padding-left: 0 !important;
width: 100% !important;
}
.layout-flex-item table,
.layout-flex-item td {
box-sizing: border-box !important;
display: block !important;
width: 100% !important;
}
/* Flex Layout End */
}
/* stylelint-enable property-no-unknown */

View File

@ -0,0 +1,38 @@
<?php declare(strict_types = 1);
// phpcs:disable Generic.Files.InlineHTML.Found
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
/**
* Template file to render the current 'wp_template', specifcally for emails.
*/
?><!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<title><?php echo esc_html($subject); ?></title>
<meta charset="<?php bloginfo('charset'); ?>" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="telephone=no" />
<?php echo $metaRobots; // HTML defined by MailPoet--do not escape ?>
<!-- Forced Styles -->
</head>
<body>
<div class="email_layout_wrapper">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td class="email_preheader" height="1">
<?php echo esc_html(wp_strip_all_tags($preHeader)); ?>
</td>
</tr>
<tr>
<td class="email_content_wrapper">
<?php echo $templateHtml; ?>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,146 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine;
class SettingsController {
const ALLOWED_BLOCK_TYPES = [
'core/button',
'core/buttons',
'core/paragraph',
'core/heading',
'core/column',
'core/columns',
'core/image',
'core/list',
'core/list-item',
'core/group',
];
const DEFAULT_SETTINGS = [
'enableCustomUnits' => ['px', '%'],
];
/**
* Width of the email in pixels.
* @var string
*/
const EMAIL_WIDTH = '660px';
private ThemeController $themeController;
private array $iframeAssets = [];
/**
* @param ThemeController $themeController
*/
public function __construct(
ThemeController $themeController
) {
$this->themeController = $themeController;
}
public function init() {
// We need to initialize these assets early because they are read from global variables $wp_styles and $wp_scripts
// and in later WordPress page load pages they contain stuff we don't want (e.g. html for admin login popup)
// in the post editor this is called directly in post.php
$this->iframeAssets = _wp_get_iframed_editor_assets();
}
public function getSettings(): array {
$coreDefaultSettings = \get_default_block_editor_settings();
$themeSettings = $this->themeController->getSettings();
$settings = array_merge($coreDefaultSettings, self::DEFAULT_SETTINGS);
$settings['allowedBlockTypes'] = self::ALLOWED_BLOCK_TYPES;
// Assets for iframe editor (component styles, scripts, etc.)
$settings['__unstableResolvedAssets'] = $this->iframeAssets;
// Custom editor content styles
// body selector is later transformed to .editor-styles-wrapper
// setting padding for bottom and top is needed because \WP_Theme_JSON::get_stylesheet() set them only for .wp-site-blocks selector
$contentVariables = 'body {';
$contentVariables .= 'padding-bottom: var(--wp--style--root--padding-bottom);';
$contentVariables .= 'padding-top: var(--wp--style--root--padding-top);';
$contentVariables .= '}';
$flexEmailLayoutStyles = file_get_contents(__DIR__ . '/flex-email-layout.css');
$settings['styles'] = [
['css' => $contentVariables],
['css' => $flexEmailLayoutStyles],
];
$settings['__experimentalFeatures'] = $themeSettings;
// Enabling alignWide allows full width for specific blocks such as columns, heading, image, etc.
$settings['alignWide'] = true;
return $settings;
}
/**
* @return array{contentSize: string, wideSize: string, layout: string}
*/
public function getLayout(): array {
$themeSettings = $this->themeController->getSettings();
return [
'contentSize' => $themeSettings['layout']['contentSize'],
'wideSize' => $themeSettings['layout']['wideSize'],
'layout' => 'constrained',
];
}
/**
* @return array{
* spacing: array{
* blockGap: string,
* padding: array{bottom: string, left: string, right: string, top: string}
* },
* color: array{
* background: string
* },
* typography: array{
* fontFamily: string
* }
* }
*/
public function getEmailStyles(): array {
$theme = $this->getTheme();
return $theme->get_data()['styles'];
}
public function getLayoutWidthWithoutPadding(): string {
$styles = $this->getEmailStyles();
$layout = $this->getLayout();
$width = $this->parseNumberFromStringWithPixels($layout['contentSize']);
$width -= $this->parseNumberFromStringWithPixels($styles['spacing']['padding']['left']);
$width -= $this->parseNumberFromStringWithPixels($styles['spacing']['padding']['right']);
return "{$width}px";
}
public function parseStylesToArray(string $styles): array {
$styles = explode(';', $styles);
$parsedStyles = [];
foreach ($styles as $style) {
$style = explode(':', $style);
if (count($style) === 2) {
$parsedStyles[trim($style[0])] = trim($style[1]);
}
}
return $parsedStyles;
}
public function parseNumberFromStringWithPixels(string $string): float {
return (float)str_replace('px', '', $string);
}
public function getTheme(): \WP_Theme_JSON {
return $this->themeController->getTheme();
}
public function translateSlugToFontSize(string $fontSize): string {
return $this->themeController->translateSlugToFontSize($fontSize);
}
public function translateSlugToColor(string $colorSlug): string {
return $this->themeController->translateSlugToColor($colorSlug);
}
}

View File

@ -0,0 +1,46 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Templates;
use MailPoet\EmailEditor\Engine\ThemeController;
use MailPoet\Validator\Builder;
use WP_Theme_JSON;
class TemplatePreview {
private ThemeController $themeController;
private Templates $templates;
public function __construct(
ThemeController $themeController,
Templates $templates
) {
$this->themeController = $themeController;
$this->templates = $templates;
}
public function initialize(): void {
register_rest_field(
'wp_template',
'email_theme_css',
[
'get_callback' => [$this, 'getEmailThemePreviewCss'],
'update_callback' => null,
'schema' => Builder::string()->toArray(),
]
);
}
/**
* Generates CSS for preview of email theme
* They are applied in the preview BLockPreview in template selection
*/
public function getEmailThemePreviewCss($template): string {
$editorTheme = clone $this->themeController->getTheme();
$templateTheme = $this->templates->getBlockTemplateTheme($template['id'], $template['wp_id']);
if (is_array($templateTheme)) {
$editorTheme->merge(new WP_Theme_JSON($templateTheme, 'custom'));
}
$additionalCSS = file_get_contents(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'preview.css');
return $editorTheme->get_stylesheet() . $additionalCSS;
}
}

View File

@ -0,0 +1,303 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Templates;
use MailPoet\EmailEditor\Engine\EmailStylesSchema;
use WP_Block_Template;
// phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
class Templates {
const MAILPOET_EMAIL_META_THEME_TYPE = 'mailpoet_email_theme';
const MAILPOET_TEMPLATE_EMPTY_THEME = ['version' => 2]; // The version 2 is important to merge themes correctly
private Utils $utils;
private string $pluginSlug = 'mailpoet/mailpoet';
private string $postType = 'mailpoet_email';
private string $templateDirectory;
private array $templates = [];
private array $themeJson = [];
public function __construct(
Utils $utils
) {
$this->utils = $utils;
$this->templateDirectory = dirname(__FILE__) . DIRECTORY_SEPARATOR;
}
public function initialize(): void {
add_filter('pre_get_block_file_template', [$this, 'getBlockFileTemplate'], 10, 3);
add_filter('get_block_templates', [$this, 'addBlockTemplates'], 10, 3);
add_filter('theme_templates', [$this, 'addThemeTemplates'], 10, 4); // Needed when saving post template association
add_filter('get_block_template', [$this, 'addBlockTemplateDetails'], 10, 1);
add_filter('rest_pre_insert_wp_template', [$this, 'forcePostContent'], 9, 1);
$this->initializeTemplates();
$this->initializeApi();
}
/**
* Get a block template by ID.
*/
public function getBlockTemplate($templateId) {
$templates = $this->getBlockTemplates();
return $templates[$templateId] ?? null;
}
/**
* Get a predefined or user defined theme for a block template.
*
* @param string $templateId
* @param int|null $templateWpId
* @return array
*/
public function getBlockTemplateTheme($templateId, $templateWpId = null) {
// First check if there is a user updated theme saved
$theme = $this->getCustomTemplateTheme($templateWpId);
if ($theme) {
return $theme;
}
// If there is no user edited theme, look for default template themes in files.
['prefix' => $templatePrefix, 'slug' => $templateSlug] = $this->utils->getTemplateIdParts($templateId);
if ($this->pluginSlug !== $templatePrefix) {
return self::MAILPOET_TEMPLATE_EMPTY_THEME;
}
if (!isset($this->themeJson[$templateSlug])) {
$jsonFile = $this->templateDirectory . $templateSlug . '.json';
if (file_exists($jsonFile)) {
$this->themeJson[$templateSlug] = json_decode((string)file_get_contents($jsonFile), true);
}
}
return $this->themeJson[$templateSlug] ?? self::MAILPOET_TEMPLATE_EMPTY_THEME;
}
public function getBlockFileTemplate($return, $templateId, $template_type) {
['prefix' => $templatePrefix, 'slug' => $templateSlug] = $this->utils->getTemplateIdParts($templateId);
if ($this->pluginSlug !== $templatePrefix) {
return $return;
}
$templatePath = $templateSlug . '.html';
if (!is_readable($this->templateDirectory . $templatePath)) {
return $return;
}
return $this->getBlockTemplateFromFile($templatePath);
}
public function addBlockTemplates($query_result, $query, $template_type) {
if ('wp_template' !== $template_type) {
return $query_result;
}
$post_type = isset($query['post_type']) ? $query['post_type'] : '';
if ($post_type && $post_type !== $this->postType) {
return $query_result;
}
foreach ($this->getBlockTemplates() as $blockTemplate) {
$fits_slug_query = !isset($query['slug__in']) || in_array($blockTemplate->slug, $query['slug__in'], true);
$fits_area_query = !isset($query['area']) || ( property_exists($blockTemplate, 'area') && $blockTemplate->area === $query['area'] );
$should_include = $fits_slug_query && $fits_area_query;
if ($should_include) {
$query_result[] = $blockTemplate;
}
}
return $query_result;
}
public function addThemeTemplates($templates, $theme, $post, $post_type) {
if ($post_type && $post_type !== $this->postType) {
return $templates;
}
foreach ($this->getBlockTemplates() as $blockTemplate) {
$templates[$blockTemplate->slug] = $blockTemplate;
}
return $templates;
}
/**
* This is a workaround to ensure the post object passed to `inject_ignored_hooked_blocks_metadata_attributes` contains
* content to prevent the template being empty when saved. The issue currently occurs when WooCommerce enables block hooks,
* and when older versions of `inject_ignored_hooked_blocks_metadata_attributes` are
* used (before https://github.com/WordPress/WordPress/commit/725f302121c84c648c38789b2e88dbd1eb41fa48).
* This can be removed in the future.
*
* To test the issue create a new email, revert template changes, save a color change, then save a color change again.
* When you refresh if the post is blank, the issue is present.
*
* @param \stdClass $changes
*/
public function forcePostContent($changes) {
if (empty($changes->post_content) && !empty($changes->ID)) {
// Find the existing post object.
$post = get_post($changes->ID);
if ($post && !empty($post->post_content)) {
$changes->post_content = $post->post_content;
}
}
return $changes;
}
/**
* Add details to templates in editor.
*
* @param WP_Block_Template $block_template Block template object.
* @return WP_Block_Template
*/
public function addBlockTemplateDetails($block_template) {
if (!$block_template || !isset($this->templates[$block_template->slug])) {
return $block_template;
}
if (empty($block_template->title)) {
$block_template->title = $this->templates[$block_template->slug]['title'];
}
if (empty($block_template->description)) {
$block_template->description = $this->templates[$block_template->slug]['description'];
}
return $block_template;
}
/**
* Initialize template details. This is done at runtime because of localisation.
*/
private function initializeTemplates(): void {
$this->templates['email-general'] = [
'title' => __('General Email', 'mailpoet'),
'description' => __('A general template for emails.', 'mailpoet'),
];
$this->templates['awesome-one'] = [
'title' => __('Awesome Template One', 'mailpoet'),
'description' => __('A template used in testing.', 'mailpoet'),
];
$this->templates['awesome-two'] = [
'title' => __('Awesome Template Two', 'mailpoet'),
'description' => __('A template used in testing.', 'mailpoet'),
];
$this->templates['email-computing-mag'] = [
'title' => __('Retro Computing Mag', 'mailpoet'),
'description' => __('A retro themed template.', 'mailpoet'),
];
}
private function initializeApi(): void {
register_post_meta(
'wp_template',
self::MAILPOET_EMAIL_META_THEME_TYPE,
[
'show_in_rest' => [
'schema' => (new EmailStylesSchema())->getSchema(),
],
'single' => true,
'type' => 'object',
'default' => self::MAILPOET_TEMPLATE_EMPTY_THEME,
]
);
register_rest_field(
'wp_template',
self::MAILPOET_EMAIL_META_THEME_TYPE,
[
'get_callback' => function($object) {
return $this->getBlockTemplateTheme($object['id'], $object['wp_id']);
},
'update_callback' => function($value, $template) {
return update_post_meta($template->wp_id, self::MAILPOET_EMAIL_META_THEME_TYPE, $value);
},
'schema' => (new EmailStylesSchema())->getSchema(),
]
);
}
/**
* Gets block templates indexed by ID.
*/
private function getBlockTemplates() {
$blockTemplates = array_map(function($templateSlug) {
return $this->getBlockTemplateFromFile($templateSlug . '.html');
}, array_keys($this->templates));
$customTemplates = $this->getCustomTemplates(); // From the DB.
$customTemplateIds = wp_list_pluck($customTemplates, 'id');
// Combine to remove duplicates if a custom template has the same ID as a file template.
return array_column(
array_merge(
$customTemplates,
array_filter(
$blockTemplates,
function($blockTemplate) use ($customTemplateIds) {
return !in_array($blockTemplate->id, $customTemplateIds, true);
}
),
),
null,
'id'
);
}
private function getBlockTemplateFromFile(string $template) {
$template_slug = $this->utils->getBlockTemplateSlugFromPath($template);
$templateObject = (object)[
'slug' => $template_slug,
'id' => $this->pluginSlug . '//' . $template_slug,
'title' => $this->templates[$template_slug]['title'] ?? '',
'description' => $this->templates[$template_slug]['description'] ?? '',
'path' => $this->templateDirectory . $template,
'type' => 'wp_template',
'theme' => $this->pluginSlug,
'source' => 'plugin',
'post_types' => [
$this->postType,
],
];
return $this->utils->buildBlockTemplateFromFile($templateObject);
}
private function getCustomTemplates($slugs = [], $template_type = 'wp_template') {
$check_query_args = [
'post_type' => $template_type,
'posts_per_page' => -1,
'no_found_rows' => true,
'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
[
'taxonomy' => 'wp_theme',
'field' => 'name',
'terms' => [ $this->pluginSlug, get_stylesheet() ],
],
],
];
if (is_array($slugs) && count($slugs) > 0) {
$check_query_args['post_name__in'] = $slugs;
}
$check_query = new \WP_Query($check_query_args);
$custom_templates = $check_query->posts;
return array_map(
function($custom_template) {
return $this->utils->buildBlockTemplateFromPost($custom_template);
},
$custom_templates
);
}
private function getCustomTemplateTheme($templateWpId) {
if (!$templateWpId) {
return null;
}
$theme = get_post_meta($templateWpId, self::MAILPOET_EMAIL_META_THEME_TYPE, true);
if (is_array($theme) && isset($theme['styles'])) {
return $theme;
}
return null;
}
}

View File

@ -0,0 +1,94 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine\Templates;
use WP_Block_Template;
use WP_Error;
// phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
class Utils {
/**
* Gets the prefix and slug from the template ID.
*
* @param string $templateId Id of the template in prefix//slug format.
* @return array Associative array with keys 'prefix' and 'slug'.
*/
public function getTemplateIdParts($templateId) {
$template_name_parts = explode('//', $templateId);
if (count($template_name_parts) < 2) {
return [
'prefix' => '',
'slug' => '',
];
}
return [
'prefix' => $template_name_parts[0],
'slug' => $template_name_parts[1],
];
}
public static function getBlockTemplateSlugFromPath($path) {
return basename($path, '.html');
}
public function buildBlockTemplateFromPost($post) {
$terms = get_the_terms($post, 'wp_theme');
if (is_wp_error($terms)) {
return $terms;
}
if (!$terms) {
return new WP_Error('template_missing_theme', 'No theme is defined for this template.');
}
$templatePrefix = $terms[0]->name;
$templateSlug = $post->post_name;
$templateId = $templatePrefix . '//' . $templateSlug;
$template = new WP_Block_Template();
$template->wp_id = $post->ID;
$template->id = $templateId;
$template->theme = $templatePrefix;
$template->content = $post->post_content ? $post->post_content : '<p>empty</p>';
$template->slug = $templateSlug;
$template->source = 'custom';
$template->type = $post->post_type;
$template->description = $post->post_excerpt;
$template->title = $post->post_title;
$template->status = $post->post_status;
$template->has_theme_file = false;
$template->is_custom = true;
$template->post_types = [];
if ('wp_template_part' === $post->post_type) {
$type_terms = get_the_terms($post, 'wp_template_part_area');
if (!is_wp_error($type_terms) && false !== $type_terms) {
$template->area = $type_terms[0]->name;
}
}
return $template;
}
public function buildBlockTemplateFromFile($templateObject): WP_Block_Template {
$template = new WP_Block_Template();
$template->id = $templateObject->id;
$template->theme = $templateObject->theme;
$template->content = (string)file_get_contents($templateObject->path);
$template->source = $templateObject->source;
$template->slug = $templateObject->slug;
$template->type = $templateObject->type;
$template->title = $templateObject->title;
$template->description = $templateObject->description;
$template->status = 'publish';
$template->has_theme_file = false;
$template->post_types = $templateObject->post_types;
$template->is_custom = false; // Templates are only custom if they are loaded from the DB.
$template->area = 'uncategorized';
return $template;
}
}

View File

@ -0,0 +1,7 @@
<!-- wp:heading {"textAlign":"center"} -->
<h2 class="wp-block-heading has-text-align-center">AWESOME - Header</h2>
<!-- /wp:heading -->
<!-- wp:core/post-content {"lock":{"move":true,"remove":true},"layout":{"type":"constrained","contentSize":"660px"}} /-->
<!-- wp:heading {"textAlign":"center"} -->
<h2 class="wp-block-heading has-text-align-center">AWESOME - Footer</h2>
<!-- /wp:heading -->

View File

@ -0,0 +1,18 @@
{
"version": 2,
"styles": {
"color": {
"background": "#ee00aa"
},
"typography": {
"fontFamily": "'Tahoma, Verdana, Segoe, sans-serif'"
},
"elements": {
"heading": {
"typography": {
"fontFamily": "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif"
}
}
}
}
}

View File

@ -0,0 +1,7 @@
<!-- wp:heading {"textAlign":"center"} -->
<h2 class="wp-block-heading has-text-align-center">AWESOME - Header</h2>
<!-- /wp:heading -->
<!-- wp:core/post-content {"lock":{"move":true,"remove":true},"layout":{"type":"constrained","contentSize":"660px"}} /-->
<!-- wp:heading {"textAlign":"center"} -->
<h2 class="wp-block-heading has-text-align-center">AWESOME - Footer</h2>
<!-- /wp:heading -->

View File

@ -0,0 +1,15 @@
{
"version": 2,
"styles": {
"typography": {
"fontFamily": "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif"
},
"elements": {
"heading": {
"typography": {
"fontFamily": "'Tahoma, Verdana, Segoe, sans-serif'"
}
}
}
}
}

View File

@ -0,0 +1,10 @@
<!-- wp:image {"width":"660px","align":"center"} -->
<figure class="wp-block-image aligncenter is-resized">
<img
src="https://ps.w.org/mailpoet/assets/newsletter-templates/retro_computing_magazine/Windows94-Header.png"
alt="Computing Magazine"
style="width: 660px"
/>
</figure>
<!-- /wp:image -->
<!-- wp:core/post-content {"lock":{"move":true,"remove":true},"layout":{"type":"constrained","contentSize":"660px"}} /-->

View File

@ -0,0 +1,28 @@
{
"version": 2,
"styles": {
"spacing": {
"blockGap": "16px",
"padding": {
"bottom": "20px",
"left": "20px",
"right": "20px",
"top": "20px"
}
},
"color": {
"background": "#008282",
"text": "#000000"
},
"typography": {
"fontFamily": "'Tahoma, Verdana, Segoe, sans-serif'"
},
"elements": {
"heading": {
"typography": {
"fontFamily": "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif"
}
}
}
}
}

View File

@ -0,0 +1,2 @@
<!-- wp:core/post-content {"lock":{"move":true,"remove":true},"layout":{"type":"constrained"}} /-->
<!-- wp:mailpoet/powered-by-mailpoet {"lock":{"move":true,"remove":true}} /-->

View File

@ -0,0 +1,24 @@
/*
* This file contains additional CSS for template previews.
* It is loaded in the iframe that displays the preview in select modal.
*/
/*
* Alignment CSS - In theme CSS these are prefixed by a layout class
* Alignment rules are copied from the CSS generated from theme from class .wp-site-blocks
*/
.block-editor-block-preview__content-iframe .aligncenter {
justify-content: center;
margin-left: auto;
margin-right: auto;
}
.block-editor-block-preview__content-iframe .alignleft {
float: left;
margin-right: 2em;
}
.block-editor-block-preview__content-iframe .alignright {
float: right;
margin-left: 2em;
}

View File

@ -0,0 +1,243 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Engine;
use MailPoet\EmailEditor\Engine\Renderer\Renderer;
use WP_Theme_JSON;
use WP_Theme_JSON_Resolver;
/**
* E-mail editor works with own theme.json which defines settings for the editor and styles for the e-mail.
* This class is responsible for accessing data defined by the theme.json.
*/
class ThemeController {
private WP_Theme_JSON $coreTheme;
private WP_Theme_JSON $baseTheme;
public function __construct() {
$this->coreTheme = WP_Theme_JSON_Resolver::get_core_data();
$this->baseTheme = new WP_Theme_JSON((array)json_decode((string)file_get_contents(dirname(__FILE__) . '/theme.json'), true), 'default');
}
/**
* Gets combined theme data from the core and base theme, merged with the currently rendered template.
*
* @return WP_Theme_JSON
*/
public function getTheme(): WP_Theme_JSON {
$theme = new WP_Theme_JSON();
$theme->merge($this->coreTheme);
$theme->merge($this->baseTheme);
if (Renderer::getTheme() !== null) {
$theme->merge(Renderer::getTheme());
}
return apply_filters('mailpoet_email_editor_theme_json', $theme);
}
/**
* Convert compressed format presets to valid CSS values.
*
* @param string $value Value to convert.
* @param array $presets List of variable presets from theme.json
* @return mixed Converted or original value.
*/
private function maybeConvertPreset($value, $presets) {
if (!is_string($value)) {
return $value;
}
if (strstr($value, 'var:preset|color|')) {
$value = str_replace('var:preset|color|', '', $value);
$value = sprintf('var(--wp--preset--color--%s)', $value);
}
return preg_replace(array_keys($presets), array_values($presets), $value);
}
private function recursiveReplacePresets($values, $presets) {
foreach ($values as $key => $value) {
if (is_array($value)) {
$values[$key] = $this->recursiveReplacePresets($value, $presets);
} else {
$values[$key] = self::maybeConvertPreset($value, $presets);
}
}
return $values;
}
/**
* Get styles for the e-mail.
*
* @param \WP_Post|null $post Post object.
* @param \WP_Block_Template|null $template Template object.
* @param bool $convertPresets Convert presets to valid CSS values.
* @return array{
* spacing: array{
* blockGap: string,
* padding: array{bottom: string, left: string, right: string, top: string}
* },
* color: array{
* background: string
* },
* typography: array{
* fontFamily: string
* }
* }
*/
public function getStyles($post = null, $template = null, $convertPresets = false): array {
$themeStyles = $this->getTheme()->get_data()['styles'];
// Replace template styles.
if ($template && $template->wp_id) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$templateTheme = (array)get_post_meta($template->wp_id, 'mailpoet_email_theme', true); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$templateStyles = (array)($templateTheme['styles'] ?? []);
$themeStyles = array_replace_recursive($themeStyles, $templateStyles);
}
// Replace preset values.
if ($convertPresets) {
$variables = $this->getVariablesValuesMap();
$presets = [];
foreach ($variables as $varName => $varValue) {
$varPattern = '/var\(' . preg_quote($varName, '/') . '\)/i';
$presets[$varPattern] = $varValue;
}
$themeStyles = $this->recursiveReplacePresets($themeStyles, $presets);
}
return $themeStyles;
}
public function getSettings(): array {
$emailEditorThemeSettings = $this->getTheme()->get_settings();
$siteThemeSettings = WP_Theme_JSON_Resolver::get_theme_data()->get_settings();
$emailEditorThemeSettings['color']['palette']['theme'] = [];
if (isset($siteThemeSettings['color']['palette']['theme'])) {
$emailEditorThemeSettings['color']['palette']['theme'] = $siteThemeSettings['color']['palette']['theme'];
}
return $emailEditorThemeSettings;
}
public function getStylesheetFromContext($context, $options = []): string {
return function_exists('gutenberg_style_engine_get_stylesheet_from_context') ? gutenberg_style_engine_get_stylesheet_from_context($context, $options) : wp_style_engine_get_stylesheet_from_context($context, $options);
}
public function getStylesheetForRendering($post = null, $template = null): string {
$emailThemeSettings = $this->getSettings();
$cssPresets = '';
// Font family classes
foreach ($emailThemeSettings['typography']['fontFamilies']['default'] as $fontFamily) {
$cssPresets .= ".has-{$fontFamily['slug']}-font-family { font-family: {$fontFamily['fontFamily']}; } \n";
}
// Font size classes
foreach ($emailThemeSettings['typography']['fontSizes']['default'] as $fontSize) {
$cssPresets .= ".has-{$fontSize['slug']}-font-size { font-size: {$fontSize['size']}; } \n";
}
// Color palette classes
$colorDefinitions = array_merge($emailThemeSettings['color']['palette']['theme'], $emailThemeSettings['color']['palette']['default']);
foreach ($colorDefinitions as $color) {
$cssPresets .= ".has-{$color['slug']}-color { color: {$color['color']}; } \n";
$cssPresets .= ".has-{$color['slug']}-background-color { background-color: {$color['color']}; } \n";
$cssPresets .= ".has-{$color['slug']}-border-color { border-color: {$color['color']}; } \n";
}
// Block specific styles
$cssBlocks = '';
$blocks = $this->getTheme()->get_styles_block_nodes();
foreach ($blocks as $blockMetadata) {
$cssBlocks .= $this->getTheme()->get_styles_for_block($blockMetadata);
}
// Element specific styles
$elementsStyles = $this->getTheme()->get_raw_data()['styles']['elements'] ?? [];
// Because the section styles is not a part of the output the `get_styles_block_nodes` method, we need to get it separately
if ($template && $template->wp_id) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$templateTheme = (array)get_post_meta($template->wp_id, 'mailpoet_email_theme', true); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$templateStyles = (array)($templateTheme['styles'] ?? []);
$templateElements = $templateStyles['elements'] ?? [];
$elementsStyles = array_replace_recursive((array)$elementsStyles, (array)$templateElements);
}
if ($post) {
$postTheme = (array)get_post_meta($post->ID, 'mailpoet_email_theme', true);
$postStyles = (array)($postTheme['styles'] ?? []);
$postElements = $postStyles['elements'] ?? [];
$elementsStyles = array_replace_recursive((array)$elementsStyles, (array)$postElements);
}
$cssElements = '';
foreach ($elementsStyles as $key => $elementsStyle) {
$selector = $key;
if ($key === 'button') {
$selector = '.wp-block-button';
$cssElements .= wp_style_engine_get_styles($elementsStyle, ['selector' => '.wp-block-button'])['css'];
// Add color to link element.
$cssElements .= wp_style_engine_get_styles(['color' => ['text' => $elementsStyle['color']['text'] ?? '']], ['selector' => '.wp-block-button a'])['css'];
continue;
}
switch ($key) {
case 'heading':
$selector = 'h1, h2, h3, h4, h5, h6';
break;
case 'link':
$selector = 'a:not(.button-link)';
break;
}
$cssElements .= wp_style_engine_get_styles($elementsStyle, ['selector' => $selector])['css'];
}
$result = $cssPresets . $cssBlocks . $cssElements;
// Because font-size can by defined by the clamp() function that is not supported in the e-mail clients, we need to replace it to the value.
// Regular expression to match clamp() function and capture its max value
$pattern = '/clamp\([^,]+,\s*[^,]+,\s*([^)]+)\)/';
// Replace clamp() with its maximum value
$result = (string)preg_replace($pattern, '$1', $result);
return $result;
}
public function translateSlugToFontSize(string $fontSize): string {
$settings = $this->getSettings();
foreach ($settings['typography']['fontSizes']['default'] as $fontSizeDefinition) {
if ($fontSizeDefinition['slug'] === $fontSize) {
return $fontSizeDefinition['size'];
}
}
return $fontSize;
}
public function translateSlugToColor(string $colorSlug): string {
$settings = $this->getSettings();
$colorDefinitions = array_merge($settings['color']['palette']['theme'], $settings['color']['palette']['default']);
foreach ($colorDefinitions as $colorDefinition) {
if ($colorDefinition['slug'] === $colorSlug) {
return strtolower($colorDefinition['color']);
}
}
return $colorSlug;
}
public function getVariablesValuesMap(): array {
$variablesCss = $this->getTheme()->get_stylesheet(['variables']);
$map = [];
// Regular expression to match CSS variable definitions
$pattern = '/--(.*?):\s*(.*?);/';
if (preg_match_all($pattern, $variablesCss, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
// '--' . $match[1] is the variable name, $match[2] is the variable value
$map['--' . $match[1]] = $match[2];
}
}
return $map;
}
}

View File

@ -0,0 +1,43 @@
.is-layout-email-flex {
flex-wrap: nowrap;
}
:where(body .is-layout-flex) {
gap: var(--wp--style--block-gap, 16px);
}
.is-mobile-preview .is-layout-email-flex {
display: block;
}
.is-mobile-preview .is-layout-email-flex .block-editor-block-list__block {
padding: 5px 0;
width: 100%;
}
.is-mobile-preview .is-layout-email-flex .wp-block-button__link {
width: 100%;
}
/*
* Email Editor specific styles for vertical gap between blocks in column.
* This is needed because we disable layout for core/column and core/columns blocks, and .is-layout-flex is not applied.
*/
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block:first-child {
margin-top: 0;
}
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block {
margin-bottom: var(--wp--style--block-gap, 16px);
margin-top: var(--wp--style--block-gap, 16px);
}
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block:last-child {
margin-bottom: 0;
}

View File

@ -0,0 +1,280 @@
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 2,
"settings": {
"color": {
"customGradient": false,
"defaultGradients": false,
"gradients": [],
"background": true,
"text": true,
"link": true
},
"layout": {
"contentSize": "660px",
"wideSize": "660px"
},
"background": {
"backgroundImage": true
},
"spacing": {
"units": ["px"],
"blockGap": false,
"padding": true,
"margin": true,
"spacingSizes": [
{
"name": "1",
"size": "10px",
"slug": "10"
},
{
"name": "2",
"size": "20px",
"slug": "20"
},
{
"name": "3",
"size": "30px",
"slug": "30"
},
{
"name": "4",
"size": "40px",
"slug": "40"
},
{
"name": "5",
"size": "50px",
"slug": "50"
},
{
"name": "6",
"size": "60px",
"slug": "60"
}
]
},
"border": {
"radius": true,
"color": true,
"style": true,
"width": true
},
"typography": {
"dropCap": false,
"fontWeight": true,
"lineHeight": true,
"fontFamilies": [
{
"name": "Arial",
"slug": "arial",
"fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif"
},
{
"name": "Comic Sans MS",
"slug": "comic-sans-ms",
"fontFamily": "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif"
},
{
"name": "Courier New",
"slug": "courier-new",
"fontFamily": "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace"
},
{
"name": "Georgia",
"slug": "georgia",
"fontFamily": "Georgia, Times, 'Times New Roman', serif"
},
{
"name": "Lucida",
"slug": "lucida",
"fontFamily": "'Lucida Sans Unicode', 'Lucida Grande', sans-serif"
},
{
"name": "Tahoma",
"slug": "tahoma",
"fontFamily": "'Tahoma, Verdana, Segoe, sans-serif'"
},
{
"name": "Times New Roman",
"slug": "times-new-roman",
"fontFamily": "'Times New Roman', Times, Baskerville, Georgia, serif"
},
{
"name": "Trebuchet MS",
"slug": "trebuchet-ms",
"fontFamily": "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif"
},
{
"name": "Verdana",
"slug": "verdana",
"fontFamily": "'Verdana, Geneva, sans-serif'"
},
{
"name": "Arvo",
"slug": "arvo",
"fontFamily": "'arvo, courier, georgia, serif'"
},
{
"name": "Lato",
"slug": "lato",
"fontFamily": "lato, 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Lora",
"slug": "lora",
"fontFamily": "lora, georgia, 'times new roman', serif"
},
{
"name": "Merriweather",
"slug": "merriweather",
"fontFamily": "merriweather, georgia, 'times new roman', serif"
},
{
"name": "Merriweather Sans",
"slug": "merriweather-sans",
"fontFamily": "'merriweather sans', 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Noticia Text",
"slug": "noticia-text",
"fontFamily": "'noticia text', georgia, 'times new roman', serif"
},
{
"name": "Open Sans",
"slug": "open-sans",
"fontFamily": "'open sans', 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Playfair Display",
"slug": "playfair-display",
"fontFamily": "'playfair display', georgia, 'times new roman', serif"
},
{
"name": "Roboto",
"slug": "roboto",
"fontFamily": "roboto, 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Source Sans Pro",
"slug": "source-sans-pro",
"fontFamily": "'source sans pro', 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Oswald",
"slug": "oswald",
"fontFamily": "Oswald, 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif"
},
{
"name": "Raleway",
"slug": "raleway",
"fontFamily": "Raleway, 'Century Gothic', CenturyGothic, AppleGothic, sans-serif"
},
{
"name": "Permanent Marker",
"slug": "permanent-marker",
"fontFamily": "'Permanent Marker', Tahoma, Verdana, Segoe, sans-serif"
},
{
"name": "Pacifico",
"slug": "pacifico",
"fontFamily": "Pacifico, 'Arial Narrow', Arial, sans-serif"
}
],
"fontSizes": [
{
"name": "small",
"size": "13px",
"slug": "small"
},
{
"name": "medium",
"size": "16px",
"slug": "medium"
},
{
"name": "large",
"size": "28px",
"slug": "large"
},
{
"name": "extra-large",
"size": "42px",
"slug": "x-large"
}
]
},
"useRootPaddingAwareAlignments": true
},
"styles": {
"spacing": {
"blockGap": "16px",
"padding": {
"bottom": "20px",
"left": "20px",
"right": "20px",
"top": "20px"
}
},
"color": {
"background": "#f0f0f0",
"text": "#000000"
},
"typography": {
"fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif",
"fontSize": "16px",
"fontWeight": "400",
"fontStyle": "normal",
"letterSpacing": "0",
"lineHeight": "1.5",
"textDecoration": "none",
"textTransform": "none"
},
"elements": {
"heading": {
"typography": {
"fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif",
"fontWeight": "400",
"fontStyle": "normal",
"lineHeight": "1.5"
},
"color": {
"text": "#000000"
}
},
"h1": {
"typography": {
"fontSize": "42px",
"fontWeight": "700",
"fontStyle": "normal"
}
},
"h2": {
"typography": {
"fontSize": "42px"
}
},
"h3": {
"typography": {
"fontSize": "28px"
}
},
"h4": {
"typography": {
"fontSize": "16px"
}
},
"h5": {
"typography": {
"fontSize": "13px"
}
},
"h6": {
"typography": {
"fontSize": "13px"
}
}
}
}
}

View File

@ -0,0 +1,20 @@
# Theme.json for the email editor
We use theme.json to define settings and styles for the email editor and we reuse the definitions also in the rendering engine.
The theme is used in combination with the [core's theme.json](https://github.com/WordPress/WordPress/blob/master/wp-includes/theme.json). We load the core's theme.json first and then we merge the email editor's theme.json on top of it.
In this file we want to document settings and styles that are specific to the email editor.
## Settings
- **color**: We disable gradients, because they are not supported in many email clients. We may add the support later.
- **layout**: We set content width to 660px, because it's the most common width for emails. This is meant as a default value.
- **spacing**: We allow only px units, because they are the most reliable in email clients. We may add the support for other units later with some sort of conversion to px. We also disable margins because they are not supported in our renderer (margin collapsing might be tricky).
- **border**: We want to allow all types of borders and border styles.
- **typography**: We disabled fontWeight and dropCap appearance settings, because they are not supported in our renderer. We may add the support later. We also define a set of basic font families that are safe to use with emails. The list was copied from the battle tested legacy editor.
## Styles
- **spacing**: We define default padding for the emails.
- **color**: We define default colors for text and background of the emails.
- **typography**: We define default font family and font size for the emails.

View File

@ -0,0 +1,51 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\BlocksRegistry;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Layout\FlexLayoutRenderer;
class Initializer {
public function initialize(): void {
add_action('mailpoet_blocks_renderer_initialized', [$this, 'registerCoreBlocksRenderers'], 10, 1);
add_filter('mailpoet_email_editor_theme_json', [$this, 'adjustThemeJson'], 10, 1);
add_filter('safe_style_css', [$this, 'allowStyles']);
}
/**
* Register core blocks email renderers when the blocks renderer is initialized.
*/
public function registerCoreBlocksRenderers(BlocksRegistry $blocksRegistry): void {
$blocksRegistry->addBlockRenderer('core/paragraph', new Renderer\Blocks\Text());
$blocksRegistry->addBlockRenderer('core/heading', new Renderer\Blocks\Text());
$blocksRegistry->addBlockRenderer('core/column', new Renderer\Blocks\Column());
$blocksRegistry->addBlockRenderer('core/columns', new Renderer\Blocks\Columns());
$blocksRegistry->addBlockRenderer('core/list', new Renderer\Blocks\ListBlock());
$blocksRegistry->addBlockRenderer('core/image', new Renderer\Blocks\Image());
$blocksRegistry->addBlockRenderer('core/buttons', new Renderer\Blocks\Buttons(new FlexLayoutRenderer()));
$blocksRegistry->addBlockRenderer('core/button', new Renderer\Blocks\Button());
$blocksRegistry->addBlockRenderer('core/group', new Renderer\Blocks\Group());
}
/**
* Adjusts the editor's theme to add blocks specific settings for core blocks.
*/
public function adjustThemeJson(\WP_Theme_JSON $editorThemeJson): \WP_Theme_JSON {
$themeJson = (string)file_get_contents(dirname(__FILE__) . '/theme.json');
$themeJson = json_decode($themeJson, true);
/** @var array $themeJson */
$editorThemeJson->merge(new \WP_Theme_JSON($themeJson, 'default'));
return $editorThemeJson;
}
/**
* Allow styles for the email editor.
*/
public function allowStyles(array $allowedStyles): array {
$allowedStyles[] = 'display';
$allowedStyles[] = 'mso-padding-alt';
$allowedStyles[] = 'mso-font-width';
$allowedStyles[] = 'mso-text-raise';
return $allowedStyles;
}
}

View File

@ -0,0 +1,65 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\BlockRenderer;
use MailPoet\EmailEditor\Engine\SettingsController;
use WP_Style_Engine;
/**
* Shared functionality for block renderers.
*/
abstract class AbstractBlockRenderer implements BlockRenderer {
/**
* Wrapper for wp_style_engine_get_styles which ensures all values are returned.
*
* @param array $block_styles Array of block styles.
* @param bool $skip_convert_vars If true, --wp_preset--spacing--x type values will be left in the original var:preset:spacing:x format.
* @return array
*/
protected function getStylesFromBlock(array $block_styles, $skip_convert_vars = false) {
$styles = wp_style_engine_get_styles($block_styles, ['convert_vars_to_classnames' => $skip_convert_vars]);
return wp_parse_args($styles, [
'css' => '',
'declarations' => [],
'classnames' => '',
]);
}
/**
* Compile objects containing CSS properties to a string.
*
* @param array ...$styles Style arrays to compile.
* @return string
*/
protected function compileCss(...$styles): string {
return WP_Style_Engine::compile_css(array_merge(...$styles), '');
}
protected function addSpacer($content, $emailAttrs): string {
$gapStyle = WP_Style_Engine::compile_css(array_intersect_key($emailAttrs, array_flip(['margin-top'])), '');
$paddingStyle = WP_Style_Engine::compile_css(array_intersect_key($emailAttrs, array_flip(['padding-left', 'padding-right'])), '');
if (!$gapStyle && !$paddingStyle) {
return $content;
}
return sprintf(
'<!--[if mso | IE]><table align="left" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%" style="%2$s"><tr><td style="%3$s"><![endif]-->
<div class="email-block-layout" style="%2$s %3$s">%1$s</div>
<!--[if mso | IE]></td></tr></table><![endif]-->',
$content,
esc_attr($gapStyle),
esc_attr($paddingStyle)
);
}
public function render(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
return $this->addSpacer(
$this->renderContent($blockContent, $parsedBlock, $settingsController),
$parsedBlock['email_attrs'] ?? []
);
}
abstract protected function renderContent(string $blockContent, array $parsedBlock, SettingsController $settingsController): string;
}

View File

@ -0,0 +1,99 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
use MailPoet\EmailEditor\Integrations\Utils\DomDocumentHelper;
/**
* Renders a button block.
* @see https://www.activecampaign.com/blog/email-buttons
* @see https://documentation.mjml.io/#mj-button
*/
class Button extends AbstractBlockRenderer {
private function getWrapperStyles(array $blockStyles) {
$properties = ['border', 'color', 'typography', 'spacing'];
$styles = $this->getStylesFromBlock(array_intersect_key($blockStyles, array_flip($properties)));
return (object)[
'css' => $this->compileCss($styles['declarations'], ['word-break' => 'break-word', 'display' => 'block']),
'classname' => $styles['classnames'],
];
}
private function getLinkStyles(array $blockStyles) {
$styles = $this->getStylesFromBlock([
'color' => [
'text' => $blockStyles['color']['text'] ?? '',
],
'typography' => $blockStyles['typography'] ?? [],
]);
return (object)[
'css' => $this->compileCss($styles['declarations'], ['display' => 'block']),
'classname' => $styles['classnames'],
];
}
public function render(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
return $this->renderContent($blockContent, $parsedBlock, $settingsController);
}
protected function renderContent($blockContent, array $parsedBlock, SettingsController $settingsController): string {
if (empty($parsedBlock['innerHTML'])) {
return '';
}
$domHelper = new DomDocumentHelper($parsedBlock['innerHTML']);
$blockClassname = $domHelper->getAttributeValueByTagName('div', 'class') ?? '';
$buttonLink = $domHelper->findElement('a');
if (!$buttonLink) {
return '';
}
$buttonText = $domHelper->getElementInnerHTML($buttonLink) ?: '';
$buttonUrl = $buttonLink->getAttribute('href') ?: '#';
$blockAttributes = wp_parse_args($parsedBlock['attrs'] ?? [], [
'width' => '',
'style' => [],
'textAlign' => 'center',
'backgroundColor' => '',
'textColor' => '',
]);
$blockStyles = array_replace_recursive(
[
'color' => array_filter([
'background' => $blockAttributes['backgroundColor'] ? $settingsController->translateSlugToColor($blockAttributes['backgroundColor']) : null,
'text' => $blockAttributes['textColor'] ? $settingsController->translateSlugToColor($blockAttributes['textColor']) : null,
]),
],
$blockAttributes['style'] ?? []
);
if (!empty($blockStyles['border']) && empty($blockStyles['border']['style'])) {
$blockStyles['border']['style'] = 'solid';
}
$wrapperStyles = $this->getWrapperStyles($blockStyles);
$linkStyles = $this->getLinkStyles($blockStyles);
return sprintf(
'<table border="0" cellspacing="0" cellpadding="0" role="presentation" style="width:%1$s;">
<tr>
<td align="%2$s" valign="middle" role="presentation" class="%3$s" style="%4$s">
<a class="button-link %5$s" style="%6$s" href="%7$s" target="_blank">%8$s</a>
</td>
</tr>
</table>',
esc_attr($blockAttributes['width'] ? '100%' : 'auto'),
esc_attr($blockAttributes['textAlign']),
esc_attr($wrapperStyles->classname . ' ' . $blockClassname),
esc_attr($wrapperStyles->css),
esc_attr($linkStyles->classname),
esc_attr($linkStyles->css),
esc_url($buttonUrl),
$buttonText,
);
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\Renderer\ContentRenderer\Layout\FlexLayoutRenderer;
use MailPoet\EmailEditor\Engine\SettingsController;
class Buttons extends AbstractBlockRenderer {
/** @var FlexLayoutRenderer */
private $flexLayoutRenderer;
public function __construct(
FlexLayoutRenderer $flexLayoutRenderer
) {
$this->flexLayoutRenderer = $flexLayoutRenderer;
}
protected function renderContent($blockContent, array $parsedBlock, SettingsController $settingsController): string {
// Ignore font size set on the buttons block
// We rely on TypographyPreprocessor to set the font size on the buttons
// Rendering font size on the wrapper causes unwanted whitespace below the buttons
if (isset($parsedBlock['attrs']['style']['typography']['fontSize'])) {
unset($parsedBlock['attrs']['style']['typography']['fontSize']);
}
return $this->flexLayoutRenderer->renderInnerBlocksInLayout($parsedBlock, $settingsController);
}
}

View File

@ -0,0 +1,84 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
use MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks\AbstractBlockRenderer;
use MailPoet\EmailEditor\Integrations\Utils\DomDocumentHelper;
use WP_Style_Engine;
class Column extends AbstractBlockRenderer {
protected function renderContent(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$content = '';
foreach ($parsedBlock['innerBlocks'] ?? [] as $block) {
$content .= render_block($block);
}
return str_replace(
'{column_content}',
$content,
$this->getBlockWrapper($blockContent, $parsedBlock, $settingsController)
);
}
/**
* Based on MJML <mj-column>
*/
private function getBlockWrapper(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$originalWrapperClassname = (new DomDocumentHelper($blockContent))->getAttributeValueByTagName('div', 'class') ?? '';
$block_attributes = wp_parse_args($parsedBlock['attrs'] ?? [], [
'verticalAlignment' => 'stretch',
'width' => $settingsController->getLayoutWidthWithoutPadding(),
'style' => [],
]);
// The default column alignment is `stretch to fill` which means that we need to set the background color to the main cell
// to create a feeling of a stretched column. This also needs to apply to CSS classnames which can also apply styles.
$isStretched = empty($block_attributes['verticalAlignment']) || $block_attributes['verticalAlignment'] === 'stretch';
$paddingCSS = $this->getStylesFromBlock(['spacing' => ['padding' => $block_attributes['style']['spacing']['padding'] ?? []]])['css'];
$cellStyles = $this->getStylesFromBlock([
'color' => $block_attributes['style']['color'] ?? [],
'background' => $block_attributes['style']['background'] ?? [],
])['declarations'];
$borderStyles = $this->getStylesFromBlock(['border' => $block_attributes['style']['border'] ?? []])['declarations'];
if (!empty($borderStyles)) {
$cellStyles = array_merge($cellStyles, ['border-style' => 'solid'], $borderStyles);
}
if (!empty($cellStyles['background-image']) && empty($cellStyles['background-size'])) {
$cellStyles['background-size'] = 'cover';
}
$wrapperClassname = 'block wp-block-column email-block-column';
$contentClassname = 'email-block-column-content';
$wrapperCSS = WP_Style_Engine::compile_css([
'vertical-align' => $isStretched ? 'top' : $block_attributes['verticalAlignment'],
], '');
$contentCSS = 'vertical-align: top;';
if ($isStretched) {
$wrapperClassname .= ' ' . $originalWrapperClassname;
$wrapperCSS .= ' ' . WP_Style_Engine::compile_css($cellStyles, '');
} else {
$contentClassname .= ' ' . $originalWrapperClassname;
$contentCSS .= ' ' . WP_Style_Engine::compile_css($cellStyles, '');
}
return '
<td class="' . esc_attr($wrapperClassname) . '" style="' . esc_attr($wrapperCSS) . '" width="' . esc_attr($block_attributes['width']) . '">
<table class="' . esc_attr($contentClassname) . '" style="' . esc_attr($contentCSS) . '" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td align="left" style="text-align:left;' . esc_attr($paddingCSS) . '">
{column_content}
</td>
</tr>
</tbody>
</table>
</td>
';
}
}

View File

@ -0,0 +1,75 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
use MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks\AbstractBlockRenderer;
use MailPoet\EmailEditor\Integrations\Utils\DomDocumentHelper;
use WP_Style_Engine;
class Columns extends AbstractBlockRenderer {
protected function renderContent(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$content = '';
foreach ($parsedBlock['innerBlocks'] ?? [] as $block) {
$content .= render_block($block);
}
return str_replace(
'{columns_content}',
$content,
$this->getBlockWrapper($blockContent, $parsedBlock, $settingsController)
);
}
/**
* Based on MJML <mj-section>
*/
private function getBlockWrapper(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$originalWrapperClassname = (new DomDocumentHelper($blockContent))->getAttributeValueByTagName('div', 'class') ?? '';
$block_attributes = wp_parse_args($parsedBlock['attrs'] ?? [], [
'align' => null,
'width' => $settingsController->getLayoutWidthWithoutPadding(),
'style' => [],
]);
$columnsStyles = $this->getStylesFromBlock([
'spacing' => [ 'padding' => $block_attributes['style']['spacing']['padding'] ?? [] ],
'color' => $block_attributes['style']['color'] ?? [],
'background' => $block_attributes['style']['background'] ?? [],
])['declarations'];
$borderStyles = $this->getStylesFromBlock(['border' => $block_attributes['style']['border'] ?? []])['declarations'];
if (!empty($borderStyles)) {
$columnsStyles = array_merge($columnsStyles, ['border-style' => 'solid'], $borderStyles);
}
if (empty($columnsStyles['background-size'])) {
$columnsStyles['background-size'] = 'cover';
}
$renderedColumns = '<table class="' . esc_attr('email-block-columns ' . $originalWrapperClassname) . '" style="width:100%;border-collapse:separate;text-align:left;' . esc_attr(WP_Style_Engine::compile_css($columnsStyles, '')) . '" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>{columns_content}</tr>
</tbody>
</table>';
// Margins are not supported well in outlook for tables, so wrap in another table.
$margins = $block_attributes['style']['spacing']['margin'] ?? [];
if (!empty($margins)) {
$marginToPaddingStyles = $this->getStylesFromBlock([
'spacing' => [ 'margin' => $margins ],
])['css'];
$renderedColumns = '<table class="email-block-columns-wrapper" style="width:100%;border-collapse:separate;text-align:left;' . esc_attr($marginToPaddingStyles) . '" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>' . $renderedColumns . '</td>
</tr>
</tbody>
</table>';
}
return $renderedColumns;
}
}

View File

@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
use MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks\AbstractBlockRenderer;
use MailPoet\EmailEditor\Integrations\Utils\DomDocumentHelper;
use WP_Style_Engine;
class Group extends AbstractBlockRenderer {
protected function renderContent(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$content = '';
$innerBlocks = $parsedBlock['innerBlocks'] ?? [];
foreach ($innerBlocks as $block) {
$content .= render_block($block);
}
return str_replace(
'{group_content}',
$content,
$this->getBlockWrapper($blockContent, $parsedBlock, $settingsController)
);
}
private function getBlockWrapper(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$originalClassname = (new DomDocumentHelper($blockContent))->getAttributeValueByTagName('div', 'class') ?? '';
$blockAttributes = wp_parse_args($parsedBlock['attrs'] ?? [], [
'style' => [],
'backgroundColor' => '',
'textColor' => '',
'borderColor' => '',
'layout' => [],
]);
// Layout, background, borders need to be on the outer table element.
$tableStyles = $this->getStylesFromBlock([
'color' => array_filter([
'background' => $blockAttributes['backgroundColor'] ? $settingsController->translateSlugToColor($blockAttributes['backgroundColor']) : null,
'text' => $blockAttributes['textColor'] ? $settingsController->translateSlugToColor($blockAttributes['textColor']) : null,
'border' => $blockAttributes['borderColor'] ? $settingsController->translateSlugToColor($blockAttributes['borderColor']) : null,
]),
'background' => $blockAttributes['style']['background'] ?? [],
'border' => $blockAttributes['style']['border'] ?? [],
'spacing' => [ 'padding' => $blockAttributes['style']['spacing']['margin'] ?? [] ],
])['declarations'];
// Padding properties need to be added to the table cell.
$cellStyles = $this->getStylesFromBlock([
'spacing' => [ 'padding' => $blockAttributes['style']['spacing']['padding'] ?? [] ],
])['declarations'];
$tableStyles['background-size'] = empty($tableStyles['background-size']) ? 'cover' : $tableStyles['background-size'];
$justifyContent = $blockAttributes['layout']['justifyContent'] ?? 'center';
$width = $parsedBlock['email_attrs']['width'] ?? '100%';
return sprintf(
'<table class="email-block-group %3$s" style="%1$s" width="100%%" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td class="email-block-group-content" style="%2$s" align="%4$s" width="%5$s">
{group_content}
</td>
</tr>
</tbody>
</table>',
esc_attr(WP_Style_Engine::compile_css($tableStyles, '')),
esc_attr(WP_Style_Engine::compile_css($cellStyles, '')),
esc_attr($originalClassname),
esc_attr($justifyContent),
esc_attr($width),
);
}
}

View File

@ -0,0 +1,238 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
use MailPoet\EmailEditor\Integrations\Utils\DomDocumentHelper;
class Image extends AbstractBlockRenderer {
protected function renderContent($blockContent, array $parsedBlock, SettingsController $settingsController): string {
$parsedHtml = $this->parseBlockContent($blockContent);
if (!$parsedHtml) {
return '';
}
$imageUrl = $parsedHtml['imageUrl'];
$image = $parsedHtml['image'];
$caption = $parsedHtml['caption'];
$parsedBlock = $this->addImageSizeWhenMissing($parsedBlock, $imageUrl, $settingsController);
$image = $this->addImageDimensions($image, $parsedBlock, $settingsController);
$image = $this->applyImageBorderStyle($image, $parsedBlock, $settingsController);
$image = $this->applyRoundedStyle($image, $parsedBlock);
return str_replace(
['{image_content}', '{caption_content}'],
[$image, $caption],
$this->getBlockWrapper($parsedBlock, $settingsController)
);
}
private function applyRoundedStyle(string $blockContent, array $parsedBlock): string {
// Because the isn't an attribute for definition of rounded style, we have to check the class name
if (isset($parsedBlock['attrs']['className']) && strpos($parsedBlock['attrs']['className'], 'is-style-rounded') !== false) {
// If the image should be in a circle, we need to set the border-radius to 9999px to make it the same as is in the editor
// This style cannot be applied on the wrapper, and we need to set it directly on the image
$blockContent = $this->removeStyleAttributeFromElement($blockContent, ['tag_name' => 'img'], 'border-radius');
$blockContent = $this->addStyleToElement($blockContent, ['tag_name' => 'img'], 'border-radius: 9999px;');
}
return $blockContent;
}
/**
* When the width is not set, it's important to get it for the image to be displayed correctly
*/
private function addImageSizeWhenMissing(array $parsedBlock, string $imageUrl, SettingsController $settingsController): array {
if (!isset($parsedBlock['attrs']['width'])) {
$maxWidth = $settingsController->parseNumberFromStringWithPixels($parsedBlock['email_attrs']['width'] ?? SettingsController::EMAIL_WIDTH);
$imageSize = wp_getimagesize($imageUrl);
$imageSize = $imageSize ? $imageSize[0] : $maxWidth;
// Because width is primarily used for the max-width property, we need to add the left and right border width to it
$borderWidth = $parsedBlock['attrs']['style']['border']['width'] ?? '0px';
$borderLeftWidth = $parsedBlock['attrs']['style']['border']['left']['width'] ?? $borderWidth;
$borderRightWidth = $parsedBlock['attrs']['style']['border']['right']['width'] ?? $borderWidth;
$width = min($imageSize, $maxWidth);
$width += $settingsController->parseNumberFromStringWithPixels($borderLeftWidth ?? '0px');
$width += $settingsController->parseNumberFromStringWithPixels($borderRightWidth ?? '0px');
$parsedBlock['attrs']['width'] = "{$width}px";
}
return $parsedBlock;
}
private function applyImageBorderStyle(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
// Getting individual border properties
$borderStyles = wp_style_engine_get_styles(['border' => $parsedBlock['attrs']['style']['border'] ?? []]);
$borderStyles = $borderStyles['declarations'] ?? [];
if (!empty($borderStyles)) {
$borderStyles['border-style'] = 'solid';
$borderStyles['box-sizing'] = 'border-box';
}
return $this->addStyleToElement($blockContent, ['tag_name' => 'img'], \WP_Style_Engine::compile_css($borderStyles, ''));
}
/**
* Settings width and height attributes for images is important for MS Outlook.
*/
private function addImageDimensions($blockContent, array $parsedBlock, SettingsController $settingsController): string {
$html = new \WP_HTML_Tag_Processor($blockContent);
if ($html->next_tag(['tag_name' => 'img'])) {
// Getting height from styles and if it's set, we set the height attribute
$styles = $html->get_attribute('style') ?? '';
$styles = $settingsController->parseStylesToArray($styles);
$height = $styles['height'] ?? null;
if ($height && $height !== 'auto' && is_numeric($settingsController->parseNumberFromStringWithPixels($height))) {
$height = $settingsController->parseNumberFromStringWithPixels($height);
$html->set_attribute('height', esc_attr($height));
}
if (isset($parsedBlock['attrs']['width'])) {
$width = $settingsController->parseNumberFromStringWithPixels($parsedBlock['attrs']['width']);
$html->set_attribute('width', esc_attr($width));
}
$blockContent = $html->get_updated_html();
}
return $blockContent;
}
/**
* This method configure the font size of the caption because it's set to 0 for the parent element to avoid unexpected white spaces
* We try to use font-size passed down from the parent element $parsedBlock['email_attrs']['font-size'], but if it's not set, we use the default font-size from the email theme.
*/
private function getCaptionStyles(SettingsController $settingsController, array $parsedBlock): string {
$themeData = $settingsController->getTheme()->get_data();
$styles = [
'text-align' => 'center',
];
$styles['font-size'] = $parsedBlock['email_attrs']['font-size'] ?? $themeData['styles']['typography']['fontSize'];
return \WP_Style_Engine::compile_css($styles, '');
}
/**
* Based on MJML <mj-image> but because MJML doesn't support captions, our solution is a bit different
*/
private function getBlockWrapper(array $parsedBlock, SettingsController $settingsController): string {
$styles = [
'border-collapse' => 'collapse',
'border-spacing' => '0px',
'font-size' => '0px',
'vertical-align' => 'top',
'width' => '100%',
];
// When the image is not aligned, the wrapper is set to 100% width due to caption that can be longer than the image
$wrapperWidth = isset($parsedBlock['attrs']['align']) ? ($parsedBlock['attrs']['width'] ?? '100%') : '100%';
$wrapperStyles = $styles;
$wrapperStyles['width'] = $wrapperWidth;
$captionStyles = $this->getCaptionStyles($settingsController, $parsedBlock);
$styles['width'] = '100%';
$align = $parsedBlock['attrs']['align'] ?? 'left';
return '
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="' . esc_attr(\WP_Style_Engine::compile_css($styles, '')) . '"
width="100%"
>
<tr>
<td align="' . esc_attr($align) . '">
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="' . esc_attr(\WP_Style_Engine::compile_css($wrapperStyles, '')) . '"
width="' . esc_attr($wrapperWidth) . '"
>
<tr>
<td>{image_content}</td>
</tr>
<tr>
<td style="' . esc_attr($captionStyles) . '">{caption_content}</td>
</tr>
</table>
</td>
</tr>
</table>
';
}
/**
* @param array{tag_name: string, class_name?: string} $tag
* @param string $style
*/
private function addStyleToElement($blockContent, array $tag, string $style): string {
$html = new \WP_HTML_Tag_Processor($blockContent);
if ($html->next_tag($tag)) {
$elementStyle = $html->get_attribute('style') ?? '';
$elementStyle = !empty($elementStyle) ? (rtrim($elementStyle, ';') . ';') : ''; // Adding semicolon if it's missing
$elementStyle .= $style;
$html->set_attribute('style', esc_attr($elementStyle));
$blockContent = $html->get_updated_html();
}
return $blockContent;
}
/**
* @param array{tag_name: string, class_name?: string} $tag
*/
private function removeStyleAttributeFromElement($blockContent, array $tag, string $styleName): string {
$html = new \WP_HTML_Tag_Processor($blockContent);
if ($html->next_tag($tag)) {
$elementStyle = $html->get_attribute('style') ?? '';
$elementStyle = preg_replace('/' . $styleName . ':(.?[0-9]+px)+;?/', '', $elementStyle);
$html->set_attribute('style', esc_attr($elementStyle));
$blockContent = $html->get_updated_html();
}
return $blockContent;
}
/**
* @param string $blockContent
* @return array{imageUrl: string, image: string, caption: string}|null
*/
private function parseBlockContent(string $blockContent): ?array {
// If block's image is not set, we don't need to parse the content
if (empty($blockContent)) {
return null;
}
$domHelper = new DomDocumentHelper($blockContent);
$figureTag = $domHelper->findElement('figure');
if (!$figureTag) {
return null;
}
$imgTag = $domHelper->findElement('img');
if (!$imgTag) {
return null;
}
$imageSrc = $domHelper->getAttributeValue($imgTag, 'src');
$imageHtml = $domHelper->getOuterHtml($imgTag);
$figcaption = $domHelper->findElement('figcaption');
$figcaptionHtml = $figcaption ? $domHelper->getOuterHtml($figcaption) : '';
$figcaptionHtml = str_replace(['<figcaption', '</figcaption>'], ['<span', '</span>'], $figcaptionHtml);
return [
'imageUrl' => $imageSrc ?: '',
'image' => $imageHtml,
'caption' => $figcaptionHtml ?: '',
];
}
}

View File

@ -0,0 +1,42 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
// We have to avoid using keyword `List`
class ListBlock extends AbstractBlockRenderer {
protected function renderContent(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
$html = new \WP_HTML_Tag_Processor($blockContent);
$tagName = ($parsedBlock['attrs']['ordered'] ?? false) ? 'ol' : 'ul';
if ($html->next_tag(['tag_name' => $tagName])) {
$styles = $html->get_attribute('style') ?? '';
$styles = $settingsController->parseStylesToArray($styles);
// Font size
if (isset($parsedBlock['email_attrs']['font-size'])) {
$styles['font-size'] = $parsedBlock['email_attrs']['font-size'];
} else {
// Use font-size from email theme when those properties are not set
$themeData = $settingsController->getTheme()->get_data();
$styles['font-size'] = $themeData['styles']['typography']['fontSize'];
}
$html->set_attribute('style', esc_attr(\WP_Style_Engine::compile_css($styles, '')));
$blockContent = $html->get_updated_html();
}
$wrapperStyle = \WP_Style_Engine::compile_css([
'margin-top' => $parsedBlock['email_attrs']['margin-top'] ?? '0px',
], '');
// \WP_HTML_Tag_Processor escapes the content, so we have to replace it back
$blockContent = str_replace('&#039;', "'", $blockContent);
return sprintf(
'<div style="%1$s">%2$s</div>',
esc_attr($wrapperStyle),
$blockContent
);
}
}

View File

@ -0,0 +1,90 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks;
use MailPoet\EmailEditor\Engine\SettingsController;
/**
* This renderer covers both core/paragraph and core/heading blocks
*/
class Text extends AbstractBlockRenderer {
protected function renderContent(string $blockContent, array $parsedBlock, SettingsController $settingsController): string {
// Do not render empty blocks.
if (empty(trim(strip_tags($blockContent)))) {
return '';
}
$blockContent = $this->adjustStyleAttribute($blockContent);
$blockAttributes = wp_parse_args($parsedBlock['attrs'] ?? [], [
'textAlign' => 'left',
'style' => [],
]);
$html = new \WP_HTML_Tag_Processor($blockContent);
$classes = '';
if ($html->next_tag()) {
$classes = $html->get_attribute('class') ?? '';
}
$blockStyles = $this->getStylesFromBlock([
'color' => $blockAttributes['style']['color'] ?? [],
'spacing' => $blockAttributes['style']['spacing'] ?? [],
'typography' => $blockAttributes['style']['typography'] ?? [],
]);
$styles = [
'min-width' => '100%', // prevent Gmail App from shrinking the table on mobile devices
];
$styles['text-align'] = 'left';
if (isset($parsedBlock['attrs']['textAlign'])) {
$styles['text-align'] = $parsedBlock['attrs']['textAlign'];
} elseif (in_array($parsedBlock['attrs']['align'] ?? null, ['left', 'center', 'right'])) {
$styles['text-align'] = $parsedBlock['attrs']['align'];
}
$compiledStyles = $this->compileCss($blockStyles['declarations'], $styles);
return sprintf(
'<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
width="100%%"
>
<tr>
<td class="%1$s" style="%2$s" align="%3$s">%4$s</td>
</tr>
</table>',
esc_attr($classes),
esc_attr($compiledStyles),
esc_attr($styles['text-align'] ?? 'left'),
$blockContent
);
}
/**
* 1) We need to remove padding because we render padding on wrapping table cell
* 2) We also need to replace font-size to avoid clamp() because clamp() is not supported in many email clients.
* The font size values is automatically converted to clamp() when WP site theme is configured to use fluid layouts.
* Currently (WP 6.5), there is no way to disable this behavior.
*/
private function adjustStyleAttribute(string $blockContent): string {
$html = new \WP_HTML_Tag_Processor($blockContent);
if ($html->next_tag()) {
$elementStyle = $html->get_attribute('style') ?? '';
// Padding may contain value like 10px or variable like var(--spacing-10)
$elementStyle = preg_replace('/padding[^:]*:.?[0-9a-z-()]+;?/', '', $elementStyle);
// We define the font-size on the wrapper element, but we need to keep font-size definition here
// to prevent CSS Inliner from adding a default value and overriding the value set by user, which is on the wrapper element.
// The value provided by WP uses clamp() function which is not supported in many email clients
$elementStyle = preg_replace('/font-size:[^;]+;?/', 'font-size: inherit;', $elementStyle);
$html->set_attribute('style', esc_attr($elementStyle));
$blockContent = $html->get_updated_html();
}
return $blockContent;
}
}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 2,
"styles": {
"blocks": {
"core/button": {
"variations": {}
}
},
"elements": {
"button": {
"color": {
"background": "#32373c",
"text": "#ffffff"
},
"spacing": {
"padding": {
"bottom": "0.7em",
"left": "1.4em",
"right": "1.4em",
"top": "0.7em"
}
}
}
}
}
}

View File

@ -0,0 +1,115 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet\Blocks\BlockTypes;
use MailPoet\Config\Env;
use WP_Style_Engine;
abstract class AbstractBlock {
protected $namespace = 'mailpoet';
protected $blockName = '';
public function initialize() {
$this->registerAssets();
$this->registerBlockType();
}
protected function getBlockType(): string {
return $this->namespace . '/' . $this->blockName;
}
protected function parseRenderCallbackAttributes($attributes): array {
return is_a($attributes, 'WP_Block') ? $attributes->attributes : $attributes;
}
protected function registerAssets() {
if (null !== $this->getEditorScript()) {
// @todo Would usually just register, but the editor_script are not being loaded in the custom editor.
wp_enqueue_script(
$this->getEditorScript('handle'),
$this->getEditorScript('path'),
$this->getEditorScript('dependencies'),
$this->getEditorScript('version'),
true
);
}
if (null !== $this->getEditorStyle()) {
// @todo Would usually just register, but the editor_script are not being loaded in the custom editor.
wp_enqueue_style(
$this->getEditorStyle('handle'),
$this->getEditorStyle('path'),
[],
$this->getEditorScript('version'),
'all'
);
}
}
protected function registerBlockType() {
if (\WP_Block_Type_Registry::get_instance()->is_registered($this->getBlockType())) {
return;
}
$metadata_path = Env::$assetsPath . '/js/src/email-editor/blocks/' . $this->blockName . '/block.json';
$block_settings = [
'render_callback' => [$this, 'render'],
'editor_script' => $this->getEditorScript('handle'),
'editor_style' => $this->getEditorStyle('handle'),
];
register_block_type_from_metadata(
$metadata_path,
$block_settings
);
}
protected function getEditorScript($key = null) {
$asset_file_path = Env::$assetsPath . '/dist/js/email-editor-blocks/' . $this->blockName . '-block.asset.php';
if (!file_exists($asset_file_path)) {
return null;
}
$asset_file = require $asset_file_path;
$script = [
'handle' => 'mailpoet-' . $this->blockName . '-block',
'path' => Env::$assetsUrl . '/dist/js/email-editor-blocks/' . $this->blockName . '-block.js',
'dependencies' => $asset_file['dependencies'],
'version' => $asset_file['version'],
];
return $key ? $script[$key] : $script;
}
protected function getEditorStyle($key = null) {
$path = Env::$assetsUrl . '/dist/js/email-editor-blocks/style-' . $this->blockName . '-block.css';
if (!file_exists($path)) {
return null;
}
$style = [
'handle' => 'mailpoet-' . $this->blockName . '-block',
'path' => Env::$assetsUrl . '/dist/js/email-editor-blocks/style-' . $this->blockName . '-block.css',
];
return $key ? $style[$key] : $style;
}
protected function addSpacer($content, $emailAttrs): string {
$gapStyle = WP_Style_Engine::compile_css(array_intersect_key($emailAttrs, array_flip(['margin-top'])), '');
$paddingStyle = WP_Style_Engine::compile_css(array_intersect_key($emailAttrs, array_flip(['padding-left', 'padding-right'])), '');
if (!$gapStyle && !$paddingStyle) {
return $content;
}
return sprintf(
'<!--[if mso | IE]><table align="left" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%" style="%2$s"><tr><td style="%3$s"><![endif]-->
<div class="email-block-layout" style="%2$s %3$s">%1$s</div>
<!--[if mso | IE]></td></tr></table><![endif]-->',
$content,
esc_attr($gapStyle),
esc_attr($paddingStyle)
);
}
abstract public function render($attributes, $content, $block);
}

View File

@ -0,0 +1,35 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet\Blocks\BlockTypes;
use MailPoet\Config\ServicesChecker;
use MailPoet\Util\CdnAssetUrl;
class PoweredByMailpoet extends AbstractBlock {
private ServicesChecker $servicesChecker;
private CdnAssetUrl $cdnAssetUrl;
protected $blockName = 'powered-by-mailpoet';
public function __construct(
ServicesChecker $servicesChecker,
CdnAssetUrl $cdnAssetUrl
) {
$this->cdnAssetUrl = $cdnAssetUrl;
$this->servicesChecker = $servicesChecker;
}
public function render($attributes, $content, $block) {
if ($this->servicesChecker->isPremiumPluginActive()) {
return '';
}
$logo = $attributes['logo'] ?? 'default';
$logoUrl = $this->cdnAssetUrl->generateCdnUrl('email-editor/logo-' . $logo . '.png');
return $this->addSpacer(sprintf(
'<div class="%1$s" style="text-align:center">%2$s</div>',
esc_attr('wp-block-' . $this->blockName),
'<img src="' . esc_attr($logoUrl) . '" alt="Powered by MailPoet" width="100px" />'
), $block->parsed_block['email_attrs'] ?? []); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
}

View File

@ -0,0 +1,19 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet\Blocks;
use MailPoet\EmailEditor\Integrations\MailPoet\Blocks\BlockTypes\PoweredByMailpoet;
class BlockTypesController {
private $poweredByMailPoet;
public function __construct(
PoweredByMailpoet $poweredByMailPoet
) {
$this->poweredByMailPoet = $poweredByMailPoet;
}
public function initialize(): void {
$this->poweredByMailPoet->initialize();
}
}

View File

@ -0,0 +1,71 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Newsletter\NewsletterSaveController;
use MailPoet\WP\Functions as WPFunctions;
use WP_CLI;
class Cli {
private const TEMPLATES = [
[
'name' => 'testing-email-with-core-blocks',
'subject' => 'Hey [subscriber:firstname | default:subscriber], we test new email editor!',
'preheader' => 'This is a testing email containing core blocks with different configurations.',
],
];
private NewsletterSaveController $newsletterSaveController;
private WPFunctions $wp;
public function __construct(
NewsletterSaveController $newsletterSaveController,
WPFunctions $wp
) {
$this->newsletterSaveController = $newsletterSaveController;
$this->wp = $wp;
}
public function initialize(): void {
if (!class_exists(WP_CLI::class)) {
return;
}
WP_CLI::add_command('mailpoet:email-editor:create-templates', [$this, 'createTemplates'], [
'shortdesc' => 'Create MailPoet email editor templates',
]);
}
public function createTemplates(): void {
WP_CLI::log("Starting creating MailPoet email editor templates.");
foreach (self::TEMPLATES as $template) {
$content = file_get_contents(__DIR__ . "/templates/{$template['name']}.html");
$newsletter = $this->newsletterSaveController->save([
'subject' => $template['subject'],
'preheader' => $template['preheader'],
'type' => NewsletterEntity::TYPE_STANDARD,
'new_editor' => true,
]);
$wpPost = $newsletter->getWpPost();
if (!$wpPost) {
WP_CLI::error("Failed to create a post for the email template {$template['name']}.");
}
$this->wp->wpUpdatePost([
'ID' => $wpPost->getId(),
'post_title' => $this->getTemplateName($template['name']),
'post_content' => $content,
]);
WP_CLI::log("Created a new email template {$template['name']}.");
}
WP_CLI::log('Finished creating MailPoet email editor templates.');
}
private function getTemplateName(string $templateName): string {
$name = str_replace('-', ' ', $templateName);
return ucwords($name);
}
}

View File

@ -0,0 +1,88 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Url as NewsletterUrl;
use MailPoet\NotFoundException;
use MailPoet\UnexpectedValueException;
use MailPoet\Validator\Builder;
class EmailApiController {
/** @var NewslettersRepository */
private $newsletterRepository;
/** @var NewsletterUrl */
private $newsletterUrl;
public function __construct(
NewslettersRepository $newsletterRepository,
NewsletterUrl $newsletterUrl
) {
$this->newsletterRepository = $newsletterRepository;
$this->newsletterUrl = $newsletterUrl;
}
/**
* @param array $postEmailData - WP_Post data
* @return array - MailPoet specific email data that will be attached to the post API response
*/
public function getEmailData($postEmailData): array {
$newsletter = $this->newsletterRepository->findOneBy(['wpPost' => $postEmailData['id']]);
return [
'id' => $newsletter ? $newsletter->getId() : null,
'subject' => $newsletter ? $newsletter->getSubject() : '',
'preheader' => $newsletter ? $newsletter->getPreheader() : '',
'preview_url' => $this->newsletterUrl->getViewInBrowserUrl($newsletter),
'deleted_at' => $newsletter && $newsletter->getDeletedAt() !== null ? $newsletter->getDeletedAt()->format('c') : null,
];
}
/**
* Update MailPoet specific data we store with Emails.
*/
public function saveEmailData(array $data, \WP_Post $emailPost): void {
$newsletter = $this->newsletterRepository->findOneById($data['id']);
if (!$newsletter) {
throw new NotFoundException('Newsletter was not found');
}
if ($newsletter->getWpPostId() !== $emailPost->ID) {
throw new UnexpectedValueException('Newsletter ID does not match the post ID');
}
$newsletter->setSubject($data['subject']);
$newsletter->setPreheader($data['preheader']);
if (isset($data['deleted_at'])) {
if (empty($data['deleted_at'])) {
$data['deleted_at'] = null;
} else {
$data['deleted_at'] = new \DateTime($data['deleted_at']);
}
$newsletter->setDeletedAt($data['deleted_at']);
}
$this->newsletterRepository->flush();
}
public function trashEmail(\WP_Post $wpPost) {
$newsletter = $this->newsletterRepository->findOneBy(['wpPost' => $wpPost->ID]);
if (!$newsletter) {
throw new NotFoundException('Newsletter was not found');
}
if ($newsletter->getWpPostId() !== $wpPost->ID) {
throw new UnexpectedValueException('Newsletter ID does not match the post ID');
}
$this->newsletterRepository->bulkTrash([$newsletter->getId()]);
}
public function getEmailDataSchema(): array {
return Builder::object([
'id' => Builder::integer()->nullable(),
'subject' => Builder::string(),
'preheader' => Builder::string(),
'preview_url' => Builder::string(),
'deleted_at' => Builder::string()->nullable(),
])->toArray();
}
}

View File

@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet;
use MailPoet\Config\Menu;
use MailPoet\Features\FeaturesController;
use MailPoet\WP\Functions as WPFunctions;
class EmailEditor {
const MAILPOET_EMAIL_POST_TYPE = 'mailpoet_email';
private WPFunctions $wp;
private FeaturesController $featuresController;
private EmailApiController $emailApiController;
private Cli $cli;
public function __construct(
WPFunctions $wp,
FeaturesController $featuresController,
EmailApiController $emailApiController,
Cli $cli
) {
$this->wp = $wp;
$this->featuresController = $featuresController;
$this->emailApiController = $emailApiController;
$this->cli = $cli;
}
public function initialize(): void {
if (!$this->featuresController->isSupported(FeaturesController::GUTENBERG_EMAIL_EDITOR)) {
return;
}
$this->cli->initialize();
$this->wp->addFilter('mailpoet_email_editor_post_types', [$this, 'addEmailPostType']);
$this->wp->addAction('rest_delete_mailpoet_email', [$this->emailApiController, 'trashEmail'], 10, 1);
$this->wp->addFilter('mailpoet_is_email_editor_page', [$this, 'isEditorPage'], 10, 1);
$this->extendEmailPostApi();
}
public function addEmailPostType(array $postTypes): array {
$postTypes[] = [
'name' => self::MAILPOET_EMAIL_POST_TYPE,
'args' => [
'labels' => [
'name' => __('Emails', 'mailpoet'),
'singular_name' => __('Email', 'mailpoet'),
],
'rewrite' => ['slug' => self::MAILPOET_EMAIL_POST_TYPE],
],
];
return $postTypes;
}
public function isEditorPage(bool $isEditorPage): bool {
return $isEditorPage || (isset($_GET['page']) && $_GET['page'] === Menu::EMAIL_EDITOR_V2_PAGE_SLUG);
}
public function extendEmailPostApi() {
$this->wp->registerRestField(self::MAILPOET_EMAIL_POST_TYPE, 'mailpoet_data', [
'get_callback' => [$this->emailApiController, 'getEmailData'],
'update_callback' => [$this->emailApiController, 'saveEmailData'],
'schema' => $this->emailApiController->getEmailDataSchema(),
]);
}
}

View File

@ -0,0 +1,355 @@
<!-- wp:heading {"textAlign":"center","level":1,"align":"full","textColor":"luminous-vivid-orange","style":{"elements":{"link":{"color":{"text":"var:preset|color|luminous-vivid-orange"}}},"color":{"background":"#f0f0f0"}}} -->
<h1
class="wp-block-heading alignfull has-text-align-center has-luminous-vivid-orange-color has-text-color has-background has-link-color"
style="background-color: #f0f0f0"
>
Sample Newsletter
</h1>
<!-- /wp:heading -->
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column {"style":{"spacing":{"padding":{"right":"10px","left":"10px"}}}} -->
<div class="wp-block-column" style="padding-right: 10px; padding-left: 10px">
<!-- wp:heading {"backgroundColor":"pale-cyan-blue","style":{"spacing":{"padding":{"right":"var:preset|spacing|20","left":"var:preset|spacing|20"}}}} -->
<h2
class="wp-block-heading has-pale-cyan-blue-background-color has-background"
style="
padding-right: var(--wp--preset--spacing--20);
padding-left: var(--wp--preset--spacing--20);
"
>
1 Column Part
</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>
A one-column layout is great for simplified and concise content, like
announcements or newsletters with brief updates. Drag blocks to add
content and customize your styles from the styles panel on the top right.
</p>
<!-- /wp:paragraph -->
<!-- wp:image {"sizeSlug":"large"} -->
<figure class="wp-block-image size-large">
<img src="https://picsum.photos/id/133/600/300" alt="" />
</figure>
<!-- /wp:image -->
<!-- wp:buttons {"layout":{"justifyContent":"center"}} -->
<div class="wp-block-buttons">
<!-- wp:button {"backgroundColor":"pale-cyan-blue","textColor":"black","width":100,"style":{"elements":{"link":{"color":{"text":"var:preset|color|black"}}}}} -->
<div class="wp-block-button has-custom-width wp-block-button__width-100">
<a
class="wp-block-button__link has-black-color has-pale-cyan-blue-background-color has-text-color has-background has-link-color wp-element-button"
>Button</a
>
</div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
<!-- wp:heading {"textAlign":"center","align":"full","style":{"color":{"background":"#f0f0f0"},"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20","left":"0","right":"0"}}}} -->
<h2
class="wp-block-heading alignfull has-text-align-center has-background"
style="
background-color: #f0f0f0;
padding-top: var(--wp--preset--spacing--20);
padding-right: 0;
padding-bottom: var(--wp--preset--spacing--20);
padding-left: 0;
"
>
2 Columns Part
</h2>
<!-- /wp:heading -->
<!-- wp:paragraph {"fontFamily":"comic-sans-ms"} -->
<p class="has-comic-sans-ms-font-family">
A two-column layout organizes information into sections, making it easier for
users to navigate content. Try other layouts by adding or removing columns,
drag blocks into them to add content and customize your email styles from the
styles panel.
</p>
<!-- /wp:paragraph -->
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:image {"width":"280px","sizeSlug":"large","align":"left"} -->
<figure class="wp-block-image alignleft size-large is-resized">
<img
src="https://picsum.photos/id/133/300/200"
alt=""
style="width: 280px"
/>
</figure>
<!-- /wp:image -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"300px","style":{"spacing":{"padding":{"right":"0","left":"0"}}}} -->
<div
class="wp-block-column"
style="padding-right: 0; padding-left: 0; flex-basis: 300px"
>
<!-- wp:image {"width":"280px","sizeSlug":"large","align":"right"} -->
<figure class="wp-block-image alignright size-large is-resized">
<img
src="https://picsum.photos/id/133/300/200"
alt=""
style="width: 280px"
/>
</figure>
<!-- /wp:image -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"level":3,"textColor":"vivid-red","style":{"elements":{"link":{"color":{"text":"var:preset|color|vivid-red"}}}}} -->
<h3
class="wp-block-heading has-vivid-red-color has-text-color has-link-color"
>
Heading 2
</h3>
<!-- /wp:heading -->
<!-- wp:paragraph {"style":{"typography":{"fontStyle":"italic","fontWeight":"700"}}} -->
<p style="font-style: italic; font-weight: 700">
You can also add text blocks into a column next to
<mark
style="background-color: #dc2d2d"
class="has-inline-color has-white-color"
>an image block</mark
>
to create unique layouts.
</p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"justifyContent":"right"}} -->
<div class="wp-block-buttons">
<!-- wp:button {"backgroundColor":"pale-cyan-blue","textColor":"contrast","width":50,"style":{"elements":{"link":{"color":{"text":"var:preset|color|contrast"}}}}} -->
<div class="wp-block-button has-custom-width wp-block-button__width-50">
<a
class="wp-block-button__link has-contrast-color has-pale-cyan-blue-background-color has-text-color has-background has-link-color wp-element-button"
>Button</a
>
</div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"300px","style":{"spacing":{"padding":{"right":"0","left":"0"}}}} -->
<div
class="wp-block-column"
style="padding-right: 0; padding-left: 0; flex-basis: 300px"
>
<!-- wp:image {"width":"280px","height":"auto","sizeSlug":"large","align":"right"} -->
<figure class="wp-block-image alignright size-large is-resized">
<img
src="https://picsum.photos/id/133/300/200"
alt=""
style="width: 280px; height: auto"
/>
</figure>
<!-- /wp:image -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
<!-- wp:columns {"style":{"spacing":{"padding":{"right":"var:preset|spacing|10","left":"var:preset|spacing|10"}}}} -->
<div
class="wp-block-columns"
style="
padding-right: var(--wp--preset--spacing--10);
padding-left: var(--wp--preset--spacing--10);
"
>
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"textAlign":"center","level":4} -->
<h4 class="wp-block-heading has-text-align-center">Product 1</h4>
<!-- /wp:heading -->
<!-- wp:image {"width":"150px","sizeSlug":"large","align":"center"} -->
<figure class="wp-block-image aligncenter size-large is-resized">
<img
src="https://picsum.photos/id/36/180/200"
alt=""
style="width: 150px"
/>
</figure>
<!-- /wp:image -->
<!-- wp:list -->
<ul>
<!-- wp:list-item -->
<li>Item 1</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>
Item 2
<!-- wp:list {"textColor":"vivid-red","style":{"elements":{"link":{"color":{"text":"var:preset|color|vivid-red"}}},"typography":{"fontStyle":"italic","fontWeight":"400"}}} -->
<ul
style="font-style: italic; font-weight: 400"
class="has-vivid-red-color has-text-color has-link-color"
>
<!-- wp:list-item -->
<li>Item A</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Item B</li>
<!-- /wp:list-item -->
</ul>
<!-- /wp:list -->
</li>
<!-- /wp:list-item -->
</ul>
<!-- /wp:list -->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"textAlign":"center","level":4} -->
<h4 class="wp-block-heading has-text-align-center">Product 2</h4>
<!-- /wp:heading -->
<!-- wp:image {"width":"150px","sizeSlug":"large","align":"center"} -->
<figure class="wp-block-image aligncenter size-large is-resized">
<img
src="https://picsum.photos/id/36/180/200"
alt=""
style="width: 150px"
/>
</figure>
<!-- /wp:image -->
<!-- wp:list {"style":{"typography":{"textDecoration":"none"}}} -->
<ul style="text-decoration: none">
<!-- wp:list-item -->
<li>Item 1</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>
Item 2
<!-- wp:list {"textColor":"vivid-cyan-blue","style":{"elements":{"link":{"color":{"text":"var:preset|color|vivid-cyan-blue"}}},"typography":{"textDecoration":"underline"}}} -->
<ul
style="text-decoration: underline"
class="has-vivid-cyan-blue-color has-text-color has-link-color"
>
<!-- wp:list-item -->
<li>Item A</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Item B</li>
<!-- /wp:list-item -->
</ul>
<!-- /wp:list -->
</li>
<!-- /wp:list-item -->
</ul>
<!-- /wp:list -->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"textAlign":"center","level":4} -->
<h4 class="wp-block-heading has-text-align-center">Product 3</h4>
<!-- /wp:heading -->
<!-- wp:image {"width":"150px","sizeSlug":"large","align":"center"} -->
<figure class="wp-block-image aligncenter size-large is-resized">
<img
src="https://picsum.photos/id/36/180/200"
alt=""
style="width: 150px"
/>
</figure>
<!-- /wp:image -->
<!-- wp:list {"ordered":true} -->
<ol>
<!-- wp:list-item -->
<li>Item 1</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>
Item 2
<!-- wp:list {"textColor":"vivid-green-cyan","style":{"elements":{"link":{"color":{"text":"var:preset|color|vivid-green-cyan"}}},"typography":{"fontStyle":"normal","fontWeight":"700"}}} -->
<ul
style="font-style: normal; font-weight: 700"
class="has-vivid-green-cyan-color has-text-color has-link-color"
>
<!-- wp:list-item -->
<li>Item A</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Item B</li>
<!-- /wp:list-item -->
</ul>
<!-- /wp:list -->
</li>
<!-- /wp:list-item -->
</ol>
<!-- /wp:list -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
<!-- wp:buttons {"align":"full","style":{"spacing":{"blockGap":"var:preset|spacing|30"}},"layout":{"justifyContent":"center"}} -->
<div class="wp-block-buttons alignfull">
<!-- wp:button {"width":25} -->
<div class="wp-block-button has-custom-width wp-block-button__width-25">
<a class="wp-block-button__link wp-element-button">Button 1</a>
</div>
<!-- /wp:button -->
<!-- wp:button {"width":25} -->
<div class="wp-block-button has-custom-width wp-block-button__width-25">
<a class="wp-block-button__link wp-element-button">Button 2</a>
</div>
<!-- /wp:button -->
<!-- wp:button {"width":25} -->
<div class="wp-block-button has-custom-width wp-block-button__width-25">
<a class="wp-block-button__link wp-element-button">Button 3</a>
</div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
<!-- wp:paragraph {"align":"center","fontSize":"small"} -->
<p class="has-text-align-center has-small-font-size">
You received this email because you are subscribed to the [site:title]
</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"align":"center","fontSize":"small"} -->
<p class="has-text-align-center has-small-font-size">
<a href="[link:subscription_unsubscribe_url]">Unsubscribe</a> |
<a href="[link:subscription_manage_url]">Manage subscription</a>
</p>
<!-- /wp:paragraph -->

View File

@ -0,0 +1,60 @@
<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\Utils;
/**
* This class should guarantee that our work with the DOMDocument is unified and safe.
*/
class DomDocumentHelper {
private \DOMDocument $dom;
public function __construct(
string $htmlContent
) {
$this->loadHtml($htmlContent);
}
private function loadHtml(string $htmlContent): void {
libxml_use_internal_errors(true);
$this->dom = new \DOMDocument();
if (!empty($htmlContent)) {
// prefixing the content with the XML declaration to force the input encoding to UTF-8
$this->dom->loadHTML('<?xml encoding="UTF-8">' . $htmlContent, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
}
libxml_clear_errors();
}
public function findElement(string $tagName): ?\DOMElement {
$elements = $this->dom->getElementsByTagName($tagName);
return $elements->item(0) ?: null;
}
public function getAttributeValue(\DOMElement $element, string $attribute): string {
return $element->hasAttribute($attribute) ? $element->getAttribute($attribute) : '';
}
/**
* Searches for the first appearance of the given tag name and returns the value of specified attribute.
*/
public function getAttributeValueByTagName(string $tagName, string $attribute): ?string {
$element = $this->findElement($tagName);
if (!$element) {
return null;
}
return $this->getAttributeValue($element, $attribute);
}
public function getOuterHtml(\DOMElement $element): string {
return (string)$this->dom->saveHTML($element);
}
public function getElementInnerHTML(\DOMElement $element): string {
$innerHTML = '';
$children = $element->childNodes;
foreach ($children as $child) {
if (!$child instanceof \DOMNode) continue;
$innerHTML .= $this->dom->saveHTML($child);
}
return $innerHTML;
}
}