From 6d6d2af067c116d06e25a9fa5bba2e83ce4f7641 Mon Sep 17 00:00:00 2001 From: Piotr Delawski Date: Thu, 23 Jul 2020 16:58:37 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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';