Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.71% covered (warning)
88.71%
110 / 124
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Email_Design_Editor
90.91% covered (success)
90.91%
110 / 121
78.57% covered (warning)
78.57%
11 / 14
26.51
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 register_feature_flags
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_admin_page
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 on_load
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_fullscreen_body_class
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_assets
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 get_layout_css
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_block_editor_assets
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 get_screen_data
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_iframe_asset_settings
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 get_allowed_iframe_style_handles
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 get_resolved_assets
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 render
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The newsletter email design screen.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Feature_Flags\Feature_Flags;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * Registers an Appearance page that mounts the WooCommerce email editor against the
16 * newsletter template, so a creator sets their email design once for the whole site.
17 *
18 * The design itself lives on the WordPress.com shadow blog: the browser fetches it from
19 * `/wpcom/v2/email-editor-bootstrap` and this page supplies only what describes *this*
20 * installation. See NL-839.
21 */
22class Jetpack_Email_Design_Editor {
23
24    /**
25     * The `page` query arg the screen answers to.
26     */
27    const PAGE_SLUG = 'jetpack-email-design';
28
29    /**
30     * The feature flag gating the screen. Registered off, forced on for testing with
31     * `wp companion feature-flag enable jetpack-email-design`.
32     */
33    const FEATURE_FLAG = 'jetpack-email-design';
34
35    /**
36     * The script handle, and the id of the element the editor mounts into.
37     */
38    const HANDLE = 'jetpack-email-design-editor';
39
40    /**
41     * Flags for JSON handed to a `<script>` tag.
42     */
43    const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP;
44
45    /**
46     * The hook suffix `add_theme_page()` returned, or null when the page is not registered.
47     *
48     * @var string|null
49     */
50    private static $hook_suffix = null;
51
52    /**
53     * Wire the screen up.
54     */
55    public static function init() {
56        // Registered here rather than on `admin_menu` so the flag exists under WP-CLI, REST
57        // and cron too — `wp companion feature-flag list` reads it from one of those.
58        self::register_feature_flags();
59
60        add_action( 'admin_menu', array( __CLASS__, 'add_admin_page' ) );
61    }
62
63    /**
64     * Declare the screen's feature flag.
65     */
66    public static function register_feature_flags() {
67        Feature_Flags::register(
68            self::FEATURE_FLAG,
69            array(
70                'default'     => false,
71                'description' => 'Edit the newsletter email design in wp-admin, under Appearance.',
72                'owner'       => 'jetpack-newsletter',
73            )
74        );
75    }
76
77    /**
78     * Whether the screen should exist on this site.
79     *
80     * The WordPress.com `email-design-editor` sticker gates the bootstrap endpoint, not this
81     * page: a Jetpack site cannot read stickers without an API call, so the screen carries
82     * its own gate.
83     *
84     * @return bool
85     */
86    public static function is_enabled() {
87        return Feature_Flags::is_enabled( self::FEATURE_FLAG );
88    }
89
90    /**
91     * Add the screen under Appearance.
92     */
93    public static function add_admin_page() {
94        if ( ! self::is_enabled() ) {
95            return;
96        }
97
98        self::$hook_suffix = add_theme_page(
99            __( 'Email Design', 'jetpack' ),
100            __( 'Email Design', 'jetpack' ),
101            'edit_theme_options',
102            self::PAGE_SLUG,
103            array( __CLASS__, 'render' )
104        );
105
106        if ( self::$hook_suffix ) {
107            add_action( 'load-' . self::$hook_suffix, array( __CLASS__, 'on_load' ) );
108        }
109    }
110
111    /**
112     * Scope everything else to this one screen.
113     */
114    public static function on_load() {
115        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
116        add_filter( 'admin_body_class', array( __CLASS__, 'add_fullscreen_body_class' ) );
117    }
118
119    /**
120     * Put the screen in fullscreen mode before the bundle has loaded.
121     *
122     * `wp-editor` hides the admin menu on this class, and the editor sets it from a React
123     * component — so it cannot apply until the bundle has loaded and the bootstrap has answered,
124     * and the page visibly reflows a few seconds in. Setting it here makes fullscreen the layout
125     * from first paint. The admin bar is untouched, which keeps a way out if the editor fails.
126     *
127     * @param string $classes Space-separated admin body classes.
128     * @return string
129     */
130    public static function add_fullscreen_body_class( $classes ) {
131        return trim( $classes . ' is-fullscreen-mode' );
132    }
133
134    /**
135     * Enqueue the editor bundle and the block-editor assets it expects to find.
136     */
137    public static function enqueue_assets() {
138        $asset_path = JETPACK__PLUGIN_DIR . '_inc/build/email-design-editor.asset.php';
139
140        if ( ! file_exists( $asset_path ) ) {
141            return;
142        }
143
144        $asset = include $asset_path;
145
146        self::enqueue_block_editor_assets();
147
148        // `@woocommerce/email-editor` opts into core's private APIs as `@wordpress/edit-site`,
149        // resolved against the site's `wp-private-apis`. Re-check on a package bump. NL-839 (j).
150        wp_enqueue_script(
151            self::HANDLE,
152            plugins_url( '_inc/build/email-design-editor.js', JETPACK__PLUGIN_FILE ),
153            $asset['dependencies'],
154            $asset['version'],
155            true
156        );
157        wp_set_script_translations( self::HANDLE, 'jetpack' );
158
159        // `wp-editor`, `wp-block-editor` and `wp-preferences` are what lay the editor's frame
160        // out; without them every region stacks into one narrow column. Keep the list to
161        // handles WordPress registers — an unregistered one drops this stylesheet silently,
162        // which is `wp-interface`'s trap.
163        wp_enqueue_style(
164            self::HANDLE,
165            plugins_url( '_inc/build/email-design-editor.css', JETPACK__PLUGIN_FILE ),
166            array(
167                'wp-components',
168                'wp-block-editor',
169                'wp-editor',
170                'wp-edit-blocks',
171                'wp-preferences',
172                'wp-format-library',
173            ),
174            $asset['version']
175        );
176        wp_style_add_data( self::HANDLE, 'rtl', 'replace' );
177        wp_add_inline_style( self::HANDLE, self::get_layout_css() );
178
179        wp_add_inline_script(
180            self::HANDLE,
181            'window.JetpackEmailDesignEditor = ' . wp_json_encode( self::get_screen_data(), self::JSON_FLAGS ) . ';',
182            'before'
183        );
184    }
185
186    /**
187     * Fill the screen with the editor.
188     *
189     * The editor's frame expects a viewport, not the flow of an admin page: inside the usual
190     * wp-admin content column it collapses to a fraction of the height and scrolls its own
191     * regions. Woo avoids this by running on `post.php`, which is already fullscreen.
192     *
193     * @return string
194     */
195    private static function get_layout_css() {
196        return '
197            #wpcontent { padding-inline-start: 0; }
198            #wpfooter { display: none; }
199            #' . self::HANDLE . ' {
200                position: fixed;
201                /* Below #wpadminbar (99999) so the editor\'s own popovers and snackbars, which ask
202                   for 100000, cannot cover it — this caps everything inside. The admin menu is
203                   hidden on this screen, so there is nothing else left to clear. */
204                z-index: 99990;
205                inset-block: var(--wp-admin--admin-bar--height, 32px) 0;
206                inset-inline: 0;
207            }
208            /* wp-edit-post pins this and the screen does not load it, so without this the
209               snackbar renders in flow beside the header instead of over the canvas. */
210            #' . self::HANDLE . ' .components-editor-notices__snackbar {
211                position: absolute;
212                inset-block-end: 20px;
213                inset-inline-start: 20px;
214            }
215        ';
216    }
217
218    /**
219     * Reproduce what the package's `Assets_Manager` does on WooCommerce's own screen.
220     *
221     * None of this happens automatically on a custom admin page: without it the editor
222     * mounts against no block library, no block categories and no server-side block
223     * definitions. See NL-839 (a).
224     *
225     * @todo Firing `enqueue_block_editor_assets` wholesale is the leading suspect for the
226     *       second Styles button — it pulls in core's site-editing global styles UI. NL-839 (e).
227     */
228    private static function enqueue_block_editor_assets() {
229        // Named rather than built from a post: there is no post here, and `get_block_categories()`
230        // hands whatever it gets to filters that type-hint the context.
231        $context = new WP_Block_Editor_Context( array( 'name' => 'jetpack/email-design' ) );
232
233        wp_enqueue_media();
234
235        do_action( 'enqueue_block_assets' );
236        do_action( 'enqueue_block_editor_assets' );
237
238        wp_enqueue_style( 'wp-edit-blocks' );
239        wp_enqueue_style( 'wp-format-library' );
240
241        wp_add_inline_script(
242            'wp-blocks',
243            sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( get_block_categories( $context ), self::JSON_FLAGS ) ),
244            'after'
245        );
246        wp_add_inline_script(
247            'wp-blocks',
248            sprintf(
249                'wp.blocks.unstable__bootstrapServerSideBlockDefinitions( %s );',
250                wp_json_encode( get_block_editor_server_block_settings(), self::JSON_FLAGS )
251            ),
252            'after'
253        );
254    }
255
256    /**
257     * What the page hands the bundle, as `window.JetpackEmailDesignEditor`.
258     *
259     * Every WordPress.com id — the template's, the global-styles record's — comes from the
260     * bootstrap response instead, because they are namespaced to the shadow blog's theme and
261     * a locally computed one is right on Simple and wrong everywhere else. See NL-839 (c).
262     *
263     * @return array
264     */
265    private static function get_screen_data() {
266        return array(
267            'elementId'      => self::HANDLE,
268            'editorSettings' => self::get_iframe_asset_settings(),
269
270            // The editor assigns these to `window.location.href` from its header buttons.
271            // Both point at Appearance until the screen has a real entry point (NL-844).
272            'urls'           => array(
273                'back'     => admin_url( 'themes.php' ),
274                'listings' => admin_url( 'themes.php' ),
275            ),
276            'userEmail'      => wp_get_current_user()->user_email,
277        );
278    }
279
280    /**
281     * The two editor settings that describe this installation rather than the design.
282     *
283     * WordPress.com strips both from the bootstrap bundle, because there they would name
284     * WordPress.com's own asset URLs and push them into the site's canvas.
285     *
286     * @return array
287     */
288    private static function get_iframe_asset_settings() {
289        // Absent before WP 6.3, and private, but it is what core's own block editors call to
290        // resolve the assets an iframed canvas needs.
291        if ( ! function_exists( '_wp_get_iframed_editor_assets' ) ) {
292            return array();
293        }
294
295        $handles = self::get_allowed_iframe_style_handles();
296
297        return array(
298            '__unstableResolvedAssets'  => self::get_resolved_assets( $handles ),
299            'allowedIframeStyleHandles' => $handles,
300        );
301    }
302
303    /**
304     * The stylesheet handles the canvas is allowed to keep.
305     *
306     * Mirrors the package's `Settings_Controller::get_allowed_iframe_style_handles()`. An empty
307     * list is not a no-op: the client strips every stylesheet not named here, so omitting this
308     * leaves the canvas painted in the site's own styles rather than the email's.
309     *
310     * @return string[]
311     */
312    private static function get_allowed_iframe_style_handles() {
313        $handles = array(
314            'wp-components-css',
315            'wp-reset-editor-styles-css',
316            'wp-block-library-css',
317            'wp-block-editor-content-css',
318            'wp-edit-blocks-css',
319        );
320
321        // Registration args reach the block type verbatim — `WP_Block_Type::set_props()` normalizes
322        // only `attributes`, and `register_block_type_args` can rewrite the rest — so a block
323        // declaring a bare string here would otherwise fatal the screen inside `array_merge()`.
324        foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $block ) {
325            if ( ! is_array( $block->supports ) || empty( $block->supports['email'] ) ) {
326                continue;
327            }
328
329            foreach ( array_merge( (array) $block->style_handles, (array) $block->editor_style_handles ) as $handle ) {
330                if ( is_string( $handle ) ) {
331                    $handles[] = $handle . '-css';
332                }
333            }
334        }
335
336        return $handles;
337    }
338
339    /**
340     * The iframe assets, trimmed to the allowed handles.
341     *
342     * @param string[] $allowed Handles to keep.
343     * @return array The `_wp_get_iframed_editor_assets()` shape, with `styles` filtered.
344     */
345    private static function get_resolved_assets( array $allowed ) {
346        $assets = _wp_get_iframed_editor_assets();
347        $kept   = array();
348
349        foreach ( explode( "\n", (string) $assets['styles'] ) as $asset ) {
350            foreach ( $allowed as $handle ) {
351                if ( str_contains( $asset, $handle ) ) {
352                    $kept[] = $asset;
353                    break;
354                }
355            }
356        }
357
358        $assets['styles'] = implode( "\n", $kept );
359
360        return $assets;
361    }
362
363    /**
364     * Render the container the editor mounts into.
365     */
366    public static function render() {
367        printf( '<div id="%s" class="jetpack-email-design-editor"></div>', esc_attr( self::HANDLE ) );
368    }
369}
370
371Jetpack_Email_Design_Editor::init();