Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Facilitate using AMP components outside of AMP documents (AMP in PWA, “dirty AMP”) #1013

Merged
merged 4 commits into from
Mar 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions amp.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ function amp_deactivate() {
flush_rewrite_rules();
}

/*
* Register AMP scripts regardless of whether AMP is enabled or it is the AMP endpoint
* for the sake of being able to use AMP components on non-AMP documents ("dirty AMP").
*/
add_action( 'wp_default_scripts', 'amp_register_default_scripts' );

// Ensure async and custom-element/custom-template attributes are present on script tags.
add_filter( 'script_loader_tag', 'amp_filter_script_loader_tag', PHP_INT_MAX, 2 );

/**
* Set up AMP.
*
Expand Down
122 changes: 122 additions & 0 deletions includes/amp-helper-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,135 @@ function amp_get_asset_url( $file ) {
*
* @since 0.7
* @link https://www.ampproject.org/docs/reference/spec#boilerplate
*
* @return string Boilerplate code.
*/
function amp_get_boilerplate_code() {
return '<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style>'
. '<noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>';
}

/**
* Register default scripts for AMP components.
*
* @param WP_Scripts $wp_scripts Scripts.
*/
function amp_register_default_scripts( $wp_scripts ) {

// AMP Runtime.
$handle = 'amp-runtime';
$wp_scripts->add(
$handle,
'https://cdn.ampproject.org/v0.js',
array(),
null
);
$wp_scripts->add_data( $handle, 'amp_script_attributes', array(
'async' => true,
) );

// Shadow AMP API.
$handle = 'amp-shadow';
$wp_scripts->add(
$handle,
'https://cdn.ampproject.org/shadow-v0.js',
array(),
null
);
$wp_scripts->add_data( $handle, 'amp_script_attributes', array(
'async' => true,
) );

// Get all AMP components as defined in the spec.
$extensions = array();
foreach ( AMP_Allowed_Tags_Generated::get_allowed_tags() as $allowed_tag ) {
foreach ( $allowed_tag as $rule_spec ) {
if ( ! empty( $rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['requires_extension'] ) ) {
$extensions = array_merge(
$extensions,
$rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['requires_extension']
);
}
}
}
$extensions = array_unique( $extensions );

foreach ( $extensions as $extension ) {
$src = sprintf(
'https://cdn.ampproject.org/v0/%s-%s.js',
$extension,
'latest'
);

$wp_scripts->add(
$extension,
$src,
array( 'amp-runtime' ),
null
);
}
}

/**
* Add AMP script attributes to enqueued scripts.
*
* @link https://core.trac.wordpress.org/ticket/12009
* @since 0.7
*
* @param string $tag The script tag.
* @param string $handle The script handle.
* @return string Script loader tag.
*/
function amp_filter_script_loader_tag( $tag, $handle ) {
$prefix = 'https://cdn.ampproject.org/';
$src = wp_scripts()->registered[ $handle ]->src;
if ( 0 !== strpos( $src, $prefix ) ) {
return $tag;
}

/*
* All scripts from AMP CDN should be loaded async.
* See <https://www.ampproject.org/docs/integration/pwa-amp/amp-in-pwa#include-"shadow-amp"-in-your-progressive-web-app>.
*/
$attributes = array(
'async' => true,
);

// Add custom-template and custom-element attributes. All component scripts look like https://cdn.ampproject.org/v0/:name-:version.js.
if ( 'v0' === strtok( substr( $src, strlen( $prefix ) ), '/' ) ) {
/*
* Per the spec, "Most extensions are custom-elements." In fact, there is only one custom template. So we hard-code it here.
*
* @link https://github.com/ampproject/amphtml/blob/cd685d4e62153557519553ffa2183aedf8c93d62/validator/validator.proto#L326-L328
* @link https://github.com/ampproject/amphtml/blob/cd685d4e62153557519553ffa2183aedf8c93d62/extensions/amp-mustache/validator-amp-mustache.protoascii#L27
*/
if ( 'amp-mustache' === $handle ) {
$attributes['custom-template'] = $handle;
} else {
$attributes['custom-element'] = $handle;
}
}

// Add each attribute (if it hasn't already been added).
foreach ( $attributes as $key => $value ) {
if ( ! preg_match( ":\s$key(=|>|\s):", $tag ) ) {
if ( true === $value ) {
$attribute_string = sprintf( ' %s', esc_attr( $key ) );
} else {
$attribute_string = sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $value ) );
}
$tag = preg_replace(
':(?=></script>):',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the slash be escaped?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. This is doing a lookahead search to find ></script> in the string to inject content before it.

$attribute_string,
$tag,
1
);
}
}

return $tag;
}

