diff --git a/.dev-lib b/.dev-lib index 25f81df2221..663a5ce0043 100644 --- a/.dev-lib +++ b/.dev-lib @@ -1,6 +1,8 @@ PATH_EXCLUDES_PATTERN=includes/lib/ DEFAULT_BASE_BRANCH=develop ASSETS_DIR=wp-assets +PROJECT_SLUG=amp +SKIP_ECHO_PATHS_SCOPE=1 function after_wp_install { echo "Installing REST API..." diff --git a/.travis.yml b/.travis.yml index 66fc489ed79..c7e4d9968e7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,31 +4,10 @@ language: php # Opt to use Travis container-based environment. sudo: false -# PHP version used in first build configuration. -php: - - "7.1" - -# WordPress version used in first build configuration. -env: - - WP_VERSION=latest - # Newer versions like trusty don't have PHP 5.2 or 5.3 # https://blog.travis-ci.com/2017-07-11-trusty-as-default-linux-is-coming dist: precise -# Next we define our matrix of additional build configurations to test against. -# The versions listed above will automatically create our first configuration, -# so it doesn't need to be re-defined below. - -# WP_VERSION specifies the tag to use. The way these tests are configured to run -# requires at least WordPress 3.8. Specify "latest" to test against SVN trunk. - -# Note that Travis CI supports listing these above to automatically build a -# matrix of configurations, but we're being nice here by manually building a -# total of four configurations even though we're testing 4 versions of PHP -# along with 2 versions of WordPress (which would build 8 configs otherwise). -# This takes half as long to run while still providing adequate coverage. - notifications: email: on_success: never @@ -39,24 +18,7 @@ cache: - node_modules - vendor - $HOME/phpunit-bin - -matrix: - include: - - php: "5.3" - env: WP_VERSION=latest DEV_LIB_SKIP=phpcs - - php: "5.3" - env: WP_VERSION=4.7 DEV_LIB_SKIP=phpcs - - php: "5.4" - env: WP_VERSION=latest - - php: "5.4" - env: WP_VERSION=4.7 - - php: "7.0" - env: WP_VERSION=latest - - php: "7.0" - env: WP_VERSION=4.7 - # 7.1 / latest already included above as first build. - - php: "7.1" - env: WP_VERSION=4.7 + - $HOME/deployment-targets install: - nvm install 6 && nvm use 6 @@ -68,3 +30,40 @@ script: after_script: - source $DEV_LIB_PATH/travis.after_script.sh + +jobs: + include: + - stage: test + php: "7.2" + env: WP_VERSION=trunk + - php: "5.3" + env: WP_VERSION=latest DEV_LIB_SKIP=composer,phpcs + - php: "5.4" + env: WP_VERSION=4.7 DEV_LIB_SKIP=composer,phpcs + - php: "5.5" + env: WP_VERSION=latest DEV_LIB_SKIP=phpcs + - php: "5.6" + env: WP_VERSION=4.8 DEV_LIB_SKIP=phpcs + - php: "5.6" + env: WP_VERSION=latest DEV_LIB_SKIP=phpcs + - php: "7.0" + env: WP_VERSION=latest DEV_LIB_SKIP=phpcs + - php: "7.1" + env: WP_VERSION=latest DEV_LIB_SKIP=phpcs + - stage: deploy + if: type = push AND fork = false AND ( branch =~ ^[a-z][a-z0-9-]{0,10}$ OR branch =~ ^[0-9]+\.[0-9]+$ ) AND NOT branch IN ( live, test, dev, settings, team, support, debug, multidev, files, tags, billing ) + php: "7.1" + env: WP_VERSION=latest DEV_LIB_ONLY=composer,grunt + script: + - | + eval "$(ssh-agent -s)" + pantheon_branch=$( echo $TRAVIS_BRANCH | sed 's/^\([0-9]\)/v\1/' | sed 's/[^a-z0-9-]/-/' ) + echo "Initializing deployment to Pantheon branch: $pantheon_branch" + openssl aes-256-cbc -K $encrypted_7eb11f40d4e9_key -iv $encrypted_7eb11f40d4e9_iv -in bin/keys/id_rsa_ampconfdemo.enc -out bin/keys/id_rsa_ampconfdemo -d + chmod 600 bin/keys/id_rsa_ampconfdemo + ./bin/deploy-travis-pantheon.sh \ + ampconfdemo \ + db7f3307-9808-4753-aaa4-acb387c94472 \ + $(pwd)/bin/keys/id_rsa_ampconfdemo \ + $pantheon_branch + after_script: skip diff --git a/Gruntfile.js b/Gruntfile.js index c0baa9fd8b3..fc2bd647957 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,5 +1,6 @@ /* eslint-env node */ /* jshint node:true */ +/* eslint-disable no-param-reassign */ module.exports = function( grunt ) { 'use strict'; @@ -76,33 +77,81 @@ module.exports = function( grunt ) { ] ); grunt.registerTask( 'build', function() { - var done = this.async(); + var done = this.async(), spawnQueue = [], stdout = []; - grunt.util.spawn( + spawnQueue.push( { cmd: 'git', - args: [ 'ls-files' ] + args: [ '--no-pager', 'log', '-1', '--format=%h', '--date=short' ] }, - function( err, res ) { - if ( err ) { - throw new Error( err.message ); + { + cmd: 'git', + args: [ 'ls-files' ] + } + ); + + function finalize() { + var commitHash, lsOutput, versionAppend; + commitHash = stdout.shift(); + lsOutput = stdout.shift(); + versionAppend = commitHash + '-' + new Date().toISOString().replace( /\.\d+/, '' ).replace( /-|:/g, '' ); + + grunt.task.run( 'clean' ); + grunt.config.set( 'copy', { + build: { + src: lsOutput.trim().split( /\n/ ).filter( function( file ) { + return ! /^(\.|bin|([^/]+)+\.(md|json|xml)|Gruntfile\.js|tests|wp-assets|dev-lib|readme\.md|composer\..*)/.test( file ); + } ), + dest: 'build', + expand: true, + options: { + noProcess: [ '*/**', 'LICENSE', 'jetpack-helper.php', 'wpcom-helper.php' ], // That is, only process amp.php and readme.txt. + process: function( content, srcpath ) { + var matches, version, versionRegex; + if ( /amp\.php$/.test( srcpath ) ) { + versionRegex = /(\*\s+Version:\s+)(\d+(\.\d+)+-\w+)/; + + // If not a stable build (e.g. 0.7.0-beta), amend the version with the git commit and current timestamp. + matches = content.match( versionRegex ); + if ( matches ) { + version = matches[2] + '-' + versionAppend; + console.log( 'Updating version in amp.php to ' + version ); + content = content.replace( versionRegex, '$1' + version ); + content = content.replace( /(define\(\s*'AMP__VERSION',\s*')(.+?)(?=')/, '$1' + version ); + } + } + return content; + } + } } + } ); + grunt.task.run( 'readme' ); + grunt.task.run( 'copy' ); + + grunt.task.run( 'shell:create_release_zip' ); + + done(); + } - grunt.config.set( 'copy', { - build: { - src: res.stdout.trim().split( /\n/ ).filter( function( file ) { - return ! /^(\.|bin|([^/]+)+\.(md|json|xml)|Gruntfile\.js|tests|wp-assets|dev-lib|readme\.md)/.test( file ); - } ), - dest: 'build', - expand: true + function doNext() { + var nextSpawnArgs = spawnQueue.shift(); + if ( ! nextSpawnArgs ) { + finalize(); + } else { + grunt.util.spawn( + nextSpawnArgs, + function( err, res ) { + if ( err ) { + throw new Error( err.message ); + } + stdout.push( res.stdout ); + doNext(); } - } ); - grunt.task.run( 'readme' ); - grunt.task.run( 'copy' ); - grunt.task.run( 'shell:create_release_zip' ); - done(); + ); } - ); + } + + doNext(); } ); grunt.registerTask( 'create-release-zip', [ @@ -115,7 +164,6 @@ module.exports = function( grunt ) { 'jshint', 'shell:phpunit', 'shell:verify_matching_versions', - 'wp_deploy', - 'clean' + 'wp_deploy' ] ); }; diff --git a/amp.php b/amp.php index 66b44f38762..5aa304c5939 100644 --- a/amp.php +++ b/amp.php @@ -5,7 +5,7 @@ * Plugin URI: https://github.com/automattic/amp-wp * Author: Automattic * Author URI: https://automattic.com - * Version: 0.7-beta1 + * Version: 0.7-RC1 * Text Domain: amp * Domain Path: /languages/ * License: GPLv2 or later @@ -32,7 +32,7 @@ function _amp_print_php_version_admin_notice() { define( 'AMP__FILE__', __FILE__ ); define( 'AMP__DIR__', dirname( __FILE__ ) ); -define( 'AMP__VERSION', '0.7-beta1' ); +define( 'AMP__VERSION', '0.7-RC1' ); require_once AMP__DIR__ . '/includes/class-amp-autoloader.php'; AMP_Autoloader::register(); @@ -55,7 +55,7 @@ function amp_deactivate() { // We need to manually remove the amp endpoint global $wp_rewrite; foreach ( $wp_rewrite->endpoints as $index => $endpoint ) { - if ( AMP_QUERY_VAR === $endpoint[1] ) { + if ( amp_get_slug() === $endpoint[1] ) { unset( $wp_rewrite->endpoints[ $index ] ); break; } @@ -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. * @@ -73,29 +82,13 @@ function amp_deactivate() { * @since 0.6 */ function amp_after_setup_theme() { + amp_get_slug(); // Ensure AMP_QUERY_VAR is set. + if ( false === apply_filters( 'amp_is_enabled', true ) ) { return; } - if ( ! defined( 'AMP_QUERY_VAR' ) ) { - /** - * Filter the AMP query variable. - * - * @since 0.3.2 - * @param string $query_var The AMP query variable. - */ - define( 'AMP_QUERY_VAR', apply_filters( 'amp_query_var', 'amp' ) ); - } - - add_action( 'init', 'amp_init' ); - add_action( 'widgets_init', 'AMP_Theme_Support::register_widgets' ); // @todo Let this be called by AMP_Theme_Support::init(). - add_action( 'init', 'AMP_Theme_Support::setup_commenting' ); // @todo Let this be called by AMP_Theme_Support::init(). - add_action( 'admin_init', 'AMP_Options_Manager::register_settings' ); - add_action( 'wp_loaded', 'amp_post_meta_box' ); - add_action( 'wp_loaded', 'amp_add_options_menu' ); - add_action( 'parse_query', 'amp_correct_query_when_is_front_page' ); - AMP_Post_Type_Support::add_post_type_support(); - AMP_Validation_Utils::init(); + add_action( 'init', 'amp_init', 0 ); // Must be 0 because widgets_init happens at init priority 1. } add_action( 'after_setup_theme', 'amp_after_setup_theme', 5 ); @@ -103,7 +96,6 @@ function amp_after_setup_theme() { * Init AMP. * * @since 0.1 - * @global string $pagenow */ function amp_init() { @@ -116,10 +108,16 @@ function amp_init() { load_plugin_textdomain( 'amp', false, plugin_basename( AMP__DIR__ ) . '/languages' ); - add_rewrite_endpoint( AMP_QUERY_VAR, EP_PERMALINK ); + add_rewrite_endpoint( amp_get_slug(), EP_PERMALINK ); + AMP_Validation_Utils::init(); + AMP_Theme_Support::init(); + AMP_Post_Type_Support::add_post_type_support(); add_filter( 'request', 'amp_force_query_var_value' ); - add_action( 'wp', 'amp_maybe_add_actions' ); + add_action( 'admin_init', 'AMP_Options_Manager::register_settings' ); + add_action( 'wp_loaded', 'amp_post_meta_box' ); + add_action( 'wp_loaded', 'amp_add_options_menu' ); + add_action( 'parse_query', 'amp_correct_query_when_is_front_page' ); // Redirect the old url of amp page to the updated url. add_filter( 'old_slug_redirect_url', 'amp_redirect_old_slug_to_new_url' ); @@ -127,14 +125,16 @@ function amp_init() { if ( class_exists( 'Jetpack' ) && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) { require_once AMP__DIR__ . '/jetpack-helper.php'; } - amp_handle_xhr_request(); + + // Add actions for legacy post templates. + add_action( 'wp', 'amp_maybe_add_actions' ); } // Make sure the `amp` query var has an explicit value. // Avoids issues when filtering the deprecated `query_string` hook. function amp_force_query_var_value( $query_vars ) { - if ( isset( $query_vars[ AMP_QUERY_VAR ] ) && '' === $query_vars[ AMP_QUERY_VAR ] ) { - $query_vars[ AMP_QUERY_VAR ] = 1; + if ( isset( $query_vars[ amp_get_slug() ] ) && '' === $query_vars[ amp_get_slug() ] ) { + $query_vars[ amp_get_slug() ] = 1; } return $query_vars; } @@ -150,15 +150,9 @@ function amp_force_query_var_value( $query_vars ) { * @return void */ function amp_maybe_add_actions() { - $is_amp_endpoint = is_amp_endpoint(); - // Add hooks for when a themes that support AMP. + // Short-circuit when theme supports AMP, as everything is handled by AMP_Theme_Support. if ( current_theme_supports( 'amp' ) ) { - if ( $is_amp_endpoint ) { - AMP_Theme_Support::init(); - } else { - amp_add_frontend_actions(); - } return; } @@ -168,6 +162,8 @@ function amp_maybe_add_actions() { return; } + $is_amp_endpoint = is_amp_endpoint(); + /** * Queried post object. * @@ -207,7 +203,7 @@ function amp_correct_query_when_is_front_page( WP_Query $query ) { $query->is_home() && // Is AMP endpoint. - false !== $query->get( AMP_QUERY_VAR, false ) + false !== $query->get( amp_get_slug(), false ) && // Is query not yet fixed uo up to be front page. ! $query->is_front_page() @@ -219,7 +215,7 @@ function amp_correct_query_when_is_front_page( WP_Query $query ) { get_option( 'page_on_front' ) && // See line in WP_Query::parse_query() at . - 0 === count( array_diff( array_keys( wp_parse_args( $query->query ) ), array( AMP_QUERY_VAR, 'preview', 'page', 'paged', 'cpage' ) ) ) + 0 === count( array_diff( array_keys( wp_parse_args( $query->query ) ), array( amp_get_slug(), 'preview', 'page', 'paged', 'cpage' ) ) ) ); if ( $is_front_page_query ) { $query->is_home = false; @@ -309,9 +305,9 @@ function amp_render_post( $post ) { * which is not ideal for any code that expects to run in an AMP context. * Let's force the value to be true while we render AMP. */ - $was_set = isset( $wp_query->query_vars[ AMP_QUERY_VAR ] ); + $was_set = isset( $wp_query->query_vars[ amp_get_slug() ] ); if ( ! $was_set ) { - $wp_query->query_vars[ AMP_QUERY_VAR ] = true; + $wp_query->query_vars[ amp_get_slug() ] = true; } // Prevent New Relic from causing invalid AMP responses due the NREUM script it injects after the meta charset. @@ -333,7 +329,7 @@ function amp_render_post( $post ) { $template->load(); if ( ! $was_set ) { - unset( $wp_query->query_vars[ AMP_QUERY_VAR ] ); + unset( $wp_query->query_vars[ amp_get_slug() ] ); } } @@ -364,7 +360,7 @@ function _amp_bootstrap_customizer() { function amp_redirect_old_slug_to_new_url( $link ) { if ( is_amp_endpoint() ) { - $link = trailingslashit( trailingslashit( $link ) . AMP_QUERY_VAR ); + $link = trailingslashit( trailingslashit( $link ) . amp_get_slug() ); } return $link; diff --git a/assets/css/amp-default.css b/assets/css/amp-default.css new file mode 100644 index 00000000000..d745dd9b186 --- /dev/null +++ b/assets/css/amp-default.css @@ -0,0 +1,11 @@ +.amp-wp-enforced-sizes { + /** Our sizes fallback is 100vw, and we have a padding on the container; the max-width here prevents the element from overflowing. **/ + max-width: 100%; + margin: 0 auto; +} + +.amp-wp-unknown-size img { + /** Worst case scenario when we can't figure out dimensions for an image. **/ + /** Force the image into a box of fixed dimensions and use object-fit to scale. **/ + object-fit: contain; +} diff --git a/assets/css/amp-playlist-shortcode.css b/assets/css/amp-playlist-shortcode.css new file mode 100644 index 00000000000..3d8de1c254d --- /dev/null +++ b/assets/css/amp-playlist-shortcode.css @@ -0,0 +1,19 @@ +/** +* For the custom AMP implementation of the 'playlist' shortcode. +*/ +.wp-playlist .wp-playlist-current-item img { + margin-right: 0; +} + +.wp-playlist .wp-playlist-current-item amp-img { + float: left; + margin-right: 10px; +} + +.wp-playlist audio { + display: block; +} + +.wp-playlist .amp-carousel-button { + visibility: hidden; +} diff --git a/assets/js/amp-post-meta-box.js b/assets/js/amp-post-meta-box.js index ecbea808b81..efeadeb7485 100644 --- a/assets/js/amp-post-meta-box.js +++ b/assets/js/amp-post-meta-box.js @@ -161,6 +161,8 @@ var ampPostMetaBox = ( function( $ ) { editAmpStatus.fadeToggle( component.toggleSpeed, function() { if ( editAmpStatus.is( ':visible' ) ) { editAmpStatus.focus(); + } else { + $container.find( 'input[type="radio"]' ).first().focus(); } } ); $container.slideToggle( component.toggleSpeed ); diff --git a/bin/create-gutenberg-test-post.php b/bin/create-gutenberg-test-post.php new file mode 100644 index 00000000000..75211d31c69 --- /dev/null +++ b/bin/create-gutenberg-test-post.php @@ -0,0 +1,175 @@ +.+)\.html:s', basename( $file ), $matches ); + if ( isset( $matches['block'] ) ) { + $content .= sprintf( '

%s

', $matches['block'] ); + } + $content .= file_get_contents( $file ); // @codingStandardsIgnoreLine: file_get_contents_file_get_contents, file_system_read_file_get_contents. + } + } + + // Replace broken URLs in fixture files. + $content = str_replace( 'http://google.com/hi.png', 'https://cldup.com/-3VMmmrPm9.jpg', $content ); + $content = str_replace( 'https://awesome-fake.video/file.mp4', 'https://videos.files.wordpress.com/DK5mLrbr/video-ca6dc0ab4a_hd.mp4', $content ); + return $content; +} + +/** + * Gets the Gutenberg block permutations. + * + * These are mostly copied from gutenberg/blocks/test/fixtures/, and slightly modified. + * Embeds and shortcodes are tested in a separate script, so this does not have have many. + * + * @return string $content The blocks as HTML. + */ +function get_test_block_permutations() { + $blocks = array( + array( + 'title' => '(Reusable) Block With Video', + 'content' => sprintf( '', create_test_reusable_block() ), + ), + array( + 'title' => 'Categories With Dropdown', + 'content' => '', + ), + array( + 'title' => 'Columns, With 2 Columns', + 'content' => '

Column One, Paragraph One

Column One, Paragraph Two

Column Two, Paragraph One

', + ), + array( + 'title' => 'Cover Image With Fixed Background', + 'content' => '

Guten Berg!

', + ), + array( + 'title' => 'WordPress Embed', + 'content' => '
https://make.wordpress.org/core/2017/12/11/whats-new-in-gutenberg-11th-december/
Embedded content from WordPress
', + ), + array( + 'title' => 'YouTube Embed', + 'content' => '
https://www.youtube.com/watch?v=GGS-tKTXw4Y
Embedded content from youtube
', + ), + array( + 'title' => 'Twitter Embed', + 'content' => '
https://twitter.com/AMPhtml/status/963443140005957632
We are Automattic
', + ), + array( + 'title' => 'Gallery With 3 Columns', + 'content' => '', + ), + array( + 'title' => 'Audio Shortcode', + 'content' => '[audio src=https://wptavern.com/wp-content/uploads/2017/11/EPISODE-296-Gutenberg-Telemetry-Calypso-and-More-With-Matt-Mullenweg.mp3]', + ), + array( + 'title' => 'Caption Shortcode', + 'content' => '[caption width=150]This is a caption[/caption]', + ), + ); + + $content = ''; + foreach ( $blocks as $block ) { + $content .= sprintf( '

%s

', $block['title'] ); + $content .= $block['content']; + } + + return $content; +} + +/** + * Creates a reusable block with a video. + * + * Reusable blocks are stored in custom post types. + * This creates one, and returns the ID on success. + * + * @return int|\WP_Error $post_id The post ID where the reusable block is stored, and 0 or WP_Error in case of failure. + */ +function create_test_reusable_block() { + return wp_insert_post( array( + 'post_type' => 'wp_block', + 'post_title' => 'Test Reusable Block', + 'post_content' => '
', + 'post_status' => 'publish', + ) ); +} + +/** + * Creates a Gutenberg test post (page). + * + * @throws \Exception If there is an error in creating the test page. + * @param string $content The content to add to the post. + * @return int Page ID. + */ +function create_gutenberg_test_post( $content ) { + $slug = 'amp-test-gutenberg-blocks'; + $title = 'AMP Test Gutenberg Blocks'; + $page = get_page_by_path( "/{$slug}/" ); + $failure_message = 'The test page could not be added, please try again.'; + if ( $page ) { + $page_id = $page->ID; + } else { + $page_id = wp_insert_post( array( + 'post_name' => $slug, + 'post_title' => $title, + 'post_type' => 'page', + ) ); + + if ( ! $page_id || is_wp_error( $page_id ) ) { + throw new \Exception( $failure_message ); + } + } + + $update = wp_update_post( array( + 'ID' => $page_id, + 'post_content' => $content, + ) ); + + if ( ! $update ) { + throw new \Exception( $failure_message ); + } + return $update; +} + +// Bootstrap. +if ( defined( 'WP_CLI' ) ) { + try { + $post_id = create_gutenberg_test_post( get_test_block_fixtures() ); + \WP_CLI::success( sprintf( 'The test page is at: %s', \amp_get_permalink( $post_id ) . '#development=1' ) ); + } catch ( \Exception $e ) { + \WP_CLI::error( $e->getMessage() ); + } +} else { + echo "This script should be run WP-CLI via: wp eval-file bin/create-gutenberg-test-post.php\n"; + exit( 1 ); +} diff --git a/bin/create-gutenberg-test-post.sh b/bin/create-gutenberg-test-post.sh new file mode 100644 index 00000000000..1c719dac00c --- /dev/null +++ b/bin/create-gutenberg-test-post.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" + +BIN_PATH="$(pwd)" +PROJECT_PATH=$(dirname $PWD) +PLUGINS_PATH=$(dirname $PROJECT_PATH) +GUTENBERG_PATH=$PLUGINS_PATH/gutenberg + +# Clone the Gutenberg plugin, or pull the master branch if it's already present. +if [[ ! -e $GUTENBERG_PATH ]]; then + cd $PLUGINS_PATH + echo "This needs to clone the Gutenberg plugin into your plugins directory, as it looks like it's not there." + read -p "Is that alright? y/n " -r + if [[ $REPLY =~ [Yy] ]]; then + git clone https://github.com/WordPress/gutenberg.git + wp plugin activate gutenberg + echo "The Gutenberg plugin is cloned. Please follow the build steps:" + echo "https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md" + else + echo "Exiting script." + exit 1 + fi +else + cd $GUTENBERG_PATH + if [ 'master' == $( git rev-parse --abbrev-ref HEAD ) ]; then + git pull origin master + fi +fi + +cd $PROJECT_PATH +wp eval-file bin/create-gutenberg-test-post.php diff --git a/bin/deploy-travis-pantheon.sh b/bin/deploy-travis-pantheon.sh new file mode 100755 index 00000000000..3d84d8e6e45 --- /dev/null +++ b/bin/deploy-travis-pantheon.sh @@ -0,0 +1,99 @@ +#!/bin/bash +set -e +# Travis CI Deploy script to Pantheon + +# Positional args: +pantheon_site=$1 +pantheon_uuid=$2 +ssh_identity=$3 +pantheon_branch=$4 + +if [[ -z "$pantheon_site" ]]; then + echo "Missing pantheon_site positional arg 1" + exit 1 +fi +if [[ -z "$pantheon_uuid" ]]; then + echo "Missing pantheon_uuid positional arg 2" + exit 1 +fi +if [[ -z "$ssh_identity" ]]; then + echo "Missing ssh_identity positional arg 3" + exit 1 +fi +if [[ -z "$pantheon_branch" ]]; then + echo "Missing pantheon_branch positional arg 4" + exit 1 +fi + +cd "$(dirname "$0")/.." +project_dir="$(pwd)" +repo_dir="$HOME/deployment-targets/$pantheon_site" + +# Restrict deploys to commits pushed to a branch name that can be used as a subdomain, specifically here on a Pantheon multidev environment: +# "Branch names can contain any ASCII letter and number (a through z, 0 through 9) and hyphen (dash). The branch name must start with a letter or number. +# Currently, the maximum length is 11 characters and environments cannot be created with the following reserved names." +# Note: master is allowed since it maps to dev; the dev branch is instead disallowed. +if ! [[ $pantheon_branch =~ ^[a-z][a-z0-9-]{0,10}$ ]] || [[ $pantheon_branch =~ ^(live|test|dev|settings|team|support|debug|multidev|files|tags|billing)$ ]]; then + echo "Aborting since branch '$pantheon_branch' cannot be an environment." + exit 1 +fi + +ssh-add $ssh_identity + +if ! grep -q "codeserver.dev.$pantheon_uuid.drush.in" ~/.ssh/known_hosts; then + ssh-keyscan -p 2222 codeserver.dev.$pantheon_uuid.drush.in >> ~/.ssh/known_hosts +fi + +if ! grep -q "codeserver.dev.$pantheon_uuid.drush.in" ~/.ssh/config; then + echo "Host $pantheon_site" >> ~/.ssh/config + echo " Hostname codeserver.dev.$pantheon_uuid.drush.in" >> ~/.ssh/config + echo " User codeserver.dev.$pantheon_uuid" >> ~/.ssh/config + echo " IdentityFile $ssh_identity" >> ~/.ssh/config + echo " IdentitiesOnly yes" >> ~/.ssh/config + echo " Port 2222" >> ~/.ssh/config + echo " KbdInteractiveAuthentication no" >> ~/.ssh/config +fi +git config --global user.name "Travis CI" +git config --global user.email "travis-ci+$pantheon_site@xwp.co" + +# Set the branch. +if [[ $pantheon_branch == 'master' ]]; then + pantheon_env=dev +else + pantheon_env=$pantheon_branch +fi + +if [ ! -e "$repo_dir/.git" ]; then + git clone -v ssh://codeserver.dev.$pantheon_uuid@codeserver.dev.$pantheon_uuid.drush.in:2222/~/repository.git "$repo_dir" +fi + +cd "$repo_dir" +git fetch + +if git rev-parse --verify --quiet "$pantheon_branch" > /dev/null; then + git checkout "$pantheon_branch" +else + git checkout -b "$pantheon_branch" +fi +if git rev-parse --verify --quiet "origin/$pantheon_branch" > /dev/null; then + git reset --hard "origin/$pantheon_branch" +fi + +# Install and build. +cd "$project_dir" +if [ ! -e node_modules/.bin ]; then + npm install +fi +PATH="node_modules/.bin/:$PATH" +grunt build +rsync -avz --delete ./build/ "$repo_dir/wp-content/plugins/amp/" +git --no-pager log -1 --format="Build AMP plugin at %h: %s" > /tmp/commit-message.txt + +# Commit and deploy. +cd "$repo_dir" +git add -A "wp-content/plugins/amp/" +git commit -F /tmp/commit-message.txt +git push origin $pantheon_branch + +echo "View site at http://$pantheon_env-$pantheon_site.pantheonsite.io/" +echo "Access Pantheon dashboard at https://dashboard.pantheon.io/sites/$pantheon_uuid#$pantheon_env" diff --git a/bin/keys/id_rsa_ampconfdemo.enc b/bin/keys/id_rsa_ampconfdemo.enc new file mode 100644 index 00000000000..807047641ec Binary files /dev/null and b/bin/keys/id_rsa_ampconfdemo.enc differ diff --git a/bin/keys/id_rsa_ampconfdemo.pub b/bin/keys/id_rsa_ampconfdemo.pub new file mode 100644 index 00000000000..153effc4fe7 --- /dev/null +++ b/bin/keys/id_rsa_ampconfdemo.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKI5pL/0+v6neXAyIAy9nCrFQS4g4YoeNe7Ij+2wzgO5tZ1wnY931pm5IJ4JD310m/LYqpVn6Uwc/qKwVDeHHPQDOAuL7eCJIt2ns+DqYoP1VEYBYgeyOdc8j20n0doi5fYklc6CYWHVJomvQ/Sn5ACfnuA4ppBZdUxIo70tJzv7SAyOZli4D7hNhxpBNl4i9H0S/XIT61TSroEAFU78BR1hv9e+U0SkGSpIP44vuwQ7jG/DlB0X7J2p1w9VosnEcwbDaMJF4THiSDUAB6Ezwxa481vKPHFunGnwuOl7QSbglz/WrZ1/v5N8Uzz2E6moRT1fMgBC2ygWJAgwq3VS/b travis-ci+ampconfdemo@xwp.co diff --git a/bin/verify-version-consistency.php b/bin/verify-version-consistency.php index 4a42d2fcb9f..1c9b8328fee 100755 --- a/bin/verify-version-consistency.php +++ b/bin/verify-version-consistency.php @@ -1,4 +1,4 @@ -#!/usr/bin/env +#!/usr/bin/env php \d+\.\d+(?:.\d+)?)/', $plugin_file, $matches ) ) { +if ( ! preg_match( '/\*\s*Version:\s*(?P\d+\.\d+(?:.\d+)?(-\w+)?)/', $plugin_file, $matches ) ) { echo "Could not find version in readme metadata\n"; exit( 1 ); } @@ -59,3 +59,8 @@ fwrite( STDERR, "Error: Not all version references have been updated.\n" ); exit( 1 ); } + +if ( false === strpos( $versions['amp.php#metadata'], '-' ) && ! preg_match( '/^\d+\.\d+\.\d+$/', $versions['amp.php#metadata'] ) ) { + fwrite( STDERR, sprintf( "Error: Release version (%s) lacks patch number. For new point releases, supply patch number of 0, such as 0.9.0 instead of 0.9.\n", $versions['amp.php#metadata'] ) ); + exit( 1 ); +} diff --git a/contributing.md b/contributing.md index 9b7c8c29830..ba5f791c223 100644 --- a/contributing.md +++ b/contributing.md @@ -42,6 +42,14 @@ To run it: 3. run `wp eval-file bin/create-comments-on-test-post.php` 4. go to the URL that is output in the command line +## Testing Gutenberg Block Support + +The following script creates a post with all core Gutenberg blocks. To run it: +1. `ssh` into an environment like [VVV](https://github.com/Varying-Vagrant-Vagrants/VVV) +2. `cd` to the root of this plugin +3. run `bash bin/create-gutenberge-test-post.sh` +4. go to the URL that is output in the command line + ## PHPUnit Testing Please run these tests in an environment with WordPress unit tests installed, like [VVV](https://github.com/Varying-Vagrant-Vagrants/VVV). diff --git a/dev-lib b/dev-lib index 4ac4bd20b5a..c80f10dad3c 160000 --- a/dev-lib +++ b/dev-lib @@ -1 +1 @@ -Subproject commit 4ac4bd20b5a4b3953eb81a8d2536baef2bf60dcb +Subproject commit c80f10dad3cb402213914438d8ae1cdeccce9e4a diff --git a/includes/admin/class-amp-customizer.php b/includes/admin/class-amp-customizer.php index a3efe74c55e..cfb7bb30fc6 100644 --- a/includes/admin/class-amp-customizer.php +++ b/includes/admin/class-amp-customizer.php @@ -134,7 +134,7 @@ public function add_customizer_scripts() { wp_add_inline_script( 'amp-customize-controls', sprintf( 'ampCustomizeControls.boot( %s );', wp_json_encode( array( - 'queryVar' => AMP_QUERY_VAR, + 'queryVar' => amp_get_slug(), 'panelId' => self::PANEL_ID, 'ampUrl' => amp_admin_get_preview_permalink(), 'l10n' => array( diff --git a/includes/admin/class-amp-post-meta-box.php b/includes/admin/class-amp-post-meta-box.php index 1152c4db1a3..55842b335ba 100644 --- a/includes/admin/class-amp-post-meta-box.php +++ b/includes/admin/class-amp-post-meta-box.php @@ -121,6 +121,8 @@ public function enqueue_admin_assets() { isset( $screen->base ) && 'post' === $screen->base + && + is_post_type_viewable( $post->post_type ) ); if ( ! $validate ) { return; @@ -143,7 +145,7 @@ public function enqueue_admin_assets() { ); wp_add_inline_script( self::ASSETS_HANDLE, sprintf( 'ampPostMetaBox.boot( %s );', wp_json_encode( array( - 'previewLink' => esc_url_raw( add_query_arg( AMP_QUERY_VAR, '', get_preview_post_link( $post ) ) ), + 'previewLink' => esc_url_raw( add_query_arg( amp_get_slug(), '', get_preview_post_link( $post ) ) ), 'canonical' => amp_is_canonical(), 'enabled' => post_supports_amp( $post ), 'canSupport' => count( AMP_Post_Type_Support::get_support_errors( $post ) ) === 0, @@ -165,6 +167,8 @@ public function render_status( $post ) { $verify = ( isset( $post->ID ) && + is_post_type_viewable( $post->post_type ) + && current_user_can( 'edit_post', $post->ID ) && ! amp_is_canonical() @@ -233,7 +237,7 @@ public function preview_post_link( $link ) { ); if ( $is_amp ) { - $link = add_query_arg( AMP_QUERY_VAR, true, $link ); + $link = add_query_arg( amp_get_slug(), true, $link ); } return $link; diff --git a/includes/admin/functions.php b/includes/admin/functions.php index f5539a05a04..80b94511e3d 100644 --- a/includes/admin/functions.php +++ b/includes/admin/functions.php @@ -48,7 +48,7 @@ function amp_admin_get_preview_permalink() { */ $post_type = (string) apply_filters( 'amp_customizer_post_type', 'post' ); - if ( ! post_type_supports( $post_type, AMP_QUERY_VAR ) ) { + if ( ! post_type_supports( $post_type, amp_get_slug() ) ) { return null; } diff --git a/includes/amp-frontend-actions.php b/includes/amp-frontend-actions.php index f62b93f6b70..f5d7ebca13d 100644 --- a/includes/amp-frontend-actions.php +++ b/includes/amp-frontend-actions.php @@ -10,23 +10,31 @@ /** * Add amphtml link to frontend. * + * @todo This function's name is incorrect. It's not about adding a canonical link but adding the amphtml link. + * * @since 0.2 */ function amp_frontend_add_canonical() { - // Prevent showing amphtml link if theme supports AMP but paired mode is not available. - if ( current_theme_supports( 'amp' ) && ! AMP_Theme_Support::is_paired_available() ) { - return; - } - /** * Filters whether to show the amphtml link on the frontend. * + * @todo This filter's name is incorrect. It's not about adding a canonical link but adding the amphtml link. * @since 0.2 */ if ( false === apply_filters( 'amp_frontend_show_canonical', true ) ) { return; } - printf( '', esc_url( amp_get_permalink( get_queried_object_id() ) ) ); + $amp_url = null; + if ( is_singular() ) { + $amp_url = amp_get_permalink( get_queried_object_id() ); + } elseif ( isset( $_SERVER['REQUEST_URI'] ) ) { + $host_url = preg_replace( '#(^https?://[^/]+)/.*#', '$1', home_url( '/' ) ); + $self_url = esc_url_raw( $host_url . wp_unslash( $_SERVER['REQUEST_URI'] ) ); + $amp_url = add_query_arg( amp_get_slug(), '', $self_url ); + } + if ( $amp_url ) { + printf( '', esc_url( $amp_url ) ); + } } diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index d8c4690568c..27d27696058 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -5,6 +5,36 @@ * @package AMP */ +/** + * Get the slug used in AMP for the query var, endpoint, and post type support. + * + * The return value can be overridden by previously defining a AMP_QUERY_VAR + * constant or by adding a 'amp_query_var' filter, but *warning* this ability + * may be deprecated in the future. Normally the slug should be just 'amp'. + * + * @since 0.7 + * @return string Slug used for query var, endpoint, and post type support. + */ +function amp_get_slug() { + if ( defined( 'AMP_QUERY_VAR' ) ) { + return AMP_QUERY_VAR; + } + + /** + * Filter the AMP query variable. + * + * Warning: This filter may become deprecated. + * + * @since 0.3.2 + * @param string $query_var The AMP query variable. + */ + $query_var = apply_filters( 'amp_query_var', 'amp' ); + + define( 'AMP_QUERY_VAR', $query_var ); + + return $query_var; +} + /** * Retrieves the full AMP-specific permalink for the given post ID. * @@ -32,12 +62,16 @@ function amp_get_permalink( $post_id ) { return $pre_url; } - $parsed_url = wp_parse_url( get_permalink( $post_id ) ); - $structure = get_option( 'permalink_structure' ); - if ( empty( $structure ) || ! empty( $parsed_url['query'] ) || is_post_type_hierarchical( get_post_type( $post_id ) ) ) { - $amp_url = add_query_arg( AMP_QUERY_VAR, '', get_permalink( $post_id ) ); + if ( amp_is_canonical() ) { + $amp_url = get_permalink( $post_id ); } else { - $amp_url = trailingslashit( get_permalink( $post_id ) ) . user_trailingslashit( AMP_QUERY_VAR, 'single_amp' ); + $parsed_url = wp_parse_url( get_permalink( $post_id ) ); + $structure = get_option( 'permalink_structure' ); + if ( empty( $structure ) || ! empty( $parsed_url['query'] ) || is_post_type_hierarchical( get_post_type( $post_id ) ) ) { + $amp_url = add_query_arg( amp_get_slug(), '', get_permalink( $post_id ) ); + } else { + $amp_url = trailingslashit( get_permalink( $post_id ) ) . user_trailingslashit( amp_get_slug(), 'single_amp' ); + } } /** @@ -51,6 +85,25 @@ function amp_get_permalink( $post_id ) { return apply_filters( 'amp_get_permalink', $amp_url, $post_id ); } +/** + * Remove the AMP endpoint (and query var) from a given URL. + * + * @since 0.7 + * + * @param string $url URL. + * @return string URL with AMP stripped. + */ +function amp_remove_endpoint( $url ) { + + // Strip endpoint. + $url = preg_replace( ':/' . preg_quote( amp_get_slug(), ':' ) . '(?=/?(\?|#|$)):', '', $url ); + + // Strip query var. + $url = remove_query_arg( amp_get_slug(), $url ); + + return $url; +} + /** * Determine whether a given post supports AMP. * @@ -118,6 +171,10 @@ function post_supports_amp( $post ) { * @return bool Whether it is the AMP endpoint. */ function is_amp_endpoint() { + if ( is_admin() || is_feed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { + return false; + } + if ( amp_is_canonical() ) { return true; } @@ -126,7 +183,7 @@ function is_amp_endpoint() { _doing_it_wrong( __FUNCTION__, sprintf( esc_html__( "is_amp_endpoint() was called before the 'parse_query' hook was called. This function will always return 'false' before the 'parse_query' hook is called.", 'amp' ) ), '0.4.2' ); } - return false !== get_query_var( AMP_QUERY_VAR, false ); + return false !== get_query_var( amp_get_slug(), false ); } /** @@ -144,6 +201,7 @@ 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() { @@ -151,6 +209,127 @@ function amp_get_boilerplate_code() { . ''; } +/** + * 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 . + */ + $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( + ':(?=>):', + $attribute_string, + $tag, + 1 + ); + } + } + + return $tag; +} + /** * Retrieve analytics data added in backend. * @@ -194,9 +373,12 @@ function amp_get_analytics( $analytics = array() ) { * * @since 0.7 * - * @param array $analytics Analytics entries. + * @param array|string $analytics Analytics entries, or empty string when called via wp_footer action. */ function amp_print_analytics( $analytics ) { + if ( '' === $analytics ) { + $analytics = array(); + } $analytics_entries = amp_get_analytics( $analytics ); if ( empty( $analytics_entries ) ) { @@ -260,6 +442,7 @@ function amp_get_content_embed_handlers( $post = null ) { 'AMP_Vine_Embed_Handler' => array(), 'AMP_Facebook_Embed_Handler' => array(), 'AMP_Pinterest_Embed_Handler' => array(), + 'AMP_Playlist_Embed_Handler' => array(), 'AMP_Reddit_Embed_Handler' => array(), 'AMP_Tumblr_Embed_Handler' => array(), 'AMP_Gallery_Embed_Handler' => array(), @@ -483,94 +666,3 @@ function amp_print_schemaorg_metadata() { get_error_message(); - } - $amp_mustache_allowed_html_tags = array( 'strong', 'b', 'em', 'i', 'u', 's', 'small', 'mark', 'del', 'ins', 'sup', 'sub' ); - wp_send_json( array( - 'error' => wp_kses( $error, array_fill_keys( $amp_mustache_allowed_html_tags, array() ) ), - ) ); - }; - } ); - - // Send AMP header. - $origin = esc_url_raw( wp_unslash( $_GET['__amp_source_origin'] ) ); // WPCS: CSRF ok. - header( 'AMP-Access-Control-Allow-Source-Origin: ' . $origin, true ); -} - -/** - * Intercept the response to a non-comment POST request. - * - * @since 0.7.0 - * @param string $location The location to redirect to. - */ -function amp_intercept_post_request_redirect( $location ) { - - // Make sure relative redirects get made absolute. - $parsed_location = array_merge( - array( - 'scheme' => 'https', - 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), - 'path' => strtok( wp_unslash( $_SERVER['REQUEST_URI'] ), '?' ), - ), - wp_parse_url( $location ) - ); - - $absolute_location = $parsed_location['scheme'] . '://' . $parsed_location['host']; - if ( isset( $parsed_location['port'] ) ) { - $absolute_location .= ':' . $parsed_location['port']; - } - $absolute_location .= $parsed_location['path']; - if ( isset( $parsed_location['query'] ) ) { - $absolute_location .= '?' . $parsed_location['query']; - } - if ( isset( $parsed_location['fragment'] ) ) { - $absolute_location .= '#' . $parsed_location['fragment']; - } - - header( 'AMP-Redirect-To: ' . $absolute_location ); - header( 'Access-Control-Expose-Headers: AMP-Redirect-To' ); - // Send json success as no data is required. - wp_send_json_success(); -} diff --git a/includes/amp-post-template-actions.php b/includes/amp-post-template-actions.php index c33835a65dc..b956faaf6c6 100644 --- a/includes/amp-post-template-actions.php +++ b/includes/amp-post-template-actions.php @@ -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( - '', // 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( - '', // 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 ) + ) ); } /** diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index 7bf991c8d54..3ec923b7bde 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -42,6 +42,7 @@ class AMP_Autoloader { 'AMP_Issuu_Embed_Handler' => 'includes/embeds/class-amp-issuu-embed-handler', 'AMP_Meetup_Embed_Handler' => 'includes/embeds/class-amp-meetup-embed-handler', 'AMP_Pinterest_Embed_Handler' => 'includes/embeds/class-amp-pinterest-embed', + 'AMP_Playlist_Embed_Handler' => 'includes/embeds/class-amp-playlist-embed-handler', 'AMP_Reddit_Embed_Handler' => 'includes/embeds/class-amp-reddit-embed-handler', 'AMP_SoundCloud_Embed_Handler' => 'includes/embeds/class-amp-soundcloud-embed', 'AMP_Tumblr_Embed_Handler' => 'includes/embeds/class-amp-tumblr-embed-handler', diff --git a/includes/class-amp-post-type-support.php b/includes/class-amp-post-type-support.php index dfe1e0496d4..22a1bef580b 100644 --- a/includes/class-amp-post-type-support.php +++ b/includes/class-amp-post-type-support.php @@ -54,7 +54,7 @@ public static function add_post_type_support() { AMP_Options_Manager::get_option( 'supported_post_types', array() ) ); foreach ( $post_types as $post_type ) { - add_post_type_support( $post_type, AMP_QUERY_VAR ); + add_post_type_support( $post_type, amp_get_slug() ); } } @@ -73,7 +73,7 @@ public static function get_support_errors( $post ) { $errors = array(); // Because `add_rewrite_endpoint` doesn't let us target specific post_types. - if ( isset( $post->post_type ) && ! post_type_supports( $post->post_type, AMP_QUERY_VAR ) ) { + if ( isset( $post->post_type ) && ! post_type_supports( $post->post_type, amp_get_slug() ) ) { $errors[] = 'post-type-support'; } diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index acda180917f..bef0df4d80a 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -67,10 +67,34 @@ class AMP_Theme_Support { */ public static $purged_amp_query_vars = array(); + /** + * Headers sent (or attempted to be sent). + * + * @since 0.7 + * @see AMP_Theme_Support::send_header() + * @var array[] + */ + public static $headers_sent = array(); + + /** + * Output buffering level when starting. + * + * @since 0.7 + * @var int + */ + protected static $initial_ob_level = 0; + /** * Initialize. */ public static function init() { + if ( ! current_theme_supports( 'amp' ) ) { + return; + } + + self::purge_amp_query_vars(); + self::handle_xhr_request(); + require_once AMP__DIR__ . '/includes/amp-post-template-actions.php'; // Validate theme support usage. @@ -79,26 +103,66 @@ public static function init() { $args = array_shift( $support ); if ( ! is_array( $args ) ) { trigger_error( esc_html__( 'Expected AMP theme support arg to be array.', 'amp' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - } elseif ( count( array_diff( array_keys( $args ), array( 'template_dir', 'available_callback' ) ) ) !== 0 ) { + } elseif ( count( array_diff( array_keys( $args ), array( 'template_dir', 'available_callback', 'comments_live_list' ) ) ) !== 0 ) { trigger_error( esc_html__( 'Expected AMP theme support to only have template_dir and/or available_callback.', 'amp' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error } } - if ( amp_is_canonical() ) { + add_action( 'widgets_init', array( __CLASS__, 'register_widgets' ) ); + + /* + * Note that wp action is use instead of template_redirect because some themes/plugins output + * the response at this action and then short-circuit with exit. So this is why the the preceding + * action to template_redirect--the wp action--is used instead. + */ + add_action( 'wp', array( __CLASS__, 'finish_init' ), PHP_INT_MAX ); + } - // Redirect to canonical URL if the AMP URL was loaded, since canonical is now AMP. - if ( false !== get_query_var( AMP_QUERY_VAR, false ) ) { // Because is_amp_endpoint() now returns true if amp_is_canonical(). - wp_safe_redirect( self::get_current_canonical_url(), 302 ); // Temporary redirect because canonical may change in future. - exit; + /** + * Finish initialization once query vars are set. + * + * @since 0.7 + */ + public static function finish_init() { + if ( ! is_amp_endpoint() ) { + // Add amphtml link when paired mode is available. + if ( self::is_paired_available() ) { + amp_add_frontend_actions(); // @todo This function is poor in how it requires a file that then does add_action(). + if ( ! has_action( 'wp_head', 'amp_frontend_add_canonical' ) ) { + add_action( 'wp_head', 'amp_frontend_add_canonical' ); + } } + return; + } + + if ( amp_is_canonical() ) { + self::redirect_canonical_amp(); } else { self::register_paired_hooks(); } - self::purge_amp_query_vars(); // Note that amp_prepare_xhr_post() still looks at $_GET['__amp_source_origin']. - self::register_hooks(); - self::$embed_handlers = self::register_content_embed_handlers(); + self::add_hooks(); self::$sanitizer_classes = amp_get_content_sanitizers(); + self::$embed_handlers = self::register_content_embed_handlers(); + } + + /** + * Redirect to canonical URL if the AMP URL was loaded, since canonical is now AMP. + * + * @since 0.7 + */ + public static function redirect_canonical_amp() { + if ( false !== get_query_var( amp_get_slug(), false ) ) { // Because is_amp_endpoint() now returns true if amp_is_canonical(). + $url = preg_replace( '#^(https?://.+?)(/.*)$#', '$1', home_url( '/' ) ); + if ( isset( $_SERVER['REQUEST_URI'] ) ) { + $url .= wp_unslash( $_SERVER['REQUEST_URI'] ); + } + + $url = amp_remove_endpoint( $url ); + + wp_safe_redirect( $url, 302 ); // Temporary redirect because canonical may change in future. + exit; + } } /** @@ -128,6 +192,18 @@ public static function is_paired_available() { return true; } + /** + * Determine whether the user is in the Customizer preview iframe. + * + * @since 0.7 + * + * @return bool Whether in Customizer preview iframe. + */ + public static function is_customize_preview_iframe() { + global $wp_customize; + return is_customize_preview() && $wp_customize->get_messenger_channel(); + } + /** * Register hooks for paired mode. */ @@ -141,14 +217,21 @@ public static function register_paired_hooks() { /** * Register hooks. */ - public static function register_hooks() { + public static function add_hooks() { // Remove core actions which are invalid AMP. remove_action( 'wp_head', 'wp_post_preview_js', 1 ); remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); - remove_action( 'wp_head', 'wp_print_head_scripts', 9 ); - remove_action( 'wp_footer', 'wp_print_footer_scripts', 20 ); remove_action( 'wp_print_styles', 'print_emoji_styles' ); + remove_action( 'wp_head', 'wp_oembed_add_host_js' ); + + // Prevent MediaElement.js scripts/styles from being enqueued. + add_filter( 'wp_video_shortcode_library', function() { + return 'amp'; + } ); + add_filter( 'wp_audio_shortcode_library', function() { + return 'amp'; + } ); /* * Add additional markup required by AMP . @@ -159,10 +242,12 @@ public static function register_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_head', array( __CLASS__, 'print_amp_styles' ) ); + add_action( 'wp_print_styles', array( __CLASS__, 'print_amp_styles' ), 0 ); // Print boilerplate before theme and plugin stylesheets. add_action( 'wp_head', 'amp_add_generator_metadata', 20 ); - add_action( 'wp_head', 'amp_print_schemaorg_metadata' ); + + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_customize_preview_scripts' ), 1000 ); + add_filter( 'customize_partial_render', array( __CLASS__, 'filter_customize_partial_render' ) ); add_action( 'wp_footer', 'amp_print_analytics' ); @@ -178,11 +263,18 @@ public static function register_hooks() { */ add_action( 'template_redirect', array( __CLASS__, 'start_output_buffering' ), 0 ); - add_filter( 'wp_list_comments_args', array( __CLASS__, 'amp_set_comments_walker' ), PHP_INT_MAX ); + // Commenting hooks. + add_filter( 'wp_list_comments_args', array( __CLASS__, 'set_comments_walker' ), PHP_INT_MAX ); add_filter( 'comment_form_defaults', array( __CLASS__, 'filter_comment_form_defaults' ) ); add_filter( 'comment_reply_link', array( __CLASS__, 'filter_comment_reply_link' ), 10, 4 ); add_filter( 'cancel_comment_reply_link', array( __CLASS__, 'filter_cancel_comment_reply_link' ), 10, 3 ); - add_action( 'comment_form', array( __CLASS__, 'add_amp_comment_form_templates' ), 100 ); + add_action( 'comment_form', array( __CLASS__, 'amend_comment_form' ), 100 ); + remove_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ); + add_filter( 'wp_kses_allowed_html', array( __CLASS__, 'whitelist_layout_in_wp_kses_allowed_html' ), 10 ); + + if ( AMP_Validation_Utils::should_validate_response() ) { + AMP_Validation_Utils::add_validation_hooks(); + } // @todo Add character conversion. } @@ -204,6 +296,7 @@ public static function purge_amp_query_vars() { '__amp_source_origin', '_wp_amp_action_xhr_converted', 'amp_latest_update_time', + 'amp_last_check_time', ); // Scrub input vars. @@ -247,36 +340,217 @@ public static function purge_amp_query_vars() { } /** - * Set up commenting. + * Send an HTTP response header. + * + * This largely exists to facilitate unit testing but it also provides a better interface for sending headers. + * + * @since 0.7.0 + * + * @param string $name Header name. + * @param string $value Header value. + * @param array $args { + * Args to header(). + * + * @type bool $replace Whether to replace a header previously sent. Default true. + * @type int $status_code Status code to send with the sent header. + * } + * @return bool Whether the header was sent. */ - public static function setup_commenting() { - if ( ! current_theme_supports( AMP_QUERY_VAR ) ) { + public static function send_header( $name, $value, $args = array() ) { + $args = array_merge( + array( + 'replace' => true, + 'status_code' => null, + ), + $args + ); + + self::$headers_sent[] = array_merge( compact( 'name', 'value' ), $args ); + if ( headers_sent() ) { + return false; + } + + header( + sprintf( '%s: %s', $name, $value ), + $args['replace'], + $args['status_code'] + ); + return true; + } + + /** + * Hook into a POST form submissions, such as the comment form or some other form submission. + * + * @since 0.7.0 + */ + public static function handle_xhr_request() { + $is_amp_xhr = ( + ! empty( self::$purged_amp_query_vars['_wp_amp_action_xhr_converted'] ) + && + ! empty( self::$purged_amp_query_vars['__amp_source_origin'] ) + && + ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] ) + ); + if ( ! $is_amp_xhr ) { return; } - /* - * Temporarily force comments to be listed in descending order. + // Send AMP response header. + $origin = wp_validate_redirect( wp_sanitize_redirect( esc_url_raw( self::$purged_amp_query_vars['__amp_source_origin'] ) ) ); + if ( $origin ) { + self::send_header( 'AMP-Access-Control-Allow-Source-Origin', $origin, array( 'replace' => true ) ); + } + + // Intercept POST requests which redirect. + add_filter( 'wp_redirect', array( __CLASS__, 'intercept_post_request_redirect' ), PHP_INT_MAX ); + + // Add special handling for redirecting after comment submission. + add_filter( 'comment_post_redirect', array( __CLASS__, 'filter_comment_post_redirect' ), PHP_INT_MAX, 2 ); + + // Add die handler for AMP error display, most likely due to problem with comment. + add_filter( 'wp_die_handler', function() { + return array( __CLASS__, 'handle_wp_die' ); + } ); + + } + + /** + * Strip tags that are not allowed in amp-mustache. + * + * @since 0.7.0 + * + * @param string $text Text to sanitize. + * @return string Sanitized text. + */ + protected static function wp_kses_amp_mustache( $text ) { + $amp_mustache_allowed_html_tags = array( 'strong', 'b', 'em', 'i', 'u', 's', 'small', 'mark', 'del', 'ins', 'sup', 'sub' ); + return wp_kses( $text, array_fill_keys( $amp_mustache_allowed_html_tags, array() ) ); + } + + /** + * Handle comment_post_redirect to ensure page reload is done when comments_live_list is not supported, while sending back a success message when it is. + * + * @since 0.7.0 + * + * @param string $url Comment permalink to redirect to. + * @param WP_Comment $comment Posted comment. + * @return string URL. + */ + public static function filter_comment_post_redirect( $url, $comment ) { + $theme_support = get_theme_support( 'amp' ); + + // Cause a page refresh if amp-live-list is not implemented for comments via add_theme_support( 'amp', array( 'comments_live_list' => true ) ). + if ( empty( $theme_support[0]['comments_live_list'] ) ) { + /* + * Add the comment ID to the URL to force AMP to refresh the page. + * This is ideally a temporary workaround to deal with https://github.com/ampproject/amphtml/issues/14170 + */ + $url = add_query_arg( 'comment', $comment->comment_ID, $url ); + + // Pass URL along to wp_redirect(). + return $url; + } + + // Create a success message to display to the user. + if ( '1' === (string) $comment->comment_approved ) { + $message = __( 'Your comment has been posted.', 'amp' ); + } else { + $message = __( 'Your comment is awaiting moderation.', 'default' ); // Note core string re-use. + } + + /** + * Filters the message when comment submitted success message when * - * The following hooks are temporary while waiting for amphtml#5396 to be resolved. + * @since 0.7 */ - add_filter( 'option_comment_order', function() { - return 'desc'; - }, PHP_INT_MAX ); - - add_action( 'admin_print_footer_scripts-options-discussion.php', function() { - ?> -

support ascending comments with newer entries appearing at the bottom.', 'amp' ) ); ?>

- - self::wp_kses_amp_mustache( $message ), + ) ); + } + + /** + * New error handler for AMP form submission. + * + * @since 0.7.0 + * @see wp_die() + * + * @param WP_Error|string $error The error to handle. + * @param string|int $title Optional. Error title. If `$message` is a `WP_Error` object, + * error data with the key 'title' may be used to specify the title. + * If `$title` is an integer, then it is treated as the response + * code. Default empty. + * @param string|array|int $args { + * Optional. Arguments to control behavior. If `$args` is an integer, then it is treated + * as the response code. Default empty array. + * + * @type int $response The HTTP response code. Default 200 for Ajax requests, 500 otherwise. + * } + */ + public static function handle_wp_die( $error, $title = '', $args = array() ) { + if ( is_int( $title ) ) { + $status_code = $title; + } elseif ( is_int( $args ) ) { + $status_code = $args; + } elseif ( is_array( $args ) && isset( $args['response'] ) ) { + $status_code = $args['response']; + } else { + $status_code = 500; + } + status_header( $status_code ); + + if ( is_wp_error( $error ) ) { + $error = $error->get_error_message(); + } + + // Message will be shown in template defined by AMP_Theme_Support::amend_comment_form(). + wp_send_json( array( + 'error' => self::wp_kses_amp_mustache( $error ), + ) ); + } + + /** + * Intercept the response to a POST request. + * + * @since 0.7.0 + * @see wp_redirect() + * + * @param string $location The location to redirect to. + */ + public static function intercept_post_request_redirect( $location ) { + + // Make sure relative redirects get made absolute. + $parsed_location = array_merge( + array( + 'scheme' => 'https', + 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), + 'path' => isset( $_SERVER['REQUEST_URI'] ) ? strtok( wp_unslash( $_SERVER['REQUEST_URI'] ), '?' ) : '/', + ), + wp_parse_url( $location ) + ); + + $absolute_location = ''; + if ( 'https' === $parsed_location['scheme'] ) { + $absolute_location .= $parsed_location['scheme'] . ':'; + } + $absolute_location .= '//' . $parsed_location['host']; + if ( isset( $parsed_location['port'] ) ) { + $absolute_location .= ':' . $parsed_location['port']; + } + $absolute_location .= $parsed_location['path']; + if ( isset( $parsed_location['query'] ) ) { + $absolute_location .= '?' . $parsed_location['query']; + } + if ( isset( $parsed_location['fragment'] ) ) { + $absolute_location .= '#' . $parsed_location['fragment']; + } + + self::send_header( 'AMP-Redirect-To', $absolute_location ); + self::send_header( 'Access-Control-Expose-Headers', 'AMP-Redirect-To' ); + + wp_send_json_success(); } /** @@ -349,23 +623,24 @@ public static function register_content_embed_handlers() { * @param array $args the args for the comments list.. * @return array Args to return. */ - public static function amp_set_comments_walker( $args ) { + public static function set_comments_walker( $args ) { $amp_walker = new AMP_Comment_Walker(); $args['walker'] = $amp_walker; - // Add reverse order here as well, in case theme overrides it. - $args['reverse_top_level'] = true; - return $args; } /** * Adds the form submit success and fail templates. */ - public static function add_amp_comment_form_templates() { + public static function amend_comment_form() { ?> + + + +
@@ -414,16 +689,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. * @@ -465,13 +730,7 @@ public static function get_current_canonical_url() { $url = add_query_arg( $added_query_vars, $url ); } - // Strip endpoint. - $url = preg_replace( ':/' . preg_quote( AMP_QUERY_VAR, ':' ) . '(?=/?(\?|#|$)):', '', $url ); - - // Strip query var. - $url = remove_query_arg( AMP_QUERY_VAR, $url ); - - return $url; + return amp_remove_endpoint( $url ); } /** @@ -602,62 +861,6 @@ public static function print_amp_styles() { echo "\n"; // This will by populated by AMP_Style_Sanitizer. } - /** - * 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 = ''; // 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( - '', // phpcs:ignore WordPress.WP.EnqueuedResources, WordPress.XSS.EscapeOutput.OutputNotEscaped - $custom_type, - $amp_script_component, - $amp_script_source - ); - } - - return $scripts; - } - /** * Ensure markup required by AMP . * @@ -666,10 +869,11 @@ 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 ) { + public static function ensure_required_markup( DOMDocument $dom ) { $head = $dom->getElementsByTagName( 'head' )->item( 0 ); if ( ! $head ) { $head = $dom->createElement( 'head' ); @@ -703,7 +907,19 @@ protected static function ensure_required_markup( DOMDocument $dom ) { ) ); $head->insertBefore( $meta_viewport, $meta_charset->nextSibling ); } - + // Prevent schema.org duplicates. + $has_schema_org_metadata = false; + foreach ( $head->getElementsByTagName( 'script' ) as $script ) { + if ( 'application/ld+json' === $script->getAttribute( 'type' ) && false !== strpos( $script->nodeValue, 'schema.org' ) ) { + $has_schema_org_metadata = true; + break; + } + } + if ( ! $has_schema_org_metadata ) { + $script = $dom->createElement( 'script', wp_json_encode( amp_get_schemaorg_metadata() ) ); + $script->setAttribute( 'type', 'application/ld+json' ); + $head->appendChild( $script ); + } // Ensure rel=canonical link. $rel_canonical = null; foreach ( $head->getElementsByTagName( 'link' ) as $link ) { @@ -721,6 +937,27 @@ protected static function ensure_required_markup( DOMDocument $dom ) { } } + /** + * Dequeue Customizer assets which are not necessary outside the preview iframe. + * + * Prevent enqueueing customize-preview styles if not in customizer preview iframe. + * These are only needed for when there is live editing of content, such as selective refresh. + * + * @since 0.7 + */ + public static function dequeue_customize_preview_scripts() { + + // Dequeue styles unnecessary unless in customizer preview iframe when editing (such as for edit shortcuts). + if ( ! self::is_customize_preview_iframe() ) { + wp_dequeue_style( 'customize-preview' ); + foreach ( wp_styles()->registered as $handle => $dependency ) { + if ( in_array( 'customize-preview', $dependency->deps, true ) ) { + wp_dequeue_style( $handle ); + } + } + } + } + /** * Start output buffering. * @@ -735,11 +972,12 @@ public static function start_output_buffering() { * Sites with New Relic will need to specially configure New Relic for AMP: * https://docs.newrelic.com/docs/browser/new-relic-browser/installation/monitor-amp-pages-new-relic-browser */ - if ( extension_loaded( 'newrelic' ) ) { + if ( function_exists( 'newrelic_disable_autorum' ) ) { newrelic_disable_autorum(); } ob_start(); + self::$initial_ob_level = ob_get_level(); // Note that the following must be at 0 because wp_ob_end_flush_all() runs at shutdown:1. add_action( 'shutdown', array( __CLASS__, 'finish_output_buffering' ), 0 ); @@ -752,9 +990,40 @@ public static function start_output_buffering() { * @see AMP_Theme_Support::start_output_buffering() */ public static function finish_output_buffering() { + + // Flush output buffer stack until we get to the output buffer we started. + while ( ob_get_level() > self::$initial_ob_level ) { + ob_end_flush(); + } + echo self::prepare_response( ob_get_clean() ); // WPCS: xss ok. } + /** + * Filter rendered partial to convert to AMP. + * + * @see WP_Customize_Partial::render() + * + * @param string|mixed $partial Rendered partial. + * @return string|mixed Filtered partial. + * @global int $content_width + */ + public static function filter_customize_partial_render( $partial ) { + global $content_width; + if ( is_string( $partial ) && preg_match( '/<\w/', $partial ) ) { + $dom = AMP_DOM_Utils::get_dom_from_content( $partial ); + $args = array( + 'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat. + 'use_document_element' => false, + 'allow_dirty_styles' => true, + 'allow_dirty_scripts' => false, + ); + AMP_Content_Sanitizer::sanitize_document( $dom, self::$sanitizer_classes, $args ); // @todo Include script assets in response? + $partial = AMP_DOM_Utils::get_content_from_dom( $dom ); + } + return $partial; + } + /** * Process response to ensure AMP validity. * @@ -781,11 +1050,15 @@ public static function prepare_response( $response, $args = array() ) { return $response; } + $is_validation_debug_mode = ! empty( $_REQUEST[ AMP_Validation_Utils::DEBUG_QUERY_VAR ] ); // WPCS: csrf ok. + $args = array_merge( array( 'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat. 'use_document_element' => true, - 'remove_invalid_callback' => null, + 'allow_dirty_styles' => self::is_customize_preview_iframe(), // Dirty styles only needed when editing (e.g. for edit shortcodes). + 'allow_dirty_scripts' => is_customize_preview(), // Scripts are always needed to inject changeset UUID. + 'disable_invalid_removal' => $is_validation_debug_mode, ), $args ); @@ -805,7 +1078,18 @@ 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. + $xpath = new DOMXPath( $dom ); + + $head = $dom->getElementsByTagName( 'head' )->item( 0 ); + + if ( isset( $head ) ) { + // Make sure scripts from the body get moved to the head. + foreach ( $xpath->query( '//body//script[ @custom-element or @custom-template ]' ) as $script ) { + $head->appendChild( $script ); + } + } + + // 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', '' ); } @@ -820,17 +1104,82 @@ public static function prepare_response( $response, $args = array() ) { trigger_error( esc_html( sprintf( __( 'The database has the %s encoding when it needs to be utf-8 to work with AMP.', 'amp' ), get_bloginfo( 'charset' ) ) ), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error } + if ( AMP_Validation_Utils::should_validate_response() ) { + AMP_Validation_Utils::finalize_validation( $dom, array( + 'remove_source_comments' => ! $is_validation_debug_mode, + ) ); + } + $response = "\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 additional AMP component scripts which have been discovered by the sanitizers, and inject into the head. + * Before printing the AMP scripts, make sure that no plugins will be manipulating the output to be invalid AMP + * since at this point the sanitizers have completed and won't check what is output here. + */ + ob_start(); + $possible_hooks = array( 'wp_print_scripts', 'print_scripts_array', 'script_loader_src', 'clean_url', 'script_loader_tag', 'attribute_escape' ); + foreach ( $possible_hooks as $possible_hook ) { + remove_all_filters( $possible_hook ); // An action and a filter are the same thing. + } + add_filter( 'script_loader_tag', 'amp_filter_script_loader_tag', PHP_INT_MAX, 2 ); // Make sure this one required filter is present. + wp_scripts()->do_items( array_keys( $amp_scripts ) ); + $script_tags = ob_get_clean(); + if ( ! empty( $script_tags ) ) { + $response = preg_replace( + '#(?=)#', + $script_tags, + $response, + 1 + ); + } return $response; } + + /** + * Adds 'data-amp-layout' to the allowed attributes for wp_kses(). + * + * @since 0.7 + * + * @param array $context Allowed tags and their allowed attributes. + * @return array $context Filtered allowed tags and attributes. + */ + public static function whitelist_layout_in_wp_kses_allowed_html( $context ) { + if ( ! empty( $context['img']['width'] ) && ! empty( $context['img']['height'] ) ) { + $context['img']['data-amp-layout'] = true; + } + + return $context; + } + + /** + * Enqueue AMP assets if this is an AMP endpoint. + * + * @since 0.7 + * + * @return void + */ + public static function enqueue_assets() { + 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 ); + } } diff --git a/includes/embeds/class-amp-playlist-embed-handler.php b/includes/embeds/class-amp-playlist-embed-handler.php new file mode 100644 index 00000000000..32eea3d9322 --- /dev/null +++ b/includes/embeds/class-amp-playlist-embed-handler.php @@ -0,0 +1,324 @@ +(.+?):s'; + + /** + * The ID of individual playlist. + * + * @var int + */ + public static $playlist_id = 0; + + /** + * The removed shortcode callback. + * + * @var callable + */ + public $removed_shortcode_callback; + + /** + * Registers the playlist shortcode. + * + * @global array $shortcode_tags + * @return void + */ + public function register_embed() { + global $shortcode_tags; + if ( shortcode_exists( self::SHORTCODE ) ) { + $this->removed_shortcode_callback = $shortcode_tags[ self::SHORTCODE ]; + } + add_shortcode( self::SHORTCODE, array( $this, 'shortcode' ) ); + remove_action( 'wp_playlist_scripts', 'wp_playlist_scripts' ); + } + + /** + * Unregisters the playlist shortcode. + * + * @return void + */ + public function unregister_embed() { + if ( $this->removed_shortcode_callback ) { + add_shortcode( self::SHORTCODE, $this->removed_shortcode_callback ); + $this->removed_shortcode_callback = null; + } + add_action( 'wp_playlist_scripts', 'wp_playlist_scripts' ); + } + + /** + * Enqueues the playlist styling. + * + * @return void + */ + public function enqueue_styles() { + wp_enqueue_style( + 'amp-playlist-shortcode', + amp_get_asset_url( 'css/amp-playlist-shortcode.css' ), + array( 'wp-mediaelement' ), + AMP__VERSION + ); + } + + /** + * Gets AMP-compliant markup for the playlist shortcode. + * + * Uses the JSON that wp_playlist_shortcode() produces. + * Gets the markup, based on the type of playlist. + * + * @param array $attr The playlist attributes. + * @return string Playlist shortcode markup. + */ + public function shortcode( $attr ) { + $data = $this->get_data( $attr ); + if ( isset( $data['type'] ) && ( 'audio' === $data['type'] ) ) { + return $this->audio_playlist( $data ); + } elseif ( isset( $data['type'] ) && ( 'video' === $data['type'] ) ) { + return $this->video_playlist( $data ); + } + } + + /** + * Gets an AMP-compliant audio playlist. + * + * @param array $data Data. + * @return string Playlist shortcode markup, or an empty string. + */ + public function audio_playlist( $data ) { + if ( ! isset( $data['tracks'] ) ) { + return ''; + } + self::$playlist_id++; + $container_id = 'wpPlaylist' . self::$playlist_id . 'Carousel'; + $state_id = 'wpPlaylist' . self::$playlist_id; + $amp_state = array( + 'selectedIndex' => 0, + ); + + $this->enqueue_styles(); + ob_start(); + ?> +
+ + + + + get_title( $track ); + $image_url = isset( $track['thumb']['src'] ) ? $track['thumb']['src'] : ''; + $dimensions = $this->get_thumb_dimensions( $track ); + ?> +
+
+ + + +
+ +
+
+ +
+ +
+ print_tracks( $state_id, $data['tracks'] ); ?> +
+ 0, + ); + foreach ( $data['tracks'] as $index => $track ) { + $amp_state[ $index ] = array( + 'videoUrl' => $track['src'], + 'thumb' => isset( $track['thumb']['src'] ) ? $track['thumb']['src'] : '', + ); + } + + $dimensions = isset( $data['tracks'][0]['dimensions']['resized'] ) ? $data['tracks'][0]['dimensions']['resized'] : null; + $width = isset( $dimensions['width'] ) ? $dimensions['width'] : $content_width; + $height = isset( $dimensions['height'] ) ? $dimensions['height'] : null; + $src_bound = sprintf( '%s[%s.selectedIndex].videoUrl', $state_id, $state_id ); + + $this->enqueue_styles(); + ob_start(); + ?> +
+ + + + + print_tracks( $state_id, $data['tracks'] ); ?> +
+ self::THUMB_MAX_WIDTH ) { + $ratio = $original_width / self::THUMB_MAX_WIDTH; + $height = intval( $original_height / $ratio ); + } else { + $height = $original_height; + } + $width = min( self::THUMB_MAX_WIDTH, $original_width ); + return compact( 'height', 'width' ); + } + + /** + * Outputs the playlist tracks, based on the type of playlist. + * + * These typically appear below the player. + * Clicking a track triggers the player to appear with its src. + * + * @param string $state_id The ID of the container. + * @param array $tracks Tracks. + * @return void + */ + public function print_tracks( $state_id, $tracks ) { + ?> +
+ $track ) : ?> + array( 'selectedIndex' => $index ) ) ) . ')'; + $initial_class = 0 === $index ? 'wp-playlist-item wp-playlist-playing' : 'wp-playlist-item'; + $bound_class = sprintf( '%d == %s.selectedIndex ? "wp-playlist-item wp-playlist-playing" : "wp-playlist-item"', $index, $state_id ); + ?> + + +
+ name, AMP_QUERY_VAR ); + $post_type_supported = post_type_supports( $post_type->name, amp_get_slug() ); $is_support_elected = in_array( $post_type->name, $supported_types, true ); $error = null; diff --git a/includes/options/class-amp-options-menu.php b/includes/options/class-amp-options-menu.php index a06d5f4169a..3bc556a551f 100644 --- a/includes/options/class-amp-options-menu.php +++ b/includes/options/class-amp-options-menu.php @@ -22,7 +22,7 @@ class AMP_Options_Menu { */ public function init() { add_action( 'admin_post_amp_analytics_options', 'AMP_Options_Manager::handle_analytics_submit' ); - add_action( 'admin_menu', array( $this, 'add_menu_items' ) ); + add_action( 'admin_menu', array( $this, 'add_menu_items' ), 9 ); } /** @@ -94,7 +94,7 @@ public function render_post_types_support() { id="" name="" value="name ); ?>" - name, AMP_QUERY_VAR ) ); ?> + name, amp_get_slug() ) ); ?> >
'; + $args = array_merge( + $base_args, + array( + 'style' => 'div', + ) + ); + $comment = $this->factory()->comment->create_and_get(); + $this->walker->start_el( $output, $comment, 0, $args ); + $this->assertContains( '
factory()->post->create(); // WPCS: global override OK. + $comments = $this->get_comments(); + $args = array( + 'format' => 'div', + 'style' => 'baz', + 'avatar_size' => 100, + 'max_depth' => 5, + ); + $output = $this->walker->paged_walk( $comments, 5, 1, 5, $args ); + + foreach ( $comments as $comment ) { + $this->assertContains( $comment->comment_author, $output ); + $this->assertContains( $comment->comment_content, $output ); + } + } + + /** + * Test AMP_Comment_Walker::build_thread_latest_date. + * + * @covers AMP_Comment_Walker::build_thread_latest_date() + */ + public function test_build_thread_latest_date() { + $comments = $this->get_comments(); + $reflection = new ReflectionObject( $this->walker ); + $tested_method = $reflection->getMethod( 'build_thread_latest_date' ); + $tested_method->setAccessible( true ); + $latest_time = $tested_method->invoke( $this->walker, $comments ); + $comment_thread_age = $reflection->getProperty( 'comment_thread_age' ); + $comment_thread_age->setAccessible( true ); + $comment_thread_value = $comment_thread_age->getValue( $this->walker ); + + foreach ( $comments as $comment ) { + $this->assertEquals( strtotime( $comment->comment_date ), $comment_thread_value[ $comment->comment_ID ] ); + } + + $last_comment = end( $comments ); + $this->assertEquals( strtotime( $last_comment->comment_date ), $latest_time ); + } + + /** + * Gets comments for tests. + * + * @return array $comments An array of WP_Comment instances. + */ + public function get_comments() { + $comments = array(); + for ( $i = 0; $i < 5; $i++ ) { + $comments[] = $this->factory()->comment->create_and_get( array( + 'comment_date' => gmdate( 'Y-m-d H:i:s', ( time() + $i ) ), // Ensure each comment has a different date. + ) ); + } + return $comments; + } + +} diff --git a/tests/test-class-amp-comments-sanitizer.php b/tests/test-class-amp-comments-sanitizer.php new file mode 100644 index 00000000000..d5cfda24882 --- /dev/null +++ b/tests/test-class-amp-comments-sanitizer.php @@ -0,0 +1,131 @@ +factory()->post->create_and_get(); // WPCS: global override ok. + $this->dom = new DOMDocument(); + $this->instance = new AMP_Comments_Sanitizer( $this->dom ); + } + + /** + * Test AMP_Comments_Sanitizer::sanitize. + * + * @covers AMP_Comments_Sanitizer::sanitize() + */ + public function test_sanitize() { + $form = $this->create_form( 'incorrect-action.php' ); + $this->instance->sanitize(); + $on = $form->getAttribute( 'on' ); + $this->assertNotContains( 'submit:AMP.setState(', $on ); + $this->assertNotContains( 'submit-error:AMP.setState(', $on ); + foreach ( $this->get_element_names() as $name ) { + $this->assertNotContains( $name, $on ); + } + + // Use an allowed action. + $form = $this->create_form( '/wp-comments-post.php' ); + $this->instance->sanitize(); + $on = $form->getAttribute( 'on' ); + $this->assertContains( 'submit:AMP.setState(', $on ); + $this->assertContains( 'submit-error:AMP.setState(', $on ); + foreach ( $this->get_element_names() as $name ) { + $this->assertContains( $name, $on ); + } + } + + /** + * Test AMP_Comments_Sanitizer::process_comment_form. + * + * @covers AMP_Comments_Sanitizer::process_comment_form() + */ + public function test_process_comment_form() { + $form = $this->create_form( '/wp-comments-post.php' ); + $reflection = new ReflectionObject( $this->instance ); + $tested_method = $reflection->getMethod( 'process_comment_form' ); + $tested_method->setAccessible( true ); + $tested_method->invoke( $this->instance, $form ); + $on = $form->getAttribute( 'on' ); + $amp_state = $this->dom->getElementsByTagName( 'amp-state' )->item( 0 ); + + $this->assertContains( 'submit:AMP.setState(', $on ); + $this->assertContains( 'submit-error:AMP.setState(', $on ); + $this->assertContains( 'submit-success:AMP.setState(', $on ); + $this->assertContains( strval( $GLOBALS['post']->ID ), $on ); + $this->assertEquals( 'script', $amp_state->firstChild->nodeName ); + + foreach ( $this->get_element_names() as $name ) { + $this->assertContains( $name, $on ); + $this->assertContains( $name, $amp_state->nodeValue ); + } + foreach ( $form->getElementsByTagName( 'input' ) as $input ) { + $on = $input->getAttribute( 'on' ); + $this->assertContains( 'change:AMP.setState(', $on ); + $this->assertContains( strval( $GLOBALS['post']->ID ), $on ); + } + } + + /** + * Creates a form for testing. + * + * @param string $action_value Value of the 'action' attribute. + * @return DomElement $form A form element. + */ + public function create_form( $action_value ) { + $form = $this->dom->createElement( 'form' ); + $this->dom->appendChild( $form ); + $form->setAttribute( 'action', $action_value ); + + foreach ( $this->get_element_names() as $name ) { + $element = $this->dom->createElement( 'input' ); + $element->setAttribute( 'name', $name ); + $element->setAttribute( 'value', $GLOBALS['post']->ID ); + $form->appendChild( $element ); + } + return $form; + } + + /** + * Gets the element names to add to the
. + * + * @return array An array of strings to add to the . + */ + public function get_element_names() { + return array( + 'comment_post_ID', + 'foo', + 'bar', + ); + } + +} diff --git a/tests/test-class-amp-dom-utils.php b/tests/test-class-amp-dom-utils.php index 67b0b1aa96c..7fe8106fd07 100644 --- a/tests/test-class-amp-dom-utils.php +++ b/tests/test-class-amp-dom-utils.php @@ -145,4 +145,75 @@ public function test_html5_empty_elements() { $this->assertEquals( 'div', $video->childNodes->item( 4 )->nodeName ); $this->assertEquals( 'span', $video->childNodes->item( 5 )->nodeName ); } + + /** + * Test encoding. + * + * @covers \AMP_DOM_Utils::get_dom() + */ + public function test_get_dom_encoding() { + $html = ''; + $html .= '

Check out ‘this’ and “that” and—other things.

'; + $html .= '

Check out ‘this’ and “that” and—other things.

'; + $html .= '

Check out ‘this’ and “that” and—other things.

'; + $html .= ''; + + $document = AMP_DOM_Utils::get_dom_from_content( $html ); + $this->assertEquals( 'UTF-8', $document->encoding ); + $paragraphs = $document->getElementsByTagName( 'p' ); + $this->assertSame( 3, $paragraphs->length ); + $this->assertSame( $paragraphs->item( 0 )->textContent, $paragraphs->item( 1 )->textContent ); + $this->assertSame( $paragraphs->item( 1 )->textContent, $paragraphs->item( 2 )->textContent ); + } + + /** + * Get Table Row Iterations + * + * @return array An array of arrays holding an integer representation of iterations. + */ + public function get_table_row_iterations() { + return array( + array( 1 ), + array( 10 ), + array( 100 ), + array( 1000 ), + ); + } + + /** + * Tests attribute conversions on content with iframe srcdocs of variable lengths. + * + * @dataProvider get_table_row_iterations + * + * @param int $iterations The number of table rows to append to iframe srcdoc. + */ + public function test_attribute_conversion_on_long_iframe_srcdocs( $iterations ) { + $html = ''; + + for ( $i = 0; $i < $iterations; $i++ ) { + $html .= ' + + + + + + + + + + + '; + } + + $html .= '
14531947Pittsburgh Ironmen1242119211111182
'; + + $to_convert = sprintf( + ' ', + htmlentities( $html ) + ); + + AMP_DOM_Utils::convert_amp_bind_attributes( $to_convert ); + + $this->assertSame( PREG_NO_ERROR, preg_last_error(), 'Probably failed when backtrack limit was exhausted.' ); + } } diff --git a/tests/test-class-amp-meta-box.php b/tests/test-class-amp-meta-box.php index c301a8aaca8..aefba4db352 100644 --- a/tests/test-class-amp-meta-box.php +++ b/tests/test-class-amp-meta-box.php @@ -67,6 +67,7 @@ public function test_enqueue_admin_assets() { // Test inline script boot. $this->assertTrue( false !== stripos( wp_json_encode( $script_data ), 'ampPostMetaBox.boot(' ) ); unset( $GLOBALS['post'] ); + unset( $GLOBALS['current_screen'] ); } /** @@ -93,13 +94,13 @@ public function test_render_status() { $this->instance->render_status( $post ); $this->assertContains( $amp_status_markup, ob_get_clean() ); - remove_post_type_support( 'post', AMP_QUERY_VAR ); + remove_post_type_support( 'post', amp_get_slug() ); ob_start(); $this->instance->render_status( $post ); $this->assertEmpty( ob_get_clean() ); - add_post_type_support( 'post', AMP_QUERY_VAR ); + add_post_type_support( 'post', amp_get_slug() ); wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber', ) ) ); @@ -160,7 +161,7 @@ public function test_preview_post_link() { $link = 'https://foo.bar'; $this->assertEquals( 'https://foo.bar', $this->instance->preview_post_link( $link ) ); $_POST['amp-preview'] = 'do-preview'; - $this->assertEquals( 'https://foo.bar?' . AMP_QUERY_VAR . '=1', $this->instance->preview_post_link( $link ) ); + $this->assertEquals( 'https://foo.bar?' . amp_get_slug() . '=1', $this->instance->preview_post_link( $link ) ); } } diff --git a/tests/test-class-amp-options-manager.php b/tests/test-class-amp-options-manager.php index afe0ffc0863..d08584efdd3 100644 --- a/tests/test-class-amp-options-manager.php +++ b/tests/test-class-amp-options-manager.php @@ -171,7 +171,7 @@ public function test_check_supported_post_type_update_errors() { $this->assertEmpty( get_settings_errors() ); // Activation error. - remove_post_type_support( 'foo', AMP_QUERY_VAR ); + remove_post_type_support( 'foo', amp_get_slug() ); AMP_Options_Manager::check_supported_post_type_update_errors(); $errors = get_settings_errors(); $this->assertCount( 1, $errors ); @@ -181,7 +181,7 @@ public function test_check_supported_post_type_update_errors() { // Deactivation error. AMP_Options_Manager::update_option( 'supported_post_types', array() ); - add_post_type_support( 'foo', AMP_QUERY_VAR ); + add_post_type_support( 'foo', amp_get_slug() ); AMP_Options_Manager::check_supported_post_type_update_errors(); $errors = get_settings_errors(); $this->assertCount( 1, $errors ); diff --git a/tests/test-class-amp-options-menu.php b/tests/test-class-amp-options-menu.php index ec5109d2612..035706e3b85 100644 --- a/tests/test-class-amp-options-menu.php +++ b/tests/test-class-amp-options-menu.php @@ -43,7 +43,7 @@ public function test_constants() { */ public function test_init() { $this->instance->init(); - $this->assertEquals( 10, has_action( 'admin_menu', array( $this->instance, 'add_menu_items' ) ) ); + $this->assertEquals( 9, has_action( 'admin_menu', array( $this->instance, 'add_menu_items' ) ) ); $this->assertEquals( 10, has_action( 'admin_post_amp_analytics_options', 'AMP_Options_Manager::handle_analytics_submit' ) ); } diff --git a/tests/test-class-amp-playlist-embed-handler.php b/tests/test-class-amp-playlist-embed-handler.php new file mode 100644 index 00000000000..70088396756 --- /dev/null +++ b/tests/test-class-amp-playlist-embed-handler.php @@ -0,0 +1,344 @@ +instance = new AMP_Playlist_Embed_Handler(); + } + + /** + * Tear down test. + * + * @global WP_Styles $wp_styles + */ + public function tearDown() { + global $wp_styles; + $wp_styles = null; + + AMP_Playlist_Embed_Handler::$playlist_id = 0; + } + + /** + * Test register_embed. + * + * @covers AMP_Playlist_Embed_Handler::register_embed() + */ + public function test_register_embed() { + global $shortcode_tags; + $removed_shortcode = 'wp_playlist_shortcode'; + add_shortcode( 'playlist', $removed_shortcode ); + $this->instance->register_embed(); + $this->assertEquals( 'AMP_Playlist_Embed_Handler', get_class( $shortcode_tags[ AMP_Playlist_Embed_Handler::SHORTCODE ][0] ) ); + $this->assertEquals( 'shortcode', $shortcode_tags[ AMP_Playlist_Embed_Handler::SHORTCODE ][1] ); + $this->assertEquals( $removed_shortcode, $this->instance->removed_shortcode_callback ); + $this->instance->unregister_embed(); + } + + /** + * Test unregister_embed. + * + * @covers AMP_Playlist_Embed_Handler::unregister_embed() + */ + public function test_unregister_embed() { + global $shortcode_tags; + $expected_removed_shortcode = 'wp_playlist_shortcode'; + $this->instance->removed_shortcode_callback = $expected_removed_shortcode; + $this->instance->unregister_embed(); + $this->assertEquals( $expected_removed_shortcode, $shortcode_tags[ AMP_Playlist_Embed_Handler::SHORTCODE ] ); + } + + /** + * Test styling. + * + * @covers AMP_Playlist_Embed_Handler::enqueue_styles() + */ + public function test_styling() { + global $post; + $playlist_shortcode = 'amp-playlist-shortcode'; + $this->instance->register_embed(); + $this->assertFalse( in_array( 'wp-mediaelement', wp_styles()->queue, true ) ); + $this->assertFalse( in_array( $playlist_shortcode, wp_styles()->queue, true ) ); + + $post = $this->factory()->post->create_and_get(); // WPCS: global override OK. + $post->post_content = '[playlist ids="5,3"]'; + $this->instance->enqueue_styles(); + $style = wp_styles()->registered[ $playlist_shortcode ]; + $this->assertContains( $playlist_shortcode, wp_styles()->queue ); + $this->assertEquals( array( 'wp-mediaelement' ), $style->deps ); + $this->assertEquals( $playlist_shortcode, $style->handle ); + $this->assertEquals( amp_get_asset_url( 'css/amp-playlist-shortcode.css' ), $style->src ); + $this->assertEquals( AMP__VERSION, $style->ver ); + } + + /** + * Test shortcode. + * + * @covers AMP_Playlist_Embed_Handler::shortcode() + * @covers AMP_Playlist_Embed_Handler::video_playlist() + */ + public function test_shortcode() { + $attr = $this->get_attributes( 'video' ); + $playlist = $this->instance->shortcode( $attr ); + $this->assertContains( 'assertContains( 'assertContains( $this->file_1, $playlist ); + $this->assertContains( $this->file_2, $playlist ); + } + + /** + * Test video_playlist. + * + * @covers AMP_Playlist_Embed_Handler::video_playlist() + */ + public function test_video_playlist() { + $attr = $this->get_attributes( 'video' ); + $data = $this->instance->get_data( $attr ); + $playlist = $this->instance->video_playlist( $data ); + $this->assertContains( 'assertContains( 'assertContains( $this->file_1, $playlist ); + $this->assertContains( $this->file_2, $playlist ); + $this->assertContains( '[src]="wpPlaylist1[wpPlaylist1.selectedIndex].videoUrl"', $playlist ); + $this->assertContains( 'on="tap:AMP.setState({"wpPlaylist1":{"selectedIndex":0}})"', $playlist ); + } + + /** + * Test get_thumb_dimensions. + * + * @covers AMP_Playlist_Embed_Handler::get_thumb_dimensions() + */ + public function test_get_thumb_dimensions() { + $dimensions = array( + 'height' => 60, + 'width' => 60, + ); + $track = array( + 'thumb' => $dimensions, + ); + $this->assertEquals( $dimensions, $this->instance->get_thumb_dimensions( $track ) ); + + $dimensions = array( + 'height' => 68, + 'width' => 59, + ); + $track = array( + 'thumb' => $dimensions, + ); + $this->assertEquals( $dimensions, $this->instance->get_thumb_dimensions( $track ) ); + + $dimensions = array( + 'height' => 70, + 'width' => 80.5, + ); + $expected_dimensions = array( + 'height' => 52, + 'width' => 60, + ); + $track = array( + 'thumb' => $dimensions, + ); + $this->assertEquals( $expected_dimensions, $this->instance->get_thumb_dimensions( $track ) ); + + $dimensions = array( + 'width' => 80.5, + ); + $track = array( + 'thumb' => $dimensions, + ); + $expected_dimensions = array( + 'height' => 48, + 'width' => 60, + ); + $this->assertEquals( $expected_dimensions, $this->instance->get_thumb_dimensions( $track ) ); + + $track = array( + 'thumb' => array(), + ); + $expected_dimensions = array( + 'height' => AMP_Playlist_Embed_Handler::DEFAULT_THUMB_HEIGHT, + 'width' => AMP_Playlist_Embed_Handler::DEFAULT_THUMB_WIDTH, + ); + $this->assertEquals( $expected_dimensions, $this->instance->get_thumb_dimensions( $track ) ); + } + + /** + * Test audio_playlist. + * + * Logic for creating the videos copied from Tests_Media. + * + * @covers AMP_Playlist_Embed_Handler::audio_playlist() + */ + public function test_audio_playlist() { + $attr = $this->get_attributes( 'audio' ); + $playlist = $this->instance->audio_playlist( array() ); + $this->assertEquals( '', $playlist ); + + $data = $this->instance->get_data( $attr ); + $playlist = $this->instance->audio_playlist( $data ); + $this->assertContains( 'assertContains( 'assertContains( $this->file_1, $playlist ); + $this->assertContains( $this->file_2, $playlist ); + $this->assertContains( 'tap:AMP.setState({"wpPlaylist1":{"selectedIndex":0}})"', $playlist ); + } + + /** + * Test tracks. + * + * @covers AMP_Playlist_Embed_Handler::print_tracks() + */ + public function test_tracks() { + $type = 'video'; + $attr = $this->get_attributes( $type ); + $data = $this->instance->get_data( $attr ); + $container_id = 'fooContainerId1'; + $state_id = 'fooId1'; + $expected_on = 'tap:AMP.setState({"' . $state_id . '":{"selectedIndex":0}})'; + + ob_start(); + $this->instance->print_tracks( $state_id, $data['tracks'] ); + $tracks = ob_get_clean(); + $this->assertContains( '
', $tracks ); + $this->assertContains( $state_id, $tracks ); + $this->assertContains( $expected_on, $tracks ); + + $attr = $this->get_attributes( $type ); + $data = $this->instance->get_data( $attr ); + $expected_on = 'tap:AMP.setState({"' . $state_id . '":{"selectedIndex":0}})'; + + ob_start(); + $this->instance->print_tracks( $state_id, $data['tracks'] ); + $tracks = ob_get_clean(); + $this->assertContains( $expected_on, $tracks ); + } + + /** + * Test get_data. + * + * @covers AMP_Playlist_Embed_Handler::get_data() + */ + public function test_get_data() { + $type = 'audio'; + $data = $this->instance->get_data( $this->get_attributes( $type ) ); + $this->assertEquals( $type, $data['type'] ); + $this->assertContains( $this->file_1, $data['tracks'][0]['src'] ); + $this->assertContains( $this->file_2, $data['tracks'][1]['src'] ); + } + + /** + * Test get_title. + * + * @covers AMP_Playlist_Embed_Handler::get_data() + */ + public function test_get_title() { + $caption = 'Example caption'; + $title = 'Media Title'; + $track = array( + 'caption' => $caption, + ); + + $this->assertEquals( $caption, $this->instance->get_title( $track ) ); + + $track = array( + 'title' => $title, + ); + $this->assertEquals( $title, $this->instance->get_title( $track ) ); + + $track = array( + 'caption' => $caption, + 'title' => $title, + ); + $this->assertEquals( $caption, $this->instance->get_title( $track ) ); + $this->assertEquals( null, $this->instance->get_title( array() ) ); + } + + /** + * Gets the shortcode attributes. + * + * @param string $type The type of shortcode attributes: 'audio' or 'video'. + * @return array $attrs The shortcode attributes. + */ + public function get_attributes( $type ) { + if ( 'audio' === $type ) { + $this->file_1 = 'example-audio-1.mp3'; + $this->file_2 = 'example-audio-2.mp3'; + $mime_type = 'audio/mp3'; + } elseif ( 'video' === $type ) { + $this->file_1 = 'example-video-1.mp4'; + $this->file_2 = 'example-video-2.mkv'; + $mime_type = 'video/mp4'; + } else { + return; + } + + $files = array( + $this->file_1, + $this->file_2, + ); + $ids = $this->get_file_ids( $files, $mime_type ); + return array( + 'ids' => implode( ',', $ids ), + 'type' => $type, + ); + } + + /** + * Gets test file IDs. + * + * @param array $files The file names to create. + * @param string $mime_type The type of file. + * @return array $ids The IDs of the test files. + */ + public function get_file_ids( $files, $mime_type ) { + $ids = array(); + foreach ( $files as $file ) { + $ids[] = $this->factory()->attachment->create_object( + $file, + 0, + array( + 'post_mime_type' => $mime_type, + 'post_type' => 'attachment', + ) + ); + } + return $ids; + } + +} diff --git a/tests/test-class-amp-post-type-support.php b/tests/test-class-amp-post-type-support.php index 733d9ae46c2..b32674715d4 100644 --- a/tests/test-class-amp-post-type-support.php +++ b/tests/test-class-amp-post-type-support.php @@ -73,9 +73,9 @@ public function test_add_post_type_support() { AMP_Options_Manager::update_option( 'supported_post_types', array( 'poem' ) ); AMP_Post_Type_Support::add_post_type_support(); - $this->assertTrue( post_type_supports( 'post', AMP_QUERY_VAR ) ); - $this->assertTrue( post_type_supports( 'poem', AMP_QUERY_VAR ) ); - $this->assertFalse( post_type_supports( 'book', AMP_QUERY_VAR ) ); + $this->assertTrue( post_type_supports( 'post', amp_get_slug() ) ); + $this->assertTrue( post_type_supports( 'poem', amp_get_slug() ) ); + $this->assertFalse( post_type_supports( 'book', amp_get_slug() ) ); } /** @@ -92,7 +92,7 @@ public function test_get_support_error() { // Post type support. $book_id = $this->factory()->post->create( array( 'post_type' => 'book' ) ); $this->assertEquals( array( 'post-type-support' ), AMP_Post_Type_Support::get_support_errors( $book_id ) ); - add_post_type_support( 'book', AMP_QUERY_VAR ); + add_post_type_support( 'book', amp_get_slug() ); $this->assertEmpty( AMP_Post_Type_Support::get_support_errors( $book_id ) ); // Password-protected. diff --git a/tests/test-class-amp-theme-support.php b/tests/test-class-amp-theme-support.php index b71c4d41228..df6e94b0301 100644 --- a/tests/test-class-amp-theme-support.php +++ b/tests/test-class-amp-theme-support.php @@ -13,14 +13,123 @@ */ class Test_AMP_Theme_Support extends WP_UnitTestCase { + /** + * The name of the tested class. + * + * @var string + */ + const TESTED_CLASS = 'AMP_Theme_Support'; + /** * After a test method runs, reset any state in WordPress the test method might have changed. + * + * @global WP_Scripts $wp_scripts */ public function tearDown() { + global $wp_scripts; + $wp_scripts = null; parent::tearDown(); remove_theme_support( 'amp' ); $_REQUEST = array(); // phpcs:ignore WordPress.CSRF.NonceVerification.NoNonceVerification $_SERVER['QUERY_STRING'] = ''; + unset( $_SERVER['REQUEST_URI'] ); + unset( $_SERVER['REQUEST_METHOD'] ); + if ( isset( $GLOBALS['wp_customize'] ) ) { + $GLOBALS['wp_customize']->stop_previewing_theme(); + } + AMP_Theme_Support::$headers_sent = array(); + } + + /** + * Test init. + * + * @covers AMP_Theme_Support::init() + * @covers AMP_Theme_Support::finish_init() + */ + public function test_init() { + $_REQUEST['__amp_source_origin'] = 'foo'; + $_GET['__amp_source_origin'] = 'foo'; + AMP_Theme_Support::init(); + $this->assertNotEquals( 10, has_action( 'widgets_init', array( self::TESTED_CLASS, 'register_widgets' ) ) ); + + // Ensure that purge_amp_query_vars() didn't execute. + $this->assertTrue( isset( $_REQUEST['__amp_source_origin'] ) ); // WPCS: CSRF ok. + + add_theme_support( 'amp' ); + AMP_Theme_Support::init(); + $this->assertEquals( 10, has_action( 'widgets_init', array( self::TESTED_CLASS, 'register_widgets' ) ) ); + $this->assertEquals( PHP_INT_MAX, has_action( 'wp', array( self::TESTED_CLASS, 'finish_init' ) ) ); + $this->assertFalse( isset( $_REQUEST['__amp_source_origin'] ) ); // WPCS: CSRF ok. + + add_theme_support( 'amp', 'invalid_argumnet_type' ); + $e = null; + try { + AMP_Theme_Support::init(); + } catch ( Exception $exception ) { + $e = $exception; + } + $this->assertInstanceOf( 'PHPUnit_Framework_Error_Notice', $e ); + $this->assertEquals( 'Expected AMP theme support arg to be array.', $e->getMessage() ); + + add_theme_support( 'amp', array( + 'invalid_param_key' => array(), + ) ); + try { + AMP_Theme_Support::init(); + } catch ( Exception $exception ) { + $e = $exception; + } + $this->assertEquals( 'Expected AMP theme support to only have template_dir and/or available_callback.', $e->getMessage() ); + } + + /** + * Test that amphtml link is added at the right time. + * + * @covers AMP_Theme_Support::finish_init() + */ + public function test_amphtml_link() { + $post_id = $this->factory()->post->create( array( 'post_title' => 'Test' ) ); + add_theme_support( 'amp', array( + 'template_dir' => '...', + 'available_callback' => function() { + return is_singular(); + }, + ) ); + + // Test paired mode singular. + remove_action( 'wp_head', 'amp_frontend_add_canonical' ); + $this->go_to( get_permalink( $post_id ) ); + AMP_Theme_Support::finish_init(); + $this->assertEquals( 10, has_action( 'wp_head', 'amp_frontend_add_canonical' ) ); + + // Test paired mode homepage. + remove_action( 'wp_head', 'amp_frontend_add_canonical' ); + $this->go_to( home_url() ); + AMP_Theme_Support::finish_init(); + $this->assertFalse( has_action( 'wp_head', 'amp_frontend_add_canonical' ) ); + + // Test canonical. + remove_theme_support( 'amp' ); + add_theme_support( 'amp' ); + $this->go_to( get_permalink( $post_id ) ); + AMP_Theme_Support::finish_init(); + $this->assertFalse( has_action( 'wp_head', 'amp_frontend_add_canonical' ) ); + } + + /** + * Test redirect_canonical_amp. + * + * @covers AMP_Theme_Support::redirect_canonical_amp() + */ + public function test_redirect_canonical_amp() { + set_query_var( amp_get_slug(), 1 ); + try { + AMP_Theme_Support::redirect_canonical_amp(); + } catch ( Exception $exception ) { + $e = $exception; + } + // wp_safe_redirect() modifies the headers, and causes an error. + $this->assertTrue( isset( $e ) ); } /** @@ -70,6 +179,411 @@ public function test_is_paired_available() { $this->assertFalse( AMP_Theme_Support::is_paired_available() ); } + /** + * Test is_customize_preview_iframe. + * + * @covers AMP_Theme_Support::is_customize_preview_iframe() + */ + public function test_is_customize_preview_iframe() { + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + $GLOBALS['wp_customize'] = new WP_Customize_Manager(); + $this->assertFalse( AMP_Theme_Support::is_customize_preview_iframe() ); + $GLOBALS['wp_customize'] = new WP_Customize_Manager( array( + 'messenger_channel' => 'baz', + ) ); + $this->assertFalse( AMP_Theme_Support::is_customize_preview_iframe() ); + $GLOBALS['wp_customize']->start_previewing_theme(); + $this->assertTrue( AMP_Theme_Support::is_customize_preview_iframe() ); + } + + /** + * Test register_paired_hooks. + * + * @covers AMP_Theme_Support::register_paired_hooks() + */ + public function test_register_paired_hooks() { + $template_types = array( + 'paged', + 'index', + '404', + 'archive', + 'author', + 'category', + ); + AMP_Theme_Support::register_paired_hooks(); + foreach ( $template_types as $template_type ) { + $this->assertEquals( 10, has_filter( "{$template_type}_template_hierarchy", array( self::TESTED_CLASS, 'filter_paired_template_hierarchy' ) ) ); + } + $this->assertEquals( 100, has_filter( 'template_include', array( self::TESTED_CLASS, 'filter_paired_template_include' ) ) ); + } + + /** + * Test validate_non_amp_theme. + * + * @global WP_Widget_Factory $wp_widget_factory + * @global WP_Scripts $wp_scripts + * @covers AMP_Theme_Support::prepare_response() + */ + public function test_validate_non_amp_theme() { + add_theme_support( 'amp' ); + AMP_Theme_Support::init(); + AMP_Theme_Support::finish_init(); + + ob_start(); + ?> + + + + + + + + + + + + assertNotContains( '', $sanitized_html ); + + // Correct viewport meta tag was added. + $this->assertContains( '', $sanitized_html ); + + // MathML script was added. + $this->assertContains( '', $sanitized_html ); // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + } + + /** + * Test add_hooks. + * + * @covers AMP_Theme_Support::add_hooks() + */ + public function test_add_hooks() { + AMP_Theme_Support::add_hooks(); + $this->assertFalse( has_action( 'wp_head', 'wp_post_preview_js' ) ); + $this->assertFalse( has_action( 'wp_head', 'print_emoji_detection_script' ) ); + $this->assertFalse( has_action( 'wp_print_styles', 'print_emoji_styles' ) ); + $this->assertFalse( has_action( 'wp_head', 'wp_oembed_add_host_js' ) ); + + $this->assertEquals( 0, has_action( 'wp_print_styles', array( self::TESTED_CLASS, 'print_amp_styles' ) ) ); + $this->assertEquals( 20, has_action( 'wp_head', 'amp_add_generator_metadata' ) ); + $this->assertEquals( 20, has_action( 'wp_head', 'amp_add_generator_metadata' ) ); + $this->assertEquals( 10, has_action( 'wp_enqueue_scripts', array( self::TESTED_CLASS, 'enqueue_assets' ) ) ); + + $this->assertEquals( 1000, has_action( 'wp_enqueue_scripts', array( self::TESTED_CLASS, 'dequeue_customize_preview_scripts' ) ) ); + $this->assertEquals( 10, has_filter( 'customize_partial_render', array( self::TESTED_CLASS, 'filter_customize_partial_render' ) ) ); + $this->assertEquals( 10, has_action( 'wp_footer', 'amp_print_analytics' ) ); + $this->assertEquals( 100, has_filter( 'show_admin_bar', '__return_false' ) ); + $this->assertEquals( 0, has_action( 'template_redirect', array( self::TESTED_CLASS, 'start_output_buffering' ) ) ); + + $this->assertEquals( PHP_INT_MAX, has_filter( 'wp_list_comments_args', array( self::TESTED_CLASS, 'set_comments_walker' ) ) ); + $this->assertEquals( 10, has_filter( 'comment_form_defaults', array( self::TESTED_CLASS, 'filter_comment_form_defaults' ) ) ); + $this->assertEquals( 10, has_filter( 'comment_reply_link', array( self::TESTED_CLASS, 'filter_comment_reply_link' ) ) ); + $this->assertEquals( 10, has_filter( 'cancel_comment_reply_link', array( self::TESTED_CLASS, 'filter_cancel_comment_reply_link' ) ) ); + $this->assertEquals( 100, has_action( 'comment_form', array( self::TESTED_CLASS, 'amend_comment_form' ) ) ); + $this->assertFalse( has_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ) ); + } + + /** + * Test purge_amp_query_vars. + * + * @covers AMP_Theme_Support::purge_amp_query_vars() + */ + public function test_purge_amp_query_vars() { + // phpcs:disable WordPress.CSRF.NonceVerification.NoNonceVerification + $bad_query_vars = array( + 'amp_latest_update_time' => '1517199956', + 'amp_last_check_time' => '1517599126', + '__amp_source_origin' => home_url(), + ); + $ok_query_vars = array( + 'bar' => 'baz', + ); + $all_query_vars = array_merge( $bad_query_vars, $ok_query_vars ); + + $_SERVER['QUERY_STRING'] = build_query( $all_query_vars ); + + remove_action( 'wp', 'amp_maybe_add_actions' ); + $this->go_to( add_query_arg( $all_query_vars, home_url( '/foo/' ) ) ); + $_REQUEST = $_GET; + foreach ( $all_query_vars as $key => $value ) { + $this->assertArrayHasKey( $key, $_GET ); + $this->assertArrayHasKey( $key, $_REQUEST ); + $this->assertContains( "$key=$value", $_SERVER['QUERY_STRING'] ); + $this->assertContains( "$key=$value", $_SERVER['REQUEST_URI'] ); + } + + AMP_Theme_Support::$purged_amp_query_vars = array(); + AMP_Theme_Support::purge_amp_query_vars(); + $this->assertEqualSets( AMP_Theme_Support::$purged_amp_query_vars, $bad_query_vars ); + + foreach ( $bad_query_vars as $key => $value ) { + $this->assertArrayNotHasKey( $key, $_GET ); + $this->assertArrayNotHasKey( $key, $_REQUEST ); + $this->assertNotContains( "$key=$value", $_SERVER['QUERY_STRING'] ); + $this->assertNotContains( "$key=$value", $_SERVER['REQUEST_URI'] ); + } + foreach ( $ok_query_vars as $key => $value ) { + $this->assertArrayHasKey( $key, $_GET ); + $this->assertArrayHasKey( $key, $_REQUEST ); + $this->assertContains( "$key=$value", $_SERVER['QUERY_STRING'] ); + $this->assertContains( "$key=$value", $_SERVER['REQUEST_URI'] ); + } + // phpcs:enable WordPress.CSRF.NonceVerification.NoNonceVerification + } + + /** + * Test send_header. + * + * @covers AMP_Theme_Support::send_header() + */ + public function test_send_header() { + $name = 'foo'; + $value = 'bar'; + $args = array( + 'X-Example' => 'baz', + ); + $default_args = array( + 'replace' => true, + 'status_code' => null, + ); + AMP_Theme_Support::send_header( $name, $value, $args ); + $this->assertEquals( array_merge( compact( 'name', 'value' ), $args, $default_args ), reset( AMP_Theme_Support::$headers_sent ) ); + } + + /** + * Test handle_xhr_request(). + * + * @covers AMP_Theme_Support::handle_xhr_request() + */ + public function test_handle_xhr_request() { + AMP_Theme_Support::purge_amp_query_vars(); + AMP_Theme_Support::handle_xhr_request(); + $this->assertEmpty( AMP_Theme_Support::$headers_sent ); + + $_GET['_wp_amp_action_xhr_converted'] = '1'; + + // Try bad source origin. + $_GET['__amp_source_origin'] = 'http://evil.example.com/'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + AMP_Theme_Support::purge_amp_query_vars(); + AMP_Theme_Support::handle_xhr_request(); + $this->assertEmpty( AMP_Theme_Support::$headers_sent ); + + // Try home source origin. + $_GET['__amp_source_origin'] = home_url(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + AMP_Theme_Support::purge_amp_query_vars(); + AMP_Theme_Support::handle_xhr_request(); + $this->assertCount( 1, AMP_Theme_Support::$headers_sent ); + $this->assertEquals( + array( + 'name' => 'AMP-Access-Control-Allow-Source-Origin', + 'value' => home_url(), + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent[0] + ); + $this->assertEquals( PHP_INT_MAX, has_filter( 'wp_redirect', array( 'AMP_Theme_Support', 'intercept_post_request_redirect' ) ) ); + $this->assertEquals( PHP_INT_MAX, has_filter( 'comment_post_redirect', array( 'AMP_Theme_Support', 'filter_comment_post_redirect' ) ) ); + $this->assertEquals( + array( 'AMP_Theme_Support', 'handle_wp_die' ), + apply_filters( 'wp_die_handler', '__return_true' ) + ); + } + + /** + * Test filter_comment_post_redirect(). + * + * @covers AMP_Theme_Support::filter_comment_post_redirect() + */ + public function test_filter_comment_post_redirect() { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', function() { + return '__return_null'; + } ); + + add_theme_support( 'amp' ); + $post = $this->factory()->post->create_and_get(); + $comment = $this->factory()->comment->create_and_get( array( + 'comment_post_ID' => $post->ID, + ) ); + $url = get_comment_link( $comment ); + + // Test without comments_live_list. + $filtered_url = AMP_Theme_Support::filter_comment_post_redirect( $url, $comment ); + $this->assertNotEquals( + strtok( $url, '#' ), + strtok( $filtered_url, '#' ) + ); + + // Test with comments_live_list. + add_theme_support( 'amp', array( + 'comments_live_list' => true, + ) ); + add_filter( 'amp_comment_posted_message', function( $message, WP_Comment $filter_comment ) { + return sprintf( '(comment=%d,approved=%d)', $filter_comment->comment_ID, $filter_comment->comment_approved ); + }, 10, 2 ); + + // Test approved comment. + $comment->comment_approved = '1'; + ob_start(); + AMP_Theme_Support::filter_comment_post_redirect( $url, $comment ); + $response = json_decode( ob_get_clean(), true ); + $this->assertArrayHasKey( 'message', $response ); + $this->assertEquals( + sprintf( '(comment=%d,approved=1)', $comment->comment_ID ), + $response['message'] + ); + + // Test moderated comment. + $comment->comment_approved = '0'; + ob_start(); + AMP_Theme_Support::filter_comment_post_redirect( $url, $comment ); + $response = json_decode( ob_get_clean(), true ); + $this->assertArrayHasKey( 'message', $response ); + $this->assertEquals( + sprintf( '(comment=%d,approved=0)', $comment->comment_ID ), + $response['message'] + ); + } + + /** + * Test handle_wp_die(). + * + * @covers AMP_Theme_Support::handle_wp_die() + */ + public function test_handle_wp_die() { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', function() { + return '__return_null'; + } ); + + ob_start(); + AMP_Theme_Support::handle_wp_die( 'string' ); + $this->assertEquals( '{"error":"string"}', ob_get_clean() ); + + ob_start(); + $error = new WP_Error( 'code', 'The Message' ); + AMP_Theme_Support::handle_wp_die( $error ); + $this->assertEquals( '{"error":"The Message"}', ob_get_clean() ); + } + + /** + * Test intercept_post_request_redirect(). + * + * @covers AMP_Theme_Support::intercept_post_request_redirect() + */ + public function test_intercept_post_request_redirect() { + + add_theme_support( 'amp' ); + $url = home_url( '', 'https' ) . ':443/?test=true#test'; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', function () { + return '__return_false'; + } ); + + // Test redirecting to full URL with HTTPS protocol. + AMP_Theme_Support::$headers_sent = array(); + ob_start(); + AMP_Theme_Support::intercept_post_request_redirect( $url ); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + $this->assertContains( + array( + 'name' => 'AMP-Redirect-To', + 'value' => $url, + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + $this->assertContains( + array( + 'name' => 'Access-Control-Expose-Headers', + 'value' => 'AMP-Redirect-To', + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + + // Test redirecting to non-HTTPS URL. + AMP_Theme_Support::$headers_sent = array(); + ob_start(); + $url = home_url( '/', 'http' ); + AMP_Theme_Support::intercept_post_request_redirect( $url ); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + $this->assertContains( + array( + 'name' => 'AMP-Redirect-To', + 'value' => preg_replace( '#^\w+:#', '', $url ), + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + $this->assertContains( + array( + 'name' => 'Access-Control-Expose-Headers', + 'value' => 'AMP-Redirect-To', + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + + // Test redirecting to host-less location. + AMP_Theme_Support::$headers_sent = array(); + ob_start(); + AMP_Theme_Support::intercept_post_request_redirect( '/new-location/' ); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + $this->assertContains( + array( + 'name' => 'AMP-Redirect-To', + 'value' => set_url_scheme( home_url( '/new-location/' ), 'https' ), + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + + // Test redirecting to scheme-less location. + AMP_Theme_Support::$headers_sent = array(); + ob_start(); + $url = home_url( '/new-location/' ); + AMP_Theme_Support::intercept_post_request_redirect( substr( $url, strpos( $url, ':' ) + 1 ) ); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + $this->assertContains( + array( + 'name' => 'AMP-Redirect-To', + 'value' => set_url_scheme( home_url( '/new-location/' ), 'https' ), + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + + // Test redirecting to empty location. + AMP_Theme_Support::$headers_sent = array(); + ob_start(); + AMP_Theme_Support::intercept_post_request_redirect( '' ); + $this->assertEquals( '{"success":true}', ob_get_clean() ); + $this->assertContains( + array( + 'name' => 'AMP-Redirect-To', + 'value' => set_url_scheme( home_url(), 'https' ), + 'replace' => true, + 'status_code' => null, + ), + AMP_Theme_Support::$headers_sent + ); + } + /** * Test register_widgets(). * @@ -87,21 +601,401 @@ public function test_register_widgets() { $this->assertArrayHasKey( 'AMP_Widget_Categories', $wp_widget_factory->widgets ); } + /** + * Test register_content_embed_handlers. + * + * @covers AMP_Theme_Support::register_content_embed_handlers() + */ + public function test_register_content_embed_handlers() { + $embed_handlers = AMP_Theme_Support::register_content_embed_handlers(); + foreach ( $embed_handlers as $embed_handler ) { + $this->assertTrue( is_subclass_of( $embed_handler, 'AMP_Base_Embed_Handler' ) ); + $reflection = new ReflectionObject( $embed_handler ); + $args = $reflection->getProperty( 'args' ); + $args->setAccessible( true ); + $property = $args->getValue( $embed_handler ); + $this->assertEquals( AMP_Post_Template::CONTENT_MAX_WIDTH, $property['content_max_width'] ); + } + } + + /** + * Test set_comments_walker. + * + * @covers AMP_Theme_Support::set_comments_walker() + */ + public function test_set_comments_walker() { + $args = AMP_Theme_Support::set_comments_walker( array( + 'walker' => null, + ) ); + $this->assertInstanceOf( 'AMP_Comment_Walker', $args['walker'] ); + } + + /** + * Test amend_comment_form(). + * + * @covers AMP_Theme_Support::amend_comment_form() + */ + public function test_amend_comment_form() { + $post_id = $this->factory()->post->create(); + $this->go_to( get_permalink( $post_id ) ); + $this->assertTrue( is_singular() ); + + // Test native AMP. + add_theme_support( 'amp' ); + $this->assertTrue( amp_is_canonical() ); + ob_start(); + AMP_Theme_Support::amend_comment_form(); + $output = ob_get_clean(); + $this->assertNotContains( 'assertContains( '
', $output ); + $this->assertContains( '
', $output ); + + // Test paired AMP. + add_theme_support( 'amp', array( + 'template_dir' => 'amp-templates', + ) ); + $this->assertFalse( amp_is_canonical() ); + ob_start(); + AMP_Theme_Support::amend_comment_form(); + $output = ob_get_clean(); + $this->assertContains( 'assertContains( '
', $output ); + $this->assertContains( '
', $output ); + } + + /** + * Test filter_paired_template_hierarchy. + * + * @covers AMP_Theme_Support::filter_paired_template_hierarchy() + */ + public function test_filter_paired_template_hierarchy() { + $template_dir = 'amp-templates'; + add_theme_support( 'amp', array( + 'template_dir' => $template_dir, + ) ); + $templates = array( + 'single-post-example.php', + 'single-post.php', + 'single.php', + ); + $filtered_templates = AMP_Theme_Support::filter_paired_template_hierarchy( $templates ); + foreach ( $filtered_templates as $key => $filtered_template ) { + $this->assertEquals( $template_dir . '/' . $templates[ $key ], $filtered_template ); + } + } + + /** + * Test filter_paired_template_include. + * + * @covers AMP_Theme_Support::filter_paired_template_include() + */ + public function test_filter_paired_template_include() { + $template_dir = 'amp-templates'; + $template = 'single.php'; + add_theme_support( 'amp', array( + 'template_dir' => $template_dir, + ) ); + $this->assertEquals( $template, AMP_Theme_Support::filter_paired_template_include( $template ) ); + remove_theme_support( 'amp' ); + try { + AMP_Theme_Support::filter_paired_template_include( $template ); + } catch ( Exception $exception ) { + $e = $exception; + } + $this->assertTrue( isset( $e ) ); + } + + /** + * Test get_current_canonical_url. + * + * @covers AMP_Theme_Support::get_current_canonical_url() + */ + public function test_get_current_canonical_url() { + global $post, $wp; + $home_url = home_url( '/' ); + $this->assertEquals( $home_url, AMP_Theme_Support::get_current_canonical_url() ); + + $added_query_vars = array( + 'foo' => 'bar', + ); + $wp->query_vars = $added_query_vars; + $this->assertEquals( add_query_arg( $added_query_vars, $home_url ), AMP_Theme_Support::get_current_canonical_url() ); + + $post = $this->factory()->post->create_and_get(); // WPCS: global override ok. + $this->go_to( get_permalink( $post ) ); + $this->assertEquals( wp_get_canonical_url(), AMP_Theme_Support::get_current_canonical_url() ); + + } + + /** + * Test get_comment_form_state_id. + * + * @covers AMP_Theme_Support::get_comment_form_state_id() + */ + public function test_get_comment_form_state_id() { + $post_id = 54; + $this->assertEquals( 'commentform_post_' . $post_id, AMP_Theme_Support::get_comment_form_state_id( $post_id ) ); + $post_id = 91542; + $this->assertEquals( 'commentform_post_' . $post_id, AMP_Theme_Support::get_comment_form_state_id( $post_id ) ); + } + + /** + * Test filter_comment_form_defaults. + * + * @covers AMP_Theme_Support::filter_comment_form_defaults() + */ + public function test_filter_comment_form_defaults() { + global $post; + $post = $this->factory()->post->create_and_get(); // WPCS: global override ok. + $defaults = AMP_Theme_Support::filter_comment_form_defaults( array( + 'title_reply_to' => 'Reply To', + 'title_reply' => 'Reply', + 'cancel_reply_before' => '', + 'title_reply_before' => '', + ) ); + $this->assertContains( AMP_Theme_Support::get_comment_form_state_id( get_the_ID() ), $defaults['title_reply_before'] ); + $this->assertContains( 'replyToName ?', $defaults['title_reply_before'] ); + $this->assertContains( '', $defaults['cancel_reply_before'] ); + } + + /** + * Test filter_comment_reply_link. + * + * @covers AMP_Theme_Support::filter_comment_reply_link() + */ + public function test_filter_comment_reply_link() { + global $post; + $post = $this->factory()->post->create_and_get(); // WPCS: global override ok. + $comment = $this->factory()->comment->create_and_get(); + $link = get_comment_link( $comment ); + $respond_id = '5234'; + $reply_text = 'Reply'; + $reply_to_text = 'Reply to'; + $args = compact( 'respond_id', 'reply_text', 'reply_to_text' ); + $comment = $this->factory()->comment->create_and_get(); + update_option( 'comment_registration', true ); + $filtered_link = AMP_Theme_Support::filter_comment_reply_link( $link, $args, $comment ); + $this->assertEquals( $link, $filtered_link ); + update_option( 'comment_registration', false ); + + $filtered_link = AMP_Theme_Support::filter_comment_reply_link( $link, $args, $comment ); + $this->assertContains( AMP_Theme_Support::get_comment_form_state_id( get_the_ID() ), $filtered_link ); + $this->assertContains( $comment->comment_author, $filtered_link ); + $this->assertContains( $comment->comment_ID, $filtered_link ); + $this->assertContains( 'tap:AMP.setState', $filtered_link ); + $this->assertContains( $reply_text, $filtered_link ); + $this->assertContains( $reply_to_text, $filtered_link ); + $this->assertContains( $respond_id, $filtered_link ); + } + + /** + * Test filter_cancel_comment_reply_link. + * + * @covers AMP_Theme_Support::filter_cancel_comment_reply_link() + */ + public function test_filter_cancel_comment_reply_link() { + global $post; + $post = $this->factory()->post->create_and_get(); // WPCS: global override ok. + $url = get_permalink( $post ); + $_SERVER['REQUEST_URI'] = $url; + $this->factory()->comment->create_and_get(); + $formatted_link = get_cancel_comment_reply_link(); + $link = remove_query_arg( 'replytocom' ); + $text = 'Cancel your reply'; + $filtered_link = AMP_Theme_Support::filter_cancel_comment_reply_link( $formatted_link, $link, $text ); + $this->assertContains( $url, $filtered_link ); + $this->assertContains( $text, $filtered_link ); + $this->assertContains( 'assertContains( '.values.comment_parent ==', $filtered_link ); + $this->assertContains( 'tap:AMP.setState(', $filtered_link ); + + $filtered_link_no_text_passed = AMP_Theme_Support::filter_cancel_comment_reply_link( $formatted_link, $link, '' ); + $this->assertContains( 'Click here to cancel reply.', $filtered_link_no_text_passed ); + } + + /** + * Test print_amp_styles. + * + * @covers AMP_Theme_Support::print_amp_styles() + */ + public function test_print_amp_styles() { + ob_start(); + AMP_Theme_Support::print_amp_styles(); + $output = ob_get_clean(); + $this->assertContains( amp_get_boilerplate_code(), $output ); + $this->assertContains( '', $output ); + } + + /** + * Test ensure_required_markup(). + * + * @dataProvider get_script_data + * @covers AMP_Theme_Support::ensure_required_markup() + * @param string $script The value of the script. + * @param boolean $expected The expected result. + */ + public function test_ensure_required_markup( $script, $expected ) { + $page = 'Test'; + $dom = new DOMDocument(); + $dom->loadHTML( sprintf( $page, $script ) ); + AMP_Theme_Support::ensure_required_markup( $dom ); + $this->assertEquals( $expected, substr_count( $dom->saveHTML(), 'schema.org' ) ); + } + + /** + * Test dequeue_customize_preview_scripts. + * + * @covers AMP_Theme_Support::dequeue_customize_preview_scripts() + */ + public function test_dequeue_customize_preview_scripts() { + // Ensure AMP_Theme_Support::is_customize_preview_iframe() is true. + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + $GLOBALS['wp_customize'] = new WP_Customize_Manager( array( + 'messenger_channel' => 'baz', + ) ); + $GLOBALS['wp_customize']->start_previewing_theme(); + $customize_preview = 'customize-preview'; + $preview_style = 'example-preview-style'; + wp_enqueue_style( $preview_style, home_url( '/' ), array( $customize_preview ) ); + AMP_Theme_Support::dequeue_customize_preview_scripts(); + $this->assertTrue( wp_style_is( $preview_style ) ); + $this->assertTrue( wp_style_is( $customize_preview ) ); + + wp_enqueue_style( $preview_style, home_url( '/' ), array( $customize_preview ) ); + wp_enqueue_style( $customize_preview ); + // Ensure AMP_Theme_Support::is_customize_preview_iframe() is false. + $GLOBALS['wp_customize'] = new WP_Customize_Manager(); + AMP_Theme_Support::dequeue_customize_preview_scripts(); + $this->assertFalse( wp_style_is( $preview_style ) ); + $this->assertFalse( wp_style_is( $customize_preview ) ); + } + + /** + * Test start_output_buffering. + * + * @covers AMP_Theme_Support::start_output_buffering() + */ + public function test_start_output_buffering() { + if ( ! function_exists( 'newrelic_disable_autorum ' ) ) { + /** + * Define newrelic_disable_autorum to allow passing line. + */ + function newrelic_disable_autorum() { + return true; + } + } + + add_theme_support( 'amp' ); + AMP_Theme_Support::init(); + AMP_Theme_Support::finish_init(); + + $ob_level = ob_get_level(); + AMP_Theme_Support::start_output_buffering(); + + $this->assertEquals( 0, has_action( 'shutdown', array( self::TESTED_CLASS, 'finish_output_buffering' ) ) ); + $this->assertTrue( ob_get_level() > $ob_level ); + + // End output buffer. + if ( ob_get_level() > $ob_level ) { + ob_get_clean(); + } + } + + /** + * Test finish_output_buffering. + * + * @covers AMP_Theme_Support::finish_output_buffering() + */ + public function test_finish_output_buffering() { + add_theme_support( 'amp' ); + AMP_Theme_Support::init(); + AMP_Theme_Support::finish_init(); + + // start first layer buffer. + ob_start(); + AMP_Theme_Support::start_output_buffering(); + + echo ''; + + // Additional nested output bufferings which aren't getting closed. + ob_start(); + echo 'foo'; + ob_start( function( $response ) { + return strtoupper( $response ); + } ); + echo 'bar'; + + AMP_Theme_Support::finish_output_buffering(); + // get first layer buffer. + $output = ob_get_clean(); + + $this->assertContains( 'assertContains( 'foo', $output ); + $this->assertContains( 'BAR', $output ); + $this->assertContains( 'assertNotContains( ' + @@ -114,18 +1008,29 @@ public function test_prepare_response() { data-aax_src="302"> + + function( $removed ) use ( &$removed_nodes ) { + $removed_nodes[ $removed['node']->nodeName ] = $removed['node']; + }, + ) ); + $this->assertNotContains( 'handle=', $sanitized_html ); + $this->assertEquals( 2, substr_count( $sanitized_html, '' ) ); $this->assertContains( '', $sanitized_html ); $this->assertContains( '', $sanitized_html ); $this->assertContains( '