diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 4718918448c..35e5b16cc1e 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -524,6 +524,10 @@ function amp_get_content_sanitizers( $post = null ) { */ $sanitizers = apply_filters( 'amp_content_sanitizers', array( + 'AMP_Core_Theme_Sanitizer' => array( + 'template' => get_template(), + 'stylesheet' => get_stylesheet(), + ), 'AMP_Img_Sanitizer' => array(), 'AMP_Form_Sanitizer' => array(), 'AMP_Comments_Sanitizer' => array(), diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index 416b8e42081..1ad252863eb 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -82,6 +82,7 @@ class AMP_Autoloader { 'AMP_Style_Sanitizer' => 'includes/sanitizers/class-amp-style-sanitizer', 'AMP_Tag_And_Attribute_Sanitizer' => 'includes/sanitizers/class-amp-tag-and-attribute-sanitizer', 'AMP_Video_Sanitizer' => 'includes/sanitizers/class-amp-video-sanitizer', + 'AMP_Core_Theme_Sanitizer' => 'includes/sanitizers/class-amp-core-theme-sanitizer', 'AMP_Customizer_Design_Settings' => 'includes/settings/class-amp-customizer-design-settings', 'AMP_Customizer_Settings' => 'includes/settings/class-amp-customizer-settings', 'AMP_Content' => 'includes/templates/class-amp-content', diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 2ecb8ebc9dc..5097844e350 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -159,6 +159,12 @@ public static function finish_init() { self::$sanitizer_classes = amp_get_content_sanitizers(); self::$sanitizer_classes = AMP_Validation_Manager::filter_sanitizer_args( self::$sanitizer_classes ); self::$embed_handlers = self::register_content_embed_handlers(); + + foreach ( self::$sanitizer_classes as $sanitizer_class => $args ) { + if ( method_exists( $sanitizer_class, 'add_buffering_hooks' ) ) { + call_user_func( array( $sanitizer_class, 'add_buffering_hooks' ), $args ); + } + } } /** @@ -303,6 +309,10 @@ public static function add_hooks() { add_action( 'comment_form', array( __CLASS__, 'amend_comment_form' ), 100 ); remove_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ); add_filter( 'wp_kses_allowed_html', array( __CLASS__, 'whitelist_layout_in_wp_kses_allowed_html' ), 10 ); + add_filter( 'get_header_image_tag', array( __CLASS__, 'conditionally_output_header' ), 10, 3 ); + add_action( 'wp_enqueue_scripts', function() { + wp_dequeue_script( 'comment-reply' ); // Handled largely by AMP_Comments_Sanitizer and *reply* methods in this class. + } ); // @todo Add character conversion. } @@ -1264,4 +1274,89 @@ public static function enqueue_assets() { // Enqueue default styles expected by sanitizer. wp_enqueue_style( 'amp-default', amp_get_asset_url( 'css/amp-default.css' ), array(), AMP__VERSION ); } + + /** + * Conditionally replace the header image markup with a header video or image. + * + * This is JS-driven in Core themes like Twenty Sixteen and Twenty Seventeen. + * So in order for the header video to display, + * this replaces the markup of the header image. + * + * @since 1.0 + * @link https://github.com/WordPress/wordpress-develop/blob/d002fde80e5e3a083e5f950313163f566561517f/src/wp-includes/js/wp-custom-header.js#L54 + * @param string $html The image markup to filter. + * @param array $header The header config array. + * @param array $atts The image markup attributes. + * @return string $html Filtered markup. + */ + public static function conditionally_output_header( $html, $header, $atts ) { + unset( $header ); + if ( ! is_header_video_active() ) { + return $html; + }; + + if ( ! has_header_video() ) { + return AMP_HTML_Utils::build_tag( 'amp-img', $atts ); + } + + return self::output_header_video( $atts ); + } + + /** + * Replace the header image markup with a header video. + * + * @since 1.0 + * @link https://github.com/WordPress/wordpress-develop/blob/d002fde80e5e3a083e5f950313163f566561517f/src/wp-includes/js/wp-custom-header.js#L54 + * @link https://github.com/WordPress/wordpress-develop/blob/d002fde80e5e3a083e5f950313163f566561517f/src/wp-includes/js/wp-custom-header.js#L78 + * + * @param array $atts The header tag attributes array. + * @return string $html Filtered markup. + */ + public static function output_header_video( $atts ) { + // Remove the script for video. + wp_deregister_script( 'wp-custom-header' ); + $video_settings = get_header_video_settings(); + + $parsed_url = wp_parse_url( $video_settings['videoUrl'] ); + $query = isset( $parsed_url['query'] ) ? wp_parse_args( $parsed_url['query'] ) : null; + $video_attributes = array( + 'media' => '(min-width: ' . $video_settings['minWidth'] . 'px)', + 'width' => $video_settings['width'], + 'height' => $video_settings['height'], + 'layout' => 'responsive', + 'autoplay' => '', + 'id' => 'wp-custom-header-video', + ); + + // Create image banner to stay behind the video. + $image_header = AMP_HTML_Utils::build_tag( 'amp-img', $atts ); + + // If the video URL is for YouTube, return an element. + if ( isset( $parsed_url['host'], $query['v'] ) && ( false !== strpos( $parsed_url['host'], 'youtube' ) ) ) { + $video_header = AMP_HTML_Utils::build_tag( + 'amp-youtube', + array_merge( + $video_attributes, + array( + 'data-videoid' => $query['v'], + 'data-param-rel' => '0', // Don't show related videos. + 'data-param-showinfo' => '0', // Don't show video title at the top. + 'data-param-controls' => '0', // Don't show video controls. + ) + ) + ); + } else { + $video_header = AMP_HTML_Utils::build_tag( + 'amp-video', + array_merge( + $video_attributes, + array( + 'src' => $video_settings['videoUrl'], + ) + ) + ); + } + + return $image_header . $video_header; + } } diff --git a/includes/sanitizers/class-amp-base-sanitizer.php b/includes/sanitizers/class-amp-base-sanitizer.php index 71aaec157a6..55e42505339 100644 --- a/includes/sanitizers/class-amp-base-sanitizer.php +++ b/includes/sanitizers/class-amp-base-sanitizer.php @@ -121,6 +121,22 @@ public function __construct( $dom, $args = array() ) { } } + /** + * Add filters to manipulate output during output buffering before the DOM is constructed. + * + * Add actions and filters before the page is rendered so that the sanitizer can fix issues during output buffering. + * This provides an alternative to manipulating the DOM in the sanitize method. This is a static function because + * it is invoked before the class is instantiated, as the DOM is not available yet. This method is only called + * when 'amp' theme support is present. It is conceptually similar to the AMP_Base_Embed_Handler class's register_embed + * method. + * + * @since 1.0 + * @see \AMP_Base_Embed_Handler::register_embed() + * + * @param array $args Args. + */ + public static function add_buffering_hooks( $args = array() ) {} + /** * Get mapping of HTML selectors to the AMP component selectors which they may be converted into. * diff --git a/includes/sanitizers/class-amp-core-theme-sanitizer.php b/includes/sanitizers/class-amp-core-theme-sanitizer.php new file mode 100644 index 00000000000..b07854bb1a7 --- /dev/null +++ b/includes/sanitizers/class-amp-core-theme-sanitizer.php @@ -0,0 +1,943 @@ + array( + // @todo Try to implement belowEntryMetaClass(). + 'dequeue_scripts' => array( + 'twentyseventeen-html5', // Only relevant for IE<9. + 'twentyseventeen-global', // There are somethings not yet implemented in AMP. See todos below. + 'jquery-scrollto', // Implemented via add_smooth_scrolling(). + 'twentyseventeen-navigation', // Handled by add_nav_menu_styles, add_nav_menu_toggle, add_nav_sub_menu_buttons. + 'twentyseventeen-skip-link-focus-fix', // Only needed by IE11 and when admin bar is present. + ), + 'remove_actions' => array( + 'wp_head' => array( + 'twentyseventeen_javascript_detection', // AMP is essentially no-js, with any interactively added explicitly via amp-bind. + ), + ), + 'force_svg_support' => array(), + 'force_fixed_background_support' => array(), + 'add_twentyseventeen_masthead_styles' => array(), + 'add_twentyseventeen_sticky_nav_menu' => array(), + 'add_has_header_video_body_class' => array(), + 'add_nav_menu_styles' => array(), + 'add_nav_menu_toggle' => array(), + 'add_nav_sub_menu_buttons' => array(), + 'add_smooth_scrolling' => array( + '//header[@id = "masthead"]//a[ contains( @class, "menu-scroll-down" ) ]', + ), + 'set_twentyseventeen_quotes_icon' => array(), + ), + + // Twenty Sixteen. + 'twentysixteen' => array( + // @todo Figure out an AMP solution for onResizeARIA(). + // @todo Try to implement belowEntryMetaClass(). + 'dequeue_scripts' => array( + 'twentysixteen-script', + 'twentysixteen-html5', // Only relevant for IE<9. + 'twentysixteen-keyboard-image-navigation', // AMP does not yet allow for listening to keydown events. + 'twentysixteen-skip-link-focus-fix', // Only needed by IE11 and when admin bar is present. + ), + 'remove_actions' => array( + 'wp_head' => array( + 'twentysixteen_javascript_detection', // AMP is essentially no-js, with any interactively added explicitly via amp-bind. + ), + ), + 'add_nav_menu_styles' => array(), + 'add_nav_menu_toggle' => array(), + 'add_nav_sub_menu_buttons' => array(), + ), + + // Twenty Fifteen. + 'twentyfifteen' => array( + // @todo Figure out an AMP solution for onResizeARIA(). + 'dequeue_scripts' => array( + 'twentyfifteen-script', + 'twentyfifteen-keyboard-image-navigation', // AMP does not yet allow for listening to keydown events. + 'twentyfifteen-skip-link-focus-fix', // Only needed by IE11 and when admin bar is present. + ), + 'remove_actions' => array( + 'wp_head' => array( + 'twentyfifteen_javascript_detection', // AMP is essentially no-js, with any interactively added explicitly via amp-bind. + ), + ), + 'add_nav_menu_styles' => array(), + 'add_nav_menu_toggle' => array(), + 'add_nav_sub_menu_buttons' => array(), + ), + ); + + /** + * Get the acceptable validation errors. + * + * @since 1.0 + * + * @param string $template Template. + * @return array Acceptable errors. + */ + public static function get_acceptable_errors( $template ) { + switch ( $template ) { + case 'twentyfifteen': + return array( + 'removed_unused_css_rules' => true, + 'illegal_css_at_rule' => array( + array( + 'at_rule' => 'viewport', + 'node_attributes' => array( + 'id' => 'twentyfifteen-style-css', + ), + ), + array( + 'at_rule' => '-ms-viewport', + 'node_attributes' => array( + 'id' => 'twentyfifteen-style-css', + ), + ), + ), + 'invalid_element' => array( + array( + 'node_name' => 'meta', + 'parent_name' => 'head', + 'node_attributes' => array( + 'name' => 'viewport', + 'content' => 'width=device-width', + ), + ), + ), + ); + case 'twentysixteen': + return array( + 'removed_unused_css_rules' => true, + 'illegal_css_at_rule' => array( + array( + 'at_rule' => 'viewport', + 'node_attributes' => array( + 'id' => 'twentysixteen-style-css', + ), + ), + array( + 'at_rule' => '-ms-viewport', + 'node_attributes' => array( + 'id' => 'twentysixteen-style-css', + ), + ), + ), + 'invalid_element' => array( + array( + 'node_name' => 'meta', + 'parent_name' => 'head', + 'node_attributes' => array( + 'name' => 'viewport', + 'content' => 'width=device-width, initial-scale=1', + ), + ), + ), + ); + case 'twentyseventeen': + return array( + 'removed_unused_css_rules' => true, + 'invalid_element' => array( + array( + 'node_name' => 'meta', + 'parent_name' => 'head', + 'node_attributes' => array( + 'name' => 'viewport', + 'content' => 'width=device-width, initial-scale=1', + ), + ), + ), + ); + } + return array(); + } + + /** + * Get theme config. + * + * @since 1.0 + * + * @param string $theme Theme slug. + * @return array Class names. + */ + protected static function get_theme_config( $theme ) { + // phpcs:disable WordPress.WP.I18n.TextDomainMismatch + $config = array( + 'sub_menu_button_class' => 'dropdown-toggle', + ); + switch ( $theme ) { + case 'twentyfifteen': + return array_merge( + $config, + array( + 'nav_container_id' => 'secondary', + 'nav_container_toggle_class' => 'toggled-on', + 'menu_button_class' => 'secondary-toggle', + 'menu_button_xpath' => '//header[ @id = "masthead" ]//button[ contains( @class, "secondary-toggle" ) ]', + 'menu_button_toggle_class' => 'toggled-on', + 'sub_menu_button_toggle_class' => 'toggle-on', + 'expand_text ' => __( 'expand child menu', 'twentyfifteen' ), + 'collapse_text' => __( 'collapse child menu', 'twentyfifteen' ), + ) + ); + + case 'twentysixteen': + return array_merge( + $config, + array( + 'nav_container_id' => 'site-header-menu', + 'nav_container_toggle_class' => 'toggled-on', + 'menu_button_class' => 'menu-toggle', + 'menu_button_xpath' => '//header[@id = "masthead"]//button[ @id = "menu-toggle" ]', + 'menu_button_toggle_class' => 'toggled-on', + 'sub_menu_button_toggle_class' => 'toggled-on', + 'expand_text ' => __( 'expand child menu', 'twentysixteen' ), + 'collapse_text' => __( 'collapse child menu', 'twentysixteen' ), + ) + ); + + case 'twentyseventeen': + default: + return array_merge( + $config, + array( + 'nav_container_id' => 'site-navigation', + 'nav_container_toggle_class' => 'toggled-on', + 'menu_button_class' => 'menu-toggle', + 'menu_button_xpath' => '//nav[@id = "site-navigation"]//button[ contains( @class, "menu-toggle" ) ]', + 'menu_button_toggle_class' => 'toggled-on', + 'sub_menu_button_toggle_class' => 'toggled-on', + 'expand_text ' => __( 'expand child menu', 'twentyseventeen' ), + 'collapse_text' => __( 'collapse child menu', 'twentyseventeen' ), + ) + ); + } + // phpcs:enable WordPress.WP.I18n.TextDomainMismatch + } + + /** + * Find theme features for core theme. + * + * @since 1.0 + * + * @param array $args Args. + * @param bool $static Static. that is, whether should run during output buffering. + * @return array Theme features. + */ + protected static function get_theme_features( $args, $static = false ) { + $theme_features = array(); + $theme_candidates = wp_array_slice_assoc( $args, array( 'stylesheet', 'template' ) ); + foreach ( $theme_candidates as $theme_candidate ) { + if ( isset( self::$theme_features[ $theme_candidate ] ) ) { + $theme_features = self::$theme_features[ $theme_candidate ]; + break; + } + } + + // Allow specific theme features to be requested even if the theme is not in core. + if ( isset( $args['theme_features'] ) ) { + $theme_features = array_merge( $args['theme_features'], $theme_features ); + } + + $final_theme_features = array(); + foreach ( $theme_features as $theme_feature => $feature_args ) { + if ( ! method_exists( __CLASS__, $theme_feature ) ) { + continue; + } + try { + $reflection = new ReflectionMethod( __CLASS__, $theme_feature ); + if ( $reflection->isStatic() === $static ) { + $final_theme_features[ $theme_feature ] = $feature_args; + } + } catch ( Exception $e ) { + unset( $e ); + } + } + return $final_theme_features; + } + + /** + * Add filters to manipulate output during output buffering before the DOM is constructed. + * + * @since 1.0 + * + * @param array $args Args. + */ + public static function add_buffering_hooks( $args = array() ) { + $theme_features = self::get_theme_features( $args, true ); + foreach ( $theme_features as $theme_feature => $feature_args ) { + if ( method_exists( __CLASS__, $theme_feature ) ) { + call_user_func( array( __CLASS__, $theme_feature ), $feature_args ); + } + } + } + + /** + * Add filter to output the quote icons in front of the article content. + * + * This is only used in Twenty Seventeen. + * + * @since 1.0 + * @link https://github.com/WordPress/wordpress-develop/blob/f4580c122b7d0d2d66d22f806c6fe6e11023c6f0/src/wp-content/themes/twentyseventeen/assets/js/global.js#L105-L108 + */ + public static function set_twentyseventeen_quotes_icon() { + add_filter( 'the_content', function ( $content ) { + + // Why isn't Twenty Seventeen doing this to begin with? Why is it using JS to add the quote icon? + if ( function_exists( 'twentyseventeen_get_svg' ) && 'quote' === get_post_format() ) { + $icon = twentyseventeen_get_svg( array( 'icon' => 'quote-right' ) ); + $content = preg_replace( '#()#s', '$1' . $icon, $content ); + } + + return $content; + } ); + } + + /** + * Fix up core themes to do things in the AMP way. + * + * @since 1.0 + */ + public function sanitize() { + $this->body = $this->dom->getElementsByTagName( 'body' )->item( 0 ); + if ( ! $this->body ) { + return; + } + + $this->xpath = new DOMXPath( $this->dom ); + + $theme_features = self::get_theme_features( $this->args, false ); + foreach ( $theme_features as $theme_feature => $feature_args ) { + if ( method_exists( $this, $theme_feature ) ) { + call_user_func( array( $this, $theme_feature ), $feature_args ); + } + } + } + + /** + * Dequeue scripts. + * + * @since 1.0 + * + * @param string[] $handles Handles, where each item value is the script handle. + */ + public static function dequeue_scripts( $handles = array() ) { + add_action( 'wp_enqueue_scripts', function() use ( $handles ) { + foreach ( $handles as $handle ) { + wp_dequeue_script( $handle ); + } + }, PHP_INT_MAX ); + } + + /** + * Remove actions. + * + * @since 1.0 + * + * @param array $actions Actions, with action name as key and value being callback. + */ + public static function remove_actions( $actions = array() ) { + foreach ( $actions as $action => $callbacks ) { + foreach ( $callbacks as $callback ) { + $priority = has_action( $action, $callback ); + if ( false !== $priority ) { + remove_action( $action, $callback, $priority ); + } + } + } + } + + /** + * Add smooth scrolling from link to target element. + * + * @since 1.0 + * + * @param string[] $link_xpaths XPath queries to the links that should smooth scroll. + */ + public function add_smooth_scrolling( $link_xpaths ) { + foreach ( $link_xpaths as $link_xpath ) { + foreach ( $this->xpath->query( $link_xpath ) as $link ) { + if ( $link instanceof DOMElement && preg_match( '/#(.+)/', $link->getAttribute( 'href' ), $matches ) ) { + $link->setAttribute( 'on', sprintf( 'tap:%s.scrollTo(duration=600)', $matches[1] ) ); + } + } + } + } + + /** + * Force SVG support, replacing no-svg class name with svg class name. + * + * @since 1.0 + * + * @link https://github.com/WordPress/wordpress-develop/blob/1af1f65a21a1a697fb5f33027497f9e5ae638453/src/wp-content/themes/twentyseventeen/assets/js/global.js#L211-L213 + * @link https://caniuse.com/#feat=svg + */ + public function force_svg_support() { + $this->dom->documentElement->setAttribute( + 'class', + preg_replace( + '/(^|\s)no-svg(\s|$)/', + ' svg ', + $this->dom->documentElement->getAttribute( 'class' ) + ) + ); + } + + /** + * Force support for fixed background-attachment. + * + * @since 1.0 + * + * @link https://github.com/WordPress/wordpress-develop/blob/1af1f65a21a1a697fb5f33027497f9e5ae638453/src/wp-content/themes/twentyseventeen/assets/js/global.js#L215-L217 + * @link https://caniuse.com/#feat=background-attachment + */ + public function force_fixed_background_support() { + $this->dom->documentElement->setAttribute( + 'class', + $this->dom->documentElement->getAttribute( 'class' ) . ' background-fixed' + ); + } + + /** + * Add body class when there is a header video. + * + * @since 1.0 + * @link https://github.com/WordPress/wordpress-develop/blob/a26c24226c6b131a0ed22c722a836c100d3ba254/src/wp-content/themes/twentyseventeen/assets/js/global.js#L244-L247 + * + * @param array $args Args. + */ + public static function add_has_header_video_body_class( $args = array() ) { + $args = array_merge( + array( + 'class_name' => 'has-header-video', + ), + $args + ); + + add_filter( 'body_class', function( $body_classes ) use ( $args ) { + if ( has_header_video() ) { + $body_classes[] = $args['class_name']; + } + return $body_classes; + } ); + } + + /** + * Add required styles for video and image headers. + * + * This is currently used exclusively for Twenty Seventeen. + * + * @since 1.0 + * @link https://github.com/WordPress/wordpress-develop/blob/1af1f65a21a1a697fb5f33027497f9e5ae638453/src/wp-content/themes/twentyseventeen/style.css#L1687 + * @link https://github.com/WordPress/wordpress-develop/blob/1af1f65a21a1a697fb5f33027497f9e5ae638453/src/wp-content/themes/twentyseventeen/style.css#L1743 + */ + public static function add_twentyseventeen_masthead_styles() { + $args = self::get_theme_config( get_template() ); + + /* + * The following is necessary because the styles in the theme apply to img and video, + * and the CSS parser will then convert the selectors to amp-img and amp-video respectively. + * Nevertheless, object-fit does not apply on amp-img and it needs to apply on an actual img. + */ + add_action( 'wp_enqueue_scripts', function() use ( $args ) { + $is_front_page_layout = ( is_front_page() && 'posts' !== get_option( 'show_on_front' ) ) || ( is_home() && is_front_page() ); + ob_start(); + ?> + + ', '' ), '', ob_get_clean() ); + wp_add_inline_style( get_template() . '-style', $styles ); + }, 11 ); + } + + /** + * Add sticky nav menu to Twenty Seventeen. + * + * This is implemented by cloning the navigation-top element, giving it a fixed position outside of the viewport, + * and then showing it at the top of the window as soon as the original nav begins to get scrolled out of view. + * In order to improve accessibility, the cloned nav gets aria-hidden=true and all of the links get tabindex=-1 + * to prevent the keyboard from focusing on elements off the screen; it is not necessary to focus on the elements + * in the fixed nav menu because as soon as the original nav menu is focused then the window is scrolled to the + * top anyway. + * + * @since 1.0 + */ + public function add_twentyseventeen_sticky_nav_menu() { + /** + * Elements. + * + * @var DOMElement $link + * @var DOMElement $navigation_top + * @var DOMElement $navigation_top_fixed + */ + $navigation_top = $this->xpath->query( '//header[ @id = "masthead" ]//div[ contains( @class, "navigation-top" ) ]' )->item( 0 ); + if ( ! $navigation_top ) { + return; + } + + $navigation_top_fixed = $navigation_top->cloneNode( true ); + $navigation_top_fixed->setAttribute( 'class', $navigation_top_fixed->getAttribute( 'class' ) . ' site-navigation-fixed' ); + + $navigation_top_fixed->setAttribute( 'aria-hidden', 'true' ); + foreach ( $navigation_top_fixed->getElementsByTagName( 'a' ) as $link ) { + $link->setAttribute( 'tabindex', '-1' ); + } + + $navigation_top->parentNode->insertBefore( $navigation_top_fixed, $navigation_top->nextSibling ); + + $position_observer = AMP_DOM_Utils::create_node( $this->dom, 'amp-position-observer', array( + 'layout' => 'nodisplay', + 'intersection-ratios' => 1, + 'on' => implode( ';', array( + 'exit:navigationTopShow.start', + 'enter:navigationTopHide.start', + ) ), + ) ); + $navigation_top->appendChild( $position_observer ); + + $animations = array( + 'navigationTopShow' => array( + 'duration' => 0, + 'fill' => 'both', + 'animations' => array( + 'selector' => '.navigation-top.site-navigation-fixed', + 'media' => '(min-width: 48em)', + 'keyframes' => array( + 'opacity' => 1.0, + 'transform' => 'translateY( 0 )', + ), + ), + ), + 'navigationTopHide' => array( + 'duration' => 0, + 'fill' => 'both', + 'animations' => array( + 'selector' => '.navigation-top.site-navigation-fixed', + 'media' => '(min-width: 48em)', + 'keyframes' => array( + 'opacity' => 0.0, + 'transform' => 'translateY( -72px )', + ), + ), + ), + ); + + foreach ( $animations as $animation_id => $animation ) { + $amp_animation = AMP_DOM_Utils::create_node( $this->dom, 'amp-animation', array( + 'id' => $animation_id, + 'layout' => 'nodisplay', + ) ); + $position_script = $this->dom->createElement( 'script' ); + $position_script->setAttribute( 'type', 'application/json' ); + $position_script->appendChild( $this->dom->createTextNode( wp_json_encode( $animation ) ) ); + $amp_animation->appendChild( $position_script ); + $this->body->appendChild( $amp_animation ); + } + } + + /** + * Add styles for the nav menu specifically to deal with AMP running in a no-js context. + * + * @since 1.0 + * + * @param array $args Args. + */ + public static function add_nav_menu_styles( $args = array() ) { + $args = array_merge( + self::get_theme_config( get_template() ), + $args + ); + + add_action( 'wp_enqueue_scripts', function() use ( $args ) { + ob_start(); + ?> + + ', '' ), '', ob_get_clean() ); + wp_add_inline_style( get_template() . '-style', $styles ); + }, 11 ); + } + + /** + * Ensure that JS-only nav menu styles apply to AMP as well since even though scripts are not allowed, there are AMP-bind implementations. + * + * @since 1.0 + * + * @param array $args Args. + */ + public function add_nav_menu_toggle( $args = array() ) { + $args = array_merge( + self::get_theme_config( get_template() ), + $args + ); + + $nav_el = $this->dom->getElementById( $args['nav_container_id'] ); + if ( ! $nav_el ) { + return; + } + + $button_el = $this->xpath->query( $args['menu_button_xpath'] )->item( 0 ); + if ( ! $button_el ) { + return; + } + + $state_id = 'navMenuToggledOn'; + $expanded = false; + + $nav_el->setAttribute( + AMP_DOM_Utils::get_amp_bind_placeholder_prefix() . 'class', + sprintf( + "%s + ( $state_id ? %s : '' )", + wp_json_encode( $nav_el->getAttribute( 'class' ) ), + wp_json_encode( ' ' . $args['nav_container_toggle_class'] ) + ) + ); + + $state_el = $this->dom->createElement( 'amp-state' ); + $state_el->setAttribute( 'id', $state_id ); + $script_el = $this->dom->createElement( 'script' ); + $script_el->setAttribute( 'type', 'application/json' ); + $script_el->appendChild( $this->dom->createTextNode( wp_json_encode( $expanded ) ) ); + $state_el->appendChild( $script_el ); + $nav_el->parentNode->insertBefore( $state_el, $nav_el ); + + $button_on = sprintf( "tap:AMP.setState({ $state_id: ! $state_id })" ); + $button_el->setAttribute( 'on', $button_on ); + $button_el->setAttribute( 'aria-expanded', 'false' ); + $button_el->setAttribute( AMP_DOM_Utils::get_amp_bind_placeholder_prefix() . 'aria-expanded', "$state_id ? 'true' : 'false'" ); + $button_el->setAttribute( + AMP_DOM_Utils::get_amp_bind_placeholder_prefix() . 'class', + sprintf( "%s + ( $state_id ? %s : '' )", wp_json_encode( $button_el->getAttribute( 'class' ) ), wp_json_encode( ' ' . $args['menu_button_toggle_class'] ) ) + ); + } + + /** + * Add buttons for nav sub-menu items. + * + * @since 1.0 + * @link https://github.com/WordPress/wordpress-develop/blob/a26c24226c6b131a0ed22c722a836c100d3ba254/src/wp-content/themes/twentyseventeen/assets/js/navigation.js#L11-L43 + * + * @param array $args Args. + */ + public static function add_nav_sub_menu_buttons( $args = array() ) { + $default_args = self::get_theme_config( get_template() ); + switch ( get_template() ) { + case 'twentyseventeen': + if ( function_exists( 'twentyseventeen_get_svg' ) ) { + $default_args['icon'] = twentyseventeen_get_svg( array( + 'icon' => 'angle-down', + 'fallback' => true, + ) ); + } + break; + } + $args = array_merge( $default_args, $args ); + + /** + * Filter the HTML output of a nav menu item to add the AMP dropdown button to reveal the sub-menu. + * + * @see twentyfifteen_amp_setup_hooks() + * + * @param string $item_output Nav menu item HTML. + * @param object $item Nav menu item. + * @return string Modified nav menu item HTML. + */ + add_filter( 'walker_nav_menu_start_el', function( $item_output, $item, $depth, $nav_menu_args ) use ( $args ) { + unset( $depth ); + + // Skip adding buttons to nav menu widgets for now. + if ( empty( $nav_menu_args->theme_location ) ) { + return $item_output; + } + + if ( ! in_array( 'menu-item-has-children', $item->classes, true ) ) { + return $item_output; + } + static $nav_menu_item_number = 0; + $nav_menu_item_number++; + + $expanded = in_array( 'current-menu-ancestor', $item->classes, true ); + + $expanded_state_id = 'navMenuItemExpanded' . $nav_menu_item_number; + + // Create new state for managing storing the whether the sub-menu is expanded. + $item_output .= sprintf( + '', + esc_attr( $expanded_state_id ), + wp_json_encode( $expanded ) + ); + + $dropdown_button = '%s', + esc_attr( sprintf( "$expanded_state_id ? %s : %s", wp_json_encode( $args['collapse_text'] ), wp_json_encode( $args['expand_text'] ) ) ), + esc_html( $expanded ? $args['collapse_text'] : $args['expand_text'] ) + ); + } + + $dropdown_button .= ''; + + $item_output .= $dropdown_button; + return $item_output; + }, 10, 4 ); + } +} diff --git a/includes/sanitizers/class-amp-video-sanitizer.php b/includes/sanitizers/class-amp-video-sanitizer.php index b61c85343fb..668dfed9064 100644 --- a/includes/sanitizers/class-amp-video-sanitizer.php +++ b/includes/sanitizers/class-amp-video-sanitizer.php @@ -39,7 +39,7 @@ class AMP_Video_Sanitizer extends AMP_Base_Sanitizer { */ public function get_selector_conversion_mapping() { return array( - 'video' => array( 'amp-video' ), + 'video' => array( 'amp-video', 'amp-youtube' ), ); } diff --git a/includes/validation/class-amp-invalid-url-post-type.php b/includes/validation/class-amp-invalid-url-post-type.php index 8504a36ffce..aa9e9b71f1d 100644 --- a/includes/validation/class-amp-invalid-url-post-type.php +++ b/includes/validation/class-amp-invalid-url-post-type.php @@ -1192,7 +1192,7 @@ public static function print_url_as_title( $post ) { * @return string Title. */ public static function filter_the_title_in_post_list_table( $title, $post ) { - if ( get_current_screen()->base === 'edit' && get_current_screen()->post_type === self::POST_TYPE_SLUG && self::POST_TYPE_SLUG === get_post_type( $post ) ) { + if ( function_exists( 'get_current_screen' ) && get_current_screen()->base === 'edit' && get_current_screen()->post_type === self::POST_TYPE_SLUG && self::POST_TYPE_SLUG === get_post_type( $post ) ) { $title = preg_replace( '#^(\w+:)?//[^/]+#', '', $title ); } return $title; diff --git a/includes/validation/class-amp-validation-error-taxonomy.php b/includes/validation/class-amp-validation-error-taxonomy.php index 381e415ca4d..e63c36ee148 100644 --- a/includes/validation/class-amp-validation-error-taxonomy.php +++ b/includes/validation/class-amp-validation-error-taxonomy.php @@ -158,6 +158,8 @@ public static function register() { if ( is_admin() ) { self::add_admin_hooks(); } + + self::accept_validation_errors( AMP_Core_Theme_Sanitizer::get_acceptable_errors( get_template() ) ); } /** @@ -246,6 +248,58 @@ public static function get_validation_error_sanitization( $error ) { return compact( 'status', 'forced' ); } + /** + * Automatically (forcibly) accept validation errors that arise. + * + * @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. + */ + 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; + } + } + 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 ) ) { + return true; + } + } + } + return $sanitized; + }, 10, 2 ); + } + /** * Get the count of validation error terms, optionally restricted by term group (e.g. accepted or rejected). * diff --git a/phpcs.xml b/phpcs.xml index fb1137e950a..ef3a5c5b010 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -28,7 +28,7 @@ - + diff --git a/tests/test-class-amp-theme-support.php b/tests/test-class-amp-theme-support.php index 049d1012473..6a7d088fb34 100644 --- a/tests/test-class-amp-theme-support.php +++ b/tests/test-class-amp-theme-support.php @@ -41,6 +41,7 @@ public function tearDown() { parent::tearDown(); AMP_Validation_Manager::reset_validation_results(); remove_theme_support( 'amp' ); + remove_theme_support( 'custom-header' ); $_REQUEST = array(); // phpcs:ignore WordPress.CSRF.NonceVerification.NoNonceVerification $_SERVER['QUERY_STRING'] = ''; unset( $_SERVER['REQUEST_URI'] ); @@ -299,6 +300,7 @@ public function test_add_hooks() { $this->assertEquals( 10, has_filter( 'cancel_comment_reply_link', array( self::TESTED_CLASS, 'filter_cancel_comment_reply_link' ) ) ); $this->assertEquals( 100, has_action( 'comment_form', array( self::TESTED_CLASS, 'amend_comment_form' ) ) ); $this->assertFalse( has_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ) ); + $this->assertEquals( 10, has_filter( 'get_header_image_tag', array( self::TESTED_CLASS, 'conditionally_output_header' ) ) ); } /** @@ -1246,4 +1248,31 @@ public function test_whitelist_layout_in_wp_kses_allowed_html() { $image = ''; $this->assertEquals( $image, wp_kses_post( $image ) ); } + + /** + * Test AMP_Theme_Support::conditionally_output_header(). + * + * @see AMP_Theme_Support::conditionally_output_header() + */ + public function conditionally_output_header() { + $mock_image = ''; + + // If there's no theme support for 'custom-header', the callback should simply return the image. + $this->assertEquals( + $mock_image, + AMP_Theme_Support::conditionally_output_header( $mock_image ) + ); + + // If theme support is present, but there isn't a header video selected, the callback should again return the image. + add_theme_support( 'custom-header', array( + 'video' => true, + ) ); + + // There's a YouTube URL as the header video. + set_theme_mod( 'external_header_video', 'https://www.youtube.com/watch?v=a8NScvBhVnc' ); + $this->assertEquals( + '', + AMP_Theme_Support::conditionally_output_header( $mock_image ) + ); + } }