From 5a30354418ca690ab97cd519e586ce0b3eb2c495 Mon Sep 17 00:00:00 2001 From: Adam Boro Date: Thu, 9 Apr 2020 16:27:35 +0200 Subject: [PATCH] feat: blocks to MJML conversion (#5) --- .gitignore | 1 + README.md | 13 +- assets/facebook.svg | 1 + assets/instagram.svg | 1 + assets/linkedin.svg | 1 + assets/twitter.svg | 1 + assets/wordpress.svg | 1 + assets/youtube.svg | 1 + .../class-newspack-newsletters-renderer.php | 472 ++++++++++++++++++ includes/class-newspack-newsletters.php | 43 +- includes/email-template.mjml.php | 64 +++ src/editor/style.scss | 18 +- 12 files changed, 607 insertions(+), 10 deletions(-) create mode 100644 assets/facebook.svg create mode 100644 assets/instagram.svg create mode 100644 assets/linkedin.svg create mode 100644 assets/twitter.svg create mode 100644 assets/wordpress.svg create mode 100644 assets/youtube.svg create mode 100644 includes/class-newspack-newsletters-renderer.php create mode 100644 includes/email-template.mjml.php diff --git a/.gitignore b/.gitignore index f4ee63937..b39ca2914 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor/ /node_modules/ /dist/ +.DS_Store diff --git a/README.md b/README.md index f163b15a6..74935c2a8 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,19 @@ Author email newsletters in WordPress ## Use -Copy your Mailchimp API key, which can be found in Mailchimp in `Account->Extras->API Keys`. Navigate to `Settings->Newspack Newsletters`. Input Mailchimp API key. Click Newsletters in the left menu. Create a new one. +Copy your Mailchimp API key, which can be found in Mailchimp in `Account->Extras->API Keys`. Navigate to `Settings->Newspack Newsletters`. Input Mailchimp API key. Click Newsletters in the left menu. Create a new one. ## Development Run `composer update && npm install`. -Run `npm run build`. \ No newline at end of file +Run `npm run build`. + +#### Environment variables + +This feature requires environment variables to be set (e.g. in `wp-config.php`): + +```php +define( 'NEWSPACK_MJML_API_KEY', 'abc1' ); +define( 'NEWSPACK_MJML_API_SECRET', 'abc1' ); +``` diff --git a/assets/facebook.svg b/assets/facebook.svg new file mode 100644 index 000000000..0260b0e9a --- /dev/null +++ b/assets/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/instagram.svg b/assets/instagram.svg new file mode 100644 index 000000000..ac8cd4598 --- /dev/null +++ b/assets/instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/linkedin.svg b/assets/linkedin.svg new file mode 100644 index 000000000..9dbb9f5d7 --- /dev/null +++ b/assets/linkedin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/twitter.svg b/assets/twitter.svg new file mode 100644 index 000000000..acce63b3e --- /dev/null +++ b/assets/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/wordpress.svg b/assets/wordpress.svg new file mode 100644 index 000000000..2316cbf0d --- /dev/null +++ b/assets/wordpress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/youtube.svg b/assets/youtube.svg new file mode 100644 index 000000000..9aec89aa0 --- /dev/null +++ b/assets/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/includes/class-newspack-newsletters-renderer.php b/includes/class-newspack-newsletters-renderer.php new file mode 100644 index 000000000..78dc7743a --- /dev/null +++ b/includes/class-newspack-newsletters-renderer.php @@ -0,0 +1,472 @@ + '13px', + 'normal' => '16px', + 'medium' => '20px', + 'large' => '36px', + 'huge' => '48px', + ); + return $sizes[ $block_attrs['fontSize'] ]; + } + return '16px'; + } + + /** + * Get colors based on block attributes. + * + * @param array $block_attrs Block attributes. + * @param bool $set_container_bg_color Whether to set container color. + * @return array Array of color attributes for MJML component. + */ + private static function get_colors( $block_attrs, $set_container_bg_color = true ) { + $colors = array(); + // Gutenberg's default color palette. + // https://github.com/WordPress/gutenberg/blob/359858da0675943d8a759a0a7c03e7b3846536f5/packages/block-editor/src/store/defaults.js#L30-L85 . + $colors_palette = array( + 'pale-pink' => '#f78da7', + 'vivid-red' => '#cf2e2e', + 'luminous-vivid-orange' => '#ff6900', + 'luminous-vivid-amber' => '#fcb900', + 'light-green-cyan' => '#7bdcb5', + 'vivid-green-cyan' => '#00d084', + 'pale-cyan-blue' => '#8ed1fc', + 'vivid-cyan-blue' => '#0693e3', + 'very-light-gray' => '#eeeeee', + 'cyan-bluish-gray' => '#abb8c3', + 'very-dark-gray' => '#313131', + ); + + // For text. + if ( isset( $block_attrs['textColor'] ) ) { + $colors['color'] = $colors_palette[ $block_attrs['textColor'] ]; + } + // customTextColor is set inline, but it's passed here for consistency. + if ( isset( $block_attrs['customTextColor'] ) ) { + $colors['color'] = $block_attrs['customTextColor']; + } + if ( isset( $block_attrs['backgroundColor'] ) ) { + if ( $set_container_bg_color ) { + $colors['container-background-color'] = $colors_palette[ $block_attrs['backgroundColor'] ]; + } + $colors['background-color'] = $colors_palette[ $block_attrs['backgroundColor'] ]; + } + // customBackgroundColor is set inline, but not on mjml wrapper element. + if ( isset( $block_attrs['customBackgroundColor'] ) ) { + if ( $set_container_bg_color ) { + $colors['container-background-color'] = $block_attrs['customBackgroundColor']; + } + $colors['background-color'] = $block_attrs['customBackgroundColor']; + } + + // For separators. + if ( isset( $block_attrs['color'] ) ) { + $colors['border-color'] = $colors_palette[ $block_attrs['color'] ]; + } + if ( isset( $block_attrs['customColor'] ) ) { + $colors['border-color'] = $block_attrs['customColor']; + } + return $colors; + } + + /** + * Render MJML component for a Gutenberg block. + * + * @param WP_Block $block The block. + * @param bool $is_in_column Whether the component is a child of a column component. + * @return string MJML component. + */ + private static function render_mjml_component( $block, $is_in_column = false ) { + $block_name = $block['blockName']; + $attrs = $block['attrs']; + $inner_blocks = $block['innerBlocks']; + $inner_html = $block['innerHTML']; + + if ( empty( $block_name ) || empty( $inner_html ) ) { + return ''; + } + + $block_mjml_markup = ''; + $section_attrs = array( + 'padding' => '0', + ); + $column_attrs = array( + 'padding' => '10px 16px', + ); + + switch ( $block_name ) { + /** + * Paragraph, List, Heading blocks. + */ + case 'core/paragraph': + case 'core/list': + case 'core/heading': + case 'core/quote': + // TODO disable/handle/warn for: + // - without inline image + // - drop cap? + $text_attrs = array_merge( + array( + 'padding' => '0', + 'align' => isset( $attrs['align'] ) ? $attrs['align'] : false, + 'font-size' => self::get_font_size( $attrs ), + 'line-height' => '1.8', + ), + self::get_colors( $attrs ) + ); + + $block_mjml_markup = '' . $inner_html . ''; + break; + + /** + * Image block. + */ + case 'core/image': + // TODO disable/handle/warn for: + // - align right, align left. + + // Parse block content. + $dom = new DomDocument(); + @$dom->loadHTML( $inner_html ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $xpath = new DOMXpath( $dom ); + $img = $xpath->query( '//img' )[0]; + $img_src = $img->getAttribute( 'src' ); + $figcaption = $xpath->query( '//figcaption/text()' )[0]; + + $img_attrs = array( + 'padding' => '0', + 'align' => isset( $attrs['align'] ) ? $attrs['align'] : 'left', + 'src' => $img_src, + ); + + if ( isset( $attrs['sizeSlug'] ) ) { + if ( 'medium' == $attrs['sizeSlug'] ) { + $img_attrs['width'] = '300px'; + } + if ( 'thumbnail' == $attrs['sizeSlug'] ) { + $img_attrs['width'] = '150px'; + } + } + if ( isset( $attrs['width'] ) ) { + $img_attrs['width'] = $attrs['width'] . 'px'; + } + if ( isset( $attrs['height'] ) ) { + $img_attrs['height'] = $attrs['height'] . 'px'; + } + + if ( isset( $attrs['className'] ) && strpos( $attrs['className'], 'is-style-rounded' ) !== false ) { + $img_attrs['border-radius'] = '999px'; + } + $markup = ''; + + if ( $figcaption ) { + $caption_attrs = array( + 'align' => 'center', + 'color' => '#555d66', + 'font-size' => '13px', + ); + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $markup .= '' . $figcaption->wholeText . ''; + } + + $block_mjml_markup = $markup; + break; + + /** + * Buttons block. + */ + case 'core/buttons': + // TODO disable/handle/warn for: + // - layouts. + // - align right, align left (it will always be center aligned, in columns. mjml does not handle one next to another). + // - gradients. + + foreach ( $inner_blocks as $button_block ) { + // Parse block content. + $dom = new DomDocument(); + @$dom->loadHTML( $button_block['innerHTML'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $xpath = new DOMXpath( $dom ); + $anchor = $xpath->query( '//a' )[0]; + $attrs = $button_block['attrs']; + $text = $anchor->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $border_radius = isset( $attrs['borderRadius'] ) ? $attrs['borderRadius'] : 28; + $is_outlined = isset( $attrs['className'] ) && 'is-style-outline' == $attrs['className']; + + $default_button_attrs = array( + 'padding' => '0', + 'href' => $anchor->getAttribute( 'href' ), + 'border-radius' => $border_radius . 'px', + 'font-size' => '18px', + // Default color - will be replaced by get_colors if there are colors set. + 'color' => $is_outlined ? '#32373c' : '#fff', + ); + if ( $is_outlined ) { + $default_button_attrs['background-color'] = 'transparent'; + } + $button_attrs = array_merge( + $default_button_attrs, + self::get_colors( $attrs, false ) + ); + + if ( $is_outlined ) { + $button_attrs['css-class'] = $attrs['className']; + } + + $block_mjml_markup .= '$text"; + } + + + break; + + /** + * Separator block. + */ + case 'core/separator': + // TODO disable/handle/warn for: + // - dots style - it wont be supported. + $is_style_default = 'is-style-default' == $attrs['className']; + $divider_attrs = array_merge( + array( + 'padding' => '0', + 'css-class' => $attrs['className'], + 'border-width' => $is_style_default ? '2px' : '1px', + 'width' => $is_style_default ? '100px' : '100%', + // Default color - will be replaced by get_colors if there are colors set. + 'border-color' => '#8f98a1', + ), + self::get_colors( $attrs ) + ); + $block_mjml_markup .= ''; + + break; + + /** + * Social links block. + */ + case 'core/social-links': + // TODO disable/handle/warn for: + // - styles. Pill could be supported, though. + // - align right, align left (it will always be center aligned, in columns. mjml does not handle one next to another). + + $social_icons = array( + 'wordpress' => array( + 'color' => '#3499cd', + 'icon' => 'wordpress.svg', + ), + 'facebook' => array( + 'color' => '#1977f2', + 'icon' => 'facebook.svg', + ), + 'twitter' => array( + 'color' => '#21a1f3', + 'icon' => 'twitter.svg', + ), + 'instagram' => array( + 'color' => '#f00075', + 'icon' => 'instagram.svg', + ), + 'linkedin' => array( + 'color' => '#0577b5', + 'icon' => 'linkedin.svg', + ), + 'youtube' => array( + 'color' => '#ff0100', + 'icon' => 'youtube.svg', + ), + ); + + $social_wrapper_attrs = array( + 'align' => isset( $attrs['align'] ) && 'center' == $attrs['align'] ? 'center' : 'left', + 'icon-size' => '22px', + 'mode' => 'horizontal', + 'padding' => '5px', + 'border-radius' => '999px', + 'icon-padding' => '8px', + ); + $markup = ''; + foreach ( $inner_blocks as $link_block ) { + if ( isset( $link_block['attrs']['url'] ) ) { + $url = $link_block['attrs']['url']; + // Handle older version of the block, where innner blocks we named `core/social-link-`. + $service_name = isset( $link_block['attrs']['service'] ) ? $link_block['attrs']['service'] : str_replace( 'core/social-link-', '', $link_block['blockName'] ); + + if ( isset( $social_icons[ $service_name ] ) ) { + $img_attrs = array( + 'href' => $url, + 'src' => plugins_url( 'assets/' . $social_icons[ $service_name ]['icon'], dirname( __FILE__ ) ), + 'background-color' => $social_icons[ $service_name ]['color'], + 'css-class' => 'social-element', + ); + + $markup .= ''; + } + } + } + $block_mjml_markup .= $markup . ''; + + break; + + /** + * Single Column block. + */ + case 'core/column': + // TODO disable/handle/warn for: + // - alignments. Middle/center will not work in mjml, top and bottom are looking slightly different in G editor and MJML. + // - nested colums. Not allowed in MJML. + + if ( isset( $attrs['verticalAlignment'] ) ) { + if ( 'center' == $attrs['verticalAlignment'] ) { + $column_attrs['vertical-align'] = 'middle'; + } else { + $column_attrs['vertical-align'] = $attrs['verticalAlignment']; + } + } + if ( isset( $attrs['width'] ) ) { + $column_attrs['width'] = $attrs['width'] . '%'; + } + + $markup = ''; + foreach ( $inner_blocks as $block ) { + $markup .= self::render_mjml_component( $block, true ); + } + $block_mjml_markup = $markup . ''; + break; + + /** + * Columns block. + */ + case 'core/columns': + $markup = ''; + foreach ( $inner_blocks as $block ) { + $markup .= self::render_mjml_component( $block, true ); + } + + $block_mjml_markup = $markup; + break; + } + + if ( ! $is_in_column && 'core/columns' != $block_name && 'core/column' != $block_name && 'core/buttons' != $block_name ) { + $column_attrs['width'] = '100%'; + $block_mjml_markup = '' . $block_mjml_markup . ''; + } + if ( $is_in_column ) { + // For a nested block, render without a wrapping section. + return $block_mjml_markup; + } else { + return '' . $block_mjml_markup . ''; + } + } + + /** + * Convert a WP post to MJML markup. + * + * @param WP_Post $post The post. + * @return string MJML markup. + */ + private static function render_mjml( $post ) { + $title = $post->post_title; + $blocks = parse_blocks( $post->post_content ); + $body = ''; + foreach ( $blocks as $block ) { + $block_content = self::render_mjml_component( $block ); + if ( ! empty( $block_content ) ) { + $body .= $block_content; + } + } + + ob_start(); + include dirname( __FILE__ ) . '/email-template.mjml.php'; + return ob_get_clean(); + } + + /** + * Return MJML API credentials. + * + * @return string API key and API secret as a key:secret string. + */ + public static function mjml_api_credentials() { + $key = ( defined( 'NEWSPACK_MJML_API_KEY' ) && NEWSPACK_MJML_API_KEY ) ? NEWSPACK_MJML_API_KEY : false; + $secret = ( defined( 'NEWSPACK_MJML_API_SECRET' ) && NEWSPACK_MJML_API_SECRET ) ? NEWSPACK_MJML_API_SECRET : false; + if ( isset( $key, $secret ) ) { + return "$key:$secret"; + } + return false; + } + + /** + * Convert a WP Post to email-compliant HTML. + * + * @param WP_Post $post The post. + * @return string email-compliant HTML. + */ + public static function render_html_email( $post ) { + $mjml_creds = self::mjml_api_credentials(); + if ( $mjml_creds ) { + $mjml_api_url = 'https://api.mjml.io/v1/render'; + $request = wp_remote_post( + $mjml_api_url, + array( + 'body' => wp_json_encode( + array( + 'mjml' => self::render_mjml( $post ), + ) + ), + 'headers' => array( + 'Authorization' => 'Basic ' . base64_encode( $mjml_creds ), + ), + ) + ); + + $email_html = json_decode( $request['body'] )->html; + return $email_html; + } + } +} diff --git a/includes/class-newspack-newsletters.php b/includes/class-newspack-newsletters.php index 0275e8d6a..892b3cda8 100644 --- a/includes/class-newspack-newsletters.php +++ b/includes/class-newspack-newsletters.php @@ -47,7 +47,9 @@ public function __construct() { add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] ); add_action( 'rest_api_init', [ __CLASS__, 'rest_api_init' ] ); add_action( 'publish_' . self::NEWSPACK_NEWSLETTERS_CPT, [ __CLASS__, 'newsletter_published' ], 10, 2 ); + add_filter( 'allowed_block_types', [ __CLASS__, 'newsletters_allowed_block_types' ], 10, 2 ); include_once dirname( __FILE__ ) . '/class-newspack-newsletters-settings.php'; + include_once dirname( __FILE__ ) . '/class-newspack-newsletters-renderer.php'; } /** @@ -83,6 +85,30 @@ public static function register_cpt() { \register_post_type( self::NEWSPACK_NEWSLETTERS_CPT, $cpt_args ); } + /** + * Restrict block types for Newsletter CPT. + * + * @param array $allowed_block_types default block types. + * @param WP_Post $post the post to consider. + */ + public static function newsletters_allowed_block_types( $allowed_block_types, $post ) { + if ( self::NEWSPACK_NEWSLETTERS_CPT !== $post->post_type ) { + return $allowed_block_types; + } + return array( + 'core/paragraph', + 'core/heading', + 'core/column', + 'core/columns', + 'core/buttons', + 'core/image', + 'core/separator', + 'core/list', + 'core/quote', + 'core/social-links', + ); + } + /** * Load up common JS/CSS for wizards. */ @@ -99,6 +125,14 @@ public static function enqueue_block_editor_assets() { filemtime( NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/dist/editor.js' ), true ); + wp_register_style( + 'newspack-newsletters', + plugins_url( '../dist/editor.css', __FILE__ ), + [], + filemtime( NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/dist/editor.css' ) + ); + wp_style_add_data( 'newspack-newsletters', 'rtl', 'replace' ); + wp_enqueue_style( 'newspack-newsletters' ); } /** @@ -404,14 +438,9 @@ public static function newsletter_published( $id, $post ) { $campaign_id = $campaign['id']; update_post_meta( $id, 'mc_campaign_id', $campaign_id ); - $blocks = parse_blocks( $post->post_content ); - $body = sprintf( '

%s

', $post->post_title ); - foreach ( $blocks as $block ) { - $body .= render_block( $block ); - } - + $renderer = new Newspack_Newsletters_Renderer(); $content_payload = [ - 'html' => $body, + 'html' => $renderer->render_html_email( $post ), ]; $result = $mc->put( "campaigns/$campaign_id/content", $content_payload ); diff --git a/includes/email-template.mjml.php b/includes/email-template.mjml.php new file mode 100644 index 000000000..67d088914 --- /dev/null +++ b/includes/email-template.mjml.php @@ -0,0 +1,64 @@ + + + + + + + /* Paragraph */ + p.has-background { padding: 20px 30px !important; } + p { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + /* Button */ + .is-style-outline a { + background: none !important; + border: 2px solid !important; + } + + /* Heading */ + h1 { font-size: 2.64em; } + h2 { font-size: 2.15em; } + h3 { font-size: 1.76em; } + h4 { font-size: 1.45em; } + h5 { font-size: 1.2em; } + h6 { font-size: 1em; } + h1, h2, h3, h4, h5, h6 {margin: 0 !important;} + + /* Quote */ + .wp-block-quote { + margin: 0 0 28px; + padding-left: 1em; + } + .wp-block-quote cite { + color: #6c7781; + font-size: 13px; + } + .wp-block-quote.is-style-default { + border-left: 4px solid #000; + } + .wp-block-quote.is-style-large p { + font-size: 24px; + font-style: italic; + line-height: 1.6; + } + + /* Social links */ + .social-element img { + border-radius: 0 !important; + } + + + + + + diff --git a/src/editor/style.scss b/src/editor/style.scss index 67916978a..d265795e0 100644 --- a/src/editor/style.scss +++ b/src/editor/style.scss @@ -1 +1,17 @@ -// Hello. +// TODO hide title after it's not required in MC initial call +.editor-post-title { + // display: none; +} + +.wp-block { + max-width: 568px; +} + +.block-editor-block-list__layout { + font-family: Ubuntu, Helvetica, Arial, sans-serif; + + code { + background: none; + color: inherit; + } +}