diff --git a/.travis.yml b/.travis.yml index eb94773397a..9675ab1ab36 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,6 +39,7 @@ matrix: env: WP_VERSION=trunk install: + - if [[ $DEV_LIB_SKIP =~ composer ]]; then composer install --no-dev; fi - nvm install 6 && nvm use 6 - export DEV_LIB_PATH=dev-lib - source $DEV_LIB_PATH/travis.install.sh diff --git a/Gruntfile.js b/Gruntfile.js index 24f1475ee3b..09b3eec6eb9 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -84,15 +84,21 @@ module.exports = function( grunt ) { args: [ 'ls-files' ] }, function( err, res ) { + var paths; if ( err ) { throw new Error( err.message ); } + paths = res.stdout.trim().split( /\n/ ).filter( function( file ) { + return ! /^(\.|bin|([^/]+)+\.(md|json|xml)|Gruntfile\.js|tests|wp-assets|dev-lib|readme\.md|composer\..*)/.test( file ); + } ); + paths.push( 'vendor/autoload.php' ); + paths.push( 'vendor/composer/**' ); + paths.push( 'vendor/sabberworm/php-css-parser/lib/**' ); + 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|composer\..*)/.test( file ); - } ), + src: paths, dest: 'build', expand: true } diff --git a/amp.php b/amp.php index 6481ebcb119..860e4371cd9 100644 --- a/amp.php +++ b/amp.php @@ -21,15 +21,32 @@ function _amp_print_php_version_admin_notice() { ?>
-

-
+

+ +
+

+
+ endpoints as $index => $endpoint ) { if ( amp_get_slug() === $endpoint[1] ) { @@ -130,8 +159,16 @@ function amp_init() { 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. +/** + * Make sure the `amp` query var has an explicit value. + * + * This avoids issues when filtering the deprecated `query_string` hook. + * + * @since 0.3.3 + * + * @param array $query_vars Query vars. + * @return array Query vars. + */ function amp_force_query_var_value( $query_vars ) { if ( isset( $query_vars[ amp_get_slug() ] ) && '' === $query_vars[ amp_get_slug() ] ) { $query_vars[ amp_get_slug() ] = 1; @@ -250,20 +287,41 @@ function amp_is_canonical() { return false; } +/** + * Load classes. + * + * @since 0.2 + * @deprecated As of 0.6 since autoloading is now employed. + */ function amp_load_classes() { _deprecated_function( __FUNCTION__, '0.6' ); } +/** + * Add frontend actions. + * + * @since 0.2 + */ function amp_add_frontend_actions() { require_once AMP__DIR__ . '/includes/amp-frontend-actions.php'; } +/** + * Add post template actions. + * + * @since 0.2 + */ function amp_add_post_template_actions() { require_once AMP__DIR__ . '/includes/amp-post-template-actions.php'; require_once AMP__DIR__ . '/includes/amp-post-template-functions.php'; amp_post_template_init_hooks(); } +/** + * Add action to do post template rendering at template_redirect action. + * + * @since 0.2 + */ function amp_prepare_render() { add_action( 'template_redirect', 'amp_render' ); } diff --git a/bin/amphtml-update.py b/bin/amphtml-update.py index 399d2e07377..15d609a6454 100644 --- a/bin/amphtml-update.py +++ b/bin/amphtml-update.py @@ -356,12 +356,37 @@ def GetTagSpec(tag_spec, attr_lists): for (field_descriptor, field_value) in tag_spec.cdata.ListFields(): if isinstance(field_value, (unicode, str, bool, int)): cdata_dict[ field_descriptor.name ] = field_value - else: - if hasattr( field_value, '_values' ): - cdata_dict[ field_descriptor.name ] = {} - for _value in field_value._values: - for (key,val) in _value.ListFields(): - cdata_dict[ field_descriptor.name ][ key.name ] = val + elif hasattr( field_value, '_values' ): + cdata_dict[ field_descriptor.name ] = {} + for _value in field_value._values: + for (key,val) in _value.ListFields(): + cdata_dict[ field_descriptor.name ][ key.name ] = val + elif 'css_spec' == field_descriptor.name: + css_spec = {} + + css_spec['allowed_at_rules'] = [] + for at_rule_spec in field_value.at_rule_spec: + if '$DEFAULT' == at_rule_spec.name: + continue + css_spec['allowed_at_rules'].append( at_rule_spec.name ) + + for css_spec_field_name in ( 'allowed_declarations', 'font_url_spec', 'image_url_spec', 'validate_keyframes' ): + if not hasattr( field_value, css_spec_field_name ): + continue + css_spec_field_value = getattr( field_value, css_spec_field_name ) + if isinstance(css_spec_field_value, (list, collections.Sequence, google.protobuf.internal.containers.RepeatedScalarFieldContainer)): + css_spec[ css_spec_field_name ] = [ val for val in css_spec_field_value ] + elif hasattr( css_spec_field_value, 'ListFields' ): + css_spec[ css_spec_field_name ] = {} + for (css_spec_field_item_descriptor, css_spec_field_item_value) in getattr( field_value, css_spec_field_name ).ListFields(): + if isinstance(css_spec_field_item_value, (list, collections.Sequence, google.protobuf.internal.containers.RepeatedScalarFieldContainer)): + css_spec[ css_spec_field_name ][ css_spec_field_item_descriptor.name ] = [ val for val in css_spec_field_item_value ] + else: + css_spec[ css_spec_field_name ][ css_spec_field_item_descriptor.name ] = css_spec_field_item_value + else: + css_spec[ css_spec_field_name ] = css_spec_field_value + + cdata_dict['css_spec'] = css_spec if len( cdata_dict ) > 0: tag_spec_dict['cdata'] = cdata_dict diff --git a/composer.json b/composer.json index 33409a6e7de..bf538e3783b 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,15 @@ "type": "wordpress-plugin", "license": "GPL-2.0", "version": "1.0.0", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/xwp/PHP-CSS-Parser" + } + ], + "require": { + "sabberworm/php-css-parser": "dev-master" + }, "require-dev": { "wp-coding-standards/wpcs": "^0.14.0", "dealerdirect/phpcodesniffer-composer-installer": "^0.4.4", diff --git a/composer.lock b/composer.lock index de486f97bc5..4579f0f7cd1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,55 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "984ff0ea17c1c8dff71e6e2e2e9dc8a0", - "packages": [], + "content-hash": "3e2682fa9441981321b1f903b75abb25", + "packages": [ + { + "name": "sabberworm/php-css-parser", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/xwp/PHP-CSS-Parser.git", + "reference": "e3204589287c28396b3db16b92ec30dab19ac2e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/xwp/PHP-CSS-Parser/zipball/e3204589287c28396b3db16b92ec30dab19ac2e9", + "reference": "e3204589287c28396b3db16b92ec30dab19ac2e9", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-0": { + "Sabberworm\\CSS": "lib/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "http://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "source": "https://github.com/xwp/PHP-CSS-Parser/tree/master" + }, + "time": "2018-04-01 07:35:36" + } + ], "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -77,16 +124,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.2.2", + "version": "3.2.3", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "d7c00c3000ac0ce79c96fcbfef86b49a71158cd1" + "reference": "4842476c434e375f9d3182ff7b89059583aa8b27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/d7c00c3000ac0ce79c96fcbfef86b49a71158cd1", - "reference": "d7c00c3000ac0ce79c96fcbfef86b49a71158cd1", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/4842476c434e375f9d3182ff7b89059583aa8b27", + "reference": "4842476c434e375f9d3182ff7b89059583aa8b27", "shasum": "" }, "require": { @@ -96,7 +143,7 @@ "php": ">=5.4.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" }, "bin": [ "bin/phpcs", @@ -124,7 +171,7 @@ "phpcs", "standards" ], - "time": "2017-12-19T21:44:46+00:00" + "time": "2018-02-20T21:35:23+00:00" }, { "name": "wimg/php-compatibility", @@ -180,16 +227,16 @@ }, { "name": "wp-coding-standards/wpcs", - "version": "0.14.0", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards.git", - "reference": "8cadf48fa1c70b2381988e0a79e029e011a8f41c" + "reference": "cf6b310caad735816caef7573295f8a534374706" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress-Coding-Standards/WordPress-Coding-Standards/zipball/8cadf48fa1c70b2381988e0a79e029e011a8f41c", - "reference": "8cadf48fa1c70b2381988e0a79e029e011a8f41c", + "url": "https://api.github.com/repos/WordPress-Coding-Standards/WordPress-Coding-Standards/zipball/cf6b310caad735816caef7573295f8a534374706", + "reference": "cf6b310caad735816caef7573295f8a534374706", "shasum": "" }, "require": { @@ -216,12 +263,14 @@ "standards", "wordpress" ], - "time": "2017-11-01T15:10:46+00:00" + "time": "2018-02-16T01:57:48+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "sabberworm/php-css-parser": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": [], diff --git a/contributing.md b/contributing.md index ba5f791c223..6dfae576415 100644 --- a/contributing.md +++ b/contributing.md @@ -2,11 +2,22 @@ Thanks for taking the time to contribute! -To clone this repository -``` bash -$ git clone --recursive git@github.com:Automattic/amp-wp.git +To start, clone this repository into your WordPress install being used for development: + +```bash +cd wp-content/plugins && git clone --recursive git@github.com:Automattic/amp-wp.git amp ``` +If you happened to have cloned without `--recursive` previously, please do `git submodule update --init` to ensure the [dev-lib](https://github.com/xwp/wp-dev-lib/) submodule is available for development. + +Lastly, to get the plugin running in your WordPress install, run `composer install` and then activate the plugin via the WordPress dashboard or `wp plugin activate amp`. + +To install the `pre-commit` hook, do `bash dev-lib/install-pre-commit-hook.sh`. + +Note that pull requests will be checked against [WordPress-Coding-Standards](https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards) with PHPCS, and for JavaScript linting is done with ESLint and (for now) JSCS and JSHint. + +To run the Grunt commands, please first `npm install -g grunt-cli` and then `npm install`. + ## Updating Allowed Tags And Attributes The file `class-amp-allowed-tags-generated.php` has the AMP specification's allowed tags and attributes. It's used in sanitization. @@ -72,6 +83,7 @@ When you push a commit to your PR, Travis CI will run the PHPUnit tests and snif Contributors who want to make a new release, follow these steps: +0. Do `grunt build` and install the `amp.zip` onto a normal WordPress install running a stable release build; do smoke test to ensure it works. 1. Bump plugin versions in `package.json` (×1), `package-lock.json` (×1, just do `npm install` first), `composer.json` (×1), and in `amp.php` (×2: the metadata block in the header and also the `AMP__VERSION` constant). 2. Add changelog entry to readme. 3. Merge release branch into `master`. diff --git a/includes/amp-post-template-actions.php b/includes/amp-post-template-actions.php index b956faaf6c6..88d717c2c6e 100644 --- a/includes/amp-post-template-actions.php +++ b/includes/amp-post-template-actions.php @@ -107,6 +107,12 @@ function amp_post_template_add_schemaorg_metadata() { * @param AMP_Post_Template $amp_template Template. */ function amp_post_template_add_styles( $amp_template ) { + $stylesheets = $amp_template->get( 'post_amp_stylesheets' ); + if ( ! empty( $stylesheets ) ) { + echo '/* Inline stylesheets */' . PHP_EOL; // WPCS: XSS OK. + echo implode( '', $stylesheets ); // WPCS: XSS OK. + } + $styles = $amp_template->get( 'post_amp_styles' ); if ( ! empty( $styles ) ) { echo '/* Inline styles */' . PHP_EOL; // WPCS: XSS OK. diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index b98afe871be..c86307f0a77 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -30,6 +30,7 @@ class AMP_Autoloader { */ private static $_classmap = array( 'AMP_Theme_Support' => 'includes/class-amp-theme-support', + 'AMP_Response_Headers' => 'includes/class-amp-response-headers', 'AMP_Comment_Walker' => 'includes/class-amp-comment-walker', 'AMP_Template_Customizer' => 'includes/admin/class-amp-customizer', 'AMP_Post_Meta_Box' => 'includes/admin/class-amp-post-meta-box', @@ -130,6 +131,10 @@ protected static function autoload( $class_name ) { * Called at the end of this file; calling a second time has no effect. */ public static function register() { + if ( file_exists( AMP__DIR__ . '/vendor/autoload.php' ) ) { + require_once AMP__DIR__ . '/vendor/autoload.php'; + } + if ( ! self::$is_registered ) { spl_autoload_register( array( __CLASS__, 'autoload' ) ); self::$is_registered = true; diff --git a/includes/class-amp-response-headers.php b/includes/class-amp-response-headers.php new file mode 100644 index 00000000000..d8a5e62d635 --- /dev/null +++ b/includes/class-amp-response-headers.php @@ -0,0 +1,88 @@ + 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; + } + + /** + * Send Server-Timing header. + * + * @since 1.0 + * @todo What is the ordering in Chrome dev tools? What are the colors about? + * @todo Is there a better name standardization? + * @todo Is there a way to indicate nested server timings, so an outer method's own time can be seen separately from the inner method's time? + * + * @param string $name Name. + * @param float $duration Duration. If negative, will be added to microtime( true ). Optional. + * @param string $description Description. Optional. + * @return bool Return value of send_header call. + */ + public static function send_server_timing( $name, $duration = null, $description = null ) { + $value = $name; + if ( isset( $description ) ) { + $value .= sprintf( ';desc=%s', wp_json_encode( $description ) ); + } + if ( isset( $duration ) ) { + if ( $duration < 0 ) { + $duration = microtime( true ) + $duration; + } + $value .= sprintf( ';dur=%f', $duration * 1000 ); + } + return self::send_header( 'Server-Timing', $value, array( 'replace' => false ) ); + } +} diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 49479a0b401..01207b645ca 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -68,22 +68,25 @@ class AMP_Theme_Support { public static $purged_amp_query_vars = array(); /** - * Headers sent (or attempted to be sent). + * Start time when init was called. * - * @since 0.7 - * @see AMP_Theme_Support::send_header() - * @var array[] + * @since 1.0 + * @var float */ - public static $headers_sent = array(); + public static $init_start_time; /** * Initialize. + * + * @since 0.7 */ public static function init() { if ( ! current_theme_supports( 'amp' ) ) { return; } + self::$init_start_time = microtime( true ); + self::purge_amp_query_vars(); self::handle_xhr_request(); @@ -322,45 +325,6 @@ public static function purge_amp_query_vars() { } } - /** - * 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 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. * @@ -381,7 +345,7 @@ public static function handle_xhr_request() { // 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 ) ); + AMP_Response_Headers::send_header( 'AMP-Access-Control-Allow-Source-Origin', $origin, array( 'replace' => true ) ); } // Intercept POST requests which redirect. @@ -530,8 +494,8 @@ public static function intercept_post_request_redirect( $location ) { $absolute_location .= '#' . $parsed_location['fragment']; } - self::send_header( 'AMP-Redirect-To', $absolute_location ); - self::send_header( 'Access-Control-Expose-Headers', 'AMP-Redirect-To' ); + AMP_Response_Headers::send_header( 'AMP-Redirect-To', $absolute_location ); + AMP_Response_Headers::send_header( 'Access-Control-Expose-Headers', 'AMP-Redirect-To' ); wp_send_json_success(); } @@ -972,6 +936,7 @@ public static function start_output_buffering() { * @see AMP_Theme_Support::start_output_buffering() */ public static function finish_output_buffering() { + AMP_Response_Headers::send_server_timing( 'amp_output_buffer', -self::$init_start_time, 'AMP Output Buffer' ); echo self::prepare_response( ob_get_clean() ); // WPCS: xss ok. } @@ -1039,6 +1004,8 @@ public static function prepare_response( $response, $args = array() ) { $args ); + $dom_parse_start = microtime( true ); + /* * Make sure that is present in output prior to parsing. * Note that the meta charset is supposed to appear within the first 1024 bytes. @@ -1070,8 +1037,11 @@ public static function prepare_response( $response, $args = array() ) { $dom->documentElement->setAttribute( 'amp', '' ); } + AMP_Response_Headers::send_server_timing( 'amp_dom_parse', -$dom_parse_start, 'AMP DOM Parse' ); + $assets = AMP_Content_Sanitizer::sanitize_document( $dom, self::$sanitizer_classes, $args ); + $dom_serialize_start = microtime( true ); self::ensure_required_markup( $dom ); // @todo If 'utf-8' is not the blog charset, then we'll need to do some character encoding conversation or "entityification". @@ -1117,6 +1087,8 @@ public static function prepare_response( $response, $args = array() ) { ); } + AMP_Response_Headers::send_server_timing( 'amp_dom_serialize', -$dom_serialize_start, 'AMP DOM Serialize' ); + return $response; } diff --git a/includes/sanitizers/class-amp-allowed-tags-generated.php b/includes/sanitizers/class-amp-allowed-tags-generated.php index d7e28aecf92..f666c891ae0 100644 --- a/includes/sanitizers/class-amp-allowed-tags-generated.php +++ b/includes/sanitizers/class-amp-allowed-tags-generated.php @@ -9180,6 +9180,35 @@ class AMP_Allowed_Tags_Generated { 'error_message' => 'CSS !important', 'regex' => '!important', ), + 'css_spec' => array( + 'allowed_at_rules' => array( + 'font-face', + 'keyframes', + 'media', + 'supports', + ), + 'allowed_declarations' => array(), + 'font_url_spec' => array( + 'allow_empty' => true, + 'allow_relative' => true, + 'allowed_protocol' => array( + 'https', + 'http', + 'data', + ), + ), + 'image_url_spec' => array( + 'allow_empty' => true, + 'allow_relative' => true, + 'allowed_protocol' => array( + 'https', + 'http', + 'data', + 'absolute', + ), + ), + 'validate_keyframes' => false, + ), 'max_bytes' => 50000, 'max_bytes_spec_url' => 'https://www.ampproject.org/docs/reference/spec#maximum-size', ), @@ -9240,6 +9269,23 @@ class AMP_Allowed_Tags_Generated { ), ), 'cdata' => array( + 'css_spec' => array( + 'allowed_at_rules' => array( + 'keyframes', + 'media', + 'supports', + ), + 'allowed_declarations' => array( + 'animation-timing-function', + 'offset-distance', + 'opacity', + 'transform', + 'visibility', + ), + 'font_url_spec' => array(), + 'image_url_spec' => array(), + 'validate_keyframes' => true, + ), 'max_bytes' => 500000, 'max_bytes_spec_url' => 'https://www.ampproject.org/docs/reference/spec#keyframes-stylesheet', ), diff --git a/includes/sanitizers/class-amp-base-sanitizer.php b/includes/sanitizers/class-amp-base-sanitizer.php index 0fc9a697085..1c2cb183be6 100644 --- a/includes/sanitizers/class-amp-base-sanitizer.php +++ b/includes/sanitizers/class-amp-base-sanitizer.php @@ -55,7 +55,7 @@ abstract class AMP_Base_Sanitizer { * @type bool $allow_dirty_styles * @type bool $allow_dirty_scripts * @type bool $disable_invalid_removal - * @type callable $remove_invalid_callback + * @type callable $validation_error_callback * } */ protected $args; @@ -130,6 +130,7 @@ public function get_scripts() { * Return array of values that would be valid as an HTML `style` attribute. * * @since 0.4 + * @deprecated As of 1.0, use get_stylesheets(). * * @return array[][] Mapping of CSS selectors to arrays of properties. */ diff --git a/includes/sanitizers/class-amp-style-sanitizer.php b/includes/sanitizers/class-amp-style-sanitizer.php index fe07b47e46c..8440c097369 100644 --- a/includes/sanitizers/class-amp-style-sanitizer.php +++ b/includes/sanitizers/class-amp-style-sanitizer.php @@ -5,6 +5,19 @@ * @package AMP */ +use \Sabberworm\CSS\RuleSet\DeclarationBlock; +use \Sabberworm\CSS\CSSList\CSSList; +use \Sabberworm\CSS\Property\Selector; +use \Sabberworm\CSS\RuleSet\RuleSet; +use \Sabberworm\CSS\Rule\Rule; +use \Sabberworm\CSS\Property\AtRule; +use \Sabberworm\CSS\CSSList\KeyFrame; +use \Sabberworm\CSS\RuleSet\AtRuleSet; +use \Sabberworm\CSS\Property\Import; +use \Sabberworm\CSS\CSSList\AtRuleBlockList; +use \Sabberworm\CSS\Value\RuleValueList; +use \Sabberworm\CSS\Value\URL; + /** * Class AMP_Style_Sanitizer * @@ -13,19 +26,38 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { /** - * Styles. + * Array of flags used to control sanitization. * - * List of CSS styles in HTML content of DOMDocument ($this->dom). + * @var array { + * @type string $remove_unused_rules Enum 'never', 'sometimes' (default), 'always'. If total CSS is greater than max_bytes, whether to strip selectors (and then empty rules) when they are not found to be used in doc. A validation error will be emitted when stripping happens since it is not completely safe in the case of dynamic content. + * @type string[] $dynamic_element_selectors Selectors for elements (or their ancestors) which contain dynamic content; selectors containing these will not be filtered. + * @type bool $use_document_element Whether the root of the document should be used rather than the body. + * @type bool $require_https_src Require HTTPS URLs. + * @type bool $allow_dirty_styles Allow dirty styles. This short-circuits the sanitize logic; it is used primarily in Customizer preview. + * @type callable $validation_error_callback Function to call when a validation error is encountered. + * } + */ + protected $args; + + /** + * Default args. * - * @since 0.4 - * @var array[] + * @var array */ - private $styles = array(); + protected $DEFAULT_ARGS = array( + 'remove_unused_rules' => 'sometimes', + 'dynamic_element_selectors' => array( + 'amp-list', + 'amp-live-list', + '[submit-error]', + '[submit-success]', + ), + ); /** * Stylesheets. * - * Values are the CSS stylesheets. Keys are MD5 hashes of the stylesheets + * Values are the CSS stylesheets. Keys are MD5 hashes of the stylesheets, * * @since 0.7 * @var string[] @@ -33,29 +65,27 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { private $stylesheets = array(); /** - * Maximum number of bytes allowed for a keyframes style. + * List of stylesheet parts prior to selector/rule removal (tree shaking). * - * @since 0.7 - * @var int - */ - private $keyframes_max_size; - - /** - * Maximum number of bytes allowed for a AMP Custom style. + * Keys are MD5 hashes of stylesheets. * - * @since 0.7 - * @var int + * @since 1.0 + * @var array[] { + * @type array $stylesheet Array of stylesheet chunked, with declaration blocks being represented as arrays. + * @type DOMElement|DOMAttr $node Origin for styles. + * @type array $sources Sources for the node. + * @type bool $keyframes Whether an amp-keyframes. + * } */ - private $custom_max_size; + private $pending_stylesheets = array(); /** - * Current CSS size. + * Spec for style[amp-custom] cdata. * - * Sum of CSS located in $styles and $stylesheets. - * - * @var int + * @since 1.0 + * @var array */ - private $current_custom_size = 0; + private $style_custom_cdata_spec; /** * The style[amp-custom] element. @@ -64,6 +94,14 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { */ private $amp_custom_style_element; + /** + * Spec for style[amp-keyframes] cdata. + * + * @since 1.0 + * @var array + */ + private $style_keyframes_cdata_spec; + /** * Regex for allowed font stylesheet URL. * @@ -87,6 +125,30 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { */ private $content_url; + /** + * Class names used in document. + * + * @since 1.0 + * @var array + */ + private $used_class_names = array(); + + /** + * XPath. + * + * @since 1.0 + * @var DOMXPath + */ + private $xpath; + + /** + * Amount of time that was spent parsing CSS. + * + * @since 1.0 + * @var float + */ + private $parse_css_duration = 0.0; + /** * AMP_Base_Sanitizer constructor. * @@ -98,19 +160,14 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { public function __construct( DOMDocument $dom, array $args = array() ) { parent::__construct( $dom, $args ); - $spec_name = 'style[amp-keyframes]'; foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'style' ) as $spec_rule ) { - if ( isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) && $spec_name === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { - $this->keyframes_max_size = $spec_rule[ AMP_Rule_Spec::CDATA ]['max_bytes']; - break; + if ( ! isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) ) { + continue; } - } - - $spec_name = 'style amp-custom'; - foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'style' ) as $spec_rule ) { - if ( isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) && $spec_name === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { - $this->custom_max_size = $spec_rule[ AMP_Rule_Spec::CDATA ]['max_bytes']; - break; + if ( 'style[amp-keyframes]' === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { + $this->style_keyframes_cdata_spec = $spec_rule[ AMP_Rule_Spec::CDATA ]; + } elseif ( 'style amp-custom' === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { + $this->style_custom_cdata_spec = $spec_rule[ AMP_Rule_Spec::CDATA ]; } } @@ -128,20 +185,19 @@ public function __construct( DOMDocument $dom, array $args = array() ) { } $this->base_url = $guessurl; $this->content_url = WP_CONTENT_URL; + $this->xpath = new DOMXPath( $dom ); } /** * Get list of CSS styles in HTML content of DOMDocument ($this->dom). * * @since 0.4 + * @deprecated As of 1.0, use get_stylesheets(). * * @return array[] Mapping CSS selectors to array of properties, or mapping of keys starting with 'stylesheet:' with value being the stylesheet. */ public function get_styles() { - if ( ! $this->did_convert_elements ) { - return array(); - } - return $this->styles; + return array(); } /** @@ -151,7 +207,24 @@ public function get_styles() { * @returns array Values are the CSS stylesheets. Keys are MD5 hashes of the stylesheets. */ public function get_stylesheets() { - return array_merge( $this->stylesheets, parent::get_stylesheets() ); + return $this->stylesheets; + } + + /** + * Get list of all the class names used in the document. + * + * @since 1.0 + * @return array Used class names. + */ + private function get_used_class_names() { + if ( empty( $this->used_class_names ) ) { + $classes = ' '; + foreach ( $this->xpath->query( '//*/@class' ) as $class_attribute ) { + $classes .= ' ' . $class_attribute->nodeValue; + } + $this->used_class_names = array_unique( array_filter( preg_split( '/\s+/', trim( $classes ) ) ) ); + } + return $this->used_class_names; } /** @@ -167,11 +240,13 @@ public function sanitize() { return; } + $this->parse_css_duration = 0.0; + /* * Note that xpath is used to query the DOM so that the link and style elements will be * in document order. DOMNode::compareDocumentPosition() is not yet implemented. */ - $xpath = new DOMXPath( $this->dom ); + $xpath = $this->xpath; $lower_case = 'translate( %s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" )'; // In XPath 2.0 this is lower-case(). $predicates = array( @@ -204,83 +279,68 @@ public function sanitize() { foreach ( $elements as $element ) { $this->collect_inline_styles( $element ); } - $this->did_convert_elements = true; - // Now make sure the amp-custom style is in the DOM and populated, if we're working with the document element. - if ( ! empty( $this->args['use_document_element'] ) ) { - if ( ! $this->amp_custom_style_element ) { - $this->amp_custom_style_element = $this->dom->createElement( 'style' ); - $this->amp_custom_style_element->setAttribute( 'amp-custom', '' ); - $head = $this->dom->getElementsByTagName( 'head' )->item( 0 ); - if ( ! $head ) { - $head = $this->dom->createElement( 'head' ); - $this->dom->documentElement->insertBefore( $head, $this->dom->documentElement->firstChild ); - } - $head->appendChild( $this->amp_custom_style_element ); - } + $this->finalize_styles(); - $css = implode( '', $this->get_stylesheets() ); + $this->did_convert_elements = true; - /* - * Let the style[amp-custom] be populated with the concatenated CSS. - * !important: Updating the contents of this style element by setting textContent is not - * reliable across PHP/libxml versions, so this is why the children are removed and the - * text node is then explicitly added containing the CSS. - */ - while ( $this->amp_custom_style_element->firstChild ) { - $this->amp_custom_style_element->removeChild( $this->amp_custom_style_element->firstChild ); - } - $this->amp_custom_style_element->appendChild( $this->dom->createTextNode( $css ) ); + if ( $this->parse_css_duration > 0.0 ) { + AMP_Response_Headers::send_server_timing( 'amp_parse_css', $this->parse_css_duration, 'AMP Parse CSS' ); } } /** - * Generates an enqueued style's fully-qualified file path. + * Generate a URL's fully-qualified file path. * * @since 0.7 * @see WP_Styles::_css_href() * - * @param string $src The source URL of the enqueued style. + * @param string $url The file URL. + * @param string[] $allowed_extensions Allowed file extensions. * @return string|WP_Error Style's absolute validated filesystem path, or WP_Error when error. */ - public function get_validated_css_file_path( $src ) { + public function get_validated_url_file_path( $url, $allowed_extensions = array() ) { $needs_base_url = ( - ! is_bool( $src ) + ! is_bool( $url ) && - ! preg_match( '|^(https?:)?//|', $src ) + ! preg_match( '|^(https?:)?//|', $url ) && - ! ( $this->content_url && 0 === strpos( $src, $this->content_url ) ) + ! ( $this->content_url && 0 === strpos( $url, $this->content_url ) ) ); if ( $needs_base_url ) { - $src = $this->base_url . $src; + $url = $this->base_url . $url; } // Strip query and fragment from URL. - $src = preg_replace( ':[\?#].*$:', '', $src ); - - if ( ! preg_match( '/\.(css|less|scss|sass)$/i', $src ) ) { - /* translators: %s is stylesheet URL */ - return new WP_Error( 'amp_css_bad_file_extension', sprintf( __( 'Skipped stylesheet which does not have recognized CSS file extension (%s).', 'amp' ), $src ) ); + $url = preg_replace( ':[\?#].*$:', '', $url ); + + // Validate file extensions. + if ( ! empty( $allowed_extensions ) ) { + $pattern = sprintf( '/\.(%s)$/i', implode( '|', $allowed_extensions ) ); + if ( ! preg_match( $pattern, $url ) ) { + /* translators: %s is the file URL */ + return new WP_Error( 'amp_disallowed_file_extension', sprintf( __( 'Skipped file which does not have an allowed file extension (%s).', 'amp' ), $url ) ); + } } $includes_url = includes_url( '/' ); $content_url = content_url( '/' ); $admin_url = get_admin_url( null, '/' ); - $css_path = null; - if ( 0 === strpos( $src, $content_url ) ) { - $css_path = WP_CONTENT_DIR . substr( $src, strlen( $content_url ) - 1 ); - } elseif ( 0 === strpos( $src, $includes_url ) ) { - $css_path = ABSPATH . WPINC . substr( $src, strlen( $includes_url ) - 1 ); - } elseif ( 0 === strpos( $src, $admin_url ) ) { - $css_path = ABSPATH . 'wp-admin' . substr( $src, strlen( $admin_url ) - 1 ); + $file_path = null; + if ( 0 === strpos( $url, $content_url ) ) { + $file_path = WP_CONTENT_DIR . substr( $url, strlen( $content_url ) - 1 ); + } elseif ( 0 === strpos( $url, $includes_url ) ) { + $file_path = ABSPATH . WPINC . substr( $url, strlen( $includes_url ) - 1 ); + } elseif ( 0 === strpos( $url, $admin_url ) ) { + $file_path = ABSPATH . 'wp-admin' . substr( $url, strlen( $admin_url ) - 1 ); } - if ( ! $css_path || false !== strpos( '../', $css_path ) || 0 !== validate_file( $css_path ) || ! file_exists( $css_path ) ) { - /* translators: %s is stylesheet URL */ - return new WP_Error( 'amp_css_path_not_found', sprintf( __( 'Unable to locate filesystem path for stylesheet %s.', 'amp' ), $src ) ); + if ( ! $file_path || false !== strpos( '../', $file_path ) || 0 !== validate_file( $file_path ) || ! file_exists( $file_path ) ) { + /* translators: %s is file URL */ + return new WP_Error( 'amp_file_path_not_found', sprintf( __( 'Unable to locate filesystem path for %s.', 'amp' ), $url ) ); } - return $css_path; + return $file_path; } /** @@ -289,30 +349,29 @@ public function get_validated_css_file_path( $src ) { * @param DOMElement $element Style element. */ private function process_style_element( DOMElement $element ) { - if ( $element->hasAttribute( 'amp-keyframes' ) ) { - $validity = $this->validate_amp_keyframe( $element ); - if ( is_wp_error( $validity ) ) { - $this->remove_invalid_child( $element, array( - 'message' => $validity->get_error_message(), - ) ); - } - return; - } - $rules = trim( $element->textContent ); - $rules = $this->remove_illegal_css( $rules, $element ); + // @todo Any @keyframes rules could be removed from amp-custom and instead added to amp-keyframes. + $is_keyframes = $element->hasAttribute( 'amp-keyframes' ); + $stylesheet = trim( $element->textContent ); + $cdata_spec = $is_keyframes ? $this->style_keyframes_cdata_spec : $this->style_custom_cdata_spec; + if ( $stylesheet ) { - // Remove if surpasses max size. - $length = strlen( $rules ); - if ( $this->current_custom_size + $length > $this->custom_max_size ) { - $this->remove_invalid_child( $element, array( - 'message' => __( 'Too much CSS enqueued.', 'amp' ), + $stylesheet = $this->process_stylesheet( $stylesheet, $element, array( + 'allowed_at_rules' => $cdata_spec['css_spec']['allowed_at_rules'], + 'property_whitelist' => $cdata_spec['css_spec']['allowed_declarations'], + 'validate_keyframes' => $cdata_spec['css_spec']['validate_keyframes'], ) ); - return; - } - $this->stylesheets[ md5( $rules ) ] = $rules; - $this->current_custom_size += $length; + $pending_stylesheet = array( + 'keyframes' => $is_keyframes, + 'stylesheet' => $stylesheet, + 'node' => $element, + ); + if ( ! empty( $this->args['validation_error_callback'] ) ) { + $pending_stylesheet['sources'] = AMP_Validation_Utils::locate_sources( $element ); // Needed because node is removed below. + } + $this->pending_stylesheets[] = $pending_stylesheet; + } if ( $element->hasAttribute( 'amp-custom' ) ) { if ( ! $this->amp_custom_style_element ) { @@ -340,7 +399,7 @@ private function process_link_element( DOMElement $element ) { return; } - $css_file_path = $this->get_validated_css_file_path( $href ); + $css_file_path = $this->get_validated_url_file_path( $href, array( 'css', 'less', 'scss', 'sass' ) ); if ( is_wp_error( $css_file_path ) ) { $this->remove_invalid_child( $element, array( 'message' => $css_file_path->get_error_message(), @@ -349,90 +408,643 @@ private function process_link_element( DOMElement $element ) { } // Load the CSS from the filesystem. - $rules = "\n/* $href */\n"; - $rules .= file_get_contents( $css_file_path ); // phpcs:ignore -- It's a local filesystem path not a remote request. - - $rules = $this->remove_illegal_css( $rules, $element ); + $stylesheet = file_get_contents( $css_file_path ); // phpcs:ignore -- It's a local filesystem path not a remote request. + if ( false === $stylesheet ) { + $this->remove_invalid_child( $element, array( + 'message' => __( 'Unable to load stylesheet from filesystem.', 'amp' ), + ) ); + return; + } + // Honor the link's media attribute. $media = $element->getAttribute( 'media' ); if ( $media && 'all' !== $media ) { - $rules = sprintf( '@media %s { %s }', $media, $rules ); + $stylesheet = sprintf( '@media %s { %s }', $media, $stylesheet ); } - // Remove if surpasses max size. - $length = strlen( $rules ); - if ( $this->current_custom_size + $length > $this->custom_max_size ) { - $this->remove_invalid_child( $element, array( - 'message' => __( 'Too much CSS enqueued.', 'amp' ), - ) ); - return; + $stylesheet = $this->process_stylesheet( $stylesheet, $element, array( + 'allowed_at_rules' => $this->style_custom_cdata_spec['css_spec']['allowed_at_rules'], + 'property_whitelist' => $this->style_custom_cdata_spec['css_spec']['allowed_declarations'], + 'stylesheet_url' => $href, + 'stylesheet_path' => $css_file_path, + ) ); + + $pending_stylesheet = array( + 'keyframes' => false, + 'stylesheet' => $stylesheet, + 'node' => $element, + ); + if ( ! empty( $this->args['validation_error_callback'] ) ) { + $pending_stylesheet['sources'] = AMP_Validation_Utils::locate_sources( $element ); // Needed because node is removed below. } - - $this->current_custom_size += $length; - $this->stylesheets[ $href ] = $rules; + $this->pending_stylesheets[] = $pending_stylesheet; // Remove now that styles have been processed. $element->parentNode->removeChild( $element ); } /** - * Remove illegal CSS from the stylesheet. + * Process stylesheet. * - * @since 0.7 + * Sanitized invalid CSS properties and rules, removes rules which do not + * apply to the current document, and compresses the CSS to remove whitespace and comments. + * + * @since 1.0 * - * @todo This needs proper CSS parser and to take an alternative approach to removing !important by extracting - * the rule into a separate style rule with a very specific selector. - * @param string $stylesheet Stylesheet. - * @param DOMElement $element Element where the stylesheet came from. - * @return string Scrubbed stylesheet. + * @param string $stylesheet Stylesheet. + * @param DOMElement|DOMAttr $node Element (link/style) or style attribute where the stylesheet came from. + * @param array $options { + * Options. + * + * @type bool $class_selector_tree_shaking Whether to perform tree shaking to delete rules that reference class names not extant in the current document. + * @type string[] $property_whitelist Exclusively-allowed properties. + * @type string[] $property_blacklist Disallowed properties. + * @type bool $convert_width_to_max_width Convert width to max-width. + * @type string $stylesheet_url Original URL for stylesheet when originating via link (or @import?). + * @type string $stylesheet_path Original filesystem path for stylesheet when originating via link (or @import?). + * @type array $allowed_at_rules Allowed @-rules. + * @type bool $validate_keyframes Whether keyframes should be validated. + * } + * @return array Processed stylesheet parts. */ - private function remove_illegal_css( $stylesheet, $element ) { - $stylesheet = preg_replace( '/\s*!important/', '', $stylesheet, -1, $important_count ); // Note this has to also replace inside comments to be valid. - if ( $important_count > 0 && ! empty( $this->args['validation_error_callback'] ) ) { - call_user_func( $this->args['validation_error_callback'], array( - 'code' => 'css_important_removed', - 'node' => $element, - ) ); + private function process_stylesheet( $stylesheet, $node, $options = array() ) { + $cache_impacting_options = wp_array_slice_assoc( + $options, + array( 'property_whitelist', 'property_blacklist', 'convert_width_to_max_width', 'stylesheet_url', 'allowed_at_rules' ) + ); + + $cache_key = md5( $stylesheet . serialize( $cache_impacting_options ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + + $cache_group = 'amp-parsed-stylesheet-v1'; + if ( wp_using_ext_object_cache() ) { + $parsed = wp_cache_get( $cache_key, $cache_group ); + } else { + $parsed = get_transient( $cache_key . $cache_group ); } - $stylesheet = preg_replace( '/overflow(-[xy])?\s*:\s*(auto|scroll)\s*;?\s*/', '', $stylesheet, -1, $overlow_count ); - if ( $overlow_count > 0 && ! empty( $this->args['validation_error_callback'] ) ) { - call_user_func( $this->args['validation_error_callback'], array( - 'code' => 'css_overflow_property_removed', - 'node' => $element, - ) ); + if ( ! $parsed || ! isset( $parsed['stylesheet'] ) || ! is_array( $parsed['stylesheet'] ) ) { + $parsed = $this->parse_stylesheet( $stylesheet, $options ); + if ( wp_using_ext_object_cache() ) { + wp_cache_set( $cache_key, $parsed, $cache_group ); + } else { + // The expiration is to ensure transient doesn't stick around forever since no LRU flushing like with external object cache. + set_transient( $cache_key . $cache_group, $parsed, MONTH_IN_SECONDS ); + } } - return $stylesheet; + + if ( ! empty( $this->args['validation_error_callback'] ) && ! empty( $parsed['validation_errors'] ) ) { + foreach ( $parsed['validation_errors'] as $validation_error ) { + call_user_func( $this->args['validation_error_callback'], array_merge( $validation_error, compact( 'node' ) ) ); + } + } + + return $parsed['stylesheet']; } /** - * Validate amp-keyframe style. + * Parse stylesheet. * - * @since 0.7 - * @link https://github.com/ampproject/amphtml/blob/b685a0780a7f59313666225478b2b79b463bcd0b/validator/validator-main.protoascii#L1002-L1043 + * @since 1.0 + * + * @param string $stylesheet_string Stylesheet. + * @param array $options Options. See definition in \AMP_Style_Sanitizer::process_stylesheet(). + * @return array { + * Parsed stylesheet. + * + * @type array $stylesheet Stylesheet parts, where arrays are tuples for declaration blocks. + * @type array $validation_errors Validation errors. + * } + */ + private function parse_stylesheet( $stylesheet_string, $options = array() ) { + $start_time = microtime( true ); + + $options = array_merge( + array( + 'allowed_at_rules' => array(), + 'convert_width_to_max_width' => false, + 'property_blacklist' => array( + // See . + 'behavior', + '-moz-binding', + ), + 'property_whitelist' => array(), + 'validate_keyframes' => false, + 'stylesheet_url' => null, + 'stylesheet_path' => null, + ), + $options + ); + + $stylesheet = array(); + $validation_errors = array(); + try { + $parser_settings = Sabberworm\CSS\Settings::create(); + $css_parser = new Sabberworm\CSS\Parser( $stylesheet_string, $parser_settings ); + $css_document = $css_parser->parse(); + + if ( ! empty( $options['stylesheet_url'] ) ) { + $this->real_path_urls( + array_filter( + $css_document->getAllValues(), + function ( $value ) { + return $value instanceof URL; + } + ), + $options['stylesheet_url'] + ); + } + + $validation_errors = $this->process_css_list( $css_document, $options ); + + $output_format = Sabberworm\CSS\OutputFormat::createCompact(); + + $before_declaration_block = '/*AMP_WP_BEFORE_DECLARATION_BLOCK*/'; + $between_selectors = '/*AMP_WP_BETWEEN_SELECTORS*/'; + $after_declaration_block_selectors = '/*AMP_WP_BEFORE_DECLARATION_SELECTORS*/'; + $after_declaration_block = '/*AMP_WP_AFTER_DECLARATION*/'; + + $output_format->set( 'BeforeDeclarationBlock', $before_declaration_block ); + $output_format->set( 'SpaceBeforeSelectorSeparator', $between_selectors ); + $output_format->set( 'AfterDeclarationBlockSelectors', $after_declaration_block_selectors ); + $output_format->set( 'AfterDeclarationBlock', $after_declaration_block ); + + $stylesheet_string = $css_document->render( $output_format ); + + $pattern = '#'; + $pattern .= '(' . preg_quote( $before_declaration_block, '#' ) . ')'; + $pattern .= '(.+?)'; + $pattern .= preg_quote( $after_declaration_block_selectors, '#' ); + $pattern .= '(.+?)'; + $pattern .= preg_quote( $after_declaration_block, '#' ); + $pattern .= '#s'; + + $split_stylesheet = preg_split( $pattern, $stylesheet_string, -1, PREG_SPLIT_DELIM_CAPTURE ); + $length = count( $split_stylesheet ); + for ( $i = 0; $i < $length; $i++ ) { + if ( $before_declaration_block === $split_stylesheet[ $i ] ) { + $selectors = explode( $between_selectors . ',', $split_stylesheet[ ++$i ] ); + $declaration = $split_stylesheet[ ++$i ]; + + $selectors_parsed = array(); + foreach ( $selectors as $selector ) { + $classes = array(); + + // Remove :not() to eliminate false negatives, such as with `body:not(.title-tagline-hidden) .site-branding-text`. + $reduced_selector = preg_replace( '/:not\(.+?\)/', '', $selector ); + + // Remove attribute selectors to eliminate false negative, such as with `.social-navigation a[href*="example.com"]:before`. + $reduced_selector = preg_replace( '/\[\w.*?\]/', '', $reduced_selector ); + + if ( preg_match_all( '/(?<=\.)([a-zA-Z0-9_-]+)/', $reduced_selector, $matches ) ) { + $classes = $matches[0]; + } + $selectors_parsed[ $selector ] = $classes; + } + + $stylesheet[] = array( + $selectors_parsed, + $declaration, + ); + } else { + $stylesheet[] = $split_stylesheet[ $i ]; + } + } + } catch ( Exception $exception ) { + $validation_errors[] = array( + 'code' => 'css_parse_error', + 'message' => $exception->getMessage(), + ); + } + + $this->parse_css_duration += ( microtime( true ) - $start_time ); + + return compact( 'stylesheet', 'validation_errors' ); + } + + /** + * Process CSS list. + * + * @since 1.0 + * + * @param CSSList $css_list CSS List. + * @param array $options Options. + * @return array Validation errors. + */ + private function process_css_list( CSSList $css_list, $options ) { + $validation_errors = array(); + + foreach ( $css_list->getContents() as $css_item ) { + if ( $css_item instanceof DeclarationBlock && empty( $options['validate_keyframes'] ) ) { + $validation_errors = array_merge( + $validation_errors, + $this->process_css_declaration_block( $css_item, $css_list, $options ) + ); + } elseif ( $css_item instanceof AtRuleBlockList ) { + if ( in_array( $css_item->atRuleName(), $options['allowed_at_rules'], true ) ) { + $validation_errors = array_merge( + $validation_errors, + $this->process_css_list( $css_item, $options ) + ); + } else { + $validation_errors[] = array( + 'code' => 'illegal_css_at_rule', + /* translators: %s is the CSS at-rule name. */ + 'message' => sprintf( __( 'CSS @%s rules are currently disallowed.', 'amp' ), $css_item->atRuleName() ), + ); + $css_list->remove( $css_item ); + } + } elseif ( $css_item instanceof Import ) { + $validation_errors[] = array( + 'code' => 'illegal_css_import_rule', + 'message' => __( 'CSS @import is currently disallowed.', 'amp' ), + ); + $css_list->remove( $css_item ); + } elseif ( $css_item instanceof AtRuleSet ) { + if ( in_array( $css_item->atRuleName(), $options['allowed_at_rules'], true ) ) { + $validation_errors = array_merge( + $validation_errors, + $this->process_css_declaration_block( $css_item, $css_list, $options ) + ); + } else { + $validation_errors[] = array( + 'code' => 'illegal_css_at_rule', + /* translators: %s is the CSS at-rule name. */ + 'message' => sprintf( __( 'CSS @%s rules are currently disallowed.', 'amp' ), $css_item->atRuleName() ), + ); + $css_list->remove( $css_item ); + } + } elseif ( $css_item instanceof KeyFrame ) { + if ( in_array( 'keyframes', $options['allowed_at_rules'], true ) ) { + $validation_errors = array_merge( + $validation_errors, + $this->process_css_keyframes( $css_item, $options ) + ); + } else { + $validation_errors[] = array( + 'code' => 'illegal_css_at_rule', + /* translators: %s is the CSS at-rule name. */ + 'message' => sprintf( __( 'CSS @%s rules are currently disallowed.', 'amp' ), $css_item->atRuleName() ), + ); + } + } elseif ( $css_item instanceof AtRule ) { + $validation_errors[] = array( + 'code' => 'illegal_css_at_rule', + /* translators: %s is the CSS at-rule name. */ + 'message' => sprintf( __( 'CSS @%s rules are currently disallowed.', 'amp' ), $css_item->atRuleName() ), + ); + $css_list->remove( $css_item ); + } else { + $validation_errors[] = array( + 'code' => 'unrecognized_css', + 'message' => __( 'Unrecognized CSS removed.', 'amp' ), + ); + $css_list->remove( $css_item ); + } + } + return $validation_errors; + } + + /** + * Convert URLs in to non-relative real-paths. + * + * @param URL[] $urls URLs. + * @param string $stylesheet_url Stylesheet URL. + */ + private function real_path_urls( $urls, $stylesheet_url ) { + $base_url = preg_replace( ':[^/]+(\?.*)?(#.*)?$:', '', $stylesheet_url ); + if ( empty( $base_url ) ) { + return; + } + foreach ( $urls as $url ) { + $url_string = $url->getURL()->getString(); + if ( 'data:' === substr( $url_string, 0, 5 ) ) { + continue; + } + + $parsed_url = wp_parse_url( $url_string ); + if ( ! empty( $parsed_url['host'] ) || empty( $parsed_url['path'] ) || '/' === substr( $parsed_url['path'], 0, 1 ) ) { + continue; + } + + $relative_url = preg_replace( '#^\./#', '', $url->getURL()->getString() ); + + $real_url = $base_url . $relative_url; + do { + $real_url = preg_replace( '#[^/]+/../#', '', $real_url, -1, $count ); + } while ( 0 !== $count ); + + $url->getURL()->setString( $real_url ); + } + } + + /** + * Process CSS rule set. + * + * @since 1.0 + * @link https://www.ampproject.org/docs/design/responsive/style_pages#disallowed-styles + * @link https://www.ampproject.org/docs/design/responsive/style_pages#restricted-styles * - * @param DOMElement $style Style element. - * @return true|WP_Error Validity. + * @param RuleSet $ruleset Ruleset. + * @param CSSList $css_list CSS List. + * @param array $options Options. + * + * @return array Validation errors. */ - private function validate_amp_keyframe( $style ) { - if ( 'body' !== $style->parentNode->nodeName ) { - return new WP_Error( 'mandatory_body_child', __( 'amp-keyframes is not child of body element.', 'amp' ) ); + private function process_css_declaration_block( RuleSet $ruleset, CSSList $css_list, $options ) { + $validation_errors = array(); + + // Remove disallowed properties. + if ( ! empty( $options['property_whitelist'] ) ) { + $properties = $ruleset->getRules(); + foreach ( $properties as $property ) { + $vendorless_property_name = preg_replace( '/^-\w+-/', '', $property->getRule() ); + if ( ! in_array( $vendorless_property_name, $options['property_whitelist'], true ) ) { + $validation_errors[] = array( + 'code' => 'illegal_css_property', + 'property_name' => $property->getRule(), + 'property_value' => $property->getValue(), + ); + $ruleset->removeRule( $property->getRule() ); + } + } + } else { + foreach ( $options['property_blacklist'] as $illegal_property_name ) { + $properties = $ruleset->getRules( $illegal_property_name ); + foreach ( $properties as $property ) { + $validation_errors[] = array( + 'code' => 'illegal_css_property', + 'property_name' => $property->getRule(), + 'property_value' => $property->getValue(), + ); + $ruleset->removeRule( $property->getRule() ); + } + } } - if ( $this->keyframes_max_size && strlen( $style->textContent ) > $this->keyframes_max_size ) { - return new WP_Error( 'max_bytes', __( 'amp-keyframes is too large', 'amp' ) ); + if ( $ruleset instanceof AtRuleSet && 'font-face' === $ruleset->atRuleName() ) { + $this->process_font_face_at_rule( $ruleset, $options ); } - // This logic could be in AMP_Tag_And_Attribute_Sanitizer, but since it only applies to amp-keyframes it seems unnecessary. - $next_sibling = $style->nextSibling; - while ( $next_sibling ) { - if ( $next_sibling instanceof DOMElement ) { - return new WP_Error( 'mandatory_last_child', __( 'amp-keyframes is not last element in body.', 'amp' ) ); + $validation_errors = array_merge( + $validation_errors, + $this->transform_important_qualifiers( $ruleset, $css_list ) + ); + + // Convert width to max-width when requested. See . + if ( $options['convert_width_to_max_width'] ) { + $properties = $ruleset->getRules( 'width' ); + foreach ( $properties as $property ) { + $width_property = new Rule( 'max-width' ); + $width_property->setValue( $property->getValue() ); + $ruleset->removeRule( $property ); + $ruleset->addRule( $width_property, $property ); } - $next_sibling = $next_sibling->nextSibling; } - // @todo Also add validation of the CSS spec itself. - return true; + // Remove the ruleset if it is now empty. + if ( 0 === count( $ruleset->getRules() ) ) { + $css_list->remove( $ruleset ); + } + // @todo Delete rules with selectors for -amphtml- class and i-amphtml- tags. + return $validation_errors; + } + + /** + * Process @font-face by making src URLs non-relative and converting data: URLs into (assumed) file URLs. + * + * @since 1.0 + * + * @param AtRuleSet $ruleset Ruleset for @font-face. + * @param array $options Options. + */ + private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) { + $src_properties = $ruleset->getRules( 'src' ); + if ( empty( $src_properties ) ) { + return; + } + + foreach ( $src_properties as $src_property ) { + $value = $src_property->getValue(); + if ( ! ( $value instanceof RuleValueList ) ) { + continue; + } + + /* + * The CSS Parser parses a src such as: + * + * url(data:application/font-woff;...) format('woff'), + * url('Genericons.ttf') format('truetype'), + * url('Genericons.svg#genericonsregular') format('svg') + * + * As a list of components consisting of: + * + * URL, + * RuleValueList( CSSFunction, URL ), + * RuleValueList( CSSFunction, URL ), + * CSSFunction + * + * Clearly the components here are not logically grouped. So the first step is to fix the order. + */ + $sources = array(); + foreach ( $value->getListComponents() as $component ) { + if ( $component instanceof RuleValueList ) { + $subcomponents = $component->getListComponents(); + $subcomponent = array_shift( $subcomponents ); + if ( $subcomponent ) { + if ( empty( $sources ) ) { + $sources[] = array( $subcomponent ); + } else { + $sources[ count( $sources ) - 1 ][] = $subcomponent; + } + } + foreach ( $subcomponents as $subcomponent ) { + $sources[] = array( $subcomponent ); + } + } else { + if ( empty( $sources ) ) { + $sources[] = array( $component ); + } else { + $sources[ count( $sources ) - 1 ][] = $component; + } + } + } + + /** + * Source URL lists. + * + * @var URL[] $source_file_urls + * @var URL[] $source_data_urls + */ + $source_file_urls = array(); + $source_data_urls = array(); + foreach ( $sources as $i => $source ) { + if ( $source[0] instanceof URL ) { + if ( 'data:' === substr( $source[0]->getURL()->getString(), 0, 5 ) ) { + $source_data_urls[ $i ] = $source[0]; + } else { + $source_file_urls[ $i ] = $source[0]; + } + } + } + + // Convert data: URLs into regular URLs, assuming there will be a file present (e.g. woff fonts in core themes). + if ( empty( $source_file_urls ) ) { + continue; + } + $source_file_url = current( $source_file_urls ); + foreach ( $source_data_urls as $i => $data_url ) { + $mime_type = strtok( substr( $data_url->getURL()->getString(), 5 ), ';' ); + if ( ! $mime_type ) { + continue; + } + $extension = preg_replace( ':.+/(.+-)?:', '', $mime_type ); + $guessed_url = preg_replace( + ':(?<=\.)\w+(\?.*)?(#.*)?$:', // Match the file extension in the URL. + $extension, + $source_file_url->getURL()->getString(), + 1, + $count + ); + if ( 1 !== $count ) { + continue; + } + + // Ensure file exists. + $path = $this->get_validated_url_file_path( $guessed_url ); + if ( is_wp_error( $path ) ) { + continue; + } + + $data_url->getURL()->setString( $guessed_url ); + break; + } + } + } + + /** + * Process CSS keyframes. + * + * @since 1.0 + * @link https://www.ampproject.org/docs/design/responsive/style_pages#restricted-styles. + * @link https://github.com/ampproject/amphtml/blob/b685a0780a7f59313666225478b2b79b463bcd0b/validator/validator-main.protoascii#L1002-L1043 + * @todo Tree shaking could be extended to keyframes, to omit a keyframe if it is not referenced by any rule. + * + * @param KeyFrame $css_list Ruleset. + * @param array $options Options. + * @return array Validation errors. + */ + private function process_css_keyframes( KeyFrame $css_list, $options ) { + $validation_errors = array(); + if ( ! empty( $options['property_whitelist'] ) ) { + foreach ( $css_list->getContents() as $rules ) { + if ( ! ( $rules instanceof DeclarationBlock ) ) { + $validation_errors[] = array( + 'code' => 'unrecognized_css', + 'message' => __( 'Unrecognized CSS removed.', 'amp' ), + ); + $css_list->remove( $rules ); + continue; + } + + $validation_errors = array_merge( + $validation_errors, + $this->transform_important_qualifiers( $rules, $css_list ) + ); + + $properties = $rules->getRules(); + foreach ( $properties as $property ) { + $vendorless_property_name = preg_replace( '/^-\w+-/', '', $property->getRule() ); + if ( ! in_array( $vendorless_property_name, $options['property_whitelist'], true ) ) { + $validation_errors[] = array( + 'code' => 'illegal_css_property', + 'property_name' => $property->getRule(), + 'property_value' => $property->getValue(), + ); + $rules->removeRule( $property->getRule() ); + } + } + } + } + return $validation_errors; + } + + /** + * Replace !important qualifiers with more specific rules. + * + * @since 1.0 + * @see https://www.npmjs.com/package/replace-important + * @see https://www.ampproject.org/docs/fundamentals/spec#important + * + * @param RuleSet|DeclarationBlock $ruleset Rule set. + * @param CSSList $css_list CSS List. + * @return array Validation errors. + */ + private function transform_important_qualifiers( RuleSet $ruleset, CSSList $css_list ) { + $validation_errors = array(); + $allow_transformation = ( + $ruleset instanceof DeclarationBlock + && + ! ( $css_list instanceof KeyFrame ) + ); + + $properties = $ruleset->getRules(); + $importants = array(); + foreach ( $properties as $property ) { + if ( $property->getIsImportant() ) { + $property->setIsImportant( false ); + + // An !important doesn't make sense for rulesets that don't have selectors. + if ( $allow_transformation ) { + $importants[] = $property; + $ruleset->removeRule( $property->getRule() ); + } else { + $validation_errors[] = array( + 'code' => 'illegal_css_important', + 'message' => __( 'Illegal CSS !important qualifier.', 'amp' ), + ); + } + } + } + if ( ! $allow_transformation || empty( $importants ) ) { + return $validation_errors; + } + + $important_ruleset = clone $ruleset; + $important_ruleset->setSelectors( array_map( + /** + * Modify selectors to be more specific to roughly match the effect of !important. + * + * @link https://github.com/ampproject/ampstart/blob/4c21d69afdd07b4c60cd190937bda09901955829/tools/replace-important/lib/index.js#L88-L109 + * + * @param Selector $old_selector Original selector. + * @return Selector The new more-specific selector. + */ + function( Selector $old_selector ) { + $specific = ':not(#_)'; // Here "_" is just a short single-char ID. + + $selector_mod = str_repeat( $specific, floor( $old_selector->getSpecificity() / 100 ) ); + if ( $old_selector->getSpecificity() % 100 > 0 ) { + $selector_mod .= $specific; + } + if ( $old_selector->getSpecificity() % 10 > 0 ) { + $selector_mod .= $specific; + } + + $new_selector = $old_selector->getSelector(); + + // Amend the selector mod to the first element in selector if it is already the root; otherwise add new root ancestor. + if ( preg_match( '/^\s*(html|:root)\b/i', $new_selector, $matches ) ) { + $new_selector = substr( $new_selector, 0, strlen( $matches[0] ) ) . $selector_mod . substr( $new_selector, strlen( $matches[0] ) ); + } else { + $new_selector = sprintf( ':root%s %s', $selector_mod, $new_selector ); + } + return new Selector( $new_selector ); + }, + $ruleset->getSelectors() + ) ); + $important_ruleset->setRules( $importants ); + $css_list->append( $important_ruleset ); // @todo It would be preferable if the important ruleset were inserted adjacent to the original rule. + + return $validation_errors; } /** @@ -450,129 +1062,246 @@ private function validate_amp_keyframe( $style ) { * @param DOMElement $element Node. */ private function collect_inline_styles( $element ) { - $value = $element->getAttribute( 'style' ); - if ( ! $value ) { + $style_attribute = $element->getAttributeNode( 'style' ); + if ( ! $style_attribute || ! trim( $style_attribute->nodeValue ) ) { return; } - $class = $element->getAttribute( 'class' ); - $properties = $this->process_style( $value ); + $class = 'amp-wp-' . substr( md5( $style_attribute->nodeValue ), 0, 7 ); + $root = ':root' . str_repeat( ':not(#_)', 5 ); // @todo The correctness of using "5" should be validated. + $rule = sprintf( '%s .%s { %s }', $root, $class, $style_attribute->nodeValue ); - if ( ! empty( $properties ) ) { - $class_name = $this->generate_class_name( $properties ); - $new_class = trim( $class . ' ' . $class_name ); + $stylesheet = $this->process_stylesheet( $rule, $style_attribute, array( + 'convert_width_to_max_width' => true, + 'allowed_at_rules' => array(), + 'property_whitelist' => $this->style_custom_cdata_spec['css_spec']['allowed_declarations'], + ) ); - $selector = '.' . $class_name; - $length = strlen( sprintf( '%s { %s }', $selector, join( '; ', $properties ) . ';' ) ); - - if ( $this->current_custom_size + $length > $this->custom_max_size ) { - $this->remove_invalid_attribute( $element, 'style', array( - 'message' => __( 'Too much CSS.', 'amp' ), - ) ); - return; - } + if ( empty( $stylesheet ) ) { + $element->removeAttribute( 'style' ); + return; + } - $element->setAttribute( 'class', $new_class ); - $this->styles[ $selector ] = $properties; + $pending_stylesheet = array( + 'stylesheet' => $stylesheet, + 'node' => $element, + 'keyframes' => false, + ); + if ( ! empty( $this->args['validation_error_callback'] ) ) { + $pending_stylesheet['sources'] = AMP_Validation_Utils::locate_sources( $element ); // Needed because node is removed below. } + + $this->pending_stylesheets[] = $pending_stylesheet; + $element->removeAttribute( 'style' ); + if ( $element->hasAttribute( 'class' ) ) { + $element->setAttribute( 'class', $element->getAttribute( 'class' ) . ' ' . $class ); + } else { + $element->setAttribute( 'class', $class ); + } } /** - * Sanitize and convert individual styles. + * Finalize stylesheets for style[amp-custom] and style[amp-keyframes] elements. * - * @since 0.4 + * Concatenate all pending stylesheets, remove unused rules if necessary, and add to style elements in doc. + * Combine all amp-keyframe styles and add them to the end of the body. * - * @param string $css Style string. - * @return array Style properties. + * @since 1.0 + * @see https://www.ampproject.org/docs/fundamentals/spec#keyframes-stylesheet */ - private function process_style( $css ) { + private function finalize_styles() { + + $stylesheet_sets = array( + 'custom' => array( + 'total_size' => 0, + 'cdata_spec' => $this->style_custom_cdata_spec, + 'pending_stylesheets' => array(), + 'final_stylesheets' => array(), + 'remove_unused_rules' => $this->args['remove_unused_rules'], + ), + 'keyframes' => array( + 'total_size' => 0, + 'cdata_spec' => $this->style_keyframes_cdata_spec, + 'pending_stylesheets' => array(), + 'final_stylesheets' => array(), + 'remove_unused_rules' => 'never', // Not relevant. + ), + ); - // Normalize whitespace. - $css = str_replace( array( "\n", "\r", "\t" ), '', $css ); + // Divide pending stylesheet between custom and keyframes, and calculate size of each. + while ( ! empty( $this->pending_stylesheets ) ) { + $pending_stylesheet = array_shift( $this->pending_stylesheets ); + + $set_name = ! empty( $pending_stylesheet['keyframes'] ) ? 'keyframes' : 'custom'; + $size = 0; + foreach ( $pending_stylesheet['stylesheet'] as $part ) { + if ( is_string( $part ) ) { + $size += strlen( $part ); + } elseif ( is_array( $part ) ) { + $size += strlen( implode( ',', array_keys( $part[0] ) ) ); // Selectors. + $size += strlen( $part[1] ); // Declaration block. + } + } + $stylesheet_sets[ $set_name ]['total_size'] += $size; + $stylesheet_sets[ $set_name ]['pending_stylesheets'][] = $pending_stylesheet; + } - /* - * Use preg_split to break up rules by `;` but only if the - * semi-colon is not inside parens (like a data-encoded image). - */ - $styles = preg_split( '/\s*;\s*(?![^(]*\))/', trim( $css, '; ' ) ); - $styles = array_filter( $styles ); + // Process the pending stylesheets. + foreach ( array_keys( $stylesheet_sets ) as $set_name ) { + $stylesheet_sets[ $set_name ] = $this->finalize_stylesheet_set( $stylesheet_sets[ $set_name ] ); + } - // Normalize the order of the styles. - sort( $styles ); + $this->stylesheets = $stylesheet_sets['custom']['final_stylesheets']; - $processed_styles = array(); + // If we're not working with the document element (e.g. for legacy post templates) then there is nothing left to do. + if ( empty( $this->args['use_document_element'] ) ) { + return; + } - // Normalize whitespace and filter rules. - foreach ( $styles as $index => $rule ) { - $tuple = preg_split( '/\s*:\s*/', $rule, 2 ); - if ( 2 !== count( $tuple ) ) { - continue; - } + // Add style[amp-custom] to document. + if ( ! empty( $stylesheet_sets['custom']['final_stylesheets'] ) ) { - list( $property, $value ) = $this->filter_style( $tuple[0], $tuple[1] ); - if ( empty( $property ) || empty( $value ) ) { - continue; + // Ensure style[amp-custom] is present in the document. + if ( ! $this->amp_custom_style_element ) { + $this->amp_custom_style_element = $this->dom->createElement( 'style' ); + $this->amp_custom_style_element->setAttribute( 'amp-custom', '' ); + $head = $this->dom->getElementsByTagName( 'head' )->item( 0 ); + if ( ! $head ) { + $head = $this->dom->createElement( 'head' ); + $this->dom->documentElement->insertBefore( $head, $this->dom->documentElement->firstChild ); + } + $head->appendChild( $this->amp_custom_style_element ); } - $processed_styles[ $index ] = "{$property}:{$value}"; + $css = implode( '', $stylesheet_sets['custom']['final_stylesheets'] ); + + /* + * Let the style[amp-custom] be populated with the concatenated CSS. + * !important: Updating the contents of this style element by setting textContent is not + * reliable across PHP/libxml versions, so this is why the children are removed and the + * text node is then explicitly added containing the CSS. + */ + while ( $this->amp_custom_style_element->firstChild ) { + $this->amp_custom_style_element->removeChild( $this->amp_custom_style_element->firstChild ); + } + $this->amp_custom_style_element->appendChild( $this->dom->createTextNode( $css ) ); } - return $processed_styles; + // Add style[amp-keyframes] to document. + if ( ! empty( $stylesheet_sets['keyframes']['final_stylesheets'] ) ) { + $body = $this->dom->getElementsByTagName( 'body' )->item( 0 ); + if ( ! $body ) { + if ( ! empty( $this->args['validation_error_callback'] ) ) { + call_user_func( $this->args['validation_error_callback'], array( + 'code' => 'missing_body_element', + 'message' => __( 'amp-keyframes must be last child of body element.', 'amp' ), + ) ); + } + } else { + $style_element = $this->dom->createElement( 'style' ); + $style_element->setAttribute( 'amp-keyframes', '' ); + $style_element->appendChild( $this->dom->createTextNode( implode( '', $stylesheet_sets['keyframes']['final_stylesheets'] ) ) ); + $body->appendChild( $style_element ); + } + } } /** - * Filter individual CSS name/value pairs. + * Finalize a stylesheet set (amp-custom or amp-keyframes). * - * - Remove overflow if value is `auto` or `scroll` - * - Change `width` to `max-width` - * - Remove !important + * @since 1.0 * - * @since 0.4 - * - * @param string $property Property. - * @param string $value Value. - * @return array + * @param array $stylesheet_set Stylesheet set. + * @return array Finalized stylesheet set. */ - private function filter_style( $property, $value ) { - /* - * Remove overflow if value is `auto` or `scroll`; not allowed in AMP - * - * @todo This removal needs to be reported. - * @see https://www.ampproject.org/docs/reference/spec.html#properties - */ - if ( preg_match( '#^overflow(-[xy])?$#i', $property ) && preg_match( '#^(auto|scroll)$#i', $value ) ) { - return array( false, false ); - } + private function finalize_stylesheet_set( $stylesheet_set ) { + $is_too_much_css = $stylesheet_set['total_size'] > $stylesheet_set['cdata_spec']['max_bytes']; + $should_tree_shake = ( + 'always' === $stylesheet_set['remove_unused_rules'] || ( + $is_too_much_css + && + 'sometimes' === $stylesheet_set['remove_unused_rules'] + ) + ); - if ( 'width' === $property ) { - $property = 'max-width'; + if ( $is_too_much_css && $should_tree_shake && ! empty( $this->args['validation_error_callback'] ) ) { + call_user_func( $this->args['validation_error_callback'], array( + 'code' => 'removed_unused_css_rules', + 'message' => __( 'Too much CSS is enqueued and so seemingly irrelevant rules have been removed.', 'amp' ), + ) ); } - /* - * Remove `!important`; not allowed in AMP - * - * @todo This removal needs to be reported. - */ - if ( false !== strpos( $value, 'important' ) ) { - $value = preg_replace( '/\s*\!\s*important$/', '', $value ); + $dynamic_selector_pattern = null; + if ( $should_tree_shake && ! empty( $this->args['dynamic_element_selectors'] ) ) { + $dynamic_selector_pattern = '#' . implode( '|', array_map( + function( $selector ) { + return preg_quote( $selector, '#' ); + }, + $this->args['dynamic_element_selectors'] + ) ) . '#'; } - return array( $property, $value ); - } + $final_size = 0; + foreach ( $stylesheet_set['pending_stylesheets'] as $pending_stylesheet ) { + $stylesheet = ''; + foreach ( $pending_stylesheet['stylesheet'] as $stylesheet_part ) { + if ( is_string( $stylesheet_part ) ) { + $stylesheet .= $stylesheet_part; + } else { + list( $selectors_parsed, $declaration_block ) = $stylesheet_part; + if ( $should_tree_shake ) { + $selectors = array(); + foreach ( $selectors_parsed as $selector => $class_names ) { + $should_include = ( + ( $dynamic_selector_pattern && preg_match( $dynamic_selector_pattern, $selector ) ) + || + // If all class names are used in the doc. + 0 === count( array_diff( $class_names, $this->get_used_class_names() ) ) + ); + if ( $should_include ) { + $selectors[] = $selector; + } + } + } else { + $selectors = array_keys( $selectors_parsed ); + } + if ( ! empty( $selectors ) ) { + $stylesheet .= implode( ',', $selectors ) . $declaration_block; + } + } + } - /** - * Generate a unique class name - * - * Use the md5() of the $data parameter - * - * @since 0.4 - * - * @param string $data Data. - * @return string Class name. - */ - private function generate_class_name( $data ) { - $string = maybe_serialize( $data ); - return 'amp-wp-inline-' . md5( $string ); + // Skip considering stylesheet if an identical one has already been processed. + $hash = md5( $stylesheet ); + if ( isset( $stylesheet_set['final_stylesheets'][ $hash ] ) ) { + continue; + } + + // Report validation error if size is now too big. + $sheet_size = strlen( $stylesheet ); + if ( $final_size + $sheet_size > $stylesheet_set['cdata_spec']['max_bytes'] ) { + if ( ! empty( $this->args['validation_error_callback'] ) ) { + $validation_error = array( + 'code' => 'excessive_css', + 'message' => sprintf( + /* translators: %d is the number of bytes over the limit */ + __( 'Too much CSS output (by %d bytes).', 'amp' ), + ( $final_size + $sheet_size ) - $stylesheet_set['cdata_spec']['max_bytes'] + ), + ); + if ( isset( $pending_stylesheet['sources'] ) ) { + $validation_error['sources'] = $pending_stylesheet['sources']; + } + call_user_func( $this->args['validation_error_callback'], $validation_error ); + } + } else { + $final_size += $sheet_size; + + $stylesheet_set['final_stylesheets'][ $hash ] = $stylesheet; + } + } + + return $stylesheet_set; } } diff --git a/includes/templates/class-amp-content-sanitizer.php b/includes/templates/class-amp-content-sanitizer.php index d7b75c28ec3..d9206b427f3 100644 --- a/includes/templates/class-amp-content-sanitizer.php +++ b/includes/templates/class-amp-content-sanitizer.php @@ -17,6 +17,7 @@ class AMP_Content_Sanitizer { * * @since 0.4.1 * @since 0.7 Passing return_styles=false in $global_args causes stylesheets to be returned instead of styles. + * @deprecated Since 1.0 * * @param string $content HTML content string or DOM document. * @param string[] $sanitizer_classes Sanitizer classes. @@ -63,6 +64,7 @@ public static function sanitize_document( &$dom, $sanitizer_classes, $args ) { $return_styles = ! empty( $args['return_styles'] ); unset( $args['return_styles'] ); foreach ( $sanitizer_classes as $sanitizer_class => $sanitizer_args ) { + $sanitize_class_start = microtime( true ); if ( ! class_exists( $sanitizer_class ) ) { /* translators: %s is sanitizer class */ _doing_it_wrong( __METHOD__, sprintf( esc_html__( 'Sanitizer (%s) class does not exist', 'amp' ), esc_html( $sanitizer_class ) ), '0.4.1' ); @@ -90,6 +92,8 @@ public static function sanitize_document( &$dom, $sanitizer_classes, $args ) { } else { $stylesheets = array_merge( $stylesheets, $sanitizer->get_stylesheets() ); } + + AMP_Response_Headers::send_server_timing( 'amp_sanitize', -$sanitize_class_start, $sanitizer_class ); } return compact( 'scripts', 'styles', 'stylesheets' ); diff --git a/includes/templates/class-amp-content.php b/includes/templates/class-amp-content.php index 06897b8edf1..2dc7890d0fe 100644 --- a/includes/templates/class-amp-content.php +++ b/includes/templates/class-amp-content.php @@ -34,10 +34,19 @@ class AMP_Content { /** * AMP styles. * + * @deprecated * @var array */ private $amp_styles = array(); + /** + * AMP stylesheets. + * + * @since 1.0 + * @var array + */ + private $amp_stylesheets = array(); + /** * Args. * @@ -97,10 +106,22 @@ public function get_amp_scripts() { /** * Get AMP styles. * - * @return array + * @deprecated Since 1.0 in favor of the get_amp_stylesheets method. + * @return array Empty list. */ public function get_amp_styles() { - return $this->amp_styles; + _deprecated_function( __METHOD__, '1.0', __CLASS__ . '::get_amp_stylesheets' ); + return array(); + } + + /** + * Get AMP styles. + * + * @since 1.0 + * @return array + */ + public function get_amp_stylesheets() { + return $this->amp_stylesheets; } /** @@ -130,12 +151,13 @@ private function add_scripts( $scripts ) { } /** - * Add styles. + * Add stylesheets. * - * @param array $styles Styles. + * @since 1.0 + * @param array $stylesheets Styles. */ - private function add_styles( $styles ) { - $this->amp_styles = array_merge( $this->amp_styles, $styles ); + private function add_stylesheets( $stylesheets ) { + $this->amp_stylesheets = array_merge( $this->amp_stylesheets, $stylesheets ); } /** @@ -179,14 +201,16 @@ private function unregister_embed_handlers( $embed_handlers ) { * * @see AMP_Content_Sanitizer::sanitize() * @param string $content Content. - * @return array Sanitized content. + * @return string Sanitized content. */ private function sanitize( $content ) { - list( $sanitized_content, $scripts, $styles ) = AMP_Content_Sanitizer::sanitize( $content, $this->sanitizer_classes, $this->args ); + $dom = AMP_DOM_Utils::get_dom_from_content( $content ); + + $results = AMP_Content_Sanitizer::sanitize_document( $dom, $this->sanitizer_classes, $this->args ); - $this->add_scripts( $scripts ); - $this->add_styles( $styles ); + $this->add_scripts( $results['scripts'] ); + $this->add_stylesheets( $results['stylesheets'] ); - return $sanitized_content; + return AMP_DOM_Utils::get_content_from_dom( $dom ); } } diff --git a/includes/templates/class-amp-post-template.php b/includes/templates/class-amp-post-template.php index a917e7aaf10..d0d142d3d36 100644 --- a/includes/templates/class-amp-post-template.php +++ b/includes/templates/class-amp-post-template.php @@ -129,7 +129,8 @@ public function __construct( $post ) { 'merriweather' => 'https://fonts.googleapis.com/css?family=Merriweather:400,400italic,700,700italic', ), - 'post_amp_styles' => array(), + 'post_amp_stylesheets' => array(), + 'post_amp_styles' => array(), // Deprecated. 'amp_analytics' => amp_add_custom_analytics(), ); @@ -319,7 +320,7 @@ private function build_post_content() { $this->add_data_by_key( 'post_amp_content', $amp_content->get_amp_content() ); $this->merge_data_for_key( 'amp_component_scripts', $amp_content->get_amp_scripts() ); - $this->merge_data_for_key( 'post_amp_styles', $amp_content->get_amp_styles() ); + $this->add_data_by_key( 'post_amp_stylesheets', $amp_content->get_amp_stylesheets() ); } /** @@ -347,14 +348,17 @@ private function build_post_featured_image() { $featured_image = get_post( $featured_id ); - list( $sanitized_html, $featured_scripts, $featured_styles ) = AMP_Content_Sanitizer::sanitize( - $featured_html, + $dom = AMP_DOM_Utils::get_dom_from_content( $featured_html ); + $assets = AMP_Content_Sanitizer::sanitize_document( + $dom, array( 'AMP_Img_Sanitizer' => array() ), array( 'content_max_width' => $this->get( 'content_max_width' ), ) ); + $sanitized_html = AMP_DOM_Utils::get_content_from_dom( $dom ); + $this->add_data_by_key( 'featured_image', array( 'amp_html' => $sanitized_html, @@ -362,12 +366,12 @@ private function build_post_featured_image() { ) ); - if ( $featured_scripts ) { - $this->merge_data_for_key( 'amp_component_scripts', $featured_scripts ); + if ( $assets['scripts'] ) { + $this->merge_data_for_key( 'amp_component_scripts', $assets['scripts'] ); } - if ( $featured_styles ) { - $this->merge_data_for_key( 'post_amp_styles', $featured_styles ); + if ( $assets['stylesheets'] ) { + $this->merge_data_for_key( 'post_amp_stylesheets', $assets['stylesheets'] ); } } diff --git a/includes/utils/class-amp-validation-utils.php b/includes/utils/class-amp-validation-utils.php index eff7c4cef3e..9982f4f3839 100644 --- a/includes/utils/class-amp-validation-utils.php +++ b/includes/utils/class-amp-validation-utils.php @@ -411,7 +411,9 @@ public static function add_validation_error( array $data ) { $node = $data['node']; unset( $data['node'] ); $data['node_name'] = $node->nodeName; - $data['sources'] = self::locate_sources( $node ); + if ( ! isset( $data['sources'] ) ) { + $data['sources'] = self::locate_sources( $node ); + } if ( $node->parentNode ) { $data['parent_name'] = $node->parentNode->nodeName; } diff --git a/readme.md b/readme.md index 161df3f30df..881a222ee0a 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ Enable Accelerated Mobile Pages (AMP) on your WordPress site. **Tested up to:** 4.9 **Stable tag:** 0.6.0 **License:** [GPLv2 or later](http://www.gnu.org/licenses/gpl-2.0.html) -**Requires PHP:** 5.3 +**Requires PHP:** 5.3.2 [![Build Status](https://travis-ci.org/Automattic/amp-wp.svg?branch=master)](https://travis-ci.org/Automattic/amp-wp) [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com) diff --git a/readme.txt b/readme.txt index e741eb05b17..4b41f6f5a2d 100644 --- a/readme.txt +++ b/readme.txt @@ -6,7 +6,7 @@ Tested up to: 4.9 Stable tag: 0.6.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html -Requires PHP: 5.3 +Requires PHP: 5.3.2 Enable Accelerated Mobile Pages (AMP) on your WordPress site. diff --git a/tests/test-amp-frontend-actions.php b/tests/test-amp-frontend-actions.php index e2dd7d32241..31809e82e65 100644 --- a/tests/test-amp-frontend-actions.php +++ b/tests/test-amp-frontend-actions.php @@ -15,6 +15,7 @@ class Test_AMP_Frontend_Actions extends WP_UnitTestCase { */ public function setUp() { parent::setUp(); + require_once AMP__DIR__ . '/includes/amp-helper-functions.php'; amp_add_frontend_actions(); } diff --git a/tests/test-amp-style-sanitizer.php b/tests/test-amp-style-sanitizer.php index 7c708b42d29..5c7cd0a1afe 100644 --- a/tests/test-amp-style-sanitizer.php +++ b/tests/test-amp-style-sanitizer.php @@ -27,97 +27,133 @@ public function get_body_style_attribute_data() { 'span_one_style' => array( 'This is green.', - 'This is green.', + 'This is green.', array( - '.amp-wp-inline-ad0e57ab02197f7023aa5b93bcf6c97e { color:#00ff00; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-bb01159{color:#0f0;}', ), ), 'span_one_style_bad_format' => array( 'This is green.', - 'This is green.', + 'This is green.', array( - '.amp-wp-inline-ad0e57ab02197f7023aa5b93bcf6c97e { color:#00ff00; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-0837823{color:#0f0;}', ), ), 'span_two_styles_reversed' => array( 'This is green.', - 'This is green.', + 'This is green.', array( - '.amp-wp-inline-58550689c128f3d396444313296e4c47 { background-color:#000; color:#00ff00; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-c71affe{color:#0f0;background-color:#000;}', ), ), 'width_to_max-width' => array( '
', - '
', + '
', array( - '.amp-wp-inline-2676cd1bfa7e8feb4f0e0e8086ae9ce4 { max-width:300px; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-343bce0{max-width:300px;}', ), ), 'span_display_none' => array( 'Kses-banned properties are allowed since Kses will have already applied if user does not have unfiltered_html.', - 'Kses-banned properties are allowed since Kses will have already applied if user does not have unfiltered_html.', + 'Kses-banned properties are allowed since Kses will have already applied if user does not have unfiltered_html.', array( - '.amp-wp-inline-0f1bf07c72fdf1784fff2e164d9dca98 { display:none; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-224b51a{display:none;}', ), ), - 'div_amp_banned_style' => array( - 'Scrollbars not allowed.', - 'Scrollbars not allowed.', - array(), - ), - - '!important_not_allowed' => array( - '!important not allowed.', - '!important not allowed.', + '!important_is_ok' => array( + '!important is converted.', + '!important is converted.', array( - '.amp-wp-inline-b370df7c42957a3192cac40a8ddcff79 { margin:1px; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-6a75598{padding:1px;outline:3px;}:root:not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-6a75598{margin:2px;}', ), ), - '!important_with_spaces_not_allowed' => array( - '!important not allowed.', - '!important not allowed.', + '!important_with_spaces_also_converted' => array( + '!important is converted.', + '!important is converted.', array( - '.amp-wp-inline-5b88d03e432f20476a218314084d3a05 { color:red; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-952600b{color:red;}', ), ), - '!important_multiple_not_allowed' => array( - '!important not allowed.', - '!important not allowed.', + '!important_multiple_is_converted' => array( + '!important is converted.', + '!important is converted.', array( - '.amp-wp-inline-ef4329d562b6b3486a8a661df5c5280f { background:blue; color:red; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-1e2bfaa{color:red;background:blue;}', ), ), 'two_nodes' => array( 'This is red.', - 'This is red.', + 'This is red.', array( - '.amp-wp-inline-ad0e57ab02197f7023aa5b93bcf6c97e { color:#00ff00; }', - '.amp-wp-inline-f146f9bb819d875bbe5cf83e36368b44 { color:#ff0000; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-bb01159{color:#0f0;}', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-cc68ddc{color:#f00;}', ), ), 'existing_class_attribute' => array( '
', - '
', + '
', array( - '.amp-wp-inline-3be9b2f79873ad78941ba2b3c03025a3 { background:#000; }', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-2864855{background:#000;}', ), - ), 'inline_style_element_with_multiple_rules_containing_selectors_is_removed' => array( - '
bold!
', + '
bold!
', '
bold!
', array( - 'div > span { font-weight:bold; font-style: italic; }', + 'div > span{font-style:italic;}@media screen and ( max-width: 640px ){div > span{font-style:normal;}:root:not(#_):not(#_) div > span{font-weight:normal;}}:root:not(#_):not(#_) div > span{font-weight:bold;}', + ), + ), + + 'illegal_unsafe_properties' => array( + '', + '', + array( + 'button{font-weight:bold;}@media screen{button{font-weight:bold;}}', + ), + array( 'illegal_css_property', 'illegal_css_property', 'illegal_css_property', 'illegal_css_property' ), + ), + + 'illegal_at_rule_in_style_attribute' => array( + 'Parse error.', + 'Parse error.', + array(), + array( 'css_parse_error' ), + ), + + 'illegal_at_rules_removed' => array( + '', + '', + array( + 'body{color:black;}', + ), + array( 'illegal_css_at_rule', 'illegal_css_at_rule', 'illegal_css_at_rule', 'illegal_css_at_rule', 'illegal_css_at_rule' ), + ), + + 'allowed_at_rules_retained' => array( + '', + '', + array( + '@media screen and ( max-width: 640px ){body{font-size:small;}}@font-face{font-family:"Open Sans";src:url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");}@supports (display: grid){div{display:grid;}}@-moz-keyframes appear{from{opacity:0;}to{opacity:1;}}@keyframes appear{from{opacity:0;}to{opacity:1;}}', + ), + ), + + 'selector_specificity' => array( + '
onetwothree
', + '
onetwothree
', + array( + ':root:not(#_) #child{color:red;}:root:not(#_):not(#_) #parent #child{color:pink;}:root:not(#_) .foo{color:blue;}:root:not(#_):not(#_) #me .foo{color:green;}', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-64b4fd4{color:yellow;}', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-ab79d9e{color:purple;}', ), ), ); @@ -130,11 +166,19 @@ public function get_body_style_attribute_data() { * @param string $source Source. * @param string $expected_content Expected content. * @param string $expected_stylesheets Expected stylesheets. + * @param array $expected_errors Expected error codes. */ - public function test_body_style_attribute_sanitizer( $source, $expected_content, $expected_stylesheets ) { + public function test_body_style_attribute_sanitizer( $source, $expected_content, $expected_stylesheets, $expected_errors = array() ) { $dom = AMP_DOM_Utils::get_dom_from_content( $source ); - $sanitizer = new AMP_Style_Sanitizer( $dom ); + $error_codes = array(); + $args = array( + 'validation_error_callback' => function( $error ) use ( &$error_codes ) { + $error_codes[] = $error['code']; + }, + ); + + $sanitizer = new AMP_Style_Sanitizer( $dom, $args ); $sanitizer->sanitize(); // Test content. @@ -144,6 +188,8 @@ public function test_body_style_attribute_sanitizer( $source, $expected_content, // Test stylesheet. $this->assertEquals( $expected_stylesheets, array_values( $sanitizer->get_stylesheets() ) ); + + $this->assertEquals( $expected_errors, $error_codes ); } /** @@ -154,31 +200,75 @@ public function test_body_style_attribute_sanitizer( $source, $expected_content, public function get_link_and_style_test_data() { return array( 'multiple_amp_custom_and_other_styles' => array( - '', + '', array( - 'b {color:red}', - 'i {color:blue}', - 'u {color:green}', - 's {color:yellow}', + ':root:not(#_):not(#_) b{color:red;}', + 'i{color:blue;}', + 'u{color:green;}:root:not(#_):not(#_) u{text-decoration:underline;}', + 's{color:yellow;}', ), + array(), ), 'style_elements_with_link_elements' => array( sprintf( - '', // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet + '', // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet includes_url( 'css/dashicons.css' ) ), array( 'strong.before-dashicon', '.dashicons-dashboard:before', 'strong.after-dashicon', - 's {color:yellow}', + ':root:not(#_):not(#_) s{color:yellow;}', ), + array(), ), 'style_with_no_head' => array( - 'Not good!', + 'Not good!', array( 'body{color:red;}', ), + array(), + ), + 'style_with_not_selectors' => array( + '

Hello

', + array( + 'body.foo:not(.bar) > p{color:blue;}body.foo:not(.bar) p:not(.baz){color:green;}body.foo p{color:yellow;}', + ), + array(), + ), + 'style_with_attribute_selectors' => array( + '', + array( + '.social-navigation a[href*="example.com"]{color:red;}', + ), + array(), + ), + 'style_on_root_element' => array( + 'Hi', + array( + 'html:not(#_):not(#_){background-color:blue;}', + ':root:not(#_):not(#_):not(#_):not(#_):not(#_) .amp-wp-10b06ba{color:red;}', + ), + array(), + ), + 'styles_with_dynamic_elements' => array( + implode( '', array( + '', + '', + '', + '', + '', + '
', + '
  • Hello
', + ' ', + '', + ) ), + array( + 'form [submit-success] b,div[submit-failure] b{color:green;}', + 'amp-live-list li .highlighted{background:yellow;}', + 'body amp-list .portland{color:blue;}', + ), + array(), ), ); } @@ -189,27 +279,154 @@ public function get_link_and_style_test_data() { * @dataProvider get_link_and_style_test_data * @param string $source Source. * @param array $expected_stylesheets Expected stylesheets. + * @param array $expected_errors Expected error codes. */ - public function test_link_and_style_elements( $source, $expected_stylesheets ) { + public function test_link_and_style_elements( $source, $expected_stylesheets, $expected_errors = array() ) { $dom = AMP_DOM_Utils::get_dom( $source ); - $sanitizer = new AMP_Style_Sanitizer( $dom, array( - 'use_document_element' => true, - ) ); + $error_codes = array(); + $args = array( + 'use_document_element' => true, + 'remove_unused_rules' => 'always', + 'validation_error_callback' => function( $error ) use ( &$error_codes ) { + $error_codes[] = $error['code']; + }, + ); + + $sanitizer = new AMP_Style_Sanitizer( $dom, $args ); $sanitizer->sanitize(); - $whitelist_sanitizer = new AMP_Tag_And_Attribute_Sanitizer( $dom, array( - 'use_document_element' => true, - ) ); + $whitelist_sanitizer = new AMP_Tag_And_Attribute_Sanitizer( $dom, $args ); $whitelist_sanitizer->sanitize(); $sanitized_html = AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); $actual_stylesheets = array_values( $sanitizer->get_stylesheets() ); $this->assertCount( count( $expected_stylesheets ), $actual_stylesheets ); foreach ( $expected_stylesheets as $i => $expected_stylesheet ) { - $this->assertContains( $expected_stylesheet, $actual_stylesheets[ $i ] ); + if ( false === strpos( $expected_stylesheet, '{' ) ) { + $this->assertContains( $expected_stylesheet, $actual_stylesheets[ $i ] ); + } else { + $this->assertEquals( $expected_stylesheet, $actual_stylesheets[ $i ] ); + } $this->assertContains( $expected_stylesheet, $sanitized_html ); } + + $this->assertEquals( $expected_errors, $error_codes ); + } + + /** + * Test handling of stylesheets with @font-face that have data: url source. + * + * Also confirm that class-based tree-shaking is working. + * + * @covers AMP_Style_Sanitizer::process_font_face_at_rule() + */ + public function test_font_data_url_handling() { + $html = ''; + $html .= ''; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet + $html .= ''; + + // Test with tree-shaking. + $dom = AMP_DOM_Utils::get_dom( $html ); + $error_codes = array(); + $sanitizer = new AMP_Style_Sanitizer( $dom, array( + 'use_document_element' => true, + 'remove_unused_rules' => 'always', + 'validation_error_callback' => function( $error ) use ( &$error_codes ) { + $error_codes[] = $error['code']; + }, + ) ); + $sanitizer->sanitize(); + $this->assertEquals( array(), $error_codes ); + $actual_stylesheets = array_values( $sanitizer->get_stylesheets() ); + $this->assertCount( 1, $actual_stylesheets ); + $this->assertContains( 'dashicons.woff") format("woff")', $actual_stylesheets[0] ); + $this->assertNotContains( 'data:application/font-woff;', $actual_stylesheets[0] ); + $this->assertContains( '.dashicons{', $actual_stylesheets[0] ); + $this->assertContains( '.dashicons-admin-appearance:before{', $actual_stylesheets[0] ); + $this->assertNotContains( '.dashicons-format-chat:before', $actual_stylesheets[0] ); + + // Test with rule-removal not forced, since dashicons alone is not larger than 50KB. + $dom = AMP_DOM_Utils::get_dom( $html ); + $error_codes = array(); + $sanitizer = new AMP_Style_Sanitizer( $dom, array( + 'use_document_element' => true, + 'remove_unused_rules' => 'sometimes', + 'validation_error_callback' => function( $error ) use ( &$error_codes ) { + $error_codes[] = $error['code']; + }, + ) ); + $sanitizer->sanitize(); + $this->assertEquals( array(), $error_codes ); + $actual_stylesheets = array_values( $sanitizer->get_stylesheets() ); + $this->assertContains( 'dashicons.woff") format("woff")', $actual_stylesheets[0] ); + $this->assertNotContains( 'data:application/font-woff;', $actual_stylesheets[0] ); + $this->assertContains( '.dashicons,.dashicons-before:before{', $actual_stylesheets[0] ); + $this->assertContains( '.dashicons-admin-appearance:before{', $actual_stylesheets[0] ); + $this->assertContains( '.dashicons-format-chat:before', $actual_stylesheets[0] ); + } + + /** + * Test that auto-removal is performed when remove_unused_rules=sometimes (the default), and that excessive CSS will be removed entirely. + * + * @covers AMP_Style_Sanitizer::finalize_stylesheet_set() + */ + public function test_large_custom_css_and_rule_removal() { + $custom_max_size = null; + foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'style' ) as $spec_rule ) { + if ( isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) && 'style amp-custom' === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { + $custom_max_size = $spec_rule[ AMP_Rule_Spec::CDATA ]['max_bytes']; + break; + } + } + $this->assertNotNull( $custom_max_size ); + + $html = ''; + $html .= ''; + $html .= ''; + $html .= '...'; + $dom = AMP_DOM_Utils::get_dom( $html ); + + $error_codes = array(); + $sanitizer = new AMP_Style_Sanitizer( $dom, array( + 'use_document_element' => true, + 'validation_error_callback' => function( $error ) use ( &$error_codes ) { + $error_codes[] = $error['code']; + }, + ) ); + $sanitizer->sanitize(); + + $this->assertEquals( + array( '.b{color:blue;}' ), + array_values( $sanitizer->get_stylesheets() ) + ); + + $this->assertEquals( + array( 'removed_unused_css_rules', 'excessive_css' ), + $error_codes + ); + } + + /** + * Test handling of stylesheets with relative background-image URLs. + * + * @covers AMP_Style_Sanitizer::real_path_urls() + */ + public function test_relative_background_url_handling() { + $html = ''; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet + $dom = AMP_DOM_Utils::get_dom( $html ); + + $sanitizer = new AMP_Style_Sanitizer( $dom, array( + 'use_document_element' => true, + ) ); + $sanitizer->sanitize(); + AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); + $actual_stylesheets = array_values( $sanitizer->get_stylesheets() ); + $this->assertCount( 1, $actual_stylesheets ); + $stylesheet = $actual_stylesheets[0]; + + $this->assertNotContains( '../images/spinner', $stylesheet ); + $this->assertContains( sprintf( '.spinner{background-image:url("%s");', admin_url( 'images/spinner-2x.gif' ) ), $stylesheet ); } /** @@ -218,28 +435,44 @@ public function test_link_and_style_elements( $source, $expected_stylesheets ) { * @return array */ public function get_keyframe_data() { - $keyframes_max_size = 10; + $keyframes_max_size = null; foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'style' ) as $spec_rule ) { if ( isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) && 'style[amp-keyframes]' === $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { $keyframes_max_size = $spec_rule[ AMP_Rule_Spec::CDATA ]['max_bytes']; break; } } + $this->assertNotNull( $keyframes_max_size ); return array( 'style_amp_keyframes' => array( - '', - null, // No Change. + '', + '', + array(), ), 'style_amp_keyframes_max_overflow' => array( - '', + '', '', + array( 'excessive_css' ), ), 'style_amp_keyframes_last_child' => array( - ' as after', - ' as after', + 'before between as after', + 'before between as after', + array(), + ), + + 'blacklisted_and_whitelisted_keyframe_properties' => array( + '', + '', + array( 'illegal_css_property', 'illegal_css_property', 'illegal_css_property' ), + ), + + 'style_amp_keyframes_with_disallowed_rules' => array( + '', + '', + array( 'unrecognized_css', 'illegal_css_important', 'illegal_css_at_rule' ), ), ); } @@ -250,15 +483,26 @@ public function get_keyframe_data() { * @dataProvider get_keyframe_data * @param string $source Markup to process. * @param string $expected The markup to expect. + * @param array $expected_errors Expected error codes. */ - public function test_keyframe_sanitizer( $source, $expected = null ) { - $expected = isset( $expected ) ? $expected : $source; - $dom = AMP_DOM_Utils::get_dom_from_content( $source ); - $sanitizer = new AMP_Style_Sanitizer( $dom ); + public function test_keyframe_sanitizer( $source, $expected = null, $expected_errors = array() ) { + $expected = isset( $expected ) ? $expected : $source; + $dom = AMP_DOM_Utils::get_dom_from_content( $source ); + $error_codes = array(); + $sanitizer = new AMP_Style_Sanitizer( $dom, array( + 'use_document_element' => true, + 'validation_error_callback' => function( $error ) use ( &$error_codes ) { + $error_codes[] = $error['code']; + }, + ) ); $sanitizer->sanitize(); $content = AMP_DOM_Utils::get_content_from_dom( $dom ); + $content = preg_replace( '#\s+(?=@keyframes)#', '', $content ); + $content = preg_replace( '#\s+(?=)#', '', $content ); $content = preg_replace( '/(?<=>)\s+(?=<)/', '', $content ); $this->assertEquals( $expected, $content ); + + $this->assertEquals( $expected_errors, $error_codes ); } /** @@ -292,33 +536,34 @@ public function get_stylesheet_urls() { admin_url( 'css/common.css' ), ABSPATH . 'wp-admin/css/common.css', ), - 'amp_css_bad_file_extension' => array( + 'amp_disallowed_file_extension' => array( content_url( 'themes/twentyseventeen/index.php' ), null, - 'amp_css_bad_file_extension', + 'amp_disallowed_file_extension', ), - 'amp_css_path_not_found' => array( + 'amp_file_path_not_found' => array( content_url( 'themes/twentyseventeen/404.css' ), null, - 'amp_css_path_not_found', + 'amp_file_path_not_found', ), ); } /** - * Tests get_validated_css_file_path. + * Tests get_validated_url_file_path. * * @dataProvider get_stylesheet_urls - * @covers AMP_Style_Sanitizer::get_validated_css_file_path() + * @covers AMP_Style_Sanitizer::get_validated_url_file_path() + * * @param string $source Source URL. * @param string|null $expected Expected path or null if error. * @param string $error_code Error code. Optional. */ - public function test_get_validated_css_file_path( $source, $expected, $error_code = null ) { + public function test_get_validated_url_file_path( $source, $expected, $error_code = null ) { $dom = AMP_DOM_Utils::get_dom( '' ); $sanitizer = new AMP_Style_Sanitizer( $dom ); - $actual = $sanitizer->get_validated_css_file_path( $source ); + $actual = $sanitizer->get_validated_url_file_path( $source, array( 'css' ) ); if ( isset( $error_code ) ) { $this->assertInstanceOf( 'WP_Error', $actual ); $this->assertEquals( $error_code, $actual->get_error_code() ); diff --git a/tests/test-class-amp-response-headers.php b/tests/test-class-amp-response-headers.php new file mode 100644 index 00000000000..a6408dd10e2 --- /dev/null +++ b/tests/test-class-amp-response-headers.php @@ -0,0 +1,116 @@ +assertContains( + array( + 'name' => 'Foo', + 'value' => 'Bar', + 'replace' => true, + 'status_code' => null, + ), + AMP_Response_Headers::$headers_sent + ); + } + + /** + * Test \AMP_Response_Headers::send_header() when replace arg is passed. + * + * @covers \AMP_Response_Headers::send_header() + */ + public function test_send_header_replace_arg() { + AMP_Response_Headers::send_header( 'Foo', 'Bar', array( + 'replace' => false, + ) ); + $this->assertContains( + array( + 'name' => 'Foo', + 'value' => 'Bar', + 'replace' => false, + 'status_code' => null, + ), + AMP_Response_Headers::$headers_sent + ); + } + + /** + * Test \AMP_Response_Headers::send_header() when status code is passed. + * + * @covers \AMP_Response_Headers::send_header() + */ + public function test_send_header_status_code() { + AMP_Response_Headers::send_header( 'Foo', 'Bar', array( + 'status_code' => 400, + ) ); + $this->assertContains( + array( + 'name' => 'Foo', + 'value' => 'Bar', + 'replace' => true, + 'status_code' => 400, + ), + AMP_Response_Headers::$headers_sent + ); + } + + /** + * Test \AMP_Response_Headers::send_server_timing() when positive duration passed. + * + * @covers \AMP_Response_Headers::send_server_timing() + */ + public function test_send_server_timing_positive_duration() { + AMP_Response_Headers::send_server_timing( 'name', 123, 'Description' ); + $this->assertCount( 1, AMP_Response_Headers::$headers_sent ); + $this->assertEquals( 'Server-Timing', AMP_Response_Headers::$headers_sent[0]['name'] ); + $values = preg_split( '/\s*;\s*/', AMP_Response_Headers::$headers_sent[0]['value'] ); + $this->assertEquals( 'name', $values[0] ); + $this->assertEquals( 'desc="Description"', $values[1] ); + $this->assertStringStartsWith( 'dur=123000.', $values[2] ); + $this->assertFalse( AMP_Response_Headers::$headers_sent[0]['replace'] ); + $this->assertNull( AMP_Response_Headers::$headers_sent[0]['status_code'] ); + } + + /** + * Test \AMP_Response_Headers::send_server_timing() when positive duration passed. + * + * @covers \AMP_Response_Headers::send_server_timing() + */ + public function test_send_server_timing_negative_duration() { + AMP_Response_Headers::send_server_timing( 'name', -microtime( true ) ); + $this->assertCount( 1, AMP_Response_Headers::$headers_sent ); + $this->assertEquals( 'Server-Timing', AMP_Response_Headers::$headers_sent[0]['name'] ); + $values = preg_split( '/\s*;\s*/', AMP_Response_Headers::$headers_sent[0]['value'] ); + $this->assertEquals( 'name', $values[0] ); + $this->assertStringStartsWith( 'dur=0.', $values[1] ); + $this->assertFalse( AMP_Response_Headers::$headers_sent[0]['replace'] ); + $this->assertNull( AMP_Response_Headers::$headers_sent[0]['status_code'] ); + } +} diff --git a/tests/test-class-amp-theme-support.php b/tests/test-class-amp-theme-support.php index e8e6cb0a9e4..670ad2fabdd 100644 --- a/tests/test-class-amp-theme-support.php +++ b/tests/test-class-amp-theme-support.php @@ -37,7 +37,7 @@ public function tearDown() { if ( isset( $GLOBALS['wp_customize'] ) ) { $GLOBALS['wp_customize']->stop_previewing_theme(); } - AMP_Theme_Support::$headers_sent = array(); + AMP_Response_Headers::$headers_sent = array(); } /** @@ -334,25 +334,6 @@ public function test_purge_amp_query_vars() { // 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(). * @@ -361,7 +342,7 @@ public function test_send_header() { 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 ); + $this->assertEmpty( AMP_Response_Headers::$headers_sent ); $_GET['_wp_amp_action_xhr_converted'] = '1'; @@ -370,14 +351,14 @@ public function test_handle_xhr_request() { $_SERVER['REQUEST_METHOD'] = 'POST'; AMP_Theme_Support::purge_amp_query_vars(); AMP_Theme_Support::handle_xhr_request(); - $this->assertEmpty( AMP_Theme_Support::$headers_sent ); + $this->assertEmpty( AMP_Response_Headers::$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->assertCount( 1, AMP_Response_Headers::$headers_sent ); $this->assertEquals( array( 'name' => 'AMP-Access-Control-Allow-Source-Origin', @@ -385,7 +366,7 @@ public function test_handle_xhr_request() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent[0] + AMP_Response_Headers::$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' ) ) ); @@ -488,7 +469,7 @@ public function test_intercept_post_request_redirect() { } ); // Test redirecting to full URL with HTTPS protocol. - AMP_Theme_Support::$headers_sent = array(); + AMP_Response_Headers::$headers_sent = array(); ob_start(); AMP_Theme_Support::intercept_post_request_redirect( $url ); $this->assertEquals( '{"success":true}', ob_get_clean() ); @@ -499,7 +480,7 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); $this->assertContains( array( @@ -508,11 +489,11 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); // Test redirecting to non-HTTPS URL. - AMP_Theme_Support::$headers_sent = array(); + AMP_Response_Headers::$headers_sent = array(); ob_start(); $url = home_url( '/', 'http' ); AMP_Theme_Support::intercept_post_request_redirect( $url ); @@ -524,7 +505,7 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); $this->assertContains( array( @@ -533,11 +514,11 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); // Test redirecting to host-less location. - AMP_Theme_Support::$headers_sent = array(); + AMP_Response_Headers::$headers_sent = array(); ob_start(); AMP_Theme_Support::intercept_post_request_redirect( '/new-location/' ); $this->assertEquals( '{"success":true}', ob_get_clean() ); @@ -548,11 +529,11 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); // Test redirecting to scheme-less location. - AMP_Theme_Support::$headers_sent = array(); + AMP_Response_Headers::$headers_sent = array(); ob_start(); $url = home_url( '/new-location/' ); AMP_Theme_Support::intercept_post_request_redirect( substr( $url, strpos( $url, ':' ) + 1 ) ); @@ -564,11 +545,11 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); // Test redirecting to empty location. - AMP_Theme_Support::$headers_sent = array(); + AMP_Response_Headers::$headers_sent = array(); ob_start(); AMP_Theme_Support::intercept_post_request_redirect( '' ); $this->assertEquals( '{"success":true}', ob_get_clean() ); @@ -579,7 +560,7 @@ public function test_intercept_post_request_redirect() { 'replace' => true, 'status_code' => null, ), - AMP_Theme_Support::$headers_sent + AMP_Response_Headers::$headers_sent ); } @@ -938,7 +919,6 @@ public function test_filter_customize_partial_render() { $this->assertContains( 'assertNotContains( 'assertNotContains( 'assertContains( '', $sanitized_html ); $this->assertContains( '', $sanitized_html ); $this->assertContains( '