diff --git a/assets/js/amp-block-validation.js b/assets/js/amp-block-validation.js index 26cd6bd916e..9146b9164e0 100644 --- a/assets/js/amp-block-validation.js +++ b/assets/js/amp-block-validation.js @@ -19,7 +19,8 @@ var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars */ data: { i18n: {}, - ampValidityRestField: '' + ampValidityRestField: '', + isCanonical: false }, /** @@ -91,10 +92,12 @@ var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars /** * Handle state change regarding validation errors. * + * This is essentially a JS implementation of \AMP_Validation_Manager::print_edit_form_validation_status() in PHP. + * * @return {void} */ handleValidationErrorsStateChange: function handleValidationErrorsStateChange() { - var currentPost, validationErrors, blockValidationErrors, noticeElement, noticeMessage, blockErrorCount, ampValidity; + var currentPost, validationErrors, blockValidationErrors, noticeElement, noticeMessage, blockErrorCount, ampValidity, hasActuallyUnacceptedError; // @todo Gutenberg currently is not persisting isDirty state if changes are made during save request. Block order mismatch. // We can only align block validation errors with blocks in editor when in saved state, since only here will the blocks be aligned with the validation errors. @@ -102,11 +105,15 @@ var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars return; } + hasActuallyUnacceptedError = false; currentPost = wp.data.select( 'core/editor' ).getCurrentPost(); ampValidity = currentPost[ module.data.ampValidityRestField ] || {}; validationErrors = _.map( _.filter( ampValidity.results, function( result ) { - return ! result.sanitized; + if ( result.status !== 1 /* ACCEPTED */ ) { + hasActuallyUnacceptedError = true; + } + return result.term_status !== 1; /* ACCEPTED */ } ), function( result ) { return result.error; @@ -179,7 +186,13 @@ var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars ); } - noticeMessage += ' ' + wp.i18n.__( 'Non-accepted validation errors prevent AMP from being served.', 'amp' ); + noticeMessage += ' '; + if ( hasActuallyUnacceptedError && ! module.data.isCanonical ) { + noticeMessage += wp.i18n.__( 'Non-accepted validation errors prevent AMP from being served, and the user will be redirected to the non-AMP version.', 'amp' ); + } else { + noticeMessage += wp.i18n.__( 'The invalid markup will be automatically sanitized to ensure a valid AMP response is served.', 'amp' ); + } + noticeElement = wp.element.createElement( 'p', {}, [ noticeMessage + ' ', ampValidity.review_link && wp.element.createElement( diff --git a/includes/admin/class-amp-editor-blocks.php b/includes/admin/class-amp-editor-blocks.php index a93e396b169..7e880cea840 100644 --- a/includes/admin/class-amp-editor-blocks.php +++ b/includes/admin/class-amp-editor-blocks.php @@ -43,8 +43,6 @@ public function init() { if ( function_exists( 'gutenberg_init' ) ) { add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) ); add_filter( 'wp_kses_allowed_html', array( $this, 'whitelist_block_atts_in_wp_kses_allowed_html' ), 10, 2 ); - add_filter( 'the_content', array( $this, 'tally_content_requiring_amp_scripts' ) ); - add_action( 'wp_print_footer_scripts', array( $this, 'print_dirty_amp_scripts' ) ); } } @@ -109,27 +107,28 @@ public function whitelist_block_atts_in_wp_kses_allowed_html( $tags, $context ) */ public function enqueue_block_editor_assets() { - // Styles. - wp_enqueue_style( - 'amp-editor-blocks-style', - amp_get_asset_url( 'css/amp-editor-blocks.css' ), - array(), - AMP__VERSION - ); + // Enqueue script and style for AMP-specific blocks. + if ( amp_is_canonical() ) { + wp_enqueue_style( + 'amp-editor-blocks-style', + amp_get_asset_url( 'css/amp-editor-blocks.css' ), + array(), + AMP__VERSION + ); - // Scripts. - wp_enqueue_script( - 'amp-editor-blocks-build', - amp_get_asset_url( 'js/amp-blocks-compiled.js' ), - array( 'wp-blocks', 'lodash', 'wp-i18n', 'wp-element', 'wp-components' ), - AMP__VERSION - ); + wp_enqueue_script( + 'amp-editor-blocks-build', + amp_get_asset_url( 'js/amp-blocks-compiled.js' ), + array( 'wp-blocks', 'lodash', 'wp-i18n', 'wp-element', 'wp-components' ), + AMP__VERSION + ); - wp_add_inline_script( - 'amp-editor-blocks-build', - 'wp.i18n.setLocaleData( ' . wp_json_encode( gutenberg_get_jed_locale_data( 'amp' ) ) . ', "amp" );', - 'before' - ); + wp_add_inline_script( + 'amp-editor-blocks-build', + 'wp.i18n.setLocaleData( ' . wp_json_encode( gutenberg_get_jed_locale_data( 'amp' ) ) . ', "amp" );', + 'before' + ); + } wp_enqueue_script( 'amp-editor-blocks', @@ -146,32 +145,4 @@ public function enqueue_block_editor_assets() { ) ) ) ); } - - /** - * Tally the AMP component scripts that are needed in a dirty AMP document. - * - * @param string $content Content. - * @return string Content (unmodified). - */ - public function tally_content_requiring_amp_scripts( $content ) { - if ( ! is_amp_endpoint() ) { - $pattern = sprintf( '/<(%s)\b.*?>/s', join( '|', $this->amp_blocks ) ); - if ( preg_match_all( $pattern, $content, $matches ) ) { - $this->content_required_amp_scripts = array_merge( - $this->content_required_amp_scripts, - $matches[1] - ); - } - } - return $content; - } - - /** - * Print AMP scripts required for AMP components used in a non-AMP document (dirty AMP). - */ - public function print_dirty_amp_scripts() { - if ( ! is_amp_endpoint() && ! empty( $this->content_required_amp_scripts ) ) { - wp_scripts()->do_items( $this->content_required_amp_scripts ); - } - } } diff --git a/includes/amp-frontend-actions.php b/includes/amp-frontend-actions.php index 75ce9030a35..fa23028f5a7 100644 --- a/includes/amp-frontend-actions.php +++ b/includes/amp-frontend-actions.php @@ -15,6 +15,7 @@ * * @since 0.2 * @since 1.0 Deprecated + * @see amp_add_amphtml_link() */ function amp_frontend_add_canonical() { _deprecated_function( __FUNCTION__, '1.0', 'amp_add_amphtml_link' ); diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index fdb513b6eb0..0bc418687e4 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -169,6 +169,8 @@ function amp_remove_endpoint( $url ) { /** * Add amphtml link. * + * If there are known validation errors for the current URL then do not output anything. + * * @since 1.0 */ function amp_add_amphtml_link() { @@ -183,12 +185,47 @@ function amp_add_amphtml_link() { return; } + $current_url = amp_get_current_url(); + $amp_url = null; - if ( is_singular() ) { - $amp_url = amp_get_permalink( get_queried_object_id() ); + if ( current_theme_supports( 'amp' ) ) { + if ( AMP_Theme_Support::is_paired_available() ) { + $amp_url = add_query_arg( amp_get_slug(), '', $current_url ); + } } else { - $amp_url = add_query_arg( amp_get_slug(), '', amp_get_current_url() ); + if ( is_singular() ) { + $amp_url = amp_get_permalink( get_queried_object_id() ); + } else { + $amp_url = add_query_arg( amp_get_slug(), '', $current_url ); + } + } + + if ( ! $amp_url ) { + printf( '', esc_html__( 'There is no amphtml version available for this URL.', 'amp' ) ); + return; } + + // Check to see if there are known unaccepted validation errors for this URL. + if ( current_theme_supports( 'amp' ) ) { + $validation_errors = AMP_Invalid_URL_Post_Type::get_invalid_url_validation_errors( $current_url, array( 'ignore_accepted' => true ) ); + $error_count = count( $validation_errors ); + if ( $error_count > 0 ) { + echo ""; + return; + } + } + if ( $amp_url ) { printf( '', esc_url( $amp_url ) ); } diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 9c1ee245c94..a3195b222ff 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -96,6 +96,7 @@ class AMP_Theme_Support { * @since 0.7 */ public static function init() { + self::apply_options(); if ( ! current_theme_supports( 'amp' ) ) { return; } @@ -117,7 +118,7 @@ public static function init() { $args = array_shift( $support ); if ( ! is_array( $args ) ) { trigger_error( esc_html__( 'Expected AMP theme support arg to be array.', 'amp' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - } elseif ( count( array_diff( array_keys( $args ), array( 'template_dir', 'available_callback', 'comments_live_list' ) ) ) !== 0 ) { + } elseif ( count( array_diff( array_keys( $args ), array( 'template_dir', 'available_callback', 'comments_live_list', '__added_via_option' ) ) ) !== 0 ) { trigger_error( esc_html__( 'Expected AMP theme support to only have template_dir and/or available_callback.', 'amp' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error } } @@ -132,6 +133,28 @@ public static function init() { add_action( 'wp', array( __CLASS__, 'finish_init' ), PHP_INT_MAX ); } + /** + * Apply options for whether theme support is enabled via admin and what sanitization is performed by default. + * + * @see AMP_Post_Type_Support::add_post_type_support() For where post type support is added, since it is irrespective of theme support. + */ + public static function apply_options() { + if ( ! current_theme_supports( 'amp' ) ) { + $theme_support_option = AMP_Options_Manager::get_option( 'theme_support' ); + if ( 'disabled' === $theme_support_option ) { + return; + } + + $args = array( + '__added_via_option' => true, + ); + if ( 'paired' === $theme_support_option ) { + $args['template_dir'] = './'; + } + add_theme_support( 'amp', $args ); + } + } + /** * Finish initialization once query vars are set. * @@ -139,9 +162,7 @@ public static function init() { */ public static function finish_init() { if ( ! is_amp_endpoint() ) { - if ( self::is_paired_available() ) { - amp_add_frontend_actions(); - } + amp_add_frontend_actions(); return; } @@ -1187,7 +1208,10 @@ public static function prepare_response( $response, $args = array() ) { $dom_serialize_start = microtime( true ); self::ensure_required_markup( $dom ); - if ( ! AMP_Validation_Manager::should_validate_response() && AMP_Validation_Manager::has_blocking_validation_errors() ) { + $blocking_error_count = AMP_Validation_Manager::count_blocking_validation_errors(); + if ( ! AMP_Validation_Manager::should_validate_response() && $blocking_error_count > 0 ) { + + // Note the canonical check will not currently ever be met because dirty AMP is not yet supported; all validation errors will forcibly be sanitized. if ( amp_is_canonical() ) { $dom->documentElement->removeAttribute( 'amp' ); @@ -1201,7 +1225,19 @@ public static function prepare_response( $response, $args = array() ) { $head->appendChild( $script ); } } else { - self::redirect_ampless_url( false ); + $current_url = amp_get_current_url(); + $ampless_url = amp_remove_endpoint( $current_url ); + $ampless_url = add_query_arg( + AMP_Validation_Manager::VALIDATION_ERRORS_QUERY_VAR, + $blocking_error_count, + $ampless_url + ); + + /* + * Temporary redirect because AMP URL may return when blocking validation errors + * occur or when a non-canonical AMP theme is used. + */ + wp_safe_redirect( $ampless_url, 302 ); return esc_html__( 'Redirecting to non-AMP version.', 'amp' ); } } diff --git a/includes/options/class-amp-options-manager.php b/includes/options/class-amp-options-manager.php index 4461a82f6d8..662521318ce 100644 --- a/includes/options/class-amp-options-manager.php +++ b/includes/options/class-amp-options-manager.php @@ -17,6 +17,19 @@ class AMP_Options_Manager { */ const OPTION_NAME = 'amp-options'; + /** + * Default option values. + * + * @var array + */ + protected static $defaults = array( + 'theme_support' => 'disabled', + 'supported_post_types' => array(), + 'analytics' => array(), + 'force_sanitization' => false, + 'accept_tree_shaking' => false, + ); + /** * Register settings. */ @@ -58,7 +71,11 @@ public static function maybe_flush_rewrite_rules( $old_options, $new_options ) { * @return array Options. */ public static function get_options() { - return get_option( self::OPTION_NAME, array() ); + $options = get_option( self::OPTION_NAME, array() ); + if ( empty( $options ) ) { + $options = array(); + } + return array_merge( self::$defaults, $options ); } /** @@ -86,15 +103,20 @@ public static function get_option( $option, $default = false ) { * @return array Options. */ public static function validate_options( $new_options ) { - $defaults = array( - 'supported_post_types' => array(), - 'analytics' => array(), - ); + $options = self::get_options(); - $options = array_merge( - $defaults, - self::get_options() + // Theme support. + $recognized_theme_supports = array( + 'disabled', + 'paired', + 'native', ); + if ( isset( $new_options['theme_support'] ) && in_array( $new_options['theme_support'], $recognized_theme_supports, true ) ) { + $options['theme_support'] = $new_options['theme_support']; + } + + $options['force_sanitization'] = ! empty( $new_options['force_sanitization'] ); + $options['accept_tree_shaking'] = ! empty( $new_options['accept_tree_shaking'] ); // Validate post type support. if ( isset( $new_options['supported_post_types'] ) ) { @@ -156,7 +178,6 @@ public static function validate_options( $new_options ) { return $options; } - /** * Check for errors with updating the supported post types. * diff --git a/includes/options/class-amp-options-menu.php b/includes/options/class-amp-options-menu.php index 3bc556a551f..dd7f18730fc 100644 --- a/includes/options/class-amp-options-menu.php +++ b/includes/options/class-amp-options-menu.php @@ -23,6 +23,28 @@ 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' ), 9 ); + + $plugin_file = preg_replace( '#.+/(?=.+?/.+?)#', '', AMP__FILE__ ); + add_filter( "plugin_action_links_{$plugin_file}", array( $this, 'add_plugin_action_links' ) ); + } + + /** + * Add plugin action links. + * + * @param array $links Links. + * @return array Modified links. + */ + public function add_plugin_action_links( $links ) { + return array_merge( + array( + 'settings' => sprintf( + '%2$s', + esc_url( add_query_arg( 'page', AMP_Options_Manager::OPTION_NAME, admin_url( 'admin.php' ) ) ), + __( 'Settings', 'amp' ) + ), + ), + $links + ); } /** @@ -48,17 +70,43 @@ public function add_menu_items() { ); add_settings_section( - 'post_types', + 'general', false, '__return_false', AMP_Options_Manager::OPTION_NAME ); + + add_settings_field( + 'theme_support', + __( 'Template Mode', 'amp' ), + array( $this, 'render_theme_support' ), + AMP_Options_Manager::OPTION_NAME, + 'general', + array( + 'class' => 'theme_support', + ) + ); + + add_settings_field( + 'validation', + __( 'Validation Handling', 'amp' ), + array( $this, 'render_validation_handling' ), + AMP_Options_Manager::OPTION_NAME, + 'general', + array( + 'class' => 'amp-validation-field', + ) + ); + add_settings_field( 'supported_post_types', __( 'Post Type Support', 'amp' ), array( $this, 'render_post_types_support' ), AMP_Options_Manager::OPTION_NAME, - 'post_types' + 'general', + array( + 'class' => 'amp-post-type-support-field', + ) ); $submenus = array( @@ -71,6 +119,148 @@ public function add_menu_items() { } } + /** + * Render theme support. + * + * @since 1.0 + */ + public function render_theme_support() { + $theme_support = AMP_Options_Manager::get_option( 'theme_support' ); + + $support_args = get_theme_support( 'amp' ); + + $theme_support_mutable = ( + empty( $support_args ) + || + ! empty( $support_args[0]['__added_via_option'] ) + ); + if ( ! $theme_support_mutable ) { + if ( amp_is_canonical() ) { + $theme_support = 'native'; + } else { + $theme_support = 'paired'; + } + } + + $should_have_theme_support = in_array( get_template(), array( 'twentyfifteen', 'twentysixteen', 'twentyseventeen' ), true ); + ?> +
+ +
+

+
+ +
+

+
+ +
+
+ > + +
+
+ +
+
+ > + +
+
+ +
+
+ > + +
+
+ +
+
+
+ +
+ 'non_existent', + ) ); + $tree_shaking_sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( array( + 'code' => AMP_Style_Sanitizer::TREE_SHAKING_ERROR_CODE, + ) ); + + $forced_sanitization = 'with_filter' === $auto_sanitization['forced']; + $forced_tree_shaking = $forced_sanitization || 'with_filter' === $tree_shaking_sanitization['forced']; + ?> + + +
+

