-
Notifications
You must be signed in to change notification settings - Fork 814
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Build: Server-side Render UpgradeNudge for use in PHP (#13070)
This commit adds infrastructure to server-side render individual React components during build so they can be used in PHP. The idea is that props can be passed to the component using a simplistic templating language on the server side. The benefit is that we'll be able to re-use markup and styling and don't have to duplicate code in PHP. This is inspired by [prior art in `static.jsx`](https://github.com/Automattic/jetpack/blob/f25e25705c67146317c1cb7334856352bd47b44b/webpack.config.js#L43-L76) -- see e.g.#12381 -- but hopes to apply the same principle in a cleaner, more granular way (component level). It also adds functionality to fetch plans data from the relevant WP.com endpoint.
- Loading branch information
Showing
13 changed files
with
1,135 additions
and
72 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName | ||
/** | ||
* Components Library | ||
* | ||
* Load and display a pre-rendered component | ||
*/ | ||
class Jetpack_Components { | ||
/** | ||
* Load and display a pre-rendered component | ||
* | ||
* @since 7.7.0 | ||
* | ||
* @param string $name Component name. | ||
* @param array $props Component properties. | ||
* | ||
* @return string The component markup | ||
*/ | ||
public static function render_component( $name, $props ) { | ||
|
||
$rtl = is_rtl() ? '.rtl' : ''; | ||
wp_enqueue_style( 'jetpack-components', plugins_url( "_inc/blocks/components{$rtl}.css", JETPACK__PLUGIN_FILE ), array( 'wp-components' ), JETPACK__VERSION ); | ||
|
||
ob_start(); | ||
// `include` fails gracefully and throws a warning, but doesn't halt execution. | ||
include JETPACK__PLUGIN_DIR . "_inc/blocks/$name.html"; | ||
$markup = ob_get_clean(); | ||
|
||
foreach ( $props as $key => $value ) { | ||
$markup = str_replace( | ||
"#$key#", | ||
$value, | ||
$markup | ||
); | ||
|
||
// Workaround, required to replace strings in `sprintf`-expressions. | ||
// See extensions/i18n-to-php.js for more information. | ||
$markup = str_replace( | ||
"%($key)s", | ||
$value, | ||
$markup | ||
); | ||
} | ||
|
||
return $markup; | ||
} | ||
|
||
/** | ||
* Load and display a pre-rendered component | ||
* | ||
* @since 7.7.0 | ||
* | ||
* @param array $props Component properties. | ||
* | ||
* @return string The component markup | ||
*/ | ||
public static function render_upgrade_nudge( $props ) { | ||
$plan_slug = $props['plan']; | ||
jetpack_require_lib( 'plans' ); | ||
$plan = Jetpack_Plans::get_plan( $plan_slug ); | ||
|
||
if ( ! $plan ) { | ||
return self::render_component( | ||
'upgrade-nudge', | ||
array( | ||
'planName' => __( 'a paid plan', 'jetpack' ), | ||
'upgradeUrl' => '', | ||
) | ||
); | ||
} | ||
|
||
// WP.com plan objects have a dedicated `path_slug` field, Jetpack plan objects don't | ||
// For Jetpack, we thus use the plan slug with the 'jetpack_' prefix removed. | ||
$plan_path_slug = wp_startswith( $plan_slug, 'jetpack_' ) | ||
? substr( $plan_slug, strlen( 'jetpack_' ) ) | ||
: $plan->path_slug; | ||
|
||
$post_id = get_the_ID(); | ||
$post_type = get_post_type(); | ||
|
||
// The editor for CPTs has an `edit/` route fragment prefixed. | ||
$post_type_editor_route_prefix = in_array( $post_type, array( 'page', 'post' ), true ) ? '' : 'edit'; | ||
|
||
if ( method_exists( 'Jetpack', 'build_raw_urls' ) ) { | ||
$site_slug = Jetpack::build_raw_urls( home_url() ); | ||
} elseif ( class_exists( 'WPCOM_Masterbar' ) && method_exists( 'WPCOM_Masterbar', 'get_calypso_site_slug' ) ) { | ||
$site_slug = WPCOM_Masterbar::get_calypso_site_slug( get_current_blog_id() ); | ||
} | ||
|
||
$upgrade_url = | ||
$plan_path_slug | ||
? add_query_arg( | ||
'redirect_to', | ||
'/' . implode( '/', array_filter( array( $post_type_editor_route_prefix, $post_type, $site_slug, $post_id ) ) ), | ||
"https://wordpress.com/checkout/${site_slug}/${plan_path_slug}" | ||
) : ''; | ||
|
||
return self::render_component( | ||
'upgrade-nudge', | ||
array( | ||
'planName' => $plan->product_name, | ||
'upgradeUrl' => $upgrade_url, | ||
) | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName | ||
/** | ||
* Plans Library | ||
* | ||
* Fetch plans data from WordPress.com. | ||
* | ||
* Not to be confused with the `Jetpack_Plan` (singular) | ||
* class, which stores and syncs data about the site's _current_ plan. | ||
* | ||
* @package Jetpack | ||
*/ | ||
class Jetpack_Plans { | ||
/** | ||
* Get a list of all available plans from WordPress.com | ||
* | ||
* @since 7.7.0 | ||
* | ||
* @return array The plans list | ||
*/ | ||
public static function get_plans() { | ||
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { | ||
if ( ! class_exists( 'Store_Product_List' ) ) { | ||
require WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/store-product-list.php'; | ||
} | ||
|
||
return Store_Product_List::get_active_plans_v1_5(); | ||
} | ||
|
||
// We're on Jetpack, so it's safe to use this namespace. | ||
$request = Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user( | ||
'/plans?_locale=' . get_user_locale(), | ||
// We're using version 1.5 of the endpoint rather than the default version 2 | ||
// since the latter only returns Jetpack Plans, but we're also interested in | ||
// WordPress.com plans, for consumers of this method that run on WP.com. | ||
'1.5', | ||
array( | ||
'method' => 'GET', | ||
'headers' => array( | ||
'X-Forwarded-For' => Jetpack::current_user_ip( true ), | ||
), | ||
), | ||
null, | ||
'rest' | ||
); | ||
|
||
$body = wp_remote_retrieve_body( $request ); | ||
if ( 200 === wp_remote_retrieve_response_code( $request ) ) { | ||
return json_decode( $body ); | ||
} else { | ||
return $body; | ||
} | ||
} | ||
|
||
/** | ||
* Get plan information for a plan given its slug | ||
* | ||
* @since 7.7.0 | ||
* | ||
* @param string $plan_slug Plan slug. | ||
* | ||
* @return object The plan object | ||
*/ | ||
public static function get_plan( $plan_slug ) { | ||
$plans = self::get_plans(); | ||
if ( ! is_array( $plans ) ) { | ||
return; | ||
} | ||
|
||
foreach ( $plans as $plan ) { | ||
if ( $plan_slug === $plan->product_slug ) { | ||
return $plan; | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import { renderToStaticMarkup } from 'react-dom/server'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { UpgradeNudge } from '../upgrade-nudge'; | ||
|
||
import './style.scss'; | ||
|
||
// Use dummy props that can be overwritten by a str_replace() on the server. | ||
// | ||
// Note that we're using the 'dumb' component exported from `upgrade-nudge.jsx` here, | ||
// rather than the 'smart' one (which is wrapped in `withSelect` and `withDispatch` calls). | ||
// This means putting the burden of props computation on PHP (`components.php`). | ||
// If we wanted to use the 'smart' component instead, we'd need to provide sufficiently | ||
// initialised Redux state when rendering ir (probably through globals set as arguments | ||
// to the `StaticSiteGeneratorPlugin` call in `webpack.config.extensions.js`). | ||
const upgradeNudge = renderToStaticMarkup( | ||
<UpgradeNudge planName="#planName#" upgradeUrl="#upgradeUrl#" /> | ||
); | ||
|
||
// StaticSiteGeneratorPlugin only supports `.html` extensions, even though | ||
// our rendered components contain some PHP. | ||
export default () => ( { | ||
'upgrade-nudge.html': upgradeNudge, | ||
} ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
@import '../styles/gutenberg-colors.scss'; | ||
@import '../styles/gutenberg-variables.scss'; | ||
|
||
// Styling copied from Gutenberg's `@wordpress/block-editor` styling for the Warning component | ||
// so we can use it on the frontend. | ||
.block-editor-warning { | ||
border: $border-width solid $light-gray-500; | ||
padding: 10px 14px; | ||
|
||
.block-editor-warning__message { | ||
line-height: $default-line-height; | ||
font-family: $default-font; | ||
font-size: $default-font-size; | ||
} | ||
|
||
.block-editor-warning__actions { | ||
.components-button { | ||
font-family: $default-font; | ||
font-weight: inherit; | ||
text-decoration: none; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import React from 'react'; | ||
|
||
// How (and why) does this transform work? | ||
// The idea here is to replace every call to each of `@wordpress/i18n`'s translation functions | ||
// (`__()`, `_n()`, `_x()`, `_nx()`) with their PHP counterpart, wrapped in a `<?php ... ?>` | ||
// pseudo-tag, and using `echo` to output the var, plus some escaping for sanitization. | ||
// The most puzzling part might be the `echo`: after all, we can't know _how_ the translated | ||
// strings are used in the Javascript source -- e.g. they might be assigned to a variable | ||
// for later usage, rather than rendered directly. | ||
// The answer is that this happens during React's rendering (to static markup, in this case), | ||
// where the entire component logic is essentially flattened to a component string. This means | ||
// that even translated strings that have gone through any intermediate steps will end up in | ||
// that rendered markup -- and thus, we'll have our `<?php echo esc_html__( ... ) ?>` | ||
// statements right where they belong. | ||
// A note on implementation: | ||
// Ideally, our replaced translation function would simply return `<?php echo esc_html__( ... ) ?>`. | ||
// However, React sanitizes strings by escaping chars like `<`. As a consequence, we need to use | ||
// `dangerouslySetInnerHTML` to bypass the escaping. This also requires to be attached as a prop | ||
// to a DOM element. I've chosen `<span />` since this likely has the smallest footprint for | ||
// rendering strings (e.g. shouldn't normally get in the way of styling). | ||
export const __ = ( text, domain ) => ( | ||
<span | ||
// eslint-disable-next-line react/no-danger | ||
dangerouslySetInnerHTML={ { | ||
__html: `<?php esc_html_e( '${ text }', '${ domain }' ) ?>`, | ||
} } | ||
/> | ||
); | ||
|
||
export const _n = ( single, plural, number, domain ) => ( | ||
<span | ||
// eslint-disable-next-line react/no-danger | ||
dangerouslySetInnerHTML={ { | ||
__html: `<?php echo esc_html( _n( '${ single }', '${ plural }', ${ number }, '${ domain }' ) ) ?>`, | ||
} } | ||
/> | ||
); | ||
|
||
export const _x = ( text, context, domain ) => ( | ||
<span | ||
// eslint-disable-next-line react/no-danger | ||
dangerouslySetInnerHTML={ { | ||
__html: `<?php echo esc_html( _x( '${ text }', '${ context }', '${ domain }' ) ) ?>`, | ||
} } | ||
/> | ||
); | ||
|
||
export const _nx = ( single, plural, number, context, domain ) => ( | ||
<span | ||
// eslint-disable-next-line react/no-danger | ||
dangerouslySetInnerHTML={ { | ||
__html: `<?php echo esc_html( _nx( '${ single }', '${ plural }', ${ number }, '${ context }', '${ domain }' ) ) ?>`, | ||
} } | ||
/> | ||
); | ||
|
||
// We have to stub `sprintf` with the identity function here, since the original | ||
// `sprintf from '@wordpress/i18n'` only accepts strings as its first argument -- but | ||
// our replaced translation functions return a React element (`<span />`, see above). | ||
// This means that our rendered markup will contain `sprintf`-style `%(placeholder)s` | ||
// for which we need to add an extra `str_replace()` step. This is done in `components.php`. | ||
// TODO: Provide a wrapper around `@wordpress/i18n`'s `sprintf` that accepts React elements | ||
// as first argument, and remove the `str_replace()` call in `components.php`. | ||
export const sprintf = x => x; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import { renderToStaticMarkup } from 'react-dom/server'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { __, _n, _x, _nx } from '../i18n-to-php'; | ||
|
||
describe( 'i18n-to-php', () => { | ||
test( 'renders __() to its PHP counterpart as expected', () => { | ||
expect( | ||
renderToStaticMarkup( | ||
__( 'Upgrade to a paid plan to use this block on your site.', 'text-domain' ) | ||
) | ||
).toBe( | ||
"<span><?php esc_html_e( 'Upgrade to a paid plan to use this block on your site.', 'text-domain' ) ?></span>" | ||
); | ||
} ); | ||
|
||
test( 'renders _n() to its PHP counterpart as expected', () => { | ||
expect( renderToStaticMarkup( _n( '%d person', '%d people', 1 + 2, 'text-domain' ) ) ).toBe( | ||
"<span><?php echo esc_html( _n( '%d person', '%d people', 3, 'text-domain' ) ) ?></span>" | ||
); | ||
} ); | ||
|
||
test( 'renders _x() to its PHP counterpart as expected', () => { | ||
expect( | ||
renderToStaticMarkup( _x( 'Read', 'past participle: books I have read', 'text-domain' ) ) | ||
).toBe( | ||
"<span><?php echo esc_html( _x( 'Read', 'past participle: books I have read', 'text-domain' ) ) ?></span>" | ||
); | ||
} ); | ||
|
||
test( 'renders _nx() to its PHP counterpart as expected', () => { | ||
expect( | ||
renderToStaticMarkup( | ||
_nx( '%d group', '%d groups', 2 + 3, 'group of people', 'text-domain' ) | ||
) | ||
).toBe( | ||
"<span><?php echo esc_html( _nx( '%d group', '%d groups', 5, 'group of people', 'text-domain' ) ) ?></span>" | ||
); | ||
} ); | ||
} ); |
Oops, something went wrong.