/**
* Retrieve analytics data added in backend.
*
Expand Down
28 changes: 16 additions & 12 deletions includes/amp-post-template-actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,27 @@ function amp_post_template_add_canonical( $amp_template ) {
/**
* Print scripts.
*
* @see amp_register_default_scripts()
* @see amp_filter_script_loader_tag()
* @param AMP_Post_Template $amp_template Template.
*/
function amp_post_template_add_scripts( $amp_template ) {

// Just in case the runtime has been overridden by amp_post_template_data filter.
wp_scripts()->registered['amp-runtime']->src = $amp_template->get( 'amp_runtime_script' );

// Make sure any filtered extension script URLs get updated in registered scripts before printing.
$scripts = $amp_template->get( 'amp_component_scripts', array() );
foreach ( $scripts as $element => $script ) {
$custom_type = ( 'amp-mustache' === $element ) ? 'template' : 'element';
printf(
'<script custom-%s="%s" src="%s" async></script>', // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
esc_attr( $custom_type ),
esc_attr( $element ),
esc_url( $script )
);
foreach ( $scripts as $handle => $value ) {
if ( is_string( $value ) && wp_script_is( $handle, 'registered' ) ) {
wp_scripts()->registered[ $handle ]->src = $value;
}
}
printf(
'<script src="%s" async></script>', // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
esc_url( $amp_template->get( 'amp_runtime_script' ) )
);

wp_print_scripts( array_merge(
array( 'amp-runtime' ),
array_keys( $scripts )
) );
}

/**
Expand Down
139 changes: 51 additions & 88 deletions includes/class-amp-theme-support.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ public static function finish_init() {
self::register_paired_hooks();
}

// Enqueue AMP runtime.
wp_enqueue_script( 'amp-runtime' );

// Enqueue default styles expected by sanitizer.
wp_enqueue_style( 'amp-default', amp_get_asset_url( 'css/amp-default.css' ), array(), AMP__VERSION );

self::add_hooks();
self::$sanitizer_classes = amp_get_content_sanitizers();
self::$embed_handlers = self::register_content_embed_handlers();
Expand Down Expand Up @@ -212,9 +218,7 @@ public static function add_hooks() {
* in this case too we should defer to the theme as well to output the meta charset because it is possible the
* install is not on utf-8 and we may need to do a encoding conversion.
*/
add_action( 'wp_head', array( __CLASS__, 'add_amp_component_scripts' ), 10 );
add_action( 'wp_print_styles', array( __CLASS__, 'print_amp_styles' ), 0 ); // Print boilerplate before theme and plugin stylesheets.
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_amp_default_styles' ), 9 );
add_action( 'wp_head', 'amp_add_generator_metadata', 20 );
add_action( 'wp_head', 'amp_print_schemaorg_metadata' );

Expand Down Expand Up @@ -566,16 +570,6 @@ public static function filter_paired_template_include( $template ) {
return $template;
}

/**
* Print AMP script and placeholder for others.
*
* @link https://www.ampproject.org/docs/reference/spec#scrpt
*/
public static function add_amp_component_scripts() {
// Replaced after output buffering with all AMP component scripts.
echo self::SCRIPTS_PLACEHOLDER; // phpcs:ignore WordPress.Security.EscapeOutput, WordPress.XSS.EscapeOutput
}

/**
* Get canonical URL for current request.
*
Expand Down Expand Up @@ -748,69 +742,6 @@ public static function print_amp_styles() {
echo "<style amp-custom></style>\n"; // This will by populated by AMP_Style_Sanitizer.
}

/**
* Adds default styles expected by sanitizer.
*/
public static function enqueue_amp_default_styles() {
wp_enqueue_style( 'amp-default', amp_get_asset_url( 'css/amp-default.css' ) );
}

/**
* Determine required AMP scripts.
*
* @param array $amp_scripts Initial scripts.
* @return string Scripts to inject into the HEAD.
*/
public static function get_amp_scripts( $amp_scripts ) {

foreach ( self::$embed_handlers as $embed_handler ) {
$amp_scripts = array_merge(
$amp_scripts,
$embed_handler->get_scripts()
);
}

/**
* List of components that are custom elements.
*
* Per the spec, "Most extensions are custom-elements." In fact, there is only one custom template.
*
* @link https://github.com/ampproject/amphtml/blob/cd685d4e62153557519553ffa2183aedf8c93d62/validator/validator.proto#L326-L328
* @link https://github.com/ampproject/amphtml/blob/cd685d4e62153557519553ffa2183aedf8c93d62/extensions/amp-mustache/validator-amp-mustache.protoascii#L27
*/
$custom_templates = array( 'amp-mustache' );

/**
* Filters AMP component scripts before they are injected onto the output buffer for the response.
*
* Plugins may add their own component scripts which have been rendered but which the plugin doesn't yet
* recognize.
*
* @since 0.7
*
* @param array $amp_scripts AMP Component scripts, mapping component names to component source URLs.
*/
$amp_scripts = apply_filters( 'amp_component_scripts', $amp_scripts );

$scripts = '<script async src="https://cdn.ampproject.org/v0.js"></script>'; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
foreach ( $amp_scripts as $amp_script_component => $amp_script_source ) {

$custom_type = 'custom-element';
if ( in_array( $amp_script_component, $custom_templates, true ) ) {
$custom_type = 'custom-template';
}

$scripts .= sprintf(
'<script async %s="%s" src="%s"></script>', // phpcs:ignore WordPress.WP.EnqueuedResources, WordPress.XSS.EscapeOutput.OutputNotEscaped
$custom_type,
$amp_script_component,
$amp_script_source
);
}

return $scripts;
}

/**
* Ensure markup required by AMP <https://www.ampproject.org/docs/reference/spec#required-markup>.
*
Expand All @@ -819,10 +750,18 @@ public static function get_amp_scripts( $amp_scripts ) {
* canonical URL by default if a singular post.
*
* @since 0.7
* @todo All of this might be better placed inside of a sanitizer.
*
* @param DOMDocument $dom Doc.
*/
protected static function ensure_required_markup( DOMDocument $dom ) {
$xpath = new DOMXPath( $dom );

// First ensure the mandatory amp attribute is present on the html element, as otherwise it will be stripped entirely.
if ( ! $dom->documentElement->hasAttribute( 'amp' ) && ! $dom->documentElement->hasAttribute( '⚡️' ) ) {
$dom->documentElement->setAttribute( 'amp', '' );
}

$head = $dom->getElementsByTagName( 'head' )->item( 0 );
if ( ! $head ) {
$head = $dom->createElement( 'head' );
Expand Down Expand Up @@ -872,6 +811,15 @@ protected static function ensure_required_markup( DOMDocument $dom ) {
) );
$head->appendChild( $rel_canonical );
}

// Make sure scripts from the body get moved to the head.
$scripts = array();
foreach ( $xpath->query( '//body//script[ @custom-element or @custom-template ]' ) as $script ) {
$scripts[] = $script;
}
foreach ( $scripts as $script ) {
$head->appendChild( $script );
}
}

