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