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