diff --git a/.dev-lib b/.dev-lib index 25f81df2221..6549fa32c46 100644 --- a/.dev-lib +++ b/.dev-lib @@ -1,6 +1,7 @@ PATH_EXCLUDES_PATTERN=includes/lib/ DEFAULT_BASE_BRANCH=develop ASSETS_DIR=wp-assets +PROJECT_SLUG=amp function after_wp_install { echo "Installing REST API..." diff --git a/amp.php b/amp.php index 0e59906c557..e9c3c730c22 100644 --- a/amp.php +++ b/amp.php @@ -109,9 +109,9 @@ function amp_init() { add_rewrite_endpoint( AMP_QUERY_VAR, EP_PERMALINK ); + AMP_Validation_Utils::init(); AMP_Theme_Support::init(); AMP_Post_Type_Support::add_post_type_support(); - AMP_Validation_Utils::init(); add_filter( 'request', 'amp_force_query_var_value' ); add_action( 'admin_init', 'AMP_Options_Manager::register_settings' ); add_action( 'wp_loaded', 'amp_post_meta_box' ); diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index a7c2331487d..be8fd0e3619 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -32,12 +32,16 @@ function amp_get_permalink( $post_id ) { return $pre_url; } - $parsed_url = wp_parse_url( get_permalink( $post_id ) ); - $structure = get_option( 'permalink_structure' ); - if ( empty( $structure ) || ! empty( $parsed_url['query'] ) || is_post_type_hierarchical( get_post_type( $post_id ) ) ) { - $amp_url = add_query_arg( AMP_QUERY_VAR, '', get_permalink( $post_id ) ); + if ( amp_is_canonical() ) { + $amp_url = get_permalink( $post_id ); } else { - $amp_url = trailingslashit( get_permalink( $post_id ) ) . user_trailingslashit( AMP_QUERY_VAR, 'single_amp' ); + $parsed_url = wp_parse_url( get_permalink( $post_id ) ); + $structure = get_option( 'permalink_structure' ); + if ( empty( $structure ) || ! empty( $parsed_url['query'] ) || is_post_type_hierarchical( get_post_type( $post_id ) ) ) { + $amp_url = add_query_arg( AMP_QUERY_VAR, '', get_permalink( $post_id ) ); + } else { + $amp_url = trailingslashit( get_permalink( $post_id ) ) . user_trailingslashit( AMP_QUERY_VAR, 'single_amp' ); + } } /** @@ -51,6 +55,25 @@ function amp_get_permalink( $post_id ) { return apply_filters( 'amp_get_permalink', $amp_url, $post_id ); } +/** + * Remove the AMP endpoint (and query var) from a given URL. + * + * @since 0.7 + * + * @param string $url URL. + * @return string URL with AMP stripped. + */ +function amp_remove_endpoint( $url ) { + + // Strip endpoint. + $url = preg_replace( ':/' . preg_quote( AMP_QUERY_VAR, ':' ) . '(?=/?(\?|#|$)):', '', $url ); + + // Strip query var. + $url = remove_query_arg( AMP_QUERY_VAR, $url ); + + return $url; +} + /** * Determine whether a given post supports AMP. * diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 2bee8dc416e..ee7da221ad0 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -77,13 +77,7 @@ public static function init() { self::purge_amp_query_vars(); self::handle_xhr_request(); - - if ( ! is_amp_endpoint() ) { - amp_add_frontend_actions(); - } else { - self::setup_commenting(); - add_action( 'widgets_init', array( __CLASS__, 'register_widgets' ) ); - } + self::add_temporary_discussion_restrictions(); require_once AMP__DIR__ . '/includes/amp-post-template-actions.php'; @@ -98,20 +92,55 @@ public static function init() { } } - if ( amp_is_canonical() ) { + add_action( 'widgets_init', array( __CLASS__, 'register_widgets' ) ); - // Redirect to canonical URL if the AMP URL was loaded, since canonical is now AMP. - if ( false !== get_query_var( AMP_QUERY_VAR, false ) ) { // Because is_amp_endpoint() now returns true if amp_is_canonical(). - wp_safe_redirect( self::get_current_canonical_url(), 302 ); // Temporary redirect because canonical may change in future. - exit; - } + /* + * Note that wp action is use instead of template_redirect because some themes/plugins output + * the response at this action and then short-circuit with exit. So this is why the the preceding + * action to template_redirect--the wp action--is used instead. + */ + add_action( 'wp', array( __CLASS__, 'finish_init' ), PHP_INT_MAX ); + } + + /** + * Finish initialization once query vars are set. + * + * @since 0.7 + */ + public static function finish_init() { + if ( ! is_amp_endpoint() ) { + amp_add_frontend_actions(); + return; + } + + if ( amp_is_canonical() ) { + self::redirect_canonical_amp(); } else { self::register_paired_hooks(); } - self::register_hooks(); - self::$embed_handlers = self::register_content_embed_handlers(); + self::add_hooks(); self::$sanitizer_classes = amp_get_content_sanitizers(); + self::$embed_handlers = self::register_content_embed_handlers(); + } + + /** + * Redirect to canonical URL if the AMP URL was loaded, since canonical is now AMP. + * + * @since 0.7 + */ + public static function redirect_canonical_amp() { + if ( false !== get_query_var( AMP_QUERY_VAR, false ) ) { // Because is_amp_endpoint() now returns true if amp_is_canonical(). + $url = preg_replace( '#^(https?://.+?)(/.*)$#', '$1', home_url( '/' ) ); + if ( isset( $_SERVER['REQUEST_URI'] ) ) { + $url .= wp_unslash( $_SERVER['REQUEST_URI'] ); + } + + $url = amp_remove_endpoint( $url ); + + wp_safe_redirect( $url, 302 ); // Temporary redirect because canonical may change in future. + exit; + } } /** @@ -166,7 +195,7 @@ public static function register_paired_hooks() { /** * Register hooks. */ - public static function register_hooks() { + public static function add_hooks() { // Remove core actions which are invalid AMP. remove_action( 'wp_head', 'wp_post_preview_js', 1 ); @@ -216,6 +245,10 @@ public static function register_hooks() { add_action( 'comment_form', array( __CLASS__, 'add_amp_comment_form_templates' ), 100 ); remove_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ); + if ( AMP_Validation_Utils::should_validate_response() ) { + AMP_Validation_Utils::add_validation_hooks(); + } + // @todo Add character conversion. } @@ -279,7 +312,7 @@ public static function purge_amp_query_vars() { } /** - * Hook into a form submissions, such as comment the form or some other . + * Hook into a form submissions, such as the comment form or some other form submission. * * @since 0.7.0 * @global string $pagenow @@ -370,14 +403,14 @@ public static function intercept_post_request_redirect( $location ) { } /** - * Set up commenting. + * Set up some restrictions for commenting based on amp-live-list limitations. + * + * Temporarily force comments to be listed in descending order. + * The following hooks are temporary while waiting for amphtml#5396 to be resolved. + * + * @link https://github.com/ampproject/amphtml/issues/5396 */ - public static function setup_commenting() { - /* - * Temporarily force comments to be listed in descending order. - * - * The following hooks are temporary while waiting for amphtml#5396 to be resolved. - */ + protected static function add_temporary_discussion_restrictions() { add_filter( 'option_comment_order', function() { return 'desc'; }, PHP_INT_MAX ); @@ -584,13 +617,7 @@ public static function get_current_canonical_url() { $url = add_query_arg( $added_query_vars, $url ); } - // Strip endpoint. - $url = preg_replace( ':/' . preg_quote( AMP_QUERY_VAR, ':' ) . '(?=/?(\?|#|$)):', '', $url ); - - // Strip query var. - $url = remove_query_arg( AMP_QUERY_VAR, $url ); - - return $url; + return amp_remove_endpoint( $url ); } /** @@ -953,13 +980,15 @@ public static function prepare_response( $response, $args = array() ) { return $response; } + $is_validation_debug_mode = ! empty( $_REQUEST[ AMP_Validation_Utils::DEBUG_QUERY_VAR ] ); // WPCS: csrf ok. + $args = array_merge( array( 'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat. 'use_document_element' => true, - 'remove_invalid_callback' => null, 'allow_dirty_styles' => self::is_customize_preview_iframe(), // Dirty styles only needed when editing (e.g. for edit shortcodes). 'allow_dirty_scripts' => is_customize_preview(), // Scripts are always needed to inject changeset UUID. + 'disable_invalid_removal' => $is_validation_debug_mode, ), $args ); @@ -994,6 +1023,12 @@ public static function prepare_response( $response, $args = array() ) { trigger_error( esc_html( sprintf( __( 'The database has the %s encoding when it needs to be utf-8 to work with AMP.', 'amp' ), get_bloginfo( 'charset' ) ) ), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error } + if ( AMP_Validation_Utils::should_validate_response() ) { + AMP_Validation_Utils::finalize_validation( $dom, array( + 'remove_source_comments' => ! $is_validation_debug_mode, + ) ); + } + $response = "\n"; $response .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); diff --git a/includes/options/class-amp-options-menu.php b/includes/options/class-amp-options-menu.php index a06d5f4169a..93e51e69c0b 100644 --- a/includes/options/class-amp-options-menu.php +++ b/includes/options/class-amp-options-menu.php @@ -22,7 +22,7 @@ class AMP_Options_Menu { */ public function init() { add_action( 'admin_post_amp_analytics_options', 'AMP_Options_Manager::handle_analytics_submit' ); - add_action( 'admin_menu', array( $this, 'add_menu_items' ) ); + add_action( 'admin_menu', array( $this, 'add_menu_items' ), 9 ); } /** diff --git a/includes/sanitizers/class-amp-base-sanitizer.php b/includes/sanitizers/class-amp-base-sanitizer.php index de5c08c8881..5f939c53320 100644 --- a/includes/sanitizers/class-amp-base-sanitizer.php +++ b/includes/sanitizers/class-amp-base-sanitizer.php @@ -54,6 +54,7 @@ abstract class AMP_Base_Sanitizer { * @type array $amp_bind_placeholder_prefix * @type bool $allow_dirty_styles * @type bool $allow_dirty_scripts + * @type bool $disable_invalid_removal * @type callable $remove_invalid_callback * } */ @@ -320,17 +321,19 @@ public function maybe_enforce_https_src( $src, $force_https = false ) { * * @since 0.7 * - * @param DOMNode|DOMElement $child The node to remove. + * @param DOMNode|DOMElement $node The node to remove. + * @param array $args Additional args to pass to validation error callback. + * * @return void */ - public function remove_invalid_child( $child ) { - $parent = $child->parentNode; - $child->parentNode->removeChild( $child ); - if ( isset( $this->args['remove_invalid_callback'] ) ) { - call_user_func( $this->args['remove_invalid_callback'], array( - 'node' => $child, - 'parent' => $parent, - ) ); + public function remove_invalid_child( $node, $args = array() ) { + if ( isset( $this->args['validation_error_callback'] ) ) { + call_user_func( $this->args['validation_error_callback'], + array_merge( compact( 'node' ), $args ) + ); + } + if ( empty( $this->args['disable_invalid_removal'] ) ) { + $node->parentNode->removeChild( $node ); } } @@ -344,25 +347,33 @@ public function remove_invalid_child( $child ) { * * @param DOMElement $element The node for which to remove the attribute. * @param DOMAttr|string $attribute The attribute to remove from the element. + * @param array $args Additional args to pass to validation error callback. * @return void */ - public function remove_invalid_attribute( $element, $attribute ) { - if ( isset( $this->args['remove_invalid_callback'] ) ) { + public function remove_invalid_attribute( $element, $attribute, $args = array() ) { + if ( isset( $this->args['validation_error_callback'] ) ) { if ( is_string( $attribute ) ) { $attribute = $element->getAttributeNode( $attribute ); } if ( $attribute ) { + call_user_func( $this->args['validation_error_callback'], + array_merge( + array( + 'node' => $attribute, + ), + $args + ) + ); + if ( empty( $this->args['disable_invalid_removal'] ) ) { + $element->removeAttributeNode( $attribute ); + } + } + } elseif ( empty( $this->args['disable_invalid_removal'] ) ) { + if ( is_string( $attribute ) ) { + $element->removeAttribute( $attribute ); + } else { $element->removeAttributeNode( $attribute ); - call_user_func( $this->args['remove_invalid_callback'], array( - 'node' => $attribute, - 'parent' => $element, - ) ); } - } elseif ( is_string( $attribute ) ) { - $element->removeAttribute( $attribute ); - } else { - $element->removeAttributeNode( $attribute ); } } - } diff --git a/includes/sanitizers/class-amp-style-sanitizer.php b/includes/sanitizers/class-amp-style-sanitizer.php index f085144cf87..d02257083a1 100644 --- a/includes/sanitizers/class-amp-style-sanitizer.php +++ b/includes/sanitizers/class-amp-style-sanitizer.php @@ -15,9 +15,10 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { /** * Styles. * - * @var string[] List of CSS styles in HTML content of DOMDocument ($this->dom). + * List of CSS styles in HTML content of DOMDocument ($this->dom). * * @since 0.4 + * @var array[] */ private $styles = array(); @@ -47,6 +48,15 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { */ private $custom_max_size; + /** + * Current CSS size. + * + * Sum of CSS located in $styles and $stylesheets. + * + * @var int + */ + private $current_custom_size = 0; + /** * The style[amp-custom] element. * @@ -125,7 +135,7 @@ public function __construct( DOMDocument $dom, array $args = array() ) { * * @since 0.4 * - * @return string[] Mapping CSS selectors to array of properties, or mapping of keys starting with 'stylesheet:' with value being the stylesheet. + * @return array[] Mapping CSS selectors to array of properties, or mapping of keys starting with 'stylesheet:' with value being the stylesheet. */ public function get_styles() { if ( ! $this->did_convert_elements ) { @@ -209,22 +219,7 @@ public function sanitize() { $head->appendChild( $this->amp_custom_style_element ); } - // Gather stylesheets to print as long as they don't surpass the limit. - $skipped = array(); - $css = ''; - $total_size = 0; - foreach ( $this->get_stylesheets() as $key => $stylesheet ) { - $sheet_size = strlen( $stylesheet ); - if ( $total_size + $sheet_size > $this->custom_max_size ) { - $skipped[] = $key; - } else { - if ( $total_size ) { - $css .= ' '; - } - $css .= $stylesheet; - $total_size += $sheet_size; - } - } + $css = implode( '', $this->get_stylesheets() ); /* * Let the style[amp-custom] be populated with the concatenated CSS. @@ -236,15 +231,6 @@ public function sanitize() { $this->amp_custom_style_element->removeChild( $this->amp_custom_style_element->firstChild ); } $this->amp_custom_style_element->appendChild( $this->dom->createTextNode( $css ) ); - - // @todo This would be a candidate for sanitization reporting. - // Add comments to indicate which sheets were not included. - foreach ( array_reverse( $skipped ) as $skip ) { - $this->amp_custom_style_element->parentNode->insertBefore( - $this->dom->createComment( sprintf( 'Skipped including %s stylesheet since too large.', $skip ) ), - $this->amp_custom_style_element->nextSibling - ); - } } } @@ -303,18 +289,30 @@ public function get_validated_css_file_path( $src ) { * @param DOMElement $element Style element. */ private function process_style_element( DOMElement $element ) { - if ( 'body' === $element->parentNode->nodeName && $element->hasAttribute( 'amp-keyframes' ) ) { + if ( $element->hasAttribute( 'amp-keyframes' ) ) { $validity = $this->validate_amp_keyframe( $element ); - if ( true !== $validity ) { - $element->parentNode->removeChild( $element ); // @todo Add reporting. + if ( is_wp_error( $validity ) ) { + $this->remove_invalid_child( $element, array( + 'message' => $validity->get_error_message(), + ) ); } return; } $rules = trim( $element->textContent ); - $rules = $this->remove_illegal_css( $rules ); + $rules = $this->remove_illegal_css( $rules, $element ); + + // Remove if surpasses max size. + $length = strlen( $rules ); + if ( $this->current_custom_size + $length > $this->custom_max_size ) { + $this->remove_invalid_child( $element, array( + 'message' => __( 'Too much CSS enqueued.', 'amp' ), + ) ); + return; + } $this->stylesheets[ md5( $rules ) ] = $rules; + $this->current_custom_size += $length; if ( $element->hasAttribute( 'amp-custom' ) ) { if ( ! $this->amp_custom_style_element ) { @@ -344,22 +342,34 @@ private function process_link_element( DOMElement $element ) { $css_file_path = $this->get_validated_css_file_path( $href ); if ( is_wp_error( $css_file_path ) ) { - $element->parentNode->removeChild( $element ); // @todo Report removal. Show HTML comment? + $this->remove_invalid_child( $element, array( + 'message' => $css_file_path->get_error_message(), + ) ); return; } // Load the CSS from the filesystem. - $css = "\n/* $href */\n"; - $css .= file_get_contents( $css_file_path ); // phpcs:ignore -- It's a local filesystem path not a remote request. + $rules = "\n/* $href */\n"; + $rules .= file_get_contents( $css_file_path ); // phpcs:ignore -- It's a local filesystem path not a remote request. - $css = $this->remove_illegal_css( $css ); + $rules = $this->remove_illegal_css( $rules, $element ); $media = $element->getAttribute( 'media' ); if ( $media && 'all' !== $media ) { - $css = sprintf( '@media %s { %s }', $media, $css ); + $rules = sprintf( '@media %s { %s }', $media, $rules ); + } + + // Remove if surpasses max size. + $length = strlen( $rules ); + if ( $this->current_custom_size + $length > $this->custom_max_size ) { + $this->remove_invalid_child( $element, array( + 'message' => __( 'Too much CSS enqueued.', 'amp' ), + ) ); + return; } - $this->stylesheets[ $href ] = $css; + $this->current_custom_size += $length; + $this->stylesheets[ $href ] = $rules; // Remove now that styles have been processed. $element->parentNode->removeChild( $element ); @@ -372,12 +382,25 @@ private function process_link_element( DOMElement $element ) { * * @todo This needs proper CSS parser and to take an alternative approach to removing !important by extracting * the rule into a separate style rule with a very specific selector. - * @param string $stylesheet Stylesheet. + * @param string $stylesheet Stylesheet. + * @param DOMElement $element Element where the stylesheet came from. * @return string Scrubbed stylesheet. */ - private function remove_illegal_css( $stylesheet ) { - $stylesheet = preg_replace( '/\s*!important/', '', $stylesheet ); // Note this has to also replace inside comments to be valid. - $stylesheet = preg_replace( '/overflow\s*:\s*(auto|scroll)\s*;?\s*/', '', $stylesheet ); + private function remove_illegal_css( $stylesheet, $element ) { + $stylesheet = preg_replace( '/\s*!important/', '', $stylesheet, -1, $important_count ); // Note this has to also replace inside comments to be valid. + if ( $important_count > 0 && ! empty( $this->args['validation_error_callback'] ) ) { + call_user_func( $this->args['validation_error_callback'], array( + 'code' => 'css_important_removed', + 'node' => $element, + ) ); + } + $stylesheet = preg_replace( '/overflow(-[xy])?\s*:\s*(auto|scroll)\s*;?\s*/', '', $stylesheet, -1, $overlow_count ); + if ( $overlow_count > 0 && ! empty( $this->args['validation_error_callback'] ) ) { + call_user_func( $this->args['validation_error_callback'], array( + 'code' => 'css_overflow_property_removed', + 'node' => $element, + ) ); + } return $stylesheet; } @@ -391,15 +414,19 @@ private function remove_illegal_css( $stylesheet ) { * @return true|WP_Error Validity. */ private function validate_amp_keyframe( $style ) { + if ( 'body' !== $style->parentNode->nodeName ) { + return new WP_Error( 'mandatory_body_child', __( 'amp-keyframes is not child of body element.', 'amp' ) ); + } + if ( $this->keyframes_max_size && strlen( $style->textContent ) > $this->keyframes_max_size ) { - return new WP_Error( 'max_bytes' ); + return new WP_Error( 'max_bytes', __( 'amp-keyframes is too large', 'amp' ) ); } // This logic could be in AMP_Tag_And_Attribute_Sanitizer, but since it only applies to amp-keyframes it seems unnecessary. $next_sibling = $style->nextSibling; while ( $next_sibling ) { if ( $next_sibling instanceof DOMElement ) { - return new WP_Error( 'mandatory_last_child' ); + return new WP_Error( 'mandatory_last_child', __( 'amp-keyframes is not last element in body.', 'amp' ) ); } $next_sibling = $next_sibling->nextSibling; } @@ -423,19 +450,30 @@ private function validate_amp_keyframe( $style ) { * @param DOMElement $element Node. */ private function collect_inline_styles( $element ) { - $style = $element->getAttribute( 'style' ); - if ( ! $style ) { + $value = $element->getAttribute( 'style' ); + if ( ! $value ) { return; } $class = $element->getAttribute( 'class' ); - $style = $this->process_style( $style ); - if ( ! empty( $style ) ) { - $class_name = $this->generate_class_name( $style ); + $properties = $this->process_style( $value ); + + if ( ! empty( $properties ) ) { + $class_name = $this->generate_class_name( $properties ); $new_class = trim( $class . ' ' . $class_name ); + $selector = '.' . $class_name; + $length = strlen( sprintf( '%s { %s }', $selector, join( '; ', $properties ) . ';' ) ); + + if ( $this->current_custom_size + $length > $this->custom_max_size ) { + $this->remove_invalid_attribute( $element, 'style', array( + 'message' => __( 'Too much CSS.', 'amp' ), + ) ); + return; + } + $element->setAttribute( 'class', $new_class ); - $this->styles[ '.' . $class_name ] = $style; + $this->styles[ $selector ] = $properties; } $element->removeAttribute( 'style' ); } @@ -446,12 +484,13 @@ private function collect_inline_styles( $element ) { * @since 0.4 * * @param string $string Style string. - * @return array + * @return array Style properties. */ private function process_style( $string ) { - - /** + /* * Filter properties + * + * @todo Removed values are not reported. */ $string = safecss_filter_attr( esc_html( $string ) ); @@ -503,14 +542,13 @@ private function process_style( $string ) { * @return array */ private function filter_style( $property, $value ) { - - /** + /* * Remove overflow if value is `auto` or `scroll`; not allowed in AMP * * @todo This removal needs to be reported. * @see https://www.ampproject.org/docs/reference/spec.html#properties */ - if ( preg_match( '#^overflow#i', $property ) && preg_match( '#^(auto|scroll)$#i', $value ) ) { + if ( preg_match( '#^overflow(-[xy])?$#i', $property ) && preg_match( '#^(auto|scroll)$#i', $value ) ) { return array( false, false ); } @@ -518,7 +556,7 @@ private function filter_style( $property, $value ) { $property = 'max-width'; } - /** + /* * Remove `!important`; not allowed in AMP * * @todo This removal needs to be reported. diff --git a/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php b/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php index 18f4412882e..030c2be8d3b 100644 --- a/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php +++ b/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php @@ -1559,9 +1559,8 @@ private function remove_node( $node ) { $node = $parent; $parent = $parent->parentNode; if ( $parent ) { - $this->remove_invalid_child( $node ); + $parent->removeChild( $node ); } } } } - diff --git a/includes/utils/class-amp-validation-utils.php b/includes/utils/class-amp-validation-utils.php index 17d5814a6eb..2ba61273427 100644 --- a/includes/utils/class-amp-validation-utils.php +++ b/includes/utils/class-amp-validation-utils.php @@ -13,25 +13,176 @@ class AMP_Validation_Utils { /** - * Key for the markup value in the REST API endpoint. + * Query var that triggers validation. * - * @var string. + * @var string */ - const MARKUP_KEY = 'markup'; + const VALIDATE_QUERY_VAR = 'amp_validate'; /** - * Key for the error value in the response. + * Query var that enables validation debug mode, to disable removal of invalid elements/attributes. * - * @var string. + * @var string */ - const ERROR_KEY = 'has_error'; + const DEBUG_QUERY_VAR = 'amp_debug'; /** - * The nodes that the sanitizer removed. + * Query var for cache-busting. * - * @var array[][] + * @var string */ - public static $removed_nodes = array(); + const CACHE_BUST_QUERY_VAR = 'amp_cache_bust'; + + /** + * The slug of the post type to store AMP errors. + * + * @var string + */ + const POST_TYPE_SLUG = 'amp_validation_error'; + + /** + * The key in the response for the sources that have invalid output. + * + * @var string + */ + const SOURCES_INVALID_OUTPUT = 'sources_with_invalid_output'; + + /** + * Validation code for an invalid element. + * + * @var string + */ + const INVALID_ELEMENT_CODE = 'invalid_element'; + + /** + * Validation code for an invalid attribute. + * + * @var string + */ + const INVALID_ATTRIBUTE_CODE = 'invalid_attribute'; + + /** + * Validation code for when script is enqueued (which is not allowed). + * + * @var string + */ + const ENQUEUED_SCRIPT_CODE = 'enqueued_script'; + + /** + * The meta key for the AMP URL where the error occurred. + * + * @var string + */ + const AMP_URL_META = 'amp_url'; + + /** + * The key for removed elements. + * + * @var string + */ + const REMOVED_ELEMENTS = 'removed_elements'; + + /** + * The key for removed attributes. + * + * @var string + */ + const REMOVED_ATTRIBUTES = 'removed_attributes'; + + /** + * The key for removed sources. + * + * @var string + */ + const REMOVED_SOURCES = 'removed_sources'; + + /** + * The action to recheck URLs for AMP validity. + * + * @var string + */ + const RECHECK_ACTION = 'amp_recheck'; + + /** + * The query arg for whether there are remaining errors after rechecking URLs. + * + * @var string + */ + const REMAINING_ERRORS = 'amp_remaining_errors'; + + /** + * The query arg for the number of URLs tested. + * + * @var string + */ + const URLS_TESTED = 'amp_urls_tested'; + + /** + * The nonce action for rechecking a URL. + * + * @var string + */ + const NONCE_ACTION = 'amp_recheck_'; + + /** + * HTTP response header name containing JSON-serialized validation errors. + * + * @var string + */ + const VALIDATION_ERRORS_RESPONSE_HEADER_NAME = 'X-AMP-Validation-Errors'; + + /** + * Transient key to store validation errors when activating a plugin. + * + * @var string + */ + const PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY = 'amp_plugin_activation_validation_errors'; + + /** + * The name of the side meta box on the CPT post.php page. + * + * @var string + */ + const STATUS_META_BOX = 'amp_validation_status'; + + /** + * The name of the side meta box on the CPT post.php page. + * + * @var string + */ + const VALIDATION_ERRORS_META_BOX = 'amp_validation_errors'; + + /** + * The errors encountered when validating. + * + * @var array[][] { + * @type string $code Error code. + * @type string $node_name Name of removed node. + * @type string $parent_name Name of parent node. + * } + */ + public static $validation_errors = array(); + + /** + * Sources that enqueue each script. + * + * @var array + */ + public static $enqueued_script_sources = array(); + + /** + * Sources that enqueue each style. + * + * @var array + */ + public static $enqueued_style_sources = array(); + + /** + * Post IDs for posts that have been updated which need to be re-validated. + * + * @var int[] + */ + public static $posts_pending_frontend_validation = array(); /** * Add the actions. @@ -39,78 +190,186 @@ class AMP_Validation_Utils { * @return void */ public static function init() { - add_action( 'rest_api_init', array( __CLASS__, 'amp_rest_validation' ) ); - add_action( 'edit_form_top', array( __CLASS__, 'validate_content' ), 10, 2 ); + if ( current_theme_supports( 'amp' ) ) { + add_action( 'init', array( __CLASS__, 'register_post_type' ) ); + add_filter( 'dashboard_glance_items', array( __CLASS__, 'filter_dashboard_glance_items' ) ); + add_action( 'rightnow_end', array( __CLASS__, 'print_dashboard_glance_styles' ) ); + add_action( 'save_post', array( __CLASS__, 'handle_save_post_prompting_validation' ), 10, 2 ); + } + + add_action( 'edit_form_top', array( __CLASS__, 'print_edit_form_validation_status' ), 10, 2 ); + add_action( 'all_admin_notices', array( __CLASS__, 'plugin_notice' ) ); + add_filter( 'manage_' . self::POST_TYPE_SLUG . '_posts_columns', array( __CLASS__, 'add_post_columns' ) ); + add_action( 'manage_posts_custom_column', array( __CLASS__, 'output_custom_column' ), 10, 2 ); + add_filter( 'post_row_actions', array( __CLASS__, 'filter_row_actions' ), 10, 2 ); + add_filter( 'bulk_actions-edit-' . self::POST_TYPE_SLUG, array( __CLASS__, 'add_bulk_action' ), 10, 2 ); + add_filter( 'handle_bulk_actions-edit-' . self::POST_TYPE_SLUG, array( __CLASS__, 'handle_bulk_action' ), 10, 3 ); + add_action( 'admin_notices', array( __CLASS__, 'remaining_error_notice' ) ); + add_action( 'post_action_' . self::RECHECK_ACTION, array( __CLASS__, 'handle_inline_recheck' ) ); + add_action( 'admin_menu', array( __CLASS__, 'remove_publish_meta_box' ) ); + add_action( 'admin_menu', array( __CLASS__, 'add_admin_menu_validation_status_count' ) ); + add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) ); + + // Actions and filters involved in validation. + add_action( 'activate_plugin', function() { + if ( ! has_action( 'shutdown', array( __CLASS__, 'validate_after_plugin_activation' ) ) ) { + add_action( 'shutdown', array( __CLASS__, 'validate_after_plugin_activation' ) ); // Shutdown so all plugins will have been activated. + } + } ); + } + + /** + * Add count of how many validation error posts there are to the admin menu. + */ + public static function add_admin_menu_validation_status_count() { + global $submenu; + if ( ! isset( $submenu[ AMP_Options_Manager::OPTION_NAME ] ) ) { + return; + } + $count = wp_count_posts( self::POST_TYPE_SLUG ); + if ( empty( $count->publish ) ) { + return; + } + foreach ( $submenu[ AMP_Options_Manager::OPTION_NAME ] as &$submenu_item ) { + if ( 'edit.php?post_type=' . self::POST_TYPE_SLUG === $submenu_item[2] ) { + $submenu_item[0] .= ' ' . esc_html( $count->publish ) . ''; + break; + } + } } /** - * Tracks when a sanitizer removes an node (element or attribute). + * Filter At a Glance items add AMP Validation Errors. * - * @param DOMNode $node The node which was removed. - * @return void + * @param array $items At a glance items. + * @return array Items. + */ + public static function filter_dashboard_glance_items( $items ) { + $counts = wp_count_posts( self::POST_TYPE_SLUG ); + if ( ! empty( $counts->publish ) ) { + $items[] = sprintf( + '%s', + esc_url( admin_url( 'edit.php?post_type=' . self::POST_TYPE_SLUG ) ), + esc_html( sprintf( + /* translators: %s is the validation error count */ + _n( '%s AMP Validation Error', '%s AMP Validation Errors', $counts->publish, 'amp' ), + $counts->publish + ) ) + ); + } + return $items; + } + + /** + * Print styles for the At a Glance widget. + */ + public static function print_dashboard_glance_styles() { + ?> + + post_type ) + && + ! wp_is_post_autosave( $post ) + && + ! wp_is_post_revision( $post ) + ); + if ( $should_validate_post ) { + self::$posts_pending_frontend_validation[] = $post_id; + + // The reason for shutdown is to ensure that all postmeta changes have been saved, including whether AMP is enabled. + if ( ! has_action( 'shutdown', array( __CLASS__, 'validate_queued_posts_on_frontend' ) ) ) { + add_action( 'shutdown', array( __CLASS__, 'validate_queued_posts_on_frontend' ) ); + } + } + } + + /** + * Validate the posts pending frontend validation. + * + * @see AMP_Validation_Utils::handle_save_post_prompting_validation() + */ + public static function validate_queued_posts_on_frontend() { + $posts = array_filter( + array_map( 'get_post', self::$posts_pending_frontend_validation ), + function( $post ) { + return $post && post_supports_amp( $post ) && 'trash' !== $post->post_status; + } + ); + + // @todo Only validate the first and then queue the rest in WP Cron? + foreach ( $posts as $post ) { + $url = amp_get_permalink( $post->ID ); + if ( ! $url ) { + continue; + } + + $validation_errors = self::validate_url( $url ); + if ( is_wp_error( $validation_errors ) ) { + continue; + } + + self::store_validation_errors( $validation_errors, $url ); + } } /** * Processes markup, to determine AMP validity. * * Passes $markup through the AMP sanitizers. - * Also passes a 'remove_invalid_callback' to keep track of stripped attributes and nodes. + * Also passes a 'validation_error_callback' to keep track of stripped attributes and nodes. * * @param string $markup The markup to process. - * @return void. + * @return string Sanitized markup. */ public static function process_markup( $markup ) { - if ( ! self::has_cap() ) { - return; - } - AMP_Theme_Support::register_content_embed_handlers(); - remove_filter( 'the_content', 'wpautop' ); /** This filter is documented in wp-includes/post-template.php */ $markup = apply_filters( 'the_content', $markup ); $args = array( - 'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, - 'remove_invalid_callback' => 'AMP_Validation_Utils::track_removed', + 'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, + 'validation_error_callback' => 'AMP_Validation_Utils::add_validation_error', ); - AMP_Content_Sanitizer::sanitize( $markup, amp_get_content_sanitizers(), $args ); - } - /** - * Registers the REST API endpoint for validation. - * - * @return void. - */ - public static function amp_rest_validation() { - register_rest_route( 'amp-wp/v1', '/validate', array( - 'methods' => 'POST', - 'callback' => array( __CLASS__, 'validate_markup' ), - 'args' => array( - self::MARKUP_KEY => array( - 'validate_callback' => array( __CLASS__, 'validate_arg' ), - ), - ), - 'permission_callback' => array( __CLASS__, 'has_cap' ), - ) ); + $results = AMP_Content_Sanitizer::sanitize( $markup, amp_get_content_sanitizers(), $args ); + return $results[0]; } /** * Whether the user has the required capability. * * Checks for permissions before validating. - * Also serves as the permission callback for REST requests. * * @return boolean $has_cap Whether the current user has the capability. */ @@ -119,69 +378,112 @@ public static function has_cap() { } /** - * Validate the markup passed to the REST API. + * Add validation error. + * + * @param array $data { + * Data. * - * @param WP_REST_Request $request The REST request. - * @return array|WP_Error. + * @type string $code Error code. + * @type DOMElement|DOMNode $node The removed node. + * } */ - public static function validate_markup( WP_REST_Request $request ) { - $json = $request->get_json_params(); - if ( empty( $json[ self::MARKUP_KEY ] ) ) { - return new WP_Error( 'no_markup', 'No markup passed to validator', array( - 'status' => 404, - ) ); + public static function add_validation_error( array $data ) { + $node = null; + + if ( isset( $data['node'] ) && $data['node'] instanceof DOMNode ) { + $node = $data['node']; + unset( $data['node'] ); + $data['node_name'] = $node->nodeName; + $data['sources'] = self::locate_sources( $node ); + if ( $node->parentNode ) { + $data['parent_name'] = $node->parentNode->nodeName; + } } - return self::get_response( $json[ self::MARKUP_KEY ] ); + if ( $node instanceof DOMElement ) { + if ( ! isset( $data['code'] ) ) { + $data['code'] = self::INVALID_ELEMENT_CODE; + } + $data['node_attributes'] = array(); + foreach ( $node->attributes as $attribute ) { + $data['node_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; + } + + $is_enqueued_link = ( + 'link' === $node->nodeName + && + preg_match( '/(?P.+)-css$/', (string) $node->getAttribute( 'id' ), $matches ) + && + isset( self::$enqueued_style_sources[ $matches['handle'] ] ) + ); + if ( $is_enqueued_link ) { + $data['sources'] = self::$enqueued_style_sources[ $matches['handle'] ]; + } + } elseif ( $node instanceof DOMAttr ) { + if ( ! isset( $data['code'] ) ) { + $data['code'] = self::INVALID_ATTRIBUTE_CODE; + } + $data['element_attributes'] = array(); + if ( $node->parentNode && $node->parentNode->hasAttributes() ) { + foreach ( $node->parentNode->attributes as $attribute ) { + $data['element_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; + } + } + } + + if ( ! isset( $data['code'] ) ) { + $data['code'] = 'unknown'; + } + + self::$validation_errors[] = $data; } /** * Gets the AMP validation response. * - * If $markup isn't passed, - * It will return the validation errors the sanitizers found in rendering the page. + * Returns the current validation errors the sanitizers found in rendering the page. * - * @param string $markup To validate for AMP compatibility (optional). - * @return array $response The AMP validity of the markup. + * @param array $validation_errors Validation errors. + * @return array The AMP validity of the markup. */ - public static function get_response( $markup = null ) { - $response = array(); - if ( isset( $markup ) ) { - self::process_markup( $markup ); - $response['processed_markup'] = $markup; - } - + public static function summarize_validation_errors( $validation_errors ) { + $results = array(); $removed_elements = array(); $removed_attributes = array(); - foreach ( self::$removed_nodes as $removed ) { - $node = $removed['node']; - if ( $node instanceof DOMAttr ) { - if ( ! isset( $removed_attributes[ $node->nodeName ] ) ) { - $removed_attributes[ $node->nodeName ] = 1; - } else { - $removed_attributes[ $node->nodeName ]++; + $invalid_sources = array(); + foreach ( $validation_errors as $validation_error ) { + $code = isset( $validation_error['code'] ) ? $validation_error['code'] : null; + + if ( self::INVALID_ELEMENT_CODE === $code ) { + if ( ! isset( $removed_elements[ $validation_error['node_name'] ] ) ) { + $removed_elements[ $validation_error['node_name'] ] = 0; } - } elseif ( $node instanceof DOMElement ) { - if ( ! isset( $removed_elements[ $node->nodeName ] ) ) { - $removed_elements[ $node->nodeName ] = 1; - } else { - $removed_elements[ $node->nodeName ]++; + $removed_elements[ $validation_error['node_name'] ] += 1; + } elseif ( self::INVALID_ATTRIBUTE_CODE === $code ) { + if ( ! isset( $removed_attributes[ $validation_error['node_name'] ] ) ) { + $removed_attributes[ $validation_error['node_name'] ] = 0; } + $removed_attributes[ $validation_error['node_name'] ] += 1; + } + + if ( ! empty( $validation_error['sources'] ) ) { + $source = array_pop( $validation_error['sources'] ); + + $invalid_sources[ $source['type'] ][] = $source['name']; } } - $response = array_merge( + $results = array_merge( array( - self::ERROR_KEY => self::was_node_removed(), + self::SOURCES_INVALID_OUTPUT => $invalid_sources, ), compact( 'removed_elements', 'removed_attributes' ), - $response ); - self::reset_removed(); + $results ); - return $response; + return $results; } /** @@ -191,86 +493,1203 @@ public static function get_response( $markup = null ) { * these static values will remain. * So reset them in case another test is needed. * - * @return void. - */ - public static function reset_removed() { - self::$removed_nodes = array(); - } - - /** - * Validate the argument in the REST API request. - * - * It would be ideal to simply pass 'is_string' in register_rest_route(). - * But it always returned false. - * - * @param mixed $arg The argument to validate. - * @return boolean $is_valid Whether the argument is valid. + * @return void */ - public static function validate_arg( $arg ) { - return is_string( $arg ); + public static function reset_validation_results() { + self::$validation_errors = array(); + self::$enqueued_style_sources = array(); + self::$enqueued_script_sources = array(); } /** * Checks the AMP validity of the post content. * - * If it's not valid AMP, - * it displays an error message above the 'Classic' editor. + * If it's not valid AMP, it displays an error message above the 'Classic' editor. * * @param WP_Post $post The updated post. - * @return void. + * @return void */ - public static function validate_content( $post ) { + public static function print_edit_form_validation_status( $post ) { if ( ! post_supports_amp( $post ) || ! self::has_cap() ) { return; } - AMP_Theme_Support::register_content_embed_handlers(); - /** This filter is documented in wp-includes/post-template.php */ - $filtered_content = apply_filters( 'the_content', $post->post_content, $post->ID ); - $response = self::get_response( $filtered_content ); - if ( isset( $response[ self::ERROR_KEY ] ) && ( true === $response[ self::ERROR_KEY ] ) ) { - self::display_error( $response ); + + $url = null; + $validation_status_post = null; + $validation_errors = array(); + + // Validate post content outside frontend context. + if ( post_type_supports( $post->post_type, 'editor' ) ) { + self::process_markup( $post->post_content ); + $validation_errors = array_merge( + $validation_errors, + self::$validation_errors + ); + self::reset_validation_results(); + } + + // Incorporate frontend validation status if there is a known URL for the post. + if ( is_post_type_viewable( $post->post_type ) ) { + $url = amp_get_permalink( $post->ID ); + + $validation_status_post = self::get_validation_status_post( $url ); + if ( $validation_status_post ) { + $data = json_decode( $validation_status_post->post_content, true ); + if ( is_array( $data ) ) { + $validation_errors = array_merge( $validation_errors, $data ); + } + } + } + + if ( empty( $validation_errors ) ) { + return; } - } - /** - * Displays an error message on /wp-admin/post.php. - * - * Located at the top of the 'Classic' editor. - * States that the content is not valid AMP. - * - * @param array $response The validation response, an associative array. - * @return void. - */ - public static function display_error( $response ) { echo '
'; - printf( '

%s

', esc_html__( 'Warning: There is content which fails AMP validation; it will be stripped when served as AMP.', 'amp' ) ); + echo '

'; + esc_html_e( 'Warning: There is content which fails AMP validation; it will be stripped when served as AMP.', 'amp' ); + if ( $validation_status_post || $url ) { + if ( $validation_status_post ) { + echo sprintf( + ' %s', + esc_url( get_edit_post_link( $validation_status_post ) ), + esc_html__( 'Details', 'amp' ) + ); + } + if ( $url ) { + if ( $validation_status_post ) { + echo ' | '; + } + echo sprintf( + ' %s', + esc_url( self::get_debug_url( $url ) ), + esc_attr__( 'Validate URL on frontend but without invalid elements/attributes removed', 'amp' ), + esc_html__( 'Debug', 'amp' ) + ); + } + } + echo '

'; + + $results = self::summarize_validation_errors( array_unique( $validation_errors, SORT_REGULAR ) ); $removed_sets = array(); - if ( ! empty( $response['removed_elements'] ) && is_array( $response['removed_elements'] ) ) { + if ( ! empty( $results[ self::REMOVED_ELEMENTS ] ) && is_array( $results[ self::REMOVED_ELEMENTS ] ) ) { $removed_sets[] = array( 'label' => __( 'Invalid elements:', 'amp' ), - 'names' => array_map( 'sanitize_key', $response['removed_elements'] ), + 'names' => array_map( 'sanitize_key', $results[ self::REMOVED_ELEMENTS ] ), ); } - if ( ! empty( $response['removed_attributes'] ) && is_array( $response['removed_attributes'] ) ) { + if ( ! empty( $results[ self::REMOVED_ATTRIBUTES ] ) && is_array( $results[ self::REMOVED_ATTRIBUTES ] ) ) { $removed_sets[] = array( 'label' => __( 'Invalid attributes:', 'amp' ), - 'names' => array_map( 'sanitize_key', $response['removed_attributes'] ), + 'names' => array_map( 'sanitize_key', $results[ self::REMOVED_ATTRIBUTES ] ), ); } + // @todo There are other kinds of errors other than REMOVED_ELEMENTS and REMOVED_ATTRIBUTES. foreach ( $removed_sets as $removed_set ) { printf( '

%s ', esc_html( $removed_set['label'] ) ); - $items = array(); - foreach ( $removed_set['names'] as $name => $count ) { - if ( 1 === intval( $count ) ) { - $items[] = sprintf( '%s', esc_html( $name ) ); + self::output_removed_set( $removed_set['names'] ); + echo '

'; + } + + echo '
'; + } + + /** + * Get source start comment. + * + * @param string $type Extension type. + * @param string $name Extension name. + * @param array $args Args. + * @return string HTML Comment. + */ + public static function get_source_comment_start( $type, $name, $args = array() ) { + $args_encoded = wp_json_encode( $args ); + if ( '[]' === $args_encoded ) { + $args_encoded = '{}'; + } + return sprintf( '', $type, $name, str_replace( '--', '', $args_encoded ) ); + } + + /** + * Get source end comment. + * + * @param string $type Extension type. + * @param string $name Extension name. + * @return string HTML Comment. + */ + public static function get_source_comment_end( $type, $name ) { + return sprintf( '', $type, $name ); + } + + /** + * Parse source comment. + * + * @param DOMComment $comment Comment. + * @return array|null Source info or null if not a source comment. + */ + public static function parse_source_comment( DOMComment $comment ) { + if ( ! preg_match( '#^\s*(?P/)?amp-source-stack:(?Ptheme|plugin|mu-plugin):(?P\S+)(?: (?P{.+}))?\s*$#s', $comment->nodeValue, $matches ) ) { + return null; + } + $source = wp_array_slice_assoc( $matches, array( 'type', 'name' ) ); + + $source['closing'] = ! empty( $matches['closing'] ); + if ( isset( $matches['args'] ) ) { + $source['args'] = json_decode( $matches['args'], true ); + } + return $source; + } + + /** + * Walk back tree to find the open sources. + * + * @param DOMNode $node Node to look for. + * @return array[][] { + * The data of the removed sources (theme, plugin, or mu-plugin). + * + * @type string $name The name of the source. + * @type string $type The type of the source. + * } + */ + public static function locate_sources( DOMNode $node ) { + $xpath = new DOMXPath( $node->ownerDocument ); + $comments = $xpath->query( 'preceding::comment()[ contains( ., "amp-source-stack:" ) ]', $node ); + $sources = array(); + foreach ( $comments as $comment ) { + $source = self::parse_source_comment( $comment ); + if ( $source ) { + if ( $source['closing'] ) { + array_pop( $sources ); } else { - $items[] = sprintf( '%s (%d)', esc_html( $name ), $count ); + unset( $source['closing'] ); + $sources[] = $source; } } - echo implode( ', ', $items ); // WPCS: XSS OK. - echo '

'; } - echo ''; + return $sources; + } + + /** + * Remove source comments. + * + * @param DOMDocument $dom Document. + */ + public static function remove_source_comments( $dom ) { + $xpath = new DOMXPath( $dom ); + $comments = array(); + foreach ( $xpath->query( '//comment()[ contains( ., "amp-source-stack:" ) ]' ) as $comment ) { + if ( self::parse_source_comment( $comment ) ) { + $comments[] = $comment; + } + } + foreach ( $comments as $comment ) { + $comment->parentNode->removeChild( $comment ); + } + } + + /** + * Wraps callbacks in comments to indicate to the sanitizer which extension added them. + * + * Iterates through all of the registered callbacks for actions and filters. + * If a callback is from a plugin and outputs markup, this wraps the markup in comments. + * Later, the sanitizer can identify which theme or plugin the illegal markup is from. + * + * @global array $wp_filter + * @return void + */ + public static function callback_wrappers() { + global $wp_filter; + $pending_wrap_callbacks = array(); + foreach ( $wp_filter as $filter_tag => $wp_hook ) { + foreach ( $wp_hook->callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + $source_data = self::get_source( $callback['function'] ); + if ( isset( $source_data ) ) { + $pending_wrap_callbacks[ $filter_tag ][] = array_merge( + $callback, + $source_data, + array( + 'hook' => $filter_tag, + ), + compact( 'priority' ) + ); + } + } + } + } + + // Iterate over hooks to replace after iterating over all to begin with to prevent infinite loop in PHP<=5.4. + foreach ( $pending_wrap_callbacks as $hook => $callbacks ) { + foreach ( $callbacks as $callback ) { + remove_action( $hook, $callback['function'], $callback['priority'] ); + $wrapped_callback = self::wrapped_callback( $callback ); + add_action( $hook, $wrapped_callback, $callback['priority'], $callback['accepted_args'] ); + } + } + } + + /** + * Filters the output created by a shortcode callback. + * + * @since 0.7 + * + * @param string $output Shortcode output. + * @param string $tag Shortcode name. + * @return string Output. + * @global array $shortcode_tags + */ + public static function decorate_shortcode_source( $output, $tag ) { + global $shortcode_tags; + if ( ! isset( $shortcode_tags[ $tag ] ) ) { + return $output; + } + $source = self::get_source( $shortcode_tags[ $tag ] ); + if ( empty( $source ) ) { + return $output; + } + $output = implode( '', array( + self::get_source_comment_start( $source['type'], $source['name'], array( 'shortcode' => $tag ) ), + $output, + self::get_source_comment_end( $source['type'], $source['name'] ), + ) ); + return $output; + } + + /** + * Gets the plugin or theme of the callback, if one exists. + * + * @param string|array $callback The callback for which to get the plugin. + * @return array|null { + * The source data. + * + * @type string $type Source type. + * @type string $name Source name. + * } + */ + public static function get_source( $callback ) { + try { + if ( is_string( $callback ) && is_callable( $callback ) ) { + // The $callback is a function or static method. + $exploded_callback = explode( '::', $callback ); + if ( count( $exploded_callback ) > 1 ) { + $reflection = new ReflectionClass( $exploded_callback[0] ); + } else { + $reflection = new ReflectionFunction( $callback ); + } + } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) && method_exists( $callback[0], $callback[1] ) ) { + // The $callback is a method. + $reflection = new ReflectionClass( $callback[0] ); + } elseif ( is_object( $callback ) && ( 'Closure' === get_class( $callback ) ) ) { + $reflection = new ReflectionFunction( $callback ); + } + } catch ( Exception $e ) { + return null; + } + + $file = isset( $reflection ) ? $reflection->getFileName() : null; + if ( ! isset( $file ) ) { + return null; + } + $file = wp_normalize_path( $file ); + + $slug_pattern = '([^/]+)'; + if ( preg_match( ':' . preg_quote( trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ), ':' ) . $slug_pattern . ':s', $file, $matches ) ) { + $type = 'plugin'; + $name = $matches[1]; + } elseif ( preg_match( ':' . preg_quote( trailingslashit( wp_normalize_path( get_theme_root() ) ), ':' ) . $slug_pattern . ':s', $file, $matches ) ) { + $type = 'theme'; + $name = $matches[1]; + } elseif ( preg_match( ':' . preg_quote( trailingslashit( wp_normalize_path( WPMU_PLUGIN_DIR ) ), ':' ) . $slug_pattern . ':s', $file, $matches ) ) { + $type = 'mu-plugin'; + $name = $matches[1]; + } + + if ( isset( $type, $name ) ) { + return compact( 'type', 'name' ); + } + return null; + } + + /** + * Wraps a callback in comments if it outputs markup. + * + * If the sanitizer removes markup, + * this indicates which plugin it was from. + * The call_user_func_array() logic is mainly copied from WP_Hook:apply_filters(). + * + * @param array $callback { + * The callback data. + * + * @type callable $function + * @type int $accepted_args + * @type string $type + * @type string $source + * @type string $hook + * } + * @return closure $wrapped_callback The callback, wrapped in comments. + */ + public static function wrapped_callback( $callback ) { + return function() use ( $callback ) { + global $wp_styles, $wp_scripts; + + $function = $callback['function']; + $accepted_args = $callback['accepted_args']; + $args = func_get_args(); + + $before_styles_enqueued = array(); + if ( isset( $wp_styles ) && isset( $wp_styles->queue ) ) { + $before_styles_enqueued = $wp_styles->queue; + } + $before_scripts_enqueued = array(); + if ( isset( $wp_scripts ) && isset( $wp_scripts->queue ) ) { + $before_scripts_enqueued = $wp_scripts->queue; + } + + ob_start(); + $result = call_user_func_array( $function, array_slice( $args, 0, intval( $accepted_args ) ) ); + $output = ob_get_clean(); + + // Keep track of which source enqueued the styles. + if ( isset( $wp_styles ) && isset( $wp_styles->queue ) ) { + foreach ( array_diff( $wp_styles->queue, $before_styles_enqueued ) as $handle ) { + $source = array_merge( + wp_array_slice_assoc( $callback, array( 'type', 'name' ) ), + array( + 'args' => array( + 'hook' => $callback['hook'], + ), + ) + ); + + AMP_Validation_Utils::$enqueued_style_sources[ $handle ][] = $source; + } + } + + // Keep track of which source enqueued the scripts, and immediately report validity . + if ( isset( $wp_scripts ) && isset( $wp_scripts->queue ) ) { + foreach ( array_diff( $wp_scripts->queue, $before_scripts_enqueued ) as $handle ) { + $source = array_merge( + wp_array_slice_assoc( $callback, array( 'type', 'name' ) ), + array( + 'args' => array( + 'hook' => $callback['hook'], + ), + ) + ); + + AMP_Validation_Utils::$enqueued_script_sources[ $handle ][] = $source; + + if ( isset( $wp_scripts->registered[ $handle ] ) ) { + self::add_validation_error( array( + 'code' => self::ENQUEUED_SCRIPT_CODE, + 'handle' => $handle, + 'dependency' => $wp_scripts->registered[ $handle ], + 'sources' => array( + $source, + ), + ) ); + } + } + } + + // Wrap output that contains HTML tags (as opposed to actions that trigger in HTML attributes). + if ( ! empty( $output ) && preg_match( '/<.+?>/s', $output ) ) { + echo AMP_Validation_Utils::get_source_comment_start( $callback['type'], $callback['name'], array( 'hook' => $callback['hook'] ) ); // WPCS: XSS ok. + echo $output; // WPCS: XSS ok. + echo AMP_Validation_Utils::get_source_comment_end( $callback['type'], $callback['name'] ); // WPCS: XSS ok. + } + return $result; + }; + } + + /** + * Output a removed set, each wrapped in . + * + * @param array[][] $set { + * The removed elements to output. + * + * @type string $name The name of the source. + * @type string $count The number that were invalid. + * } + * @return void + */ + protected static function output_removed_set( $set ) { + $items = array(); + foreach ( $set as $name => $count ) { + if ( 1 === intval( $count ) ) { + $items[] = sprintf( '%s', esc_html( $name ) ); + } else { + $items[] = sprintf( '%s (%d)', esc_html( $name ), $count ); + } + } + echo implode( ', ', $items ); // WPCS: XSS OK. + } + + /** + * Whether to validate the front end response. + * + * Either the user has the capability and the query var is present. + * + * @return boolean Whether to validate. + */ + public static function should_validate_response() { + return self::has_cap() && isset( $_GET[ self::VALIDATE_QUERY_VAR ] ); // WPCS: CSRF ok. + } + + /** + * Finalize validation. + * + * @param DOMDocument $dom Document. + * @param array $args { + * Args. + * + * @type bool $remove_source_comments Whether source comments should be removed. Defaults to true. + * @type bool $send_validation_errors_header Whether the X-AMP-Validation-Errors header should be sent. Defaults to true. + * @type bool $append_validation_status_comment Whether the validation errors should be appended as an HTML comment. Defaults to true. + * } + */ + public static function finalize_validation( DOMDocument $dom, $args = array() ) { + $args = array_merge( + array( + 'send_validation_errors_header' => true, + 'remove_source_comments' => true, + 'append_validation_status_comment' => true, + ), + $args + ); + + if ( $args['send_validation_errors_header'] && ! headers_sent() ) { + self::send_validation_errors_header(); + } + + if ( $args['remove_source_comments'] ) { + self::remove_source_comments( $dom ); + } + + if ( $args['append_validation_status_comment'] ) { + $report = "\n# Validation Status\n"; + $report .= "\n## Summary\n"; + $report .= wp_json_encode( self::summarize_validation_errors( self::$validation_errors ), 128 /* JSON_PRETTY_PRINT */ ) . "\n"; + $report .= "\n## Details\n"; + $report .= wp_json_encode( self::$validation_errors, 128 /* JSON_PRETTY_PRINT */ ) . "\n"; + $comment = $dom->createComment( $report ); + $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + if ( $body ) { + $body->appendChild( $comment ); + } + } + } + + /** + * Adds the validation callback if front-end validation is needed. + * + * @param array $sanitizers The AMP sanitizers. + * @return array $sanitizers The filtered AMP sanitizers. + */ + public static function add_validation_callback( $sanitizers ) { + foreach ( $sanitizers as $sanitizer => $args ) { + $sanitizers[ $sanitizer ] = array_merge( + $args, + array( + 'validation_error_callback' => __CLASS__ . '::add_validation_error', + ) + ); + } + return $sanitizers; + } + + /** + * Registers the post type to store the validation errors. + * + * @return void. + */ + public static function register_post_type() { + $post_type = register_post_type( + self::POST_TYPE_SLUG, + array( + 'labels' => array( + 'name' => _x( 'Validation Status', 'post type general name', 'amp' ), + 'singular_name' => __( 'validation error', 'amp' ), + 'not_found' => __( 'No validation errors found', 'amp' ), + 'not_found_in_trash' => __( 'No validation errors found in trash', 'amp' ), + 'search_items' => __( 'Search statuses', 'amp' ), + 'edit_item' => __( 'Validation Status', 'amp' ), + ), + 'supports' => false, + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => AMP_Options_Manager::OPTION_NAME, + ) + ); + + // Hide the add new post link. + $post_type->cap->create_posts = 'do_not_allow'; + } + + /** + * Send validation errors back in response header. + */ + public static function send_validation_errors_header() { + header( self::VALIDATION_ERRORS_RESPONSE_HEADER_NAME . ': ' . wp_json_encode( self::$validation_errors ) ); + } + + /** + * Stores the validation errors. + * + * After the preprocessors run, this gets the validation response if the query var is present. + * It then stores the response in a custom post type. + * If there's already an error post for the URL, but there's no error anymore, it deletes it. + * + * @param array $validation_errors Validation errors. + * @param string $url URL on which the validation errors occurred. + * @return int|null $post_id The post ID of the custom post type used, or null. + * @global WP $wp + */ + public static function store_validation_errors( $validation_errors, $url ) { + $post_for_this_url = self::get_validation_status_post( $url ); + + // Since there are no validation errors and there is an existing $existing_post_id, just delete the post. + if ( empty( $validation_errors ) ) { + if ( $post_for_this_url ) { + wp_delete_post( $post_for_this_url->ID, true ); + } + return null; + } + + $encoded_errors = wp_json_encode( $validation_errors ); + $post_name = md5( $encoded_errors ); + + // If the post name is unchanged then the errors are the same and there is nothing to do. + if ( $post_for_this_url && $post_for_this_url->post_name === $post_name ) { + return $post_for_this_url->ID; + } + + // If there already exists a post for the given validation errors, just amend the $url to the existing post. + $post_for_other_url = get_page_by_path( $post_name, OBJECT, self::POST_TYPE_SLUG ); + if ( ! $post_for_other_url ) { + $post_for_other_url = get_page_by_path( $post_name . '__trashed', OBJECT, self::POST_TYPE_SLUG ); + } + if ( $post_for_other_url ) { + if ( 'trash' === $post_for_other_url->post_status ) { + wp_untrash_post( $post_for_other_url->ID ); + } + if ( ! in_array( $url, get_post_meta( $post_for_other_url->ID, self::AMP_URL_META, false ), true ) ) { + add_post_meta( $post_for_other_url->ID, self::AMP_URL_META, wp_slash( $url ), false ); + } + return $post_for_other_url->ID; + } + + // Otherwise, create a new validation status post, or update the existing one. + $post_id = wp_insert_post( wp_slash( array( + 'ID' => $post_for_this_url ? $post_for_this_url->ID : null, + 'post_type' => self::POST_TYPE_SLUG, + 'post_title' => $url, + 'post_name' => $post_name, + 'post_content' => $encoded_errors, + 'post_status' => 'publish', + ) ) ); + if ( ! $post_id ) { + return null; + } + if ( ! in_array( $url, get_post_meta( $post_id, self::AMP_URL_META, false ), true ) ) { + add_post_meta( $post_id, self::AMP_URL_META, wp_slash( $url ), false ); + } + return $post_id; + } + + /** + * Gets the existing custom post that stores errors for the $url, if it exists. + * + * @param string $url The URL of the post. + * @return WP_Post|null The post of the existing custom post, or null. + */ + public static function get_validation_status_post( $url ) { + $query = new WP_Query( array( + 'post_type' => self::POST_TYPE_SLUG, + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'meta_query' => array( + array( + 'key' => self::AMP_URL_META, + 'value' => $url, + ), + ), + ) ); + return array_shift( $query->posts ); + } + + /** + * Validates the latest published post. + * + * @return array|WP_Error The validation errors, or WP_Error. + */ + public static function validate_after_plugin_activation() { + $url = amp_admin_get_preview_permalink(); + if ( ! $url ) { + return new WP_Error( 'no_published_post_url_available' ); + } + $validation_errors = self::validate_url( $url ); + if ( is_array( $validation_errors ) && count( $validation_errors ) > 0 ) { + self::store_validation_errors( $validation_errors, $url ); + set_transient( self::PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY, $validation_errors, 60 ); + } else { + delete_transient( self::PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY ); + } + return $validation_errors; + } + + /** + * Validates a given URL. + * + * The validation errors will be stored in the validation status custom post type, + * as well as in a transient. + * + * @param string $url The URL to validate. + * @return array|WP_Error The validation errors, or WP_Error on error. + */ + public static function validate_url( $url ) { + $validation_url = add_query_arg( + array( + self::VALIDATE_QUERY_VAR => 1, + self::CACHE_BUST_QUERY_VAR => wp_rand(), + ), + $url + ); + + $r = wp_remote_get( $validation_url, array( + 'cookies' => wp_unslash( $_COOKIE ), + 'sslverify' => false, + 'headers' => array( + 'Cache-Control' => 'no-cache', + ), + ) ); + if ( is_wp_error( $r ) ) { + return $r; + } + if ( wp_remote_retrieve_response_code( $r ) >= 400 ) { + return new WP_Error( + wp_remote_retrieve_response_code( $r ), + wp_remote_retrieve_response_message( $r ) + ); + } + $json = wp_remote_retrieve_header( $r, self::VALIDATION_ERRORS_RESPONSE_HEADER_NAME ); + if ( ! $json ) { + return new WP_Error( 'response_header_absent' ); + } + $validation_errors = json_decode( $json, true ); + if ( ! is_array( $validation_errors ) ) { + return new WP_Error( 'malformed_json_validation_errors' ); + } + + return $validation_errors; + } + + /** + * On activating a plugin, display a notice if a plugin causes an AMP validation error. + * + * @return void + */ + public static function plugin_notice() { + global $pagenow; + if ( ( 'plugins.php' === $pagenow ) && ( ! empty( $_GET['activate'] ) || ! empty( $_GET['activate-multi'] ) ) ) { // WPCS: CSRF ok. + $validation_errors = get_transient( self::PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY ); + if ( empty( $validation_errors ) || ! is_array( $validation_errors ) ) { + return; + } + delete_transient( self::PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY ); + $errors = self::summarize_validation_errors( $validation_errors ); + $invalid_plugins = isset( $errors[ self::SOURCES_INVALID_OUTPUT ]['plugin'] ) ? array_unique( $errors[ self::SOURCES_INVALID_OUTPUT ]['plugin'] ) : null; + if ( isset( $invalid_plugins ) ) { + $reported_plugins = array(); + foreach ( $invalid_plugins as $plugin ) { + $reported_plugins[] = sprintf( '%s', esc_html( $plugin ) ); + } + + $more_details_link = sprintf( + '%s', + esc_url( add_query_arg( + 'post_type', + self::POST_TYPE_SLUG, + admin_url( 'edit.php' ) + ) ), + __( 'More details', 'amp' ) + ); + printf( + '

%s %s %s

', + esc_html( _n( 'Warning: The following plugin may be incompatible with AMP:', 'Warning: The following plugins may be incompatible with AMP: ', count( $invalid_plugins ), 'amp' ) ), + implode( ', ', $reported_plugins ), + $more_details_link, + esc_html__( 'Dismiss this notice.', 'amp' ) + ); // WPCS: XSS ok. + } + } + } + + /** + * Adds post columns to the UI for the validation errors. + * + * @param array $columns The post columns. + * @return array $columns The new post columns. + */ + public static function add_post_columns( $columns ) { + $columns = array_merge( + $columns, + array( + 'url_count' => esc_html__( 'Count', 'amp' ), + self::REMOVED_ELEMENTS => esc_html__( 'Removed Elements', 'amp' ), + self::REMOVED_ATTRIBUTES => esc_html__( 'Removed Attributes', 'amp' ), + self::SOURCES_INVALID_OUTPUT => esc_html__( 'Incompatible Sources', 'amp' ), + ) + ); + + // Move date to end. + if ( isset( $columns['date'] ) ) { + $date = $columns['date']; + unset( $columns['date'] ); + $columns['date'] = $date; + } + + return $columns; + } + + /** + * Outputs custom columns in the /wp-admin UI for the AMP validation errors. + * + * @param string $column_name The name of the column. + * @param int $post_id The ID of the post for the column. + * @return void + */ + public static function output_custom_column( $column_name, $post_id ) { + $post = get_post( $post_id ); + if ( self::POST_TYPE_SLUG !== $post->post_type ) { + return; + } + $validation_errors = json_decode( $post->post_content, true ); + if ( ! is_array( $validation_errors ) ) { + return; + } + $errors = self::summarize_validation_errors( $validation_errors ); + $urls = get_post_meta( $post_id, self::AMP_URL_META, false ); + + switch ( $column_name ) { + case 'url_count': + echo count( $urls ); + break; + case self::REMOVED_ELEMENTS: + if ( ! empty( $errors[ self::REMOVED_ELEMENTS ] ) ) { + self::output_removed_set( $errors[ self::REMOVED_ELEMENTS ] ); + } else { + esc_html_e( '--', 'amp' ); + } + break; + case self::REMOVED_ATTRIBUTES: + if ( ! empty( $errors[ self::REMOVED_ATTRIBUTES ] ) ) { + self::output_removed_set( $errors[ self::REMOVED_ATTRIBUTES ] ); + } else { + esc_html_e( '--', 'amp' ); + } + break; + case self::SOURCES_INVALID_OUTPUT: + if ( isset( $errors[ self::SOURCES_INVALID_OUTPUT ] ) ) { + $sources = array(); + foreach ( $errors[ self::SOURCES_INVALID_OUTPUT ] as $type => $names ) { + foreach ( array_unique( $names ) as $name ) { + $sources[] = sprintf( '%s: %s', esc_html( $type ), esc_html( $name ) ); + } + } + echo implode( ', ', $sources ); // WPCS: XSS ok. + } + break; + } + } + + /** + * Adds a 'Recheck' link to the edit.php row actions. + * + * The logic to add the new action is mainly copied from WP_Posts_List_Table::handle_row_actions(). + * + * @param array $actions The actions in the edit.php page. + * @param WP_Post $post The post for the actions. + * @return array $actions The filtered actions. + */ + public static function filter_row_actions( $actions, $post ) { + if ( self::POST_TYPE_SLUG !== $post->post_type ) { + return $actions; + } + + $actions['edit'] = sprintf( + '%s', + esc_url( get_edit_post_link( $post ) ), + esc_html__( 'Details', 'amp' ) + ); + unset( $actions['inline hide-if-no-js'] ); + $url = get_post_meta( $post->ID, self::AMP_URL_META, true ); + + if ( ! empty( $url ) ) { + $actions[ self::RECHECK_ACTION ] = self::get_recheck_link( $post, get_edit_post_link( $post->ID, 'raw' ), $url ); + $actions[ self::DEBUG_QUERY_VAR ] = sprintf( + '%s', + esc_url( self::get_debug_url( $url ) ), + esc_attr__( 'Validate URL on frontend but without invalid elements/attributes removed', 'amp' ), + esc_html__( 'Debug', 'amp' ) + ); + } + + return $actions; + } + + /** + * Adds a 'Recheck' bulk action to the edit.php page. + * + * @param array $actions The bulk actions in the edit.php page. + * @return array $actions The filtered bulk actions. + */ + public static function add_bulk_action( $actions ) { + unset( $actions['edit'] ); + $actions[ self::RECHECK_ACTION ] = esc_html__( 'Recheck', 'amp' ); + return $actions; + } + + /** + * Handles the 'Recheck' bulk action on the edit.php page. + * + * @param string $redirect The URL of the redirect. + * @param string $action The action. + * @param array $items The items on which to take the action. + * @return string $redirect The filtered URL of the redirect. + */ + public static function handle_bulk_action( $redirect, $action, $items ) { + if ( self::RECHECK_ACTION !== $action ) { + return $redirect; + } + $remaining_invalid_urls = array(); + foreach ( $items as $item ) { + $url = get_post_meta( $item, self::AMP_URL_META, true ); + if ( empty( $url ) ) { + continue; + } + + $validation_errors = self::validate_url( $url ); + if ( ! is_array( $validation_errors ) ) { + continue; + } + + self::store_validation_errors( $validation_errors, $url ); + if ( ! empty( $validation_errors ) ) { + $remaining_invalid_urls[] = $url; + } + } + + // Get the URLs that still have errors after rechecking. + $args = array( + self::URLS_TESTED => count( $items ), + self::REMAINING_ERRORS => empty( $remaining_invalid_urls ) ? '0' : '1', + ); + + return add_query_arg( $args, $redirect ); + } + + /** + * Outputs an admin notice after rechecking URL(s) on the custom post page. + * + * @return void + */ + public static function remaining_error_notice() { + if ( ! isset( $_GET[ self::REMAINING_ERRORS ] ) || self::POST_TYPE_SLUG !== get_current_screen()->post_type ) { // WPCS: CSRF ok. + return; + } + + $count_urls_tested = isset( $_GET[ self::URLS_TESTED ] ) ? intval( $_GET[ self::URLS_TESTED ] ) : 1; // WPCS: CSRF ok. + $errors_remain = ! empty( $_GET[ self::REMAINING_ERRORS ] ); // WPCS: CSRF ok. + if ( $errors_remain ) { + $class = 'notice-warning'; + $message = _n( 'The rechecked URL still has validation errors.', 'The rechecked URLs still have validation errors.', $count_urls_tested, 'amp' ); + } else { + $message = _n( 'The rechecked URL has no validation errors.', 'The rechecked URLs have no validation errors.', $count_urls_tested, 'amp' ); + $class = 'updated'; + } + + printf( + '

%s

', + esc_attr( $class ), + esc_html( $message ), + esc_html__( 'Dismiss this notice.', 'amp' ) + ); + } + + /** + * Handles clicking 'recheck' on the inline post actions. + * + * @param int $post_id The post ID of the recheck. + * @return void + */ + public static function handle_inline_recheck( $post_id ) { + check_admin_referer( self::NONCE_ACTION . $post_id ); + $url = get_post_meta( $post_id, self::AMP_URL_META, true ); + if ( isset( $_GET['recheck_url'] ) ) { + $url = wp_validate_redirect( wp_unslash( $_GET['recheck_url'] ) ); + } + $validation_errors = self::validate_url( $url ); + $remaining_errors = true; + if ( is_array( $validation_errors ) ) { + self::store_validation_errors( $validation_errors, $url ); + $remaining_errors = ! empty( $validation_errors ); + } + + $redirect = wp_get_referer(); + if ( ! $redirect || empty( $validation_errors ) ) { + // If there are no remaining errors and the post was deleted, redirect to edit.php instead of post.php. + $redirect = add_query_arg( + 'post_type', + self::POST_TYPE_SLUG, + admin_url( 'edit.php' ) + ); + } + $args = array( + self::URLS_TESTED => '1', + self::REMAINING_ERRORS => $remaining_errors ? '1' : '0', + ); + wp_safe_redirect( add_query_arg( $args, $redirect ) ); + exit(); + } + + /** + * Removes the 'Publish' meta box from the CPT post.php page. + * + * @return void + */ + public static function remove_publish_meta_box() { + remove_meta_box( 'submitdiv', self::POST_TYPE_SLUG, 'side' ); + } + + /** + * Adds the meta boxes to the CPT post.php page. + * + * @return void + */ + public static function add_meta_boxes() { + add_meta_box( self::VALIDATION_ERRORS_META_BOX, __( 'Validation Errors', 'amp' ), array( __CLASS__, 'print_validation_errors_meta_box' ), self::POST_TYPE_SLUG, 'normal' ); + add_meta_box( self::STATUS_META_BOX, __( 'Status', 'amp' ), array( __CLASS__, 'print_status_meta_box' ), self::POST_TYPE_SLUG, 'side' ); + } + + /** + * Outputs the markup of the side meta box in the CPT post.php page. + * + * This is partially copied from meta-boxes.php. + * Adds 'Published on,' and links to move to trash and recheck. + * + * @param WP_Post $post The post for which to output the box. + * @return void + */ + public static function print_status_meta_box( $post ) { + $redirect_url = add_query_arg( + 'post', + $post->ID, + admin_url( 'post.php' ) + ); + + echo '
'; + /* translators: Meta box date format */ + $date_format = __( 'M j, Y @ H:i', 'default' ); + echo '
'; + /* translators: %s: The date this was published */ + printf( __( 'Published on: %s', 'amp' ), esc_html( date_i18n( $date_format, strtotime( $post->post_date ) ) ) ); // WPCS: XSS ok. + echo '
'; + printf( '', esc_url( get_delete_post_link( $post->ID ) ), esc_html__( 'Move to Trash', 'default' ) ); + + echo '
'; + echo self::get_recheck_link( $post, $redirect_url ); // WPCS: XSS ok. + $url = get_post_meta( $post->ID, self::AMP_URL_META, true ); + if ( $url ) { + printf( + ' | %s', + esc_url( self::get_debug_url( $url ) ), + esc_attr__( 'Validate URL on frontend but without invalid elements/attributes removed', 'amp' ), + esc_html__( 'Debug', 'amp' ) + ); // WPCS: XSS ok. + } + echo '
'; + + echo '
'; + } + + /** + * Outputs the full meta box on the CPT post.php page. + * + * This displays the errors stored in the post content. + * These are output as stored, but using
elements. + * + * @param WP_Post $post The post for which to output the box. + * @return void + */ + public static function print_validation_errors_meta_box( $post ) { + $errors = json_decode( $post->post_content, true ); + $urls = get_post_meta( $post->ID, self::AMP_URL_META, false ); + ?> + +
+
    + + +
  • +
    + +
      + +
    • +
      + + + ', $error['parent_name'] ) ); + } + ?> + + $value ) { + printf( ' %s="%s"', esc_html( $key ), esc_html( $value ) ); + } + } + echo esc_html( '>…' ); + ?> + + +
      + +
    • + +
    • +
      + + + $value ) { + if ( $key === $error['node_name'] ) { + echo ''; + } + printf( ' %s="%s"', esc_html( $key ), esc_html( $value ) ); + if ( $key === $error['node_name'] ) { + echo ''; + } + } + echo esc_html( '>' ); + ?> + +
      + +
    • + + + $value ) : ?> +
    • +
      > + +
      + + + +
      + +
      +
      +
    • + +
    +
    +
  • + +
+
+

+
    + +
  • + + + ID, 'raw' ), $url ); // WPCS: XSS ok. ?> + | + %s', + esc_url( self::get_debug_url( $url ) ), + esc_attr__( 'Validate URL on frontend but without invalid elements/attributes removed', 'amp' ), + esc_html__( 'Debug', 'amp' ) + ) + ?> + +
  • + +
+
+ 1, + self::DEBUG_QUERY_VAR => 1, + ), + $url + ) . '#development=1'; + } + + /** + * Gets the link to recheck the post for AMP validity. + * + * Appends a query var to $redirect_url. + * On clicking the link, it checks if errors still exist for $post. + * + * @param WP_Post $post The post storing the validation error. + * @param string $redirect_url The URL of the redirect. + * @param string $recheck_url The URL to check. Optional. + * @return string $link The link to recheck the post. + */ + public static function get_recheck_link( $post, $redirect_url, $recheck_url = null ) { + return sprintf( + '%s', + wp_nonce_url( + add_query_arg( + array( + 'action' => self::RECHECK_ACTION, + 'recheck_url' => $recheck_url, + ), + $redirect_url + ), + self::NONCE_ACTION . $post->ID + ), + esc_html__( 'Recheck the URL for AMP validity', 'amp' ), + esc_html__( 'Recheck', 'amp' ) + ); } } diff --git a/phpcs.xml b/phpcs.xml index 312fc87d2aa..16c6f31e59f 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -28,7 +28,7 @@ - + diff --git a/tests/test-amp-helper-functions.php b/tests/test-amp-helper-functions.php index d6846c33708..f39b3350233 100644 --- a/tests/test-amp-helper-functions.php +++ b/tests/test-amp-helper-functions.php @@ -36,6 +36,7 @@ public function return_example_url( $url, $post_id ) { * @covers \amp_get_permalink() */ public function test_amp_get_permalink_without_pretty_permalinks() { + remove_theme_support( 'amp' ); delete_option( 'permalink_structure' ); flush_rewrite_rules(); @@ -111,6 +112,40 @@ public function test_amp_get_permalink_with_pretty_permalinks() { $this->assertContains( 'current_filter=amp_get_permalink', $url ); } + /** + * Test amp_get_permalink() with theme support paired mode. + * + * @covers \amp_get_permalink() + */ + public function test_amp_get_permalink_with_theme_support() { + global $wp_rewrite; + add_theme_support( 'amp' ); + + update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' ); + $wp_rewrite->use_trailing_slashes = true; + $wp_rewrite->init(); + $wp_rewrite->flush_rules(); + + $post_id = $this->factory()->post->create(); + $this->assertEquals( get_permalink( $post_id ), amp_get_permalink( $post_id ) ); + + add_theme_support( 'amp', array( + 'template_dir' => 'amp', + ) ); + } + + /** + * Test amp_remove_endpoint. + * + * @covers \amp_remove_endpoint() + */ + public function test_amp_remove_endpoint() { + $this->assertEquals( 'https://example.com/foo/', amp_remove_endpoint( 'https://example.com/foo/?amp' ) ); + $this->assertEquals( 'https://example.com/foo/?#bar', amp_remove_endpoint( 'https://example.com/foo/?amp#bar' ) ); + $this->assertEquals( 'https://example.com/foo/', amp_remove_endpoint( 'https://example.com/foo/amp/' ) ); + $this->assertEquals( 'https://example.com/foo/?blaz', amp_remove_endpoint( 'https://example.com/foo/amp/?blaz' ) ); + } + /** * Filter calls. * diff --git a/tests/test-amp-style-sanitizer.php b/tests/test-amp-style-sanitizer.php index c0190ec8811..158e7d5cfd5 100644 --- a/tests/test-amp-style-sanitizer.php +++ b/tests/test-amp-style-sanitizer.php @@ -173,9 +173,9 @@ public function get_link_and_style_test_data() { ), ), 'style_with_no_head' => array( - 'Not good!', + 'Not good!', array( - 'body{color:red}', + 'body{color:red;}', ), ), ); diff --git a/tests/test-class-amp-base-sanitizer.php b/tests/test-class-amp-base-sanitizer.php index b51197854c7..f3cf1db37c7 100644 --- a/tests/test-class-amp-base-sanitizer.php +++ b/tests/test-class-amp-base-sanitizer.php @@ -1,9 +1,25 @@ array( + 'already_has_sizes' => array( array( 'sizes' => 'blah', ), @@ -12,12 +28,12 @@ public function get_data() { ), ), - 'empty' => array( + 'empty' => array( array(), array(), ), - 'no_width' => array( + 'no_width' => array( array( 'height' => 100, ), @@ -26,7 +42,7 @@ public function get_data() { ), ), - 'no_height' => array( + 'no_height' => array( array( 'width' => 200, ), @@ -35,43 +51,43 @@ public function get_data() { ), ), - 'enforce_sizes_no_class' => array( + 'enforce_sizes_no_class' => array( array( - 'width' => 200, + 'width' => 200, 'height' => 100, ), array( - 'width' => 200, + 'width' => 200, 'height' => 100, - 'sizes' => '(min-width: 200px) 200px, 100vw', - 'class' => 'amp-wp-enforced-sizes', + 'sizes' => '(min-width: 200px) 200px, 100vw', + 'class' => 'amp-wp-enforced-sizes', ), ), - 'enforce_sizes_has_class' => array( + 'enforce_sizes_has_class' => array( array( - 'width' => 200, + 'width' => 200, 'height' => 100, - 'class' => 'my-class', + 'class' => 'my-class', ), array( - 'width' => 200, + 'width' => 200, 'height' => 100, - 'sizes' => '(min-width: 200px) 200px, 100vw', - 'class' => 'my-class amp-wp-enforced-sizes', + 'sizes' => '(min-width: 200px) 200px, 100vw', + 'class' => 'my-class amp-wp-enforced-sizes', ), ), - 'enforce_sizes_with_bigger_content_max_width' => array( + 'enforce_sizes_with_bigger_content_max_width' => array( array( - 'width' => 250, + 'width' => 250, 'height' => 100, ), array( - 'width' => 250, + 'width' => 250, 'height' => 100, - 'sizes' => '(min-width: 250px) 250px, 100vw', - 'class' => 'amp-wp-enforced-sizes', + 'sizes' => '(min-width: 250px) 250px, 100vw', + 'class' => 'amp-wp-enforced-sizes', ), array( 'content_max_width' => 500, @@ -80,14 +96,14 @@ public function get_data() { 'enforce_sizes_with_smaller_content_max_width' => array( array( - 'width' => 800, + 'width' => 800, 'height' => 350, ), array( - 'width' => 800, + 'width' => 800, 'height' => 350, - 'sizes' => '(min-width: 675px) 675px, 100vw', - 'class' => 'amp-wp-enforced-sizes', + 'sizes' => '(min-width: 675px) 675px, 100vw', + 'class' => 'amp-wp-enforced-sizes', ), array( 'content_max_width' => 675, @@ -97,31 +113,40 @@ public function get_data() { } /** - * @dataProvider get_data + * Test AMP_Base_Sanitizer::enforce_sizes_attribute(). + * + * @dataProvider get_enforce_sizes_data + * @param array $source_attributes Source Attrs. + * @param array $expected_attributes Expected Attrs. + * @param array $args Args. + * @covers AMP_Base_Sanitizer::enforce_sizes_attribute() */ public function test_enforce_sizes_attribute( $source_attributes, $expected_attributes, $args = array() ) { - $sanitizer = new AMP_Test_Stub_Sanitizer( new DOMDocument, $args ); + $sanitizer = new AMP_Test_Stub_Sanitizer( new DOMDocument(), $args ); $returned_attributes = $sanitizer->enforce_sizes_attribute( $source_attributes ); $this->assertEquals( $expected_attributes, $returned_attributes ); } -} -class AMP_Base_Sanitizer__Enforce_Fixed_Height__Test extends WP_UnitTestCase { - public function get_data() { + /** + * Get enforce fixed data. + * + * @return array Data. + */ + public function get_enforce_fixed_data() { return array( 'both_dimensions_included' => array( array( - 'width' => 100, + 'width' => 100, 'height' => 100, ), array( - 'width' => 100, + 'width' => 100, 'height' => 100, ), ), - 'both_dimensions_missing' => array( + 'both_dimensions_missing' => array( array(), array( 'height' => 400, @@ -129,9 +154,9 @@ public function get_data() { ), ), - 'both_dimensions_empty' => array( + 'both_dimensions_empty' => array( array( - 'width' => '', + 'width' => '', 'height' => '', ), array( @@ -140,7 +165,7 @@ public function get_data() { ), ), - 'no_width' => array( + 'no_width' => array( array( 'height' => 100, ), @@ -150,7 +175,7 @@ public function get_data() { ), ), - 'no_height' => array( + 'no_height' => array( array( 'width' => 200, ), @@ -163,40 +188,49 @@ public function get_data() { } /** - * @dataProvider get_data + * Test AMP_Base_Sanitizer::enforce_fixed_height(). + * + * @dataProvider get_enforce_fixed_data + * @param array $source_attributes Source Attrs. + * @param array $expected_attributes Expected Attrs. + * @param array $args Args. + * @covers AMP_Base_Sanitizer::enforce_fixed_height() */ public function test_enforce_fixed_height( $source_attributes, $expected_attributes, $args = array() ) { - $sanitizer = new AMP_Test_Stub_Sanitizer( new DOMDocument, $args ); + $sanitizer = new AMP_Test_Stub_Sanitizer( new DOMDocument(), $args ); $returned_attributes = $sanitizer->enforce_fixed_height( $source_attributes ); $this->assertEquals( $expected_attributes, $returned_attributes ); } -} -class AMP_Base_Sanitizer__Sanitize_Dimension__Test extends WP_UnitTestCase { - public function get_data() { + /** + * Get sanitize_dimension data. + * + * @return array Data. + */ + public function get_sanitize_dimension_data() { return array( - 'empty' => array( + 'empty' => array( array( '', 'width' ), '', ), - 'empty_space' => array( + 'empty_space' => array( array( ' ', 'width' ), '', ), - 'int' => array( + 'int' => array( array( 123, 'width' ), 123, ), - 'int_as_string' => array( + 'int_as_string' => array( array( '123', 'width' ), 123, ), - 'with_px' => array( + 'with_px' => array( array( '567px', 'width' ), 567, ), @@ -207,23 +241,23 @@ public function get_data() { array( 'content_max_width' => 600 ), ), - '100%_width__no_max' => array( + '100%_width__no_max' => array( array( '100%', 'width' ), '', ), - '50%_width__with_max' => array( + '50%_width__with_max' => array( array( '50%', 'width' ), 300, array( 'content_max_width' => 600 ), ), - '%_height' => array( + '%_height' => array( array( '100%', 'height' ), '', ), - 'non_int' => array( + 'non_int' => array( array( 'abcd', 'width' ), '', ), @@ -231,10 +265,16 @@ public function get_data() { } /** - * @dataProvider get_data + * Test AMP_Base_Sanitizer::sanitize_dimension(). + * + * @param array $source_params Source Attrs. + * @param array $expected_value Expected Attrs. + * @param array $args Args. + * @dataProvider get_sanitize_dimension_data + * @covers AMP_Base_Sanitizer::sanitize_dimension() */ - public function test_enforce_sizes_attribute( $source_params, $expected_value, $args = array() ) { - $sanitizer = new AMP_Test_Stub_Sanitizer( new DOMDocument, $args ); + public function test_sanitize_dimension( $source_params, $expected_value, $args = array() ) { + $sanitizer = new AMP_Test_Stub_Sanitizer( new DOMDocument(), $args ); list( $value, $dimension ) = $source_params; $actual_value = $sanitizer->sanitize_dimension( $value, $dimension ); @@ -248,6 +288,7 @@ public function test_enforce_sizes_attribute( $source_params, $expected_value, $ * @covers AMP_Base_Sanitizer::remove_invalid_child() */ public function test_remove_child() { + AMP_Validation_Utils::reset_validation_results(); $parent_tag_name = 'div'; $dom_document = new DOMDocument( '1.0', 'utf-8' ); $parent = $dom_document->createElement( $parent_tag_name ); @@ -255,13 +296,15 @@ public function test_remove_child() { $parent->appendChild( $child ); $this->assertEquals( $child, $parent->firstChild ); - $sanitizer = new AMP_Iframe_Sanitizer( $dom_document, array( - 'remove_invalid_callback' => 'AMP_Validation_Utils::track_removed', - ) ); + $sanitizer = new AMP_Iframe_Sanitizer( + $dom_document, array( + 'validation_error_callback' => 'AMP_Validation_Utils::add_validation_error', + ) + ); $sanitizer->remove_invalid_child( $child ); $this->assertEquals( null, $parent->firstChild ); - $this->assertCount( 1, AMP_Validation_Utils::$removed_nodes ); - $this->assertEquals( $child, AMP_Validation_Utils::$removed_nodes[0]['node'] ); + $this->assertCount( 1, AMP_Validation_Utils::$validation_errors ); + $this->assertEquals( $child->nodeName, AMP_Validation_Utils::$validation_errors[0]['node_name'] ); $parent->appendChild( $child ); $this->assertEquals( $child, $parent->firstChild ); @@ -269,7 +312,7 @@ public function test_remove_child() { $this->assertEquals( null, $parent->firstChild ); $this->assertEquals( null, $child->parentNode ); - AMP_Validation_Utils::$removed_nodes = null; + AMP_Validation_Utils::$validation_errors = null; } /** @@ -278,28 +321,31 @@ public function test_remove_child() { * @covers AMP_Base_Sanitizer::remove_invalid_child() */ public function test_remove_attribute() { - AMP_Validation_Utils::reset_removed(); + AMP_Validation_Utils::reset_validation_results(); $video_name = 'amp-video'; $attribute = 'onload'; $dom_document = new DOMDocument( '1.0', 'utf-8' ); $video = $dom_document->createElement( $video_name ); $video->setAttribute( $attribute, 'someFunction()' ); $attr_node = $video->getAttributeNode( $attribute ); - $args = array( - 'remove_invalid_callback' => 'AMP_Validation_Utils::track_removed', + 'validation_error_callback' => 'AMP_Validation_Utils::add_validation_error', ); $sanitizer = new AMP_Video_Sanitizer( $dom_document, $args ); $sanitizer->remove_invalid_attribute( $video, $attribute ); $this->assertEquals( null, $video->getAttribute( $attribute ) ); $this->assertEquals( array( - 'node' => $attr_node, - 'parent' => $video, + 'code' => AMP_Validation_Utils::INVALID_ATTRIBUTE_CODE, + 'node_name' => $attr_node->nodeName, + 'parent_name' => $video->nodeName, + 'sources' => array(), + 'element_attributes' => array( + 'onload' => 'someFunction()', + ), ), - AMP_Validation_Utils::$removed_nodes[0] + AMP_Validation_Utils::$validation_errors[0] ); - AMP_Validation_Utils::reset_removed(); + AMP_Validation_Utils::reset_validation_results(); } - } diff --git a/tests/test-class-amp-options-menu.php b/tests/test-class-amp-options-menu.php index ec5109d2612..035706e3b85 100644 --- a/tests/test-class-amp-options-menu.php +++ b/tests/test-class-amp-options-menu.php @@ -43,7 +43,7 @@ public function test_constants() { */ public function test_init() { $this->instance->init(); - $this->assertEquals( 10, has_action( 'admin_menu', array( $this->instance, 'add_menu_items' ) ) ); + $this->assertEquals( 9, has_action( 'admin_menu', array( $this->instance, 'add_menu_items' ) ) ); $this->assertEquals( 10, has_action( 'admin_post_amp_analytics_options', 'AMP_Options_Manager::handle_analytics_submit' ) ); } diff --git a/tests/test-class-amp-theme-support.php b/tests/test-class-amp-theme-support.php index 3555afca149..9f4e38078c6 100644 --- a/tests/test-class-amp-theme-support.php +++ b/tests/test-class-amp-theme-support.php @@ -94,8 +94,10 @@ public function test_register_widgets() { * @covers AMP_Theme_Support::prepare_response() */ public function test_prepare_response() { + global $wp_widget_factory; add_theme_support( 'amp' ); AMP_Theme_Support::init(); + AMP_Theme_Support::finish_init(); $wp_widget_factory = new WP_Widget_Factory(); wp_widgets_init(); @@ -127,7 +129,7 @@ public function test_prepare_response() { $original_html = trim( ob_get_clean() ); $removed_nodes = array(); $sanitized_html = AMP_Theme_Support::prepare_response( $original_html, array( - 'remove_invalid_callback' => function( $removed ) use ( &$removed_nodes ) { + 'validation_error_callback' => function( $removed ) use ( &$removed_nodes ) { $removed_nodes[ $removed['node']->nodeName ] = $removed['node']; }, ) ); @@ -149,7 +151,7 @@ public function test_prepare_response() { $this->assertContains( ''; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript /** - * A tag that the sanitizer should strip. + * The name of a tag that the sanitizer should strip. * * @var string */ - public $disallowed_tag = ''; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + public $disallowed_tag_name = 'script'; /** - * A valid image that sanitizers should not alter. + * The name of an attribute that the sanitizer should strip. * * @var string */ - public $valid_amp_img = ''; + public $disallowed_attribute_name = 'onload'; /** - * The key in the response for whether it has an AMP error. + * A mock plugin name that outputs invalid markup. * * @var string */ - public $error_key = 'has_error'; + public $plugin_name = 'foo-bar'; + + /** + * A valid image that sanitizers should not alter. + * + * @var string + */ + public $valid_amp_img = ''; /** * The name of the tag to test. @@ -63,110 +77,125 @@ public function setUp() { parent::setUp(); $dom_document = new DOMDocument( '1.0', 'utf-8' ); $this->node = $dom_document->createElement( self::TAG_NAME ); - AMP_Validation_Utils::reset_removed(); + AMP_Validation_Utils::reset_validation_results(); } /** * Test init. * - * @see AMP_Validation_Utils::init() + * @covers AMP_Validation_Utils::init() */ public function test_init() { - $this->assertEquals( 10, has_action( 'rest_api_init', 'AMP_Validation_Utils::amp_rest_validation' ) ); - $this->assertEquals( 10, has_action( 'edit_form_top', 'AMP_Validation_Utils::validate_content' ) ); + add_theme_support( 'amp' ); + AMP_Validation_Utils::init(); + $this->assertEquals( 10, has_action( 'edit_form_top', self::TESTED_CLASS . '::print_edit_form_validation_status' ) ); + $this->assertEquals( 10, has_action( 'init', self::TESTED_CLASS . '::register_post_type' ) ); + $this->assertEquals( 10, has_action( 'all_admin_notices', self::TESTED_CLASS . '::plugin_notice' ) ); + $this->assertEquals( 10, has_filter( 'manage_' . AMP_Validation_Utils::POST_TYPE_SLUG . '_posts_columns', self::TESTED_CLASS . '::add_post_columns' ) ); + $this->assertEquals( 10, has_action( 'manage_posts_custom_column', self::TESTED_CLASS . '::output_custom_column' ) ); + $this->assertEquals( 10, has_filter( 'post_row_actions', self::TESTED_CLASS . '::filter_row_actions' ) ); + $this->assertEquals( 10, has_filter( 'bulk_actions-edit-' . AMP_Validation_Utils::POST_TYPE_SLUG, self::TESTED_CLASS . '::add_bulk_action' ) ); + $this->assertEquals( 10, has_filter( 'handle_bulk_actions-edit-' . AMP_Validation_Utils::POST_TYPE_SLUG, self::TESTED_CLASS . '::handle_bulk_action' ) ); + $this->assertEquals( 10, has_action( 'admin_notices', self::TESTED_CLASS . '::remaining_error_notice' ) ); + $this->assertEquals( 10, has_action( 'admin_menu', self::TESTED_CLASS . '::remove_publish_meta_box' ) ); + $this->assertEquals( 10, has_action( 'add_meta_boxes', self::TESTED_CLASS . '::add_meta_boxes' ) ); } /** - * Test track_removed. + * Test init. * - * @see AMP_Validation_Utils::track_removed() + * @covers AMP_Validation_Utils::add_validation_hooks() + */ + public function test_add_validation_hooks() { + AMP_Validation_Utils::add_validation_hooks(); + $this->assertEquals( 10, has_action( 'amp_content_sanitizers', array( self::TESTED_CLASS, 'add_validation_callback' ) ) ); + $this->assertEquals( -1, has_action( 'do_shortcode_tag', array( self::TESTED_CLASS, 'decorate_shortcode_source' ) ) ); + } + + /** + * Test add_validation_error. + * + * @covers AMP_Validation_Utils::add_validation_error() */ public function test_track_removed() { - $this->assertEmpty( AMP_Validation_Utils::$removed_nodes ); - AMP_Validation_Utils::track_removed( $this->node ); - AMP_Validation_Utils::track_removed( $this->node ); - $this->assertEquals( array( $this->node, $this->node ), AMP_Validation_Utils::$removed_nodes ); - AMP_Validation_Utils::reset_removed(); + $this->assertEmpty( AMP_Validation_Utils::$validation_errors ); + AMP_Validation_Utils::add_validation_error( array( + 'node' => $this->node, + ) ); + + $this->assertEquals( + array( + array( + 'node_name' => 'img', + 'sources' => array(), + 'code' => AMP_Validation_Utils::INVALID_ELEMENT_CODE, + 'node_attributes' => array(), + ), + ), + AMP_Validation_Utils::$validation_errors + ); + AMP_Validation_Utils::reset_validation_results(); } /** * Test was_node_removed. * - * @see AMP_Validation_Utils::was_node_removed() + * @covers AMP_Validation_Utils::add_validation_error() */ public function test_was_node_removed() { - $this->assertFalse( AMP_Validation_Utils::was_node_removed() ); - AMP_Validation_Utils::track_removed( $this->node ); - $this->assertTrue( AMP_Validation_Utils::was_node_removed() ); + $this->assertEmpty( AMP_Validation_Utils::$validation_errors ); + AMP_Validation_Utils::add_validation_error( + array( + 'node' => $this->node, + ) + ); + $this->assertNotEmpty( AMP_Validation_Utils::$validation_errors ); } /** * Test process_markup. * - * @see AMP_Validation_Utils::process_markup() + * @covers AMP_Validation_Utils::process_markup() */ public function test_process_markup() { - $this->set_authorized(); + $this->set_capability(); AMP_Validation_Utils::process_markup( $this->valid_amp_img ); - $this->assertEquals( array(), AMP_Validation_Utils::$removed_nodes ); + $this->assertEquals( array(), AMP_Validation_Utils::$validation_errors ); - AMP_Validation_Utils::reset_removed(); + AMP_Validation_Utils::reset_validation_results(); $video = '