'sometimes', 'dynamic_element_selectors' => array( 'amp-list', 'amp-live-list', '[submit-error]', '[submit-success]', ), 'should_locate_sources' => false, 'parsed_cache_variant' => null, 'accept_tree_shaking' => false, 'include_manifest_comment' => 'always', ); /** * Stylesheets. * * Values are the CSS stylesheets. Keys are MD5 hashes of the stylesheets, * * @since 0.7 * @var string[] */ private $stylesheets = array(); /** * List of stylesheet parts prior to selector/rule removal (tree shaking). * * Keys are MD5 hashes of stylesheets. * * @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 $pending_stylesheets = array(); /** * Spec for style[amp-custom] cdata. * * @since 1.0 * @var array */ private $style_custom_cdata_spec; /** * The style[amp-custom] element. * * @var DOMElement */ 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. * * @var string */ private $allowed_font_src_regex; /** * Base URL for styles. * * Full URL with trailing slash. * * @var string */ private $base_url; /** * URL of the content directory. * * @var string */ private $content_url; /** * Class names used in document. * * This list includes all class names that AMP can dynamically add. * * @link https://www.ampproject.org/docs/reference/components/amp-dynamic-css-classes * @since 1.0 * @var array */ private $used_class_names; /** * Attributes used in the document. * * This is initially populated with boolean attributes which can be mutated by AMP at runtime, * since they can by dynamically added at any time. * * @since 1.1 * @var array */ private $used_attributes = array( 'autofocus' => true, 'checked' => true, 'controls' => true, 'disabled' => true, 'hidden' => true, 'loop' => true, 'multiple' => true, 'open' => true, 'readonly' => true, 'required' => true, 'selected' => true, ); /** * Tag names used in document. * * @since 1.0 * @var array */ private $used_tag_names; /** * 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; /** * THe HEAD element. * * @var DOMElement */ private $head; /** * Current node being processed. * * @var DOMElement|DOMAttr */ private $current_node; /** * Current sources for a given node. * * @var array */ private $current_sources; /** * Log of the stylesheet URLs that have been imported to guard against infinite loops. * * @var array */ private $processed_imported_stylesheet_urls = array(); /** * List of font stylesheets that were @import'ed which should have been 'ed to instead. * * These font URLs will be cached with the parsed CSS and then converted into stylesheet links. * * @var array */ private $imported_font_urls = array(); /** * Mapping of HTML element selectors to AMP selector elements. * * @var array */ private $selector_mappings = array(); /** * Get error codes that can be raised during parsing of CSS. * * This is used to determine which validation errors should be taken into account * when determining which validation errors should vary the parse cache. * * @return array */ public static function get_css_parser_validation_error_codes() { return array( 'css_parse_error', 'excessive_css', self::ILLEGAL_AT_RULE_ERROR_CODE, 'illegal_css_important', 'illegal_css_property', self::TREE_SHAKING_ERROR_CODE, 'unrecognized_css', 'disallowed_file_extension', 'file_path_not_found', ); } /** * Determine whether the version of PHP-CSS-Parser loaded has all required features for tree shaking and CSS processing. * * @since 1.0.2 * * @return bool Returns true if the plugin's forked version of PHP-CSS-Parser is loaded by Composer. */ public static function has_required_php_css_parser() { $has_required_methods = ( method_exists( 'Sabberworm\CSS\CSSList\Document', 'splice' ) && method_exists( 'Sabberworm\CSS\CSSList\Document', 'replace' ) ); if ( ! $has_required_methods ) { return false; } $reflection = new ReflectionClass( 'Sabberworm\CSS\OutputFormat' ); $has_output_format_extensions = ( $reflection->hasProperty( 'sBeforeAtRuleBlock' ) && $reflection->hasProperty( 'sAfterAtRuleBlock' ) && $reflection->hasProperty( 'sBeforeDeclarationBlock' ) && $reflection->hasProperty( 'sAfterDeclarationBlockSelectors' ) && $reflection->hasProperty( 'sAfterDeclarationBlock' ) ); if ( ! $has_output_format_extensions ) { return false; } return true; } /** * AMP_Base_Sanitizer constructor. * * @since 0.7 * * @param DOMDocument $dom Represents the HTML document to sanitize. * @param array $args Args. */ public function __construct( DOMDocument $dom, array $args = array() ) { parent::__construct( $dom, $args ); foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'style' ) as $spec_rule ) { if ( ! isset( $spec_rule[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) ) { continue; } 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 ]; } } $spec_name = 'link rel=stylesheet for fonts'; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'link' ) 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->allowed_font_src_regex = '@^(' . $spec_rule[ AMP_Rule_Spec::ATTR_SPEC_LIST ]['href']['value_regex'] . ')$@'; break; } } $guessurl = site_url(); if ( ! $guessurl ) { $guessurl = wp_guess_url(); } $this->base_url = untrailingslashit( $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() { return array(); } /** * Get stylesheets. * * @since 0.7 * @returns array Values are the CSS stylesheets. Keys are MD5 hashes of the stylesheets. */ public function get_stylesheets() { return $this->stylesheets; } /** * Get list of all the class names used in the document, including those used in [class] attributes. * * @since 1.0 * @return array Used class names. */ private function get_used_class_names() { if ( isset( $this->used_class_names ) ) { return $this->used_class_names; } $dynamic_class_names = array( /* * See . * Note that amp-referrer-* class names are handled in has_used_class_name() below. */ 'amp-viewer', ); $classes = ' '; foreach ( $this->xpath->query( '//*/@class' ) as $class_attribute ) { $classes .= ' ' . $class_attribute->nodeValue; } // Find all [class] attributes and capture the contents of any single- or double-quoted strings. foreach ( $this->xpath->query( '//*/@' . AMP_DOM_Utils::get_amp_bind_placeholder_prefix() . 'class' ) as $bound_class_attribute ) { if ( preg_match_all( '/([\'"])([^\1]*?)\1/', $bound_class_attribute->nodeValue, $matches ) ) { $classes .= ' ' . implode( ' ', $matches[2] ); } } $class_names = array_merge( $dynamic_class_names, array_unique( array_filter( preg_split( '/\s+/', trim( $classes ) ) ) ) ); $this->used_class_names = array_fill_keys( $class_names, true ); return $this->used_class_names; } /** * Determine if all the supplied class names are used. * * @since 1.1 * * @param string[] $class_names Class names. * @return bool All used. */ private function has_used_class_name( $class_names ) { if ( empty( $this->used_class_names ) ) { $this->get_used_class_names(); } foreach ( $class_names as $class_name ) { // Class names for amp-dynamic-css-classes, see . if ( 'amp-referrer-' === substr( $class_name, 0, 13 ) ) { continue; } /* * Common class names used for amp-user-notification and amp-live-list. * See . * See . */ if ( 'amp-active' === $class_name || 'amp-hidden' === $class_name ) { if ( ! $this->has_used_tag_names( array( 'amp-live-list' ) ) && ! $this->has_used_tag_names( array( 'amp-user-notification' ) ) ) { return false; } continue; } // Class names for amp-carousel, see . if ( 'amp-carousel-' === substr( $class_name, 0, 13 ) ) { if ( ! $this->has_used_tag_names( array( 'amp-carousel' ) ) ) { return false; } continue; } // Class names for amp-form, see . if ( 'amp-form-' === substr( $class_name, 0, 9 ) || 'user-valid' === $class_name || 'user-invalid' === $class_name ) { if ( ! $this->has_used_tag_names( array( 'form' ) ) ) { return false; } continue; } /* * Class names for amp-access and amp-access-laterpay. * See . * See */ if ( 'amp-access-' === substr( $class_name, 0, 11 ) ) { if ( ! $this->has_used_attributes( array( 'amp-access' ) ) ) { return false; } continue; } // Class names for amp-geo, see . if ( 'amp-geo-' === substr( $class_name, 0, 8 ) || 'amp-iso-country-' === substr( $class_name, 0, 16 ) ) { if ( ! $this->has_used_tag_names( array( 'amp-geo' ) ) ) { return false; } continue; } // Class names for amp-image-lightbox, see . if ( 'amp-image-lightbox-caption' === $class_name ) { if ( ! $this->has_used_tag_names( array( 'amp-image-lightbox' ) ) ) { return false; } continue; } // Class names for amp-live-list, see . if ( 'amp-live-list-' === substr( $class_name, 0, 14 ) ) { if ( ! $this->has_used_tag_names( array( 'amp-live-list' ) ) ) { return false; } continue; } // Class names for amp-sidebar, see . if ( 'amp-sidebar-' === substr( $class_name, 0, 12 ) ) { if ( ! $this->has_used_tag_names( array( 'amp-sidebar' ) ) ) { return false; } continue; } // Class names for amp-sticky-ad, see . if ( 'amp-sticky-ad-' === substr( $class_name, 0, 14 ) ) { if ( ! $this->has_used_tag_names( array( 'amp-sticky-ad' ) ) ) { return false; } continue; } // Class names for amp-video-docking, see . if ( 'amp-docked-' === substr( $class_name, 0, 11 ) ) { if ( ! $this->has_used_attributes( array( 'dock' ) ) ) { return false; } continue; } if ( ! isset( $this->used_class_names[ $class_name ] ) ) { return false; } } return true; } /** * Get list of all the tag names used in the document. * * @since 1.0 * @return array Used tag names. */ private function get_used_tag_names() { if ( ! isset( $this->used_tag_names ) ) { $this->used_tag_names = array(); foreach ( $this->dom->getElementsByTagName( '*' ) as $el ) { $this->used_tag_names[ $el->tagName ] = true; } } return $this->used_tag_names; } /** * Determine if all the supplied tag names are used. * * @since 1.1 * * @param string[] $tag_names Tag names. * @return bool All used. */ private function has_used_tag_names( $tag_names ) { if ( empty( $this->used_tag_names ) ) { $this->get_used_tag_names(); } foreach ( $tag_names as $tag_name ) { if ( ! isset( $this->used_tag_names[ $tag_name ] ) ) { return false; } } return true; } /** * Check whether the attributes exist. * * @since 1.1 * @todo Make $attribute_names into $attributes as an associative array and implement lookups of specific values. Since attribute values can vary (e.g. with amp-bind), this may not be feasible. * * @param string[] $attribute_names Attribute names. * @return bool Whether all supplied attributes are used. */ private function has_used_attributes( $attribute_names ) { foreach ( $attribute_names as $attribute_name ) { if ( ! isset( $this->used_attributes[ $attribute_name ] ) ) { $expression = sprintf( '(//@%s)[1]', $attribute_name ); $this->used_attributes[ $attribute_name ] = ( 0 !== $this->xpath->query( $expression )->length ); } if ( ! $this->used_attributes[ $attribute_name ] ) { return false; } } return true; } /** * Run logic before any sanitizers are run. * * After the sanitizers are instantiated but before calling sanitize on each of them, this * method is called with list of all the instantiated sanitizers. * * @param AMP_Base_Sanitizer[] $sanitizers Sanitizers. */ public function init( $sanitizers ) { parent::init( $sanitizers ); foreach ( $sanitizers as $sanitizer ) { foreach ( $sanitizer->get_selector_conversion_mapping() as $html_selectors => $amp_selectors ) { if ( ! isset( $this->selector_mappings[ $html_selectors ] ) ) { $this->selector_mappings[ $html_selectors ] = $amp_selectors; } else { $this->selector_mappings[ $html_selectors ] = array_unique( array_merge( $this->selector_mappings[ $html_selectors ], $amp_selectors ) ); } // Prevent selectors like `amp-img img` getting deleted since `img` does not occur in the DOM. $this->args['dynamic_element_selectors'] = array_merge( $this->args['dynamic_element_selectors'], $this->selector_mappings[ $html_selectors ] ); } } } /** * Sanitize CSS styles within the HTML contained in this instance's DOMDocument. * * @since 0.4 */ public function sanitize() { $elements = array(); // Do nothing if inline styles are allowed. if ( ! empty( $this->args['allow_dirty_styles'] ) ) { return; } $this->head = $this->dom->getElementsByTagName( 'head' )->item( 0 ); if ( ! $this->head ) { $this->head = $this->dom->createElement( 'head' ); $this->dom->documentElement->insertBefore( $this->head, $this->dom->documentElement->firstChild ); } $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 = $this->xpath; $lower_case = 'translate( %s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" )'; // In XPath 2.0 this is lower-case(). $predicates = array( sprintf( '( self::style and not( @amp-boilerplate ) and ( not( @type ) or %s = "text/css" ) )', sprintf( $lower_case, '@type' ) ), sprintf( '( self::link and @href and %s = "stylesheet" )', sprintf( $lower_case, '@rel' ) ), ); foreach ( $xpath->query( '//*[ ' . implode( ' or ', $predicates ) . ' ]' ) as $element ) { $elements[] = $element; } // If 'width' attribute is present for 'col' tag, convert to proper CSS rule. foreach ( $this->dom->getElementsByTagName( 'col' ) as $col ) { /** * Col element. * * @var DOMElement $col */ $width_attr = $col->getAttribute( 'width' ); if ( ! empty( $width_attr ) && ( false === strpos( $width_attr, '*' ) ) ) { $width_style = 'width: ' . $width_attr; if ( is_numeric( $width_attr ) ) { $width_style .= 'px'; } if ( $col->hasAttribute( 'style' ) ) { $col->setAttribute( 'style', $width_style . ';' . $col->getAttribute( 'style' ) ); } else { $col->setAttribute( 'style', $width_style ); } $col->removeAttribute( 'width' ); } } /** * Element. * * @var DOMElement $element */ foreach ( $elements as $element ) { $node_name = strtolower( $element->nodeName ); if ( 'style' === $node_name ) { $this->process_style_element( $element ); } elseif ( 'link' === $node_name ) { $this->process_link_element( $element ); } } $elements = array(); foreach ( $xpath->query( '//*[ @style ]' ) as $element ) { $elements[] = $element; } foreach ( $elements as $element ) { $this->collect_inline_styles( $element ); } $this->finalize_styles(); $this->did_convert_elements = true; if ( $this->parse_css_duration > 0.0 ) { AMP_HTTP::send_server_timing( 'amp_parse_css', $this->parse_css_duration, 'AMP Parse CSS' ); } } /** * Eliminate relative segments (../ and ./) from a path. * * @param string $path Path with relative segments. This is not a URL, so no host and no query string. * @return string|WP_Error Unrelativized path or WP_Error if there is too much relativity. */ private function unrelativize_path( $path ) { // Eliminate current directory relative paths, like => . do { $path = preg_replace( '#/\./#', '/', $path, -1, $count ); } while ( 0 !== $count ); // Collapse relative paths, like => . do { $path = preg_replace( '#(?<=/)(?!\.\./)[^/]+/\.\./#', '', $path, 1, $count ); } while ( 0 !== $count ); if ( preg_match( '#(^|/)\.+/#', $path ) ) { /* translators: %s is the path with the remaining relative segments. */ return new WP_Error( 'remaining_relativity', sprintf( __( 'There are remaining relative path segments: %s', 'amp' ), $path ) ); } return $path; } /** * Construct a URL from a parsed one. * * @param array $parsed_url Parsed URL. * @return string Reconstructed URL. */ private function reconstruct_url( $parsed_url ) { $url = ''; if ( ! empty( $parsed_url['host'] ) ) { if ( ! empty( $parsed_url['scheme'] ) ) { $url .= $parsed_url['scheme'] . ':'; } $url .= '//'; $url .= $parsed_url['host']; if ( ! empty( $parsed_url['port'] ) ) { $url .= ':' . $parsed_url['port']; } } if ( ! empty( $parsed_url['path'] ) ) { $url .= $parsed_url['path']; } if ( ! empty( $parsed_url['query'] ) ) { $url .= '?' . $parsed_url['query']; } if ( ! empty( $parsed_url['fragment'] ) ) { $url .= '#' . $parsed_url['fragment']; } return $url; } /** * Generate a URL's fully-qualified file path. * * @since 0.7 * @see WP_Styles::_css_href() * * @param string $url The file URL. * @param string[] $allowed_extensions Allowed file extensions for local files. * @return string|WP_Error Style's absolute validated filesystem path, or WP_Error when error. */ public function get_validated_url_file_path( $url, $allowed_extensions = array() ) { if ( ! is_string( $url ) ) { return new WP_Error( 'url_not_string' ); } $needs_base_url = ( ! preg_match( '|^(https?:)?//|', $url ) && ! ( $this->content_url && 0 === strpos( $url, $this->content_url ) ) ); if ( $needs_base_url ) { $url = $this->base_url . '/' . ltrim( $url, '/' ); } $parsed_url = wp_parse_url( $url ); if ( empty( $parsed_url['host'] ) ) { /* translators: %s is the original URL */ return new WP_Error( 'no_url_host', sprintf( __( 'URL is missing host: %s', 'amp' ), $url ) ); } if ( empty( $parsed_url['path'] ) ) { /* translators: %s is the original URL */ return new WP_Error( 'no_url_path', sprintf( __( 'URL is missing path: %s', 'amp' ), $url ) ); } $path = $this->unrelativize_path( $parsed_url['path'] ); if ( is_wp_error( $path ) ) { return $path; } $parsed_url['path'] = $path; $remove_url_scheme = function( $schemed_url ) { return preg_replace( '#^\w+:(?=//)#', '', $schemed_url ); }; unset( $parsed_url['scheme'], $parsed_url['query'], $parsed_url['fragment'] ); $url = $this->reconstruct_url( $parsed_url ); $includes_url = $remove_url_scheme( includes_url( '/' ) ); $content_url = $remove_url_scheme( content_url( '/' ) ); $admin_url = $remove_url_scheme( get_admin_url( null, '/' ) ); $site_url = $remove_url_scheme( site_url( '/' ) ); $allowed_hosts = array( wp_parse_url( $includes_url, PHP_URL_HOST ), wp_parse_url( $content_url, PHP_URL_HOST ), wp_parse_url( $admin_url, PHP_URL_HOST ), ); // Validate file extensions. if ( ! empty( $allowed_extensions ) ) { $pattern = sprintf( '/\.(%s)$/i', implode( '|', $allowed_extensions ) ); if ( ! preg_match( $pattern, $url ) ) { /* translators: %s: the file URL. */ return new WP_Error( 'disallowed_file_extension', sprintf( __( 'File does not have an allowed file extension for filesystem access (%s).', 'amp' ), $url ) ); } } if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) { /* translators: %s: the file URL */ return new WP_Error( 'external_file_url', sprintf( __( 'URL is located on an external domain: %s.', 'amp' ), $parsed_url['host'] ) ); } $base_path = null; $file_path = null; $wp_content = 'wp-content'; if ( 0 === strpos( $url, $content_url ) ) { $base_path = WP_CONTENT_DIR; $file_path = substr( $url, strlen( $content_url ) - 1 ); } elseif ( 0 === strpos( $url, $includes_url ) ) { $base_path = ABSPATH . WPINC; $file_path = substr( $url, strlen( $includes_url ) - 1 ); } elseif ( 0 === strpos( $url, $admin_url ) ) { $base_path = ABSPATH . 'wp-admin'; $file_path = substr( $url, strlen( $admin_url ) - 1 ); } elseif ( 0 === strpos( $url, $site_url . trailingslashit( $wp_content ) ) ) { // Account for loading content from original wp-content directory not WP_CONTENT_DIR which can happen via register_theme_directory(). $base_path = ABSPATH . $wp_content; $file_path = substr( $url, strlen( $site_url ) + strlen( $wp_content ) ); } if ( ! $file_path || false !== strpos( $file_path, '../' ) || false !== strpos( $file_path, '..\\' ) ) { /* translators: %s: the file URL. */ return new WP_Error( 'file_path_not_allowed', sprintf( __( 'Disallowed URL filesystem path for %s.', 'amp' ), $url ) ); } if ( ! file_exists( $base_path . $file_path ) ) { /* translators: %s: the file URL. */ return new WP_Error( 'file_path_not_found', sprintf( __( 'Unable to locate filesystem path for %s.', 'amp' ), $url ) ); } return $base_path . $file_path; } /** * Set the current node (and its sources when required). * * @since 1.0 * @param DOMElement|DOMAttr|null $node Current node, or null to reset. */ private function set_current_node( $node ) { if ( $this->current_node === $node ) { return; } $this->current_node = $node; if ( empty( $node ) ) { $this->current_sources = null; } elseif ( ! empty( $this->args['should_locate_sources'] ) ) { $this->current_sources = AMP_Validation_Manager::locate_sources( $node ); } } /** * Process style element. * * @param DOMElement $element Style element. */ private function process_style_element( DOMElement $element ) { $this->set_current_node( $element ); // And sources when needing to be located. // @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; // Honor the style's media attribute. $media = $element->getAttribute( 'media' ); if ( $media && 'all' !== $media ) { $stylesheet = sprintf( '@media %s { %s }', $media, $stylesheet ); } $processed = $this->process_stylesheet( $stylesheet, array( 'allowed_at_rules' => $cdata_spec['css_spec']['allowed_at_rules'], 'property_whitelist' => $cdata_spec['css_spec']['declaration'], 'validate_keyframes' => $cdata_spec['css_spec']['validate_keyframes'], ) ); $this->pending_stylesheets[] = array_merge( array( 'keyframes' => $is_keyframes, 'node' => $element, 'sources' => $this->current_sources, ), wp_array_slice_assoc( $processed, array( 'stylesheet', 'imported_font_urls' ) ) ); if ( $element->hasAttribute( 'amp-custom' ) ) { if ( ! $this->amp_custom_style_element ) { $this->amp_custom_style_element = $element; } else { $element->parentNode->removeChild( $element ); // There can only be one. #highlander. } } else { // Remove from DOM since we'll be adding it to amp-custom. $element->parentNode->removeChild( $element ); } $this->set_current_node( null ); } /** * Process link element. * * @param DOMElement $element Link element. */ private function process_link_element( DOMElement $element ) { $href = $element->getAttribute( 'href' ); // Allow font URLs, including protocol-less URLs and recognized URLs that use HTTP instead of HTTPS. $normalized_url = preg_replace( '#^(http:)?(?=//)#', 'https:', $href ); if ( $this->allowed_font_src_regex && preg_match( $this->allowed_font_src_regex, $normalized_url ) ) { if ( $href !== $normalized_url ) { $element->setAttribute( 'href', $normalized_url ); } /* * Make sure rel=preconnect link is present for Google Fonts stylesheet. * Note that core themes normally do this already, per . * But not always, per . * This also ensures that other themes will get the preconnect link when * they don't implement the resource hint. */ $needs_preconnect_link = ( 'https://fonts.googleapis.com/' === substr( $normalized_url, 0, 29 ) && 0 === $this->xpath->query( '//link[ @rel = "preconnect" and @crossorigin and starts-with( @href, "https://fonts.gstatic.com" ) ]', $this->head )->length ); if ( $needs_preconnect_link ) { $link = AMP_DOM_Utils::create_node( $this->dom, 'link', array( 'rel' => 'preconnect', 'href' => 'https://fonts.gstatic.com/', 'crossorigin' => '', ) ); $this->head->insertBefore( $link ); // Note that \AMP_Theme_Support::ensure_required_markup() will put this in the optimal order. } return; } $css_file_path = $this->get_validated_url_file_path( $href, array( 'css', 'less', 'scss', 'sass' ) ); if ( ! is_wp_error( $css_file_path ) ) { $stylesheet = file_get_contents( $css_file_path ); // phpcs:ignore -- It's a local filesystem path not a remote request. } else { // Fall back to doing an HTTP request for the stylesheet is not accessible directly from the filesystem. $contents = $this->fetch_external_stylesheet( $normalized_url ); if ( ! is_wp_error( $contents ) ) { $stylesheet = $contents; } else { $this->remove_invalid_child( $element, array( 'code' => $contents->get_error_code(), 'message' => $contents->get_error_message(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ) ); return; } } if ( false === $stylesheet ) { $this->remove_invalid_child( $element, array( 'code' => 'stylesheet_file_missing', 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ) ); return; } // Honor the link's media attribute. $media = $element->getAttribute( 'media' ); if ( $media && 'all' !== $media ) { $stylesheet = sprintf( '@media %s { %s }', $media, $stylesheet ); } $this->set_current_node( $element ); // And sources when needing to be located. $processed = $this->process_stylesheet( $stylesheet, array( 'allowed_at_rules' => $this->style_custom_cdata_spec['css_spec']['allowed_at_rules'], 'property_whitelist' => $this->style_custom_cdata_spec['css_spec']['declaration'], 'stylesheet_url' => $href, 'stylesheet_path' => $css_file_path, ) ); $this->pending_stylesheets[] = array_merge( array( 'keyframes' => false, 'node' => $element, 'sources' => $this->current_sources, // Needed because node is removed below. ), wp_array_slice_assoc( $processed, array( 'stylesheet', 'imported_font_urls' ) ) ); // Remove now that styles have been processed. $element->parentNode->removeChild( $element ); $this->set_current_node( null ); } /** * Fetch external stylesheet. * * @todo Use Cache-Control max-age for transient. * * @param string $url External stylesheet URL. * @return string|WP_Error Stylesheet contents or WP_Error. */ private function fetch_external_stylesheet( $url ) { $cache_key = md5( $url ); $contents = get_transient( $cache_key ); if ( false === $contents ) { $r = wp_remote_get( $url ); if ( 200 !== wp_remote_retrieve_response_code( $r ) ) { $contents = new WP_Error( wp_remote_retrieve_response_code( $r ), wp_remote_retrieve_response_message( $r ) ); } elseif ( ! preg_match( '#^text/css#', wp_remote_retrieve_header( $r, 'content-type' ) ) ) { $contents = new WP_Error( 'no_css_content_type', __( 'Response did not contain the expected text/css content type.', 'amp' ) ); } else { $contents = wp_remote_retrieve_body( $r ); } set_transient( $cache_key, $contents, MONTH_IN_SECONDS ); } return $contents; } /** * Process stylesheet. * * 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 * * @param string $stylesheet Stylesheet. * @param array $options { * Options. * * @type string[] $property_whitelist Exclusively-allowed properties. * @type string[] $property_blacklist Disallowed properties. * @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. * * @type array $stylesheet Stylesheet parts, where arrays are tuples for declaration blocks. * @type array $validation_results Validation results, array containing arrays with error and sanitized keys. * @type array $imported_font_urls Imported font stylesheet URLs. * } */ private function process_stylesheet( $stylesheet, $options = array() ) { $parsed = null; $cache_key = null; $cache_group = 'amp-parsed-stylesheet-v17'; // This should be bumped whenever the PHP-CSS-Parser is updated or parsed format is updated. $cache_impacting_options = array_merge( wp_array_slice_assoc( $options, array( 'property_whitelist', 'property_blacklist', 'stylesheet_url', 'allowed_at_rules' ) ), wp_array_slice_assoc( $this->args, array( 'should_locate_sources', 'parsed_cache_variant' ) ), array( 'language' => get_bloginfo( 'language' ), // Used to tree-shake html[lang] selectors. ) ); $cache_key = md5( $stylesheet . wp_json_encode( $cache_impacting_options ) ); if ( wp_using_ext_object_cache() ) { $parsed = wp_cache_get( $cache_key, $cache_group ); } else { $parsed = get_transient( $cache_group . '-' . $cache_key ); } /* * Make sure that the parsed stylesheet was cached with current sanitizations. * The should_sanitize_validation_error method prevents duplicates from being reported. */ if ( ! empty( $parsed['validation_results'] ) ) { foreach ( $parsed['validation_results'] as $validation_result ) { $sanitized = $this->should_sanitize_validation_error( $validation_result['error'] ); if ( $sanitized !== $validation_result['sanitized'] ) { $parsed = null; // Change to sanitization of validation error detected, so cache cannot be used. break; } } } if ( ! $parsed || ! isset( $parsed['stylesheet'] ) || ! is_array( $parsed['stylesheet'] ) ) { $parsed = $this->prepare_stylesheet( $stylesheet, $options ); /* * When an object cache is not available, we cache with an expiration to prevent the options table from * getting filled infinitely. On the other hand, if an external object cache is available then we don't * set an expiration because it should implement LRU cache expulsion policy. */ 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_group . '-' . $cache_key, $parsed, MONTH_IN_SECONDS ); } } return $parsed; } /** * Parse imported stylesheet. * * @param Import $item Import object. * @param CSSList $css_list CSS List. * @param array $options { * Options. * * @type string $stylesheet_url Original URL for stylesheet when originating via link or @import. * } * @return array Validation results. */ private function parse_import_stylesheet( Import $item, CSSList $css_list, $options ) { $results = array(); $at_rule_args = $item->atRuleArgs(); $location = array_shift( $at_rule_args ); $media_query = array_shift( $at_rule_args ); if ( isset( $options['stylesheet_url'] ) ) { $this->real_path_urls( array( $location ), $options['stylesheet_url'] ); } $import_stylesheet_url = $location->getURL()->getString(); // Prevent importing something that has already been imported, and avoid infinite recursion. if ( isset( $this->processed_imported_stylesheet_urls[ $import_stylesheet_url ] ) ) { $css_list->remove( $item ); return array(); } $this->processed_imported_stylesheet_urls[ $import_stylesheet_url ] = true; // Prevent importing font stylesheets from allowed font CDNs. These will get added to the document as links instead. $https_import_stylesheet_url = preg_replace( '#^(http:)?(?=//)#', 'https:', $import_stylesheet_url ); if ( $this->allowed_font_src_regex && preg_match( $this->allowed_font_src_regex, $https_import_stylesheet_url ) ) { $this->imported_font_urls[] = $https_import_stylesheet_url; $css_list->remove( $item ); _doing_it_wrong( 'wp_enqueue_style', esc_html( sprintf( /* translators: 1: @import. 2: wp_enqueue_style(). 3: font CDN URL. */ __( 'It is not a best practice to use %1$s to load font CDN stylesheets. Please use %2$s to enqueue %3$s as its own separate script.', 'amp' ), '@import', 'wp_enqueue_style()', $import_stylesheet_url ) ), '1.0' ); return array(); } $css_file_path = $this->get_validated_url_file_path( $import_stylesheet_url, array( 'css', 'less', 'scss', 'sass' ) ); if ( is_wp_error( $css_file_path ) && ( 'disallowed_file_extension' === $css_file_path->get_error_code() || 'external_file_url' === $css_file_path->get_error_code() ) ) { $contents = $this->fetch_external_stylesheet( $import_stylesheet_url ); if ( is_wp_error( $contents ) ) { $error = array( 'code' => $contents->get_error_code(), 'message' => $contents->get_error_message(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $css_list->remove( $item ); } $results[] = compact( 'error', 'sanitized' ); return $results; } else { $stylesheet = $contents; } } elseif ( is_wp_error( $css_file_path ) ) { $error = array( 'code' => $css_file_path->get_error_code(), 'message' => $css_file_path->get_error_message(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $css_list->remove( $item ); } $results[] = compact( 'error', 'sanitized' ); return $results; } else { $stylesheet = file_get_contents( $css_file_path ); // phpcs:ignore -- It's a local filesystem path not a remote request. } if ( $media_query ) { $stylesheet = sprintf( '@media %s { %s }', $media_query, $stylesheet ); } $options['stylesheet_url'] = $import_stylesheet_url; $parsed_stylesheet = $this->parse_stylesheet( $stylesheet, $options ); $results = array_merge( $results, $parsed_stylesheet['validation_results'] ); /** * CSS Doc. * * @var Document $css_document */ $css_document = $parsed_stylesheet['css_document']; if ( ! empty( $parsed_stylesheet['css_document'] ) && method_exists( $css_list, 'replace' ) ) { $css_list->replace( $item, $css_document->getContents() ); } else { $css_list->remove( $item ); } return $results; } /** * Parse stylesheet. * * @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 Document $css_document CSS Document. * @type array $validation_results Validation results, array containing arrays with error and sanitized keys. * @type string $stylesheet_url Stylesheet URL, if available. * } */ private function parse_stylesheet( $stylesheet_string, $options ) { $validation_results = array(); $css_document = null; $this->imported_font_urls = array(); try { // Remove spaces from data URLs, which cause errors and PHP-CSS-Parser can't handle them. $stylesheet_string = $this->remove_spaces_from_data_urls( $stylesheet_string ); $parser_settings = Sabberworm\CSS\Settings::create(); $css_parser = new Sabberworm\CSS\Parser( $stylesheet_string, $parser_settings ); $css_document = $css_parser->parse(); // @todo If 'utf-8' is not $css_parser->getCharset() then issue warning? if ( ! empty( $options['stylesheet_url'] ) ) { $this->real_path_urls( array_filter( $css_document->getAllValues(), function ( $value ) { return $value instanceof URL; } ), $options['stylesheet_url'] ); } $validation_results = array_merge( $validation_results, $this->process_css_list( $css_document, $options ) ); } catch ( Exception $exception ) { $error = array( 'code' => 'css_parse_error', 'message' => $exception->getMessage(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); /* * This is not a recoverable error, so sanitized here is just used to give user control * over whether to proceed with serving this exception-raising stylesheet in AMP. */ $sanitized = $this->should_sanitize_validation_error( $error ); $validation_results[] = compact( 'error', 'sanitized' ); } return array_merge( compact( 'validation_results', 'css_document' ), array( 'imported_font_urls' => $this->imported_font_urls, ) ); } /** * Prepare stylesheet. * * @since 1.0 * * @param string $stylesheet_string Stylesheet. * @param array $options Options. See definition in \AMP_Style_Sanitizer::process_stylesheet(). * @return array { * Prepared stylesheet. * * @type array $stylesheet Stylesheet parts, where arrays are tuples for declaration blocks. * @type array $validation_results Validation results, array containing arrays with error and sanitized keys. * @type array $imported_font_urls Imported font stylesheet URLs. * } */ private function prepare_stylesheet( $stylesheet_string, $options = array() ) { $start_time = microtime( true ); $options = array_merge( array( 'allowed_at_rules' => array(), 'property_blacklist' => array( // See . 'behavior', '-moz-binding', ), 'property_whitelist' => array(), 'validate_keyframes' => false, 'stylesheet_url' => null, 'stylesheet_path' => null, ), $options ); // Strip the dreaded UTF-8 byte order mark (BOM, \uFEFF). This should ideally get handled by PHP-CSS-Parser . $stylesheet_string = preg_replace( '/^\xEF\xBB\xBF/', '', $stylesheet_string ); // Strip obsolete CDATA sections and HTML comments which were used for old school XHTML. $stylesheet_string = preg_replace( '#^\s*\s*$#', '', $stylesheet_string ); $stylesheet = array(); $parsed_stylesheet = $this->parse_stylesheet( $stylesheet_string, $options ); $validation_results = $parsed_stylesheet['validation_results']; if ( ! empty( $parsed_stylesheet['css_document'] ) ) { $css_document = $parsed_stylesheet['css_document']; $output_format = Sabberworm\CSS\OutputFormat::createCompact(); $output_format->setSemicolonAfterLastRule( false ); $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*/'; $before_at_rule = '/*AMP_WP_BEFORE_AT_RULE*/'; $after_at_rule = '/*AMP_WP_AFTER_AT_RULE*/'; // Add comments to stylesheet if PHP-CSS-Parser has the required extensions for tree shaking. if ( self::has_required_php_css_parser() ) { $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 ); $output_format->set( 'BeforeAtRuleBlock', $before_at_rule ); $output_format->set( 'AfterAtRuleBlock', $after_at_rule ); } $stylesheet_string = $css_document->render( $output_format ); $pattern = '#'; $pattern .= preg_quote( $before_at_rule, '#' ); $pattern .= '|'; $pattern .= preg_quote( $after_at_rule, '#' ); $pattern .= '|'; $pattern .= '(' . preg_quote( $before_declaration_block, '#' ) . ')'; $pattern .= '(.+?)'; $pattern .= preg_quote( $after_declaration_block_selectors, '#' ); $pattern .= '(.+?)'; $pattern .= preg_quote( $after_declaration_block, '#' ); $pattern .= '#s'; $dynamic_selector_pattern = null; if ( ! empty( $this->args['dynamic_element_selectors'] ) ) { $dynamic_selector_pattern = implode( '|', array_map( function( $selector ) { return preg_quote( $selector, '#' ); }, $this->args['dynamic_element_selectors'] ) ); } $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 ] ) { // Skip keyframe-selector, which is can be: from | to | . if ( preg_match( '/^((from|to)\b|-?\d+(\.\d+)?%)/i', $split_stylesheet[ $i + 1 ] ) ) { $stylesheet[] = str_replace( $between_selectors, '', $split_stylesheet[ ++$i ] ) . $split_stylesheet[ ++$i ]; continue; } $selectors = explode( $between_selectors . ',', $split_stylesheet[ ++$i ] ); $declaration = $split_stylesheet[ ++$i ]; // @todo The following logic could be made much more robust if PHP-CSS-Parser did parsing of selectors. See and . $selectors_parsed = array(); foreach ( $selectors as $selector ) { $selectors_parsed[ $selector ] = array(); // Remove :not() and pseudo selectors to eliminate false negatives, such as with `body:not(.title-tagline-hidden) .site-branding-text`. $reduced_selector = preg_replace( '/::?[a-zA-Z0-9_-]+(\(.+?\))?/', '', $selector ); // Ignore any selector terms that occur under a dynamic selector. if ( $dynamic_selector_pattern ) { $reduced_selector = preg_replace( '#((?:' . $dynamic_selector_pattern . ')(?:\.[a-z0-9_-]+)*)[^a-z0-9_-].*#si', '$1', $reduced_selector . ' ' ); } /* * Gather attribute names while removing attribute selectors to eliminate false negative, * such as with `.social-navigation a[href*="example.com"]:before`. */ $reduced_selector = preg_replace_callback( '/\[([A-Za-z0-9_:-]+)(\W?=[^\]]+)?\]/', function( $matches ) use ( $selector, &$selectors_parsed ) { $selectors_parsed[ $selector ][ self::SELECTOR_EXTRACTED_ATTRIBUTES ][] = $matches[1]; return ''; }, $reduced_selector ); // Extract class names. $reduced_selector = preg_replace_callback( '/\.([a-zA-Z0-9_-]+)/', function( $matches ) use ( $selector, &$selectors_parsed ) { $selectors_parsed[ $selector ][ self::SELECTOR_EXTRACTED_CLASSES ][] = $matches[1]; return ''; }, $reduced_selector ); // Extract IDs. $reduced_selector = preg_replace_callback( '/#([a-zA-Z0-9_-]+)/', function( $matches ) use ( $selector, &$selectors_parsed ) { $selectors_parsed[ $selector ][ self::SELECTOR_EXTRACTED_IDS ][] = $matches[1]; return ''; }, $reduced_selector ); // Extract tag names. $reduced_selector = preg_replace_callback( '/[a-zA-Z0-9_-]+/', function( $matches ) use ( $selector, &$selectors_parsed ) { $selectors_parsed[ $selector ][ self::SELECTOR_EXTRACTED_TAGS ][] = $matches[0]; return ''; }, $reduced_selector ); // At this point, $reduced_selector should contain just the remnants of the selector, primarily combinators. unset( $reduced_selector ); } $stylesheet[] = array( $selectors_parsed, $declaration, ); } else { $stylesheet[] = $split_stylesheet[ $i ]; } } } $this->parse_css_duration += ( microtime( true ) - $start_time ); return array_merge( compact( 'stylesheet', 'validation_results' ), array( 'imported_font_urls' => $parsed_stylesheet['imported_font_urls'], ) ); } /** * Previous return values from calls to should_sanitize_validation_error(). * * This is used to prevent duplicates from being reported when the sanitization status * changes for a validation error in a previously-cached stylesheet. * * @see AMP_Style_Sanitizer::should_sanitize_validation_error() * @var array */ protected $previous_should_sanitize_validation_error_results = array(); /** * Check whether or not sanitization should occur in response to validation error. * * Supply sources to the error and the current node to data. * * @since 1.0 * * @param array $validation_error Validation error. * @param array $data Data including the node. * @return bool Whether to sanitize. */ public function should_sanitize_validation_error( $validation_error, $data = array() ) { if ( ! isset( $data['node'] ) ) { $data['node'] = $this->current_node; } if ( ! isset( $validation_error['sources'] ) ) { $validation_error['sources'] = $this->current_sources; } /* * This is used to prevent duplicates from being reported when the sanitization status * changes for a validation error in a previously-cached stylesheet. */ $args = compact( 'validation_error', 'data' ); foreach ( $this->previous_should_sanitize_validation_error_results as $result ) { if ( $result['args'] === $args ) { return $result['sanitized']; } } $sanitized = parent::should_sanitize_validation_error( $validation_error, $data ); $this->previous_should_sanitize_validation_error_results[] = compact( 'args', 'sanitized' ); return $sanitized; } /** * Remove spaces from data URLs which PHP-CSS-Parser doesn't handle. * * @since 1.0 * * @param string $css CSS. * @return string CSS with spaces removed from data URLs. */ private function remove_spaces_from_data_urls( $css ) { return preg_replace_callback( '/\burl\([^}]*?\)/', function( $matches ) { return preg_replace( '/\s+/', '', $matches[0] ); }, $css ); } /** * 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 ) { $results = array(); foreach ( $css_list->getContents() as $css_item ) { $sanitized = false; if ( $css_item instanceof DeclarationBlock && empty( $options['validate_keyframes'] ) ) { $results = array_merge( $results, $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 ) ) { $error = array( 'code' => self::ILLEGAL_AT_RULE_ERROR_CODE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); $results[] = compact( 'error', 'sanitized' ); } if ( ! $sanitized ) { $results = array_merge( $results, $this->process_css_list( $css_item, $options ) ); } } elseif ( $css_item instanceof Import ) { $results = array_merge( $results, $this->parse_import_stylesheet( $css_item, $css_list, $options ) ); } elseif ( $css_item instanceof AtRuleSet ) { if ( ! in_array( $css_item->atRuleName(), $options['allowed_at_rules'], true ) ) { $error = array( 'code' => self::ILLEGAL_AT_RULE_ERROR_CODE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); $results[] = compact( 'error', 'sanitized' ); } if ( ! $sanitized ) { $results = array_merge( $results, $this->process_css_declaration_block( $css_item, $css_list, $options ) ); } } elseif ( $css_item instanceof KeyFrame ) { if ( ! in_array( 'keyframes', $options['allowed_at_rules'], true ) ) { $error = array( 'code' => self::ILLEGAL_AT_RULE_ERROR_CODE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); $results[] = compact( 'error', 'sanitized' ); } if ( ! $sanitized ) { $results = array_merge( $results, $this->process_css_keyframes( $css_item, $options ) ); } } elseif ( $css_item instanceof AtRule ) { if ( 'charset' === $css_item->atRuleName() ) { /* * The @charset at-rule is not allowed in style elements, so it is not allowed in AMP. * If the @charset is defined, then it really should have already been acknowledged * by PHP-CSS-Parser when the CSS was parsed in the first place, so at this point * it is irrelevant and can be removed. */ $sanitized = true; } else { $error = array( 'code' => self::ILLEGAL_AT_RULE_ERROR_CODE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); $results[] = compact( 'error', 'sanitized' ); } } else { $error = array( 'code' => 'unrecognized_css', 'item' => get_class( $css_item ), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); $results[] = compact( 'error', 'sanitized' ); } if ( $sanitized ) { $css_list->remove( $css_item ); } } return $results; } /** * 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 ) { // URLs cannot have spaces in them, so strip them (especially when spaces get erroneously injected in data: URLs). $url_string = $url->getURL()->getString(); // For data: URLs, all that is needed is to remove spaces so set and continue. if ( 'data:' === substr( $url_string, 0, 5 ) ) { continue; } // If the URL is already absolute, continue since there there is nothing left to do. $parsed_url = wp_parse_url( $url_string ); if ( ! empty( $parsed_url['host'] ) || empty( $parsed_url['path'] ) || '/' === substr( $parsed_url['path'], 0, 1 ) ) { continue; } $parsed_url = wp_parse_url( $base_url . $url->getURL()->getString() ); // Resolve any relative parent directory paths. $path = $this->unrelativize_path( $parsed_url['path'] ); if ( is_wp_error( $path ) ) { continue; } $parsed_url['path'] = $path; $real_url = $this->reconstruct_url( $parsed_url ); $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 RuleSet $ruleset Ruleset. * @param CSSList $css_list CSS List. * @param array $options Options. * * @return array Validation results. */ private function process_css_declaration_block( RuleSet $ruleset, CSSList $css_list, $options ) { $results = array(); if ( $ruleset instanceof DeclarationBlock ) { $this->ampify_ruleset_selectors( $ruleset ); if ( 0 === count( $ruleset->getSelectors() ) ) { $css_list->remove( $ruleset ); return $results; } } // 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 ) ) { $error = array( 'code' => 'illegal_css_property', 'property_name' => $property->getRule(), 'property_value' => $property->getValue(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $ruleset->removeRule( $property->getRule() ); } $results[] = compact( 'error', 'sanitized' ); } } } else { foreach ( $options['property_blacklist'] as $illegal_property_name ) { $properties = $ruleset->getRules( $illegal_property_name ); foreach ( $properties as $property ) { $error = array( 'code' => 'illegal_css_property', 'property_name' => $property->getRule(), 'property_value' => (string) $property->getValue(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $ruleset->removeRule( $property->getRule() ); } $results[] = compact( 'error', 'sanitized' ); } } } if ( $ruleset instanceof AtRuleSet && 'font-face' === $ruleset->atRuleName() ) { $this->process_font_face_at_rule( $ruleset, $options ); } $results = array_merge( $results, $this->transform_important_qualifiers( $ruleset, $css_list ) ); // 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 $results; } /** * Process @font-face by making src URLs non-relative and converting data: URLs into file URLs (with educated guessing). * * @since 1.0 * * @param AtRuleSet $ruleset Ruleset for @font-face. * @param array $options { * Options. * * @type string $stylesheet_url Stylesheet URL, if available. * } */ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) { $src_properties = $ruleset->getRules( 'src' ); if ( empty( $src_properties ) ) { return; } // Obtain the font-family name to guess the filename. $font_family = null; $font_basename = null; $properties = $ruleset->getRules( 'font-family' ); if ( isset( $properties[0] ) ) { $font_family = trim( $properties[0]->getValue(), '"\'' ); // Remove all non-word characters from the font family to serve as the filename. $font_basename = preg_replace( '/[^A-Za-z0-9_\-]/', '', $font_family ); // Same as sanitize_key() minus case changes. } // Obtain the stylesheet base URL from which to guess font file locations. $stylesheet_base_url = null; if ( ! empty( $options['stylesheet_url'] ) ) { $stylesheet_base_url = preg_replace( ':[^/]+(\?.*)?(#.*)?$:', '', $options['stylesheet_url'] ); $stylesheet_base_url = trailingslashit( $stylesheet_base_url ); } // Attempt to transform data: URLs in src properties to be external file URLs. $converted_count = 0; 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 string[] $source_file_urls * @var URL[] $source_data_url_objects */ $source_file_urls = array(); $source_data_url_objects = array(); foreach ( $sources as $i => $source ) { if ( $source[0] instanceof URL ) { $value = $source[0]->getURL()->getString(); if ( 'data:' === substr( $value, 0, 5 ) ) { $source_data_url_objects[ $i ] = $source[0]; } else { $source_file_urls[ $i ] = $value; } } } // Convert data: URLs into regular URLs, assuming there will be a file present (e.g. woff fonts in core themes). foreach ( $source_data_url_objects as $i => $data_url ) { $mime_type = strtok( substr( $data_url->getURL()->getString(), 5 ), ';' ); if ( ! $mime_type ) { continue; } $extension = preg_replace( ':.+/(.+-)?:', '', $mime_type ); $guessed_urls = array(); // Guess URLs based on any other font sources that are not using data: URLs (e.g. truetype fallback for inline woff2). foreach ( $source_file_urls as $source_file_url ) { $guessed_url = preg_replace( ':(?<=\.)\w+(\?.*)?(#.*)?$:', // Match the file extension in the URL. $extension, $source_file_url, 1, $count ); if ( 1 === $count ) { $guessed_urls[] = $guessed_url; } } /* * Guess some font file URLs based on the font name in a fonts directory based on precedence of Twenty Nineteen. * For example, the NonBreakingSpaceOverride woff2 font file is located at fonts/NonBreakingSpaceOverride.woff2. */ if ( $stylesheet_base_url && $font_basename ) { $guessed_urls[] = $stylesheet_base_url . sprintf( 'fonts/%s.%s', $font_basename, $extension ); $guessed_urls[] = $stylesheet_base_url . sprintf( 'fonts/%s.%s', strtolower( $font_basename ), $extension ); } // Find the font file that exists, and then replace the data: URL with the external URL for the font. foreach ( $guessed_urls as $guessed_url ) { $path = $this->get_validated_url_file_path( $guessed_url, array( 'woff', 'woff2', 'ttf', 'otf', 'svg' ) ); if ( ! is_wp_error( $path ) ) { $data_url->getURL()->setString( $guessed_url ); $converted_count++; break; } } } // End foreach $source_data_url_objects. } // End foreach $src_properties. /* * If a data: URL has been replaced with an external file URL, then we add a font-display:swap to the @font-face * rule if one isn't already present. This prevents FO * * If no font-display is already present, add font-display:swap since the font is now being loaded externally. */ if ( $converted_count && 0 === count( $ruleset->getRules( 'font-display' ) ) ) { $font_display_rule = new Rule( 'font-display' ); $font_display_rule->setValue( 'swap' ); $ruleset->addRule( $font_display_rule ); } } /** * 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 results. */ private function process_css_keyframes( KeyFrame $css_list, $options ) { $results = array(); if ( ! empty( $options['property_whitelist'] ) ) { foreach ( $css_list->getContents() as $rules ) { if ( ! ( $rules instanceof DeclarationBlock ) ) { $error = array( 'code' => 'unrecognized_css', 'item' => get_class( $rules ), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $css_list->remove( $rules ); } $results[] = compact( 'error', 'sanitized' ); continue; } $results = array_merge( $results, $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 ) ) { $error = array( 'code' => 'illegal_css_property', 'property_name' => $property->getRule(), 'property_value' => (string) $property->getValue(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $rules->removeRule( $property->getRule() ); } $results[] = compact( 'error', 'sanitized' ); } } } } return $results; } /** * 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 results. */ private function transform_important_qualifiers( RuleSet $ruleset, CSSList $css_list ) { $results = array(); // An !important only makes sense for rulesets that have selectors. $allow_transformation = ( $ruleset instanceof DeclarationBlock && ! ( $css_list instanceof KeyFrame ) ); $properties = $ruleset->getRules(); $importants = array(); foreach ( $properties as $property ) { if ( $property->getIsImportant() ) { if ( $allow_transformation ) { $importants[] = $property; $property->setIsImportant( false ); $ruleset->removeRule( $property->getRule() ); } else { $error = array( 'code' => 'illegal_css_important', 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $property->setIsImportant( false ); } $results[] = compact( 'error', 'sanitized' ); } } } if ( ! $allow_transformation || empty( $importants ) ) { return $results; } $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 ) { // Calculate the specificity multiplier for the placeholder. $specificity_multiplier = AMP_Style_Sanitizer::INLINE_SPECIFICITY_MULTIPLIER + 1 + floor( $old_selector->getSpecificity() / 100 ); if ( $old_selector->getSpecificity() % 100 > 0 ) { $specificity_multiplier++; } if ( $old_selector->getSpecificity() % 10 > 0 ) { $specificity_multiplier++; } $selector_mod = str_repeat( ':not(#_)', $specificity_multiplier ); // Here "_" is just a short single-char ID. $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 ); $i = array_search( $ruleset, $css_list->getContents(), true ); if ( false !== $i && method_exists( $css_list, 'splice' ) ) { $css_list->splice( $i + 1, 0, array( $important_ruleset ) ); } else { $css_list->append( $important_ruleset ); } return $results; } /** * Collect and store all CSS style attributes. * * Collects the CSS styles from within the HTML contained in this instance's DOMDocument. * * @see Retrieve array of styles using $this->get_styles() after calling this method. * * @since 0.4 * @since 0.7 Modified to use element passed by XPath query. * * @note Uses recursion to traverse down the tree of DOMDocument nodes. * * @param DOMElement $element Node. */ private function collect_inline_styles( $element ) { $style_attribute = $element->getAttributeNode( 'style' ); if ( ! $style_attribute || ! trim( $style_attribute->nodeValue ) ) { return; } $class = 'amp-wp-' . substr( md5( $style_attribute->nodeValue ), 0, 7 ); $root = ':root' . str_repeat( ':not(#_)', self::INLINE_SPECIFICITY_MULTIPLIER ); $rule = sprintf( '%s .%s { %s }', $root, $class, $style_attribute->nodeValue ); $this->set_current_node( $element ); // And sources when needing to be located. $processed = $this->process_stylesheet( $rule, array( 'allowed_at_rules' => array(), 'property_whitelist' => $this->style_custom_cdata_spec['css_spec']['declaration'], ) ); $element->removeAttribute( 'style' ); if ( $processed['stylesheet'] ) { $this->pending_stylesheets[] = array( 'stylesheet' => $processed['stylesheet'], 'node' => $element, 'sources' => $this->current_sources, ); if ( $element->hasAttribute( 'class' ) ) { $element->setAttribute( 'class', $element->getAttribute( 'class' ) . ' ' . $class ); } else { $element->setAttribute( 'class', $class ); } } $this->set_current_node( null ); } /** * Finalize stylesheets for style[amp-custom] and style[amp-keyframes] elements. * * 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. * * @since 1.0 * @see https://www.ampproject.org/docs/fundamentals/spec#keyframes-stylesheet */ private function finalize_styles() { $stylesheet_sets = array( 'custom' => array( 'source_map_comment' => "\n\n/*# sourceURL=amp-custom.css */", '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( 'source_map_comment' => "\n\n/*# sourceURL=amp-keyframes.css */", 'total_size' => 0, 'cdata_spec' => $this->style_keyframes_cdata_spec, 'pending_stylesheets' => array(), 'final_stylesheets' => array(), 'remove_unused_rules' => 'never', // Not relevant. ), ); $imported_font_urls = array(); /* * On Native AMP themes when there are new/rejected validation errors present, a parsed stylesheet may include * @import rules. These must be moved to the beginning to be honored. */ $imports = array(); // 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 $i => $part ) { if ( is_string( $part ) ) { $size += strlen( $part ); if ( '@import' === substr( $part, 0, 7 ) ) { $imports[] = $part; unset( $pending_stylesheet['stylesheet'][ $i ] ); } } 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 ]['imports'] = $imports; $stylesheet_sets[ $set_name ]['pending_stylesheets'][] = $pending_stylesheet; if ( ! empty( $pending_stylesheet['imported_font_urls'] ) ) { $imported_font_urls = array_merge( $imported_font_urls, $pending_stylesheet['imported_font_urls'] ); } } // Process the pending stylesheets. foreach ( array_keys( $stylesheet_sets ) as $set_name ) { $stylesheet_sets[ $set_name ] = $this->finalize_stylesheet_set( $stylesheet_sets[ $set_name ] ); } $this->stylesheets = $stylesheet_sets['custom']['final_stylesheets']; // 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; } // Add style[amp-custom] to document. if ( ! empty( $stylesheet_sets['custom']['final_stylesheets'] ) ) { // 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', '' ); $this->head->appendChild( $this->amp_custom_style_element ); } $css = implode( '', $stylesheet_sets['custom']['imports'] ); // For native dirty AMP. $css .= implode( '', $stylesheet_sets['custom']['final_stylesheets'] ); $css .= $stylesheet_sets['custom']['source_map_comment']; /* * 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 ) ); $included_size = 0; $included_original_size = 0; $excluded_size = 0; $excluded_original_size = 0; $included_sources = array(); foreach ( $stylesheet_sets['custom']['pending_stylesheets'] as $i => $pending_stylesheet ) { if ( ! ( $pending_stylesheet['node'] instanceof DOMElement ) || ! empty( $pending_stylesheet['duplicate'] ) ) { continue; } $message = sprintf( '% 6d B', $pending_stylesheet['size'] ); if ( $pending_stylesheet['size'] && $pending_stylesheet['size'] !== $pending_stylesheet['original_size'] ) { $message .= sprintf( ' (%d%%)', $pending_stylesheet['size'] / $pending_stylesheet['original_size'] * 100 ); } $message .= ': '; $message .= $pending_stylesheet['node']->nodeName; if ( $pending_stylesheet['node']->getAttribute( 'id' ) ) { $message .= '#' . $pending_stylesheet['node']->getAttribute( 'id' ); } if ( $pending_stylesheet['node']->getAttribute( 'class' ) ) { $message .= '.' . $pending_stylesheet['node']->getAttribute( 'class' ); } foreach ( $pending_stylesheet['node']->attributes as $attribute ) { if ( 'id' !== $attribute->nodeName || 'class' !== $attribute->nodeName ) { $message .= sprintf( '[%s=%s]', $attribute->nodeName, $attribute->nodeValue ); } } if ( ! empty( $pending_stylesheet['included'] ) ) { $included_sources[] = $message; $included_size += $pending_stylesheet['size']; $included_original_size += $pending_stylesheet['original_size']; } else { $excluded_sources[] = $message; $excluded_size += $pending_stylesheet['size']; $excluded_original_size += $pending_stylesheet['original_size']; } } $include_manifest_comment = ( 'always' === $this->args['include_manifest_comment'] || ( $excluded_size > 0 && 'when_excessive' === $this->args['include_manifest_comment'] ) ); $comment = ''; if ( $include_manifest_comment && ! empty( $included_sources ) && $included_original_size > 0 ) { $comment .= sprintf( /* translators: %s: style[amp-custom] */ esc_html__( 'The %s element is populated with:', 'amp' ), 'style[amp-custom]' ) . "\n" . implode( "\n", $included_sources ) . "\n"; if ( self::has_required_php_css_parser() ) { $comment .= sprintf( /* translators: 1: number of included bytes. 2: percentage of total CSS actually included after tree shaking. 3: total included size. */ esc_html__( 'Total included size: %1$s bytes (%2$d%% of %3$s total after tree shaking)', 'amp' ), number_format_i18n( $included_size ), $included_size / $included_original_size * 100, number_format_i18n( $included_original_size ) ) . "\n"; } else { $comment .= sprintf( /* translators: %s: number of included bytes. */ esc_html__( 'Total included size: %s bytes', 'amp' ), number_format_i18n( $included_size ), $included_size / $included_original_size * 100, number_format_i18n( $included_original_size ) ) . "\n"; } } if ( $include_manifest_comment && ! empty( $excluded_sources ) && $excluded_original_size > 0 ) { if ( $comment ) { $comment .= "\n"; } $comment .= sprintf( /* translators: %s: style[amp-custom] */ esc_html__( 'The following stylesheets are too large to be included in %s:', 'amp' ), 'style[amp-custom]' ) . "\n" . implode( "\n", $excluded_sources ) . "\n"; if ( self::has_required_php_css_parser() ) { $comment .= sprintf( /* translators: 1: number of excluded bytes. 2: percentage of total CSS actually excluded even after tree shaking. 3: total excluded size. */ esc_html__( 'Total excluded size: %1$s bytes (%2$d%% of %3$s total after tree shaking)', 'amp' ), number_format_i18n( $excluded_size ), $excluded_size / $excluded_original_size * 100, number_format_i18n( $excluded_original_size ) ) . "\n"; } else { $comment .= sprintf( /* translators: %s: number of excluded bytes. */ esc_html__( 'Total excluded size: %s bytes', 'amp' ), number_format_i18n( $excluded_size ) ) . "\n"; } $total_size = $included_size + $excluded_size; $total_original_size = $included_original_size + $excluded_original_size; if ( $total_size !== $total_original_size ) { $comment .= "\n"; $comment .= sprintf( /* translators: 1: total combined bytes. 2: is percentage of CSS after tree shaking. 3: is total before tree shaking. */ esc_html__( 'Total combined size: %1$s bytes (%2$d%% of %3$s total after tree shaking)', 'amp' ), number_format_i18n( $total_size ), ( $total_size / $total_original_size ) * 100, number_format_i18n( $total_original_size ) ) . "\n"; } } if ( $include_manifest_comment && ! self::has_required_php_css_parser() ) { $comment .= "\n" . esc_html__( 'Warning! AMP CSS processing is limited because a conflicting version of PHP-CSS-Parser has been loaded by another plugin or theme. Tree shaking is not available.', 'amp' ) . "\n"; } if ( $comment ) { $this->amp_custom_style_element->parentNode->insertBefore( $this->dom->createComment( "\n$comment" ), $this->amp_custom_style_element ); } } /* * Add font stylesheets from CDNs which were extracted from @import rules. * We can't add crossorigin=anonymous to these since such a CORS request would not be made in the non-AMP version, * and so if the service worker cached the opaque response on the non-AMP version then it wouldn't be usable in * the AMP version if it was requested with CORS. */ foreach ( array_unique( $imported_font_urls ) as $imported_font_url ) { $link = $this->dom->createElement( 'link' ); $link->setAttribute( 'rel', 'stylesheet' ); $link->setAttribute( 'href', $imported_font_url ); $this->head->appendChild( $link ); } // Add style[amp-keyframes] to document. if ( ! empty( $stylesheet_sets['keyframes']['final_stylesheets'] ) ) { $body = $this->dom->getElementsByTagName( 'body' )->item( 0 ); if ( ! $body ) { $this->should_sanitize_validation_error( array( 'code' => 'missing_body_element', 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ) ); } else { $css = implode( '', $stylesheet_sets['keyframes']['final_stylesheets'] ); $css .= $stylesheet_sets['keyframes']['source_map_comment']; $style_element = $this->dom->createElement( 'style' ); $style_element->setAttribute( 'amp-keyframes', '' ); $style_element->appendChild( $this->dom->createTextNode( $css ) ); $body->appendChild( $style_element ); } } } /** * Convert CSS selectors and remove obsolete selector hacks for IE. * * @param DeclarationBlock $ruleset Ruleset. */ private function ampify_ruleset_selectors( $ruleset ) { $selectors = array(); $changes = 0; $language = strtolower( get_bloginfo( 'language' ) ); foreach ( $ruleset->getSelectors() as $old_selector ) { $selector = $old_selector->getSelector(); // Automatically tree-shake IE6/IE7 hacks for selectors with `* html` and `*+html`. if ( preg_match( '/^\*\s*\+?\s*html/', $selector ) ) { $changes++; continue; } // Automatically remove selectors with html[lang] that are for another language (and thus are irrelevant). This is safe because amp-bind'ed [lang] is not allowed. $is_other_language_root = ( preg_match( '/^html\[lang(?P\^)?=([\'"]?)(?P.+?)\2\]/', strtolower( $selector ), $matches ) && ( empty( $matches['starts_with'] ) ? $language !== $matches['lang'] : substr( $language, 0, strlen( $matches['lang'] ) ) !== $matches['lang'] ) ); if ( $is_other_language_root ) { $changes++; continue; } // Remove selectors with :lang() for another language (and thus irrelevant). if ( preg_match( '/:lang\((?P.+?)\)/', $selector, $matches ) ) { $has_matching_language = 0; $selector_languages = array_map( function ( $selector_language ) { return trim( $selector_language, '"\'' ); }, preg_split( '/\s*,\s*/', strtolower( trim( $matches['languages'] ) ) ) ); foreach ( $selector_languages as $selector_language ) { /* * The following logic accounts for the following conditions, where all but the last is a match: * * N: en && fr * Y: en && en * Y: en && en-US * Y: en-US && en * N: en-US && en-UK */ if ( substr( $language, 0, strlen( $selector_language ) ) === $selector_language || substr( $selector_language, 0, strlen( $language ) ) === $language ) { $has_matching_language = true; break; } } if ( ! $has_matching_language ) { $changes++; continue; } } // An element (type) either starts a selector or is preceded by combinator, comma, opening paren, or closing brace. $before_type_selector_pattern = '(?<=^|\(|\s|>|\+|~|,|})'; $after_type_selector_pattern = '(?=$|[^a-zA-Z0-9_-])'; $edited_selectors = array( $selector ); foreach ( $this->selector_mappings as $html_selector => $amp_selectors ) { // Note: The $selector_mappings array contains ~6 items. $html_pattern = '/' . $before_type_selector_pattern . preg_quote( $html_selector, '/' ) . $after_type_selector_pattern . '/i'; foreach ( $edited_selectors as &$edited_selector ) { // Note: The $edited_selectors array contains only item in the normal case. $original_selector = $edited_selector; $amp_selector = array_shift( $amp_selectors ); $amp_tag_pattern = '/' . $before_type_selector_pattern . preg_quote( $amp_selector, '/' ) . $after_type_selector_pattern . '/i'; preg_match( $amp_tag_pattern, $edited_selector, $matches ); if ( ! empty( $matches ) && $amp_selector === $matches[0] ) { continue; } $edited_selector = preg_replace( $html_pattern, $amp_selector, $edited_selector, -1, $count ); if ( ! $count ) { continue; } $changes += $count; while ( ! empty( $amp_selectors ) ) { // Note: This array contains only a couple items. $amp_selector = array_shift( $amp_selectors ); $edited_selectors[] = preg_replace( $html_pattern, $amp_selector, $original_selector, -1, $count ); } } } $selectors = array_merge( $selectors, $edited_selectors ); } if ( $changes > 0 ) { $ruleset->setSelectors( $selectors ); } } /** * Finalize a stylesheet set (amp-custom or amp-keyframes). * * @since 1.0 * * @param array $stylesheet_set Stylesheet set. * @return array Finalized stylesheet set. */ private function finalize_stylesheet_set( $stylesheet_set ) { $max_bytes = $stylesheet_set['cdata_spec']['max_bytes'] - strlen( $stylesheet_set['source_map_comment'] ); $is_too_much_css = $stylesheet_set['total_size'] > $max_bytes; $should_tree_shake = ( 'always' === $stylesheet_set['remove_unused_rules'] || ( $is_too_much_css && 'sometimes' === $stylesheet_set['remove_unused_rules'] ) ); if ( $is_too_much_css && $should_tree_shake && empty( $this->args['accept_tree_shaking'] ) ) { $should_tree_shake = $this->should_sanitize_validation_error( array( 'code' => self::TREE_SHAKING_ERROR_CODE, 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ) ); } $stylesheet_set['processed_nodes'] = array(); $final_size = 0; $dom = $this->dom; foreach ( $stylesheet_set['pending_stylesheets'] as &$pending_stylesheet ) { $stylesheet_parts = array(); $original_size = 0; foreach ( $pending_stylesheet['stylesheet'] as $stylesheet_part ) { if ( is_string( $stylesheet_part ) ) { $stylesheet_parts[] = $stylesheet_part; $original_size += strlen( $stylesheet_part ); continue; } list( $selectors_parsed, $declaration_block ) = $stylesheet_part; if ( $should_tree_shake ) { $selectors = array(); foreach ( $selectors_parsed as $selector => $parsed_selector ) { $should_include = ( ( // If all class names are used in the doc. ( empty( $parsed_selector[ self::SELECTOR_EXTRACTED_CLASSES ] ) || $this->has_used_class_name( $parsed_selector[ self::SELECTOR_EXTRACTED_CLASSES ] ) ) && // If all IDs are used in the doc. ( empty( $parsed_selector[ self::SELECTOR_EXTRACTED_IDS ] ) || 0 === count( array_filter( $parsed_selector[ self::SELECTOR_EXTRACTED_IDS ], function( $id ) use ( $dom ) { return ! $dom->getElementById( $id ); } ) ) ) && // If tag names are present in the doc. ( empty( $parsed_selector[ self::SELECTOR_EXTRACTED_TAGS ] ) || $this->has_used_tag_names( $parsed_selector[ self::SELECTOR_EXTRACTED_TAGS ] ) ) && // If all attribute names are used in the doc. ( empty( $parsed_selector[ self::SELECTOR_EXTRACTED_ATTRIBUTES ] ) || $this->has_used_attributes( $parsed_selector[ self::SELECTOR_EXTRACTED_ATTRIBUTES ] ) ) ) ); if ( $should_include ) { $selectors[] = $selector; } } } else { $selectors = array_keys( $selectors_parsed ); } $stylesheet_part = implode( ',', $selectors ) . $declaration_block; $original_size += strlen( $stylesheet_part ); if ( ! empty( $selectors ) ) { $stylesheet_parts[] = $stylesheet_part; } } // Strip empty at-rules after tree shaking. $stylesheet_part_count = count( $stylesheet_parts ); for ( $i = 0; $i < $stylesheet_part_count; $i++ ) { $stylesheet_part = $stylesheet_parts[ $i ]; if ( '@' !== substr( $stylesheet_part, 0, 1 ) ) { continue; } // Delete empty at-rules. if ( '{}' === substr( $stylesheet_part, -2 ) ) { $stylesheet_part_count--; array_splice( $stylesheet_parts, $i, 1 ); $i--; continue; } // Delete at-rules that were emptied due to tree-shaking. if ( '{' === substr( $stylesheet_part, -1 ) ) { $open_braces = 1; for ( $j = $i + 1; $j < $stylesheet_part_count; $j++ ) { $stylesheet_part = $stylesheet_parts[ $j ]; $is_at_rule = '@' === substr( $stylesheet_part, 0, 1 ); if ( empty( $stylesheet_part ) ) { continue; // There was a shaken rule. } elseif ( $is_at_rule && '{}' === substr( $stylesheet_part, -2 ) ) { continue; // The rule opens is empty from the start. } elseif ( $is_at_rule && '{' === substr( $stylesheet_part, -1 ) ) { $open_braces++; } elseif ( '}' === $stylesheet_part ) { $open_braces--; } else { break; } // Splice out the parts that are empty. if ( 0 === $open_braces ) { array_splice( $stylesheet_parts, $i, $j - $i + 1 ); $stylesheet_part_count = count( $stylesheet_parts ); $i--; continue 2; } } } } $pending_stylesheet['original_size'] = $original_size; $stylesheet = implode( '', $stylesheet_parts ); unset( $stylesheet_parts ); $sheet_size = strlen( $stylesheet ); $pending_stylesheet['size'] = $sheet_size; // Skip considering stylesheet if an identical one has already been processed. $hash = md5( $stylesheet ); if ( isset( $stylesheet_set['final_stylesheets'][ $hash ] ) ) { $pending_stylesheet['included'] = true; $pending_stylesheet['duplicate'] = true; continue; } $pending_stylesheet['duplicate'] = false; // Report validation error if size is now too big. if ( $final_size + $sheet_size > $max_bytes ) { $validation_error = array( 'code' => 'excessive_css', 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, ); if ( isset( $pending_stylesheet['sources'] ) ) { $validation_error['sources'] = $pending_stylesheet['sources']; } if ( $this->should_sanitize_validation_error( $validation_error, wp_array_slice_assoc( $pending_stylesheet, array( 'node' ) ) ) ) { $pending_stylesheet['included'] = false; continue; // Skip to the next stylesheet. } } $final_size += $sheet_size; $pending_stylesheet['included'] = true; $stylesheet_set['final_stylesheets'][ $hash ] = $stylesheet; } return $stylesheet_set; } }