/**
* Client-side entry point for the newsletter email design screen.
*
* The editor's bootstrap bundle lives on the WordPress.com shadow blog and is
* fetched from the browser rather than inlined, so the screen paints without a
* blocking proxy request on Atomic and self-hosted. It also carries records the
* editor would otherwise fetch — see `buildPreloadMap()`.
*
* The page that renders the container and localises
* `window.JetpackEmailDesignEditor` lands separately; until then nothing enqueues
* this bundle and the mount below returns. See NL-848 and NL-851.
*/
import { ExperimentalEmailEditor } from '@woocommerce/email-editor';
import apiFetch from '@wordpress/api-fetch';
import { Notice } from '@wordpress/components';
import { createRoot, StrictMode } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
// Declared by the Jetpack plugin on every platform, answered by WordPress.com, so
// the browser calls one local URL everywhere.
const BOOTSTRAP_PATH = '/wpcom/v2/email-editor-bootstrap';
// The design is a block template. Unlike the template's id, this is the same
// everywhere, so the page does not supply it.
const TEMPLATE_POST_TYPE = 'wp_template';
// The editor assigns these straight to `window.location.href`, so `javascript:`
// and `data:` would execute rather than navigate.
const NAVIGABLE_PROTOCOLS = [ 'http:', 'https:' ];
/**
* Check that a URL the editor will navigate to is one the browser can navigate to.
*
* Reads the parsed protocol rather than testing the string: relative resolution
* does not neutralise a scheme, and the parser strips leading whitespace that a
* `startsWith( 'javascript:' )` test would miss.
*
* @param {*} value - The configured URL.
* @param {string} key - Its key, named in the error so the page can be fixed.
* @throws {Error} If the value is not a URL the browser can navigate to.
* @return {void}
*/
function assertNavigableUrl( value, key ) {
if ( typeof value !== 'string' ) {
throw new Error( `JetpackEmailDesignEditor.urls.${ key } must be a string.` );
}
let resolved;
try {
resolved = new URL( value, window.location.href );
} catch {
throw new Error( `JetpackEmailDesignEditor.urls.${ key } is not a valid URL.` );
}
if ( ! NAVIGABLE_PROTOCOLS.includes( resolved.protocol ) ) {
throw new Error( `JetpackEmailDesignEditor.urls.${ key } must be an http or https URL.` );
}
}
/**
* Translate the page's data and the fetched bundle into the editor's configuration.
*
* Not a pass-through: the bundle is snake_cased (`editor_settings`, `editor_theme`)
* while the package's store reads `editorSettings`, `theme`, `urls`, `userEmail`
* and `globalStylesPostId`. Passing it unmapped boots the editor with no settings
* and no theme, and reports nothing.
*
* `editorSettings` merges both halves — WordPress.com strips the two settings that
* describe the installation rather than the design, and the page supplies this
* site's own — with the page's half last so it wins.
*
* @param {object} bundle - The response from the bootstrap route.
* @param {object} data - The value of `window.JetpackEmailDesignEditor`.
* @throws {Error} If either half left out something the editor cannot start without.
* @return {object} The editor's `config` prop.
*/
export function buildEditorConfig( bundle, data ) {
const { editorSettings, urls, userEmail, globalStylesPostId } = data;
// Nothing validates these on the `config` prop path, so an omission would
// otherwise surface as an unrelated failure deep in the editor.
if ( ! bundle?.editor_settings ) {
throw new Error( 'The email editor bundle is missing editor_settings.' );
}
if ( ! bundle?.editor_theme ) {
throw new Error( 'The email editor bundle is missing editor_theme.' );
}
if ( typeof urls?.back !== 'string' || typeof urls?.listings !== 'string' ) {
throw new Error( 'JetpackEmailDesignEditor.urls.back and .listings are required strings.' );
}
// These all end up assigned to `window.location.href` by the editor's header
// buttons.
Object.entries( urls ).forEach( ( [ key, value ] ) => assertNavigableUrl( value, key ) );
return {
editorSettings: { ...bundle.editor_settings, ...editorSettings },
theme: bundle.editor_theme,
urls,
userEmail,
// Dereferenced, not merely a flag: null makes the package generate no canvas CSS at all,
// and any valid id has its record fetched — which the preload answers. The record's
// `styles` and `settings` are merged last over `editor_theme`, which is what paints the
// canvas while editing. Bundle first because it is a WordPress.com post id; the page stays
// a fallback. See NL-871.
globalStylesPostId: getGlobalStylesPostId( bundle ) ?? globalStylesPostId ?? null,
};
}
/**
* Everything the editor would otherwise fetch, keyed by the path it asks for.
*
* The canvas templates and the global-styles record are registered only while
* WordPress.com builds the bootstrap bundle, so a request from the browser cannot
* reach them and the editor waits forever on a record it will never get.
*
* Registering them for every REST request would fix that, but would also list the
* email templates in the Site Editor — a visible regression on every enrolled blog.
* Preloading confines them to this page.
*
* The records come from the bundle rather than being assembled here: the editor
* reads fields a four-field summary cannot stand in for, including `post_types`
* with no optional chaining.
*
* @param {object} bundle - The response from the bootstrap route.
* @param {string} templateId - The id of the template the editor opens.
* @return {object|null} A map for `createPreloadingMiddleware`, or null when the bundle
* carries nothing to preload.
*/
export function buildPreloadMap( bundle, templateId ) {
const map = {
...templatePreloads( bundle, templateId ),
...globalStylesPreloads( bundle ),
};
return Object.keys( map ).length > 0 ? map : null;
}
/**
* The id of the global-styles record the bundle points at, or null when it sent no usable one.
*
* Validated because it is interpolated into the preload's path keys, which are
* deliberately exact — an id carrying a slash or query string would widen what we
* answer for, and the site's own global-styles record has to keep reaching the
* network untouched.
*
* @param {object} bundle - The response from the bootstrap route.
* @return {number|null} The record's id, or null.
*/
function getGlobalStylesPostId( bundle ) {
const id = bundle?.global_styles?.post_id;
return Number.isInteger( id ) && id > 0 ? id : null;
}
/**
* The global-styles record the editor reads its design from.
*
* The `GET` and the `OPTIONS` both matter, and both carry `Allow`: core-data derives the
* record's permissions from either response, last one winning.
*
* The body has to be the record WordPress.com sent, not a placeholder — the canvas
* takes its colours from these contents.
*
* `can_edit` decides whether the Styles panel exists at all. Without update permission the
* package's sidebar returns nothing; it does not render a read-only panel.
*
* Only this exact id, never a pattern — the editor loads the site's own global-styles
* record alongside ours, and that one must keep reaching the network.
*
* @param {object} bundle - The response from the bootstrap route.
* @return {object} Preload entries, empty when the bundle carries no global styles.
*/
function globalStylesPreloads( bundle ) {
const globalStyles = bundle?.global_styles;
const id = getGlobalStylesPostId( bundle );
if ( ! id || ! globalStyles?.record ) {
return {};
}
// A preloaded GET carries permissions as well as data: core-data reads `Allow` off the record's
// own response too, and reads a missing header as "nothing is permitted" rather than as silence.
// The GETs resolve after the OPTIONS, so omitting it here overwrites the OPTIONS answer with a
// flat no and the Styles panel never renders.
const allow = globalStyles.can_edit ? 'GET, POST, PUT' : 'GET';
const record = { body: globalStyles.record, headers: { Allow: allow } };
return {
[ `/wp/v2/global-styles/${ id }` ]: record,
[ `/wp/v2/global-styles/${ id }?context=view` ]: record,
[ `/wp/v2/global-styles/${ id }?context=edit` ]: record,
// OPTIONS responses live under their own top-level key in the preload format.
OPTIONS: {
[ `/wp/v2/global-styles/${ id }` ]: { body: {}, headers: { Allow: allow } },
},
};
}
/**
* The template records the editor resolves its canvas from.
*
* @param {object} bundle - The response from the bootstrap route.
* @param {string} templateId - The id of the template the editor opens.
* @return {object} Preload entries, empty when the bundle carries no template records.
*/
function templatePreloads( bundle, templateId ) {
const templates = bundle?.templates;
if ( ! Array.isArray( templates ) || 0 === templates.length ) {
return {};
}
// `parse: false` callers build a Response from these and read `headers`
// unconditionally, so every entry carries one even when empty.
const collection = {
body: templates,
headers: {
'X-WP-Total': String( templates.length ),
'X-WP-TotalPages': '1',
},
};
// The context asked for varies by platform (`?context=edit` on WordPress 7.1, none
// on WordPress.com). An unmatched key costs nothing; a miss leaves the editor
// waiting forever.
const map = {
'/wp/v2/templates': collection,
'/wp/v2/templates?context=edit': collection,
'/wp/v2/templates?context=view': collection,
};
const item = templates.find( template => template?.id === templateId );
if ( item ) {
// Explicitly read-only, for the reason above: the header is an assertion, not decoration.
// Nothing on this screen edits the template, and granting writes here would hand the
// editor a template it believes it may save.
const record = { body: item, headers: { Allow: 'GET' } };
map[ `/wp/v2/templates/${ templateId }` ] = record;
map[ `/wp/v2/templates/${ templateId }?context=edit` ] = record;
}
return map;
}
/**
* The id of the template the editor opens.
*
* Read from the bundle, never derived: the package builds it from the stylesheet of
* whichever installation registered the template, so computing it locally is right on
* Simple and wrong on Atomic and self-hosted.
*
* @param {object} bundle - The response from the bootstrap route.
* @throws {Error} If the bundle carries no template.
* @return {string} The template's id.
*/
export function getTemplateId( bundle ) {
const id = bundle?.template?.id;
if ( typeof id !== 'string' || '' === id ) {
throw new Error( 'The email editor bundle is missing its template id.' );
}
return id;
}
/**
* What the screen shows when it could not load.
*
* The design lives on another site, so without this "nothing appeared" and "your
* design is empty" look identical to whoever opened the page.
*
* @return {import('react').ReactElement} The error notice.
*/
function LoadError() {
return (
{ __(
'The email design editor could not be loaded. Please reload the page to try again.',
'jetpack'
) }
);
}
/**
* Fetch the bootstrap bundle and mount the editor into the page's container.
*
* @return {Promise} Resolves once the editor or an error has rendered.
*/
export async function mountEmailDesignEditor() {
const data = window.JetpackEmailDesignEditor;
// Not our page — the bundle is only enqueued on the design screen.
if ( ! data || typeof data !== 'object' ) {
return;
}
const container = document.getElementById( data.elementId );
if ( ! container ) {
return;
}
const root = createRoot( container );
try {
const bundle = await apiFetch( {
path: data.templateSlug
? addQueryArgs( BOOTSTRAP_PATH, { template_slug: data.templateSlug } )
: BOOTSTRAP_PATH,
} );
const config = buildEditorConfig( bundle, data );
const postId = getTemplateId( bundle );
const preload = buildPreloadMap( bundle, postId );
if ( preload ) {
// Registered last so it runs first: api-fetch applies middlewares right to
// left, so this sees `options.path` before the rewriting middlewares. Must be
// installed before the editor mounts, which resolves the template on first
// render.
apiFetch.use( apiFetch.createPreloadingMiddleware( preload ) );
}
root.render(
);
} catch ( error ) {
// The notice deliberately does not name which half failed; this does.
// eslint-disable-next-line no-console
console.error( 'Jetpack email design editor:', error );
root.render( );
}
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', mountEmailDesignEditor, { once: true } );
} else {
mountEmailDesignEditor();
}