From 399c90a2998865531e8c155bd61217842b232580 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 22 Oct 2018 13:52:11 -0700 Subject: [PATCH 01/73] Add query var to isolate app shell inner/outer --- includes/class-amp-http.php | 1 + includes/class-amp-theme-support.php | 78 ++++++++++++++++++++++++++-- phpcs.xml | 2 +- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/includes/class-amp-http.php b/includes/class-amp-http.php index 81add1dc88a..6770d73ec86 100644 --- a/includes/class-amp-http.php +++ b/includes/class-amp-http.php @@ -118,6 +118,7 @@ public static function purge_amp_query_vars() { '_wp_amp_action_xhr_converted', 'amp_latest_update_time', 'amp_last_check_time', + AMP_Theme_Support::APP_SHELL_COMPONENT_QUERY_VAR, ); // Scrub input vars. diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 1c5414183d0..601da0afc3d 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -54,6 +54,13 @@ class AMP_Theme_Support { */ const CACHE_MISS_URL_OPTION = 'amp_cache_miss_url'; + /** + * Query var for requesting the inner or outer app shell. + * + * @var string + */ + const APP_SHELL_COMPONENT_QUERY_VAR = 'amp_app_shell_component'; + /** * Sanitizer classes. * @@ -168,10 +175,10 @@ public static function read_theme_support() { $args = self::get_theme_support_args(); // Validate theme support usage. - $keys = array( 'template_dir', 'comments_live_list', 'paired', 'templates_supported', 'available_callback' ); + $keys = array( 'template_dir', 'comments_live_list', 'paired', 'templates_supported', 'available_callback', 'app_shell' ); if ( count( array_diff( array_keys( $args ), $keys ) ) !== 0 ) { - _doing_it_wrong( 'add_theme_support', esc_html( sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + _doing_it_wrong( 'add_theme_support', esc_html( sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error /* translators: 1: comma-separated list of expected keys, 2: comma-separated list of actual keys */ __( 'Expected AMP theme support to keys (%1$s) but saw (%2$s)', 'amp' ), join( ', ', $keys ), @@ -179,6 +186,11 @@ public static function read_theme_support() { ) ), '1.0' ); } + if ( ! empty( $args['app_shell'] ) && empty( $args['app_shell']['content_element_id'] ) ) { + _doing_it_wrong( 'add_theme_support', esc_html__( 'Missing required content_element_id arg for app_shell in amp theme support.', 'amp' ), '1.1' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + unset( $args['app_shell'] ); + } + if ( isset( $args['available_callback'] ) ) { _doing_it_wrong( 'add_theme_support', esc_html__( 'The available_callback is deprecated when adding amp theme support in favor of declaratively setting the supported_templates.', 'amp' ), '1.0' ); } @@ -1499,6 +1511,22 @@ public static function filter_customize_partial_render( $partial ) { return $partial; } + /** + * Get the requested app shell component (either inner or outer). + * + * @return string|null + */ + public static function get_requested_app_shell_component() { + if ( ! isset( AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ] ) ) { + return null; + } + $component = AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ]; + if ( in_array( $component, array( 'inner', 'outer' ), true ) ) { + return $component; + } + return null; + } + /** * Process response to ensure AMP validity. * @@ -1533,6 +1561,13 @@ public static function prepare_response( $response, $args = array() ) { $stream_fragment = WP_Service_Worker_Navigation_Routing_Component::get_stream_fragment_query_var(); } + // Get request for shadow DOM. + $app_shell_component = null; + $theme_support_args = self::get_theme_support_args(); + if ( ! $stream_fragment && ! empty( $theme_support_args['app_shell'] ) ) { + $app_shell_component = self::get_requested_app_shell_component(); + } + $args = array_merge( array( 'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat. @@ -1548,6 +1583,7 @@ public static function prepare_response( $response, $args = array() ) { ), 'user_can_validate' => AMP_Validation_Manager::has_cap(), 'stream_fragment' => $stream_fragment, + 'app_shell_component' => $app_shell_component, ), $args ); @@ -1676,8 +1712,9 @@ public static function prepare_response( $response, $args = array() ) { ); } - $dom = AMP_DOM_Utils::get_dom( $response ); - $head = $dom->getElementsByTagName( 'head' )->item( 0 ); + $dom = AMP_DOM_Utils::get_dom( $response ); + $xpath = new DOMXPath( $dom ); + $head = $dom->getElementsByTagName( 'head' )->item( 0 ); // Remove scripts that are being added for PWA service worker streaming for restoration later. $stream_combine_script_define_element = null; @@ -1694,6 +1731,38 @@ public static function prepare_response( $response, $args = array() ) { $stream_combine_script_invoke_placeholder = $dom->getElementById( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ); } + // Remove the children of the content if requesting the outer app shell. + if ( $app_shell_component ) { + $content_xpath = sprintf( '//div[@id="%s"]', $theme_support_args['app_shell']['content_element_id'] ); + $content_element = $xpath->query( $content_xpath )->item( 0 ); + if ( ! $content_element ) { + status_header( 500 ); + return esc_html__( 'Unable to locate content_element_id.', 'amp' ); + } + if ( 'outer' === $app_shell_component ) { + while ( $content_element->firstChild ) { + $content_element->removeChild( $content_element->firstChild ); + } + } elseif ( 'inner' === $app_shell_component ) { + // @todo This should not be removing wpadminbar. + // @todo This should not be removing style elements. + $remove_siblings = function( DOMElement $node ) { + while ( $node->previousSibling ) { + $node->parentNode->removeChild( $node->previousSibling ); + } + while ( $node->nextSibling ) { + $node->parentNode->removeChild( $node->nextSibling ); + } + }; + + $node = $content_element; + do { + $remove_siblings( $node ); + $node = $node->parentNode; + } while ( $node && 'body' !== $node->nodeName ); + } + } + // Move anything after , such as Query Monitor output added at shutdown, to be moved before . $body = $dom->getElementsByTagName( 'body' )->item( 0 ); if ( $body ) { @@ -1712,7 +1781,6 @@ public static function prepare_response( $response, $args = array() ) { // Make sure scripts from the body get moved to the head. if ( isset( $head ) ) { - $xpath = new DOMXPath( $dom ); foreach ( $xpath->query( '//body//script[ @custom-element or @custom-template ]' ) as $script ) { $head->appendChild( $script->parentNode->removeChild( $script ) ); } diff --git a/phpcs.xml b/phpcs.xml index ef3a5c5b010..a01772e13aa 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -28,7 +28,7 @@ - + From 9ddac9e21889e5d2f5801d5e8996e766f475a378 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 22 Oct 2018 14:58:44 -0700 Subject: [PATCH 02/73] Preserve admin bar for inner shell requests --- includes/class-amp-theme-support.php | 69 ++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 601da0afc3d..ab23265bb66 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1527,6 +1527,54 @@ public static function get_requested_app_shell_component() { return null; } + /** + * Prepare outer app shell. + * + * @param DOMElement $content_element Content element. + */ + protected function prepare_outer_app_shell_document( DOMElement $content_element ) { + while ( $content_element->firstChild ) { + $content_element->removeChild( $content_element->firstChild ); + } + } + + /** + * Prepare inner app shell. + * + * @param DOMElement $content_element Content element. + */ + protected function prepare_inner_app_shell_document( DOMElement $content_element ) { + $dom = $content_element->ownerDocument; + $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + + $admin_bar = $dom->getElementById( 'wpadminbar' ); + if ( $admin_bar ) { + $admin_bar->parentNode->removeChild( $admin_bar ); + } + + // @todo This should not be removing style elements. + $remove_siblings = function( DOMElement $node ) { + while ( $node->previousSibling ) { + $node->parentNode->removeChild( $node->previousSibling ); + } + while ( $node->nextSibling ) { + $node->parentNode->removeChild( $node->nextSibling ); + } + }; + + $node = $content_element; + do { + $remove_siblings( $node ); + $node = $node->parentNode; + } while ( $node && $node !== $body ); + + // Restore admin bar element. + if ( $body && $admin_bar ) { + $body->appendChild( $admin_bar ); + } + } + + /** * Process response to ensure AMP validity. * @@ -1740,26 +1788,9 @@ public static function prepare_response( $response, $args = array() ) { return esc_html__( 'Unable to locate content_element_id.', 'amp' ); } if ( 'outer' === $app_shell_component ) { - while ( $content_element->firstChild ) { - $content_element->removeChild( $content_element->firstChild ); - } + self::prepare_outer_app_shell_document( $content_element ); } elseif ( 'inner' === $app_shell_component ) { - // @todo This should not be removing wpadminbar. - // @todo This should not be removing style elements. - $remove_siblings = function( DOMElement $node ) { - while ( $node->previousSibling ) { - $node->parentNode->removeChild( $node->previousSibling ); - } - while ( $node->nextSibling ) { - $node->parentNode->removeChild( $node->nextSibling ); - } - }; - - $node = $content_element; - do { - $remove_siblings( $node ); - $node = $node->parentNode; - } while ( $node && 'body' !== $node->nodeName ); + self::prepare_inner_app_shell_document( $content_element ); } } From 039ef477b26e22432c1b8c39b59b4a61bb697f3e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 22 Oct 2018 15:36:19 -0700 Subject: [PATCH 03/73] Fix builds after Gutenberg merged into core --- dev-lib | 2 +- .../test-class-amp-validation-manager.php | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/dev-lib b/dev-lib index 32430f45c03..41298476955 160000 --- a/dev-lib +++ b/dev-lib @@ -1 +1 @@ -Subproject commit 32430f45c03ce40b3755f7c2c0b03c8857154d59 +Subproject commit 412984769558627b1afe128bb505537ea4c6044d diff --git a/tests/validation/test-class-amp-validation-manager.php b/tests/validation/test-class-amp-validation-manager.php index 5f497691c99..785636ba61d 100644 --- a/tests/validation/test-class-amp-validation-manager.php +++ b/tests/validation/test-class-amp-validation-manager.php @@ -929,11 +929,21 @@ public function test_decorate_shortcode_and_filter_source() { $source_json = '{"hook":"the_content","filter":true,"sources":[{"type":"core","name":"wp-includes","function":"WP_Embed::run_shortcode"},{"type":"core","name":"wp-includes","function":"WP_Embed::autoembed"}'; if ( 9 === has_filter( 'the_content', 'do_blocks' ) ) { - $source_json .= ',{"type":"plugin","name":"gutenberg","function":"gutenberg_wpautop"},{"type":"plugin","name":"amp","function":"AMP_Validation_Manager::add_block_source_comments"},{"type":"plugin","name":"gutenberg","function":"do_blocks"},{"type":"core","name":"wp-includes","function":"wptexturize"},{"type":"core","name":"wp-includes","function":"shortcode_unautop"}'; - } else { - $source_json .= ',{"type":"core","name":"wp-includes","function":"wptexturize"},{"type":"core","name":"wp-includes","function":"wpautop"},{"type":"core","name":"wp-includes","function":"shortcode_unautop"}'; + if ( function_exists( 'gutenberg_wpautop' ) ) { + $source_json .= ',{"type":"plugin","name":"gutenberg","function":"gutenberg_wpautop"}'; + } + $source_json .= ',{"type":"plugin","name":"amp","function":"AMP_Validation_Manager::add_block_source_comments"}'; + $source_json .= sprintf( + ',{"type":"%s","name":"%s","function":"do_blocks"}', + function_exists( 'gutenberg_wpautop' ) ? 'plugin' : 'core', + function_exists( 'gutenberg_wpautop' ) ? 'gutenberg' : 'wp-includes' + ); + } + $source_json .= ',{"type":"core","name":"wp-includes","function":"wptexturize"}'; + if ( ! function_exists( 'gutenberg_wpautop' ) ) { + $source_json .= ',{"type":"core","name":"wp-includes","function":"wpautop"}'; } - $source_json .= ',{"type":"core","name":"wp-includes","function":"prepend_attachment"},{"type":"core","name":"wp-includes","function":"wp_make_content_images_responsive"},{"type":"core","name":"wp-includes","function":"capital_P_dangit"},{"type":"core","name":"wp-includes","function":"do_shortcode"},{"type":"core","name":"wp-includes","function":"convert_smilies"}]}'; + $source_json .= ',{"type":"core","name":"wp-includes","function":"shortcode_unautop"},{"type":"core","name":"wp-includes","function":"prepend_attachment"},{"type":"core","name":"wp-includes","function":"wp_make_content_images_responsive"},{"type":"core","name":"wp-includes","function":"capital_P_dangit"},{"type":"core","name":"wp-includes","function":"do_shortcode"},{"type":"core","name":"wp-includes","function":"convert_smilies"}]}'; $expected_content = implode( '', array( "", From 4e3afce27a4da6d10619a0689c664e6f5e675f4a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 23 Oct 2018 09:57:11 -0700 Subject: [PATCH 04/73] Preserve styles when isolating inner content element --- includes/class-amp-theme-support.php | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index ab23265bb66..d0ed02eeace 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1532,7 +1532,7 @@ public static function get_requested_app_shell_component() { * * @param DOMElement $content_element Content element. */ - protected function prepare_outer_app_shell_document( DOMElement $content_element ) { + protected static function prepare_outer_app_shell_document( DOMElement $content_element ) { while ( $content_element->firstChild ) { $content_element->removeChild( $content_element->firstChild ); } @@ -1543,16 +1543,35 @@ protected function prepare_outer_app_shell_document( DOMElement $content_element * * @param DOMElement $content_element Content element. */ - protected function prepare_inner_app_shell_document( DOMElement $content_element ) { + protected static function prepare_inner_app_shell_document( DOMElement $content_element ) { $dom = $content_element->ownerDocument; $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + if ( ! $body ) { + return; + } + $xpath = new DOMXPath( $dom ); + // Preserve the admin bar. $admin_bar = $dom->getElementById( 'wpadminbar' ); if ( $admin_bar ) { $admin_bar->parentNode->removeChild( $admin_bar ); } - // @todo This should not be removing style elements. + // Extract all stylesheet elements before the body gets isolated. + $style_elements = array(); + $lower_case = 'translate( %s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" )'; // In XPath 2.0 this is lower-case(). + $predicates = array( + sprintf( '( self::style and ( not( @type ) or %s = "text/css" ) )', sprintf( $lower_case, '@type' ) ), + sprintf( '( self::link and @href and %s = "stylesheet" )', sprintf( $lower_case, '@rel' ) ), + ); + foreach ( $xpath->query( './/*[ ' . implode( ' or ', $predicates ) . ' ]', $body ) as $element ) { + $style_elements[] = $element; + } + foreach ( $style_elements as $style_element ) { + $style_element->parentNode->removeChild( $style_element ); + } + + // Isolate the content element from the rest of the elements in the body. $remove_siblings = function( DOMElement $node ) { while ( $node->previousSibling ) { $node->parentNode->removeChild( $node->previousSibling ); @@ -1572,6 +1591,11 @@ protected function prepare_inner_app_shell_document( DOMElement $content_element if ( $body && $admin_bar ) { $body->appendChild( $admin_bar ); } + + // Restore style elements. + foreach ( $style_elements as $style_element ) { + $body->appendChild( $style_element ); + } } From 46ae4fd4b2d314e2d7490286d7a278981c773201 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 23 Oct 2018 11:37:07 -0700 Subject: [PATCH 05/73] Inject amp-shadow into outer app shell document --- includes/class-amp-theme-support.php | 40 +++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index d0ed02eeace..9b125fe227a 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1295,6 +1295,7 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles $ordered_scripts = array(); $head_scripts = array(); $runtime_src = wp_scripts()->registered['amp-runtime']->src; + $shadow_src = wp_scripts()->registered['amp-shadow']->src; foreach ( $head->getElementsByTagName( 'script' ) as $script ) { // Note that prepare_response() already moved body scripts to head. $head_scripts[] = $script; } @@ -1305,6 +1306,8 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles } if ( $runtime_src === $src ) { $amp_scripts['amp-runtime'] = $script; + } elseif ( $shadow_src === $src ) { + $amp_scripts['amp-shadow'] = $script; } elseif ( $script->hasAttribute( 'custom-element' ) ) { $amp_scripts[ $script->getAttribute( 'custom-element' ) ] = $script; } elseif ( $script->hasAttribute( 'custom-template' ) ) { @@ -1350,6 +1353,13 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles 'as' => 'script', 'href' => $runtime_src, ) ); + if ( 'outer' === self::get_requested_app_shell_component() ) { + $prioritized_preloads[] = AMP_DOM_Utils::create_node( $dom, 'link', array( + 'rel' => 'preload', + 'as' => 'script', + 'href' => $shadow_src, + ) ); + } $amp_script_handles = array_keys( $amp_scripts ); foreach ( array_intersect( $render_delaying_extensions, $amp_script_handles ) as $script_handle ) { @@ -1514,12 +1524,18 @@ public static function filter_customize_partial_render( $partial ) { /** * Get the requested app shell component (either inner or outer). * - * @return string|null + * @return string|null App shell component. */ public static function get_requested_app_shell_component() { if ( ! isset( AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ] ) ) { return null; } + + $theme_support_args = self::get_theme_support_args(); + if ( empty( $theme_support_args['app_shell'] ) ) { + return null; + } + $component = AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ]; if ( in_array( $component, array( 'inner', 'outer' ), true ) ) { return $component; @@ -1634,11 +1650,7 @@ public static function prepare_response( $response, $args = array() ) { } // Get request for shadow DOM. - $app_shell_component = null; - $theme_support_args = self::get_theme_support_args(); - if ( ! $stream_fragment && ! empty( $theme_support_args['app_shell'] ) ) { - $app_shell_component = self::get_requested_app_shell_component(); - } + $app_shell_component = self::get_requested_app_shell_component(); $args = array_merge( array( @@ -1805,7 +1817,8 @@ public static function prepare_response( $response, $args = array() ) { // Remove the children of the content if requesting the outer app shell. if ( $app_shell_component ) { - $content_xpath = sprintf( '//div[@id="%s"]', $theme_support_args['app_shell']['content_element_id'] ); + $support_args = self::get_theme_support_args(); + $content_xpath = sprintf( '//div[@id="%s"]', $support_args['app_shell']['content_element_id'] ); $content_element = $xpath->query( $content_xpath )->item( 0 ); if ( ! $content_element ) { status_header( 500 ); @@ -1880,6 +1893,9 @@ public static function prepare_response( $response, $args = array() ) { wp_scripts()->registered[ $handle ]->src = $src; } } + if ( 'outer' === $app_shell_component ) { + $amp_scripts['amp-shadow'] = true; + } self::ensure_required_markup( $dom, array_keys( $amp_scripts ) ); @@ -1946,6 +1962,16 @@ public static function prepare_response( $response, $args = array() ) { $truncate_before_comment = $dom->createComment( 'AMP_TRUNCATE_RESPONSE_FOR_STREAM_BODY' ); $stream_combine_script_invoke_element->parentNode->insertBefore( $truncate_before_comment, $stream_combine_script_invoke_element ); } + } elseif ( 'outer' === $app_shell_component ) { + $script = $dom->createElement( 'script' ); + $script->appendChild( $dom->createTextNode( + ' + (window.AMP = window.AMP || []).push(function(AMP) { + console.info( "Hello AMP! AMP.attachShadowDoc", AMP.attachShadowDoc ); + }); + ' + ) ); + $head->appendChild( $script ); } $response = "\n"; From bb160a4a5ce5a9737e0e779a64502cce06f328bd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 23 Oct 2018 11:40:30 -0700 Subject: [PATCH 06/73] Prevent outer app shell doc from being served with amp attribute --- includes/class-amp-theme-support.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 9b125fe227a..b03acf85a67 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1895,6 +1895,8 @@ public static function prepare_response( $response, $args = array() ) { } if ( 'outer' === $app_shell_component ) { $amp_scripts['amp-shadow'] = true; + $dom->documentElement->removeAttribute( 'amp' ); + $dom->documentElement->removeAttribute( '⚡️' ); } self::ensure_required_markup( $dom, array_keys( $amp_scripts ) ); @@ -1905,7 +1907,7 @@ public static function prepare_response( $response, $args = array() ) { * already surfaced inside of WordPress. This is intended to not serve dirty AMP, but rather a * non-AMP document (intentionally not valid AMP) that contains the AMP runtime and AMP components. */ - if ( amp_is_canonical() ) { + if ( amp_is_canonical() || 'outer' === $app_shell_component ) { $dom->documentElement->removeAttribute( 'amp' ); $dom->documentElement->removeAttribute( '⚡️' ); From 33cc5b7f1da6b2f1241a856bfb20d7b1545b7dec Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 23 Oct 2018 13:18:27 -0700 Subject: [PATCH 07/73] Serve shadow-v0 instead of v0 as runtime in outer app shells --- includes/class-amp-theme-support.php | 28 +++++++------------ .../class-amp-tag-and-attribute-sanitizer.php | 13 +++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index b03acf85a67..69850df5755 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1294,8 +1294,8 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles $amp_scripts = array(); $ordered_scripts = array(); $head_scripts = array(); - $runtime_src = wp_scripts()->registered['amp-runtime']->src; - $shadow_src = wp_scripts()->registered['amp-shadow']->src; + $base_handle = 'outer' === self::get_requested_app_shell_component() ? 'amp-shadow' : 'amp-runtime'; + $runtime_src = wp_scripts()->registered[ $base_handle ]->src; foreach ( $head->getElementsByTagName( 'script' ) as $script ) { // Note that prepare_response() already moved body scripts to head. $head_scripts[] = $script; } @@ -1305,9 +1305,7 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles continue; } if ( $runtime_src === $src ) { - $amp_scripts['amp-runtime'] = $script; - } elseif ( $shadow_src === $src ) { - $amp_scripts['amp-shadow'] = $script; + $amp_scripts[ $base_handle ] = $script; } elseif ( $script->hasAttribute( 'custom-element' ) ) { $amp_scripts[ $script->getAttribute( 'custom-element' ) ] = $script; } elseif ( $script->hasAttribute( 'custom-template' ) ) { @@ -1353,13 +1351,6 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles 'as' => 'script', 'href' => $runtime_src, ) ); - if ( 'outer' === self::get_requested_app_shell_component() ) { - $prioritized_preloads[] = AMP_DOM_Utils::create_node( $dom, 'link', array( - 'rel' => 'preload', - 'as' => 'script', - 'href' => $shadow_src, - ) ); - } $amp_script_handles = array_keys( $amp_scripts ); foreach ( array_intersect( $render_delaying_extensions, $amp_script_handles ) as $script_handle ) { @@ -1394,8 +1385,8 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles * should not be preloaded because they might take away important bandwidth for the initial render." * {@link https://docs.google.com/document/d/169XUxtSSEJb16NfkrCr9y5lqhUR7vxXEAsNxBzg07fM/edit AMP Hosting Guide} */ - if ( isset( $amp_scripts['amp-runtime'] ) ) { - $ordered_scripts['amp-runtime'] = $amp_scripts['amp-runtime']; + if ( isset( $amp_scripts[ $base_handle ] ) ) { + $ordered_scripts[ $base_handle ] = $amp_scripts[ $base_handle ]; } foreach ( $render_delaying_extensions as $extension ) { if ( isset( $amp_scripts[ $extension ] ) ) { @@ -1893,14 +1884,15 @@ public static function prepare_response( $response, $args = array() ) { wp_scripts()->registered[ $handle ]->src = $src; } } + + self::ensure_required_markup( $dom, array_keys( $amp_scripts ) ); + + // When serving outer app shell document, prevent it from being read as a valid AMP document (since it isn't). if ( 'outer' === $app_shell_component ) { - $amp_scripts['amp-shadow'] = true; $dom->documentElement->removeAttribute( 'amp' ); $dom->documentElement->removeAttribute( '⚡️' ); } - self::ensure_required_markup( $dom, array_keys( $amp_scripts ) ); - if ( $blocking_error_count > 0 && ! AMP_Validation_Manager::should_validate_response() ) { /* * In native AMP, strip html@amp attribute to prevent GSC from complaining about a validation error @@ -2090,7 +2082,7 @@ public static function whitelist_layout_in_wp_kses_allowed_html( $context ) { * @return void */ public static function enqueue_assets() { - wp_enqueue_script( 'amp-runtime' ); + wp_enqueue_script( 'outer' === self::get_requested_app_shell_component() ? 'amp-shadow' : 'amp-runtime' ); // Enqueue default styles expected by sanitizer. wp_enqueue_style( 'amp-default', amp_get_asset_url( 'css/amp-default.css' ), array(), AMP__VERSION ); diff --git a/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php b/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php index bd9e4d4c7be..053292b7263 100644 --- a/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php +++ b/includes/sanitizers/class-amp-tag-and-attribute-sanitizer.php @@ -169,6 +169,19 @@ public function __construct( $dom, $args = array() ) { ); } + // When serving outer app shell document, replace v0.js with shadow-v0.js. + if ( isset( $args['app_shell_component'] ) && 'outer' === $args['app_shell_component'] ) { + foreach ( $this->args['amp_allowed_tags']['script'] as $script_spec ) { + if ( isset( $script_spec[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) && 'amphtml engine v0.js script' === $script_spec[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] ) { + $shadow_script_spec = $script_spec; + $shadow_script_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ]['src'] = 'https://cdn.ampproject.org/shadow-v0.js'; + $shadow_script_spec[ AMP_Rule_Spec::TAG_SPEC ]['spec_name'] = 'amphtml shadow v0.js script'; + $this->args['amp_allowed_tags']['script'][] = $shadow_script_spec; + break; + } + } + } + // Prepare whitelists. $this->allowed_tags = $this->args['amp_allowed_tags']; foreach ( AMP_Rule_Spec::$additional_allowed_tags as $tag_name => $tag_rule_spec ) { From 423bbf73b6e773d1040025e83c2e92f8f5f5cf67 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 23 Oct 2018 22:53:40 -0700 Subject: [PATCH 08/73] Populate shadow root with content for given URL to test --- .jshintignore | 3 ++- assets/js/amp-app-shell.js | 35 +++++++++++++++++++++++++++ includes/class-amp-theme-support.php | 36 +++++++++++++++++----------- 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 assets/js/amp-app-shell.js diff --git a/.jshintignore b/.jshintignore index 327a34adf5e..2e1c722085c 100644 --- a/.jshintignore +++ b/.jshintignore @@ -1,4 +1,5 @@ **/*.min.js **/node_modules/** **/vendor/** -**/assets/js/*-compiled.js \ No newline at end of file +**/assets/js/*-compiled.js +**/assets/js/amp-app-shell.js diff --git a/assets/js/amp-app-shell.js b/assets/js/amp-app-shell.js new file mode 100644 index 00000000000..ea866d42d06 --- /dev/null +++ b/assets/js/amp-app-shell.js @@ -0,0 +1,35 @@ +/* global SHADOW_ROOT_SELECTOR, Promise */ +/* eslint-disable no-console */ + +( window.AMP = window.AMP || [] ).push( ( AMP ) => { + const currentUrl = new URL( location.href ); + + console.info( "Called AMP Shadow callback! AMP.attachShadowDoc", AMP.attachShadowDoc ); + + const fetchDocument = ( url ) => { + // unfortunately fetch() does not support retrieving documents, + // so we have to resort to good old XMLHttpRequest. + const xhr = new XMLHttpRequest(); + + // @todo Handle reject. + return new Promise( ( resolve ) => { + xhr.open( 'GET', url, true ); + xhr.responseType = 'document'; + xhr.setRequestHeader( 'Accept', 'text/html' ); + xhr.onload = () => { + resolve( xhr.responseXML ); + }; + xhr.send(); + } ); + }; + + if ( parseInt( currentUrl.searchParams.get( 'amp_shadow_doc_populate' ) ) ) { + currentUrl.searchParams.set( 'amp_app_shell_component', 'inner' ); + const container = document.querySelector( SHADOW_ROOT_SELECTOR ); + + fetchDocument( currentUrl ).then( ( doc ) => { + const shadowDoc = AMP.attachShadowDoc( container, doc, currentUrl ); + console.info( 'Shadow doc:', shadowDoc ); + } ); + } +} ); diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 69850df5755..af98edf25d3 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -186,8 +186,8 @@ public static function read_theme_support() { ) ), '1.0' ); } - if ( ! empty( $args['app_shell'] ) && empty( $args['app_shell']['content_element_id'] ) ) { - _doing_it_wrong( 'add_theme_support', esc_html__( 'Missing required content_element_id arg for app_shell in amp theme support.', 'amp' ), '1.1' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + if ( ! empty( $args['app_shell'] ) && empty( $args['app_shell']['shadow_root_xpath'] ) ) { + _doing_it_wrong( 'add_theme_support', esc_html__( 'Missing required shadow_root_xpath arg for app_shell in amp theme support.', 'amp' ), '1.1' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error unset( $args['app_shell'] ); } @@ -1807,13 +1807,14 @@ public static function prepare_response( $response, $args = array() ) { } // Remove the children of the content if requesting the outer app shell. + $content_element = null; if ( $app_shell_component ) { $support_args = self::get_theme_support_args(); - $content_xpath = sprintf( '//div[@id="%s"]', $support_args['app_shell']['content_element_id'] ); + $content_xpath = $support_args['app_shell']['shadow_root_xpath']; $content_element = $xpath->query( $content_xpath )->item( 0 ); if ( ! $content_element ) { status_header( 500 ); - return esc_html__( 'Unable to locate content_element_id.', 'amp' ); + return esc_html__( 'Unable to locate shadow_root_xpath.', 'amp' ); } if ( 'outer' === $app_shell_component ) { self::prepare_outer_app_shell_document( $content_element ); @@ -1956,16 +1957,23 @@ public static function prepare_response( $response, $args = array() ) { $truncate_before_comment = $dom->createComment( 'AMP_TRUNCATE_RESPONSE_FOR_STREAM_BODY' ); $stream_combine_script_invoke_element->parentNode->insertBefore( $truncate_before_comment, $stream_combine_script_invoke_element ); } - } elseif ( 'outer' === $app_shell_component ) { - $script = $dom->createElement( 'script' ); - $script->appendChild( $dom->createTextNode( - ' - (window.AMP = window.AMP || []).push(function(AMP) { - console.info( "Hello AMP! AMP.attachShadowDoc", AMP.attachShadowDoc ); - }); - ' - ) ); - $head->appendChild( $script ); + } elseif ( 'outer' === $app_shell_component && $content_element ) { + $script = $dom->createElement( 'script' ); + $source = file_get_contents( AMP__DIR__ . '/assets/js/amp-app-shell.js' ); // phpcs:ignore + $source = preg_replace( '#/\*\s*global.+?\*/#', '', $source ); + $selector = $content_element->nodeName; + if ( $content_element->getAttribute( 'id' ) ) { + $selector .= '#' . $content_element->getAttribute( 'id' ); + } + if ( $content_element->getAttribute( 'class' ) ) { + $classes = array_filter( preg_split( '/\s+/', trim( $content_element->getAttribute( 'class' ) ) ) ); + foreach ( $classes as $class ) { + $selector .= '.' . $class; + } + } + $source = str_replace( 'SHADOW_ROOT_SELECTOR', wp_json_encode( $selector ), $source ); + $script->appendChild( $dom->createTextNode( $source ) ); + $content_element->parentNode->insertBefore( $script, $content_element->nextSibling ); } $response = "\n"; From 01e6a5006653615173ef1c6973a586ca75d32e59 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 23 Oct 2018 23:29:02 -0700 Subject: [PATCH 09/73] Switch back to using ID for identifying content container --- assets/js/amp-app-shell.js | 6 +++--- includes/class-amp-theme-support.php | 28 +++++++++------------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/assets/js/amp-app-shell.js b/assets/js/amp-app-shell.js index ea866d42d06..6044a5f4b44 100644 --- a/assets/js/amp-app-shell.js +++ b/assets/js/amp-app-shell.js @@ -1,10 +1,10 @@ -/* global SHADOW_ROOT_SELECTOR, Promise */ +/* global CONTENT_ELEMENT_ID, Promise */ /* eslint-disable no-console */ ( window.AMP = window.AMP || [] ).push( ( AMP ) => { const currentUrl = new URL( location.href ); - console.info( "Called AMP Shadow callback! AMP.attachShadowDoc", AMP.attachShadowDoc ); + console.info( "Called AMP Shadow callback! AMP.attachShadowDoc:", AMP.attachShadowDoc ); const fetchDocument = ( url ) => { // unfortunately fetch() does not support retrieving documents, @@ -25,7 +25,7 @@ if ( parseInt( currentUrl.searchParams.get( 'amp_shadow_doc_populate' ) ) ) { currentUrl.searchParams.set( 'amp_app_shell_component', 'inner' ); - const container = document.querySelector( SHADOW_ROOT_SELECTOR ); + const container = document.getElementById( CONTENT_ELEMENT_ID ); fetchDocument( currentUrl ).then( ( doc ) => { const shadowDoc = AMP.attachShadowDoc( container, doc, currentUrl ); diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index af98edf25d3..6c492f93ebd 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -186,8 +186,8 @@ public static function read_theme_support() { ) ), '1.0' ); } - if ( ! empty( $args['app_shell'] ) && empty( $args['app_shell']['shadow_root_xpath'] ) ) { - _doing_it_wrong( 'add_theme_support', esc_html__( 'Missing required shadow_root_xpath arg for app_shell in amp theme support.', 'amp' ), '1.1' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + if ( ! empty( $args['app_shell'] ) && empty( $args['app_shell']['content_element_id'] ) ) { + _doing_it_wrong( 'add_theme_support', esc_html__( 'Missing required content_element_id arg for app_shell in amp theme support.', 'amp' ), '1.1' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error unset( $args['app_shell'] ); } @@ -1810,11 +1810,10 @@ public static function prepare_response( $response, $args = array() ) { $content_element = null; if ( $app_shell_component ) { $support_args = self::get_theme_support_args(); - $content_xpath = $support_args['app_shell']['shadow_root_xpath']; - $content_element = $xpath->query( $content_xpath )->item( 0 ); + $content_element = $dom->getElementById( $support_args['app_shell']['content_element_id'] ); if ( ! $content_element ) { status_header( 500 ); - return esc_html__( 'Unable to locate shadow_root_xpath.', 'amp' ); + return esc_html__( 'Unable to locate content_element_id.', 'amp' ); } if ( 'outer' === $app_shell_component ) { self::prepare_outer_app_shell_document( $content_element ); @@ -1958,20 +1957,11 @@ public static function prepare_response( $response, $args = array() ) { $stream_combine_script_invoke_element->parentNode->insertBefore( $truncate_before_comment, $stream_combine_script_invoke_element ); } } elseif ( 'outer' === $app_shell_component && $content_element ) { - $script = $dom->createElement( 'script' ); - $source = file_get_contents( AMP__DIR__ . '/assets/js/amp-app-shell.js' ); // phpcs:ignore - $source = preg_replace( '#/\*\s*global.+?\*/#', '', $source ); - $selector = $content_element->nodeName; - if ( $content_element->getAttribute( 'id' ) ) { - $selector .= '#' . $content_element->getAttribute( 'id' ); - } - if ( $content_element->getAttribute( 'class' ) ) { - $classes = array_filter( preg_split( '/\s+/', trim( $content_element->getAttribute( 'class' ) ) ) ); - foreach ( $classes as $class ) { - $selector .= '.' . $class; - } - } - $source = str_replace( 'SHADOW_ROOT_SELECTOR', wp_json_encode( $selector ), $source ); + $script = $dom->createElement( 'script' ); + // @todo Consider loading external async script. + $source = file_get_contents( AMP__DIR__ . '/assets/js/amp-app-shell.js' ); // phpcs:ignore + $source = preg_replace( '#/\*\s*global.+?\*/#', '', $source ); + $source = str_replace( 'CONTENT_ELEMENT_ID', wp_json_encode( $content_element->getAttribute( 'id' ) ), $source ); $script->appendChild( $dom->createTextNode( $source ) ); $content_element->parentNode->insertBefore( $script, $content_element->nextSibling ); } From 5a0d6449a26d00d8c812c0ab2dbd3d9bd3e316ad Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 24 Oct 2018 11:59:54 -0700 Subject: [PATCH 10/73] Add workaround for :root pesudo selectors in shadow documents --- .../sanitizers/class-amp-style-sanitizer.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/includes/sanitizers/class-amp-style-sanitizer.php b/includes/sanitizers/class-amp-style-sanitizer.php index 63dc8ae02f6..adcb642d5e4 100644 --- a/includes/sanitizers/class-amp-style-sanitizer.php +++ b/includes/sanitizers/class-amp-style-sanitizer.php @@ -79,6 +79,7 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { 'should_locate_sources' => false, 'parsed_cache_variant' => null, 'accept_tree_shaking' => false, + 'app_shell_component' => null, ); /** @@ -2009,6 +2010,8 @@ private function finalize_stylesheet_set( $stylesheet_set ) { && 'sometimes' === $stylesheet_set['remove_unused_rules'] ) + || + 'inner' === $this->args['app_shell_component'] ); if ( $is_too_much_css && $should_tree_shake && empty( $this->args['accept_tree_shaking'] ) ) { @@ -2060,6 +2063,19 @@ private function finalize_stylesheet_set( $stylesheet_set ) { ) ); if ( $should_include ) { + // Make changes for serving stylesheet inside shadow DOM. + if ( 'inner' === $this->args['app_shell_component'] ) { + /* + * The :root pseudo selector does not work inside shadow DOM. Additionally, + * the shadow DOM is not including the root html element (or the head element), + * however there is a body element. The AMP plugin uses :root in the transformation + * of !important rules to give selectors high specificity. Replacing :root with + * body will not work all of the time. + * @todo The use of :root pseudo selectors in stylesheets needs to be revisited in Shadow DOM. + */ + $selector = preg_replace( '/:root\b/', 'body', $selector ); + } + $selectors[] = $selector; } } From ba76bbf33717e752241e5ab428d6e6c2ffef428f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 24 Oct 2018 13:10:06 -0700 Subject: [PATCH 11/73] Preserve SVG defs when isolating inner content --- includes/class-amp-theme-support.php | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 6c492f93ebd..cda54f392f3 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1578,6 +1578,12 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ $style_element->parentNode->removeChild( $style_element ); } + // Preserve all svg defs which aren't inside the content element. + $svgs_with_def = array(); + foreach ( $xpath->query( '//svg[.//defs]' ) as $svg ) { + $svgs_with_def[] = $svg; + } + // Isolate the content element from the rest of the elements in the body. $remove_siblings = function( DOMElement $node ) { while ( $node->previousSibling ) { @@ -1603,6 +1609,44 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ foreach ( $style_elements as $style_element ) { $body->appendChild( $style_element ); } + + // Restore SVGs with defs. + foreach ( $svgs_with_def as $svg ) { + /* + * Check if the node was removed from the document. + * This is needed because Node.compareDocumentPosition() is not available in PHP. + */ + $is_connected = false; + $node = $svg; + while ( $node->parentNode ) { + if ( $node === $svg->ownerDocument ) { + $is_connected = true; + break; + } + $node = $node->parentNode; + } + + // Re-add the SVG element to the body with only its defs elements. + if ( ! $is_connected ) { + $defs = array(); + foreach ( $svg->getElementsByTagName( 'defs' ) as $def ) { + $defs[] = $def; + } + + // Remove all children. + while ( $svg->firstChild ) { + $svg->removeChild( $svg->firstChild ); + } + + // Re-add all defs. + foreach ( $defs as $def ) { + $svg->appendChild( $def ); + } + + // Add to body. + $body->appendChild( $svg ); + } + } } From adb939aef299bb9f8033af958eb59068d14a1852 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 27 Oct 2018 19:59:55 -0700 Subject: [PATCH 12/73] Use template tags to demarcate app shell content --- includes/amp-helper-functions.php | 42 ++++++++++++++++++++++++++++ includes/class-amp-theme-support.php | 21 +++++++------- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 777f70b120f..2786514d284 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -944,3 +944,45 @@ function amp_wp_kses_mustache( $markup ) { $amp_mustache_allowed_html_tags = array( 'strong', 'b', 'em', 'i', 'u', 's', 'small', 'mark', 'del', 'ins', 'sup', 'sub' ); return wp_kses( $markup, array_fill_keys( $amp_mustache_allowed_html_tags, array() ) ); } + +/** + * Mark the beginning of the content that will be displayed inside the app shell. + * + * Depends on adding app_shell to the amp theme support args. + * + * @since 1.1 + */ +function amp_start_app_shell_content() { + $support_args = AMP_Theme_Support::get_theme_support_args(); + if ( ! isset( $support_args['app_shell'] ) ) { + return; + } + + printf( '
', esc_attr( AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID ) ); + + // Start output buffering if requesting outer shell, since all content will be omitted from the response. + if ( 'outer' === AMP_Theme_Support::get_requested_app_shell_component() ) { + ob_start(); + } +} + +/** + * Mark the end of the content that will be displayed inside the app shell. + * + * Depends on adding app_shell to the amp theme support args. + * + * @since 1.1 + */ +function amp_end_app_shell_content() { + $support_args = AMP_Theme_Support::get_theme_support_args(); + if ( ! isset( $support_args['app_shell'] ) ) { + return; + } + + // Clean output buffer if requesting outer shell, since all content will be omitted from the response. + if ( 'outer' === AMP_Theme_Support::get_requested_app_shell_component() ) { + ob_end_clean(); + } + + echo '
'; +} diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index cda54f392f3..4117590706c 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -61,6 +61,13 @@ class AMP_Theme_Support { */ const APP_SHELL_COMPONENT_QUERY_VAR = 'amp_app_shell_component'; + /** + * ID for element that contains the content for app shell. + * + * @var string + */ + const APP_SHELL_CONTENT_ELEMENT_ID = 'amp-app-shell-content'; + /** * Sanitizer classes. * @@ -186,11 +193,6 @@ public static function read_theme_support() { ) ), '1.0' ); } - if ( ! empty( $args['app_shell'] ) && empty( $args['app_shell']['content_element_id'] ) ) { - _doing_it_wrong( 'add_theme_support', esc_html__( 'Missing required content_element_id arg for app_shell in amp theme support.', 'amp' ), '1.1' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - unset( $args['app_shell'] ); - } - if ( isset( $args['available_callback'] ) ) { _doing_it_wrong( 'add_theme_support', esc_html__( 'The available_callback is deprecated when adding amp theme support in favor of declaratively setting the supported_templates.', 'amp' ), '1.0' ); } @@ -1523,7 +1525,7 @@ public static function get_requested_app_shell_component() { } $theme_support_args = self::get_theme_support_args(); - if ( empty( $theme_support_args['app_shell'] ) ) { + if ( ! isset( $theme_support_args['app_shell'] ) ) { return null; } @@ -1853,11 +1855,10 @@ public static function prepare_response( $response, $args = array() ) { // Remove the children of the content if requesting the outer app shell. $content_element = null; if ( $app_shell_component ) { - $support_args = self::get_theme_support_args(); - $content_element = $dom->getElementById( $support_args['app_shell']['content_element_id'] ); + $content_element = $dom->getElementById( self::APP_SHELL_CONTENT_ELEMENT_ID ); if ( ! $content_element ) { status_header( 500 ); - return esc_html__( 'Unable to locate content_element_id.', 'amp' ); + return esc_html__( 'Unable to locate APP_SHELL_CONTENT_ELEMENT_ID.', 'amp' ); } if ( 'outer' === $app_shell_component ) { self::prepare_outer_app_shell_document( $content_element ); @@ -2005,7 +2006,7 @@ public static function prepare_response( $response, $args = array() ) { // @todo Consider loading external async script. $source = file_get_contents( AMP__DIR__ . '/assets/js/amp-app-shell.js' ); // phpcs:ignore $source = preg_replace( '#/\*\s*global.+?\*/#', '', $source ); - $source = str_replace( 'CONTENT_ELEMENT_ID', wp_json_encode( $content_element->getAttribute( 'id' ) ), $source ); + $source = str_replace( 'CONTENT_ELEMENT_ID', wp_json_encode( self::APP_SHELL_CONTENT_ELEMENT_ID ), $source ); $script->appendChild( $dom->createTextNode( $source ) ); $content_element->parentNode->insertBefore( $script, $content_element->nextSibling ); } From d6a4fc29bc6dcc9b9b52c4993912e4f8ef553124 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 27 Oct 2018 20:08:39 -0700 Subject: [PATCH 13/73] Add app shell content placeholder --- includes/amp-helper-functions.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 2786514d284..fe8606e59b5 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -951,6 +951,7 @@ function amp_wp_kses_mustache( $markup ) { * Depends on adding app_shell to the amp theme support args. * * @since 1.1 + * @todo Should this take an argument for the content placeholder? */ function amp_start_app_shell_content() { $support_args = AMP_Theme_Support::get_theme_support_args(); @@ -962,6 +963,21 @@ function amp_start_app_shell_content() { // Start output buffering if requesting outer shell, since all content will be omitted from the response. if ( 'outer' === AMP_Theme_Support::get_requested_app_shell_component() ) { + + $content_placeholder = '

