PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / modules / subscriptions / email-design-editor / src / index.jsx

index.jsx in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at modules/subscriptions/email-design-editor/src/index.jsx

550 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Client-side entry point for the newsletter email design screen.
3 *
4 * The editor's bootstrap bundle lives on the WordPress.com shadow blog and is
5 * fetched from the browser rather than inlined, so the screen paints without a
6 * blocking proxy request on Atomic and self-hosted. It also carries records the
7 * editor would otherwise fetch — see `buildPreloadMap()`.
8 *
9 * The page that renders the container and localises
10 * `window.JetpackEmailDesignEditor` lands separately; until then nothing enqueues
11 * this bundle and the mount below returns. See NL-848 and NL-851.
12 */
13 import { ExperimentalEmailEditor } from '@woocommerce/email-editor';
14 import apiFetch from '@wordpress/api-fetch';
15 import { useBlockProps } from '@wordpress/block-editor';
16 import { getBlockType, registerBlockType } from '@wordpress/blocks';
17 import { Notice } from '@wordpress/components';
18 import { store as coreStore } from '@wordpress/core-data';
19 import { dispatch, select } from '@wordpress/data';
20 import { createRoot, StrictMode } from '@wordpress/element';
21 import { __ } from '@wordpress/i18n';
22 import { store as noticesStore } from '@wordpress/notices';
23 import { addQueryArgs } from '@wordpress/url';
24
25 // Declared by the Jetpack plugin on every platform, answered by WordPress.com, so
26 // the browser calls one local URL everywhere.
27 const BOOTSTRAP_PATH = '/wpcom/v2/email-editor-bootstrap';
28
29 // The design is a block template. Unlike the template's id, this is the same
30 // everywhere, so the page does not supply it.
31 const TEMPLATE_POST_TYPE = 'wp_template';
32
33 // The editor assigns these straight to `window.location.href`, so `javascript:`
34 // and `data:` would execute rather than navigate.
35 const NAVIGABLE_PROTOCOLS = [ 'http:', 'https:' ];
36
37 // A save arrives as any of these, depending on whether core-data creates or updates.
38 const WRITE_METHODS = [ 'POST', 'PUT', 'PATCH' ];
39
40 /**
41 * Check that a URL the editor will navigate to is one the browser can navigate to.
42 *
43 * Reads the parsed protocol rather than testing the string: relative resolution
44 * does not neutralise a scheme, and the parser strips leading whitespace that a
45 * `startsWith( 'javascript:' )` test would miss.
46 *
47 * @param {*} value - The configured URL.
48 * @param {string} key - Its key, named in the error so the page can be fixed.
49 * @throws {Error} If the value is not a URL the browser can navigate to.
50 * @return {void}
51 */
52 function assertNavigableUrl( value, key ) {
53 if ( typeof value !== 'string' ) {
54 throw new Error( `JetpackEmailDesignEditor.urls.${ key } must be a string.` );
55 }
56
57 let resolved;
58
59 try {
60 resolved = new URL( value, window.location.href );
61 } catch {
62 throw new Error( `JetpackEmailDesignEditor.urls.${ key } is not a valid URL.` );
63 }
64
65 if ( ! NAVIGABLE_PROTOCOLS.includes( resolved.protocol ) ) {
66 throw new Error( `JetpackEmailDesignEditor.urls.${ key } must be an http or https URL.` );
67 }
68 }
69
70 /**
71 * Translate the page's data and the fetched bundle into the editor's configuration.
72 *
73 * Not a pass-through: the bundle is snake_cased (`editor_settings`, `editor_theme`)
74 * while the package's store reads `editorSettings`, `theme`, `urls`, `userEmail`
75 * and `globalStylesPostId`. Passing it unmapped boots the editor with no settings
76 * and no theme, and reports nothing.
77 *
78 * `editorSettings` merges both halves — WordPress.com strips the two settings that
79 * describe the installation rather than the design, and the page supplies this
80 * site's own — with the page's half last so it wins.
81 *
82 * @param {object} bundle - The response from the bootstrap route.
83 * @param {object} data - The value of `window.JetpackEmailDesignEditor`.
84 * @throws {Error} If either half left out something the editor cannot start without.
85 * @return {object} The editor's `config` prop.
86 */
87 export function buildEditorConfig( bundle, data ) {
88 const { editorSettings, urls, userEmail, globalStylesPostId } = data;
89
90 // Nothing validates these on the `config` prop path, so an omission would
91 // otherwise surface as an unrelated failure deep in the editor.
92 if ( ! bundle?.editor_settings ) {
93 throw new Error( 'The email editor bundle is missing editor_settings.' );
94 }
95
96 if ( ! bundle?.editor_theme ) {
97 throw new Error( 'The email editor bundle is missing editor_theme.' );
98 }
99
100 if ( typeof urls?.back !== 'string' || typeof urls?.listings !== 'string' ) {
101 throw new Error( 'JetpackEmailDesignEditor.urls.back and .listings are required strings.' );
102 }
103
104 // These all end up assigned to `window.location.href` by the editor's header
105 // buttons.
106 Object.entries( urls ).forEach( ( [ key, value ] ) => assertNavigableUrl( value, key ) );
107
108 return {
109 // Forced last so neither half can turn it off. The package renders core's `FullscreenMode`
110 // on this, which hides the admin menu — without it a full-viewport editor sits beside a menu
111 // whose flyouts open over the canvas, and no z-index satisfies both. Forced rather than the
112 // `fullscreenMode` preference so it is not a per-user toggle; it also brings the back button.
113 editorSettings: { ...bundle.editor_settings, ...editorSettings, isFullScreenForced: true },
114 theme: bundle.editor_theme,
115 urls,
116 userEmail,
117
118 // Dereferenced, not merely a flag: null makes the package generate no canvas CSS at all,
119 // and any valid id has its record fetched — which the preload answers. The record's
120 // `styles` and `settings` are merged last over `editor_theme`, which is what paints the
121 // canvas while editing. Bundle first because it is a WordPress.com post id; the page stays
122 // a fallback. See NL-871.
123 globalStylesPostId: getGlobalStylesPostId( bundle ) ?? globalStylesPostId ?? null,
124 };
125 }
126
127 /**
128 * Everything the editor would otherwise fetch, keyed by the path it asks for.
129 *
130 * The canvas templates and the global-styles record are registered only while
131 * WordPress.com builds the bootstrap bundle, so a request from the browser cannot
132 * reach them and the editor waits forever on a record it will never get.
133 *
134 * Registering them for every REST request would fix that, but would also list the
135 * email templates in the Site Editor — a visible regression on every enrolled blog.
136 * Preloading confines them to this page.
137 *
138 * The records come from the bundle rather than being assembled here: the editor
139 * reads fields a four-field summary cannot stand in for, including `post_types`
140 * with no optional chaining.
141 *
142 * @param {object} bundle - The response from the bootstrap route.
143 * @param {string} templateId - The id of the template the editor opens.
144 * @return {object|null} A map for `createPreloadingMiddleware`, or null when the bundle
145 * carries nothing to preload.
146 */
147 export function buildPreloadMap( bundle, templateId ) {
148 const map = {
149 ...templatePreloads( bundle, templateId ),
150 ...globalStylesPreloads( bundle ),
151 };
152
153 return Object.keys( map ).length > 0 ? map : null;
154 }
155
156 /**
157 * The id of the global-styles record the bundle points at, or null when it sent no usable one.
158 *
159 * Validated because it is interpolated into the preload's path keys, which are
160 * deliberately exact — an id carrying a slash or query string would widen what we
161 * answer for, and the site's own global-styles record has to keep reaching the
162 * network untouched.
163 *
164 * @param {object} bundle - The response from the bootstrap route.
165 * @return {number|null} The record's id, or null.
166 */
167 function getGlobalStylesPostId( bundle ) {
168 const id = bundle?.global_styles?.post_id;
169
170 return Number.isInteger( id ) && id > 0 ? id : null;
171 }
172
173 /**
174 * The global-styles record the editor reads its design from.
175 *
176 * The `GET` and the `OPTIONS` both matter, and both carry `Allow`: core-data derives the
177 * record's permissions from either response, last one winning.
178 *
179 * The body has to be the record WordPress.com sent, not a placeholder — the canvas
180 * takes its colours from these contents.
181 *
182 * `can_edit` decides whether the Styles panel exists at all. Without update permission the
183 * package's sidebar returns nothing; it does not render a read-only panel.
184 *
185 * Only this exact id, never a pattern — the editor loads the site's own global-styles
186 * record alongside ours, and that one must keep reaching the network.
187 *
188 * @param {object} bundle - The response from the bootstrap route.
189 * @return {object} Preload entries, empty when the bundle carries no global styles.
190 */
191 /**
192 * A theme.json half as an object, whatever shape it arrived in.
193 *
194 * Off Simple the bootstrap is proxied through `json_decode( …, true )`, so an empty `{}` comes
195 * back as `[]`. The editor writes edits onto whatever it finds, and a property set on an array is
196 * dropped by `JSON.stringify` — the swatch flashes and nothing persists. See NL-871.
197 *
198 * @param {*} value - `styles` or `settings` as it arrived.
199 * @return {object} The value when it is a usable object, an empty object otherwise.
200 */
201 function objectOrEmpty( value ) {
202 return value && 'object' === typeof value && ! Array.isArray( value ) ? value : {};
203 }
204
205 function globalStylesPreloads( bundle ) {
206 const globalStyles = bundle?.global_styles;
207 const id = getGlobalStylesPostId( bundle );
208
209 if ( ! id || ! globalStyles?.record ) {
210 return {};
211 }
212
213 // A preloaded GET carries permissions as well as data: core-data reads `Allow` off the record's
214 // own response too, and reads a missing header as "nothing is permitted" rather than as silence.
215 // The GETs resolve after the OPTIONS, so omitting it here overwrites the OPTIONS answer with a
216 // flat no and the Styles panel never renders.
217 const allow = globalStyles.can_edit ? 'GET, POST, PUT' : 'GET';
218 const record = {
219 body: {
220 ...globalStyles.record,
221 styles: objectOrEmpty( globalStyles.record.styles ),
222 settings: objectOrEmpty( globalStyles.record.settings ),
223 },
224 headers: { Allow: allow },
225 };
226
227 return {
228 [ `/wp/v2/global-styles/${ id }` ]: record,
229 [ `/wp/v2/global-styles/${ id }?context=view` ]: record,
230 [ `/wp/v2/global-styles/${ id }?context=edit` ]: record,
231
232 // OPTIONS responses live under their own top-level key in the preload format.
233 OPTIONS: {
234 [ `/wp/v2/global-styles/${ id }` ]: { body: {}, headers: { Allow: allow } },
235 },
236 };
237 }
238
239 /**
240 * The template records the editor resolves its canvas from.
241 *
242 * @param {object} bundle - The response from the bootstrap route.
243 * @param {string} templateId - The id of the template the editor opens.
244 * @return {object} Preload entries, empty when the bundle carries no template records.
245 */
246 function templatePreloads( bundle, templateId ) {
247 const templates = bundle?.templates;
248
249 if ( ! Array.isArray( templates ) || 0 === templates.length ) {
250 return {};
251 }
252
253 // `parse: false` callers build a Response from these and read `headers`
254 // unconditionally, so every entry carries one even when empty.
255 const collection = {
256 body: templates,
257 headers: {
258 'X-WP-Total': String( templates.length ),
259 'X-WP-TotalPages': '1',
260 },
261 };
262
263 // The context asked for varies by platform (`?context=edit` on WordPress 7.1, none
264 // on WordPress.com). An unmatched key costs nothing; a miss leaves the editor
265 // waiting forever.
266 const map = {
267 '/wp/v2/templates': collection,
268 '/wp/v2/templates?context=edit': collection,
269 '/wp/v2/templates?context=view': collection,
270 };
271
272 const item = templates.find( template => template?.id === templateId );
273
274 if ( item ) {
275 // Explicitly read-only, for the reason above: the header is an assertion, not decoration.
276 // Nothing on this screen edits the template, and granting writes here would hand the
277 // editor a template it believes it may save.
278 const record = { body: item, headers: { Allow: 'GET' } };
279
280 map[ `/wp/v2/templates/${ templateId }` ] = record;
281 map[ `/wp/v2/templates/${ templateId }?context=edit` ] = record;
282 }
283
284 return map;
285 }
286
287 /**
288 * The id of the template the editor opens.
289 *
290 * Read from the bundle, never derived: the package builds it from the stylesheet of
291 * whichever installation registered the template, so computing it locally is right on
292 * Simple and wrong on Atomic and self-hosted.
293 *
294 * @param {object} bundle - The response from the bootstrap route.
295 * @throws {Error} If the bundle carries no template.
296 * @return {string} The template's id.
297 */
298 export function getTemplateId( bundle ) {
299 const id = bundle?.template?.id;
300
301 if ( typeof id !== 'string' || '' === id ) {
302 throw new Error( 'The email editor bundle is missing its template id.' );
303 }
304
305 return id;
306 }
307
308 /**
309 * Register the email blocks the bootstrap describes and no client defines.
310 *
311 * They are registered in PHP only, on every host including Simple, so without this the editor
312 * reports each one as an unsupported block. WordPress.com allowlists the namespaces it owns, but
313 * a site is still free to have registered one itself, so anything already registered is left
314 * alone rather than replaced by a placeholder.
315 *
316 * Dynamic blocks with no client-side edit, so the canvas shows a labelled placeholder. What the
317 * subscriber receives is rendered server-side and is unaffected.
318 *
319 * @param {object} bundle - The response from the bootstrap route.
320 * @return {void}
321 */
322 export function registerEmailBlocks( bundle ) {
323 const blocks = Array.isArray( bundle?.blocks ) ? bundle.blocks : [];
324
325 blocks.forEach( block => {
326 if ( ! block?.name || getBlockType( block.name ) ) {
327 return;
328 }
329
330 // Falls back to the slug on anything that is not a usable string. A non-string title is
331 // not just unlabelled: it reaches the placeholder as a React child, and an object there
332 // throws rather than rendering.
333 const title = typeof block.title === 'string' && block.title ? block.title : block.name;
334
335 // Named and capitalised so it reads as a component: `useBlockProps` is a hook, and an
336 // anonymous arrow here trips rules-of-hooks.
337 const EmailBlockPlaceholder = () => <div { ...useBlockProps() }>{ title }</div>;
338
339 registerBlockType( block.name, {
340 apiVersion: 3,
341 title,
342 description: block.description || '',
343 category: block.category || 'design',
344 attributes: block.attributes || {},
345
346 // Template furniture rather than blocks a creator adds by hand.
347 supports: { ...( block.supports || {} ), html: false, inserter: false },
348
349 edit: EmailBlockPlaceholder,
350 save: () => null,
351 } );
352 } );
353 }
354
355 /**
356 * Catch the Styles panel's save and send it to WordPress.com instead.
357 *
358 * The editor writes a core-data `globalStyles` entity, but the design is stored in a WordPress.com
359 * blog option rather than a post, so the write has to be re-addressed to the bootstrap route.
360 *
361 * Matched on this one record's exact path and nothing else. The editor also holds the *site's* own
362 * global-styles record, at edit context, so anything broader would push the site's design through
363 * the email endpoint — and would look correct while doing it on Simple, where the site and the
364 * shadow blog are the same database.
365 *
366 * @param {number} id - The global-styles id the bundle named.
367 * @return {Function} An `apiFetch` middleware.
368 */
369 export function createDesignSaveMiddleware( id ) {
370 const target = `/wp/v2/global-styles/${ id }`;
371
372 return async ( options, next ) => {
373 const path = 'string' === typeof options.path ? options.path.split( '?' )[ 0 ] : '';
374 const method = ( options.method || 'GET' ).toUpperCase();
375
376 if ( target !== path || ! WRITE_METHODS.includes( method ) ) {
377 return next( options );
378 }
379
380 // Only the theme.json halves: core-data hands over its whole record, and its `id` is the
381 // sentinel that stands in for a post that does not exist. Sanitizing drops it either way,
382 // but sending it makes every save look like it lost a property to anything comparing what
383 // was sent against what was stored. `version` and `isGlobalStylesUserThemeJSON` are the
384 // store's to set, so they are not ours to send.
385 // core-data drops unchanged keys from its edits, so `options.data` routinely carries one half
386 // — a styles-only save is the ordinary case here, not an anomaly. The store replaces the
387 // whole document rather than merging, so sending that half alone would destroy the other.
388 // The editor's own view — persisted record plus pending edits — is what the creator means.
389 const edited = select( coreStore ).getEditedEntityRecord( 'root', 'globalStyles', id );
390
391 if ( ! edited ) {
392 throw new Error( 'Email design save found no global styles record to read.' );
393 }
394
395 const submitted = { styles: edited.styles ?? {}, settings: edited.settings ?? {} };
396
397 const saved = await apiFetch( {
398 path: BOOTSTRAP_PATH,
399 method: 'POST',
400 data: { design: submitted },
401 } );
402
403 // The route answers with an envelope — `{ blog_id, design, discarded }` — around a read-back
404 // of what was stored, since sanitizing drops anything outside the theme.json schema. Unwrap
405 // it: core-data takes what comes back as the record itself, and the canvas is drawn by
406 // merging that record's `styles` and `settings` over the theme, so handing back the envelope
407 // leaves both undefined and the canvas snaps to its pre-edit design.
408 const design = saved?.design ?? {};
409
410 // `discarded` means the save succeeded and kept none of it: sanitizing drops whatever falls
411 // outside the theme.json schema. Without saying so, the panel goes clean and the creator is
412 // told their edit was saved when the stored design no longer contains it.
413 if ( saved?.discarded ) {
414 dispatch( noticesStore ).createNotice(
415 'error',
416 __( 'Those changes could not be saved to your email design.', 'jetpack' ),
417 { type: 'snackbar', isDismissible: true }
418 );
419 }
420
421 return {
422 id,
423 settings: objectOrEmpty( design.settings ),
424 styles: objectOrEmpty( design.styles ),
425 };
426 };
427 }
428
429 /**
430 * Tell the creator when a design saved here would never reach anyone.
431 *
432 * `renders_through_email_editor` is the blog's state, not the reader's — independent of
433 * `can_edit`, which asks whether *this person* may edit. Only `false` warns: `null` means
434 * WordPress.com could not determine it during a deploy window, and warning a creator whose blog is
435 * fine is a false alarm they cannot act on. Pinned, not a snackbar — it is a standing condition.
436 * See NL-864.
437 *
438 * @param {object} bundle - The response from the bootstrap route.
439 * @return {void}
440 */
441 export function reportInactiveEmailDesign( bundle ) {
442 if ( false !== bundle?.renders_through_email_editor ) {
443 return;
444 }
445
446 dispatch( noticesStore ).createNotice(
447 'warning',
448 __(
449 'Email design is not active on this site, so changes saved here will not affect the emails your subscribers receive.',
450 'jetpack'
451 ),
452 // The editor's pinned notice list reads this context and this type; the default context
453 // only reaches its snackbars.
454 { context: 'email-editor', type: 'default', isDismissible: false }
455 );
456 }
457
458 /**
459 * What the screen shows when it could not load.
460 *
461 * The design lives on another site, so without this "nothing appeared" and "your
462 * design is empty" look identical to whoever opened the page.
463 *
464 * @return {import('react').ReactElement} The error notice.
465 */
466 function LoadError() {
467 return (
468 <Notice status="error" isDismissible={ false }>
469 { __(
470 'The email design editor could not be loaded. Please reload the page to try again.',
471 'jetpack'
472 ) }
473 </Notice>
474 );
475 }
476
477 /**
478 * Fetch the bootstrap bundle and mount the editor into the page's container.
479 *
480 * @return {Promise<void>} Resolves once the editor or an error has rendered.
481 */
482 export async function mountEmailDesignEditor() {
483 const data = window.JetpackEmailDesignEditor;
484
485 // Not our page — the bundle is only enqueued on the design screen.
486 if ( ! data || typeof data !== 'object' ) {
487 return;
488 }
489
490 const container = document.getElementById( data.elementId );
491
492 if ( ! container ) {
493 return;
494 }
495
496 const root = createRoot( container );
497
498 try {
499 const bundle = await apiFetch( {
500 path: data.templateSlug
501 ? addQueryArgs( BOOTSTRAP_PATH, { template_slug: data.templateSlug } )
502 : BOOTSTRAP_PATH,
503 } );
504
505 const config = buildEditorConfig( bundle, data );
506
507 // Before the render, not after: the template is parsed on first render and its blocks are
508 // resolved against the registry at that moment. Registering later leaves the same
509 // unsupported-block errors, which looks identical to this never running.
510 registerEmailBlocks( bundle );
511 reportInactiveEmailDesign( bundle );
512
513 const postId = getTemplateId( bundle );
514 const preload = buildPreloadMap( bundle, postId );
515
516 if ( config.globalStylesPostId ) {
517 apiFetch.use( createDesignSaveMiddleware( config.globalStylesPostId ) );
518 }
519
520 if ( preload ) {
521 // Registered last so it runs first: api-fetch applies middlewares right to
522 // left, so this sees `options.path` before the rewriting middlewares. Must be
523 // installed before the editor mounts, which resolves the template on first
524 // render.
525 apiFetch.use( apiFetch.createPreloadingMiddleware( preload ) );
526 }
527
528 root.render(
529 <StrictMode>
530 <ExperimentalEmailEditor
531 postId={ postId }
532 postType={ TEMPLATE_POST_TYPE }
533 config={ config }
534 />
535 </StrictMode>
536 );
537 } catch ( error ) {
538 // The notice deliberately does not name which half failed; this does.
539 // eslint-disable-next-line no-console
540 console.error( 'Jetpack email design editor:', error );
541 root.render( <LoadError /> );
542 }
543 }
544
545 if ( document.readyState === 'loading' ) {
546 document.addEventListener( 'DOMContentLoaded', mountEmailDesignEditor, { once: true } );
547 } else {
548 mountEmailDesignEditor();
549 }
550