Skip to content

Commit

Permalink
Build: Server-side Render UpgradeNudge for use in PHP (#13070)
Browse files Browse the repository at this point in the history
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
ockham authored Aug 2, 2019
1 parent eb9a15f commit 508024e
Show file tree
Hide file tree
Showing 13 changed files with 1,135 additions and 72 deletions.
105 changes: 105 additions & 0 deletions _inc/lib/components.php
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,
)
);
}
}
75 changes: 75 additions & 0 deletions _inc/lib/plans.php
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;
}
}
}
}
8 changes: 5 additions & 3 deletions bin/phpcs-whitelist.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ module.exports = [
'extensions/',
'functions.global.php',
'functions.opengraph.php',
'_inc/lib/debugger/',
'_inc/lib/core-api/wpcom-endpoints/memberships.php',
'_inc/lib/class.jetpack-password-checker.php',
'_inc/lib/admin-pages/class-jetpack-about-page.php',
'_inc/lib/class.jetpack-password-checker.php',
'_inc/lib/components.php',
'_inc/lib/core-api/wpcom-endpoints/memberships.php',
'_inc/lib/debugger/',
'_inc/lib/plans.php',
'load-jetpack.php',
'modules/masterbar/',
'modules/memberships/',
Expand Down
3 changes: 3 additions & 0 deletions class.jetpack-plan.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
/**
* Handles fetching of the site's plan from WordPress.com and caching the value locally.
*
* Not to be confused with the `Jetpack_Plans` class (in `_inc/lib/plans.php`), which
* fetches general information about all available plans from WordPress.com, side-effect free.
*
* @package Jetpack
*/

Expand Down
29 changes: 29 additions & 0 deletions extensions/shared/components/index.jsx
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,
} );
23 changes: 23 additions & 0 deletions extensions/shared/components/style.scss
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;
}
}
}
67 changes: 67 additions & 0 deletions extensions/shared/i18n-to-php.js
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;
45 changes: 45 additions & 0 deletions extensions/shared/test/i18n-to-php.js
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>"
);
} );
} );
Loading

0 comments on commit 508024e

Please sign in to comment.