' . esc_html__( 'Loading…', 'amp' ) . '

'; + + /** + * Filters the content which is shown in the app shell for the content before it is loaded. + * + * This is used to display a loading message or a content skeleton. + * + * @since 1.1 + * @todo Consider using template part for this instead, or an action with a default. + * + * @param string $content_placeholder Content placeholder. + */ + echo apply_filters( 'amp_app_shell_content_placeholder', $content_placeholder ); // WPCS: XSS OK. + ob_start(); } } From 6c23a176d29d563c2fe57bac779141e2f2ac0ad1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 27 Oct 2018 20:25:41 -0700 Subject: [PATCH 14/73] Force outer app shell to be served from non-AMP version --- includes/class-amp-theme-support.php | 62 ++++++++++++---------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 4117590706c..fc7084950f8 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -238,6 +238,7 @@ public static function get_theme_support_args() { * @since 0.7 */ public static function finish_init() { + $requested_app_shell_component = self::get_requested_app_shell_component(); if ( ! is_amp_endpoint() ) { // Redirect to AMP-less variable if AMP is not available for this URL and yet the query var is present. @@ -246,9 +247,27 @@ public static function finish_init() { } amp_add_frontend_actions(); + if ( 'outer' === $requested_app_shell_component ) { + wp_enqueue_script( 'amp-shadow' ); + // @todo Enqueue script which hooks uses AMP Shadow API (assets/js/amp-app-shell.js). + // @todo Prevent showing admin bar? + } elseif ( 'inner' === $requested_app_shell_component ) { + wp_die( + esc_html__( 'Inner app shell can only be requested of the AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Inner App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } return; } + if ( 'outer' === $requested_app_shell_component ) { + wp_die( + esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Outer App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } self::ensure_proper_amp_location(); $theme_support = self::get_theme_support_args(); @@ -1296,8 +1315,8 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles $amp_scripts = array(); $ordered_scripts = array(); $head_scripts = array(); - $base_handle = 'outer' === self::get_requested_app_shell_component() ? 'amp-shadow' : 'amp-runtime'; - $runtime_src = wp_scripts()->registered[ $base_handle ]->src; + $runtime_handle = 'amp-runtime'; + $runtime_src = wp_scripts()->registered[ $runtime_handle ]->src; foreach ( $head->getElementsByTagName( 'script' ) as $script ) { // Note that prepare_response() already moved body scripts to head. $head_scripts[] = $script; } @@ -1307,7 +1326,7 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles continue; } if ( $runtime_src === $src ) { - $amp_scripts[ $base_handle ] = $script; + $amp_scripts[ $runtime_handle ] = $script; } elseif ( $script->hasAttribute( 'custom-element' ) ) { $amp_scripts[ $script->getAttribute( 'custom-element' ) ] = $script; } elseif ( $script->hasAttribute( 'custom-template' ) ) { @@ -1387,8 +1406,8 @@ public static function ensure_required_markup( DOMDocument $dom, $script_handles * should not be preloaded because they might take away important bandwidth for the initial render." * {@link https://docs.google.com/document/d/169XUxtSSEJb16NfkrCr9y5lqhUR7vxXEAsNxBzg07fM/edit AMP Hosting Guide} */ - if ( isset( $amp_scripts[ $base_handle ] ) ) { - $ordered_scripts[ $base_handle ] = $amp_scripts[ $base_handle ]; + if ( isset( $amp_scripts[ $runtime_handle ] ) ) { + $ordered_scripts[ $runtime_handle ] = $amp_scripts[ $runtime_handle ]; } foreach ( $render_delaying_extensions as $extension ) { if ( isset( $amp_scripts[ $extension ] ) ) { @@ -1536,17 +1555,6 @@ public static function get_requested_app_shell_component() { return null; } - /** - * Prepare outer app shell. - * - * @param DOMElement $content_element Content element. - */ - protected static function prepare_outer_app_shell_document( DOMElement $content_element ) { - while ( $content_element->firstChild ) { - $content_element->removeChild( $content_element->firstChild ); - } - } - /** * Prepare inner app shell. * @@ -1860,9 +1868,7 @@ public static function prepare_response( $response, $args = array() ) { status_header( 500 ); return esc_html__( 'Unable to locate APP_SHELL_CONTENT_ELEMENT_ID.', 'amp' ); } - if ( 'outer' === $app_shell_component ) { - self::prepare_outer_app_shell_document( $content_element ); - } elseif ( 'inner' === $app_shell_component ) { + if ( 'inner' === $app_shell_component ) { self::prepare_inner_app_shell_document( $content_element ); } } @@ -1932,19 +1938,13 @@ public static function prepare_response( $response, $args = array() ) { self::ensure_required_markup( $dom, array_keys( $amp_scripts ) ); - // When serving outer app shell document, prevent it from being read as a valid AMP document (since it isn't). - if ( 'outer' === $app_shell_component ) { - $dom->documentElement->removeAttribute( 'amp' ); - $dom->documentElement->removeAttribute( '⚡️' ); - } - if ( $blocking_error_count > 0 && ! AMP_Validation_Manager::should_validate_response() ) { /* * In native AMP, strip html@amp attribute to prevent GSC from complaining about a validation error * already surfaced inside of WordPress. This is intended to not serve dirty AMP, but rather a * non-AMP document (intentionally not valid AMP) that contains the AMP runtime and AMP components. */ - if ( amp_is_canonical() || 'outer' === $app_shell_component ) { + if ( amp_is_canonical() ) { $dom->documentElement->removeAttribute( 'amp' ); $dom->documentElement->removeAttribute( '⚡️' ); @@ -2001,14 +2001,6 @@ public static function prepare_response( $response, $args = array() ) { $truncate_before_comment = $dom->createComment( 'AMP_TRUNCATE_RESPONSE_FOR_STREAM_BODY' ); $stream_combine_script_invoke_element->parentNode->insertBefore( $truncate_before_comment, $stream_combine_script_invoke_element ); } - } elseif ( 'outer' === $app_shell_component && $content_element ) { - $script = $dom->createElement( 'script' ); - // @todo Consider loading external async script. - $source = file_get_contents( AMP__DIR__ . '/assets/js/amp-app-shell.js' ); // phpcs:ignore - $source = preg_replace( '#/\*\s*global.+?\*/#', '', $source ); - $source = str_replace( 'CONTENT_ELEMENT_ID', wp_json_encode( self::APP_SHELL_CONTENT_ELEMENT_ID ), $source ); - $script->appendChild( $dom->createTextNode( $source ) ); - $content_element->parentNode->insertBefore( $script, $content_element->nextSibling ); } $response = "\n"; @@ -2125,7 +2117,7 @@ public static function whitelist_layout_in_wp_kses_allowed_html( $context ) { * @return void */ public static function enqueue_assets() { - wp_enqueue_script( 'outer' === self::get_requested_app_shell_component() ? 'amp-shadow' : 'amp-runtime' ); + wp_enqueue_script( 'amp-runtime' ); // Enqueue default styles expected by sanitizer. wp_enqueue_style( 'amp-default', amp_get_asset_url( 'css/amp-default.css' ), array(), AMP__VERSION ); From 8cca74c38861805f289e19d496177ab0f31ecc85 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 27 Oct 2018 21:31:28 -0700 Subject: [PATCH 15/73] Move app shell setup logic into separate method --- includes/class-amp-theme-support.php | 66 ++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index fc7084950f8..cb3e3514f8c 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -238,7 +238,8 @@ public static function get_theme_support_args() { * @since 0.7 */ public static function finish_init() { - $requested_app_shell_component = self::get_requested_app_shell_component(); + self::init_app_shell(); + if ( ! is_amp_endpoint() ) { // Redirect to AMP-less variable if AMP is not available for this URL and yet the query var is present. @@ -247,27 +248,9 @@ public static function finish_init() { } amp_add_frontend_actions(); - if ( 'outer' === $requested_app_shell_component ) { - wp_enqueue_script( 'amp-shadow' ); - // @todo Enqueue script which hooks uses AMP Shadow API (assets/js/amp-app-shell.js). - // @todo Prevent showing admin bar? - } elseif ( 'inner' === $requested_app_shell_component ) { - wp_die( - esc_html__( 'Inner app shell can only be requested of the AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Inner App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); - } return; } - if ( 'outer' === $requested_app_shell_component ) { - wp_die( - esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Outer App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); - } self::ensure_proper_amp_location(); $theme_support = self::get_theme_support_args(); @@ -288,6 +271,51 @@ public static function finish_init() { } } + /** + * Init app shell. + * + * @since 1.1 + */ + public static function init_app_shell() { + $theme_support = self::get_theme_support_args(); + if ( ! isset( $theme_support['app_shell'] ) ) { + return; + } + + $requested_app_shell_component = self::get_requested_app_shell_component(); + + if ( ! is_amp_endpoint() ) { + + if ( 'outer' === $requested_app_shell_component ) { + wp_enqueue_script( 'amp-shadow' ); + // @todo Enqueue script which hooks uses AMP Shadow API (assets/js/amp-app-shell.js). + // @todo Prevent showing admin bar? + // @todo For non-outer + } elseif ( 'inner' === $requested_app_shell_component ) { + wp_die( + esc_html__( 'Inner app shell can only be requested of the AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Inner App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } + + // @todo Is this right? It should really be enqueued regardless. It's not about whether the current template has AMP available, but _other_ URLs. + $template_availability = self::get_template_availability(); + if ( ! empty( $template_availability['supported'] ) ) { + wp_enqueue_script( 'amp-shadow' ); + // @todo Enqueue other required scripts. + } + } else { + if ( 'outer' === $requested_app_shell_component ) { + wp_die( + esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Outer App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } + } + } + /** * Ensure that the current AMP location is correct. * From 84a27497edf9365cb43e81cf9957c50bb05e61d4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 28 Oct 2018 09:27:06 -0700 Subject: [PATCH 16/73] Enqueue amp-shadow and amp-wp-app-shell script in non-inner requests --- .eslintrc | 3 +- .../{amp-app-shell.js => amp-wp-app-shell.js} | 4 +- includes/amp-helper-functions.php | 12 +++++ includes/class-amp-theme-support.php | 51 +++++++++---------- 4 files changed, 39 insertions(+), 31 deletions(-) rename assets/js/{amp-app-shell.js => amp-wp-app-shell.js} (90%) diff --git a/.eslintrc b/.eslintrc index 157c270c5c3..e7537c9dd4b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,7 +16,8 @@ "react" ], "env": { - "browser": true + "browser": true, + "es6": true }, "globals": { "wp": true, diff --git a/assets/js/amp-app-shell.js b/assets/js/amp-wp-app-shell.js similarity index 90% rename from assets/js/amp-app-shell.js rename to assets/js/amp-wp-app-shell.js index 6044a5f4b44..cee249dd3e8 100644 --- a/assets/js/amp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -1,4 +1,4 @@ -/* global CONTENT_ELEMENT_ID, Promise */ +/* global ampWpAppShell */ /* eslint-disable no-console */ ( window.AMP = window.AMP || [] ).push( ( AMP ) => { @@ -25,7 +25,7 @@ if ( parseInt( currentUrl.searchParams.get( 'amp_shadow_doc_populate' ) ) ) { currentUrl.searchParams.set( 'amp_app_shell_component', 'inner' ); - const container = document.getElementById( CONTENT_ELEMENT_ID ); + const container = document.getElementById( ampWpAppShell.contentElementId ); fetchDocument( currentUrl ).then( ( doc ) => { const shadowDoc = AMP.attachShadowDoc( container, doc, currentUrl ); diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index fe8606e59b5..2d448b584cd 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -358,6 +358,18 @@ function amp_register_default_scripts( $wp_scripts ) { 'async' => true, ) ); + // App shell library. + $handle = 'amp-wp-app-shell'; + $wp_scripts->add( + $handle, + amp_get_asset_url( 'js/amp-wp-app-shell.js' ), + array( 'amp-shadow' ), + null + ); + $wp_scripts->add_data( $handle, 'amp_script_attributes', array( + 'async' => true, + ) ); + // Get all AMP components as defined in the spec. $extensions = array(); foreach ( AMP_Allowed_Tags_Generated::get_allowed_tag( 'script' ) as $script_spec ) { diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index cb3e3514f8c..56df253770e 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -284,35 +284,30 @@ public static function init_app_shell() { $requested_app_shell_component = self::get_requested_app_shell_component(); - if ( ! is_amp_endpoint() ) { - - if ( 'outer' === $requested_app_shell_component ) { - wp_enqueue_script( 'amp-shadow' ); - // @todo Enqueue script which hooks uses AMP Shadow API (assets/js/amp-app-shell.js). - // @todo Prevent showing admin bar? - // @todo For non-outer - } elseif ( 'inner' === $requested_app_shell_component ) { - wp_die( - esc_html__( 'Inner app shell can only be requested of the AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Inner App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); - } + // @todo Prevent showing admin bar in outer app shell? + if ( ! is_amp_endpoint() && 'inner' === $requested_app_shell_component ) { + // @todo For non-outer + wp_die( + esc_html__( 'Inner app shell can only be requested of the AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Inner App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } elseif ( is_amp_endpoint() && 'outer' === $requested_app_shell_component ) { + wp_die( + esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Outer App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } - // @todo Is this right? It should really be enqueued regardless. It's not about whether the current template has AMP available, but _other_ URLs. - $template_availability = self::get_template_availability(); - if ( ! empty( $template_availability['supported'] ) ) { - wp_enqueue_script( 'amp-shadow' ); - // @todo Enqueue other required scripts. - } - } else { - if ( 'outer' === $requested_app_shell_component ) { - wp_die( - esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Outer App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); - } + // Enqueue scripts for (outer) app shell, including precached app shell and normal site navigation prior to service worker installation. + if ( 'inner' !== $requested_app_shell_component ) { + wp_enqueue_script( 'amp-shadow' ); + wp_enqueue_script( 'amp-wp-app-shell' ); + $exports = array( + 'contentElementId' => self::APP_SHELL_CONTENT_ELEMENT_ID, + ); + wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampWpAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); } } From 09f78758e65de540ab549cb1edc553db952d2c43 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 28 Oct 2018 19:45:47 -0700 Subject: [PATCH 17/73] Load AMP into shadow doc when clicking links --- .jshintignore | 2 +- assets/js/amp-wp-app-shell.js | 125 +++++++++++++++++++++++---- includes/amp-helper-functions.php | 2 +- includes/class-amp-theme-support.php | 10 ++- 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/.jshintignore b/.jshintignore index 2e1c722085c..d4950ead8de 100644 --- a/.jshintignore +++ b/.jshintignore @@ -2,4 +2,4 @@ **/node_modules/** **/vendor/** **/assets/js/*-compiled.js -**/assets/js/amp-app-shell.js +**/assets/js/amp-wp-app-shell.js diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index cee249dd3e8..ca510385d1e 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -1,11 +1,102 @@ -/* global ampWpAppShell */ +/* global ampAppShell, AMP */ /* eslint-disable no-console */ -( window.AMP = window.AMP || [] ).push( ( AMP ) => { - const currentUrl = new URL( location.href ); +{ + let currentShadowDoc; - console.info( "Called AMP Shadow callback! AMP.attachShadowDoc:", AMP.attachShadowDoc ); + /** + * Initialize. + */ + const init = () => { + const container = document.getElementById( ampAppShell.contentElementId ); + if ( ! container ) { + throw new Error( 'Lacking element with ID: ' + ampAppShell.contentElementId ); + } + // @todo Intercept GET submissions. + // @todo Make sure that POST submissions are handled. + document.body.addEventListener( 'click', handleClick ); + + window.addEventListener( 'popstate', handlePopState ); + }; + + /** + * Is loadable URL. + * + * @param {URL} url - URL to be loaded. + * @return {boolean} Whether the URL can be loaded into a shadow doc. + */ + const isLoadableURL = ( url ) => { + if ( url.pathname.endsWith( '.php' ) ) { + return false; + } + if ( url.href.startsWith( ampAppShell.adminUrl ) ) { + return false; + } + return url.href.startsWith( ampAppShell.homeUrl ); + }; + + /** + * Handle clicks on links. + * + * @param {MouseEvent} event - Event. + */ + const handleClick = ( event ) => { + if ( ! event.target.matches( 'a[href]' ) ) { + return; + } + + // @todo Handle page anchor links. + const url = new URL( event.target.href ); + if ( ! isLoadableURL( url ) ) { + return; + } + + const ampUrl = new URL( url ); + ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); + ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); + + event.preventDefault(); + fetchDocument( ampUrl ).then( + ( doc ) => { + if ( currentShadowDoc ) { + currentShadowDoc.close(); + } + + const oldContainer = document.getElementById( ampAppShell.contentElementId ); + const newContainer = document.createElement( oldContainer.nodeName ); + newContainer.setAttribute( 'id', oldContainer.getAttribute( 'id' ) ); + oldContainer.parentNode.replaceChild( newContainer, oldContainer ); + + currentShadowDoc = AMP.attachShadowDoc( newContainer, doc, url.toString() ); + + // @todo Update nav menus. + // @todo Improve styling of header when transitioning between home and non-home. + // Update body class name. + document.body.className = doc.querySelector( 'body' ).className; + document.title = currentShadowDoc.title; + history.pushState( {}, currentShadowDoc.title, currentShadowDoc.canonicalUrl ); + + currentShadowDoc.ampdoc.whenReady().then( () => { + newContainer.shadowRoot.addEventListener( 'click', handleClick ); + } ); + } + ); + }; + + /** + * Handle popstate event. + */ + const handlePopState = () => { + // @todo + }; + + /** + * Fetch document. + * + * @param {string|URL} url URL. + * @return {Promise} Promise which resolves to the fetched document. + */ const fetchDocument = ( url ) => { // unfortunately fetch() does not support retrieving documents, // so we have to resort to good old XMLHttpRequest. @@ -13,7 +104,7 @@ // @todo Handle reject. return new Promise( ( resolve ) => { - xhr.open( 'GET', url, true ); + xhr.open( 'GET', url.toString(), true ); xhr.responseType = 'document'; xhr.setRequestHeader( 'Accept', 'text/html' ); xhr.onload = () => { @@ -23,13 +114,17 @@ } ); }; - if ( parseInt( currentUrl.searchParams.get( 'amp_shadow_doc_populate' ) ) ) { - currentUrl.searchParams.set( 'amp_app_shell_component', 'inner' ); - const container = document.getElementById( ampWpAppShell.contentElementId ); - - fetchDocument( currentUrl ).then( ( doc ) => { - const shadowDoc = AMP.attachShadowDoc( container, doc, currentUrl ); - console.info( 'Shadow doc:', shadowDoc ); - } ); - } -} ); + // Initialize when Shadow API loaded and DOM Ready. + ( window.AMP = window.AMP || [] ).push( () => { + // Code from @wordpress/dom-ready NPM package . + if ( + document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. + document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly. + ) { + init(); + } else { + // DOMContentLoaded has not fired yet, delay callback until then. + document.addEventListener( 'DOMContentLoaded', init ); + } + } ); +} diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 2d448b584cd..19c7effeaf0 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -364,7 +364,7 @@ function amp_register_default_scripts( $wp_scripts ) { $handle, amp_get_asset_url( 'js/amp-wp-app-shell.js' ), array( 'amp-shadow' ), - null + AMP__VERSION ); $wp_scripts->add_data( $handle, 'amp_script_attributes', array( 'async' => true, diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 56df253770e..105403e784d 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -304,10 +304,16 @@ public static function init_app_shell() { if ( 'inner' !== $requested_app_shell_component ) { wp_enqueue_script( 'amp-shadow' ); wp_enqueue_script( 'amp-wp-app-shell' ); + + // @todo The exports will eventually need to vary the precached app shell. $exports = array( - 'contentElementId' => self::APP_SHELL_CONTENT_ELEMENT_ID, + 'contentElementId' => self::APP_SHELL_CONTENT_ELEMENT_ID, + 'homeUrl' => home_url( '/' ), + 'adminUrl' => admin_url( '/' ), + 'ampQueryVar' => amp_get_slug(), + 'componentQueryVar' => self::APP_SHELL_COMPONENT_QUERY_VAR, ); - wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampWpAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); + wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); } } From 79aeeb1275523983486b14e5e1a3b63fa1a38ea3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 28 Oct 2018 20:12:23 -0700 Subject: [PATCH 18/73] Preserve purged query vars in canonical redirects --- amp.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/amp.php b/amp.php index a5d8c593d23..d1db7b812fd 100644 --- a/amp.php +++ b/amp.php @@ -509,6 +509,7 @@ function _amp_bootstrap_customizer() { * Redirects the old AMP URL to the new AMP URL. * * If post slug is updated the amp page with old post slug will be redirected to the updated url. + * Also includes all original query vars. * * @since 0.5 * @deprecated This function is irrelevant when 'amp' theme support is added. @@ -524,6 +525,9 @@ function amp_redirect_old_slug_to_new_url( $link ) { } else { $link = trailingslashit( trailingslashit( $link ) . amp_get_slug() ); } + if ( ! empty( AMP_HTTP::$purged_amp_query_vars ) ) { + $link = add_query_arg( AMP_HTTP::$purged_amp_query_vars, $link ); + } } return $link; From df6ad6a5fc6cd566671935b23812942f39f3a363 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 28 Oct 2018 20:59:40 -0700 Subject: [PATCH 19/73] Add handling of GET form submissions --- assets/js/amp-wp-app-shell.js | 63 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index ca510385d1e..033c6ab3b90 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -16,6 +16,7 @@ // @todo Intercept GET submissions. // @todo Make sure that POST submissions are handled. document.body.addEventListener( 'click', handleClick ); + document.body.addEventListener( 'submit', handleSubmit ); window.addEventListener( 'popstate', handlePopState ); }; @@ -52,11 +53,54 @@ return; } + event.preventDefault(); + loadUrl( url ); + }; + + /** + * Handle popstate event. + */ + const handlePopState = () => { + // @todo + }; + + /** + * Handle submit on forms. + * + * @param {Event} event - Event. + */ + const handleSubmit = ( event ) => { + if ( ! event.target.matches( 'form[action]' ) || event.target.method.toUpperCase() !== 'GET' ) { + return; + } + + const url = new URL( event.target.action ); + if ( ! isLoadableURL( url ) ) { + return; + } + + event.preventDefault(); + + for ( const element of event.target.elements ) { + if ( element.name && ! element.disabled ) { + // @todo Need to handle radios, checkboxes, submit buttons, etc. + url.searchParams.set( element.name, element.value ); + } + } + loadUrl( url ); + }; + + /** + * Load URL. + * + * @todo When should scroll to the top? Only if the first element of the content is not visible? + * @param {string|URL} url - URL. + */ + const loadUrl = ( url, { scrollIntoView = false } = {} ) => { const ampUrl = new URL( url ); ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); - event.preventDefault(); fetchDocument( ampUrl ).then( ( doc ) => { if ( currentShadowDoc ) { @@ -72,6 +116,7 @@ // @todo Update nav menus. // @todo Improve styling of header when transitioning between home and non-home. + // @todo Synchronize additional meta in head. // Update body class name. document.body.className = doc.querySelector( 'body' ).className; document.title = currentShadowDoc.title; @@ -79,18 +124,20 @@ currentShadowDoc.ampdoc.whenReady().then( () => { newContainer.shadowRoot.addEventListener( 'click', handleClick ); + newContainer.shadowRoot.addEventListener( 'submit', handleSubmit ); + + if ( scrollIntoView ) { + document.body.scrollIntoView( { + block: 'start', + inline: 'start', + behavior: 'smooth' + } ); + } } ); } ); }; - /** - * Handle popstate event. - */ - const handlePopState = () => { - // @todo - }; - /** * Fetch document. * From 1d1e05f057a65bd3f5585fabf08fbabf6edaa6a8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 28 Oct 2018 21:56:17 -0700 Subject: [PATCH 20/73] Upate nav menu class names based on current URL --- assets/js/amp-wp-app-shell.js | 107 +++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 033c6ab3b90..953c6e89f1e 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -97,6 +97,8 @@ * @param {string|URL} url - URL. */ const loadUrl = ( url, { scrollIntoView = false } = {} ) => { + updateNavMenuClasses( url ); + const ampUrl = new URL( url ); ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); @@ -114,7 +116,6 @@ currentShadowDoc = AMP.attachShadowDoc( newContainer, doc, url.toString() ); - // @todo Update nav menus. // @todo Improve styling of header when transitioning between home and non-home. // @todo Synchronize additional meta in head. // Update body class name. @@ -122,6 +123,11 @@ document.title = currentShadowDoc.title; history.pushState( {}, currentShadowDoc.title, currentShadowDoc.canonicalUrl ); + // Update the nav menu classes if the final URL has redirected somewhere else. + if ( currentShadowDoc.canonicalUrl !== url.toString() ) { + updateNavMenuClasses( currentShadowDoc.canonicalUrl ); + } + currentShadowDoc.ampdoc.whenReady().then( () => { newContainer.shadowRoot.addEventListener( 'click', handleClick ); newContainer.shadowRoot.addEventListener( 'submit', handleSubmit ); @@ -138,6 +144,105 @@ ); }; + /** + * Update class names in nav menus based on what URL is being navigated to. + * + * Note that this will only be able to account for: + * - current-menu-item (current_{object}_item) + * - current-menu-parent (current_{object}_parent) + * - current-menu-ancestor (current_{object}_ancestor) + * + * @param {string|URL} url URL. + */ + const updateNavMenuClasses = ( url ) => { + const queriedUrl = new URL( url ); + queriedUrl.hash = ''; + + // Remove all contextual class names. + for ( const relation of [ 'item', 'parent', 'ancestor' ] ) { + const pattern = new RegExp( '^current[_-](.+)[_-]' + relation + '$' ); + for ( const item of document.querySelectorAll( '.menu-item.current-menu-' + relation + ', .page_item.current_page_' + relation ) ) { // Non-live NodeList. + for ( const className of Array.from( item.classList ) ) { // Live DOMTokenList. + if ( pattern.test( className ) ) { + item.classList.remove( className ); + } + } + } + } + + // Re-add class names to items generated from nav menus. + for ( const link of document.querySelectorAll( '.menu-item > a[href]' ) ) { + if ( link.href !== url.href ) { + continue; + } + + let menuItemObjectName; + const menuItemObjectNamePrefix = 'menu-item-object-'; + for ( const className of link.parentElement.classList ) { + if ( className.startsWith( menuItemObjectNamePrefix ) ) { + menuItemObjectName = className.substr( menuItemObjectNamePrefix.length ); + break; + } + } + + let depth = 0; + let item = link.parentElement; + while ( item ) { + if ( 0 === depth ) { + item.classList.add( 'current-menu-item' ); + if ( menuItemObjectName ) { + link.parentElement.classList.add( `current_${menuItemObjectName}_item` ); + } + } else if ( 1 === depth ) { + item.classList.add( 'current-menu-parent' ); + item.classList.add( 'current-menu-ancestor' ); + if ( menuItemObjectName ) { + link.parentElement.classList.add( `current_${menuItemObjectName}_parent` ); + link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); + } + } else { + item.classList.add( 'current-menu-ancestor' ); + if ( menuItemObjectName ) { + link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); + } + } + depth++; + + if ( ! item.parentElement ) { + break; + } + item = item.parentElement.closest( '.menu-item-has-children' ); + } + + link.parentElement.classList.add( 'current-menu-item' ); + } + + // Re-add class names to items generated from page listings. + for ( const link of document.querySelectorAll( '.page_item > a[href]' ) ) { + if ( link.href !== url.href ) { + continue; + } + let depth = 0; + let item = link.parentElement; + while ( item ) { + if ( 0 === depth ) { + item.classList.add( 'current_page_item' ); + } else if ( 1 === depth ) { + item.classList.add( 'current_page_parent' ); + item.classList.add( 'current_page_ancestor' ); + } else { + item.classList.add( 'current_page_ancestor' ); + } + depth++; + + if ( ! item.parentElement ) { + break; + } + item = item.parentElement.closest( '.page_item_has_children' ); + } + } + }; + /** * Fetch document. * From 2b1bd18377845c6c2bd0d9867895a0ebf9434ad3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 29 Oct 2018 10:47:09 -0700 Subject: [PATCH 21/73] Prevent finishing initialization if not on frontend --- includes/class-amp-theme-support.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 105403e784d..ea279d31f78 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -153,7 +153,9 @@ public static function init() { * the response at this action and then short-circuit with exit. So this is why the the preceding * action to template_redirect--the wp action--is used instead. */ - add_action( 'wp', array( __CLASS__, 'finish_init' ), PHP_INT_MAX ); + if ( ! is_admin() ) { + add_action( 'wp', array( __CLASS__, 'finish_init' ), PHP_INT_MAX ); + } } /** From ab9afc15994d41eb3cd7e5ca577d7f2d01e0b546 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Nov 2018 11:10:24 -0700 Subject: [PATCH 22/73] Let admin bar from shadow doc take over initial app shell admin bar --- assets/js/amp-wp-app-shell.js | 43 ++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 953c6e89f1e..90d5ac00d62 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -7,7 +7,7 @@ /** * Initialize. */ - const init = () => { + function init() { const container = document.getElementById( ampAppShell.contentElementId ); if ( ! container ) { throw new Error( 'Lacking element with ID: ' + ampAppShell.contentElementId ); @@ -19,7 +19,7 @@ document.body.addEventListener( 'submit', handleSubmit ); window.addEventListener( 'popstate', handlePopState ); - }; + } /** * Is loadable URL. @@ -27,7 +27,7 @@ * @param {URL} url - URL to be loaded. * @return {boolean} Whether the URL can be loaded into a shadow doc. */ - const isLoadableURL = ( url ) => { + function isLoadableURL( url ) { if ( url.pathname.endsWith( '.php' ) ) { return false; } @@ -35,14 +35,14 @@ return false; } return url.href.startsWith( ampAppShell.homeUrl ); - }; + } /** * Handle clicks on links. * * @param {MouseEvent} event - Event. */ - const handleClick = ( event ) => { + function handleClick( event ) { if ( ! event.target.matches( 'a[href]' ) ) { return; } @@ -55,21 +55,21 @@ event.preventDefault(); loadUrl( url ); - }; + } /** * Handle popstate event. */ - const handlePopState = () => { + function handlePopState() { // @todo - }; + } /** * Handle submit on forms. * * @param {Event} event - Event. */ - const handleSubmit = ( event ) => { + function handleSubmit( event ) { if ( ! event.target.matches( 'form[action]' ) || event.target.method.toUpperCase() !== 'GET' ) { return; } @@ -88,15 +88,16 @@ } } loadUrl( url ); - }; + } /** * Load URL. * * @todo When should scroll to the top? Only if the first element of the content is not visible? * @param {string|URL} url - URL. + * @param {boolean} scrollIntoView - Scroll into view. */ - const loadUrl = ( url, { scrollIntoView = false } = {} ) => { + function loadUrl( url, { scrollIntoView = false } = {} ) { updateNavMenuClasses( url ); const ampUrl = new URL( url ); @@ -132,6 +133,16 @@ newContainer.shadowRoot.addEventListener( 'click', handleClick ); newContainer.shadowRoot.addEventListener( 'submit', handleSubmit ); + /* + * Let admin bar in shadow doc replace admin bar in app shell (if it still exists). + * Very conveniently the admin bar _inside_ the shadow root can appear _outside_ + * the shadow root via fixed positioning! + */ + const originalAdminBar = document.getElementById( 'wpadminbar' ); + if ( originalAdminBar ) { + originalAdminBar.remove(); + } + if ( scrollIntoView ) { document.body.scrollIntoView( { block: 'start', @@ -142,7 +153,7 @@ } ); } ); - }; + } /** * Update class names in nav menus based on what URL is being navigated to. @@ -154,7 +165,7 @@ * * @param {string|URL} url URL. */ - const updateNavMenuClasses = ( url ) => { + function updateNavMenuClasses( url ) { const queriedUrl = new URL( url ); queriedUrl.hash = ''; @@ -241,7 +252,7 @@ item = item.parentElement.closest( '.page_item_has_children' ); } } - }; + } /** * Fetch document. @@ -249,7 +260,7 @@ * @param {string|URL} url URL. * @return {Promise} Promise which resolves to the fetched document. */ - const fetchDocument = ( url ) => { + function fetchDocument( url ) { // unfortunately fetch() does not support retrieving documents, // so we have to resort to good old XMLHttpRequest. const xhr = new XMLHttpRequest(); @@ -264,7 +275,7 @@ }; xhr.send(); } ); - }; + } // Initialize when Shadow API loaded and DOM Ready. ( window.AMP = window.AMP || [] ).push( () => { From dc26b2202e8a2dfe2f283ef5805d80988fadd783 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Nov 2018 11:13:40 -0700 Subject: [PATCH 23/73] Prevent event default at end of event handler --- assets/js/amp-wp-app-shell.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 90d5ac00d62..356384d5625 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -53,8 +53,8 @@ return; } - event.preventDefault(); loadUrl( url ); + event.preventDefault(); } /** @@ -79,8 +79,6 @@ return; } - event.preventDefault(); - for ( const element of event.target.elements ) { if ( element.name && ! element.disabled ) { // @todo Need to handle radios, checkboxes, submit buttons, etc. @@ -88,6 +86,7 @@ } } loadUrl( url ); + event.preventDefault(); } /** From 9349440513c231d4b236f8da22e4418f104d42ea Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Nov 2018 15:22:42 -0700 Subject: [PATCH 24/73] Prevent enqueueing app shell JS in an AMP page --- includes/class-amp-theme-support.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index ea279d31f78..812a88ba74c 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -303,7 +303,7 @@ public static function init_app_shell() { } // Enqueue scripts for (outer) app shell, including precached app shell and normal site navigation prior to service worker installation. - if ( 'inner' !== $requested_app_shell_component ) { + if ( ! is_amp_endpoint() && 'inner' !== $requested_app_shell_component ) { wp_enqueue_script( 'amp-shadow' ); wp_enqueue_script( 'amp-wp-app-shell' ); From 4dc6b1d4da3c68a8cfcf249a877bb02327bb6250 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Nov 2018 15:25:26 -0700 Subject: [PATCH 25/73] Prevent intercepting clicks and form submits in admin bar --- assets/js/amp-wp-app-shell.js | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 356384d5625..c80ccc165f2 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -21,6 +21,15 @@ window.addEventListener( 'popstate', handlePopState ); } + /** + * Handle popstate event. + * + * @param {Event} event - Event. + */ + function handlePopState( event ) { + console.info( 'popstate', event ); + } + /** * Is loadable URL. * @@ -43,7 +52,12 @@ * @param {MouseEvent} event - Event. */ function handleClick( event ) { - if ( ! event.target.matches( 'a[href]' ) ) { + if ( ! event.target.matches( 'a[href]' ) || event.target.closest( '#wpadminbar' ) ) { + return; + } + + // Skip handling click if it was handled already. + if ( event.defaultPrevented ) { return; } @@ -57,20 +71,20 @@ event.preventDefault(); } - /** - * Handle popstate event. - */ - function handlePopState() { - // @todo - } - /** * Handle submit on forms. * + * @todo Handle POST requests. + * * @param {Event} event - Event. */ function handleSubmit( event ) { - if ( ! event.target.matches( 'form[action]' ) || event.target.method.toUpperCase() !== 'GET' ) { + if ( ! event.target.matches( 'form[action]' ) || event.target.method.toUpperCase() !== 'GET' || event.target.closest( '#wpadminbar' ) ) { + return; + } + + // Skip handling click if it was handled already. + if ( event.defaultPrevented ) { return; } @@ -121,7 +135,11 @@ // Update body class name. document.body.className = doc.querySelector( 'body' ).className; document.title = currentShadowDoc.title; - history.pushState( {}, currentShadowDoc.title, currentShadowDoc.canonicalUrl ); + history.pushState( + {}, // @todo Add current scroll position? + currentShadowDoc.title, + currentShadowDoc.canonicalUrl + ); // Update the nav menu classes if the final URL has redirected somewhere else. if ( currentShadowDoc.canonicalUrl !== url.toString() ) { From 50d97027fef5d5188c4d22098e89f9827ceabda0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Nov 2018 16:43:17 -0700 Subject: [PATCH 26/73] Prevent loading non-AMP documents into the shadow DOM --- assets/js/amp-wp-app-shell.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index c80ccc165f2..7d673e936cb 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -113,12 +113,8 @@ function loadUrl( url, { scrollIntoView = false } = {} ) { updateNavMenuClasses( url ); - const ampUrl = new URL( url ); - ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); - ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); - - fetchDocument( ampUrl ).then( - ( doc ) => { + fetchDocument( url ) + .then( ( doc ) => { if ( currentShadowDoc ) { currentShadowDoc.close(); } @@ -168,6 +164,9 @@ } ); } } ); + } ) + .catch( () => { + window.location.assign( url ); } ); } @@ -278,18 +277,29 @@ * @return {Promise} Promise which resolves to the fetched document. */ function fetchDocument( url ) { + + const ampUrl = new URL( url ); + ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); + ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); + // unfortunately fetch() does not support retrieving documents, // so we have to resort to good old XMLHttpRequest. const xhr = new XMLHttpRequest(); // @todo Handle reject. - return new Promise( ( resolve ) => { - xhr.open( 'GET', url.toString(), true ); + return new Promise( ( resolve, reject ) => { + xhr.open( 'GET', ampUrl.toString(), true ); xhr.responseType = 'document'; xhr.setRequestHeader( 'Accept', 'text/html' ); xhr.onload = () => { - resolve( xhr.responseXML ); + // @todo Before getting to this point, catch for redirecting to the non-AMP version. + if ( ! xhr.responseXML.documentElement.hasAttribute( 'amp' ) && ! xhr.responseXML.documentElement.hasAttribute( '⚡️' ) ) { + reject(); + } else { + resolve( xhr.responseXML ); + } }; + xhr.onerror = reject; xhr.send(); } ); } From 1a6cace1f21233d7168d5e673a0497a739d70400 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Nov 2018 17:49:47 -0700 Subject: [PATCH 27/73] Improve XHR integration --- assets/js/amp-wp-app-shell.js | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 7d673e936cb..b2e6400d5b5 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -165,8 +165,12 @@ } } ); } ) - .catch( () => { - window.location.assign( url ); + .catch( ( error ) => { + if ( 'amp_unavailable' === error ) { + window.location.assign( url ); + } else { + console.error( error ); + } } ); } @@ -286,20 +290,30 @@ // so we have to resort to good old XMLHttpRequest. const xhr = new XMLHttpRequest(); - // @todo Handle reject. return new Promise( ( resolve, reject ) => { + /* + * It would be ideal if the XHR would not follow redirects automatically so that if a redirect to a URL + * without the 'amp' query var happens, then we could skip having to waste CPU to construct the responseXML + * document. But XHR does not support this, while fetch does: . + * @todo Consider using fetch() and then construct the DOM with DOMImplementation.createHTMLDocument(). + */ xhr.open( 'GET', ampUrl.toString(), true ); xhr.responseType = 'document'; xhr.setRequestHeader( 'Accept', 'text/html' ); + xhr.onload = () => { - // @todo Before getting to this point, catch for redirecting to the non-AMP version. - if ( ! xhr.responseXML.documentElement.hasAttribute( 'amp' ) && ! xhr.responseXML.documentElement.hasAttribute( '⚡️' ) ) { - reject(); - } else { + if ( ! xhr.responseXML ) { + reject( 'no_response' ); + } else if ( xhr.responseXML.documentElement.hasAttribute( 'amp' ) || xhr.responseXML.documentElement.hasAttribute( '⚡️' ) ) { resolve( xhr.responseXML ); + } else { + reject( 'amp_unavailable' ); } }; - xhr.onerror = reject; + // @todo What about abort and timeout events? + xhr.onerror = () => { + reject( 'xhr_error' ); + }; xhr.send(); } ); } From d952e5e21a0542e1c71eedd53c2fa3e399a08f41 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Nov 2018 10:59:19 -0700 Subject: [PATCH 28/73] Add popstate support and improve condition for scrolling --- assets/js/amp-wp-app-shell.js | 38 +++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index b2e6400d5b5..37d4ede4faf 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -27,7 +27,7 @@ * @param {Event} event - Event. */ function handlePopState( event ) { - console.info( 'popstate', event ); + loadUrl( window.location.href, { pushState: false } ); } /** @@ -67,7 +67,7 @@ return; } - loadUrl( url ); + loadUrl( url, { scrollIntoView: true } ); event.preventDefault(); } @@ -99,18 +99,30 @@ url.searchParams.set( element.name, element.value ); } } - loadUrl( url ); + loadUrl( url, { scrollIntoView: true } ); event.preventDefault(); } + /** + * Determine whether header is visible at all. + * + * @return {boolean} Whether header image is visible. + */ + function isHeaderVisible() { + const element = document.querySelector( '.site-branding' ); + const clientRect = element.getBoundingClientRect(); + return clientRect.height + clientRect.top >= 0; + } + /** * Load URL. * * @todo When should scroll to the top? Only if the first element of the content is not visible? * @param {string|URL} url - URL. * @param {boolean} scrollIntoView - Scroll into view. + * @param {boolean} pushState - Whether to push state. */ - function loadUrl( url, { scrollIntoView = false } = {} ) { + function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { updateNavMenuClasses( url ); fetchDocument( url ) @@ -124,6 +136,7 @@ newContainer.setAttribute( 'id', oldContainer.getAttribute( 'id' ) ); oldContainer.parentNode.replaceChild( newContainer, oldContainer ); + // @todo Use streaming. currentShadowDoc = AMP.attachShadowDoc( newContainer, doc, url.toString() ); // @todo Improve styling of header when transitioning between home and non-home. @@ -131,11 +144,13 @@ // Update body class name. document.body.className = doc.querySelector( 'body' ).className; document.title = currentShadowDoc.title; - history.pushState( - {}, // @todo Add current scroll position? - currentShadowDoc.title, - currentShadowDoc.canonicalUrl - ); + if ( pushState ) { + history.pushState( + {}, + currentShadowDoc.title, + currentShadowDoc.canonicalUrl + ); + } // Update the nav menu classes if the final URL has redirected somewhere else. if ( currentShadowDoc.canonicalUrl !== url.toString() ) { @@ -156,8 +171,9 @@ originalAdminBar.remove(); } - if ( scrollIntoView ) { - document.body.scrollIntoView( { + if ( scrollIntoView && ! isHeaderVisible() ) { + // @todo The scroll position is not correct when admin bar is used. Consider scrolling to Y coordinate smoothly instead. + document.querySelector( '.site-content-contain' ).scrollIntoView( { block: 'start', inline: 'start', behavior: 'smooth' From fa156acacad0a79898d958976c94f83c5508dee2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 6 Nov 2018 10:31:07 -0800 Subject: [PATCH 29/73] Triger events for app-shell navigate, load, and ready --- assets/js/amp-wp-app-shell.js | 37 ++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 37d4ede4faf..3f1d6feafc1 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -23,10 +23,8 @@ /** * Handle popstate event. - * - * @param {Event} event - Event. */ - function handlePopState( event ) { + function handlePopState() { loadUrl( window.location.href, { pushState: false } ); } @@ -123,6 +121,18 @@ * @param {boolean} pushState - Whether to push state. */ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { + const previousUrl = location.href; + const navigateEvent = new CustomEvent( 'wp-amp-app-shell-navigate', { + cancelable: true, + detail: { + previousUrl, + url + } + } ); + if ( ! window.dispatchEvent( navigateEvent ) ) { + return; // Allow scripts on page to cancel navigation via preventDefault() on wp-amp-app-shell-navigate event. + } + updateNavMenuClasses( url ); fetchDocument( url ) @@ -157,7 +167,28 @@ updateNavMenuClasses( currentShadowDoc.canonicalUrl ); } + const loadEvent = new CustomEvent( 'wp-amp-app-shell-load', { + cancelable: false, + detail: { + previousUrl, + document: doc, + oldContainer, + newContainer + } + } ); + window.dispatchEvent( loadEvent ); + currentShadowDoc.ampdoc.whenReady().then( () => { + // @todo Consider allowing cancelable and when happens to prevent default initialization. + const readyEvent = new CustomEvent( 'wp-amp-app-shell-ready', { + cancelable: false, + detail: { + previousUrl, + document: doc + } + } ); + window.dispatchEvent( readyEvent ); + newContainer.shadowRoot.addEventListener( 'click', handleClick ); newContainer.shadowRoot.addEventListener( 'submit', handleSubmit ); From 76dd089376bcf46b98f51865ead3398213cc2702 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Nov 2018 09:29:27 -0800 Subject: [PATCH 30/73] Do not show fallback source as active theme if no validation errors --- includes/validation/class-amp-validated-url-post-type.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/includes/validation/class-amp-validated-url-post-type.php b/includes/validation/class-amp-validated-url-post-type.php index cde765a129b..6fb41872051 100644 --- a/includes/validation/class-amp-validated-url-post-type.php +++ b/includes/validation/class-amp-validated-url-post-type.php @@ -934,6 +934,12 @@ public static function render_sources_column( $error_summary, $post_id ) { return; } + // Show nothing if there are no valudation errors. + if ( 0 === count( array_filter( $error_summary ) ) ) { + esc_html_e( '--', 'amp' ); + return; + } + $active_theme = null; $validated_environment = get_post_meta( $post_id, '_amp_validated_environment', true ); if ( isset( $validated_environment['theme'] ) ) { From b5200e80a8ce3dd62f1a9fb85f252e2a139c848e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Nov 2018 17:22:16 -0800 Subject: [PATCH 31/73] Temporarily add cache-busting query param for sake of XHR --- assets/js/amp-wp-app-shell.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 3f1d6feafc1..6155391854b 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -218,8 +218,7 @@ } else { console.error( error ); } - } - ); + } ); } /** @@ -332,6 +331,7 @@ const ampUrl = new URL( url ); ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); + ampUrl.searchParams.set( '_cache_bust', Math.random().toString() ); // @todo Temporary since XHR is aggressively using disk cache. // unfortunately fetch() does not support retrieving documents, // so we have to resort to good old XMLHttpRequest. @@ -345,6 +345,7 @@ * @todo Consider using fetch() and then construct the DOM with DOMImplementation.createHTMLDocument(). */ xhr.open( 'GET', ampUrl.toString(), true ); + xhr.withCredentials = true; xhr.responseType = 'document'; xhr.setRequestHeader( 'Accept', 'text/html' ); From 60bf6147b6b72b4c8d24dffa61326bfc458b31a3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Nov 2018 18:37:06 -0800 Subject: [PATCH 32/73] Preserve purged query vars during canonical redirect This supercedes 79aeeb1 --- amp.php | 4 +--- includes/class-amp-http.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/amp.php b/amp.php index d1db7b812fd..a81b172f50e 100644 --- a/amp.php +++ b/amp.php @@ -165,6 +165,7 @@ function amp_init() { add_rewrite_endpoint( amp_get_slug(), EP_PERMALINK ); add_filter( 'allowed_redirect_hosts', array( 'AMP_HTTP', 'filter_allowed_redirect_hosts' ) ); + add_filter( 'redirect_canonical', array( 'AMP_HTTP', 'add_purged_query_vars' ) ); AMP_HTTP::purge_amp_query_vars(); AMP_HTTP::send_cors_headers(); AMP_HTTP::handle_xhr_request(); @@ -525,9 +526,6 @@ function amp_redirect_old_slug_to_new_url( $link ) { } else { $link = trailingslashit( trailingslashit( $link ) . amp_get_slug() ); } - if ( ! empty( AMP_HTTP::$purged_amp_query_vars ) ) { - $link = add_query_arg( AMP_HTTP::$purged_amp_query_vars, $link ); - } } return $link; diff --git a/includes/class-amp-http.php b/includes/class-amp-http.php index 6770d73ec86..8525bef5dd8 100644 --- a/includes/class-amp-http.php +++ b/includes/class-amp-http.php @@ -162,6 +162,21 @@ public static function purge_amp_query_vars() { } } + /** + * Add purged query vars to the supplied URL. + * + * @since 1.0 + * + * @param string $url URL. + * @return string URL with purged query vars. + */ + public static function add_purged_query_vars( $url ) { + if ( ! empty( self::$purged_amp_query_vars ) ) { + $url = add_query_arg( self::$purged_amp_query_vars, $url ); + } + return $url; + } + /** * Filter the allowed redirect hosts to include AMP caches. * From afc46ba839ac65d66dc541d2c8633b9c69779937 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Nov 2018 10:02:07 -0800 Subject: [PATCH 33/73] Preserve purged query args on all redirects not just canonical ones --- amp.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amp.php b/amp.php index a81b172f50e..7d65e8cbc04 100644 --- a/amp.php +++ b/amp.php @@ -165,7 +165,7 @@ function amp_init() { add_rewrite_endpoint( amp_get_slug(), EP_PERMALINK ); add_filter( 'allowed_redirect_hosts', array( 'AMP_HTTP', 'filter_allowed_redirect_hosts' ) ); - add_filter( 'redirect_canonical', array( 'AMP_HTTP', 'add_purged_query_vars' ) ); + add_filter( 'wp_redirect', array( 'AMP_HTTP', 'add_purged_query_vars' ) ); AMP_HTTP::purge_amp_query_vars(); AMP_HTTP::send_cors_headers(); AMP_HTTP::handle_xhr_request(); From 77b5ddc34982cdce3f72178058a56daec178b83f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Nov 2018 10:11:32 -0800 Subject: [PATCH 34/73] Use AMP shadow streaming API with fetch intead of XHR --- assets/js/amp-wp-app-shell.js | 106 +++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 33 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 6155391854b..534ca1a409e 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -112,6 +112,27 @@ return clientRect.height + clientRect.top >= 0; } + /** + * Fetch response for URL of shadow doc. + * + * @param {string} url - URL. + * @return {Promise} Response promise. + */ + function fetchShadowDocResponse( url ) { + const ampUrl = new URL( url ); + ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); + ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); + + return fetch( ampUrl.toString(), { + method: 'GET', + mode: 'same-origin', + credentials: 'include', + redirect: 'follow', + cache: 'default', + referrer: 'client' + } ); + } + /** * Load URL. * @@ -135,8 +156,8 @@ updateNavMenuClasses( url ); - fetchDocument( url ) - .then( ( doc ) => { + fetchShadowDocResponse( url ) + .then( response => { if ( currentShadowDoc ) { currentShadowDoc.close(); } @@ -146,45 +167,40 @@ newContainer.setAttribute( 'id', oldContainer.getAttribute( 'id' ) ); oldContainer.parentNode.replaceChild( newContainer, oldContainer ); - // @todo Use streaming. - currentShadowDoc = AMP.attachShadowDoc( newContainer, doc, url.toString() ); - - // @todo Improve styling of header when transitioning between home and non-home. - // @todo Synchronize additional meta in head. - // Update body class name. - document.body.className = doc.querySelector( 'body' ).className; - document.title = currentShadowDoc.title; - if ( pushState ) { - history.pushState( - {}, - currentShadowDoc.title, - currentShadowDoc.canonicalUrl - ); - } + /* + * For more on this, see: + * https://www.ampproject.org/latest/blog/streaming-in-the-shadow-reader/ + * https://github.com/ampproject/amphtml/blob/master/spec/amp-shadow-doc.md#fetching-and-attaching-shadow-docs + */ + currentShadowDoc = AMP.attachShadowDocAsStream( newContainer, url, {} ); - // Update the nav menu classes if the final URL has redirected somewhere else. - if ( currentShadowDoc.canonicalUrl !== url.toString() ) { - updateNavMenuClasses( currentShadowDoc.canonicalUrl ); - } + currentShadowDoc.ampdoc.whenReady().then( () => { + // Update the nav menu classes if the final URL has redirected somewhere else. + if ( currentShadowDoc.canonicalUrl !== url.toString() ) { + updateNavMenuClasses( currentShadowDoc.canonicalUrl ); + } - const loadEvent = new CustomEvent( 'wp-amp-app-shell-load', { - cancelable: false, - detail: { - previousUrl, - document: doc, - oldContainer, - newContainer + // @todo Improve styling of header when transitioning between home and non-home. + // @todo Synchronize additional meta in head. + // Update body class name. + document.body.className = newContainer.shadowRoot.querySelector( 'body' ).className; + document.title = currentShadowDoc.title; + if ( pushState ) { + history.pushState( + {}, + currentShadowDoc.title, + currentShadowDoc.canonicalUrl + ); } - } ); - window.dispatchEvent( loadEvent ); - currentShadowDoc.ampdoc.whenReady().then( () => { // @todo Consider allowing cancelable and when happens to prevent default initialization. const readyEvent = new CustomEvent( 'wp-amp-app-shell-ready', { cancelable: false, detail: { previousUrl, - document: doc + oldContainer, + newContainer, + shadowDoc: currentShadowDoc } } ); window.dispatchEvent( readyEvent ); @@ -211,6 +227,31 @@ } ); } } ); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + function readChunk() { + return reader.read().then( chunk => { + const input = chunk.value || new Uint8Array(); + const text = decoder.decode( + input, + { + stream: ! chunk.done + } + ); + if ( text ) { + currentShadowDoc.writer.write( text ); + } + if ( chunk.done ) { + currentShadowDoc.writer.close(); + } else { + return readChunk(); + } + } ); + } + + return readChunk(); } ) .catch( ( error ) => { if ( 'amp_unavailable' === error ) { @@ -331,7 +372,6 @@ const ampUrl = new URL( url ); ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); - ampUrl.searchParams.set( '_cache_bust', Math.random().toString() ); // @todo Temporary since XHR is aggressively using disk cache. // unfortunately fetch() does not support retrieving documents, // so we have to resort to good old XMLHttpRequest. From 56e7f66887a366db3b5a12456373f1aeeb599a09 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Nov 2018 10:26:13 -0800 Subject: [PATCH 35/73] Add Web Components polyfill if Shadow DOM is not natively available --- assets/js/amp-wp-app-shell.js | 19 +++++++++++++++++-- includes/amp-helper-functions.php | 10 ++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 534ca1a409e..4cd4e2c4c04 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -406,8 +406,23 @@ } ); } - // Initialize when Shadow API loaded and DOM Ready. - ( window.AMP = window.AMP || [] ).push( () => { + // Initialize when Shadow DOM API loaded and DOM Ready. + const ampReadyPromise = new Promise( resolve => { + if ( ! window.AMP ) { + window.AMP = []; + } + window.AMP.push( resolve ); + } ); + const shadowDomPolyfillReadyPromise = new Promise( resolve => { + if ( Element.prototype.attachShadow ) { + // Native available. + resolve(); + } else { + // Otherwise, wait for polyfill to be installed. + window.addEventListener( 'WebComponentsReady', resolve ); + } + } ); + Promise.all( [ ampReadyPromise, shadowDomPolyfillReadyPromise ] ).then( () => { // Code from @wordpress/dom-ready NPM package . if ( document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 19c7effeaf0..20b876f9086 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -358,6 +358,16 @@ function amp_register_default_scripts( $wp_scripts ) { 'async' => true, ) ); + // Add Web Components polyfill if Shadow DOM is not natively available. + $wp_scripts->add_inline_script( + $handle, + sprintf( + 'if ( ! Element.prototype.attachShadow ) { const script = document.createElement( "script" ); script.src = %s; script.async = true; document.head.appendChild( script ); }', + wp_json_encode( 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.3/webcomponents-sd-ce.js' ) + ), + 'after' + ); + // App shell library. $handle = 'amp-wp-app-shell'; $wp_scripts->add( From 60b3bca47ad26eb8d82abb78a035cda72a6c1b51 Mon Sep 17 00:00:00 2001 From: Miina Sikk Date: Thu, 8 Nov 2018 10:29:17 -0800 Subject: [PATCH 36/73] Use IIFE to support Safari --- assets/js/amp-wp-app-shell.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 4cd4e2c4c04..55f4c612e54 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -1,7 +1,7 @@ /* global ampAppShell, AMP */ /* eslint-disable no-console */ -{ +( function() { let currentShadowDoc; /** @@ -434,4 +434,4 @@ document.addEventListener( 'DOMContentLoaded', init ); } } ); -} +} )(); From bb3f9bd5ea157605ac7928c6d68a7b5e18f1083f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Nov 2018 10:35:30 -0800 Subject: [PATCH 37/73] Remove unused fetchDocument function --- assets/js/amp-wp-app-shell.js | 45 ----------------------------------- 1 file changed, 45 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 55f4c612e54..47b3359cd6a 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -361,51 +361,6 @@ } } - /** - * Fetch document. - * - * @param {string|URL} url URL. - * @return {Promise} Promise which resolves to the fetched document. - */ - function fetchDocument( url ) { - - const ampUrl = new URL( url ); - ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); - ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); - - // unfortunately fetch() does not support retrieving documents, - // so we have to resort to good old XMLHttpRequest. - const xhr = new XMLHttpRequest(); - - return new Promise( ( resolve, reject ) => { - /* - * It would be ideal if the XHR would not follow redirects automatically so that if a redirect to a URL - * without the 'amp' query var happens, then we could skip having to waste CPU to construct the responseXML - * document. But XHR does not support this, while fetch does: . - * @todo Consider using fetch() and then construct the DOM with DOMImplementation.createHTMLDocument(). - */ - xhr.open( 'GET', ampUrl.toString(), true ); - xhr.withCredentials = true; - xhr.responseType = 'document'; - xhr.setRequestHeader( 'Accept', 'text/html' ); - - xhr.onload = () => { - if ( ! xhr.responseXML ) { - reject( 'no_response' ); - } else if ( xhr.responseXML.documentElement.hasAttribute( 'amp' ) || xhr.responseXML.documentElement.hasAttribute( '⚡️' ) ) { - resolve( xhr.responseXML ); - } else { - reject( 'amp_unavailable' ); - } - }; - // @todo What about abort and timeout events? - xhr.onerror = () => { - reject( 'xhr_error' ); - }; - xhr.send(); - } ); - } - // Initialize when Shadow DOM API loaded and DOM Ready. const ampReadyPromise = new Promise( resolve => { if ( ! window.AMP ) { From b37353e40e14f4875b7f4d9d5459f2c75760a847 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Nov 2018 11:19:05 -0800 Subject: [PATCH 38/73] Remove unused amp_script_attributes data; allow amp_script_attributes for non-AMP CDS assets --- includes/amp-helper-functions.php | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 20b876f9086..a29ad4777bd 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -342,9 +342,6 @@ function amp_register_default_scripts( $wp_scripts ) { array(), null ); - $wp_scripts->add_data( $handle, 'amp_script_attributes', array( - 'async' => true, - ) ); // Shadow AMP API. $handle = 'amp-shadow'; @@ -354,9 +351,6 @@ function amp_register_default_scripts( $wp_scripts ) { array(), null ); - $wp_scripts->add_data( $handle, 'amp_script_attributes', array( - 'async' => true, - ) ); // Add Web Components polyfill if Shadow DOM is not natively available. $wp_scripts->add_inline_script( @@ -466,18 +460,18 @@ function amp_render_scripts( $scripts ) { function amp_filter_script_loader_tag( $tag, $handle ) { $prefix = 'https://cdn.ampproject.org/'; $src = wp_scripts()->registered[ $handle ]->src; - if ( 0 !== strpos( $src, $prefix ) ) { + + $attributes = wp_scripts()->get_data( $handle, 'amp_script_attributes' ); + if ( empty( $attributes ) && 0 === strpos( $src, $prefix ) ) { + // All scripts from AMP CDN should be loaded async. + $attributes = array( + 'async' => true, + ); + } + if ( empty( $attributes ) ) { return $tag; } - /* - * All scripts from AMP CDN should be loaded async. - * See . - */ - $attributes = array( - 'async' => true, - ); - // Add custom-template and custom-element attributes. All component scripts look like https://cdn.ampproject.org/v0/:name-:version.js. if ( 'v0' === strtok( substr( $src, strlen( $prefix ) ), '/' ) ) { /* From 7302a8add1f6ce3326203f5c3e51d00b7f3ea875 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Nov 2018 11:20:23 -0800 Subject: [PATCH 39/73] Add amp-wp-app-shell to precache and use md5 as revision when WP_DEBUG --- includes/amp-helper-functions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index a29ad4777bd..b20728cedca 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -368,11 +368,12 @@ function amp_register_default_scripts( $wp_scripts ) { $handle, amp_get_asset_url( 'js/amp-wp-app-shell.js' ), array( 'amp-shadow' ), - AMP__VERSION + AMP__VERSION . ( WP_DEBUG ? '-' . md5( file_get_contents( AMP__DIR__ . '/assets/js/amp-wp-app-shell.js' ) ) : '' ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents ); $wp_scripts->add_data( $handle, 'amp_script_attributes', array( 'async' => true, ) ); + $wp_scripts->add_data( $handle, 'precache', true ); // Get all AMP components as defined in the spec. $extensions = array(); From a2585e79934732932ee94922c7213e3dfdd3fcd8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 10 Nov 2018 12:03:41 -0800 Subject: [PATCH 40/73] Force AMP endpoint whenever requesting inner app shell --- includes/amp-helper-functions.php | 7 +++++++ includes/class-amp-theme-support.php | 12 +++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index b20728cedca..07d430cc792 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -258,10 +258,17 @@ function is_amp_endpoint() { _doing_it_wrong( __FUNCTION__, sprintf( esc_html__( "is_amp_endpoint() was called before the 'parse_query' hook was called. This function will always return 'false' before the 'parse_query' hook is called.", 'amp' ) ), '0.4.2' ); } + $support_args = AMP_Theme_Support::get_theme_support_args(); $has_amp_query_var = ( isset( $_GET[ amp_get_slug() ] ) // WPCS: CSRF OK. || false !== get_query_var( amp_get_slug(), false ) + || + ( + isset( $support_args['app_shell'] ) + && + 'inner' === AMP_Theme_Support::get_requested_app_shell_component() + ) ); if ( ! current_theme_supports( AMP_Theme_Support::SLUG ) ) { diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 812a88ba74c..f7e40e4d5c6 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -57,6 +57,7 @@ class AMP_Theme_Support { /** * Query var for requesting the inner or outer app shell. * + * @todo This can go into the AMP_Service_Worker class or rather an AMP_App_Shell class. * @var string */ const APP_SHELL_COMPONENT_QUERY_VAR = 'amp_app_shell_component'; @@ -286,15 +287,8 @@ public static function init_app_shell() { $requested_app_shell_component = self::get_requested_app_shell_component(); - // @todo Prevent showing admin bar in outer app shell? - if ( ! is_amp_endpoint() && 'inner' === $requested_app_shell_component ) { - // @todo For non-outer - wp_die( - esc_html__( 'Inner app shell can only be requested of the AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Inner App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); - } elseif ( is_amp_endpoint() && 'outer' === $requested_app_shell_component ) { + // When inner app shell is requested, it is always an AMP request. Do not allow AMP when getting outer app shell for now (but this should be allowed in the future). + if ( is_amp_endpoint() && 'outer' === $requested_app_shell_component ) { wp_die( esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), esc_html__( 'AMP Outer App Shell Problem', 'amp' ), From 8692d3e71ea8aa340aa179215054d4d9d0a4d253 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 10 Nov 2018 13:21:02 -0800 Subject: [PATCH 41/73] Export isOuterAppShell to page --- assets/js/amp-wp-app-shell.js | 4 ++++ includes/class-amp-theme-support.php | 1 + 2 files changed, 5 insertions(+) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 47b3359cd6a..2e678525a49 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -19,6 +19,10 @@ document.body.addEventListener( 'submit', handleSubmit ); window.addEventListener( 'popstate', handlePopState ); + + if ( ampAppShell.isOuterAppShell ) { + loadUrl( location.href ); + } } /** diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index f7e40e4d5c6..acf14757b7b 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -308,6 +308,7 @@ public static function init_app_shell() { 'adminUrl' => admin_url( '/' ), 'ampQueryVar' => amp_get_slug(), 'componentQueryVar' => self::APP_SHELL_COMPONENT_QUERY_VAR, + 'isOuterAppShell' => 'outer' === $requested_app_shell_component, ); wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); } From aecf9f842d55fcc5caa7f70bbf6f354f143e5f2a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 10 Nov 2018 13:32:20 -0800 Subject: [PATCH 42/73] Use Loading as document title for app shell --- includes/class-amp-theme-support.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index acf14757b7b..80b3958c083 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -296,6 +296,13 @@ public static function init_app_shell() { ); } + // @todo This query param should be standardized and then this can be handled in the same place as WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header(). + if ( 'outer' === $requested_app_shell_component ) { + add_filter( 'pre_get_document_title', function() { + return __( 'Loading...', 'amp' ); + } ); + } + // Enqueue scripts for (outer) app shell, including precached app shell and normal site navigation prior to service worker installation. if ( ! is_amp_endpoint() && 'inner' !== $requested_app_shell_component ) { wp_enqueue_script( 'amp-shadow' ); From b9faeecadeafdd6495c0f5b48b0cc0932763ac90 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 10 Nov 2018 17:35:32 -0800 Subject: [PATCH 43/73] Fix serving error templates for app shell --- assets/js/amp-wp-app-shell.js | 21 ++++++++--- includes/class-amp-theme-support.php | 53 +++++++++++++++++----------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 2e678525a49..f0606008b2f 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -124,7 +124,6 @@ */ function fetchShadowDocResponse( url ) { const ampUrl = new URL( url ); - ampUrl.searchParams.set( ampAppShell.ampQueryVar, '1' ); ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); return fetch( ampUrl.toString(), { @@ -178,10 +177,24 @@ */ currentShadowDoc = AMP.attachShadowDocAsStream( newContainer, url, {} ); + // @todo If it is not an AMP document, then the loaded document needs to break out of the app shell. This should be done in readChunk() below. currentShadowDoc.ampdoc.whenReady().then( () => { + let currentUrl; + if ( currentShadowDoc.canonicalUrl ) { + currentUrl = new URL( currentShadowDoc.canonicalUrl ); + + // Prevent updating the URL if the canonical URL is for the error template. + // @todo The rel=canonical link should not be output for these templates. + if ( currentUrl.searchParams.has( 'wp_error_template' ) ) { + currentUrl = currentUrl.href = url; + } + } else { + currentUrl = new URL( url ); + } + // Update the nav menu classes if the final URL has redirected somewhere else. - if ( currentShadowDoc.canonicalUrl !== url.toString() ) { - updateNavMenuClasses( currentShadowDoc.canonicalUrl ); + if ( currentUrl.toString() !== url.toString() ) { + updateNavMenuClasses( currentUrl ); } // @todo Improve styling of header when transitioning between home and non-home. @@ -193,7 +206,7 @@ history.pushState( {}, currentShadowDoc.title, - currentShadowDoc.canonicalUrl + currentUrl.toString() ); } diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 80b3958c083..1c8290cccd9 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -148,6 +148,7 @@ public static function init() { require_once AMP__DIR__ . '/includes/amp-post-template-actions.php'; add_action( 'widgets_init', array( __CLASS__, 'register_widgets' ) ); + add_action( 'parse_query', array( __CLASS__, 'init_app_shell' ), 9 ); /* * Note that wp action is use instead of template_redirect because some themes/plugins output @@ -241,8 +242,6 @@ public static function get_theme_support_args() { * @since 0.7 */ public static function finish_init() { - self::init_app_shell(); - if ( ! is_amp_endpoint() ) { // Redirect to AMP-less variable if AMP is not available for this URL and yet the query var is present. @@ -288,12 +287,17 @@ public static function init_app_shell() { $requested_app_shell_component = self::get_requested_app_shell_component(); // When inner app shell is requested, it is always an AMP request. Do not allow AMP when getting outer app shell for now (but this should be allowed in the future). - if ( is_amp_endpoint() && 'outer' === $requested_app_shell_component ) { - wp_die( - esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Outer App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); + if ( 'outer' === $requested_app_shell_component ) { + add_action( 'template_redirect', function() { + if ( ! is_amp_endpoint() ) { + return; + } + wp_die( + esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Outer App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); + } ); } // @todo This query param should be standardized and then this can be handled in the same place as WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header(). @@ -304,20 +308,27 @@ public static function init_app_shell() { } // Enqueue scripts for (outer) app shell, including precached app shell and normal site navigation prior to service worker installation. - if ( ! is_amp_endpoint() && 'inner' !== $requested_app_shell_component ) { - wp_enqueue_script( 'amp-shadow' ); - wp_enqueue_script( 'amp-wp-app-shell' ); - - // @todo The exports will eventually need to vary the precached app shell. - $exports = array( - 'contentElementId' => self::APP_SHELL_CONTENT_ELEMENT_ID, - 'homeUrl' => home_url( '/' ), - 'adminUrl' => admin_url( '/' ), - 'ampQueryVar' => amp_get_slug(), - 'componentQueryVar' => self::APP_SHELL_COMPONENT_QUERY_VAR, - 'isOuterAppShell' => 'outer' === $requested_app_shell_component, + if ( 'inner' !== $requested_app_shell_component ) { + add_action( + 'wp_enqueue_scripts', + function() use ( $requested_app_shell_component ) { + if ( is_amp_endpoint() ) { + return; + } + wp_enqueue_script( 'amp-shadow' ); + wp_enqueue_script( 'amp-wp-app-shell' ); + + // @todo The exports will eventually need to vary the precached app shell. + $exports = array( + 'contentElementId' => AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID, + 'homeUrl' => home_url( '/' ), + 'adminUrl' => admin_url( '/' ), + 'componentQueryVar' => AMP_Theme_Support::APP_SHELL_COMPONENT_QUERY_VAR, + 'isOuterAppShell' => 'outer' === $requested_app_shell_component, + ); + wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); + } ); - wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); } } From d52c995f3e8b7515c06aedcb846eab1eaebb8536 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 11 Nov 2018 21:27:00 -0800 Subject: [PATCH 44/73] Fix adding current nav menu item class on initial app shell load --- assets/js/amp-wp-app-shell.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index f0606008b2f..6309b0ff688 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -150,7 +150,7 @@ cancelable: true, detail: { previousUrl, - url + url: String( url ) } } ); if ( ! window.dispatchEvent( navigateEvent ) ) { @@ -307,7 +307,7 @@ // Re-add class names to items generated from nav menus. for ( const link of document.querySelectorAll( '.menu-item > a[href]' ) ) { - if ( link.href !== url.href ) { + if ( link.href !== queriedUrl.href ) { continue; } @@ -354,7 +354,7 @@ // Re-add class names to items generated from page listings. for ( const link of document.querySelectorAll( '.page_item > a[href]' ) ) { - if ( link.href !== url.href ) { + if ( link.href !== queriedUrl.href ) { continue; } let depth = 0; From 4252c229775605b35972594807eb8d292c7eaa75 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 11 Nov 2018 22:36:15 -0800 Subject: [PATCH 45/73] Add service worker code removed from PWA plugin in https://github.com/xwp/pwa-wp/pull/107/commits/bd9084db000d5687d3a75f042246b6b05b6bfe56 --- .jshintignore | 2 + amp.php | 4 + .../amp-service-worker-offline-commenting.js | 45 +++ .../amp-service-worker-runtime-precaching.js | 9 + includes/class-amp-autoloader.php | 1 + includes/class-amp-service-worker.php | 344 ++++++++++++++++++ 6 files changed, 405 insertions(+) create mode 100644 assets/js/amp-service-worker-offline-commenting.js create mode 100644 assets/js/amp-service-worker-runtime-precaching.js create mode 100644 includes/class-amp-service-worker.php diff --git a/.jshintignore b/.jshintignore index d4950ead8de..584e196ce44 100644 --- a/.jshintignore +++ b/.jshintignore @@ -3,3 +3,5 @@ **/vendor/** **/assets/js/*-compiled.js **/assets/js/amp-wp-app-shell.js +**/assets/js/amp-service-worker-offline-commenting.js +**/assets/js/amp-service-worker-runtime-precaching.js diff --git a/amp.php b/amp.php index 7d65e8cbc04..e9832b50c6e 100644 --- a/amp.php +++ b/amp.php @@ -162,6 +162,10 @@ function amp_init() { */ do_action( 'amp_init' ); + global $amp_service_worker; + $amp_service_worker = new AMP_Service_Worker(); + $amp_service_worker->init(); + add_rewrite_endpoint( amp_get_slug(), EP_PERMALINK ); add_filter( 'allowed_redirect_hosts', array( 'AMP_HTTP', 'filter_allowed_redirect_hosts' ) ); diff --git a/assets/js/amp-service-worker-offline-commenting.js b/assets/js/amp-service-worker-offline-commenting.js new file mode 100644 index 00000000000..1958e9f9f62 --- /dev/null +++ b/assets/js/amp-service-worker-offline-commenting.js @@ -0,0 +1,45 @@ +/* global ERROR_MESSAGES, SITE_URL */ +{ + const queue = new wp.serviceWorker.backgroundSync.Queue( 'amp-wpPendingComments' ); + const errorMessages = ERROR_MESSAGES; + + const commentHandler = ( { event } ) => { + const clonedRequest = event.request.clone(); + return fetch( event.request ).catch( () => { + return clonedRequest.blob().then( ( body ) => { + const queuedRequest = new Request( event.request.url, { + method: event.request.method, + headers: event.request.headers, + mode: event.request.mode, + credentials: event.request.credentials, + referrer: event.request.referrer, + redirect: 'follow', + body: body + } ); + + // Add request to queue. @todo Replace when upgrading to Workbox v4! + queue.addRequest( queuedRequest ); + + const jsonBody = JSON.stringify( { message: errorMessages.comment } ); + return new Response( jsonBody, { + status: 202, + statusText: 'Accepted', + headers: { + 'Access-Control-Allow-Origin': SITE_URL, + 'Access-Control-Allow-Credentials': 'true', + 'Content-Type': 'application/json; charset=UTF-8', + 'Access-Control-Expose-Headers': 'AMP-Access-Control-Allow-Source-Origin', + 'AMP-Access-Control-Allow-Source-Origin': SITE_URL, + 'Cache-Control': 'no-cache, must-revalidate, max-age=0' + } + } ); + } ); + } ); + }; + + wp.serviceWorker.routing.registerRoute( + /\/wp-comments-post\.php\?.*_wp_amp_action_xhr_converted.*/, + commentHandler, + 'POST' + ); +} diff --git a/assets/js/amp-service-worker-runtime-precaching.js b/assets/js/amp-service-worker-runtime-precaching.js new file mode 100644 index 00000000000..06f9a0cf9f8 --- /dev/null +++ b/assets/js/amp-service-worker-runtime-precaching.js @@ -0,0 +1,9 @@ +/* global URLS */ +// See AMP_Service_Workers::add_amp_runtime_caching() and . +{ + self.addEventListener( 'install', event => { + event.waitUntil( + caches.open( wp.serviceWorker.core.cacheNames.runtime ).then( cache => cache.addAll( URLS ) ) + ); + } ); +} diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index 2d3c8725742..1044a5c3c30 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -30,6 +30,7 @@ class AMP_Autoloader { */ private static $_classmap = array( 'AMP_Editor_Blocks' => 'includes/admin/class-amp-editor-blocks', + 'AMP_Service_Worker' => 'includes/class-amp-service-worker', 'AMP_Theme_Support' => 'includes/class-amp-theme-support', 'AMP_HTTP' => 'includes/class-amp-http', 'AMP_Comment_Walker' => 'includes/class-amp-comment-walker', diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php new file mode 100644 index 00000000000..69329a562aa --- /dev/null +++ b/includes/class-amp-service-worker.php @@ -0,0 +1,344 @@ +register( + 'amp-cdn-runtime-cache', + function() { + $urls = AMP_Service_Worker::get_runtime_precache_urls(); + if ( empty( $urls ) ) { + return ''; + } + + $js = file_get_contents( AMP__DIR__ . '/assets/js/amp-service-worker-runtime-precaching.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents + $js = preg_replace( '#/\*\s*global.+?\*/#', '', $js ); + $js = str_replace( + 'URLS', + wp_json_encode( $urls ), + $js + ); + return $js; + } + ); + + // Serve the AMP Runtime from cache and check for an updated version in the background. See . + $service_workers->caching_routes()->register( + '^https:\/\/cdn\.ampproject\.org\/.*', + array( + 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_STALE_WHILE_REVALIDATE, + ) + ); + } + + /** + * Configure the front service worker for AMP. + * + * @link https://github.com/ampproject/amp-by-example/blob/master/boilerplate-generator/templates/files/serviceworkerJs.js + * + * @param WP_Service_Worker_Scripts $service_workers Service workers. + */ + public function add_image_runtime_caching( $service_workers ) { + if ( ! ( $service_workers instanceof WP_Service_Worker_Scripts ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Expected argument to be WP_Service_Worker_Scripts.', 'amp' ), '1.0' ); + return; + } + + $service_workers->caching_routes()->register( + '/wp-content/.*\.(?:png|gif|jpg|jpeg|svg|webp)(\?.*)?$', + array( + 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST, + 'cacheName' => 'images', // @todo This needs to get the proper prefix in JS. + 'plugins' => array( + 'cacheableResponse' => array( + 'statuses' => array( 0, 200 ), + ), + 'expiration' => array( + 'maxEntries' => 60, + 'maxAgeSeconds' => MONTH_IN_SECONDS, + ), + ), + ) + ); + } + + /** + * Add live list offline commenting service worker script. + * + * @param object $service_workers WP Service Workers object. + */ + public function add_live_list_offline_commenting( $service_workers ) { + if ( ! ( $service_workers instanceof WP_Service_Worker_Scripts ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Expected argument to be WP_Service_Worker_Scripts.', 'amp' ), '1.0' ); + return; + } + + $theme_support = AMP_Theme_Support::get_theme_support_args(); + if ( empty( $theme_support['comments_live_list'] ) ) { + return; + } + + $service_workers->register( + 'amp-offline-commenting', + function() { + $js = file_get_contents( AMP__DIR__ . '/assets/js/amp-service-worker-offline-commenting.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents + $js = preg_replace( '#/\*\s*global.+?\*/#', '', $js ); + $js = str_replace( + 'ERROR_MESSAGES', + wp_json_encode( wp_service_worker_get_error_messages() ), + $js + ); + $js = str_replace( + 'SITE_URL', + wp_json_encode( site_url() ), + $js + ); + return $js; + } + ); + + } + + /** + * Register URLs that will be precached in the runtime cache. (Yes, this sounds somewhat strange.) + * + * Note that the PWA plugin handles the precaching of custom logo, custom header, + * and custom background. The PWA plugin also automatically adds runtime caching + * for Google Fonts. The PWA plugin also handles precaching & serving of the + * offline/500 error pages, enabling navigation preload, + * + * @link https://github.com/ampproject/amp-by-example/blob/master/boilerplate-generator/templates/files/serviceworkerJs.js + * + * @return array Runtime pre-cached URLs. + */ + public function get_runtime_precache_urls() { + + // List of AMP scripts that we know will be used in WordPress always. + $precached_handles = array( + 'amp-runtime', + 'amp-bind', // Used by comments. + 'amp-form', // Used by comments. + 'amp-install-serviceworker', + ); + + $theme_support = AMP_Theme_Support::get_theme_support_args(); + if ( ! empty( $theme_support['comments_live_list'] ) ) { + $precached_handles[] = 'amp-live-list'; + } + + if ( amp_get_analytics() ) { + $precached_handles[] = 'amp-analytics'; + } + + $urls = array(); + foreach ( $precached_handles as $handle ) { + if ( wp_script_is( $handle, 'registered' ) ) { + $urls[] = wp_scripts()->registered[ $handle ]->src; + } + } + + return $urls; + } + + /** + * Add hooks to install the service worker from AMP page. + */ + public function add_install_hooks() { + if ( current_theme_supports( 'amp' ) && is_amp_endpoint() ) { + add_action( 'wp_footer', array( $this, 'install_service_worker' ) ); + + // Prevent validation error due to the script that installs the service worker on non-AMP pages. + $priority = has_action( 'wp_print_scripts', 'wp_print_service_workers' ); + if ( false !== $priority ) { + remove_action( 'wp_print_scripts', 'wp_print_service_workers', $priority ); + } + } + add_action( 'amp_post_template_footer', array( $this, 'install_service_worker' ) ); + } + + /** + * Install service worker(s). + * + * @since 1.0 + * @see wp_print_service_workers() + * @link https://github.com/xwp/pwa-wp + */ + public function install_service_worker() { + if ( ! function_exists( 'wp_service_workers' ) || ! function_exists( 'wp_get_service_worker_url' ) ) { + return; + } + + $src = wp_get_service_worker_url( WP_Service_Workers::SCOPE_FRONT ); + $iframe_src = add_query_arg( + self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR, + WP_Service_Workers::SCOPE_FRONT, + home_url( '/', 'https' ) + ); + ?> + + + query_vars[ self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR ] ) ) { + return; + } + + $scope = intval( $GLOBALS['wp']->query_vars[ self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR ] ); + if ( WP_Service_Workers::SCOPE_ADMIN !== $scope && WP_Service_Workers::SCOPE_FRONT !== $scope ) { + wp_die( + esc_html__( 'No service workers registered for the requested scope.', 'amp' ), + esc_html__( 'Service Worker Installation', 'amp' ), + array( 'response' => 404 ) + ); + } + + $front_scope = home_url( '/', 'relative' ); + + ?> + + + + + <?php esc_html_e( 'Service Worker Installation', 'amp' ); ?> + + + + navigator.serviceWorker.register( %s, %s );', + wp_json_encode( wp_get_service_worker_url( $scope ) ), + wp_json_encode( array( 'scope' => $front_scope ) ) + ); + ?> + + + Date: Sun, 11 Nov 2018 23:17:58 -0800 Subject: [PATCH 46/73] Make sure offline template is included among supportable templates --- includes/class-amp-service-worker.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php index 69329a562aa..4adddd49fe4 100644 --- a/includes/class-amp-service-worker.php +++ b/includes/class-amp-service-worker.php @@ -78,6 +78,20 @@ function ( $blacklist_patterns ) { return $blacklist_patterns; } ); + + // Make sure the offline template is added to list of templates in AMP. + add_filter( + 'amp_supportable_templates', + function( $supportable_templates ) { + if ( ! isset( $supportable_templates['is_offline'] ) ) { + $supportable_templates['is_offline'] = array( + 'label' => __( 'Offline', 'amp' ), + ); + } + return $supportable_templates; + }, + 1000 + ); } } From 12932c04db8eaabc457f280579bce363c5df624b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 12 Nov 2018 06:49:22 -0800 Subject: [PATCH 47/73] Update todo comments --- includes/class-amp-service-worker.php | 4 +--- includes/class-amp-theme-support.php | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php index 4adddd49fe4..02d2cdbcf05 100644 --- a/includes/class-amp-service-worker.php +++ b/includes/class-amp-service-worker.php @@ -2,8 +2,6 @@ /** * AMP Service Workers. * - * NOTE: This functionality will eventually be moved to the AMP plugin. It exists here now to facilitate iteration on the PWA plugin's API. - * * @package AMP * @since 1.0 */ @@ -11,7 +9,7 @@ /** * Class AMP_Service_Worker. * - * @todo It would seem preferable for this class to exted WP_Service_Worker_Base_Integration. However, to do so we'll have to break out methods for query_vars, parse_request, and wp actions. + * @todo It would seem preferable for this class to extend WP_Service_Worker_Base_Integration. However, to do so we'll have to break out methods for query_vars, parse_request, and wp actions. */ class AMP_Service_Worker { diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 1c8290cccd9..2d2c99d6377 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -318,7 +318,6 @@ function() use ( $requested_app_shell_component ) { wp_enqueue_script( 'amp-shadow' ); wp_enqueue_script( 'amp-wp-app-shell' ); - // @todo The exports will eventually need to vary the precached app shell. $exports = array( 'contentElementId' => AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID, 'homeUrl' => home_url( '/' ), From 1e77c6e56f3ad0b8a39a988f4aab409bb646e326 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Nov 2018 12:10:39 -0800 Subject: [PATCH 48/73] Fix background sync for offline comments in Workbox 4 --- assets/js/amp-service-worker-offline-commenting.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/amp-service-worker-offline-commenting.js b/assets/js/amp-service-worker-offline-commenting.js index 1958e9f9f62..f74c5644a41 100644 --- a/assets/js/amp-service-worker-offline-commenting.js +++ b/assets/js/amp-service-worker-offline-commenting.js @@ -17,8 +17,8 @@ body: body } ); - // Add request to queue. @todo Replace when upgrading to Workbox v4! - queue.addRequest( queuedRequest ); + // Add request to queue. + queue.pushRequest( queuedRequest ); const jsonBody = JSON.stringify( { message: errorMessages.comment } ); return new Response( jsonBody, { From ee465e718f52f6ea90d102a9b033240aa24505a6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Nov 2018 13:38:24 -0800 Subject: [PATCH 49/73] Fix passing request to queue.pushRequest --- assets/js/amp-service-worker-offline-commenting.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/js/amp-service-worker-offline-commenting.js b/assets/js/amp-service-worker-offline-commenting.js index f74c5644a41..30597756282 100644 --- a/assets/js/amp-service-worker-offline-commenting.js +++ b/assets/js/amp-service-worker-offline-commenting.js @@ -18,7 +18,9 @@ } ); // Add request to queue. - queue.pushRequest( queuedRequest ); + queue.pushRequest( { + request: queuedRequest + } ); const jsonBody = JSON.stringify( { message: errorMessages.comment } ); return new Response( jsonBody, { From 4ea54d8c4c9fa328c10bc39023f1a85b197ba595 Mon Sep 17 00:00:00 2001 From: Miina Sikk Date: Tue, 2 Apr 2019 13:16:06 +0200 Subject: [PATCH 50/73] Update ignoring phpcs rule. --- includes/amp-helper-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index f3f7dcea403..663cb05b2db 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -1112,7 +1112,7 @@ function amp_start_app_shell_content() { * * @param string $content_placeholder Content placeholder. */ - echo apply_filters( 'amp_app_shell_content_placeholder', $content_placeholder ); // WPCS: XSS OK. + echo apply_filters( 'amp_app_shell_content_placeholder', $content_placeholder ); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped ob_start(); } From 854250eabb30a526bc2734479a4d8c44e15625e9 Mon Sep 17 00:00:00 2001 From: Miina Sikk Date: Tue, 2 Apr 2019 13:18:59 +0200 Subject: [PATCH 51/73] Fix more phpcs issues. --- includes/class-amp-theme-support.php | 32 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 5c7fb67a817..b93266d8d84 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -189,7 +189,7 @@ public static function read_theme_support() { $args = self::get_theme_support_args(); // Validate theme support usage. - $keys = array( 'template_dir', 'comments_live_list', 'paired', 'templates_supported', 'available_callback', 'nav_menu_toggle', 'nav_menu_dropdown', 'app_shell' ); + $keys = array( 'template_dir', 'comments_live_list', 'paired', 'templates_supported', 'available_callback', 'nav_menu_toggle', 'nav_menu_dropdown', 'app_shell' ); if ( count( array_diff( array_keys( $args ), $keys ) ) !== 0 ) { _doing_it_wrong( @@ -313,23 +313,29 @@ public static function init_app_shell() { // When inner app shell is requested, it is always an AMP request. Do not allow AMP when getting outer app shell for now (but this should be allowed in the future). if ( 'outer' === $requested_app_shell_component ) { - add_action( 'template_redirect', function() { - if ( ! is_amp_endpoint() ) { - return; + add_action( + 'template_redirect', + function() { + if ( ! is_amp_endpoint() ) { + return; + } + wp_die( + esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'AMP Outer App Shell Problem', 'amp' ), + array( 'response' => 400 ) + ); } - wp_die( - esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), - esc_html__( 'AMP Outer App Shell Problem', 'amp' ), - array( 'response' => 400 ) - ); - } ); + ); } // @todo This query param should be standardized and then this can be handled in the same place as WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header(). if ( 'outer' === $requested_app_shell_component ) { - add_filter( 'pre_get_document_title', function() { - return __( 'Loading...', 'amp' ); - } ); + add_filter( + 'pre_get_document_title', + function() { + return __( 'Loading...', 'amp' ); + } + ); } // Enqueue scripts for (outer) app shell, including precached app shell and normal site navigation prior to service worker installation. From d86790214b4e95795fcbcbed0c225e58bcc038e3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Jan 2020 10:16:19 -0800 Subject: [PATCH 52/73] Add missing changes to style sanitizer; remove duplicated autoloader entry --- includes/class-amp-autoloader.php | 1 - includes/class-amp-theme-support.php | 1 + includes/sanitizers/class-amp-style-sanitizer.php | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index 7ceee80c517..80b0fe4e765 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -30,7 +30,6 @@ class AMP_Autoloader { */ private static $classmap = [ 'AMP_Editor_Blocks' => 'includes/admin/class-amp-editor-blocks', - 'AMP_Service_Worker' => 'includes/class-amp-service-worker', 'AMP_Theme_Support' => 'includes/class-amp-theme-support', 'AMP_Service_Worker' => 'includes/class-amp-service-worker', 'AMP_HTTP' => 'includes/class-amp-http', diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 59842ee6263..4d8e51f624d 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -2315,6 +2315,7 @@ public static function prepare_response( $response, $args = [] ) { 'allow_dirty_styles' => self::is_customize_preview_iframe(), // Dirty styles only needed when editing (e.g. for edit shortcuts). 'allow_dirty_scripts' => is_customize_preview(), // Scripts are always needed to inject changeset UUID. 'user_can_validate' => AMP_Validation_Manager::has_cap(), + 'app_shell_component' => $app_shell_component, ], $args, compact( 'enable_response_caching' ) diff --git a/includes/sanitizers/class-amp-style-sanitizer.php b/includes/sanitizers/class-amp-style-sanitizer.php index 5cb9ccead3a..dd0f404a20c 100644 --- a/includes/sanitizers/class-amp-style-sanitizer.php +++ b/includes/sanitizers/class-amp-style-sanitizer.php @@ -128,6 +128,7 @@ class AMP_Style_Sanitizer extends AMP_Base_Sanitizer { 'include_manifest_comment' => 'always', 'focus_within_classes' => [ 'focus' ], 'low_priority_plugins' => [ 'query-monitor' ], + 'app_shell_component' => null, ]; /** @@ -3074,6 +3075,19 @@ function( $id ) { ) ); if ( $should_include ) { + // Make changes for serving stylesheet inside shadow DOM. + if ( 'inner' === $this->args['app_shell_component'] ) { + /* + * The :root pseudo selector does not work inside shadow DOM. Additionally, + * the shadow DOM is not including the root html element (or the head element), + * however there is a body element. The AMP plugin uses :root in the transformation + * of !important rules to give selectors high specificity. Replacing :root with + * body will not work all of the time. + * @todo The use of :root pseudo selectors in stylesheets needs to be revisited in Shadow DOM. + */ + $selector = preg_replace( '/:root\b/', 'body', $selector ); + } + $selectors[] = $selector; } } From c2667f7f7c3a01399908a4f4634ca7a5801884cb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Jan 2020 10:30:09 -0800 Subject: [PATCH 53/73] Fix phpcs linting issues --- includes/amp-helper-functions.php | 10 +++++----- includes/class-amp-service-worker.php | 4 ++-- includes/class-amp-theme-support.php | 24 ++++++++++++------------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index dac36c82395..54b4669d4f5 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -471,7 +471,7 @@ function amp_register_default_scripts( $wp_scripts ) { $wp_scripts->add( $handle, amp_get_asset_url( 'js/amp-wp-app-shell.js' ), - array( 'amp-shadow' ), + [ 'amp-shadow' ], AMP__VERSION . ( WP_DEBUG ? '-' . md5( file_get_contents( AMP__DIR__ . '/assets/js/amp-wp-app-shell.js' ) ) : '' ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents ); $wp_scripts->add_data( @@ -558,14 +558,14 @@ function amp_render_scripts( $scripts ) { * @return string Script loader tag. */ function amp_filter_script_loader_tag( $tag, $handle ) { - $prefix = 'https://cdn.ampproject.org/'; - $src = wp_scripts()->registered[ $handle ]->src; + $prefix = 'https://cdn.ampproject.org/'; + $src = wp_scripts()->registered[ $handle ]->src; $attributes = wp_scripts()->get_data( $handle, 'amp_script_attributes' ); if ( empty( $attributes ) && 0 === strpos( $src, $prefix ) ) { // All scripts from AMP CDN should be loaded async. - $attributes = array( + $attributes = [ 'async' => true, - ); + ]; } if ( empty( $attributes ) ) { return $tag; diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php index b9930e6b3db..4a846ae51ec 100644 --- a/includes/class-amp-service-worker.php +++ b/includes/class-amp-service-worker.php @@ -82,9 +82,9 @@ function ( $blacklist_patterns ) { 'amp_supportable_templates', function( $supportable_templates ) { if ( ! isset( $supportable_templates['is_offline'] ) ) { - $supportable_templates['is_offline'] = array( + $supportable_templates['is_offline'] = [ 'label' => __( 'Offline', 'amp' ), - ); + ]; } return $supportable_templates; }, diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index c17cbf347df..e8832201aa7 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -204,7 +204,7 @@ public static function init() { } add_action( 'widgets_init', [ __CLASS__, 'register_widgets' ] ); - add_action( 'parse_query', array( __CLASS__, 'init_app_shell' ), 9 ); + add_action( 'parse_query', [ __CLASS__, 'init_app_shell' ], 9 ); /* * Note that wp action is use instead of template_redirect because some themes/plugins output @@ -212,7 +212,7 @@ public static function init() { * action to template_redirect--the wp action--is used instead. */ if ( ! is_admin() ) { - add_action( 'wp', array( __CLASS__, 'finish_init' ), PHP_INT_MAX ); + add_action( 'wp', [ __CLASS__, 'finish_init' ], PHP_INT_MAX ); } } elseif ( AMP_Options_Manager::is_stories_experience_enabled() ) { add_action( @@ -495,7 +495,7 @@ function() { wp_die( esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), esc_html__( 'AMP Outer App Shell Problem', 'amp' ), - array( 'response' => 400 ) + [ 'response' => 400 ] ); } ); @@ -519,13 +519,13 @@ function() use ( $requested_app_shell_component ) { } wp_enqueue_script( 'amp-shadow' ); wp_enqueue_script( 'amp-wp-app-shell' ); - $exports = array( + $exports = [ 'contentElementId' => AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID, 'homeUrl' => home_url( '/' ), 'adminUrl' => admin_url( '/' ), 'componentQueryVar' => AMP_Theme_Support::APP_SHELL_COMPONENT_QUERY_VAR, 'isOuterAppShell' => 'outer' === $requested_app_shell_component, - ); + ]; wp_add_inline_script( 'amp-wp-app-shell', sprintf( 'var ampAppShell = %s;', wp_json_encode( $exports ) ), 'before' ); } ); @@ -2069,7 +2069,7 @@ public static function get_requested_app_shell_component() { return null; } $component = AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ]; - if ( in_array( $component, array( 'inner', 'outer' ), true ) ) { + if ( in_array( $component, [ 'inner', 'outer' ], true ) ) { return $component; } return null; @@ -2092,12 +2092,12 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ $admin_bar->parentNode->removeChild( $admin_bar ); } // Extract all stylesheet elements before the body gets isolated. - $style_elements = array(); + $style_elements = []; $lower_case = 'translate( %s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" )'; // In XPath 2.0 this is lower-case(). - $predicates = array( + $predicates = [ sprintf( '( self::style and ( not( @type ) or %s = "text/css" ) )', sprintf( $lower_case, '@type' ) ), sprintf( '( self::link and @href and %s = "stylesheet" )', sprintf( $lower_case, '@rel' ) ), - ); + ]; foreach ( $xpath->query( './/*[ ' . implode( ' or ', $predicates ) . ' ]', $body ) as $element ) { $style_elements[] = $element; } @@ -2105,7 +2105,7 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ $style_element->parentNode->removeChild( $style_element ); } // Preserve all svg defs which aren't inside the content element. - $svgs_with_def = array(); + $svgs_with_def = []; foreach ( $xpath->query( '//svg[.//defs]' ) as $svg ) { $svgs_with_def[] = $svg; } @@ -2118,7 +2118,7 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ $node->parentNode->removeChild( $node->nextSibling ); } }; - $node = $content_element; + $node = $content_element; do { $remove_siblings( $node ); $node = $node->parentNode; @@ -2148,7 +2148,7 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ } // Re-add the SVG element to the body with only its defs elements. if ( ! $is_connected ) { - $defs = array(); + $defs = []; foreach ( $svg->getElementsByTagName( 'defs' ) as $def ) { $defs[] = $def; } From 4c7595b77974f4aaff89af30fd910b5ddb89c4db Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Jan 2020 10:50:47 -0800 Subject: [PATCH 54/73] Restore missing live_list_offline_commenting to AMP_Service_Worker --- includes/class-amp-service-worker.php | 47 +++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php index 4a846ae51ec..5ea64169b5c 100644 --- a/includes/class-amp-service-worker.php +++ b/includes/class-amp-service-worker.php @@ -97,9 +97,10 @@ function( $supportable_templates ) { * See . */ $enabled_options = [ - 'cdn_script_caching' => true, - 'image_caching' => false, - 'google_fonts_caching' => false, + 'cdn_script_caching' => true, + 'image_caching' => false, + 'google_fonts_caching' => false, + 'live_list_offline_commenting' => false, ]; if ( isset( $theme_support['service_worker'] ) && is_array( $theme_support['service_worker'] ) ) { $enabled_options = array_merge( @@ -117,6 +118,9 @@ function( $supportable_templates ) { if ( $enabled_options['google_fonts_caching'] ) { add_action( 'wp_front_service_worker', [ __CLASS__, 'add_google_fonts_caching' ] ); } + if ( $enabled_options['google_fonts_caching'] ) { + add_action( 'wp_front_service_worker', [ __CLASS__, 'add_live_list_offline_commenting' ] ); + } } /** @@ -252,6 +256,43 @@ public static function add_google_fonts_caching( $service_workers ) { ); } + /** + * Add live list offline commenting service worker script. + * + * @param WP_Service_Worker_Scripts $service_workers WP Service Workers object. + */ + public static function add_live_list_offline_commenting( $service_workers ) { + if ( ! ( $service_workers instanceof WP_Service_Worker_Scripts ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Expected argument to be WP_Service_Worker_Scripts.', 'amp' ), '1.0' ); + return; + } + + $theme_support = AMP_Theme_Support::get_theme_support_args(); + if ( empty( $theme_support['comments_live_list'] ) ) { + return; + } + + $service_workers->register( + 'amp-offline-commenting', + function() { + $js = file_get_contents( AMP__DIR__ . '/assets/js/amp-service-worker-offline-commenting.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents + $js = preg_replace( '#/\*\s*global.+?\*/#', '', $js ); + $js = str_replace( + 'ERROR_MESSAGES', + wp_json_encode( wp_service_worker_get_error_messages() ), + $js + ); + $js = str_replace( + 'SITE_URL', + wp_json_encode( site_url() ), + $js + ); + return $js; + } + ); + + } + /** * Register URLs that will be precached in the runtime cache. (Yes, this sounds somewhat strange.) * From 06218820cdda9061221bb21d722725fa33fea3aa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Jan 2020 10:54:30 -0800 Subject: [PATCH 55/73] Make use of new Amp\AmpWP\Document methods; add line breaks --- includes/amp-helper-functions.php | 7 +++++ includes/class-amp-service-worker.php | 1 - includes/class-amp-theme-support.php | 41 ++++++++++++++++++--------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 54b4669d4f5..3d94be02c94 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -466,6 +466,7 @@ function amp_register_default_scripts( $wp_scripts ) { ), 'after' ); + // App shell library. $handle = 'amp-wp-app-shell'; $wp_scripts->add( @@ -1260,10 +1261,13 @@ function amp_start_app_shell_content() { if ( ! isset( $support_args['app_shell'] ) ) { return; } + printf( '
', esc_attr( AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID ) ); + // Start output buffering if requesting outer shell, since all content will be omitted from the response. if ( 'outer' === AMP_Theme_Support::get_requested_app_shell_component() ) { $content_placeholder = '

' . esc_html__( 'Loading…', 'amp' ) . '

'; + /** * Filters the content which is shown in the app shell for the content before it is loaded. * @@ -1275,6 +1279,7 @@ function amp_start_app_shell_content() { * @param string $content_placeholder Content placeholder. */ echo apply_filters( 'amp_app_shell_content_placeholder', $content_placeholder ); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped + ob_start(); } } @@ -1290,10 +1295,12 @@ function amp_end_app_shell_content() { if ( ! isset( $support_args['app_shell'] ) ) { return; } + // Clean output buffer if requesting outer shell, since all content will be omitted from the response. if ( 'outer' === AMP_Theme_Support::get_requested_app_shell_component() ) { ob_end_clean(); } + echo '
'; } diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php index 5ea64169b5c..fd4b0a8e8d5 100644 --- a/includes/class-amp-service-worker.php +++ b/includes/class-amp-service-worker.php @@ -290,7 +290,6 @@ function() { return $js; } ); - } /** diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index e8832201aa7..935adacb991 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -63,6 +63,7 @@ class AMP_Theme_Support { * @var string */ const APP_SHELL_COMPONENT_QUERY_VAR = 'amp_app_shell_component'; + /** * ID for element that contains the content for app shell. * @@ -483,7 +484,9 @@ public static function init_app_shell() { if ( ! isset( $theme_support['app_shell'] ) ) { return; } + $requested_app_shell_component = self::get_requested_app_shell_component(); + // When inner app shell is requested, it is always an AMP request. Do not allow AMP when getting outer app shell for now (but this should be allowed in the future). if ( 'outer' === $requested_app_shell_component ) { add_action( @@ -500,6 +503,7 @@ function() { } ); } + // @todo This query param should be standardized and then this can be handled in the same place as WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header(). if ( 'outer' === $requested_app_shell_component ) { add_filter( @@ -509,6 +513,7 @@ function() { } ); } + // Enqueue scripts for (outer) app shell, including precached app shell and normal site navigation prior to service worker installation. if ( 'inner' !== $requested_app_shell_component ) { add_action( @@ -519,6 +524,7 @@ function() use ( $requested_app_shell_component ) { } wp_enqueue_script( 'amp-shadow' ); wp_enqueue_script( 'amp-wp-app-shell' ); + $exports = [ 'contentElementId' => AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID, 'homeUrl' => home_url( '/' ), @@ -2064,33 +2070,33 @@ public static function get_requested_app_shell_component() { if ( ! isset( AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ] ) ) { return null; } + $theme_support_args = self::get_theme_support_args(); if ( ! isset( $theme_support_args['app_shell'] ) ) { return null; } + $component = AMP_HTTP::$purged_amp_query_vars[ self::APP_SHELL_COMPONENT_QUERY_VAR ]; if ( in_array( $component, [ 'inner', 'outer' ], true ) ) { return $component; } return null; } + /** * Prepare inner app shell. * * @param DOMElement $content_element Content element. */ protected static function prepare_inner_app_shell_document( DOMElement $content_element ) { - $dom = $content_element->ownerDocument; - $body = $dom->getElementsByTagName( 'body' )->item( 0 ); - if ( ! $body ) { - return; - } - $xpath = new DOMXPath( $dom ); + $dom = Document::from_node( $content_element ); + // Preserve the admin bar. $admin_bar = $dom->getElementById( 'wpadminbar' ); if ( $admin_bar ) { $admin_bar->parentNode->removeChild( $admin_bar ); } + // Extract all stylesheet elements before the body gets isolated. $style_elements = []; $lower_case = 'translate( %s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" )'; // In XPath 2.0 this is lower-case(). @@ -2098,17 +2104,19 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ sprintf( '( self::style and ( not( @type ) or %s = "text/css" ) )', sprintf( $lower_case, '@type' ) ), sprintf( '( self::link and @href and %s = "stylesheet" )', sprintf( $lower_case, '@rel' ) ), ]; - foreach ( $xpath->query( './/*[ ' . implode( ' or ', $predicates ) . ' ]', $body ) as $element ) { + foreach ( $dom->xpath->query( './/*[ ' . implode( ' or ', $predicates ) . ' ]', $dom->body ) as $element ) { $style_elements[] = $element; } foreach ( $style_elements as $style_element ) { $style_element->parentNode->removeChild( $style_element ); } + // Preserve all svg defs which aren't inside the content element. $svgs_with_def = []; - foreach ( $xpath->query( '//svg[.//defs]' ) as $svg ) { + foreach ( $dom->xpath->query( '//svg[.//defs]' ) as $svg ) { $svgs_with_def[] = $svg; } + // Isolate the content element from the rest of the elements in the body. $remove_siblings = function( DOMElement $node ) { while ( $node->previousSibling ) { @@ -2122,15 +2130,18 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ do { $remove_siblings( $node ); $node = $node->parentNode; - } while ( $node && $node !== $body ); + } while ( $node && $node !== $dom->body ); + // Restore admin bar element. - if ( $body && $admin_bar ) { - $body->appendChild( $admin_bar ); + if ( $admin_bar ) { + $dom->body->appendChild( $admin_bar ); } + // Restore style elements. foreach ( $style_elements as $style_element ) { - $body->appendChild( $style_element ); + $dom->body->appendChild( $style_element ); } + // Restore SVGs with defs. foreach ( $svgs_with_def as $svg ) { /* @@ -2146,22 +2157,26 @@ protected static function prepare_inner_app_shell_document( DOMElement $content_ } $node = $node->parentNode; } + // Re-add the SVG element to the body with only its defs elements. if ( ! $is_connected ) { $defs = []; foreach ( $svg->getElementsByTagName( 'defs' ) as $def ) { $defs[] = $def; } + // Remove all children. while ( $svg->firstChild ) { $svg->removeChild( $svg->firstChild ); } + // Re-add all defs. foreach ( $defs as $def ) { $svg->appendChild( $def ); } + // Add to body. - $body->appendChild( $svg ); + $dom->body->appendChild( $svg ); } } } From 280ae0b12fd9cf9aedab948c2301479bec7510de Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Jan 2020 11:10:53 -0800 Subject: [PATCH 56/73] Fix build and unit tests by preserving uncompiled scripts --- Gruntfile.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Gruntfile.js b/Gruntfile.js index 5fcdaace420..75a70bfae76 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -47,7 +47,13 @@ module.exports = function( grunt ) { // Clean up the build. clean: { compiled: { - src: [ 'assets/js/*.js', '!assets/js/amp-service-worker-runtime-precaching.js', 'assets/js/*.asset.php' ], + src: [ + 'assets/js/*.js', + '!assets/js/amp-service-worker-runtime-precaching.js', + '!assets/js/amp-service-worker-offline-commenting.js', + '!assets/js/amp-wp-app-shell.js', + 'assets/js/*.asset.php', + ], }, build: { src: [ 'build' ], From 69afdbd7644f802dc4bd03be9c9ee344062e4fe3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Jan 2020 11:20:47 -0800 Subject: [PATCH 57/73] Fix eslint issues --- assets/js/amp-service-worker-runtime-precaching.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/assets/js/amp-service-worker-runtime-precaching.js b/assets/js/amp-service-worker-runtime-precaching.js index 06f9a0cf9f8..4375b1bd3c7 100644 --- a/assets/js/amp-service-worker-runtime-precaching.js +++ b/assets/js/amp-service-worker-runtime-precaching.js @@ -1,9 +1,10 @@ -/* global URLS */ +/* global URLS, self, caches */ // See AMP_Service_Workers::add_amp_runtime_caching() and . { - self.addEventListener( 'install', event => { + self.addEventListener( 'install', ( event ) => { event.waitUntil( - caches.open( wp.serviceWorker.core.cacheNames.runtime ).then( cache => cache.addAll( URLS ) ) + caches.open( wp.serviceWorker.core.cacheNames.runtime ) + .then( ( cache ) => cache.addAll( URLS ) ), ); } ); } From 0c73fa33688708c0dfa8ed0de995cfb2211dc42c Mon Sep 17 00:00:00 2001 From: Mike Crantea Date: Sun, 15 Dec 2019 01:40:25 +0200 Subject: [PATCH 58/73] AppShell polyfill: Update to latest version - 2.4.0 --- includes/amp-helper-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 3d94be02c94..6e8bd5bc8eb 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -462,7 +462,7 @@ function amp_register_default_scripts( $wp_scripts ) { $handle, sprintf( 'if ( ! Element.prototype.attachShadow ) { const script = document.createElement( "script" ); script.src = %s; script.async = true; document.head.appendChild( script ); }', - wp_json_encode( 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.3.3/webcomponents-sd-ce.js' ) + wp_json_encode( 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/2.4.0/webcomponents-bundle.js' ) ), 'after' ); From 6054285f382b60790a0a4ab8af4fcd550959ec41 Mon Sep 17 00:00:00 2001 From: Mike Crantea Date: Sun, 15 Dec 2019 01:42:17 +0200 Subject: [PATCH 59/73] Amp-Wp-App-Shell - Improve flexibility for different types of caching Update amp-wp-app-shell.js **Rationale** Due to caching restrictions with WPengine the use of a query-string parameter to denote an inner shell request is problematic as it prevents the request from being cached independently. The request needs to be distinctly different from the outer shell in-order for it to be treated as a seperate cache item. **Changes** - Query string parameter is now appended to the path instead of - Fixed bug where code was always expecting a .site-branding element to exist --- assets/js/amp-wp-app-shell.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 6309b0ff688..2cccffe6781 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -112,6 +112,9 @@ */ function isHeaderVisible() { const element = document.querySelector( '.site-branding' ); + if ( ! element ) { + return; + } const clientRect = element.getBoundingClientRect(); return clientRect.height + clientRect.top >= 0; } @@ -124,7 +127,8 @@ */ function fetchShadowDocResponse( url ) { const ampUrl = new URL( url ); - ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); + const pathSuffix = '_' + ampAppShell.componentQueryVar + '_inner'; + ampUrl.pathname = ampUrl.pathname + pathSuffix; return fetch( ampUrl.toString(), { method: 'GET', From e6d97fd1e208bc2b4806ac071f2c9bea3f88692c Mon Sep 17 00:00:00 2001 From: Jonathan Barnett Date: Wed, 27 Nov 2019 20:54:22 +1100 Subject: [PATCH 60/73] Ensure .site-content-contain element exists **Rationale** A prior change introduced an early return for `isHeaderVisible` should the `site-branding` element not be found in the document. However this early return caused code to trigger elsewhere related to scrolling. **Changes** - Ensure `.site-content-contain` document lookup is also guarded to prevent calling scrollIntoView on a non-existing dom element. --- assets/js/amp-wp-app-shell.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 2cccffe6781..714bbe61c38 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -240,8 +240,14 @@ } if ( scrollIntoView && ! isHeaderVisible() ) { + const siteContent = document.querySelector( '.site-content-contain' ); + + if ( ! siteContent ) { + return; + } + // @todo The scroll position is not correct when admin bar is used. Consider scrolling to Y coordinate smoothly instead. - document.querySelector( '.site-content-contain' ).scrollIntoView( { + siteContent.scrollIntoView( { block: 'start', inline: 'start', behavior: 'smooth' From bb371b1c0115009d82289c49a72dd6009bababba Mon Sep 17 00:00:00 2001 From: Mike Crantea Date: Sun, 15 Dec 2019 01:50:57 +0200 Subject: [PATCH 61/73] Add a filter to allow altering AMP output from themes --- includes/class-amp-theme-support.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 935adacb991..f0da07cf65d 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -2033,7 +2033,7 @@ public static function is_output_buffering() { */ public static function finish_output_buffering( $response ) { self::$is_output_buffering = false; - return self::prepare_response( $response ); + return apply_filters( 'amp_document_output', self::prepare_response( $response ) ); } /** From b7ec7dcece92dd0a8b32fc120bd3e0f651ce97bd Mon Sep 17 00:00:00 2001 From: Mike Crantea Date: Wed, 22 Jan 2020 17:11:42 +0200 Subject: [PATCH 62/73] AppShell polyfill: Update to latest version - 2.4.1 --- includes/amp-helper-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 6e8bd5bc8eb..9f7ddc59b6e 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -462,7 +462,7 @@ function amp_register_default_scripts( $wp_scripts ) { $handle, sprintf( 'if ( ! Element.prototype.attachShadow ) { const script = document.createElement( "script" ); script.src = %s; script.async = true; document.head.appendChild( script ); }', - wp_json_encode( 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/2.4.0/webcomponents-bundle.js' ) + wp_json_encode( 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/2.4.1/webcomponents-bundle.js' ) ), 'after' ); From f13a442804eccb01ae07eb4246b5a777bfd9463f Mon Sep 17 00:00:00 2001 From: Jaroslav Polakovic Date: Mon, 10 Feb 2020 11:51:20 +0100 Subject: [PATCH 63/73] attachShadowDocAsStream expects a string type variable A recent change on the AMP platform enforces the URL parameter to be a string, but a URL object instance is passed on navigation or form submission currently. Adding an explicit type cast fixes the issue. ref. https://github.com/ampproject/amphtml/blob/f41de5e5205ae37a823e774af817e2748ad5ed09/src/multidoc-manager.js#L273 ref. https://github.com/ampproject/amphtml/commit/f41de5e5205ae37a823e774af817e2748ad5ed09 --- assets/js/amp-wp-app-shell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js index 6309b0ff688..b6e669fbd0f 100644 --- a/assets/js/amp-wp-app-shell.js +++ b/assets/js/amp-wp-app-shell.js @@ -175,7 +175,7 @@ * https://www.ampproject.org/latest/blog/streaming-in-the-shadow-reader/ * https://github.com/ampproject/amphtml/blob/master/spec/amp-shadow-doc.md#fetching-and-attaching-shadow-docs */ - currentShadowDoc = AMP.attachShadowDocAsStream( newContainer, url, {} ); + currentShadowDoc = AMP.attachShadowDocAsStream( newContainer, String( url ), {} ); // @todo If it is not an AMP document, then the loaded document needs to break out of the app shell. This should be done in readChunk() below. currentShadowDoc.ampdoc.whenReady().then( () => { From f59a9c97558fea957c956fe71a4d78bd850c9f28 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 16 Apr 2020 13:14:49 -0700 Subject: [PATCH 64/73] Revert introduction of amp_document_output filter for now --- includes/class-amp-theme-support.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index f0da07cf65d..935adacb991 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -2033,7 +2033,7 @@ public static function is_output_buffering() { */ public static function finish_output_buffering( $response ) { self::$is_output_buffering = false; - return apply_filters( 'amp_document_output', self::prepare_response( $response ) ); + return self::prepare_response( $response ); } /** From c4f67605b3b1a3e46fc1140c352e1d0db99a9604 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2020 16:04:56 -0700 Subject: [PATCH 65/73] Reuse constant Co-authored-by: Piotr Delawski --- includes/amp-helper-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 9f7ddc59b6e..bd4dceef839 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -1301,7 +1301,7 @@ function amp_end_app_shell_content() { ob_end_clean(); } - echo ''; + printf( '', esc_attr( AMP_Theme_Support::APP_SHELL_CONTENT_ELEMENT_ID ) ); } /** From 30e9faacb95198647917e668ecc794c492e1df2f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2020 16:05:46 -0700 Subject: [PATCH 66/73] Improve copy in comment and error message Co-authored-by: Piotr Delawski --- includes/class-amp-theme-support.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 935adacb991..be2919c83b2 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -208,8 +208,8 @@ public static function init() { add_action( 'parse_query', [ __CLASS__, 'init_app_shell' ], 9 ); /* - * Note that wp action is use instead of template_redirect because some themes/plugins output - * the response at this action and then short-circuit with exit. So this is why the the preceding + * Note that wp action is used instead of template_redirect because some themes/plugins output + * the response at this action and then short-circuit with exit. So this is why the preceding * action to template_redirect--the wp action--is used instead. */ if ( ! is_admin() ) { @@ -496,7 +496,7 @@ function() { return; } wp_die( - esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires paired mode).', 'amp' ), + esc_html__( 'Outer app shell can only be requested of the non-AMP version (thus requires Transitional mode).', 'amp' ), esc_html__( 'AMP Outer App Shell Problem', 'amp' ), [ 'response' => 400 ] ); From 6d6d2af067c116d06e25a9fa5bba2e83ce4f7641 Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Thu, 23 Jul 2020 16:58:37 +0200 Subject: [PATCH 67/73] Compile app shell JS as part of the webpack build process Building the app shell JS with webpack allows us to use third-party code (e.g. @wordpress packages) easily. We also take advantage of the code minification and babel transpilation. This is a step towards using @wordpress/hooks package in the app shell script. --- Gruntfile.js | 1 - assets/js/amp-wp-app-shell.js | 419 --------------------------- assets/src/amp-wp-app-shell/index.js | 416 ++++++++++++++++++++++++++ includes/amp-helper-functions.php | 13 +- webpack.config.js | 16 + 5 files changed, 441 insertions(+), 424 deletions(-) delete mode 100644 assets/js/amp-wp-app-shell.js create mode 100644 assets/src/amp-wp-app-shell/index.js diff --git a/Gruntfile.js b/Gruntfile.js index 75a70bfae76..f205b5d76ce 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -51,7 +51,6 @@ module.exports = function( grunt ) { 'assets/js/*.js', '!assets/js/amp-service-worker-runtime-precaching.js', '!assets/js/amp-service-worker-offline-commenting.js', - '!assets/js/amp-wp-app-shell.js', 'assets/js/*.asset.php', ], }, diff --git a/assets/js/amp-wp-app-shell.js b/assets/js/amp-wp-app-shell.js deleted file mode 100644 index 7120fb4c285..00000000000 --- a/assets/js/amp-wp-app-shell.js +++ /dev/null @@ -1,419 +0,0 @@ -/* global ampAppShell, AMP */ -/* eslint-disable no-console */ - -( function() { - let currentShadowDoc; - - /** - * Initialize. - */ - function init() { - const container = document.getElementById( ampAppShell.contentElementId ); - if ( ! container ) { - throw new Error( 'Lacking element with ID: ' + ampAppShell.contentElementId ); - } - - // @todo Intercept GET submissions. - // @todo Make sure that POST submissions are handled. - document.body.addEventListener( 'click', handleClick ); - document.body.addEventListener( 'submit', handleSubmit ); - - window.addEventListener( 'popstate', handlePopState ); - - if ( ampAppShell.isOuterAppShell ) { - loadUrl( location.href ); - } - } - - /** - * Handle popstate event. - */ - function handlePopState() { - loadUrl( window.location.href, { pushState: false } ); - } - - /** - * Is loadable URL. - * - * @param {URL} url - URL to be loaded. - * @return {boolean} Whether the URL can be loaded into a shadow doc. - */ - function isLoadableURL( url ) { - if ( url.pathname.endsWith( '.php' ) ) { - return false; - } - if ( url.href.startsWith( ampAppShell.adminUrl ) ) { - return false; - } - return url.href.startsWith( ampAppShell.homeUrl ); - } - - /** - * Handle clicks on links. - * - * @param {MouseEvent} event - Event. - */ - function handleClick( event ) { - if ( ! event.target.matches( 'a[href]' ) || event.target.closest( '#wpadminbar' ) ) { - return; - } - - // Skip handling click if it was handled already. - if ( event.defaultPrevented ) { - return; - } - - // @todo Handle page anchor links. - const url = new URL( event.target.href ); - if ( ! isLoadableURL( url ) ) { - return; - } - - loadUrl( url, { scrollIntoView: true } ); - event.preventDefault(); - } - - /** - * Handle submit on forms. - * - * @todo Handle POST requests. - * - * @param {Event} event - Event. - */ - function handleSubmit( event ) { - if ( ! event.target.matches( 'form[action]' ) || event.target.method.toUpperCase() !== 'GET' || event.target.closest( '#wpadminbar' ) ) { - return; - } - - // Skip handling click if it was handled already. - if ( event.defaultPrevented ) { - return; - } - - const url = new URL( event.target.action ); - if ( ! isLoadableURL( url ) ) { - return; - } - - for ( const element of event.target.elements ) { - if ( element.name && ! element.disabled ) { - // @todo Need to handle radios, checkboxes, submit buttons, etc. - url.searchParams.set( element.name, element.value ); - } - } - loadUrl( url, { scrollIntoView: true } ); - event.preventDefault(); - } - - /** - * Determine whether header is visible at all. - * - * @return {boolean} Whether header image is visible. - */ - function isHeaderVisible() { - const element = document.querySelector( '.site-branding' ); - if ( ! element ) { - return; - } - const clientRect = element.getBoundingClientRect(); - return clientRect.height + clientRect.top >= 0; - } - - /** - * Fetch response for URL of shadow doc. - * - * @param {string} url - URL. - * @return {Promise} Response promise. - */ - function fetchShadowDocResponse( url ) { - const ampUrl = new URL( url ); - const pathSuffix = '_' + ampAppShell.componentQueryVar + '_inner'; - ampUrl.pathname = ampUrl.pathname + pathSuffix; - - return fetch( ampUrl.toString(), { - method: 'GET', - mode: 'same-origin', - credentials: 'include', - redirect: 'follow', - cache: 'default', - referrer: 'client' - } ); - } - - /** - * Load URL. - * - * @todo When should scroll to the top? Only if the first element of the content is not visible? - * @param {string|URL} url - URL. - * @param {boolean} scrollIntoView - Scroll into view. - * @param {boolean} pushState - Whether to push state. - */ - function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { - const previousUrl = location.href; - const navigateEvent = new CustomEvent( 'wp-amp-app-shell-navigate', { - cancelable: true, - detail: { - previousUrl, - url: String( url ) - } - } ); - if ( ! window.dispatchEvent( navigateEvent ) ) { - return; // Allow scripts on page to cancel navigation via preventDefault() on wp-amp-app-shell-navigate event. - } - - updateNavMenuClasses( url ); - - fetchShadowDocResponse( url ) - .then( response => { - if ( currentShadowDoc ) { - currentShadowDoc.close(); - } - - const oldContainer = document.getElementById( ampAppShell.contentElementId ); - const newContainer = document.createElement( oldContainer.nodeName ); - newContainer.setAttribute( 'id', oldContainer.getAttribute( 'id' ) ); - oldContainer.parentNode.replaceChild( newContainer, oldContainer ); - - /* - * For more on this, see: - * https://www.ampproject.org/latest/blog/streaming-in-the-shadow-reader/ - * https://github.com/ampproject/amphtml/blob/master/spec/amp-shadow-doc.md#fetching-and-attaching-shadow-docs - */ - currentShadowDoc = AMP.attachShadowDocAsStream( newContainer, String( url ), {} ); - - // @todo If it is not an AMP document, then the loaded document needs to break out of the app shell. This should be done in readChunk() below. - currentShadowDoc.ampdoc.whenReady().then( () => { - let currentUrl; - if ( currentShadowDoc.canonicalUrl ) { - currentUrl = new URL( currentShadowDoc.canonicalUrl ); - - // Prevent updating the URL if the canonical URL is for the error template. - // @todo The rel=canonical link should not be output for these templates. - if ( currentUrl.searchParams.has( 'wp_error_template' ) ) { - currentUrl = currentUrl.href = url; - } - } else { - currentUrl = new URL( url ); - } - - // Update the nav menu classes if the final URL has redirected somewhere else. - if ( currentUrl.toString() !== url.toString() ) { - updateNavMenuClasses( currentUrl ); - } - - // @todo Improve styling of header when transitioning between home and non-home. - // @todo Synchronize additional meta in head. - // Update body class name. - document.body.className = newContainer.shadowRoot.querySelector( 'body' ).className; - document.title = currentShadowDoc.title; - if ( pushState ) { - history.pushState( - {}, - currentShadowDoc.title, - currentUrl.toString() - ); - } - - // @todo Consider allowing cancelable and when happens to prevent default initialization. - const readyEvent = new CustomEvent( 'wp-amp-app-shell-ready', { - cancelable: false, - detail: { - previousUrl, - oldContainer, - newContainer, - shadowDoc: currentShadowDoc - } - } ); - window.dispatchEvent( readyEvent ); - - newContainer.shadowRoot.addEventListener( 'click', handleClick ); - newContainer.shadowRoot.addEventListener( 'submit', handleSubmit ); - - /* - * Let admin bar in shadow doc replace admin bar in app shell (if it still exists). - * Very conveniently the admin bar _inside_ the shadow root can appear _outside_ - * the shadow root via fixed positioning! - */ - const originalAdminBar = document.getElementById( 'wpadminbar' ); - if ( originalAdminBar ) { - originalAdminBar.remove(); - } - - if ( scrollIntoView && ! isHeaderVisible() ) { - const siteContent = document.querySelector( '.site-content-contain' ); - - if ( ! siteContent ) { - return; - } - - // @todo The scroll position is not correct when admin bar is used. Consider scrolling to Y coordinate smoothly instead. - siteContent.scrollIntoView( { - block: 'start', - inline: 'start', - behavior: 'smooth' - } ); - } - } ); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - function readChunk() { - return reader.read().then( chunk => { - const input = chunk.value || new Uint8Array(); - const text = decoder.decode( - input, - { - stream: ! chunk.done - } - ); - if ( text ) { - currentShadowDoc.writer.write( text ); - } - if ( chunk.done ) { - currentShadowDoc.writer.close(); - } else { - return readChunk(); - } - } ); - } - - return readChunk(); - } ) - .catch( ( error ) => { - if ( 'amp_unavailable' === error ) { - window.location.assign( url ); - } else { - console.error( error ); - } - } ); - } - - /** - * Update class names in nav menus based on what URL is being navigated to. - * - * Note that this will only be able to account for: - * - current-menu-item (current_{object}_item) - * - current-menu-parent (current_{object}_parent) - * - current-menu-ancestor (current_{object}_ancestor) - * - * @param {string|URL} url URL. - */ - function updateNavMenuClasses( url ) { - const queriedUrl = new URL( url ); - queriedUrl.hash = ''; - - // Remove all contextual class names. - for ( const relation of [ 'item', 'parent', 'ancestor' ] ) { - const pattern = new RegExp( '^current[_-](.+)[_-]' + relation + '$' ); - for ( const item of document.querySelectorAll( '.menu-item.current-menu-' + relation + ', .page_item.current_page_' + relation ) ) { // Non-live NodeList. - for ( const className of Array.from( item.classList ) ) { // Live DOMTokenList. - if ( pattern.test( className ) ) { - item.classList.remove( className ); - } - } - } - } - - // Re-add class names to items generated from nav menus. - for ( const link of document.querySelectorAll( '.menu-item > a[href]' ) ) { - if ( link.href !== queriedUrl.href ) { - continue; - } - - let menuItemObjectName; - const menuItemObjectNamePrefix = 'menu-item-object-'; - for ( const className of link.parentElement.classList ) { - if ( className.startsWith( menuItemObjectNamePrefix ) ) { - menuItemObjectName = className.substr( menuItemObjectNamePrefix.length ); - break; - } - } - - let depth = 0; - let item = link.parentElement; - while ( item ) { - if ( 0 === depth ) { - item.classList.add( 'current-menu-item' ); - if ( menuItemObjectName ) { - link.parentElement.classList.add( `current_${menuItemObjectName}_item` ); - } - } else if ( 1 === depth ) { - item.classList.add( 'current-menu-parent' ); - item.classList.add( 'current-menu-ancestor' ); - if ( menuItemObjectName ) { - link.parentElement.classList.add( `current_${menuItemObjectName}_parent` ); - link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); - } - } else { - item.classList.add( 'current-menu-ancestor' ); - if ( menuItemObjectName ) { - link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); - } - } - depth++; - - if ( ! item.parentElement ) { - break; - } - item = item.parentElement.closest( '.menu-item-has-children' ); - } - - link.parentElement.classList.add( 'current-menu-item' ); - } - - // Re-add class names to items generated from page listings. - for ( const link of document.querySelectorAll( '.page_item > a[href]' ) ) { - if ( link.href !== queriedUrl.href ) { - continue; - } - let depth = 0; - let item = link.parentElement; - while ( item ) { - if ( 0 === depth ) { - item.classList.add( 'current_page_item' ); - } else if ( 1 === depth ) { - item.classList.add( 'current_page_parent' ); - item.classList.add( 'current_page_ancestor' ); - } else { - item.classList.add( 'current_page_ancestor' ); - } - depth++; - - if ( ! item.parentElement ) { - break; - } - item = item.parentElement.closest( '.page_item_has_children' ); - } - } - } - - // Initialize when Shadow DOM API loaded and DOM Ready. - const ampReadyPromise = new Promise( resolve => { - if ( ! window.AMP ) { - window.AMP = []; - } - window.AMP.push( resolve ); - } ); - const shadowDomPolyfillReadyPromise = new Promise( resolve => { - if ( Element.prototype.attachShadow ) { - // Native available. - resolve(); - } else { - // Otherwise, wait for polyfill to be installed. - window.addEventListener( 'WebComponentsReady', resolve ); - } - } ); - Promise.all( [ ampReadyPromise, shadowDomPolyfillReadyPromise ] ).then( () => { - // Code from @wordpress/dom-ready NPM package . - if ( - document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. - document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly. - ) { - init(); - } else { - // DOMContentLoaded has not fired yet, delay callback until then. - document.addEventListener( 'DOMContentLoaded', init ); - } - } ); -} )(); diff --git a/assets/src/amp-wp-app-shell/index.js b/assets/src/amp-wp-app-shell/index.js new file mode 100644 index 00000000000..041ca7d8582 --- /dev/null +++ b/assets/src/amp-wp-app-shell/index.js @@ -0,0 +1,416 @@ +/* global ampAppShell, AMP */ +/* eslint-disable no-console */ +let currentShadowDoc; + +/** + * Initialize. + */ +function init() { + const container = document.getElementById( ampAppShell.contentElementId ); + if ( ! container ) { + throw new Error( 'Lacking element with ID: ' + ampAppShell.contentElementId ); + } + + // @todo Intercept GET submissions. + // @todo Make sure that POST submissions are handled. + document.body.addEventListener( 'click', handleClick ); + document.body.addEventListener( 'submit', handleSubmit ); + + window.addEventListener( 'popstate', handlePopState ); + + if ( ampAppShell.isOuterAppShell ) { + loadUrl( location.href ); + } +} + +/** + * Handle popstate event. + */ +function handlePopState() { + loadUrl( window.location.href, { pushState: false } ); +} + +/** + * Is loadable URL. + * + * @param {URL} url - URL to be loaded. + * @return {boolean} Whether the URL can be loaded into a shadow doc. + */ +function isLoadableURL( url ) { + if ( url.pathname.endsWith( '.php' ) ) { + return false; + } + if ( url.href.startsWith( ampAppShell.adminUrl ) ) { + return false; + } + return url.href.startsWith( ampAppShell.homeUrl ); +} + +/** + * Handle clicks on links. + * + * @param {MouseEvent} event - Event. + */ +function handleClick( event ) { + if ( ! event.target.matches( 'a[href]' ) || event.target.closest( '#wpadminbar' ) ) { + return; + } + + // Skip handling click if it was handled already. + if ( event.defaultPrevented ) { + return; + } + + // @todo Handle page anchor links. + const url = new URL( event.target.href ); + if ( ! isLoadableURL( url ) ) { + return; + } + + loadUrl( url, { scrollIntoView: true } ); + event.preventDefault(); +} + +/** + * Handle submit on forms. + * + * @todo Handle POST requests. + * + * @param {Event} event - Event. + */ +function handleSubmit( event ) { + if ( ! event.target.matches( 'form[action]' ) || event.target.method.toUpperCase() !== 'GET' || event.target.closest( '#wpadminbar' ) ) { + return; + } + + // Skip handling click if it was handled already. + if ( event.defaultPrevented ) { + return; + } + + const url = new URL( event.target.action ); + if ( ! isLoadableURL( url ) ) { + return; + } + + for ( const element of event.target.elements ) { + if ( element.name && ! element.disabled ) { + // @todo Need to handle radios, checkboxes, submit buttons, etc. + url.searchParams.set( element.name, element.value ); + } + } + loadUrl( url, { scrollIntoView: true } ); + event.preventDefault(); +} + +/** + * Determine whether header is visible at all. + * + * @return {boolean} Whether header image is visible. + */ +function isHeaderVisible() { + const element = document.querySelector( '.site-branding' ); + if ( ! element ) { + return; + } + const clientRect = element.getBoundingClientRect(); + return clientRect.height + clientRect.top >= 0; +} + +/** + * Fetch response for URL of shadow doc. + * + * @param {string} url - URL. + * @return {Promise} Response promise. + */ +function fetchShadowDocResponse( url ) { + const ampUrl = new URL( url ); + const pathSuffix = '_' + ampAppShell.componentQueryVar + '_inner'; + ampUrl.pathname = ampUrl.pathname + pathSuffix; + + return fetch( ampUrl.toString(), { + method: 'GET', + mode: 'same-origin', + credentials: 'include', + redirect: 'follow', + cache: 'default', + referrer: 'client' + } ); +} + +/** + * Load URL. + * + * @todo When should scroll to the top? Only if the first element of the content is not visible? + * @param {string|URL} url - URL. + * @param {boolean} scrollIntoView - Scroll into view. + * @param {boolean} pushState - Whether to push state. + */ +function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { + const previousUrl = location.href; + const navigateEvent = new CustomEvent( 'wp-amp-app-shell-navigate', { + cancelable: true, + detail: { + previousUrl, + url: String( url ) + } + } ); + if ( ! window.dispatchEvent( navigateEvent ) ) { + return; // Allow scripts on page to cancel navigation via preventDefault() on wp-amp-app-shell-navigate event. + } + + updateNavMenuClasses( url ); + + fetchShadowDocResponse( url ) + .then( response => { + if ( currentShadowDoc ) { + currentShadowDoc.close(); + } + + const oldContainer = document.getElementById( ampAppShell.contentElementId ); + const newContainer = document.createElement( oldContainer.nodeName ); + newContainer.setAttribute( 'id', oldContainer.getAttribute( 'id' ) ); + oldContainer.parentNode.replaceChild( newContainer, oldContainer ); + + /* + * For more on this, see: + * https://www.ampproject.org/latest/blog/streaming-in-the-shadow-reader/ + * https://github.com/ampproject/amphtml/blob/master/spec/amp-shadow-doc.md#fetching-and-attaching-shadow-docs + */ + currentShadowDoc = AMP.attachShadowDocAsStream( newContainer, String( url ), {} ); + + // @todo If it is not an AMP document, then the loaded document needs to break out of the app shell. This should be done in readChunk() below. + currentShadowDoc.ampdoc.whenReady().then( () => { + let currentUrl; + if ( currentShadowDoc.canonicalUrl ) { + currentUrl = new URL( currentShadowDoc.canonicalUrl ); + + // Prevent updating the URL if the canonical URL is for the error template. + // @todo The rel=canonical link should not be output for these templates. + if ( currentUrl.searchParams.has( 'wp_error_template' ) ) { + currentUrl = currentUrl.href = url; + } + } else { + currentUrl = new URL( url ); + } + + // Update the nav menu classes if the final URL has redirected somewhere else. + if ( currentUrl.toString() !== url.toString() ) { + updateNavMenuClasses( currentUrl ); + } + + // @todo Improve styling of header when transitioning between home and non-home. + // @todo Synchronize additional meta in head. + // Update body class name. + document.body.className = newContainer.shadowRoot.querySelector( 'body' ).className; + document.title = currentShadowDoc.title; + if ( pushState ) { + history.pushState( + {}, + currentShadowDoc.title, + currentUrl.toString() + ); + } + + // @todo Consider allowing cancelable and when happens to prevent default initialization. + const readyEvent = new CustomEvent( 'wp-amp-app-shell-ready', { + cancelable: false, + detail: { + previousUrl, + oldContainer, + newContainer, + shadowDoc: currentShadowDoc + } + } ); + window.dispatchEvent( readyEvent ); + + newContainer.shadowRoot.addEventListener( 'click', handleClick ); + newContainer.shadowRoot.addEventListener( 'submit', handleSubmit ); + + /* + * Let admin bar in shadow doc replace admin bar in app shell (if it still exists). + * Very conveniently the admin bar _inside_ the shadow root can appear _outside_ + * the shadow root via fixed positioning! + */ + const originalAdminBar = document.getElementById( 'wpadminbar' ); + if ( originalAdminBar ) { + originalAdminBar.remove(); + } + + if ( scrollIntoView && ! isHeaderVisible() ) { + const siteContent = document.querySelector( '.site-content-contain' ); + + if ( ! siteContent ) { + return; + } + + // @todo The scroll position is not correct when admin bar is used. Consider scrolling to Y coordinate smoothly instead. + siteContent.scrollIntoView( { + block: 'start', + inline: 'start', + behavior: 'smooth' + } ); + } + } ); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + function readChunk() { + return reader.read().then( chunk => { + const input = chunk.value || new Uint8Array(); + const text = decoder.decode( + input, + { + stream: ! chunk.done + } + ); + if ( text ) { + currentShadowDoc.writer.write( text ); + } + if ( chunk.done ) { + currentShadowDoc.writer.close(); + } else { + return readChunk(); + } + } ); + } + + return readChunk(); + } ) + .catch( ( error ) => { + if ( 'amp_unavailable' === error ) { + window.location.assign( url ); + } else { + console.error( error ); + } + } ); +} + +/** + * Update class names in nav menus based on what URL is being navigated to. + * + * Note that this will only be able to account for: + * - current-menu-item (current_{object}_item) + * - current-menu-parent (current_{object}_parent) + * - current-menu-ancestor (current_{object}_ancestor) + * + * @param {string|URL} url URL. + */ +function updateNavMenuClasses( url ) { + const queriedUrl = new URL( url ); + queriedUrl.hash = ''; + + // Remove all contextual class names. + for ( const relation of [ 'item', 'parent', 'ancestor' ] ) { + const pattern = new RegExp( '^current[_-](.+)[_-]' + relation + '$' ); + for ( const item of document.querySelectorAll( '.menu-item.current-menu-' + relation + ', .page_item.current_page_' + relation ) ) { // Non-live NodeList. + for ( const className of Array.from( item.classList ) ) { // Live DOMTokenList. + if ( pattern.test( className ) ) { + item.classList.remove( className ); + } + } + } + } + + // Re-add class names to items generated from nav menus. + for ( const link of document.querySelectorAll( '.menu-item > a[href]' ) ) { + if ( link.href !== queriedUrl.href ) { + continue; + } + + let menuItemObjectName; + const menuItemObjectNamePrefix = 'menu-item-object-'; + for ( const className of link.parentElement.classList ) { + if ( className.startsWith( menuItemObjectNamePrefix ) ) { + menuItemObjectName = className.substr( menuItemObjectNamePrefix.length ); + break; + } + } + + let depth = 0; + let item = link.parentElement; + while ( item ) { + if ( 0 === depth ) { + item.classList.add( 'current-menu-item' ); + if ( menuItemObjectName ) { + link.parentElement.classList.add( `current_${menuItemObjectName}_item` ); + } + } else if ( 1 === depth ) { + item.classList.add( 'current-menu-parent' ); + item.classList.add( 'current-menu-ancestor' ); + if ( menuItemObjectName ) { + link.parentElement.classList.add( `current_${menuItemObjectName}_parent` ); + link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); + } + } else { + item.classList.add( 'current-menu-ancestor' ); + if ( menuItemObjectName ) { + link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); + } + } + depth++; + + if ( ! item.parentElement ) { + break; + } + item = item.parentElement.closest( '.menu-item-has-children' ); + } + + link.parentElement.classList.add( 'current-menu-item' ); + } + + // Re-add class names to items generated from page listings. + for ( const link of document.querySelectorAll( '.page_item > a[href]' ) ) { + if ( link.href !== queriedUrl.href ) { + continue; + } + let depth = 0; + let item = link.parentElement; + while ( item ) { + if ( 0 === depth ) { + item.classList.add( 'current_page_item' ); + } else if ( 1 === depth ) { + item.classList.add( 'current_page_parent' ); + item.classList.add( 'current_page_ancestor' ); + } else { + item.classList.add( 'current_page_ancestor' ); + } + depth++; + + if ( ! item.parentElement ) { + break; + } + item = item.parentElement.closest( '.page_item_has_children' ); + } + } +} + +// Initialize when Shadow DOM API loaded and DOM Ready. +const ampReadyPromise = new Promise( resolve => { + if ( ! window.AMP ) { + window.AMP = []; + } + window.AMP.push( resolve ); +} ); +const shadowDomPolyfillReadyPromise = new Promise( resolve => { + if ( Element.prototype.attachShadow ) { + // Native available. + resolve(); + } else { + // Otherwise, wait for polyfill to be installed. + window.addEventListener( 'WebComponentsReady', resolve ); + } +} ); +Promise.all( [ ampReadyPromise, shadowDomPolyfillReadyPromise ] ).then( () => { + // Code from @wordpress/dom-ready NPM package . + if ( + document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. + document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly. + ) { + init(); + } else { + // DOMContentLoaded has not fired yet, delay callback until then. + document.addEventListener( 'DOMContentLoaded', init ); + } +} ); diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index bd4dceef839..13613a34346 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -468,12 +468,17 @@ function amp_register_default_scripts( $wp_scripts ) { ); // App shell library. - $handle = 'amp-wp-app-shell'; + $handle = 'amp-wp-app-shell'; + $asset_file = AMP__DIR__ . '/assets/js/' . $handle . '.asset.php'; + $asset = require $asset_file; + $dependencies = $asset['dependencies']; + $dependencies[] = 'amp-shadow'; + $version = $asset['version']; $wp_scripts->add( $handle, - amp_get_asset_url( 'js/amp-wp-app-shell.js' ), - [ 'amp-shadow' ], - AMP__VERSION . ( WP_DEBUG ? '-' . md5( file_get_contents( AMP__DIR__ . '/assets/js/amp-wp-app-shell.js' ) ) : '' ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents + amp_get_asset_url( 'js/' . $handle . '.js' ), + $dependencies, + $version ); $wp_scripts->add_data( $handle, diff --git a/webpack.config.js b/webpack.config.js index 973bebe819a..22e00082a31 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -113,6 +113,21 @@ const ampValidation = { ], }; +const ampWpAppShell = { + ...defaultConfig, + ...sharedConfig, + entry: { + 'amp-wp-app-shell': './assets/src/amp-wp-app-shell/index.js', + }, + plugins: [ + ...defaultConfig.plugins, + new WebpackBar( { + name: 'AMP WP App Shell', + color: '#7F3f9c', + } ), + ], +}; + const blockEditor = { ...defaultConfig, ...sharedConfig, @@ -273,6 +288,7 @@ const wpPolyfills = { module.exports = [ ampStories, ampValidation, + ampWpAppShell, blockEditor, classicEditor, admin, From 5fbc2779fa168673ca8844d19f9f859753d8244c Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Thu, 23 Jul 2020 17:13:05 +0200 Subject: [PATCH 68/73] Use query param for inner component URL (revert 6054285f382b60790a0a4ab8af4fcd550959ec41) The change to the inner component URL format introduced in 6054285f382b60790a0a4ab8af4fcd550959ec41 broke the app shell experience. While URL format (use an URL segment instead of a query parameter) was changed in the JS implementation, the back-end PHP handler was still expecting the old format. It resulted in loading 404 pages instead of actual inner components for requested pages. The follow up commit will allow altering of the URL format if a plugin integrator really needs to do so. --- assets/src/amp-wp-app-shell/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/src/amp-wp-app-shell/index.js b/assets/src/amp-wp-app-shell/index.js index 041ca7d8582..b2388ea7a02 100644 --- a/assets/src/amp-wp-app-shell/index.js +++ b/assets/src/amp-wp-app-shell/index.js @@ -125,8 +125,7 @@ function isHeaderVisible() { */ function fetchShadowDocResponse( url ) { const ampUrl = new URL( url ); - const pathSuffix = '_' + ampAppShell.componentQueryVar + '_inner'; - ampUrl.pathname = ampUrl.pathname + pathSuffix; + ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); return fetch( ampUrl.toString(), { method: 'GET', From 3b9e07bf96c5f48188f4b1aa701687e557760570 Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Thu, 23 Jul 2020 17:19:23 +0200 Subject: [PATCH 69/73] Allow filter of the inner component URL In some cases, the plugin integrator may need to alter the format of the inner component URL. While it is possible to change the way incoming requests for inner components are handled on the back-end (PHP), there was no way to change the URL format of the request on the front-end (JS). This commit makes the inner component request URL filterable using wp.hooks. Here's how you could filter the URL in your plugin or theme so that instead of using a query parameter, a new segment is added to the URL: ``` if ( ampAppShell && ampAppShell.hooks ) { function filterInnerComponentUrl( url, baseUrl, componentQueryVar ) { const filteredUrl = new URL( baseUrl ); const pathSuffix = '_' + componentQueryVar + '_inner'; filteredUrl.pathname = filteredUrl.pathname + pathSuffix; return filteredUrl; } ampAppShell.hooks.addFilter( 'amp.appShell.innerComponentUrl', 'ampWpAppShell/filterInnerComponentUrl', filterInnerComponentUrl ); } ``` --- assets/src/amp-wp-app-shell/index.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/assets/src/amp-wp-app-shell/index.js b/assets/src/amp-wp-app-shell/index.js index b2388ea7a02..7fc9783a42d 100644 --- a/assets/src/amp-wp-app-shell/index.js +++ b/assets/src/amp-wp-app-shell/index.js @@ -1,5 +1,12 @@ /* global ampAppShell, AMP */ /* eslint-disable no-console */ +/** + * WordPress dependencies + */ +import { createHooks } from '@wordpress/hooks'; + +ampAppShell.hooks = ampAppShell.hooks || createHooks(); + let currentShadowDoc; /** @@ -124,8 +131,21 @@ function isHeaderVisible() { * @return {Promise} Response promise. */ function fetchShadowDocResponse( url ) { - const ampUrl = new URL( url ); - ampUrl.searchParams.set( ampAppShell.componentQueryVar, 'inner' ); + const { componentQueryVar } = ampAppShell; + const componentUrl = new URL( url ); + componentUrl.searchParams.set( componentQueryVar, 'inner' ); + + /** + * Filters the inner component URL. + * + * This filter is useful in case a format of the inner component URL has to + * be changed. + * + * @param {string} componentUrl Inner component URL. + * @param {string} url Document base URL. + * @param {string} componentQueryVar Component query parameter name. + */ + const ampUrl = ampAppShell.hooks.applyFilters( 'amp.appShell.innerComponentUrl', componentUrl, url, componentQueryVar ); return fetch( ampUrl.toString(), { method: 'GET', From c1cfd2151997e0ee03667257331fd6b9b3632e9e Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Thu, 23 Jul 2020 18:16:14 +0200 Subject: [PATCH 70/73] Fix lint issues --- assets/src/amp-wp-app-shell/index.js | 57 ++++++++++++++++------------ includes/amp-helper-functions.php | 2 +- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/assets/src/amp-wp-app-shell/index.js b/assets/src/amp-wp-app-shell/index.js index 7fc9783a42d..55aa941c3ae 100644 --- a/assets/src/amp-wp-app-shell/index.js +++ b/assets/src/amp-wp-app-shell/index.js @@ -1,5 +1,5 @@ /* global ampAppShell, AMP */ -/* eslint-disable no-console */ +/* eslint-env browser */ /** * WordPress dependencies */ @@ -118,7 +118,7 @@ function handleSubmit( event ) { function isHeaderVisible() { const element = document.querySelector( '.site-branding' ); if ( ! element ) { - return; + return false; } const clientRect = element.getBoundingClientRect(); return clientRect.height + clientRect.top >= 0; @@ -153,7 +153,7 @@ function fetchShadowDocResponse( url ) { credentials: 'include', redirect: 'follow', cache: 'default', - referrer: 'client' + referrer: 'client', } ); } @@ -162,8 +162,9 @@ function fetchShadowDocResponse( url ) { * * @todo When should scroll to the top? Only if the first element of the content is not visible? * @param {string|URL} url - URL. - * @param {boolean} scrollIntoView - Scroll into view. - * @param {boolean} pushState - Whether to push state. + * @param {Object} Args - Additional arguments. + * @param {boolean} Args.scrollIntoView - Scroll into view. + * @param {boolean} Args.pushState - Whether to push state. */ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { const previousUrl = location.href; @@ -171,8 +172,8 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { cancelable: true, detail: { previousUrl, - url: String( url ) - } + url: String( url ), + }, } ); if ( ! window.dispatchEvent( navigateEvent ) ) { return; // Allow scripts on page to cancel navigation via preventDefault() on wp-amp-app-shell-navigate event. @@ -181,7 +182,7 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { updateNavMenuClasses( url ); fetchShadowDocResponse( url ) - .then( response => { + .then( ( response ) => { if ( currentShadowDoc ) { currentShadowDoc.close(); } @@ -227,7 +228,7 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { history.pushState( {}, currentShadowDoc.title, - currentUrl.toString() + currentUrl.toString(), ); } @@ -238,8 +239,8 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { previousUrl, oldContainer, newContainer, - shadowDoc: currentShadowDoc - } + shadowDoc: currentShadowDoc, + }, } ); window.dispatchEvent( readyEvent ); @@ -267,7 +268,7 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { siteContent.scrollIntoView( { block: 'start', inline: 'start', - behavior: 'smooth' + behavior: 'smooth', } ); } } ); @@ -276,13 +277,15 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { const decoder = new TextDecoder(); function readChunk() { - return reader.read().then( chunk => { + // @todo Get rid of the eslint disable rule + /* eslint-disable consistent-return */ + return reader.read().then( ( chunk ) => { const input = chunk.value || new Uint8Array(); const text = decoder.decode( input, { - stream: ! chunk.done - } + stream: ! chunk.done, + }, ); if ( text ) { currentShadowDoc.writer.write( text ); @@ -293,6 +296,7 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { return readChunk(); } } ); + /* eslint-enable consistent-return */ } return readChunk(); @@ -301,18 +305,20 @@ function loadUrl( url, { scrollIntoView = false, pushState = true } = {} ) { if ( 'amp_unavailable' === error ) { window.location.assign( url ); } else { - console.error( error ); + console.error( error ); // eslint-disable-line no-console } } ); } +// @todo Refactor the function so that it doesn't exceed the complexity limit +/* eslint-disable complexity */ /** * Update class names in nav menus based on what URL is being navigated to. * * Note that this will only be able to account for: - * - current-menu-item (current_{object}_item) - * - current-menu-parent (current_{object}_parent) - * - current-menu-ancestor (current_{object}_ancestor) + * - current-menu-item (current_{object}_item) + * - current-menu-parent (current_{object}_parent) + * - current-menu-ancestor (current_{object}_ancestor) * * @param {string|URL} url URL. */ @@ -353,19 +359,19 @@ function updateNavMenuClasses( url ) { if ( 0 === depth ) { item.classList.add( 'current-menu-item' ); if ( menuItemObjectName ) { - link.parentElement.classList.add( `current_${menuItemObjectName}_item` ); + link.parentElement.classList.add( `current_${ menuItemObjectName }_item` ); } } else if ( 1 === depth ) { item.classList.add( 'current-menu-parent' ); item.classList.add( 'current-menu-ancestor' ); if ( menuItemObjectName ) { - link.parentElement.classList.add( `current_${menuItemObjectName}_parent` ); - link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); + link.parentElement.classList.add( `current_${ menuItemObjectName }_parent` ); + link.parentElement.classList.add( `current_${ menuItemObjectName }_ancestor` ); } } else { item.classList.add( 'current-menu-ancestor' ); if ( menuItemObjectName ) { - link.parentElement.classList.add( `current_${menuItemObjectName}_ancestor` ); + link.parentElement.classList.add( `current_${ menuItemObjectName }_ancestor` ); } } depth++; @@ -404,15 +410,16 @@ function updateNavMenuClasses( url ) { } } } +/* eslint-enable complexity */ // Initialize when Shadow DOM API loaded and DOM Ready. -const ampReadyPromise = new Promise( resolve => { +const ampReadyPromise = new Promise( ( resolve ) => { if ( ! window.AMP ) { window.AMP = []; } window.AMP.push( resolve ); } ); -const shadowDomPolyfillReadyPromise = new Promise( resolve => { +const shadowDomPolyfillReadyPromise = new Promise( ( resolve ) => { if ( Element.prototype.attachShadow ) { // Native available. resolve(); diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 13613a34346..6f30ae3fa19 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -469,7 +469,7 @@ function amp_register_default_scripts( $wp_scripts ) { // App shell library. $handle = 'amp-wp-app-shell'; - $asset_file = AMP__DIR__ . '/assets/js/' . $handle . '.asset.php'; + $asset_file = AMP__DIR__ . '/assets/js/' . $handle . '.asset.php'; $asset = require $asset_file; $dependencies = $asset['dependencies']; $dependencies[] = 'amp-shadow'; From fd23c00c566aeb7eab4ed2a66f900a36c22990f7 Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Tue, 28 Jul 2020 11:38:45 +0200 Subject: [PATCH 71/73] Fix indentation --- includes/class-amp-theme-support.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index aa30b7a9a79..2e6c521d0f0 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -173,10 +173,10 @@ public static function init() { add_action( 'parse_query', [ __CLASS__, 'init_app_shell' ], 9 ); /* - * Note that wp action is used instead of template_redirect because some themes/plugins output - * the response at this action and then short-circuit with exit. So this is why the preceding - * action to template_redirect--the wp action--is used instead. - */ + * Note that wp action is used instead of template_redirect because some themes/plugins output + * the response at this action and then short-circuit with exit. So this is why the preceding + * action to template_redirect--the wp action--is used instead. + */ if ( ! is_admin() ) { add_action( 'wp', [ __CLASS__, 'finish_init' ], PHP_INT_MAX ); } From 25045a761e65e00673f79fa9fde64bb268effea6 Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Tue, 28 Jul 2020 11:39:23 +0200 Subject: [PATCH 72/73] Use proper method name --- includes/class-amp-theme-support.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 2e6c521d0f0..ac410356155 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -1969,7 +1969,7 @@ public static function get_requested_app_shell_component() { * @param DOMElement $content_element Content element. */ protected static function prepare_inner_app_shell_document( DOMElement $content_element ) { - $dom = Document::from_node( $content_element ); + $dom = Document::fromNode( $content_element ); // Preserve the admin bar. $admin_bar = $dom->getElementById( 'wpadminbar' ); From 7b9077dab0a0191299862c176e19df47e43815db Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Tue, 28 Jul 2020 12:04:00 +0200 Subject: [PATCH 73/73] Fix lint issues --- includes/amp-helper-functions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 2d5c73c1f85..613a1aa6242 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -97,7 +97,7 @@ function amp_init() { $amp_service_worker->init(); add_filter( 'allowed_redirect_hosts', [ 'AMP_HTTP', 'filter_allowed_redirect_hosts' ] ); - add_filter( 'wp_redirect', array( 'AMP_HTTP', 'add_purged_query_vars' ) ); + add_filter( 'wp_redirect', [ 'AMP_HTTP', 'add_purged_query_vars' ] ); AMP_HTTP::purge_amp_query_vars(); AMP_HTTP::send_cors_headers(); AMP_HTTP::handle_xhr_request(); @@ -1779,7 +1779,6 @@ function amp_wp_kses_mustache( $markup ) { return wp_kses( $markup, array_fill_keys( $amp_mustache_allowed_html_tags, [] ) ); } - /** * Mark the beginning of the content that will be displayed inside the app shell. * @@ -1815,6 +1814,7 @@ function amp_start_app_shell_content() { ob_start(); } } + /** * Mark the end of the content that will be displayed inside the app shell. *