/**
Expand Down Expand Up @@ -1008,15 +956,10 @@ public static function prepare_response( $response, $args = array() ) {
}
$dom = AMP_DOM_Utils::get_dom( $response );

// First ensure the mandatory amp attribute is present on the html element, as otherwise it will be stripped entirely.
if ( ! $dom->documentElement->hasAttribute( 'amp' ) && ! $dom->documentElement->hasAttribute( '⚡️' ) ) {
$dom->documentElement->setAttribute( 'amp', '' );
}
self::ensure_required_markup( $dom );

$assets = AMP_Content_Sanitizer::sanitize_document( $dom, self::$sanitizer_classes, $args );

self::ensure_required_markup( $dom );

// @todo If 'utf-8' is not the blog charset, then we'll need to do some character encoding conversation or "entityification".
if ( 'utf-8' !== strtolower( get_bloginfo( 'charset' ) ) ) {
/* translators: %s is the charset of the current site */
Expand All @@ -1032,13 +975,33 @@ public static function prepare_response( $response, $args = array() ) {
$response = "<!DOCTYPE html>\n";
$response .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement );

// Inject required scripts.
$response = preg_replace(
'#' . preg_quote( self::SCRIPTS_PLACEHOLDER, '#' ) . '#',
self::get_amp_scripts( $assets['scripts'] ),
$response,
1
);
$amp_scripts = $assets['scripts'];
foreach ( self::$embed_handlers as $embed_handler ) {
$amp_scripts = array_merge(
$amp_scripts,
$embed_handler->get_scripts()
);
}

// Allow for embed handlers to override the default extension version by defining a different URL.
foreach ( $amp_scripts as $handle => $value ) {
if ( is_string( $value ) && wp_script_is( $handle, 'registered' ) ) {
wp_scripts()->registered[ $handle ]->src = $value;
}
}

// Print all scripts, some of which may have already been printed and inject into head.
ob_start();
wp_print_scripts( array_keys( $amp_scripts ) );
$script_tags = ob_get_clean();
if ( ! empty( $script_tags ) ) {
$response = preg_replace(
'#(?=</head>)#',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the slash be escaped?

$script_tags,
$response,
1
);
}

return $response;
}
Expand Down
Loading