+
+ + +
+

+
+
+

+ +

+

+ +

+
+ + + + + +
+

+ +

+

+ +

+
+ + + +
+ +
name}"; - $is_builtin = amp_is_canonical() || in_array( $post_type->name, $builtin_support, true ); + $is_builtin = in_array( $post_type->name, $builtin_support, true ); ?> @@ -103,13 +298,7 @@ public function render_post_types_support() {

- +

diff --git a/includes/sanitizers/class-amp-core-theme-sanitizer.php b/includes/sanitizers/class-amp-core-theme-sanitizer.php index b07854bb1a7..d645054b1c1 100644 --- a/includes/sanitizers/class-amp-core-theme-sanitizer.php +++ b/includes/sanitizers/class-amp-core-theme-sanitizer.php @@ -810,12 +810,17 @@ public function add_nav_menu_toggle( $args = array() ) { $args ); - $nav_el = $this->dom->getElementById( $args['nav_container_id'] ); + $nav_el = $this->dom->getElementById( $args['nav_container_id'] ); + $button_el = $this->xpath->query( $args['menu_button_xpath'] )->item( 0 ); if ( ! $nav_el ) { + if ( $button_el ) { + + // Remove the button since it won't be used. + $button_el->parentNode->removeChild( $button_el ); + } return; } - $button_el = $this->xpath->query( $args['menu_button_xpath'] )->item( 0 ); if ( ! $button_el ) { return; } diff --git a/includes/sanitizers/class-amp-style-sanitizer.php b/includes/sanitizers/class-amp-style-sanitizer.php index 1b17b2a65e7..1843f0bb06c 100644 --- a/includes/sanitizers/class-amp-style-sanitizer.php +++ b/includes/sanitizers/class-amp-style-sanitizer.php @@ -25,6 +25,13 @@ */ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { + /** + * Error code for tree shaking. + * + * @var string + */ + const TREE_SHAKING_ERROR_CODE = 'removed_unused_css_rules'; + /** * Array of flags used to control sanitization. * @@ -37,6 +44,7 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { * @type callable $validation_error_callback Function to call when a validation error is encountered. * @type bool $should_locate_sources Whether to locate the sources when reporting validation errors. * @type string $parsed_cache_variant Additional value by which to vary parsed cache. + * @type bool $accept_tree_shaking Whether to accept tree-shaking by default and bypass a validation error. * } */ protected $args; @@ -56,6 +64,7 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { ), 'should_locate_sources' => false, 'parsed_cache_variant' => null, + 'accept_tree_shaking' => false, ); /** @@ -204,7 +213,7 @@ public static function get_css_parser_validation_error_codes() { 'illegal_css_at_rule', 'illegal_css_important', 'illegal_css_property', - 'removed_unused_css_rules', + self::TREE_SHAKING_ERROR_CODE, 'unrecognized_css', 'disallowed_file_extension', 'file_path_not_found', @@ -1867,9 +1876,9 @@ private function finalize_stylesheet_set( $stylesheet_set ) { ) ); - if ( $is_too_much_css && $should_tree_shake ) { + if ( $is_too_much_css && $should_tree_shake && empty( $this->args['accept_tree_shaking'] ) ) { $should_tree_shake = $this->should_sanitize_validation_error( array( - 'code' => 'removed_unused_css_rules', + 'code' => self::TREE_SHAKING_ERROR_CODE, ) ); } diff --git a/includes/validation/class-amp-invalid-url-post-type.php b/includes/validation/class-amp-invalid-url-post-type.php index f420eb5da5a..93464b8e609 100644 --- a/includes/validation/class-amp-invalid-url-post-type.php +++ b/includes/validation/class-amp-invalid-url-post-type.php @@ -24,7 +24,14 @@ class AMP_Invalid_URL_Post_Type { * * @var string */ - const RECHECK_ACTION = 'amp_recheck'; + const VALIDATE_ACTION = 'amp_validate'; + + /** + * The action to bulk recheck URLs for AMP validity. + * + * @var string + */ + const BULK_VALIDATE_ACTION = 'amp_bulk_validate'; /** * Action to update the status of AMP validation errors. @@ -119,7 +126,7 @@ public static function add_admin_hooks() { 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__, 'print_admin_notice' ) ); - add_action( 'post_action_' . self::RECHECK_ACTION, array( __CLASS__, 'handle_inline_recheck' ) ); + add_action( 'admin_action_' . self::VALIDATE_ACTION, array( __CLASS__, 'handle_validate_request' ) ); add_action( 'post_action_' . self::UPDATE_POST_TERM_STATUS_ACTION, array( __CLASS__, 'handle_validation_error_status_update' ) ); add_action( 'admin_menu', array( __CLASS__, 'add_admin_menu_new_invalid_url_count' ) ); @@ -135,7 +142,7 @@ public static function add_admin_hooks() { add_filter( 'removable_query_args', function( $query_vars ) { $query_vars[] = 'amp_actioned'; $query_vars[] = 'amp_taxonomy_terms_updated'; - $query_vars[] = self::REMAINING_ERRORS; + $query_vars[] = AMP_Invalid_URL_Post_Type::REMAINING_ERRORS; $query_vars[] = 'amp_urls_tested'; $query_vars[] = 'amp_validate_error'; return $query_vars; @@ -172,33 +179,48 @@ public static function add_admin_menu_new_invalid_url_count() { /** * Gets validation errors for a given invalid URL post. * - * @param int|WP_Post $post Post of amp_invalid_url type. - * @param array $args { + * @param string|int|WP_Post $url Either the URL string or a post (ID or WP_Post) of amp_invalid_url type. + * @param array $args { * Args. * * @type bool $ignore_accepted Exclude validation errors that are accepted. Default false. * } * @return array List of errors, with keys for term, data, status, and (sanitization) forced. */ - public static function get_invalid_url_validation_errors( $post, $args = array() ) { - $args = array_merge( + public static function get_invalid_url_validation_errors( $url, $args = array() ) { + $args = array_merge( array( 'ignore_accepted' => false, ), $args ); - $post = get_post( $post ); - $errors = array(); + // Look up post by URL or ensure the amp_invalid_url object. + if ( is_string( $url ) ) { + $post = self::get_invalid_url_post( $url ); + } else { + $post = get_post( $url ); + } + if ( ! $post || self::POST_TYPE_SLUG !== $post->post_type ) { + return array(); + } + + // Skip when parse error. $stored_validation_errors = json_decode( $post->post_content, true ); if ( ! is_array( $stored_validation_errors ) ) { return array(); } + + $errors = array(); foreach ( $stored_validation_errors as $stored_validation_error ) { if ( ! isset( $stored_validation_error['term_slug'] ) ) { continue; } + $term = get_term_by( 'slug', $stored_validation_error['term_slug'], AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG ); + if ( ! $term ) { + continue; + } $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $stored_validation_error['data'] ); if ( $args['ignore_accepted'] && AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS === $sanitization['status'] ) { @@ -217,55 +239,51 @@ public static function get_invalid_url_validation_errors( $post, $args = array() } /** - * Get counts for the validation errors associated with a given invalid URL. + * Display summary of the validation error counts for a given post. * * @param int|WP_Post $post Post of amp_invalid_url type. - * @return array Term counts. */ - public static function get_invalid_url_validation_error_counts( $post ) { + public static function display_invalid_url_validation_error_counts_summary( $post ) { $counts = array_fill_keys( - array( - AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_STATUS, - AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS, - AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_REJECTED_STATUS, - ), + array( 'new', 'accepted', 'rejected' ), 0 ); $validation_errors = self::get_invalid_url_validation_errors( $post ); foreach ( $validation_errors as $error ) { - $counts[ $error['status'] ]++; + switch ( $error['term']->term_group ) { + case AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_STATUS: + $counts['new']++; + break; + case AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS: + $counts['accepted']++; + break; + case AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_REJECTED_STATUS: + $counts['rejected']++; + break; + } } - return $counts; - } - /** - * Display summary of the validation error counts for a given post. - * - * @param int|WP_Post $post Post of amp_invalid_url type. - */ - public static function display_invalid_url_validation_error_counts_summary( $post ) { $result = array(); - $counts = self::get_invalid_url_validation_error_counts( $post ); - if ( $counts[ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_STATUS ] ) { + if ( $counts['new'] ) { $result[] = esc_html( sprintf( /* translators: %s is count */ __( '❓ New: %s', 'amp' ), - number_format_i18n( $counts[ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_STATUS ] ) + number_format_i18n( $counts['new'] ) ) ); } - if ( $counts[ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS ] ) { + if ( $counts['accepted'] ) { $result[] = esc_html( sprintf( /* translators: %s is count */ __( '✅ Accepted: %s', 'amp' ), - number_format_i18n( $counts[ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS ] ) + number_format_i18n( $counts['accepted'] ) ) ); } - if ( $counts[ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_REJECTED_STATUS ] ) { + if ( $counts['rejected'] ) { $result[] = esc_html( sprintf( /* translators: %s is count */ __( '❌ Rejected: %s', 'amp' ), - number_format_i18n( $counts[ AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_REJECTED_STATUS ] ) + number_format_i18n( $counts['rejected'] ) ) ); } echo implode( '
', $result ); // WPCS: xss ok. @@ -311,7 +329,7 @@ public static function get_url_from_post( $post ) { * @param array $validation_errors Validation errors. * @param string $url URL on which the validation errors occurred. Will be normalized to non-AMP version. * @param int|WP_Post $post Post to update. Optional. If empty, then post is looked up by URL. - * @return int|WP_Error $post_id The post ID of the custom post type used, null if post was deleted due to no validation errors, or WP_Error on failure. + * @return int|WP_Error $post_id The post ID of the custom post type used, or WP_Error on failure. * @global WP $wp */ public static function store_validation_errors( $validation_errors, $url, $post = null ) { @@ -326,14 +344,6 @@ public static function store_validation_errors( $validation_errors, $url, $post } } - // Since there are no validation errors and there is an existing $existing_post_id, just delete the post. - if ( empty( $validation_errors ) ) { - if ( $post ) { - wp_delete_post( $post->ID, true ); - } - return null; - } - /* * The details for individual validation errors is stored in the amp_validation_error taxonomy terms. * The post content just contains the slugs for these terms and the sources for the given instance of @@ -341,23 +351,23 @@ public static function store_validation_errors( $validation_errors, $url, $post */ $stored_validation_errors = array(); + // Prevent Kses from corrupting JSON in description. + $has_pre_term_description_filter = has_filter( 'pre_term_description', 'wp_filter_kses' ); + if ( false !== $has_pre_term_description_filter ) { + remove_filter( 'pre_term_description', 'wp_filter_kses', $has_pre_term_description_filter ); + } + $terms = array(); foreach ( $validation_errors as $data ) { $term_data = AMP_Validation_Error_Taxonomy::prepare_validation_error_taxonomy_term( $data ); $term_slug = $term_data['slug']; + if ( ! isset( $terms[ $term_slug ] ) ) { // Not using WP_Term_Query since more likely individual terms are cached and wp_insert_term() will itself look at this cache anyway. $term = get_term_by( 'slug', $term_slug, AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG ); if ( ! ( $term instanceof WP_Term ) ) { - $has_pre_term_description_filter = has_filter( 'pre_term_description', 'wp_filter_kses' ); - if ( false !== $has_pre_term_description_filter ) { - remove_filter( 'pre_term_description', 'wp_filter_kses', $has_pre_term_description_filter ); - } $r = wp_insert_term( $term_slug, AMP_Validation_Error_Taxonomy::TAXONOMY_SLUG, wp_slash( $term_data ) ); - if ( false !== $has_pre_term_description_filter ) { - add_filter( 'pre_term_description', 'wp_filter_kses', $has_pre_term_description_filter ); - } if ( is_wp_error( $r ) ) { continue; } @@ -371,6 +381,11 @@ public static function store_validation_errors( $validation_errors, $url, $post $stored_validation_errors[] = compact( 'term_slug', 'data' ); } + // Finish preventing Kses from corrupting JSON in description. + if ( false !== $has_pre_term_description_filter ) { + add_filter( 'pre_term_description', 'wp_filter_kses', $has_pre_term_description_filter ); + } + $post_content = wp_json_encode( $stored_validation_errors ); $placeholder = 'amp_invalid_url_content_placeholder' . wp_rand(); @@ -381,7 +396,7 @@ public static function store_validation_errors( $validation_errors, $url, $post && $placeholder === $post_data['post_content'] && - self::POST_TYPE_SLUG === $post_data['post_type'] + AMP_Invalid_URL_Post_Type::POST_TYPE_SLUG === $post_data['post_type'] ); if ( $should_supply_post_content ) { $post_data['post_content'] = wp_slash( $post_content ); @@ -648,9 +663,9 @@ public static function filter_row_actions( $actions, $post ) { ); } - $actions[ self::RECHECK_ACTION ] = sprintf( + $actions[ self::VALIDATE_ACTION ] = sprintf( '%s', - esc_url( self::get_recheck_url( $post, get_edit_post_link( $post->ID, 'raw' ) ) ), + esc_url( self::get_recheck_url( $post ) ), esc_html__( 'Re-check', 'amp' ) ); @@ -665,7 +680,7 @@ public static function filter_row_actions( $actions, $post ) { */ public static function add_bulk_action( $actions ) { unset( $actions['edit'] ); - $actions[ self::RECHECK_ACTION ] = esc_html__( 'Recheck', 'amp' ); + $actions[ self::BULK_VALIDATE_ACTION ] = esc_html__( 'Recheck', 'amp' ); return $actions; } @@ -678,7 +693,7 @@ public static function add_bulk_action( $actions ) { * @return string $redirect The filtered URL of the redirect. */ public static function handle_bulk_action( $redirect, $action, $items ) { - if ( self::RECHECK_ACTION !== $action ) { + if ( self::BULK_VALIDATE_ACTION !== $action ) { return $redirect; } $remaining_invalid_urls = array(); @@ -703,7 +718,13 @@ public static function handle_bulk_action( $redirect, $action, $items ) { } self::store_validation_errors( $validity['validation_errors'], $validity['url'], $post ); - if ( ! empty( $validity['validation_errors'] ) ) { + $unaccepted_error_count = count( array_filter( + $validity['validation_errors'], + function( $error ) { + return ! AMP_Validation_Error_Taxonomy::is_validation_error_sanitized( $error ); + } + ) ); + if ( $unaccepted_error_count > 0 ) { $remaining_invalid_urls[] = $validity['url']; } } @@ -761,10 +782,10 @@ public static function print_admin_notice() { $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 ) { + $message = _n( 'The rechecked URL still has unaccepted validation errors.', 'The rechecked URLs still have unaccepted validation errors.', $count_urls_tested, 'amp' ); $class = 'notice-warning'; - $message = _n( 'The rechecked URL still has blocking validation errors.', 'The rechecked URLs still have validation errors.', $count_urls_tested, 'amp' ); } else { - $message = _n( 'The rechecked URL has no blocking validation errors.', 'The rechecked URLs have no validation errors.', $count_urls_tested, 'amp' ); + $message = _n( 'The rechecked URL is free of unaccepted validation errors.', 'The rechecked URLs are free of unaccepted validation errors.', $count_urls_tested, 'amp' ); $class = 'updated'; } @@ -798,41 +819,70 @@ public static function print_admin_notice() { } /** - * Handles clicking 'recheck' on the inline post actions. + * Handles clicking 'recheck' on the inline post actions and in the admin bar on the frontend. * - * @param int $post_id The post ID of the recheck. - * @return void + * @throws Exception But it is caught. This is here for a PHPCS bug. */ - public static function handle_inline_recheck( $post_id ) { - check_admin_referer( self::NONCE_ACTION . $post_id ); - $validation_results = self::recheck_post( $post_id ); + public static function handle_validate_request() { + check_admin_referer( self::NONCE_ACTION ); + if ( ! AMP_Validation_Manager::has_cap() ) { + wp_die( esc_html__( 'You do not have permissions to validate an AMP URL. Did you get logged out?', 'amp' ) ); + } $redirect = wp_get_referer(); - if ( $redirect ) { - $redirect = remove_query_arg( wp_removable_query_args(), $redirect ); - } - if ( ! $redirect || empty( $validation_results ) ) { - // 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', - ); + $post = null; + $url = null; - if ( is_wp_error( $validation_results ) ) { - $args['amp_validate_error'] = $validation_results->get_error_code(); - } else { - $args[ self::REMAINING_ERRORS ] = count( array_filter( - $validation_results, - function( $result ) { - return ! $result['sanitized']; + try { + if ( isset( $_GET['post'] ) ) { + $post = intval( $_GET['post'] ); + if ( $post <= 0 ) { + throw new Exception( 'unknown_post' ); + } + $post = get_post( $post ); + if ( ! $post || self::POST_TYPE_SLUG !== $post->post_type ) { + throw new Exception( 'invalid_post' ); + } + $url = self::get_url_from_post( $post ); + } elseif ( isset( $_GET['url'] ) ) { + $url = wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['url'] ) ), null ); + if ( ! $url ) { + throw new Exception( 'illegal_url' ); + } + } + + if ( ! $url ) { + throw new Exception( 'missing_url' ); + } + + $validity = AMP_Validation_Manager::validate_url( $url ); + if ( is_wp_error( $validity ) ) { + throw new Exception( esc_html( $validity->get_error_code() ) ); + } + + $stored = self::store_validation_errors( $validity['validation_errors'], $validity['url'], $post->ID ); + if ( is_wp_error( $stored ) ) { + throw new Exception( esc_html( $stored->get_error_code() ) ); + } + $redirect = get_edit_post_link( $stored, 'raw' ); + + $error_count = count( array_filter( + $validity['validation_errors'], + function ( $error ) { + return ! AMP_Validation_Error_Taxonomy::is_validation_error_sanitized( $error ); } ) ); + + $args[ self::URLS_TESTED ] = '1'; + $args[ self::REMAINING_ERRORS ] = $error_count; + } catch ( Exception $e ) { + $args['amp_validate_error'] = $e->getMessage(); + $args[ self::URLS_TESTED ] = '0'; + + if ( $post ) { + $redirect = get_edit_post_link( $post->ID, 'raw' ); + } } wp_safe_redirect( add_query_arg( $args, $redirect ) ); @@ -843,7 +893,7 @@ function( $result ) { * Re-check invalid URL post for whether it has blocking validation errors. * * @param int|WP_Post $post Post. - * @return array|WP_Error List of blocking validation resukts, or a WP_Error in the case of failure. + * @return array|WP_Error List of blocking validation results, or a WP_Error in the case of failure. */ public static function recheck_post( $post ) { $post = get_post( $post ); @@ -960,12 +1010,6 @@ public static function add_meta_boxes() { * @return void */ public static function print_status_meta_box( $post ) { - $redirect_url = add_query_arg( - 'post', - $post->ID, - admin_url( 'post.php' ) - ); - ?> - + + +
+

+ + + + + + +

+
+ +
+

+
+ +

- -
-

-

@@ -1079,6 +1147,7 @@ public static function print_validation_errors_meta_box( $post ) { } } ); validatePreviewUrl += '&' + $.param( params ); + validatePreviewUrl += '#development=1'; window.open( validatePreviewUrl, 'amp-validation-error-term-status-preview-' + String( postId ) ); } ); } ); @@ -1094,9 +1163,9 @@ public static function print_validation_errors_meta_box( $post ) { } ?> - +

