// Class names for amp-geo, see . case 'amp-iso-country-': if ( ! $this->has_used_tag_names( [ 'amp-geo' ] ) ) { return false; } continue 2; } } elseif ( ctype_upper( $class_name[0] ) && $this->has_used_tag_names( [ 'amp-date-picker' ] ) && $this->is_class_allowed_in_amp_date_picker( $class_name ) ) { // If the document has an amp-date-picker tag, check if this class is an allowed child of it. // That component's child classes won't be present yet in the document, so prevent tree-shaking valid classes. // The ctype_upper() check is an optimization since we know up front that all class names in React Dates are // in CamelCase form, thus we can short-circut if the first character of the class name is not upper-case. 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 = []; 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->dom->xpath->query( $expression )->length ); } // Attributes for amp-accordion, see . if ( 'expanded' === $attribute_name ) { if ( ! $this->has_used_tag_names( [ 'amp-accordion' ] ) ) { return false; } continue; } // Attributes for amp-sidebar, see . if ( 'open' === $attribute_name ) { // The 'open' attribute is also used by the HTML5
attribute. if ( ! $this->has_used_tag_names( [ 'amp-sidebar' ] ) && ! $this->has_used_tag_names( [ 'details' ] ) ) { return false; } continue; } // Attributes for amp-live-list, see . if ( 'data-tombstone' === $attribute_name ) { if ( ! $this->has_used_tag_names( [ 'amp-live-list' ] ) ) { return false; } continue; } // Attributes for amp-experiment begin with 'amp-x-', see . if ( 'amp-x-' === substr( $attribute_name, 0, 6 ) ) { if ( ! $this->has_used_tag_names( [ 'amp-experiment' ] ) ) { return false; } continue; } if ( ! $this->used_attributes[ $attribute_name ] ) { return false; } } return true; } /** * Whether a given class is allowed to be styled in . * * That component has child classes that won't be present in the document yet. * So get whether a class is an allowed child. * * @since 1.5.0 * @link https://github.com/airbnb/react-dates/tree/05356/src/components * * @param string $class The name of the class to evaluate. * @return bool Whether the class is allowed as a child of . */ private function is_class_allowed_in_amp_date_picker( $class ) { static $class_prefixes = [ 'CalendarDay', 'CalendarMonth', 'CalendarMonthGrid', 'DayPicker', 'DayPickerKeyboardShortcuts', 'DayPickerNavigation', 'KeyboardShortcutRow', ]; return in_array( strtok( $class, '_' ), $class_prefixes, 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 ); $this->sanitizers = $sanitizers; } /** * Sanitize CSS styles within the HTML contained in this instance's Dom\Document. * * @since 0.4 */ public function sanitize() { // When style processing is disabled, simply mark all the CSS elements/attributes as PX-verified. if ( $this->args['disable_style_processing'] ) { foreach ( $this->dom->xpath->query( '//link[ @rel = "stylesheet" and @href ] | //style | //*/@style' ) as $node ) { ValidationExemption::mark_node_as_px_verified( $node ); // Since stylesheet links are allowed in the HEAD if they are for fonts, mark the href specifically as being the exempted attribute. if ( $node instanceof Element && Tag::LINK === $node->tagName ) { ValidationExemption::mark_node_as_px_verified( $node->getAttributeNode( Attribute::HREF ) ); ValidationExemption::mark_node_as_px_verified( $node->getAttributeNode( Attribute::REL ) ); } } return; } // Capture the selector conversion mappings from the other sanitizers. foreach ( $this->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. if ( $sanitizer->has_light_shadow_dom() ) { $this->args['dynamic_element_selectors'] = array_merge( $this->args['dynamic_element_selectors'], $this->selector_mappings[ $html_selectors ] ); } } } $elements = []; $this->focus_class_name_selector_pattern = ( ! empty( $this->args['focus_within_classes'] ) ? self::get_class_name_selector_pattern( $this->args['focus_within_classes'] ) : null ); /* * 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. */ // @todo Also consider skipping the processing of link and style elements that have data-px-verified-tag. $dev_mode_predicate = ''; if ( DevMode::isActiveForDocument( $this->dom ) ) { $dev_mode_predicate = sprintf( ' and not ( @%s )', AMP_Rule_Spec::DEV_MODE_ATTRIBUTE ); } $lower_case = 'translate( %s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" )'; // In XPath 2.0 this is lower-case(). $predicates = [ sprintf( '( self::style and not( @amp-boilerplate ) and ( not( @type ) or %s = "text/css" ) %s )', sprintf( $lower_case, '@type' ), $dev_mode_predicate ), sprintf( '( self::link and @href and %s = "stylesheet" %s )', sprintf( $lower_case, '@rel' ), $dev_mode_predicate ), ]; foreach ( $this->dom->xpath->query( '//*[ ' . implode( ' or ', $predicates ) . ' ]' ) as $element ) { $elements[] = $element; } // If 'width' attribute is present for 'col' tag, convert to proper CSS rule. // @todo The width attribute on the tag is probably something that should just be allowed in AMP. 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 ); // If the element is still in the document, it is a font stylesheet; make sure it gets moved to the head as required. if ( $element->parentNode && 'head' !== $element->parentNode->nodeName ) { $this->dom->head->appendChild( $element->parentNode->removeChild( $element ) ); } } } $styled_elements = $this->dom->xpath->query( "//*[ @style $dev_mode_predicate ]" ); if ( $this->args['transform_important_qualifiers'] ) { foreach ( iterator_to_array( $styled_elements ) as $element ) { $this->collect_inline_styles( $element ); } } else { foreach ( $styled_elements as $element ) { $attr = $element->getAttributeNode( Attribute::STYLE ); if ( $attr && preg_match( '/!\s*important/i', $attr->value ) ) { ValidationExemption::mark_node_as_px_verified( $attr ); } } } $this->finalize_styles(); $this->did_convert_elements = true; $parse_css_duration = 0.0; $shake_css_duration = 0.0; foreach ( $this->pending_stylesheets as $pending_stylesheet ) { if ( ! $pending_stylesheet['cached'] ) { $parse_css_duration += $pending_stylesheet['parse_time']; } $shake_css_duration += $pending_stylesheet['shake_time']; } // TODO: These cannot use actions when we extract the sanitizers into an external library. /** * Logs the server-timing measurement for the CSS parsing. * * @since 2.0 * @internal * * @param string $event_name Name of the event to log. * @param string $event_description Description of the event to log. * @param string[] $properties Optional. Additional properties to add * to the logged record. * @param bool $verbose_only Optional. Whether to only show the * event in verbose mode. */ do_action( 'amp_server_timing_log', 'amp_parse_css', '', [ 'dur' => $parse_css_duration * 1000 ], true ); /** * Logs the server-timing measurement for the CSS tree-shaking. * * @since 2.0 * @internal * * @param string $event_name Name of the event to log. * @param string $event_description Description of the event to log. * @param string[] $properties Optional. Additional properties to add * to the logged record. * @param bool $verbose_only Optional. Whether to only show the * event in verbose mode. */ do_action( 'amp_server_timing_log', 'amp_shake_css', '', [ 'dur' => $shake_css_duration * 1000 ], true ); } /** * Get the priority of the stylesheet associated with the given element. * * As with hooks, lower priorities mean they should be included first. * The higher the priority value, the more likely it will be that the * stylesheet will be among those excluded due to STYLESHEET_TOO_LONG when * concatenated CSS reaches 75KB. * * @todo This will eventually need to be abstracted to not be CMS-specific, allowing for the prioritization scheme to be defined by configuration. * * @param DOMNode|DOMElement|DOMAttr $node Node. * @return int Priority. */ private function get_stylesheet_priority( DOMNode $node ) { $print_priority_base = 100; $admin_bar_priority = 200; $remove_url_scheme = static function( $url ) { return preg_replace( '/^https?:/', '', $url ); }; if ( $node instanceof DOMElement && 'link' === $node->tagName ) { $element_id = (string) $node->getAttribute( 'id' ); $schemeless_href = $remove_url_scheme( $node->getAttribute( 'href' ) ); $plugin = null; if ( preg_match( sprintf( '#^(?:%s|%s)(?[^/]+)#i', preg_quote( $remove_url_scheme( trailingslashit( WP_PLUGIN_URL ) ), '#' ), preg_quote( $remove_url_scheme( trailingslashit( WPMU_PLUGIN_URL ) ), '#' ) ), $schemeless_href, $matches ) ) { $plugin = $matches['plugin']; } $style_handle = null; if ( preg_match( '/^(.+)-css$/', $element_id, $matches ) ) { $style_handle = $matches[1]; } $core_frontend_handles = [ 'wp-block-library', 'wp-block-library-theme', ]; $non_amp_handles = [ 'mediaelement', 'wp-mediaelement', 'thickbox', ]; if ( in_array( $style_handle, $non_amp_handles, true ) ) { // Styles are for non-AMP JS only so not be used in AMP at all. $priority = 1000; } elseif ( 'admin-bar' === $style_handle ) { // Admin bar has lowest priority. If it gets excluded, then the entire admin bar should be removed. $priority = $admin_bar_priority; } elseif ( 'dashicons' === $style_handle ) { // Dashicons could be used by the theme, but low priority compared to other styles. $priority = 90; } elseif ( false !== strpos( $schemeless_href, $remove_url_scheme( trailingslashit( get_template_directory_uri() ) ) ) ) { // Highest priority are parent theme styles. $priority = 1; } elseif ( false !== strpos( $schemeless_href, $remove_url_scheme( trailingslashit( get_stylesheet_directory_uri() ) ) ) ) { // Penultimate highest priority are child theme styles. $priority = 10; } elseif ( in_array( $style_handle, $core_frontend_handles, true ) ) { // Styles from wp-includes which are enqueued for themes are next highest priority. $priority = 20; } elseif ( $plugin ) { // Styles from plugins are next-highest priority, unless they are in the list of low-priority plugins. $priority = in_array( $plugin, $this->args['low_priority_plugins'], true ) ? 150 : 30; } elseif ( 0 === strpos( $schemeless_href, $remove_url_scheme( includes_url() ) ) ) { // Other styles from wp-includes come next. $priority = 40; } else { // Everything else, perhaps wp-admin styles or stylesheets from remote servers. $priority = 50; } if ( 'print' === $node->getAttribute( 'media' ) ) { $priority += $print_priority_base; } } elseif ( $node instanceof DOMElement && 'style' === $node->tagName && $node->hasAttribute( 'id' ) ) { $id = $node->getAttribute( 'id' ); $dependency = null; if ( preg_match( '/^(?.+)-inline-css$/', $id, $matches ) ) { $dependency = wp_styles()->query( $matches['handle'], 'registered' ); } if ( $dependency && ( 0 === strpos( $dependency->src, get_template_directory_uri() ) || // Add special case for core theme sanitizer which sets the src of the theme stylesheet to false // in order to attach the amended stylesheet contents as an inline style for AMP-compatibility. // See AMP_Core_Theme_Sanitizer::amend_twentytwentyone_styles() and // AMP_Core_Theme_Sanitizer::amend_twentytwentyone_dark_mode_styles(). 'twenty-twenty-one-style' === $dependency->handle ) ) { // Parent theme inline style. $priority = 2; } elseif ( $dependency && get_stylesheet() !== get_template() && 0 === strpos( $dependency->src, get_stylesheet_directory_uri() ) ) { // Child theme inline style. $priority = 12; } elseif ( 'admin-bar-inline-css' === $id ) { $priority = $admin_bar_priority; } elseif ( 'wp-custom-css' === $id ) { // Additional CSS from Customizer. $priority = 60; } else { // Other style elements, including from Recent Comments widget. $priority = 70; } if ( 'print' === $node->getAttribute( 'media' ) ) { $priority += $print_priority_base; } } else { // Style attribute. $priority = 70; } return $priority; } /** * 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 ) ) { return new WP_Error( self::STYLESHEET_INVALID_RELATIVE_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 = [] ) { if ( ! is_string( $url ) ) { return new WP_Error( self::STYLESHEET_URL_SYNTAX_ERROR ); } $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'] ) ) { return new WP_Error( self::STYLESHEET_URL_SYNTAX_ERROR ); } if ( empty( $parsed_url['path'] ) ) { return new WP_Error( self::STYLESHEET_URL_SYNTAX_ERROR ); } $path = $this->unrelativize_path( $parsed_url['path'] ); if ( is_wp_error( $path ) ) { return $path; } $parsed_url['path'] = $path; $remove_url_scheme = static 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 = [ 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( self::STYLESHEET_DISALLOWED_FILE_EXT ); } } if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) { /* translators: %s: the file URL */ return new WP_Error( self::STYLESHEET_EXTERNAL_FILE_URL ); } $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, '..\\' ) ) { return new WP_Error( self::STYLESHEET_FILE_PATH_NOT_ALLOWED ); } if ( ! file_exists( $base_path . $file_path ) ) { return new WP_Error( self::STYLESHEET_FILE_PATH_NOT_FOUND ); } 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 ); } // @todo If ValidationExemption::is_px_verified_for_node( $element ) then keep !important. // @todo If ValidationExemption::is_amp_unvalidated_for_node( $element ) then keep invalid markup. $parsed = $this->get_parsed_stylesheet( $stylesheet, [ 'allowed_at_rules' => $cdata_spec['css_spec']['allowed_at_rules'], 'property_allowlist' => $cdata_spec['css_spec']['declaration'], 'validate_keyframes' => $cdata_spec['css_spec']['validate_keyframes'], 'spec_name' => $is_keyframes ? self::STYLE_AMP_KEYFRAMES_SPEC_NAME : self::STYLE_AMP_CUSTOM_SPEC_NAME, ] ); if ( $parsed['viewport_rules'] ) { $this->create_meta_viewport( $element, $parsed['viewport_rules'] ); } $this->pending_stylesheets[] = [ 'group' => $is_keyframes ? self::STYLE_AMP_KEYFRAMES_GROUP_INDEX : self::STYLE_AMP_CUSTOM_GROUP_INDEX, 'original_size' => (int) strlen( $stylesheet ), 'final_size' => null, 'element' => $element, 'origin' => 'style_element', 'sources' => $this->current_sources, 'priority' => $this->get_stylesheet_priority( $element ), 'tokens' => $parsed['tokens'], 'hash' => $parsed['hash'], 'parse_time' => $parsed['parse_time'], 'shake_time' => null, 'cached' => $parsed['cached'], 'imported_font_urls' => $parsed['imported_font_urls'], 'important_count' => $parsed['important_count'], 'kept_error_count' => $parsed['kept_error_count'], 'preload_font_urls' => $parsed['preload_font_urls'], ]; // Remove from DOM since we'll be adding it to a newly-created style[amp-custom] element later. $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->dom->xpath->query( '//link[ @rel = "preconnect" and @crossorigin and starts-with( @href, "https://fonts.gstatic.com" ) ]', $this->dom->head )->length ); if ( $needs_preconnect_link ) { $link = AMP_DOM_Utils::create_node( $this->dom, 'link', [ 'rel' => 'preconnect', 'href' => 'https://fonts.gstatic.com/', 'crossorigin' => '', ] ); $this->dom->head->insertBefore( $link ); // Note that \AMP_Theme_Support::ensure_required_markup() will put this in the optimal order. } return; } $stylesheet = $this->get_stylesheet_from_url( $href ); if ( $stylesheet instanceof WP_Error ) { $this->remove_invalid_child( $element, [ 'code' => self::STYLESHEET_FETCH_ERROR, 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'url' => $normalized_url, 'message' => $stylesheet->get_error_message(), ] ); 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. // @todo If ValidationExemption::is_px_verified_for_node( $element ) then keep !important. // @todo If ValidationExemption::is_amp_unvalidated_for_node( $element ) then keep invalid markup. $parsed = $this->get_parsed_stylesheet( $stylesheet, [ 'allowed_at_rules' => $this->style_custom_cdata_spec['css_spec']['allowed_at_rules'], 'property_allowlist' => $this->style_custom_cdata_spec['css_spec']['declaration'], 'stylesheet_url' => $href, 'spec_name' => self::STYLE_AMP_CUSTOM_SPEC_NAME, ] ); if ( $parsed['viewport_rules'] ) { $this->create_meta_viewport( $element, $parsed['viewport_rules'] ); } $this->pending_stylesheets[] = [ 'group' => self::STYLE_AMP_CUSTOM_GROUP_INDEX, 'original_size' => strlen( $stylesheet ), 'final_size' => null, 'element' => $element, 'origin' => 'link_element', 'sources' => $this->current_sources, // Needed because node is removed below. 'priority' => $this->get_stylesheet_priority( $element ), 'tokens' => $parsed['tokens'], 'hash' => $parsed['hash'], 'parse_time' => $parsed['parse_time'], 'shake_time' => null, 'cached' => $parsed['cached'], 'imported_font_urls' => $parsed['imported_font_urls'], 'important_count' => $parsed['important_count'], 'kept_error_count' => $parsed['kept_error_count'], 'preload_font_urls' => $parsed['preload_font_urls'], ]; // Remove now that styles have been processed. $element->parentNode->removeChild( $element ); $this->set_current_node( null ); } /** * Get stylesheet from URL. * * @since 1.5.0 * * @param string $stylesheet_url Stylesheet URL. * @return string|WP_Error Stylesheet string on success, or WP_Error on failure. */ private function get_stylesheet_from_url( $stylesheet_url ) { $stylesheet = false; $css_file_path = $this->get_validated_url_file_path( $stylesheet_url, [ 'css', 'less', 'scss', 'sass' ] ); if ( ! is_wp_error( $css_file_path ) ) { $stylesheet = file_get_contents( $css_file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request. } if ( is_string( $stylesheet ) ) { return $stylesheet; } // Fall back to doing an HTTP request for the stylesheet is not accessible directly from the filesystem. return $this->fetch_external_stylesheet( $stylesheet_url ); } /** * Fetch external stylesheet. * * @param string $url External stylesheet URL. * @return string|WP_Error Stylesheet contents or WP_Error. */ private function fetch_external_stylesheet( $url ) { // Prepend schemeless stylesheet URL with the same URL scheme as the current site. if ( '//' === substr( $url, 0, 2 ) ) { $url = wp_parse_url( home_url(), PHP_URL_SCHEME ) . ':' . $url; } try { $response = $this->remote_request->get( $url ); } catch ( Exception $exception ) { if ( $exception instanceof FailedToGetFromRemoteUrl && $exception->hasStatusCode() ) { return new WP_Error( "http_{$exception->getStatusCode()}", $exception->getMessage() ); } /* translators: %1$s: the fetched URL, %2$s the error message that was returned */ return new WP_Error( 'http_error', sprintf( __( 'Failed to fetch: %1$s (%2$s)', 'amp' ), $url, $exception->getMessage() ) ); } $status = $response->getStatusCode(); if ( $status < 200 || $status >= 300 ) { /* translators: %1$s: the URL, %2$d: the HTTP status code, %3$s: the HTTP status message */ return new WP_Error( "http_{$status}", sprintf( __( 'Failed to fetch: %1$s (HTTP %2$d: %3$s)', 'amp' ), $url, $status, get_status_header_desc( $status ) ) ); } $content_type = (array) $response->getHeader( 'content-type' ); if ( ! empty( $content_type ) && ! preg_match( '#^text/css#', $content_type[0] ) ) { return new WP_Error( 'no_css_content_type', __( 'Response did not contain the expected text/css content type.', 'amp' ) ); } return $response->getBody(); } /** * Get parsed stylesheet (from cache). * * If the sanitization status has changed for the validation errors in the cached stylesheet since it was cached, * then the cache is invalidated, as the parsed stylesheet needs to be re-constructed. * * @since 1.0 * @see \AMP_Style_Sanitizer::parse_stylesheet() * * @param string $stylesheet Stylesheet. * @param array $options { * Options. * * @type string[] $property_allowlist Exclusively-allowed properties. * @type string[] $property_denylist Disallowed properties. * @type string $stylesheet_url Original URL for stylesheet when originating via link or @import. * @type array $allowed_at_rules Allowed @-rules. * @type bool $validate_keyframes Whether keyframes should be validated. * @type string $spec_name Spec name. * } * @return array { * Processed stylesheet. * * @type array $tokens Stylesheet tokens, where arrays are tuples for declaration blocks. * @type string $hash MD5 hash of the parsed stylesheet. * @type array $validation_results Validation results, array containing arrays with error and sanitized keys. * @type array $imported_font_urls Imported font stylesheet URLs. * @type int $priority The priority of the stylesheet. * @type float $parse_time The time duration it took to parse the stylesheet, in milliseconds. * @type float $shake_time The time duration it took to tree-shake the stylesheet, in milliseconds. * @type bool $cached Whether the parsed stylesheet was cached. * @type int $important_count Number of !important qualifiers. * @type int $kept_error_count Number of instances of invalid markup causing validation errors which are kept. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function get_parsed_stylesheet( $stylesheet, $options = [] ) { $cached = true; $cache_group = 'amp-parsed-stylesheet-v38'; // This should be bumped whenever the PHP-CSS-Parser is updated or parsed format is updated. $use_transients = $this->should_use_transient_caching(); // @todo If ValidationExemption::is_px_verified_for_node( $this->current_node ) then keep !important. // @todo If ValidationExemption::is_amp_unvalidated_for_node( $this->current_node ) then keep invalid markup. $cache_impacting_options = array_merge( wp_array_slice_assoc( $options, [ 'property_allowlist', 'property_denylist', 'stylesheet_url', 'allowed_at_rules', ] ), wp_array_slice_assoc( $this->args, [ 'should_locate_sources', 'parsed_cache_variant', 'dynamic_element_selectors', 'transform_important_qualifiers', 'font_face_display_overrides', ] ), [ 'language' => get_bloginfo( 'language' ), // Used to tree-shake html[lang] selectors. 'selector_mappings' => $this->selector_mappings, ] ); $cache_key = md5( $stylesheet . wp_json_encode( $cache_impacting_options ) ); if ( $use_transients ) { $parsed = get_transient( $cache_group . '-' . $cache_key ); } else { $parsed = wp_cache_get( $cache_key, $cache_group ); } /* * 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['tokens'] ) || ! is_array( $parsed['tokens'] ) ) { $parsed = $this->parse_stylesheet( $stylesheet, $options ); $cached = false; /* * 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 ( $use_transients ) { // 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 ); } else { wp_cache_set( $cache_key, $parsed, $cache_group ); } } $parsed['kept_error_count'] = 0; foreach ( $parsed['validation_results'] as $validation_result ) { if ( ! $validation_result['sanitized'] ) { $parsed['kept_error_count']++; } } $parsed['cached'] = $cached; return $parsed; } /** * Check whether transient caching for stylesheets should be used. * * @return bool Whether transient caching should be used. */ private function should_use_transient_caching() { if ( wp_using_ext_object_cache() ) { return false; } if ( ! $this->args['allow_transient_caching'] ) { return false; } if ( AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ) ) { return false; } return true; } /** * Parse imported stylesheet and replace the `@import` rule with the imported rules in the provided CSS list (in place). * * @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 { * Results. * * @type array $validation_results Validation results. * @type array $imported_font_urls Imported font URLs. * @type array $viewport_rules Extracted viewport rules. * @type int $important_count Number of !important qualifiers. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function splice_imported_stylesheet( Import $item, CSSList $css_list, $options ) { $validation_results = []; $imported_font_urls = []; $viewport_rules = []; $at_rule_args = $item->atRuleArgs(); $location = array_shift( $at_rule_args ); $media_query = array_shift( $at_rule_args ); $important_count = 0; $preload_font_urls = []; if ( isset( $options['stylesheet_url'] ) ) { $this->real_path_urls( [ $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 compact( 'validation_results', 'imported_font_urls', 'viewport_rules', 'important_count', 'preload_font_urls' ); } $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 ) ) { $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 compact( 'validation_results', 'imported_font_urls', 'viewport_rules', 'important_count', 'preload_font_urls' ); } $stylesheet = $this->get_stylesheet_from_url( $import_stylesheet_url ); if ( $stylesheet instanceof WP_Error ) { $error = [ 'code' => self::STYLESHEET_FETCH_ERROR, 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'url' => $import_stylesheet_url, 'message' => $stylesheet->get_error_message(), ]; $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $css_list->remove( $item ); } $validation_results[] = compact( 'error', 'sanitized' ); return compact( 'validation_results', 'imported_font_urls', 'viewport_rules', 'important_count', 'preload_font_urls' ); } if ( $media_query ) { $stylesheet = sprintf( '@media %s { %s }', $media_query, $stylesheet ); } $options['stylesheet_url'] = $import_stylesheet_url; $parsed_stylesheet = $this->create_validated_css_document( $stylesheet, $options ); $validation_results = array_merge( $validation_results, $parsed_stylesheet['validation_results'] ); $viewport_rules = $parsed_stylesheet['viewport_rules']; $important_count = $parsed_stylesheet['important_count']; $preload_font_urls = $parsed_stylesheet['preload_font_urls']; if ( ! empty( $parsed_stylesheet['css_document'] ) ) { /** * CSS Doc. * * @var CSSDocument $css_document */ $css_document = $parsed_stylesheet['css_document']; $this->replace_inside_css_list( $css_list, $item, $css_document->getContents() ); } else { $css_list->remove( $item ); } return compact( 'validation_results', 'imported_font_urls', 'viewport_rules', 'important_count', 'preload_font_urls' ); } /** * Replace an item inside of a CSSList. * * This is being used instead of `CSSList::splice()` because it uses `array_splice()` which does not work properly * if the array keys are not sequentially indexed from 0, which happens when `CSSList::remove()` is employed. * * @see CSSList::splice() * @see CSSList::replace() * @see CSSList::remove() * * @param CSSList $css_list CSS list. * @param AtRule|RuleSet|CSSList $old_item Old item. * @param AtRule[]|RuleSet[]|CSSList[] $new_items New item(s). If empty, the old item is simply removed. * @return bool Whether the replacement was successful. */ private function replace_inside_css_list( CSSList $css_list, $old_item, $new_items = [] ) { $contents = array_values( $css_list->getContents() ); // Required to obtain the offset instead of the index. $offset = array_search( $old_item, $contents, true ); if ( false !== $offset ) { array_splice( $contents, $offset, 1, $new_items ); $css_list->setContents( $contents ); return true; } return false; } /** * Create validated CSS document. * * @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 CSSDocument $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. * @type array $imported_font_urls Imported font URLs. * @type array $viewport_rules Extracted viewport rules. * @type int $important_count Number of !important qualifiers. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function create_validated_css_document( $stylesheet_string, $options ) { $validation_results = []; $imported_font_urls = []; $viewport_rules = []; $important_count = 0; $preload_font_urls = []; $css_document = null; // Note that there is no known case where an exception can be thrown here since PHP-CSS-Parser is using lenient parsing. try { // Remove spaces from data URLs, which cause errors and PHP-CSS-Parser can't handle them. $stylesheet_string = $this->remove_spaces_from_url_values( $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(), static function ( $value ) { return $value instanceof URL; } ), $options['stylesheet_url'] ); } $processed_css_list = $this->process_css_list( $css_document, $options ); $validation_results = array_merge( $validation_results, $processed_css_list['validation_results'] ); $viewport_rules = array_merge( $viewport_rules, $processed_css_list['viewport_rules'] ); $important_count = $processed_css_list['important_count']; $imported_font_urls = $processed_css_list['imported_font_urls']; $preload_font_urls = array_merge( $preload_font_urls, $processed_css_list['preload_font_urls'] ); } catch ( SourceException $exception ) { $error = [ 'code' => self::CSS_SYNTAX_PARSE_ERROR, 'message' => $exception->getMessage(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; /* * 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 compact( 'validation_results', 'css_document', 'imported_font_urls', 'viewport_rules', 'important_count', 'preload_font_urls' ); } /** * Parse stylesheet. * * Sanitizes invalid CSS properties and rules, compresses the CSS to remove whitespace and comments, and parses * declaration blocks to allow selectors to later be evaluated for whether they apply to the current document * during tree-shaking. * * @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 $tokens Stylesheet tokens, where arrays are tuples for declaration blocks. * @type string $hash MD5 hash of the parsed stylesheet. * @type array $validation_results Validation results, array containing arrays with error and sanitized keys. * @type array $imported_font_urls Imported font stylesheet URLs. * @type float $parse_time The time duration it took to parse the stylesheet, in milliseconds. * @type int $important_count Number of !important qualifiers. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function parse_stylesheet( $stylesheet_string, $options = [] ) { $start_time = microtime( true ); $options = array_merge( [ 'allowed_at_rules' => [], 'property_denylist' => [ // See . 'behavior', '-moz-binding', ], 'property_allowlist' => [], 'validate_keyframes' => false, 'stylesheet_url' => null, 'spec_name' => 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 ); $tokens = []; $parsed_stylesheet = $this->create_validated_css_document( $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 = sprintf( '/*%s*/', chr( 1 ) ); $between_selectors = sprintf( '/*%s*/', chr( 2 ) ); $after_declaration_block_selectors = sprintf( '/*%s*/', chr( 3 ) ); $between_properties = sprintf( '/*%s*/', chr( 4 ) ); $after_declaration_block = sprintf( '/*%s*/', chr( 5 ) ); $before_at_rule = sprintf( '/*%s*/', chr( 6 ) ); $after_at_rule = sprintf( '/*%s*/', chr( 7 ) ); // 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 ); } $output_format->set( 'SpaceBetweenRules', $between_properties ); $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( static function( $selector ) { return preg_quote( $selector, '#' ); }, $this->args['dynamic_element_selectors'] ) ); } $split_stylesheet = preg_split( $pattern, $stylesheet_string, -1, PREG_SPLIT_DELIM_CAPTURE ); // Ensure all instances of are escaped as <\/style> (such as can occur in SVG data: URLs) to prevent // the inline style from prematurely closing style[amp-custom]. $split_stylesheet = str_replace( '', '<\/style>', $split_stylesheet, $count ); $length = count( $split_stylesheet ); for ( $i = 0; $i < $length; $i++ ) { // Skip empty tokens. if ( '' === $split_stylesheet[ $i ] ) { unset( $split_stylesheet[ $i ] ); continue; } 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 ] ) ) { $tokens[] = ( str_replace( $between_selectors, '', $split_stylesheet[ ++$i ] ) . str_replace( $between_properties, '', $split_stylesheet[ ++$i ] ) ); continue; } $selectors = explode( $between_selectors . ',', $split_stylesheet[ ++$i ] ); $declaration = explode( ';' . $between_properties, trim( $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 = []; foreach ( $selectors as $selector ) { $selectors_parsed[ $selector ] = []; // Remove :not() and pseudo selectors to eliminate false negatives, such as with `body:not(.title-tagline-hidden) .site-branding-text` (but not after escape character). $reduced_selector = preg_replace( '/(? $parsed_stylesheet['imported_font_urls'], 'hash' => md5( wp_json_encode( $tokens ) ), 'parse_time' => ( microtime( true ) - $start_time ), 'viewport_rules' => $parsed_stylesheet['viewport_rules'], 'important_count' => $parsed_stylesheet['important_count'], 'preload_font_urls' => $parsed_stylesheet['preload_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 = []; /** * 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 = [] ) { 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 CSS URL values which PHP-CSS-Parser doesn't handle. * * @since 1.0 * * @param string $css CSS. * @return string CSS with spaces removed from URLs. */ private function remove_spaces_from_url_values( $css ) { return preg_replace_callback( // Match CSS url() values that don't have quoted string values. '/\burl\(\s*(?=\w)(?P[^}]*?\s*)\)/', static 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 { * Processed CSS list. * * @type array $validation_results Validation results. * @type array $viewport_rules Extracted viewport rules. * @type array $imported_font_urls Imported font URLs. * @type int $important_count Number of !important qualifiers. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function process_css_list( CSSList $css_list, $options ) { $validation_results = []; $viewport_rules = []; $imported_font_urls = []; $preload_font_urls = []; $important_count = 0; foreach ( $css_list->getContents() as $css_item ) { $sanitized = false; if ( $css_item instanceof DeclarationBlock && empty( $options['validate_keyframes'] ) ) { $processed = $this->process_css_declaration_block( $css_item, $css_list, $options ); $important_count += $processed['important_count']; $preload_font_urls = array_merge( $preload_font_urls, $processed['preload_font_urls'] ); $validation_results = array_merge( $validation_results, $processed['validation_results'] ); } elseif ( $css_item instanceof AtRuleBlockList ) { if ( ! in_array( $css_item->atRuleName(), $options['allowed_at_rules'], true ) ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_AT_RULE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); $validation_results[] = compact( 'error', 'sanitized' ); } if ( ! $sanitized ) { $processed = $this->process_css_list( $css_item, $options ); $viewport_rules = array_merge( $viewport_rules, $processed['viewport_rules'] ); $important_count += $processed['important_count']; $preload_font_urls = array_merge( $preload_font_urls, $processed['preload_font_urls'] ); $validation_results = array_merge( $validation_results, $processed['validation_results'] ); } } elseif ( $css_item instanceof Import ) { $imported_stylesheet = $this->splice_imported_stylesheet( $css_item, $css_list, $options ); $imported_font_urls = array_merge( $imported_font_urls, $imported_stylesheet['imported_font_urls'] ); $validation_results = array_merge( $validation_results, $imported_stylesheet['validation_results'] ); $preload_font_urls = array_merge( $preload_font_urls, $imported_stylesheet['preload_font_urls'] ); $viewport_rules = array_merge( $viewport_rules, $imported_stylesheet['viewport_rules'] ); $important_count += $imported_stylesheet['important_count']; } elseif ( $css_item instanceof AtRuleSet ) { if ( preg_match( '/^(-.+-)?viewport$/', $css_item->atRuleName() ) ) { $output_format = new OutputFormat(); foreach ( $css_item->getRules() as $rule ) { $rule_value = $rule->getValue(); if ( $rule_value instanceof Value ) { $rule_value = $rule_value->render( $output_format ); } $viewport_rules[ $rule->getRule() ] = $rule_value; } $css_list->remove( $css_item ); } elseif ( ! in_array( $css_item->atRuleName(), $options['allowed_at_rules'], true ) ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_AT_RULE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); $validation_results[] = compact( 'error', 'sanitized' ); } if ( ! $sanitized ) { $processed = $this->process_css_declaration_block( $css_item, $css_list, $options ); $validation_results = array_merge( $validation_results, $processed['validation_results'] ); $important_count += $processed['important_count']; $preload_font_urls = array_merge( $preload_font_urls, $processed['preload_font_urls'] ); } } elseif ( $css_item instanceof KeyFrame ) { if ( ! in_array( 'keyframes', $options['allowed_at_rules'], true ) ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_AT_RULE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); $validation_results[] = compact( 'error', 'sanitized' ); } if ( ! $sanitized ) { $processed = $this->process_css_keyframes( $css_item, $options ); $validation_results = array_merge( $validation_results, $processed['validation_results'] ); $important_count += $processed['important_count']; } } 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 = [ 'code' => self::CSS_SYNTAX_INVALID_AT_RULE, 'at_rule' => $css_item->atRuleName(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); $validation_results[] = compact( 'error', 'sanitized' ); } } else { $error = [ 'code' => self::CSS_SYNTAX_INVALID_DECLARATION, 'item' => get_class( $css_item ), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); $validation_results[] = compact( 'error', 'sanitized' ); } if ( $sanitized ) { $css_list->remove( $css_item ); } } return compact( 'validation_results', 'imported_font_urls', 'viewport_rules', 'important_count', 'preload_font_urls' ); } /** * 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 { * Results. * * @type array $validation_results Validation results. * @type int $important_count Number of !important qualifiers. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function process_css_declaration_block( RuleSet $ruleset, CSSList $css_list, $options ) { $validation_results = []; $important_count = 0; $preload_font_urls = []; if ( $ruleset instanceof DeclarationBlock ) { $validation_results = array_merge( $validation_results, $this->ampify_ruleset_selectors( $ruleset ) ); if ( 0 === count( $ruleset->getSelectors() ) ) { $css_list->remove( $ruleset ); return compact( 'validation_results', 'important_count', 'preload_font_urls' ); } } // Remove disallowed properties. if ( ! empty( $options['property_allowlist'] ) ) { $properties = $ruleset->getRules(); foreach ( $properties as $property ) { $vendorless_property_name = preg_replace( '/^-\w+-/', '', $property->getRule() ); if ( ! in_array( $vendorless_property_name, $options['property_allowlist'], true ) ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_PROPERTY, 'css_property_name' => $property->getRule(), 'css_property_value' => $property->getValue(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $ruleset->removeRule( $property->getRule() ); } $validation_results[] = compact( 'error', 'sanitized' ); } } } else { foreach ( $options['property_denylist'] as $illegal_property_name ) { $properties = $ruleset->getRules( $illegal_property_name ); foreach ( $properties as $property ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_PROPERTY_NOLIST, 'css_property_name' => $property->getRule(), 'css_property_value' => (string) $property->getValue(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $ruleset->removeRule( $property->getRule() ); } $validation_results[] = compact( 'error', 'sanitized' ); } } } if ( $ruleset instanceof AtRuleSet && 'font-face' === $ruleset->atRuleName() ) { $preload_font_urls = $this->process_font_face_at_rule( $ruleset, $options ); } $transformed = $this->transform_important_qualifiers( $ruleset, $css_list, $options ); $validation_results = array_merge( $validation_results, $transformed['validation_results'] ); $important_count = $transformed['important_count']; // Remove the ruleset if it is now empty. if ( 0 === count( $ruleset->getRules() ) ) { $css_list->remove( $ruleset ); } return compact( 'validation_results', 'important_count', 'preload_font_urls' ); } /** * 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. * } * @return string[] Font URLs to preload. */ private function process_font_face_at_rule( AtRuleSet $ruleset, $options ) { $src_properties = $ruleset->getRules( 'src' ); if ( empty( $src_properties ) ) { return []; } $preload_font_urls = []; // Obtain the font-family name to guess the filename. $font_family = null; $font_basename = null; $properties = $ruleset->getRules( 'font-family' ); $property = end( $properties ); if ( $property instanceof Rule ) { $font_family = trim( $property->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 ); } // Obtain the font file path (if any) and the first font src type. $font_file = ''; $first_src_type = ''; // Attempt to transform data: URLs in src properties to be external file URLs. 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 = []; foreach ( $value->getListComponents() as $component ) { if ( $component instanceof RuleValueList ) { $subcomponents = $component->getListComponents(); $subcomponent = array_shift( $subcomponents ); if ( $subcomponent ) { if ( empty( $sources ) ) { $sources[] = [ $subcomponent ]; } else { $sources[ count( $sources ) - 1 ][] = $subcomponent; } } foreach ( $subcomponents as $subcomponent ) { $sources[] = [ $subcomponent ]; } } elseif ( empty( $sources ) ) { $sources[] = [ $component ]; } else { $sources[ count( $sources ) - 1 ][] = $component; } } /** * Source file URL list. * * @var string[] $source_file_urls */ $source_file_urls = []; /** * Source data URL collection. * * @var URL[] $source_data_url_objects */ $source_data_url_objects = []; foreach ( $sources as $source ) { if ( count( $source ) !== 2 ) { continue; } list( $url, $format ) = $source; if ( ! $url instanceof URL || ! $format instanceof CSSFunction || $format->getName() !== 'format' || count( $format->getArguments() ) !== 1 ) { continue; } list( $format_value ) = $format->getArguments(); $format_value = trim( $format_value, '"\'' ); $value = $url->getURL()->getString(); if ( 'data:' === substr( $value, 0, 5 ) ) { $source_data_url_objects[ $format_value ] = $source[0]; if ( empty( $first_src_type ) ) { $first_src_type = 'inline'; } } else { $source_file_urls[] = $value; if ( empty( $first_src_type ) ) { $first_src_type = 'file'; $font_file = $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 $format => $data_url ) { $mime_type = strtok( substr( $data_url->getURL()->getString(), 5 ), ';' ); if ( $mime_type ) { $extension = preg_replace( ':.+/(.+-)?:', '', $mime_type ); } else { $extension = $format; } $extension = sanitize_key( $extension ); $guessed_urls = []; // 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, [ 'woff', 'woff2', 'ttf', 'otf', 'svg' ] ); if ( ! is_wp_error( $path ) ) { $data_url->getURL()->setString( $guessed_url ); if ( 'inline' === $first_src_type ) { $first_src_type = 'file'; $font_file = $guessed_url; } continue 2; } } // As fallback, look for fonts bundled with the AMP plugin. $font_filename = sprintf( '%s.%s', strtolower( $font_basename ), $extension ); $bundled_fonts = [ 'nonbreakingspaceoverride.woff', 'nonbreakingspaceoverride.woff2', 'genericons.woff', ]; if ( in_array( $font_filename, $bundled_fonts, true ) ) { $font_file = plugin_dir_url( AMP__FILE__ ) . "assets/fonts/$font_filename"; $data_url->getURL()->setString( $font_file ); $first_src_type = 'file'; } } // End foreach $source_data_url_objects. } // End foreach $src_properties. // Override the 'font-display' property to improve font performance. if ( $font_family && in_array( $font_family, array_keys( $this->args['font_face_display_overrides'] ), true ) ) { $ruleset->removeRule( 'font-display' ); $font_display_rule = new Rule( 'font-display' ); $font_display_rule->setValue( $this->args['font_face_display_overrides'][ $font_family ] ); $ruleset->addRule( $font_display_rule ); } // If the font-display is auto, block, or swap then we should automatically add the preload link for the first font file. $properties = $ruleset->getRules( 'font-display' ); $property = end( $properties ); // Last since the last property wins in CSS. if ( ( // Defaults to 'auto', hence should be preloaded as well. ! $property instanceof Rule || in_array( $property->getValue(), [ 'auto', 'block', 'swap' ], true ) ) && 'file' === $first_src_type && ! empty( $font_file ) ) { $preload_font_urls[] = $font_file; } return $preload_font_urls; } /** * 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 { * Results. * * @type array $validation_results Validation results. * @type int $important_count Number of !important qualifiers. * } */ private function process_css_keyframes( KeyFrame $css_list, $options ) { $validation_results = []; $important_count = 0; foreach ( $css_list->getContents() as $rules ) { if ( ! ( $rules instanceof DeclarationBlock ) ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_DECLARATION, 'item' => get_class( $rules ), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $css_list->remove( $rules ); } $validation_results[] = compact( 'error', 'sanitized' ); continue; } $transformed = $this->transform_important_qualifiers( $rules, $css_list, $options ); $validation_results = array_merge( $validation_results, $transformed['validation_results'] ); $important_count += $transformed['important_count']; if ( ! empty( $options['property_allowlist'] ) ) { $properties = $rules->getRules(); foreach ( $properties as $property ) { $vendorless_property_name = preg_replace( '/^-\w+-/', '', $property->getRule() ); if ( ! in_array( $vendorless_property_name, $options['property_allowlist'], true ) ) { $error = [ 'code' => self::CSS_SYNTAX_INVALID_PROPERTY, 'css_property_name' => $property->getRule(), 'css_property_value' => (string) $property->getValue(), 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $rules->removeRule( $property->getRule() ); } $validation_results[] = compact( 'error', 'sanitized' ); } } } } return compact( 'validation_results', 'important_count' ); } /** * 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. * @param array $options Options. * @return array { * Results. * * @type array $validation_results Validation results. * @type int $important_count Number of !important qualifiers. * } */ private function transform_important_qualifiers( RuleSet $ruleset, CSSList $css_list, $options ) { $important_count = 0; $validation_results = []; // An !important only makes sense for rulesets that have selectors. $allow_transformation = ( $ruleset instanceof DeclarationBlock && ! ( $css_list instanceof KeyFrame ) ); $properties = $ruleset->getRules(); $importants = []; foreach ( $properties as $property ) { if ( ! $property->getIsImportant() ) { continue; } if ( ! $this->args['transform_important_qualifiers'] ) { $important_count++; } elseif ( $allow_transformation ) { $importants[] = $property; $property->setIsImportant( false ); $ruleset->removeRule( $property->getRule() ); } else { $error = [ 'code' => self::CSS_SYNTAX_INVALID_IMPORTANT, 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'css_property_name' => $property->getRule(), 'css_property_value' => $property->getValue(), 'spec_name' => $options['spec_name'], ]; $sanitized = $this->should_sanitize_validation_error( $error ); if ( $sanitized ) { $property->setIsImportant( false ); } else { $important_count++; } $validation_results[] = compact( 'error', 'sanitized' ); } } if ( ! $ruleset instanceof DeclarationBlock || ! $allow_transformation || empty( $importants ) ) { return compact( 'validation_results', 'important_count' ); } /** * Ruleset covering !important styles. * * @var DeclarationBlock $important_ruleset */ $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. */ static 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 ); $contents = array_values( $css_list->getContents() ); // Ensure keys are 0-indexed and sequential. $offset = array_search( $ruleset, $contents, true ); if ( false !== $offset ) { array_splice( $contents, $offset + 1, 0, [ $important_ruleset ] ); $css_list->setContents( $contents ); } return compact( 'validation_results', 'important_count' ); } /** * Collect and store all CSS style attributes. * * Collects the CSS styles from within the HTML contained in this instance's Dom\Document. * * @since 0.4 * @since 0.7 Modified to use element passed by XPath query. * * @note Uses recursion to traverse down the tree of Dom\Document nodes. * * @param DOMElement $element Element with a style attribute. */ private function collect_inline_styles( DOMElement $element ) { $attr_node = $element->getAttributeNode( 'style' ); if ( ! $attr_node ) { return; } $value = trim( $attr_node->nodeValue ); if ( empty( $value ) ) { $element->removeAttribute( 'style' ); return; } // Skip processing stylesheets that contain mustache template variables if the element is inside of a mustache template. if ( preg_match( '/{{[^}]+?}}/', $value ) && 0 !== $this->dom->xpath->query( '//template[ @type="amp-mustache" ]//.|//script[ @template="amp-mustache" and @type="text/plain" ]//.', $element )->length ) { return; } $class = 'amp-wp-' . substr( md5( $value ), 0, 7 ); $root = ':root' . str_repeat( ':not(#_)', self::INLINE_SPECIFICITY_MULTIPLIER ); $rule = sprintf( '%s .%s { %s }', $root, $class, $value ); $this->set_current_node( $element ); // And sources when needing to be located. // @todo If ValidationExemption::is_px_verified_for_node( $element ) then keep !important. // @todo If ValidationExemption::is_amp_unvalidated_for_node( $element ) then keep invalid markup. $parsed = $this->get_parsed_stylesheet( $rule, [ 'allowed_at_rules' => [], 'property_allowlist' => $this->style_custom_cdata_spec['css_spec']['declaration'], 'spec_name' => self::STYLE_AMP_CUSTOM_SPEC_NAME, ] ); $element->removeAttribute( 'style' ); $element->setAttribute( self::ORIGINAL_STYLE_ATTRIBUTE_NAME, $value ); if ( $parsed['tokens'] ) { $this->pending_stylesheets[] = [ 'group' => self::STYLE_AMP_CUSTOM_GROUP_INDEX, 'original_size' => strlen( $rule ), 'final_size' => null, 'element' => $element, 'origin' => 'style_attribute', 'sources' => $this->current_sources, 'priority' => $this->get_stylesheet_priority( $attr_node ), 'tokens' => $parsed['tokens'], 'hash' => $parsed['hash'], 'parse_time' => $parsed['parse_time'], 'shake_time' => null, 'cached' => $parsed['cached'], 'important_count' => $parsed['important_count'], 'kept_error_count' => $parsed['kept_error_count'], 'preload_font_urls' => $parsed['preload_font_urls'], ]; 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, and add to AMP style elements in document. * 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() { $preload_font_urls = []; $stylesheet_groups = [ self::STYLE_AMP_CUSTOM_GROUP_INDEX => [ 'source_map_comment' => "\n\n/*# sourceURL=amp-custom.css */", 'cdata_spec' => $this->style_custom_cdata_spec, 'included_count' => 0, 'import_front_matter' => '', // Extra @import statements that are prepended when fetch fails and validation error is rejected. 'important_count' => 0, 'kept_error_count' => 0, 'is_excessive_size' => false, 'preload_font_urls' => [], ], self::STYLE_AMP_KEYFRAMES_GROUP_INDEX => [ 'source_map_comment' => "\n\n/*# sourceURL=amp-keyframes.css */", 'cdata_spec' => $this->style_keyframes_cdata_spec, 'included_count' => 0, 'import_front_matter' => '', 'important_count' => 0, 'kept_error_count' => 0, 'is_excessive_size' => false, 'preload_font_urls' => [], ], ]; $imported_font_urls = []; // Divide pending stylesheet between custom and keyframes, and calculate size of each (before tree shaking). foreach ( $this->pending_stylesheets as $i => $pending_stylesheet ) { foreach ( $pending_stylesheet['tokens'] as $j => $part ) { if ( is_string( $part ) && 0 === strpos( $part, '@import' ) ) { $stylesheet_groups[ $pending_stylesheet['group'] ]['import_front_matter'] .= $part; // @todo Not currently relayed in stylesheet data. unset( $this->pending_stylesheets[ $i ]['tokens'][ $j ] ); } } 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_groups ) as $group ) { $stylesheet_groups[ $group ] = array_merge( $stylesheet_groups[ $group ], $this->finalize_stylesheet_group( $group, $stylesheet_groups[ $group ] ) ); } // If we're not working with the document element (e.g. for Customizer rendered partials) then there is nothing left to do. if ( empty( $this->args['use_document_element'] ) ) { return; } // Add the font preloads. foreach ( $stylesheet_groups as $stylesheet_group ) { foreach ( $stylesheet_group['preload_font_urls'] as $preload_font_url ) { $this->dom->links->addPreload( $preload_font_url, RequestDestination::FONT ); } } // Add style[amp-custom] to document. if ( $stylesheet_groups[ self::STYLE_AMP_CUSTOM_GROUP_INDEX ]['included_count'] > 0 ) { /* * On AMP-first 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. */ $css = $stylesheet_groups[ self::STYLE_AMP_CUSTOM_GROUP_INDEX ]['import_front_matter']; $css .= implode( '', $this->get_stylesheets() ); $css .= $stylesheet_groups[ self::STYLE_AMP_CUSTOM_GROUP_INDEX ]['source_map_comment']; // Create the style[amp-custom] element and add it to the . $this->amp_custom_style_element = $this->dom->createElement( 'style' ); $this->amp_custom_style_element->setAttribute( 'amp-custom', '' ); $this->amp_custom_style_element->appendChild( $this->dom->createTextNode( $css ) ); // When there are kept errors, then mark the element as being AMP-unvalidated. Note that excessive CSS // is not a validation error that is arisen when parsing a stylesheet (as that is emitted when finalizing // a stylesheet group). Otherwise, if there are !important qualifiers or the amount of CSS is greater than // the maximum allowed by AMP, mark the custom style as PX-verified. if ( $stylesheet_groups[ self::STYLE_AMP_CUSTOM_GROUP_INDEX ]['kept_error_count'] > 0 ) { ValidationExemption::mark_node_as_amp_unvalidated( $this->amp_custom_style_element ); } elseif ( $stylesheet_groups[ self::STYLE_AMP_CUSTOM_GROUP_INDEX ]['important_count'] > 0 || $stylesheet_groups[ self::STYLE_AMP_CUSTOM_GROUP_INDEX ]['is_excessive_size'] ) { ValidationExemption::mark_node_as_px_verified( $this->amp_custom_style_element ); } $this->dom->head->appendChild( $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->dom->head->appendChild( $link ); } // Add style[amp-keyframes] to document. if ( $stylesheet_groups[ self::STYLE_AMP_KEYFRAMES_GROUP_INDEX ]['included_count'] > 0 ) { $css = $stylesheet_groups[ self::STYLE_AMP_KEYFRAMES_GROUP_INDEX ]['import_front_matter']; $css .= implode( '', wp_list_pluck( array_filter( $this->pending_stylesheets, static function( $pending_stylesheet ) { return $pending_stylesheet['included'] && self::STYLE_AMP_KEYFRAMES_GROUP_INDEX === $pending_stylesheet['group']; } ), 'serialized' ) ); $css .= $stylesheet_groups[ self::STYLE_AMP_KEYFRAMES_GROUP_INDEX ]['source_map_comment']; $style_element = $this->dom->createElement( 'style' ); $style_element->setAttribute( 'amp-keyframes', '' ); $style_element->appendChild( $this->dom->createTextNode( $css ) ); $this->dom->body->appendChild( $style_element ); } $this->remove_admin_bar_if_css_excluded(); $this->add_css_budget_to_admin_bar(); } /** * Remove admin bar if its CSS was excluded. * * @since 1.2 */ private function remove_admin_bar_if_css_excluded() { if ( ! is_admin_bar_showing() ) { return; } $admin_bar_id = 'wpadminbar'; $admin_bar = $this->dom->getElementById( $admin_bar_id ); if ( ! $admin_bar || ! $admin_bar->parentNode ) { return; } $included = true; foreach ( $this->pending_stylesheets as &$pending_stylesheet ) { $is_admin_bar_css = ( self::STYLE_AMP_CUSTOM_GROUP_INDEX === $pending_stylesheet['group'] && 'admin-bar-css' === $pending_stylesheet['element']->getAttribute( 'id' ) ); if ( $is_admin_bar_css ) { $included = $pending_stylesheet['included']; break; } } unset( $pending_stylesheet ); if ( ! $included ) { // Remove admin-bar class from body element. // @todo It would be nice if any style rules which refer to .admin-bar could also be removed, but this would mean retroactively going back over the CSS again and re-shaking it. if ( $this->dom->body->hasAttribute( 'class' ) ) { $this->dom->body->setAttribute( 'class', preg_replace( '/(^|\s)admin-bar(\s|$)/', ' ', $this->dom->body->getAttribute( 'class' ) ) ); } // Remove admin bar element. $comment_text = sprintf( /* translators: %s: CSS selector for admin bar element */ __( 'Admin bar (%s) was removed to preserve AMP validity due to excessive CSS.', 'amp' ), '#' . $admin_bar_id ); $admin_bar->parentNode->replaceChild( $this->dom->createComment( ' ' . $comment_text . ' ' ), $admin_bar ); } } /** * Get data to amend to the validate response. * * @return array { * Validate response data. * * @type array $stylesheets Stylesheets. * } */ public function get_validate_response_data() { $stylesheets = []; foreach ( $this->pending_stylesheets as $pending_stylesheet ) { $attributes = []; foreach ( $pending_stylesheet['element']->attributes as $attribute ) { $attributes[ $attribute->nodeName ] = $attribute->nodeValue; } $pending_stylesheet['element'] = [ 'name' => $pending_stylesheet['element']->nodeName, 'attributes' => $attributes, ]; switch ( $pending_stylesheet['group'] ) { case self::STYLE_AMP_CUSTOM_GROUP_INDEX: $pending_stylesheet['group'] = 'amp-custom'; break; case self::STYLE_AMP_KEYFRAMES_SPEC_NAME: $pending_stylesheet['group'] = 'amp-keyframes'; break; } unset( $pending_stylesheet['serialized'] ); $stylesheets[] = $pending_stylesheet; } return compact( 'stylesheets' ); } /** * Update admin bar. */ public function add_css_budget_to_admin_bar() { if ( ! is_admin_bar_showing() ) { return; } $validity_li_element = $this->dom->getElementById( 'wp-admin-bar-amp-validity' ); if ( ! $validity_li_element instanceof DOMElement ) { return; } /** * Cloned
  • element that we can modify to include stylesheet information. * * @var DOMElement $stylesheets_li_element */ $stylesheets_li_element = $validity_li_element->cloneNode( true ); $stylesheets_li_element->setAttribute( 'id', 'wp-admin-bar-amp-stylesheets' ); $stylesheets_a_element = $stylesheets_li_element->getElementsByTagName( 'a' )->item( 0 ); if ( ! ( $stylesheets_a_element instanceof DOMElement ) ) { return; } $stylesheets_a_element->setAttribute( 'href', $stylesheets_a_element->getAttribute( 'href' ) . '#amp_stylesheets' ); while ( $stylesheets_a_element->firstChild ) { $stylesheets_a_element->removeChild( $stylesheets_a_element->firstChild ); } $total_size = 0; foreach ( $this->pending_stylesheets as $pending_stylesheet ) { if ( empty( $pending_stylesheet['duplicate'] ) ) { $total_size += $pending_stylesheet['final_size']; } } $css_usage_percentage = ceil( ( $total_size / $this->style_custom_cdata_spec['max_bytes'] ) * 100 ); $menu_item_text = __( 'CSS Usage', 'amp' ) . ': '; $menu_item_text .= $css_usage_percentage . '%'; $stylesheets_a_element->appendChild( $this->dom->createTextNode( $menu_item_text ) ); if ( $css_usage_percentage > 100 ) { $icon = Icon::INVALID; } elseif ( $css_usage_percentage >= self::CSS_BUDGET_WARNING_PERCENTAGE ) { $icon = Icon::WARNING; } if ( isset( $icon ) ) { $span = $this->dom->createElement( 'span' ); $span->setAttribute( 'class', 'ab-icon amp-icon ' . $icon ); $stylesheets_a_element->appendChild( $span ); } $validity_li_element->parentNode->insertBefore( $stylesheets_li_element, $validity_li_element->nextSibling ); } /** * Convert CSS selectors and remove obsolete selector hacks for IE. * * @param DeclarationBlock $ruleset Ruleset. * @return array Validation results. */ private function ampify_ruleset_selectors( $ruleset ) { $selectors = []; $results = []; $has_changed_selectors = false; $language = strtolower( get_bloginfo( 'language' ) ); foreach ( $ruleset->getSelectors() as $old_selector ) { $selector = $old_selector->getSelector(); // Strip out selectors that contain the disallowed prefix 'i-amphtml-'. if ( preg_match( '/(^|\W)i-amphtml-/', $selector ) ) { $error = [ 'code' => self::CSS_DISALLOWED_SELECTOR, 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'css_selector' => $selector, ]; $sanitized = $this->should_sanitize_validation_error( $error ); $results[] = compact( 'error', 'sanitized' ); if ( $sanitized ) { $has_changed_selectors = true; continue; } } // Automatically tree-shake IE6/IE7 hacks for selectors with `* html` and `*+html`. if ( preg_match( '/^\*\s*\+?\s*html/', $selector ) ) { $has_changed_selectors = true; 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 ) { $has_changed_selectors = true; 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( static 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 ) { $has_changed_selectors = true; 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_-])'; // Replace focus selectors with :focus-within. if ( $this->focus_class_name_selector_pattern ) { $count = 0; $selector = preg_replace_callback( $this->focus_class_name_selector_pattern, static function ( $matches ) { $replacement = ':focus-within'; if ( 'focus' === $matches['class'] && ( ! empty( $matches['beginning'] ) || ( ! empty( $matches['combinator'] ) && '' === trim( $matches['combinator'] ) ) ) ) { /* * If a descendant combinator precedes the focus selector, prefix the pseudo class selector * with a class selector that's known to be common among themes that use the focus selector. * This is to prevent the pseudo class selector being applied to the ancestor selector, * which can cause unintended behavior on the page. */ $replacement = '.menu-item-has-children' . $replacement; } // Ensure preceding combinator is preserved. if ( ! empty( $matches['combinator'] ) ) { $replacement = $matches['combinator'] . $replacement; } return $replacement; }, $selector, -1, $count ); if ( $count > 0 ) { $has_changed_selectors = true; } } // Replace the somewhat-meta [style] attribute selectors with attribute selector using the data attribute the original styles are copied into. if ( $this->args['transform_important_qualifiers'] ) { $selector = preg_replace( '/(?<=\[)style(?=([*$~]?=.*?)?])/is', self::ORIGINAL_STYLE_ATTRIBUTE_NAME, $selector, - 1, $count ); if ( $count > 0 ) { $has_changed_selectors = true; } } /* * Loop over each selector mappings. A single HTML tag can map to multiple AMP tags (e.g. img could be amp-img or amp-anim). * The $selector_mappings array contains ~6 items, so rest easy your O(n^3) eyes when seeing triple nested loops! */ $edited_selectors = [ $selector ]; foreach ( $this->selector_mappings as $html_tag => $amp_tags ) { // Create pattern for determining whether a mapped HTML element is present in this selector. $html_pattern = '/' . $before_type_selector_pattern . preg_quote( $html_tag, '/' ) . $after_type_selector_pattern . '/i'; /* * Iterate over each selector and perform the tag mapping replacements. * Note that $edited_selectors array contains only item in the normal case. * Note also that the size of $edited_selectors can grow while iterating, hence disabling sniffs. */ for ( $i = 0; $i < count( $edited_selectors ); $i++ ) { // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed, Squiz.PHP.DisallowSizeFunctionsInLoops.Found // Skip doing any replacement if the AMP tag is already present, as this indicates the selector was written for AMP already. $amp_tag_pattern = '/' . $before_type_selector_pattern . implode( '|', $amp_tags ) . $after_type_selector_pattern . '/i'; if ( preg_match( $amp_tag_pattern, $edited_selectors[ $i ], $matches ) && in_array( $matches[0], $amp_tags, true ) ) { continue; } // Replace the HTML tag with the first first mapped AMP tag. $edited_selector = preg_replace( $html_pattern, $amp_tags[0], $edited_selectors[ $i ], -1, $count ); // If the HTML tag was not found, then short-circuit. if ( 0 === $count ) { continue; } $edited_selectors_from_selector = [ $edited_selector ]; // Replace the HTML tag with the the remaining mapped AMP tags. foreach ( array_slice( $amp_tags, 1 ) as $amp_tag ) { // Note: This array contains only a couple items. $edited_selectors_from_selector[] = preg_replace( $html_pattern, $amp_tag, $edited_selectors[ $i ] ); } // Replace the current edited selector with all the new edited selectors resulting from the mapping replacement. array_splice( $edited_selectors, $i, 1, $edited_selectors_from_selector ); $has_changed_selectors = true; } } $selectors = array_merge( $selectors, $edited_selectors ); } if ( $has_changed_selectors ) { $ruleset->setSelectors( $selectors ); } return $results; } /** * Given a list of class names, create a regular expression pattern to match them in a selector. * * @since 1.4 * @since 2.0 In addition to the class, now includes capture groups for an immediately-preceding combinator or whether the class begins the selector. * * @param string[] $class_names Class names. * @return string Regular expression pattern. */ private static function get_class_name_selector_pattern( $class_names ) { $class_pattern = implode( '|', array_map( static function ( $class_name ) { return preg_quote( $class_name, '/' ); }, (array) $class_names ) ); return "/(?:(?^\s*\.)|(?[>+~\s]*)\.)(?{$class_pattern})(?=$|[^a-zA-Z0-9_-])/s"; } /** * Finalize a stylesheet group (amp-custom or amp-keyframes). * * @since 1.2 * * @param int $group Group name (either self::STYLE_AMP_CUSTOM_GROUP_INDEX or self::STYLE_AMP_KEYFRAMES_GROUP_INDEX ). * @param array $group_config Group config. * @return array { * Finalized group info. * * @type int $included_count Number of included stylesheets in group. * @type bool $is_excessive_size Whether the total is greater than the max bytes allowed. * @type int $important_count Number of !important qualifiers. * @type int $kept_error_count Number of validation errors whose markup was kept. * @type string[] $preload_font_urls Font URLs to preload. * } */ private function finalize_stylesheet_group( $group, $group_config ) { $max_bytes = $group_config['cdata_spec']['max_bytes'] - strlen( $group_config['source_map_comment'] ); $included_count = 0; $is_excessive_size = false; $concatenated_size = 0; $important_count = 0; $kept_error_count = 0; $preload_font_urls = []; $previously_seen_stylesheet_index = []; foreach ( $this->pending_stylesheets as $pending_stylesheet_index => &$pending_stylesheet ) { if ( $group !== $pending_stylesheet['group'] ) { continue; } $start_time = microtime( true ); $shaken_tokens = []; foreach ( $pending_stylesheet['tokens'] as $token ) { if ( is_string( $token ) ) { $shaken_tokens[] = [ true, $token ]; continue; } list( $selectors_parsed, $declaration_block ) = $token; $used_selector_count = 0; $selectors = []; foreach ( $selectors_parsed as $selector => $parsed_selector ) { $should_include = $this->args['skip_tree_shaking'] || ( // 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 ) { return ! $this->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 ] ) ) ); $selectors[ $selector ] = $should_include; if ( $should_include ) { $used_selector_count++; } } $shaken_tokens[] = [ 0 !== $used_selector_count, $selectors, $declaration_block, ]; } // Strip empty at-rules after tree shaking. $stylesheet_part_count = count( $shaken_tokens ); for ( $i = 0; $i < $stylesheet_part_count; $i++ ) { // Skip anything that isn't an at-rule. if ( ! is_string( $shaken_tokens[ $i ][1] ) || '@' !== substr( $shaken_tokens[ $i ][1], 0, 1 ) ) { continue; } // Delete empty at-rules. if ( '{}' === substr( $shaken_tokens[ $i ][1], -2 ) ) { $shaken_tokens[ $i ][0] = false; continue; } // Delete at-rules that were emptied due to tree-shaking. if ( '{' === substr( $shaken_tokens[ $i ][1], -1 ) ) { $open_braces = 1; for ( $j = $i + 1; $j < $stylesheet_part_count; $j++ ) { if ( is_array( $shaken_tokens[ $j ][1] ) ) { // Is declaration block. if ( true === $shaken_tokens[ $j ][0] ) { // The declaration block has selectors which survived tree shaking, so the contained at- // rule cannot be removed and so we must abort. break; } else { // Continue to the next stylesheet part as this declaration block can be included in the // list of parts that may be part of an at-rule that is now empty and should be removed. continue; } } $is_at_rule = '@' === substr( $shaken_tokens[ $j ][1], 0, 1 ); if ( $is_at_rule && '{}' === substr( $shaken_tokens[ $j ][1], -2 ) ) { continue; // The rule opened is empty from the start. } if ( $is_at_rule && '{' === substr( $shaken_tokens[ $j ][1], -1 ) ) { $open_braces++; } elseif ( '}' === $shaken_tokens[ $j ][1] ) { $open_braces--; } else { break; } // Splice out the parts that are empty. if ( 0 === $open_braces ) { for ( $k = $i; $k <= $j; $k++ ) { $shaken_tokens[ $k ][0] = false; } $i = $j; // Jump the outer loop ahead to skip over what has been already marked as removed. continue 2; } } } } $pending_stylesheet['shaken_tokens'] = $shaken_tokens; unset( $pending_stylesheet['tokens'], $shaken_tokens ); // @todo After this point we could unset( $pending_stylesheet['tokens'] ) since they wouldn't be used in the course of generating a page, though they would still be useful for other purposes. $pending_stylesheet['serialized'] = implode( '', array_map( static function ( $shaken_token ) { if ( is_array( $shaken_token[1] ) ) { // Construct a declaration block. $selectors = array_keys( array_filter( $shaken_token[1] ) ); if ( empty( $selectors ) ) { return ''; } else { return implode( ',', $selectors ) . '{' . implode( ';', $shaken_token[2] ) . '}'; } } else { // Pass through parts other than declaration blocks. return $shaken_token[1]; } }, // Include the stylesheet parts that were not marked for exclusion during tree shaking. array_filter( $pending_stylesheet['shaken_tokens'], static function( $shaken_token ) { return false !== $shaken_token[0]; } ) ) ); $pending_stylesheet['included'] = null; // To be determined below. $pending_stylesheet['final_size'] = strlen( $pending_stylesheet['serialized'] ); // If this stylesheet is a duplicate of something that came before, mark the previous as not included automatically. if ( isset( $previously_seen_stylesheet_index[ $pending_stylesheet['hash'] ] ) ) { $this->pending_stylesheets[ $previously_seen_stylesheet_index[ $pending_stylesheet['hash'] ] ]['included'] = false; $this->pending_stylesheets[ $previously_seen_stylesheet_index[ $pending_stylesheet['hash'] ] ]['duplicate'] = true; } $previously_seen_stylesheet_index[ $pending_stylesheet['hash'] ] = $pending_stylesheet_index; $pending_stylesheet['shake_time'] = microtime( true ) - $start_time; } // End foreach pending_stylesheets. unset( $pending_stylesheet ); // Determine which stylesheets are included based on their priorities. $pending_stylesheet_indices = array_keys( $this->pending_stylesheets ); usort( $pending_stylesheet_indices, function ( $a, $b ) { return $this->pending_stylesheets[ $a ]['priority'] - $this->pending_stylesheets[ $b ]['priority']; } ); foreach ( $pending_stylesheet_indices as $i ) { if ( $group !== $this->pending_stylesheets[ $i ]['group'] ) { continue; } // Skip duplicates. if ( false === $this->pending_stylesheets[ $i ]['included'] ) { continue; } // Skip stylesheets that were completely tree-shaken and mark as included. if ( 0 === $this->pending_stylesheets[ $i ]['final_size'] ) { $this->pending_stylesheets[ $i ]['included'] = true; continue; } $is_stylesheet_excessive = $concatenated_size + $this->pending_stylesheets[ $i ]['final_size'] > $max_bytes; // Report validation error if size is now too big. if ( ! $this->args['allow_excessive_css'] && $is_stylesheet_excessive ) { $validation_error = [ 'code' => self::STYLESHEET_TOO_LONG, 'type' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE, 'spec_name' => self::STYLE_AMP_KEYFRAMES_GROUP_INDEX === $group ? self::STYLE_AMP_KEYFRAMES_SPEC_NAME : self::STYLE_AMP_CUSTOM_SPEC_NAME, ]; if ( isset( $this->pending_stylesheets[ $i ]['sources'] ) ) { $validation_error['sources'] = $this->pending_stylesheets[ $i ]['sources']; } $data = [ 'node' => $this->pending_stylesheets[ $i ]['element'], ]; if ( $this->should_sanitize_validation_error( $validation_error, $data ) ) { $this->pending_stylesheets[ $i ]['included'] = false; continue; // Skip to the next stylesheet. } } if ( ! isset( $this->pending_stylesheets[ $i ]['included'] ) ) { $this->pending_stylesheets[ $i ]['included'] = true; $included_count++; $concatenated_size += $this->pending_stylesheets[ $i ]['final_size']; $preload_font_urls = array_merge( $preload_font_urls, $this->pending_stylesheets[ $i ]['preload_font_urls'] ); if ( $is_stylesheet_excessive ) { $is_excessive_size = true; } // Note: the following two may be incorrect because the !important property or erroneous rule may have // actually been tree-shaken and thus is no longer in the document. $important_count += $this->pending_stylesheets[ $i ]['important_count']; $kept_error_count += $this->pending_stylesheets[ $i ]['kept_error_count']; } } return compact( 'included_count', 'is_excessive_size', 'important_count', 'kept_error_count', 'preload_font_urls' ); } /** * Creates and inserts a meta[name="viewport"] tag if there are @viewport style rules. * * These rules aren't valid in CSS, but they might be valid in that meta tag. * So this adds them to the content attribute of a new meta tag. * These are later processed, to merge the content values into a single meta tag. * * @param DOMElement $element An element. * @param array $viewport_rules An associative array of $rule_name => $rule_value. */ private function create_meta_viewport( DOMElement $element, $viewport_rules ) { if ( empty( $viewport_rules ) ) { return; } $viewport_meta = $this->dom->createElement( 'meta' ); $viewport_meta->setAttribute( 'name', 'viewport' ); $viewport_meta->setAttribute( 'content', implode( ',', array_map( static function ( $property_name ) use ( $viewport_rules ) { return $property_name . '=' . $viewport_rules[ $property_name ]; }, array_keys( $viewport_rules ) ) ) ); // Inject a potential duplicate meta viewport element, to later be merged in AMP_Meta_Sanitizer. $element->parentNode->insertBefore( $viewport_meta, $element ); } } صندوق بازار گردانی شستا با مدیریت صبا تامین تشکیل شد - اکوفاین | مدیریتی ، مالی ، اقتصادی
    بورس

    صندوق بازار گردانی شستا با مدیریت صبا تامین تشکیل شد

    صندوق بازار گردانی شستا متشکل از همه نمادهای زیرمجموعه شستا و در جهت صیانت از حقوق سهامداران تشکیل شد.

    🔘شستا با ایجاد صندوق بازارگردانی که متشکل از تمامی نمادهای فعال شستا در بازار سرمایه است قصد دارد تا از سهام هلدینگ‌ها و‌ شرکت‌های زیرمجموعه آنها حمایت کند.

    🔘همچنین در راستای حمایت از سهام‌داران، شستا اقدام به انتشار اوراق اختیار فروش تبعی بر روی نمادهای تاصیکو و سیتا کرده است. 

    🔘شستا در نظر دارد در صورت موافقت بورس، برروی نماد سایر هلدینگ‌های خود نیز نسبت به انتشار اوراق اختیار فروش تبعی اقدام نماید.

    🔘گفتنی است مجموعه شرکت‌های مرتبط با شستا درجهت حمایت از سهام هلدینگ‌ها و‌ شرکت‌های زیرمجموعه تاکنون ۳ هزار میلیارد تومان سهام در جهت حمایت از بازار خریداری کرده‌اند.

    مطالب مرتبط

    افزایش سرمایه 1200 درصدی شستا از محل تجدید ارزیابی دارایی‌ها

    اکوفاین
    4 سال قبل

    استارتاپ ها به بازار بورس رسیدند

    اکوفاین
    5 سال قبل

    سهام بانک صادرات بیمه شد

    اکوفاین
    5 سال قبل