-

+

🚩

@@ -1109,25 +1178,32 @@ public static function print_validation_errors_meta_box( $post ) { $select_name = sprintf( '%s[%s]', AMP_Validation_Manager::VALIDATION_ERROR_TERM_STATUS_QUERY_VAR, $term->slug ); ?>
  • -
    > +
    term_group ) ? 'open' : ''; ?>> - + term_group ) : ?> - - + + - - ❓ - - ❌ - - ✅ - count > 1 ) : ?> @@ -1284,19 +1360,22 @@ public static function filter_the_title_in_post_list_table( $title, $post ) { * 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|WP_Post $url_or_post The post storing the validation error or the URL to check. * @return string The URL to recheck the post. */ - public static function get_recheck_url( $post, $redirect_url ) { + public static function get_recheck_url( $url_or_post ) { + $args = array( + 'action' => self::VALIDATE_ACTION, + ); + if ( is_string( $url_or_post ) ) { + $args['url'] = $url_or_post; + } elseif ( $url_or_post instanceof WP_Post && self::POST_TYPE_SLUG === $url_or_post->post_type ) { + $args['post'] = $url_or_post->ID; + } + return wp_nonce_url( - add_query_arg( - array( - 'action' => self::RECHECK_ACTION, - ), - $redirect_url - ), - self::NONCE_ACTION . $post->ID + add_query_arg( $args, admin_url() ), + self::NONCE_ACTION ); } diff --git a/includes/validation/class-amp-validation-error-taxonomy.php b/includes/validation/class-amp-validation-error-taxonomy.php index 97da59b8f2a..f43d5e63c50 100644 --- a/includes/validation/class-amp-validation-error-taxonomy.php +++ b/includes/validation/class-amp-validation-error-taxonomy.php @@ -201,8 +201,9 @@ public static function is_validation_error_sanitized( $error ) { * @return array { * Validation error sanitization. * - * @type int $status Validation status (0=VALIDATION_ERROR_NEW_STATUS, 1=VALIDATION_ERROR_ACCEPTED_STATUS, 2=VALIDATION_ERROR_REJECTED_STATUS). - * @type bool $forced Whether sanitization is forced via filter. + * @type int $status Validation status (0=VALIDATION_ERROR_NEW_STATUS, 1=VALIDATION_ERROR_ACCEPTED_STATUS, 2=VALIDATION_ERROR_REJECTED_STATUS). + * @type int $term_status The initial validation status prior to being overridden by previewing, option, or filter. + * @type false|string $forced If and how the status is overridden from its initial term status. * } */ public static function get_validation_error_sanitization( $error ) { @@ -213,24 +214,43 @@ public static function get_validation_error_sanitization( $error ) { self::VALIDATION_ERROR_ACCEPTED_STATUS, self::VALIDATION_ERROR_REJECTED_STATUS, ); + if ( ! empty( $term ) && in_array( $term->term_group, $statuses, true ) ) { + $term_status = $term->term_group; + } else { + $term_status = self::VALIDATION_ERROR_NEW_STATUS; + } + + $forced = false; + $status = $term_status; + + // See note in AMP_Validation_Manager::add_validation_error_sourcing() for why amp_validation_error_sanitized filter isn't used. if ( isset( AMP_Validation_Manager::$validation_error_status_overrides[ $term_data['slug'] ] ) ) { - // See note in AMP_Validation_Manager::add_validation_error_sourcing() for why amp_validation_error_sanitized filter isn't used. $status = AMP_Validation_Manager::$validation_error_status_overrides[ $term_data['slug'] ]; - } elseif ( ! empty( $term ) && in_array( $term->term_group, $statuses, true ) ) { - $status = $term->term_group; - } else { - $status = self::VALIDATION_ERROR_NEW_STATUS; + $forced = 'with_preview'; + } + + $is_forced_with_option = ( + AMP_Style_Sanitizer::TREE_SHAKING_ERROR_CODE === $error['code'] && AMP_Options_Manager::get_option( 'accept_tree_shaking' ) + || + AMP_Options_Manager::get_option( 'force_sanitization' ) + ); + if ( $is_forced_with_option ) { + $forced = 'with_option'; + $status = self::VALIDATION_ERROR_ACCEPTED_STATUS; } /** * Filters whether the validation error should be sanitized. * + * Returning true this indicates that the validation error is acceptable + * and should not be considered a blocker to render AMP. Returning null + * means that the default status should be used. + * * Note that the $node is not passed here to ensure that the filter can be * applied on validation errors that have been stored. Likewise, the $sources * are also omitted because these are only available during an explicit * validation request and so they are not suitable for plugins to vary - * sanitization by. Note that returning false this indicates that the - * validation error should not be considered a blocker to render AMP. + * sanitization by. * * @since 1.0 * @@ -239,13 +259,12 @@ public static function get_validation_error_sanitization( $error ) { */ $sanitized = apply_filters( 'amp_validation_error_sanitized', null, $error ); - $forced = false; if ( null !== $sanitized ) { - $forced = true; + $forced = 'with_filter'; $status = $sanitized ? self::VALIDATION_ERROR_ACCEPTED_STATUS : self::VALIDATION_ERROR_REJECTED_STATUS; } - return compact( 'status', 'forced' ); + return compact( 'status', 'forced', 'term_status' ); } /** @@ -254,44 +273,23 @@ public static function get_validation_error_sanitization( $error ) { * @since 1.0 * @see AMP_Core_Theme_Sanitizer::get_acceptable_errors() * - * @param array $acceptable_errors Acceptable validation errors, where keys are codes and values are either `true` or sparse array to check as subset. + * @param array|true $acceptable_errors Acceptable validation errors, where keys are codes and values are either `true` or sparse array to check as subset. If just true, then all validation errors are accepted. */ public static function accept_validation_errors( $acceptable_errors ) { if ( empty( $acceptable_errors ) ) { return; } - - /** - * Check if one array is a sparse subset of another array. - * - * @param array $superset Superset array. - * @param array $subset Subset array. - * - * @return bool Whether subset is contained in superset. - */ - $is_array_subset = function( $superset, $subset ) use ( &$is_array_subset ) { - foreach ( $subset as $key => $subset_value ) { - if ( ! isset( $superset[ $key ] ) || gettype( $subset_value ) !== gettype( $superset[ $key ] ) ) { - return false; - } - if ( is_array( $subset_value ) ) { - if ( ! $is_array_subset( $superset[ $key ], $subset_value ) ) { - return false; - } - } elseif ( $superset[ $key ] !== $subset_value ) { - return false; - } + add_filter( 'amp_validation_error_sanitized', function( $sanitized, $error ) use ( $acceptable_errors ) { + if ( true === $acceptable_errors ) { + return true; } - return true; - }; - add_filter( 'amp_validation_error_sanitized', function( $sanitized, $error ) use ( $is_array_subset, $acceptable_errors ) { if ( isset( $acceptable_errors[ $error['code'] ] ) ) { if ( true === $acceptable_errors[ $error['code'] ] ) { return true; } foreach ( $acceptable_errors[ $error['code'] ] as $acceptable_error_props ) { - if ( $is_array_subset( $error, $acceptable_error_props ) ) { + if ( AMP_Validation_Error_Taxonomy::is_array_subset( $error, $acceptable_error_props ) ) { return true; } } @@ -300,6 +298,30 @@ public static function accept_validation_errors( $acceptable_errors ) { }, 10, 2 ); } + /** + * Check if one array is a sparse subset of another array. + * + * @param array $superset Superset array. + * @param array $subset Subset array. + * + * @return bool Whether subset is contained in superset. + */ + public static function is_array_subset( $superset, $subset ) { + foreach ( $subset as $key => $subset_value ) { + if ( ! isset( $superset[ $key ] ) || gettype( $subset_value ) !== gettype( $superset[ $key ] ) ) { + return false; + } + if ( is_array( $subset_value ) ) { + if ( ! self::is_array_subset( $superset[ $key ], $subset_value ) ) { + return false; + } + } elseif ( $superset[ $key ] !== $subset_value ) { + return false; + } + } + return true; + } + /** * Get the count of validation error terms, optionally restricted by term group (e.g. accepted or rejected). * @@ -564,20 +586,13 @@ public static function add_group_terms_clauses_filter() { * @return array All caps. */ public static function filter_user_has_cap_for_hiding_term_list_table_checkbox( $allcaps, $caps, $args ) { + unset( $caps ); if ( isset( $args[0] ) && 'delete_term' === $args[0] ) { $term = get_term( $args[2] ); $error = json_decode( $term->description, true ); if ( ! is_array( $error ) ) { return $allcaps; } - - $sanitization = self::get_validation_error_sanitization( $error ); - if ( $sanitization['forced'] ) { - $allcaps = array_merge( - $allcaps, - array_fill_keys( $caps, false ) - ); - } } return $allcaps; } @@ -663,29 +678,27 @@ public static function filter_tag_row_actions( $actions, WP_Term $tag ) { unset( $actions['delete'] ); $sanitization = self::get_validation_error_sanitization( json_decode( $term->description, true ) ); - if ( ! $sanitization['forced'] ) { - if ( self::VALIDATION_ERROR_REJECTED_STATUS !== $sanitization['status'] ) { - $actions[ self::VALIDATION_ERROR_REJECT_ACTION ] = sprintf( - '%s', - wp_nonce_url( - add_query_arg( array_merge( array( 'action' => self::VALIDATION_ERROR_REJECT_ACTION ), compact( 'term_id' ) ) ), - self::VALIDATION_ERROR_REJECT_ACTION - ), - esc_attr__( 'Rejecting an error acknowledges that it should block a URL from being served as AMP.', 'amp' ), - esc_html__( 'Reject', 'amp' ) - ); - } - if ( self::VALIDATION_ERROR_ACCEPTED_STATUS !== $sanitization['status'] ) { - $actions[ self::VALIDATION_ERROR_ACCEPT_ACTION ] = sprintf( - '%s', - wp_nonce_url( - add_query_arg( array_merge( array( 'action' => self::VALIDATION_ERROR_ACCEPT_ACTION ), compact( 'term_id' ) ) ), - self::VALIDATION_ERROR_ACCEPT_ACTION - ), - esc_attr__( 'Accepting an error means it will get sanitized and not block a URL from being served as AMP.', 'amp' ), - esc_html__( 'Accept', 'amp' ) - ); - } + if ( self::VALIDATION_ERROR_REJECTED_STATUS !== $sanitization['status'] ) { + $actions[ self::VALIDATION_ERROR_REJECT_ACTION ] = sprintf( + '%s', + wp_nonce_url( + add_query_arg( array_merge( array( 'action' => self::VALIDATION_ERROR_REJECT_ACTION ), compact( 'term_id' ) ) ), + self::VALIDATION_ERROR_REJECT_ACTION + ), + esc_attr__( 'Rejecting an error acknowledges that it should block a URL from being served as AMP.', 'amp' ), + esc_html__( 'Reject', 'amp' ) + ); + } + if ( self::VALIDATION_ERROR_ACCEPTED_STATUS !== $sanitization['status'] ) { + $actions[ self::VALIDATION_ERROR_ACCEPT_ACTION ] = sprintf( + '%s', + wp_nonce_url( + add_query_arg( array_merge( array( 'action' => self::VALIDATION_ERROR_ACCEPT_ACTION ), compact( 'term_id' ) ) ), + self::VALIDATION_ERROR_ACCEPT_ACTION + ), + esc_attr__( 'Accepting an error means it will get sanitized and not block a URL from being served as AMP.', 'amp' ), + esc_html__( 'Accept', 'amp' ) + ); } } return $actions; @@ -861,10 +874,20 @@ public static function filter_manage_custom_columns( $content, $column_name, $te break; case 'status': $sanitization = self::get_validation_error_sanitization( $validation_error ); - if ( self::VALIDATION_ERROR_ACCEPTED_STATUS === $sanitization['status'] ) { - $content = '✅ ' . esc_html__( 'Accepted', 'amp' ); - } elseif ( self::VALIDATION_ERROR_REJECTED_STATUS === $sanitization['status'] ) { - $content = '❌ ' . esc_html__( 'Rejected', 'amp' ); + if ( self::VALIDATION_ERROR_ACCEPTED_STATUS === $sanitization['term_status'] ) { + if ( $sanitization['forced'] && $sanitization['term_status'] !== $sanitization['status'] ) { + $content .= '🚩'; + } else { + $content .= '✅'; + } + $content .= ' ' . esc_html__( 'Accepted', 'amp' ); + } elseif ( self::VALIDATION_ERROR_REJECTED_STATUS === $sanitization['term_status'] ) { + if ( $sanitization['forced'] && $sanitization['term_status'] !== $sanitization['status'] ) { + $content .= '🚩'; + } else { + $content .= '❌'; + } + $content .= ' ' . esc_html__( 'Rejected', 'amp' ); } else { $content = '❓ ' . esc_html__( 'New', 'amp' ); } diff --git a/includes/validation/class-amp-validation-manager.php b/includes/validation/class-amp-validation-manager.php index f2a8259dd9f..b0f4841092d 100644 --- a/includes/validation/class-amp-validation-manager.php +++ b/includes/validation/class-amp-validation-manager.php @@ -19,6 +19,13 @@ class AMP_Validation_Manager { */ const VALIDATE_QUERY_VAR = 'amp_validate'; + /** + * Query var for containing the number of validation errors on the frontend after redirection when invalid. + * + * @var string + */ + const VALIDATION_ERRORS_QUERY_VAR = 'amp_validation_errors'; + /** * Query var for passing status preview/update for validation error. * @@ -141,8 +148,8 @@ public static function init( $args = array() ) { self::$should_locate_sources = $args['should_locate_sources']; - add_action( 'init', array( 'AMP_Invalid_URL_Post_Type', 'register' ) ); - add_action( 'init', array( 'AMP_Validation_Error_Taxonomy', 'register' ) ); + AMP_Invalid_URL_Post_Type::register(); + AMP_Validation_Error_Taxonomy::register(); add_action( 'save_post', array( __CLASS__, 'handle_save_post_prompting_validation' ), 10, 2 ); add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_block_validation' ) ); @@ -159,11 +166,145 @@ public static function init( $args = array() ) { } } ); + // Prevent query vars from persisting after redirect. + add_filter( 'removable_query_args', function( $query_vars ) { + $query_vars[] = AMP_Validation_Manager::VALIDATION_ERRORS_QUERY_VAR; + return $query_vars; + } ); + + add_action( 'admin_bar_menu', array( __CLASS__, 'add_admin_bar_menu_items' ), 100 ); + + /* + * Set sanitization options on the frontend. These filters get added only on the frontend so that + * the user is able to keep track of the errors that they haven't seen before and decide whether + * they need to get fixed (rejected) or not (accepted). + */ + add_action( 'template_redirect', function() { + if ( AMP_Validation_Manager::is_sanitization_forcibly_accepted() ) { + AMP_Validation_Error_Taxonomy::accept_validation_errors( true ); + } elseif ( AMP_Options_Manager::get_option( 'accept_tree_shaking' ) ) { + AMP_Validation_Error_Taxonomy::accept_validation_errors( + array( + AMP_Style_Sanitizer::TREE_SHAKING_ERROR_CODE => true, + ) + ); + } + } ); + if ( self::$should_locate_sources ) { self::add_validation_error_sourcing(); } } + /** + * Return whether sanitization is forcibly accepted, whether because in native mode or via user option. + * + * @return bool Whether sanitization is forcibly accepted. + */ + public static function is_sanitization_forcibly_accepted() { + return amp_is_canonical() || AMP_Options_Manager::get_option( 'force_sanitization' ); + } + + /** + * Add menu items to admin bar for AMP. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar. + */ + public static function add_admin_bar_menu_items( $wp_admin_bar ) { + if ( is_admin() || ! self::has_cap() || ! ( amp_is_canonical() || AMP_Theme_Support::is_paired_available() ) ) { + return; + } + + $amp_invalid_url_post = null; + + // @todo The amp_invalid_url post should probably only be accessible to users who can manage_options, or limit access to a post if the user has the cap to edit the queried object? + $amp_url = amp_get_current_url(); + $amp_url = remove_query_arg( wp_removable_query_args(), $amp_url ); + if ( ! amp_is_canonical() ) { + $amp_url = add_query_arg( amp_get_slug(), '', $amp_url ); + } + + $error_count = -1; + if ( isset( $_GET[ self::VALIDATION_ERRORS_QUERY_VAR ] ) && is_numeric( $_GET[ self::VALIDATION_ERRORS_QUERY_VAR ] ) ) { // WPCS: CSRF OK. + $error_count = intval( $_GET[ self::VALIDATION_ERRORS_QUERY_VAR ] ); + } + if ( $error_count < 0 ) { + $amp_invalid_url_post = AMP_Invalid_URL_Post_Type::get_invalid_url_post( $amp_url ); + if ( $amp_invalid_url_post ) { + $error_count = count( AMP_Invalid_URL_Post_Type::get_invalid_url_validation_errors( $amp_invalid_url_post, array( 'ignore_accepted' => true ) ) ); + } + } + + $title = ''; + if ( $error_count > -1 ) { + if ( 0 === $error_count ) { + $title .= '✅ '; + } else { + $title .= '❌ '; + } + } + $title .= 'AMP'; + + $wp_admin_bar->add_menu( array( + 'id' => 'amp', + 'title' => $title, + 'href' => esc_url( $amp_url ), + ) ); + + $wp_admin_bar->add_menu( array( + 'parent' => 'amp', + 'id' => 'amp-view', + 'title' => esc_html__( 'View AMP version', 'amp' ), + 'href' => esc_url( $amp_url ), + ) ); + + if ( $error_count <= 0 ) { + $title = esc_html__( 'Re-validate', 'amp' ); + } else { + $title = esc_html( + sprintf( + /* translators: %s is count of validation errors */ + _n( + 'Re-validate (%s validation error)', + 'Re-validate (%s validation errors)', + $error_count, + 'amp' + ), + number_format_i18n( $error_count ) + ) + ); + } + + $validate_url = AMP_Invalid_URL_Post_Type::get_recheck_url( $amp_invalid_url_post ? $amp_invalid_url_post : $amp_url ); + + $wp_admin_bar->add_menu( array( + 'parent' => 'amp', + 'id' => 'amp-validity', + 'title' => $title, + 'href' => esc_url( $validate_url ), + ) ); + + // Scrub the query var from the URL. + if ( ! is_amp_endpoint() && isset( $_GET[ self::VALIDATION_ERRORS_QUERY_VAR ] ) ) { // WPCS: CSRF OK. + add_action( 'wp_before_admin_bar_render', function() { + ?> + + ID, 'raw' ); foreach ( AMP_Invalid_URL_Post_Type::get_invalid_url_validation_errors( $validation_status_post ) as $result ) { $field['results'][] = array( - 'sanitized' => AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS === $result['status'], - 'error' => $result['data'], + 'sanitized' => AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS === $result['status'], + 'error' => $result['data'], + 'status' => $result['status'], + 'term_status' => $result['term_status'], + 'forced' => $result['forced'], ); } } @@ -461,6 +605,8 @@ public static function reset_validation_results() { * * If it's not valid AMP, it displays an error message above the 'Classic' editor. * + * This is essentially a PHP implementation of ampBlockValidation.handleValidationErrorsStateChange() in JS. + * * @param WP_Post $post The updated post. * @return void */ @@ -479,10 +625,16 @@ public static function print_edit_form_validation_status( $post ) { return; } - $validation_errors = wp_list_pluck( - AMP_Invalid_URL_Post_Type::get_invalid_url_validation_errors( $invalid_url_post, array( 'ignore_accepted' => true ) ), - 'data' - ); + $has_actually_unaccepted_error = false; + $validation_errors = array(); + foreach ( AMP_Invalid_URL_Post_Type::get_invalid_url_validation_errors( $invalid_url_post ) as $error ) { + if ( AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS !== $error['term_status'] ) { + $validation_errors[] = $error['data']; + if ( AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACCEPTED_STATUS !== $error['status'] ) { + $has_actually_unaccepted_error = true; + } + } + } // No validation errors so abort. if ( empty( $validation_errors ) ) { @@ -491,7 +643,13 @@ public static function print_edit_form_validation_status( $post ) { echo '
    '; echo '

    '; - esc_html_e( 'There is content which fails AMP validation. Non-accepted validation errors prevent AMP from being served.', 'amp' ); + esc_html_e( 'There is content which fails AMP validation.', 'amp' ); + echo ' '; + if ( $has_actually_unaccepted_error && ! amp_is_canonical() ) { + esc_html_e( 'Non-accepted validation errors prevent AMP from being served, and the user will be redirected to the non-AMP version.', 'amp' ); + } else { + esc_html_e( 'The invalid markup will be automatically sanitized to ensure a valid AMP response is served.', 'amp' ); + } echo sprintf( ' %s', esc_url( get_edit_post_link( $invalid_url_post ) ), @@ -1182,15 +1340,16 @@ public static function should_validate_response() { /** * Determine if there are any validation errors which have not been ignored. * - * @return bool Whether AMP is blocked. + * @return int Count of errors that block AMP. */ - public static function has_blocking_validation_errors() { + public static function count_blocking_validation_errors() { + $count = 0; foreach ( self::$validation_results as $result ) { if ( false === $result['sanitized'] ) { - return true; + $count++; } } - return false; + return $count; } /** @@ -1261,6 +1420,7 @@ public static function filter_sanitizer_args( $sanitizers ) { if ( ! empty( $css_validation_errors ) ) { $sanitizers['AMP_Style_Sanitizer']['parsed_cache_variant'] = md5( wp_json_encode( $css_validation_errors ) ); } + $sanitizers['AMP_Style_Sanitizer']['accept_tree_shaking'] = AMP_Options_Manager::get_option( 'accept_tree_shaking' ); } return $sanitizers; @@ -1450,6 +1610,7 @@ public static function enqueue_block_validation() { $data = wp_json_encode( array( 'i18n' => gutenberg_get_jed_locale_data( 'amp' ), // @todo POT file. 'ampValidityRestField' => self::VALIDITY_REST_FIELD_NAME, + 'isCanonical' => amp_is_canonical(), ) ); wp_add_inline_script( $slug, sprintf( 'ampBlockValidation.boot( %s );', $data ) ); } diff --git a/tests/test-amp-helper-functions.php b/tests/test-amp-helper-functions.php index 9d3e5a096a7..5df1ddaa730 100644 --- a/tests/test-amp-helper-functions.php +++ b/tests/test-amp-helper-functions.php @@ -267,21 +267,47 @@ public function get_amphtml_urls() { * @param string $amphtml_url The amphtml URL. */ public function test_amp_add_amphtml_link( $canonical_url, $amphtml_url ) { + $test = $this; // For PHP 5.3. + + $get_amp_html_link = function() { + ob_start(); + amp_add_amphtml_link(); + return ob_get_clean(); + }; + + $assert_amphtml_link_present = function() use ( $test, $amphtml_url, $get_amp_html_link ) { + $test->assertEquals( + sprintf( '', esc_url( $amphtml_url ) ), + $get_amp_html_link() + ); + }; + $this->go_to( $canonical_url ); - ob_start(); - amp_add_amphtml_link(); - $output = ob_get_clean(); - $this->assertEquals( - sprintf( '', esc_url( $amphtml_url ) ), - $output - ); + $assert_amphtml_link_present(); // Make sure adding the filter hides the amphtml link. add_filter( 'amp_frontend_show_canonical', '__return_false' ); - ob_start(); - amp_add_amphtml_link(); - $output = ob_get_clean(); - $this->assertEmpty( $output ); + $this->assertEmpty( $get_amp_html_link() ); + remove_filter( 'amp_frontend_show_canonical', '__return_false' ); + $assert_amphtml_link_present(); + + // Make sure that the link is not provided when there are validation errors associated with the URL. + add_theme_support( 'amp', array( + 'template_dir' => './', + ) ); + AMP_Theme_Support::init(); + $invalid_url_post_id = AMP_Invalid_URL_Post_Type::store_validation_errors( + array( + array( 'code' => 'foo' ), + ), + $canonical_url + ); + $this->assertNotInstanceOf( 'WP_Error', $invalid_url_post_id ); + + // Allow the URL when the errors are forcibly sanitized. + $this->assertContains( '