diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index ebbca0b6c9f..cd9765b85cc 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -13,6 +13,7 @@ 'admin.onboarding_wizard' => \AmpProject\AmpWP\Admin\OnboardingWizardSubmenuPage::class, 'admin.options_menu' => \AmpProject\AmpWP\Admin\OptionsMenu::class, 'admin.polyfills' => \AmpProject\AmpWP\Admin\Polyfills::class, + 'admin.paired_browsing' => \AmpProject\AmpWP\Admin\PairedBrowsing::class, 'amp_slug_customization_watcher' => \AmpProject\AmpWP\AmpSlugCustomizationWatcher::class, 'css_transient_cache.ajax_handler' => \AmpProject\AmpWP\Admin\ReenableCssTransientCachingAjaxAction::class, 'css_transient_cache.monitor' => \AmpProject\AmpWP\BackgroundTask\MonitorCssTransientCaching::class, @@ -38,6 +39,8 @@ 'url_validation_cron' => \AmpProject\AmpWP\Validation\URLValidationCron::class, 'save_post_validation_event' => \AmpProject\AmpWP\Validation\SavePostValidationEvent::class, 'background_task_deactivator' => \AmpProject\AmpWP\BackgroundTask\BackgroundTaskDeactivator::class, + 'paired_routing' => \AmpProject\AmpWP\PairedRouting::class, + 'paired_url' => \AmpProject\AmpWP\PairedUrl::class, ] ) ); diff --git a/assets/src/admin/paired-browsing/app.css b/assets/src/admin/paired-browsing/app.css index 5e20cb7fc08..8d905a9dea9 100644 --- a/assets/src/admin/paired-browsing/app.css +++ b/assets/src/admin/paired-browsing/app.css @@ -152,7 +152,11 @@ iframe { padding: 13px 16px; } -.disconnect-overlay .dialog .dialog-buttons button { +.disconnect-overlay .dialog .dialog-buttons .button { + display: inline-block; + text-decoration: none; + color: #000; + background: rgb(239, 239, 239); margin: 5px; border: none; box-shadow: none; @@ -189,7 +193,3 @@ iframe { height: auto; width: auto; } - -.hidden { - display: none; -} diff --git a/assets/src/admin/paired-browsing/app.js b/assets/src/admin/paired-browsing/app.js index 821ec6a435c..bc9fdb5ff73 100644 --- a/assets/src/admin/paired-browsing/app.js +++ b/assets/src/admin/paired-browsing/app.js @@ -1,14 +1,20 @@ /** * WordPress dependencies */ -import { addQueryArgs, hasQueryArg, removeQueryArgs } from '@wordpress/url'; +import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; + /** * Internal dependencies */ import './app.css'; -const { app, history } = window; -const { ampSlug, noampQueryVar, noampMobile, ampPairedBrowsingQueryVar, documentTitlePrefix } = app; +const { ampPairedBrowsingAppData, history } = window; +const { + noampQueryVar, + noampMobile, + ampPairedBrowsingQueryVar, + documentTitlePrefix, +} = ampPairedBrowsingAppData; class PairedBrowsingApp { /** @@ -25,6 +31,13 @@ class PairedBrowsingApp { */ ampIframe; + /** + * Timestamp when the AMP iframe last sent a heartbeat. + * + * @type {number} + */ + ampHeartbeatTimestamp = Date.now(); + /** * Non-AMP IFrame * @@ -32,6 +45,55 @@ class PairedBrowsingApp { */ nonAmpIframe; + /** + * Timestamp when the non-AMP iframe last sent a heartbeat. + * + * @type {number} + */ + nonAmpHeartbeatTimestamp = Date.now(); + + /** + * Current AMP URL. + * + * @type {string} + */ + currentAmpUrl; + + /** + * Initial URL object for the AMP URL. + * + * @type {URL} + */ + initialAmpUrlObject; + + /** + * The most recent URL that was being navigated to in the AMP window. + * + * @type {?string} + */ + navigateAmpUrl; + + /** + * Current non-AMP URL. + * + * @type {string} + */ + currentNonAmpUrl; + + /** + * Initial URL object for the non-AMP URL. + * + * @type {URL} + */ + initialNonAmpUrlObject; + + /** + * The most recent URL that was being navigated to in the non-AMP window. + * + * @type {?string} + */ + navigateNonAmpUrl; + /** * Non-AMP Link * @@ -46,13 +108,24 @@ class PairedBrowsingApp { */ ampLink; + /** + * Active iframe. + * + * @type {?HTMLIFrameElement} + */ + activeIframe; + /** * Constructor. */ constructor() { this.nonAmpIframe = document.querySelector( '#non-amp iframe' ); this.ampIframe = document.querySelector( '#amp iframe' ); - this.ampPageHasErrors = false; + + this.currentNonAmpUrl = this.nonAmpIframe.src; + this.initialNonAmpUrlObject = new URL( this.currentNonAmpUrl ); + this.currentAmpUrl = this.ampIframe.src; + this.initialAmpUrlObject = new URL( this.currentNonAmpUrl ); // Link to exit paired browsing. this.nonAmpLink = /** @type {HTMLAnchorElement} */ document.getElementById( 'non-amp-link' ); @@ -60,33 +133,108 @@ class PairedBrowsingApp { // Overlay that is displayed on the client that becomes disconnected. this.disconnectOverlay = document.querySelector( '.disconnect-overlay' ); - this.disconnectText = { - general: document.querySelector( '.disconnect-overlay .dialog-text span.general' ), - invalidAmp: document.querySelector( '.disconnect-overlay .dialog-text span.invalid-amp' ), - }; this.disconnectButtons = { exit: document.querySelector( '.disconnect-overlay .button.exit' ), goBack: document.querySelector( '.disconnect-overlay .button.go-back' ), }; this.addDisconnectButtonListeners(); + global.addEventListener( 'message', ( event ) => { + this.receiveMessage( event ); + } ); + + // Set the active iframe based on which got the last mouseenter. + // Note that setting activeIframe may get set by receiveScroll if the user starts scrolling + // before moving the mouse. + document.getElementById( 'non-amp' ).addEventListener( 'mouseenter', () => { + this.activeIframe = this.nonAmpIframe; + } ); + document.getElementById( 'amp' ).addEventListener( 'mouseenter', () => { + this.activeIframe = this.ampIframe; + } ); + // Load clients. - Promise.all( this.getIframeLoadedPromises() ); + Promise.all( this.getIframeLoadedPromises() ).then( () => { + setInterval( + () => { + this.checkConnectedClients(); + }, + 100, + ); + } ); } /** - * Add event listeners for buttons on disconnect overlay. + * Return whether the window is for the AMP page. + * + * @param {Window} win Window. + * @return {boolean} Whether AMP window. */ - addDisconnectButtonListeners() { - // The 'Exit' button navigates the parent window to the URL of the disconnected client. - this.disconnectButtons.exit.addEventListener( 'click', () => { - window.location.assign( this.disconnectedClient.contentWindow.location.href ); - } ); + isAmpWindow( win ) { + return win === this.ampIframe.contentWindow; + } - // The 'Go back' button goes back to the previous page of the parent window. - this.disconnectButtons.goBack.addEventListener( 'click', () => { - window.history.back(); - } ); + /** + * Return whether the window is for the non-AMP page. + * + * @param {Window} win Window. + * @return {boolean} Whether non-AMP window. + */ + isNonAmpWindow( win ) { + return win === this.nonAmpIframe.contentWindow; + } + + /** + * Send message to app. + * + * @param {Window} win Window. + * @param {string} type Type. + * @param {Object} data Data. + */ + sendMessage( win, type, data = {} ) { + win.postMessage( + { + type, + ...data, + ampPairedBrowsing: true, + }, + this.isAmpWindow( win ) ? this.currentAmpUrl : this.currentNonAmpUrl, + ); + } + + /** + * Receive message. + * + * @param {MessageEvent} event + */ + receiveMessage( event ) { + if ( ! event.data || ! event.data.type || ! event.data.ampPairedBrowsing || ! event.source ) { + return; + } + + if ( ! [ this.initialNonAmpUrlObject.origin, this.initialAmpUrlObject.origin ].includes( event.origin ) ) { + return; + } + + if ( ! this.isAmpWindow( event.source ) && ! this.isNonAmpWindow( event.source ) ) { + return; + } + + switch ( event.data.type ) { + case 'loaded': + this.receiveLoaded( event.data, event.source ); + break; + case 'scroll': + this.receiveScroll( event.data, event.source ); + break; + case 'heartbeat': + this.receiveHeartbeat( event.source ); + break; + case 'navigate': + this.receiveNavigate( event.data, event.source ); + break; + default: + } } /** @@ -97,106 +245,117 @@ class PairedBrowsingApp { getIframeLoadedPromises() { return [ new Promise( ( resolve ) => { - this.nonAmpIframe.addEventListener( 'load', () => { - this.toggleDisconnectOverlay( this.nonAmpIframe ); - resolve(); - } ); + this.nonAmpIframe.addEventListener( 'load', resolve ); } ), - new Promise( ( resolve ) => { - this.ampIframe.addEventListener( 'load', () => { - this.toggleDisconnectOverlay( this.ampIframe ); - resolve(); - } ); + this.ampIframe.addEventListener( 'load', resolve ); } ), ]; } /** - * Validates whether or not the window document is AMP compatible. + * Receive heartbeat. * - * @param {Document} doc Window document. - * @return {boolean} True if AMP compatible, false if not. + * @param {Window} sourceWindow The source window. */ - documentIsAmp( doc ) { - return doc.querySelector( 'head > script[src="https://cdn.ampproject.org/v0.js"]' ); + receiveHeartbeat( sourceWindow ) { + if ( this.isAmpWindow( sourceWindow ) ) { + this.ampHeartbeatTimestamp = Date.now(); + } else { + this.nonAmpHeartbeatTimestamp = Date.now(); + } } /** - * Toggles the 'disconnected' overlay for the supplied iframe. + * Receive navigate. * - * @param {HTMLIFrameElement} iframe The iframe that hosts the paired browsing client. + * @param {Object} data Data. + * @param {string} data.href Href. + * @param {Window} sourceWindow The source window. */ - toggleDisconnectOverlay( iframe ) { - const isClientConnected = this.isClientConnected( iframe ); + receiveNavigate( { href }, sourceWindow ) { + if ( this.isAmpWindow( sourceWindow ) ) { + this.navigateAmpUrl = href; + } else { + this.navigateNonAmpUrl = href; + } + } - if ( ! isClientConnected ) { - if ( this.ampIframe === iframe && this.ampPageHasErrors ) { - this.disconnectText.general.classList.toggle( 'hidden', true ); - this.disconnectText.invalidAmp.classList.toggle( 'hidden', false ); - } else { - this.disconnectText.general.classList.toggle( 'hidden', false ); - this.disconnectText.invalidAmp.classList.toggle( 'hidden', true ); - } + /** + * Check connected clients. + */ + checkConnectedClients() { + this.sendMessage( this.ampIframe.contentWindow, 'init' ); + this.sendMessage( this.nonAmpIframe.contentWindow, 'init' ); + + if ( ! this.isClientConnected( this.ampIframe ) ) { + this.showDisconnectOverlay( this.ampIframe ); + } else if ( ! this.isClientConnected( this.nonAmpIframe ) ) { + this.showDisconnectOverlay( this.nonAmpIframe ); + } else { + this.disconnectOverlay.classList.remove( 'disconnected' ); + } + } - // Show the 'Go Back' button if the parent window has history. - this.disconnectButtons.goBack.classList.toggle( 'hidden', 0 >= window.history.length ); - // If the document is not available, the window URL cannot be accessed. - this.disconnectButtons.exit.classList.toggle( 'hidden', null === iframe.contentDocument ); + /** + * Add event listeners for buttons on disconnect overlay. + */ + addDisconnectButtonListeners() { + // The 'Go back' button goes back to the previous page of the parent window. + this.disconnectButtons.goBack.addEventListener( 'click', () => { + window.history.back(); + } ); + } - this.disconnectedClient = iframe; + /** + * Shows the 'disconnected' overlay for the supplied iframe. + * + * @param {HTMLIFrameElement} iframe The iframe that hosts the paired browsing client. + */ + showDisconnectOverlay( iframe ) { + // Show the exit link if we know the URL that the user was last trying to go to. + const navigateUrl = this.ampIframe === iframe ? this.navigateAmpUrl : this.navigateNonAmpUrl; + if ( navigateUrl ) { + this.disconnectButtons.exit.hidden = false; + this.disconnectButtons.exit.href = navigateUrl; + } else { + this.disconnectButtons.exit.hidden = true; } + // Show the 'Go Back' button if the parent window has history. + this.disconnectButtons.goBack.hidden = 0 >= window.history.length; + // Applying the 'amp' class will overlay it on the AMP iframe. this.disconnectOverlay.classList.toggle( 'amp', - ! isClientConnected && this.ampIframe === iframe, + this.ampIframe === iframe, ); - this.disconnectOverlay.classList.toggle( - 'disconnected', - ! isClientConnected, - ); + this.disconnectOverlay.classList.add( 'disconnected' ); } /** * Determines the status of the paired browsing client in an iframe. * * @param {HTMLIFrameElement} iframe The iframe. + * @return {boolean} Whether the client is connected. */ isClientConnected( iframe ) { - if ( this.ampIframe === iframe && this.ampPageHasErrors ) { - return false; + const threshold = 2000; + if ( iframe === this.ampIframe ) { + return Date.now() - this.ampHeartbeatTimestamp < threshold; } - - return null !== iframe.contentWindow && - null !== iframe.contentDocument && - true === iframe.contentWindow.ampPairedBrowsingClient; + return Date.now() - this.nonAmpHeartbeatTimestamp < threshold; } /** - * Removes AMP related query variables from the supplied URL. + * Purge removable query vars from the supplied URL. * * @param {string} url URL string. * @return {string} Modified URL without any AMP related query variables. */ - removeAmpQueryVars( url ) { - return removeQueryArgs( url, ampSlug, noampQueryVar, ampPairedBrowsingQueryVar ); - } - - /** - * Adds the AMP query variable to the supplied URL. - * - * @param {string} url URL string. - * @return {string} Modified URL with the AMP query variable. - */ - addAmpQueryVar( url ) { - return addQueryArgs( - url, - { - [ ampSlug ]: '1', - }, - ); + purgeRemovableQueryVars( url ) { + return removeQueryArgs( url, noampQueryVar, ampPairedBrowsingQueryVar ); } /** @@ -227,108 +386,112 @@ class PairedBrowsingApp { } /** - * Checks if a URL has the 'amp_validation_errors' query variable. + * Replace location. * - * @param {string} url URL string. - * @return {boolean} True if such query var exists, false if not. + * @param {HTMLIFrameElement} iframe IFrame Element. + * @param {string} url URL. + */ + replaceLocation( iframe, url ) { + this.sendMessage( + iframe.contentWindow, + 'replaceLocation', + { href: url }, + ); + } + + /** + * Receive scroll. + * + * @param {Object} data Data. + * @param {number} data.x X position. + * @param {number} data.y Y position. + * @param {Window} sourceWindow The source window. */ - urlHasValidationErrorQueryVar( url ) { - return hasQueryArg( url, 'amp_validation_errors' ); + receiveScroll( { x, y }, sourceWindow ) { + // Rely on scroll event to determine initially-active iframe before mouse first moves. + if ( ! this.activeIframe ) { + this.activeIframe = this.isAmpWindow( sourceWindow ) + ? this.ampIframe + : this.nonAmpIframe; + } + + // Ignore scroll events from the non-active iframe. + if ( ! this.activeIframe || sourceWindow !== this.activeIframe.contentWindow ) { + return; + } + + const otherWindow = this.isAmpWindow( sourceWindow ) + ? this.nonAmpIframe.contentWindow + : this.ampIframe.contentWindow; + this.sendMessage( otherWindow, 'scroll', { x, y } ); } /** - * Registers the provided client window with its parent, so that it can be managed by it. + * Receive loaded. * - * @param {Window} win Document window. - */ - registerClientWindow( win ) { - let oppositeWindow; - - if ( win === this.ampIframe.contentWindow ) { - if ( ! this.documentIsAmp( win.document ) ) { - if ( this.urlHasValidationErrorQueryVar( win.location.href ) ) { - /* - * If the AMP page has validation errors, mark the page as invalid so that the - * 'disconnected' overlay can be shown. - */ - this.ampPageHasErrors = true; - this.toggleDisconnectOverlay( this.ampIframe ); - return; - } else if ( win.document.querySelector( 'head > link[rel=amphtml]' ) ) { - // Force the AMP iframe to always have an AMP URL, if an AMP version is available. - win.location.replace( this.addAmpQueryVar( win.location.href ) ); - return; - } - - /* - * If the AMP iframe has loaded a non-AMP page and none of the conditions above are - * true, then explicitly mark it as having errors and display the 'disconnected - * overlay. - */ - this.ampPageHasErrors = true; - this.toggleDisconnectOverlay( this.ampIframe ); + * @param {Object} data Data. + * @param {boolean} data.isAmpDocument Whether the document is actually an AMP page. + * @param {string} data.ampUrl The AMP URL. + * @param {string} data.nonAmpUrl The non-AMP URL. + * @param {string} data.documentTitle The title of the document. + * @param {Window} sourceWindow The source window. + */ + receiveLoaded( { isAmpDocument, ampUrl, nonAmpUrl, documentTitle }, sourceWindow ) { + const isAmpSource = this.isAmpWindow( sourceWindow ); + const sourceIframe = isAmpSource ? this.ampIframe : this.nonAmpIframe; + + if ( isAmpSource ) { + // Force the AMP iframe to always have an AMP URL. + if ( ! isAmpDocument ) { + this.replaceLocation( sourceIframe, ampUrl ); return; } - // Update the AMP link above the iframe used for exiting paired browsing. - this.ampLink.href = removeQueryArgs( this.ampIframe.contentWindow.location.href, noampQueryVar ); + this.currentAmpUrl = ampUrl; - this.ampPageHasErrors = false; - oppositeWindow = this.nonAmpIframe.contentWindow; + // Update the AMP link above the iframe used for exiting paired browsing. + this.ampLink.href = removeQueryArgs( ampUrl, noampQueryVar ); } else { // Force the non-AMP iframe to always have a non-AMP URL. - if ( this.documentIsAmp( win.document ) ) { - win.location.replace( this.removeAmpQueryVars( win.location.href ) ); + if ( isAmpDocument ) { + this.replaceLocation( sourceIframe, nonAmpUrl ); return; } + this.currentNonAmpUrl = nonAmpUrl; + // Update the non-AMP link above the iframe used for exiting paired browsing. this.nonAmpLink.href = addQueryArgs( - this.nonAmpIframe.contentWindow.location.href, + nonAmpUrl, { [ noampQueryVar ]: noampMobile }, ); - - oppositeWindow = this.ampIframe.contentWindow; } - // Synchronize scrolling from current window to its opposite. - win.addEventListener( - 'scroll', - () => { - if ( oppositeWindow && oppositeWindow.ampPairedBrowsingClient && oppositeWindow.scrollTo ) { - oppositeWindow.scrollTo( win.scrollX, win.scrollY ); - } - }, - { passive: true }, - ); - - // Scrolling is not synchronized if `scroll-behavior` is set to `smooth`. - win.document.documentElement.style.setProperty( 'scroll-behavior', 'auto', 'important' ); - // Make sure the opposite iframe is set to match. + const thisCurrentUrl = isAmpSource ? nonAmpUrl : ampUrl; + const otherCurrentUrl = isAmpSource ? this.currentNonAmpUrl : this.currentAmpUrl; + if ( - oppositeWindow && - oppositeWindow.location && - ( - this.removeAmpQueryVars( this.removeUrlHash( oppositeWindow.location.href ) ) !== - this.removeAmpQueryVars( this.removeUrlHash( win.location.href ) ) - ) + this.purgeRemovableQueryVars( this.removeUrlHash( thisCurrentUrl ) ) !== + this.purgeRemovableQueryVars( this.removeUrlHash( otherCurrentUrl ) ) ) { - const url = oppositeWindow === this.ampIframe.contentWindow - ? this.addAmpQueryVar( win.location.href ) - : this.removeAmpQueryVars( win.location.href ); - - oppositeWindow.location.replace( url ); + const url = isAmpSource + ? nonAmpUrl + : ampUrl; + this.replaceLocation( + isAmpSource ? this.nonAmpIframe : this.ampIframe, + this.purgeRemovableQueryVars( url ), + ); return; } - document.title = documentTitlePrefix + ' ' + win.document.title; + document.title = documentTitlePrefix + ' ' + documentTitle; history.replaceState( {}, '', - this.addPairedBrowsingQueryVar( this.removeAmpQueryVars( win.location.href ) ), + this.addPairedBrowsingQueryVar( this.purgeRemovableQueryVars( nonAmpUrl ) ), ); } } diff --git a/assets/src/admin/paired-browsing/client.js b/assets/src/admin/paired-browsing/client.js index c54c8779f0f..36e503d0dbb 100644 --- a/assets/src/admin/paired-browsing/client.js +++ b/assets/src/admin/paired-browsing/client.js @@ -3,33 +3,169 @@ */ import domReady from '@wordpress/dom-ready'; -const { parent } = window; +const { parent, ampPairedBrowsingClientData } = window; +const { ampUrl, nonAmpUrl, isAmpDocument } = ampPairedBrowsingClientData; -if ( parent.pairedBrowsingApp ) { - window.ampPairedBrowsingClient = true; - const app = parent.pairedBrowsingApp; +const nonAmpUrlObject = new URL( nonAmpUrl ); - app.registerClientWindow( window ); +/** + * Modify document for paired browsing. + */ +function modifyDocumentForPairedBrowsing() { + // Scrolling is not synchronized if `scroll-behavior` is set to `smooth`. + document.documentElement.style.setProperty( 'scroll-behavior', 'auto', 'important' ); - domReady( () => { - if ( app.documentIsAmp( document ) ) { - // Hide the paired browsing menu item. - const pairedBrowsingMenuItem = document.getElementById( 'wp-admin-bar-amp-paired-browsing' ); - if ( pairedBrowsingMenuItem ) { - pairedBrowsingMenuItem.remove(); - } + if ( isAmpDocument ) { + // Hide the paired browsing menu item. + const pairedBrowsingMenuItem = document.getElementById( 'wp-admin-bar-amp-paired-browsing' ); + if ( pairedBrowsingMenuItem ) { + pairedBrowsingMenuItem.remove(); + } - // Hide menu item to view non-AMP version. - const ampViewBrowsingItem = document.getElementById( 'wp-admin-bar-amp-view' ); - if ( ampViewBrowsingItem ) { - ampViewBrowsingItem.remove(); - } - } else { - /** - * No need to show the AMP menu in the Non-AMP window. - */ - const ampMenuItem = document.getElementById( 'wp-admin-bar-amp' ); - ampMenuItem.remove(); + // Hide menu item to view non-AMP version. + const ampViewBrowsingItem = document.getElementById( 'wp-admin-bar-amp-view' ); + if ( ampViewBrowsingItem ) { + ampViewBrowsingItem.remove(); } - } ); + } else { + // No need to show the AMP menu in the Non-AMP window. + const ampMenuItem = document.getElementById( 'wp-admin-bar-amp' ); + ampMenuItem.remove(); + } +} + +/** + * Send message to app. + * + * @param {Window} win Window. + * @param {string} type Type. + * @param {Object} data Data. + */ +function sendMessage( win, type, data = {} ) { + win.postMessage( + { + type, + ...data, + ampPairedBrowsing: true, + }, + nonAmpUrlObject.origin, // Because the paired browsing app is accessed via the canonical URL. + ); +} + +let initialized = false; + +/** + * Receive message. + * + * @param {MessageEvent} event + */ +function receiveMessage( event ) { + if ( ! event.data || ! event.data.ampPairedBrowsing || ! event.data.type || ! event.source || nonAmpUrlObject.origin !== event.origin ) { + return; + } + switch ( event.data.type ) { + case 'init': + if ( ! initialized ) { + initialized = true; + receiveInit( event.data ); + } + break; + case 'scroll': + receiveScroll( event.data ); + break; + case 'replaceLocation': + receiveReplaceLocation( event.data ); + break; + default: + } +} + +/** + * Send scroll. + */ +function sendScroll() { + sendMessage( + parent, + 'scroll', + { + x: window.scrollX, + y: window.scrollY, + }, + ); +} + +/** + * Receive scroll. + * + * @param {Object} data + * @param {number} data.x + * @param {number} data.y + */ +function receiveScroll( { x, y } ) { + window.scrollTo( x, y ); } + +/** + * Handle click event. + * + * @param {MouseEvent} event + */ +function handleClick( event ) { + const element = event.target; + const link = element.matches( '[href]' ) ? element : element.closest( '[href]' ); + if ( link ) { + sendMessage( + parent, + 'navigate', + { href: link.href }, + ); + } +} + +/** + * Receive replace location. + * + * @param {string} href + */ +function receiveReplaceLocation( { href } ) { + window.location.replace( href ); +} + +/** + * Send loaded. + */ +function sendLoaded() { + sendMessage( + parent, + 'loaded', + { + isAmpDocument, + ampUrl, + nonAmpUrl, + documentTitle: document.title, + }, + ); +} + +/** + * Send heartbeat. + */ +function sendHeartbeat() { + sendMessage( parent, 'heartbeat' ); +} + +/** + * Receive init. + */ +function receiveInit() { + sendHeartbeat(); + setInterval( sendHeartbeat, 500 ); + + global.document.addEventListener( 'click', handleClick, { passive: true } ); + global.addEventListener( 'scroll', sendScroll, { passive: true } ); + domReady( modifyDocumentForPairedBrowsing ); + + sendLoaded(); +} + +global.addEventListener( 'message', receiveMessage ); diff --git a/assets/src/block-editor/components/amp-preview-button.js b/assets/src/block-editor/components/amp-preview-button.js index 252a0f366c1..454798385cd 100644 --- a/assets/src/block-editor/components/amp-preview-button.js +++ b/assets/src/block-editor/components/amp-preview-button.js @@ -11,7 +11,6 @@ import { compose } from '@wordpress/compose'; import { withDispatch, withSelect } from '@wordpress/data'; import { Component, createRef, renderToString } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -172,6 +171,9 @@ class AmpPreviewButton extends Component { // https://github.com/WordPress/gutenberg/pull/8330 event.preventDefault(); + /** @type {HTMLAnchorElement} target */ + const { target } = event; + // Open up a Preview tab if needed. This is where we'll show the preview. if ( ! this.previewWindow || this.previewWindow.closed ) { this.previewWindow = window.open( '', this.getWindowTarget() ); @@ -185,7 +187,7 @@ class AmpPreviewButton extends Component { // If we don't need to autosave the post before previewing, then we simply // load the Preview URL in the Preview tab. if ( ! this.props.isAutosaveable ) { - this.setPreviewWindowLink( event.target.href ); + this.setPreviewWindowLink( target.href ); return; } @@ -253,7 +255,6 @@ export default compose( [ withSelect( ( select, { forcePreviewLink, forceIsAutosaveable } ) => { const { getCurrentPostId, - getCurrentPostAttribute, getEditedPostAttribute, isEditedPostSaveable, isEditedPostAutosaveable, @@ -261,20 +262,27 @@ export default compose( [ } = select( 'core/editor' ); const { - getAmpSlug, + getAmpUrl, + getAmpPreviewLink, getErrorMessages, isStandardMode, } = select( 'amp/block-editor' ); - const queryArgs = {}; - queryArgs[ getAmpSlug() ] = 1; + const copyQueryArgs = ( source, destination ) => { + const sourceUrl = new URL( source ); + const destinationUrl = new URL( destination ); + for ( const [ key, value ] of sourceUrl.searchParams.entries() ) { + destinationUrl.searchParams.set( key, value ); + } + return destinationUrl.href; + }; const initialPreviewLink = getEditedPostPreviewLink(); - const previewLink = initialPreviewLink ? addQueryArgs( initialPreviewLink, queryArgs ) : undefined; + const previewLink = initialPreviewLink ? copyQueryArgs( initialPreviewLink, getAmpPreviewLink() ) : undefined; return { postId: getCurrentPostId(), - currentPostLink: addQueryArgs( getCurrentPostAttribute( 'link' ), queryArgs ), + currentPostLink: getAmpUrl(), previewLink: forcePreviewLink !== undefined ? forcePreviewLink : previewLink, isSaveable: isEditedPostSaveable(), isAutosaveable: forceIsAutosaveable || isEditedPostAutosaveable(), diff --git a/assets/src/block-editor/index.js b/assets/src/block-editor/index.js index dc238acd3c0..1c7de28ea42 100644 --- a/assets/src/block-editor/index.js +++ b/assets/src/block-editor/index.js @@ -22,7 +22,11 @@ const { const plugins = require.context( './plugins', true, /.*\.js$/ ); plugins.keys().forEach( ( modulePath ) => { - const { name, render, icon } = plugins( modulePath ); + const { name, render, icon, onlyPaired = false } = plugins( modulePath ); + + if ( onlyPaired && isStandardMode() ) { + return; + } registerPlugin( name, { icon, render } ); } ); diff --git a/assets/src/block-editor/plugins/wrapped-amp-preview-button.js b/assets/src/block-editor/plugins/wrapped-amp-preview-button.js index 7e8847e002d..9f143f42652 100644 --- a/assets/src/block-editor/plugins/wrapped-amp-preview-button.js +++ b/assets/src/block-editor/plugins/wrapped-amp-preview-button.js @@ -71,6 +71,8 @@ class WrappedAmpPreviewButton extends Component { export const name = 'amp-preview-button-wrapper'; +export const onlyPaired = true; + export const render = pure( compose( [ withSelect( ( select ) => { diff --git a/assets/src/block-editor/store/selectors.js b/assets/src/block-editor/store/selectors.js index 02153bf2273..ac917e506b9 100644 --- a/assets/src/block-editor/store/selectors.js +++ b/assets/src/block-editor/store/selectors.js @@ -32,12 +32,23 @@ export function getErrorMessages( state ) { } /** - * Returns the AMP slug used in the query var, like 'amp'. + * Returns the AMP preview link (URL). * * @param {Object} state The editor state. * - * @return {string} The slug for AMP, like 'amp'. + * @return {string} The AMP preview link URL. */ -export function getAmpSlug( state ) { - return state.ampSlug; +export function getAmpPreviewLink( state ) { + return state.ampPreviewLink; +} + +/** + * Returns the AMP URL. + * + * @param {Object} state The editor state. + * + * @return {string} The AMP URL. + */ +export function getAmpUrl( state ) { + return state.ampUrl; } diff --git a/assets/src/block-editor/store/test/selectors.js b/assets/src/block-editor/store/test/selectors.js index 08da44bd3d3..19eacf352dd 100644 --- a/assets/src/block-editor/store/test/selectors.js +++ b/assets/src/block-editor/store/test/selectors.js @@ -5,7 +5,8 @@ import { hasThemeSupport, isStandardMode, getErrorMessages, - getAmpSlug, + getAmpPreviewLink, + getAmpUrl, } from '../selectors'; describe( 'selectors', () => { @@ -34,12 +35,21 @@ describe( 'selectors', () => { } ); } ); - describe( 'getAmpSlug', () => { - it( 'should return the AMP slug', () => { - const slug = 'amp'; - const state = { ampSlug: slug }; + describe( 'getAmpUrl', () => { + it( 'should return the paired AMP url', () => { + const url = 'https://example.com/?amp=1'; + const state = { ampUrl: url }; - expect( getAmpSlug( state ) ).toStrictEqual( slug ); + expect( getAmpUrl( state ) ).toStrictEqual( url ); + } ); + } ); + + describe( 'getAmpPreviewLink', () => { + it( 'should return the AMP preview link', () => { + const url = 'https://example.com/?preview=true&=1'; + const state = { ampPreviewLink: url }; + + expect( getAmpPreviewLink( state ) ).toStrictEqual( url ); } ); } ); } ); diff --git a/assets/src/settings-page/index.js b/assets/src/settings-page/index.js index 95b4b2885a3..52bb5a6078b 100644 --- a/assets/src/settings-page/index.js +++ b/assets/src/settings-page/index.js @@ -37,6 +37,7 @@ import { MobileRedirection } from './mobile-redirection'; import { SettingsFooter } from './settings-footer'; import { PluginSuppression } from './plugin-suppression'; import { Analytics } from './analytics'; +import { PairedUrlStructure } from './paired-url-structure'; const { ajaxurl: wpAjaxUrl } = global; @@ -188,6 +189,7 @@ function Root( { appRoot } ) { > + diff --git a/assets/src/settings-page/paired-url-structure.js b/assets/src/settings-page/paired-url-structure.js new file mode 100644 index 00000000000..af8f3b98c79 --- /dev/null +++ b/assets/src/settings-page/paired-url-structure.js @@ -0,0 +1,263 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { useContext } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { Options } from '../components/options-context-provider'; +import { AMPDrawer } from '../components/amp-drawer'; +import { AMPNotice, NOTICE_TYPE_INFO, NOTICE_SIZE_LARGE } from '../components/amp-notice'; + +/** + * @typedef {{name: string, slug: string, type: string}} Source + * @typedef {{query_var: string[], path_suffix: string[], legacy_transitional: string[], legacy_reader: string[], custom: string[]}} PairedUrlExamplesData + */ + +/** + * Paired URL examples. + * + * @param {Object} props Component props. + * @param {?Array} props.pairedUrls Paired URLs. + */ +const PairedUrlExamples = ( { pairedUrls } ) => { + if ( ! pairedUrls ) { + return null; + } + + return ( +
+ + { __( 'Examples', 'amp' ) } + + { + pairedUrls.map( ( pairedUrl ) => { + return ( +
+ + { pairedUrl } + +
+ ); + } ) + } +
+ ); +}; +PairedUrlExamples.propTypes = { + pairedUrls: PropTypes.arrayOf( PropTypes.string ), +}; + +/** + * Get custom paired structure sources. + * + * @param {Array.} sources Sources. + * @return {string} Sources string. + */ +function getCustomPairedStructureSources( sources ) { + let message = __( 'The custom structure is being introduced by:', 'amp' ) + ' '; + message += sources.map( ( source ) => { + let sourceString = source.name || source.slug; + let typeString; + switch ( source.type ) { + case 'plugin': + typeString = __( 'a plugin', 'amp' ); + break; + case 'theme': + typeString = __( 'a theme', 'amp' ); + break; + case 'mu-plugin': + typeString = __( 'a must-use plugin', 'amp' ); + break; + default: + typeString = null; + } + if ( typeString ) { + sourceString += ` (${ typeString })`; + } + + return sourceString; + } ).join( ', ' ) + '.'; + return message; +} + +/** + * Component rendering the paired URL structure. + * + * @param {Object} props Component props. + * @param {string} props.focusedSection Focused section. + */ +export function PairedUrlStructure( { focusedSection } ) { + /** @type {{amp_slug:string, endpoint_path_slug_conflicts:Array, custom_paired_endpoint_sources:Array., paired_url_examples: PairedUrlExamplesData, rewrite_using_permalinks: boolean}} editedOptions */ + const { editedOptions, updateOptions } = useContext( Options ); + + const { theme_support: themeSupport } = editedOptions || {}; + + // Don't show if the mode is standard or the themeSupport is not yet set. + if ( ! themeSupport || 'standard' === themeSupport ) { + return null; + } + + const slug = editedOptions.amp_slug; + + const isCustom = 'custom' === editedOptions.paired_url_structure; + + const endpointSuffixAvailable = editedOptions.endpoint_path_slug_conflicts.length === 0; + + return ( + + { __( 'Paired URL Structure', 'amp' ) } + + ) } + hiddenTitle={ __( 'Paired URL Structure', 'amp' ) } + id="paired-url-structure" + initialOpen={ 'paired-url-structure' === focusedSection } + > + + { isCustom && ( + +

+ { __( 'A custom paired URL structure is being applied so the following options are unavailable.', 'amp' ) } + { editedOptions.custom_paired_endpoint_sources.length > 0 && + ' ' + getCustomPairedStructureSources( editedOptions.custom_paired_endpoint_sources ) } +

+ +
+ ) } + + { ! endpointSuffixAvailable && ( + +

+ { + sprintf( + /* translators: %s is the AMP slug */ + __( 'There is a post, term, user, or some other entity that is already using the ā€œ%sā€ URL slug. For this reason, you cannot currently use the path suffix or legacy reader paired URL structures.', 'amp' ), + slug, + ) + } +

+
+ ) } + +
    +
  • + { + updateOptions( { paired_url_structure: 'query_var' } ); + } } + disabled={ isCustom } + /> + + +
  • +
  • + { + updateOptions( { paired_url_structure: 'path_suffix' } ); + } } + disabled={ isCustom || ! endpointSuffixAvailable || ! editedOptions.rewrite_using_permalinks } + /> + + +
  • +
  • + { + updateOptions( { paired_url_structure: 'legacy_transitional' } ); + } } + disabled={ isCustom } + /> + + +
  • +
  • + { + updateOptions( { paired_url_structure: 'legacy_reader' } ); + } } + disabled={ isCustom || ! endpointSuffixAvailable || ! editedOptions.rewrite_using_permalinks } + /> + + +
  • +
+
+ ); +} +PairedUrlStructure.propTypes = { + focusedSection: PropTypes.string, +}; diff --git a/assets/src/settings-page/style.css b/assets/src/settings-page/style.css index 20c3f1c3676..dc1e3e0e5a4 100644 --- a/assets/src/settings-page/style.css +++ b/assets/src/settings-page/style.css @@ -360,7 +360,8 @@ li.error-kept { } #plugin-suppression .amp-drawer__panel-body-inner, -.amp-analytics .amp-drawer__panel-body-inner { +.amp-analytics .amp-drawer__panel-body-inner, +#paired-url-structure .amp-drawer__panel-body-inner { padding: 1.5rem 1.5rem 3rem; @media (min-width: 783px) { @@ -369,11 +370,54 @@ li.error-kept { } #plugin-suppression .amp-drawer__panel-body-inner > p, -.amp-analytics .amp-drawer__panel-body-inner p { +.amp-analytics .amp-drawer__panel-body-inner p, +#paired-url-structure .amp-drawer__panel-body-inner { margin-top: 0; font-size: 14px; } +#paired-url-structure .amp-drawer__panel-body-inner .amp-notice { + margin-bottom: 1em; +} + +.amp-drawer__panel-body-inner .amp-paired-url-examples { + margin-top: 0.5em; + margin-bottom: 0; +} + +.amp-drawer__panel-body-inner .amp-paired-url-example { + margin-top: 5px; + margin-bottom: 5px; + line-height: 1.5; +} + +#paired-url-structure .amp-notice--large { + align-items: start; +} + +#paired-url-structure .amp-drawer__panel-body-inner li { + margin-top: 1em; +} + +#paired-url-structure .amp-drawer__panel-body-inner li:first-child { + margin-top: 0; +} + +#paired-url-structure .amp-drawer__panel-body-inner li .amp-paired-url-examples { + margin-left: 34px; + margin-bottom: 0; +} + +#paired-url-structure .amp-paired-url-examples summary { + display: inline-block; + padding-right: 4px; + user-select: none; +} + +#paired-url-structure .amp-drawer__panel-body-inner input { + margin-right: 8px; +} + #analytics-options details > summary { font-size: 14px; } diff --git a/includes/admin/class-amp-post-meta-box.php b/includes/admin/class-amp-post-meta-box.php index 632213442e5..159182d1c68 100644 --- a/includes/admin/class-amp-post-meta-box.php +++ b/includes/admin/class-amp-post-meta-box.php @@ -249,13 +249,16 @@ public function enqueue_block_assets() { true ); + $is_standard_mode = amp_is_canonical(); + list( $featured_image_minimum_width, $featured_image_minimum_height ) = self::get_featured_image_dimensions(); $data = [ - 'ampSlug' => amp_get_slug(), + 'ampUrl' => $is_standard_mode ? null : amp_add_paired_endpoint( get_permalink( $post ) ), + 'ampPreviewLink' => $is_standard_mode ? null : amp_add_paired_endpoint( get_preview_post_link( $post ) ), 'errorMessages' => $this->get_error_messages( $status_and_errors['errors'] ), 'hasThemeSupport' => ! amp_is_legacy(), - 'isStandardMode' => amp_is_canonical(), + 'isStandardMode' => $is_standard_mode, 'featuredImageMinimumWidth' => $featured_image_minimum_width, 'featuredImageMinimumHeight' => $featured_image_minimum_height, ]; diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index f2ad206a7ca..37e0e25a499 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -23,11 +23,6 @@ */ function amp_activate( $network_wide = false ) { AmpWpPluginFactory::create()->activate( $network_wide ); - amp_after_setup_theme(); - if ( ! did_action( 'amp_init' ) ) { - amp_init(); - } - flush_rewrite_rules(); } /** @@ -40,16 +35,6 @@ function amp_activate( $network_wide = false ) { */ function amp_deactivate( $network_wide = false ) { AmpWpPluginFactory::create()->deactivate( $network_wide ); - // We need to manually remove the amp endpoint. - global $wp_rewrite; - foreach ( $wp_rewrite->endpoints as $index => $endpoint ) { - if ( amp_get_slug() === $endpoint[1] ) { - unset( $wp_rewrite->endpoints[ $index ] ); - break; - } - } - - flush_rewrite_rules( false ); } /** @@ -123,8 +108,6 @@ function amp_init() { add_action( 'rest_api_init', 'AMP_Options_Manager::register_settings' ); add_action( 'wp_loaded', 'amp_bootstrap_admin' ); - add_rewrite_endpoint( amp_get_slug(), EP_PERMALINK ); - add_action( 'parse_query', 'amp_correct_query_when_is_front_page' ); add_action( 'admin_bar_menu', 'amp_add_admin_bar_view_link', 100 ); add_action( @@ -141,9 +124,6 @@ function () { add_action( 'wp_loaded', 'amp_editor_core_blocks' ); add_filter( 'request', 'amp_force_query_var_value' ); - // Redirect the old url of amp page to the updated url. - add_filter( 'old_slug_redirect_url', 'amp_redirect_old_slug_to_new_url' ); - if ( defined( 'WP_CLI' ) && WP_CLI ) { if ( class_exists( 'WP_CLI\Dispatcher\CommandNamespace' ) ) { WP_CLI::add_command( 'amp', 'AMP_CLI_Namespace' ); @@ -279,47 +259,6 @@ function amp_force_query_var_value( $query_vars ) { return $query_vars; } -/** - * Fix up WP_Query for front page when amp query var is present. - * - * Normally the front page would not get served if a query var is present other than preview, page, paged, and cpage. - * - * @since 0.6 - * @internal - * @see WP_Query::parse_query() - * @link https://github.com/WordPress/wordpress-develop/blob/0baa8ae85c670d338e78e408f8d6e301c6410c86/src/wp-includes/class-wp-query.php#L951-L971 - * - * @param WP_Query $query Query. - */ -function amp_correct_query_when_is_front_page( WP_Query $query ) { - $is_front_page_query = ( - $query->is_main_query() - && - $query->is_home() - && - // Is AMP endpoint. - false !== $query->get( amp_get_slug(), false ) - && - // Is query not yet fixed uo up to be front page. - ! $query->is_front_page() - && - // Is showing pages on front. - 'page' === get_option( 'show_on_front' ) - && - // Has page on front set. - get_option( 'page_on_front' ) - && - // See line in WP_Query::parse_query() at . - 0 === count( array_diff( array_keys( wp_parse_args( $query->query ) ), [ amp_get_slug(), 'preview', 'page', 'paged', 'cpage' ] ) ) - ); - if ( $is_front_page_query ) { - $query->is_home = false; - $query->is_page = true; - $query->is_singular = true; - $query->set( 'page_id', get_option( 'page_on_front' ) ); - } -} - /** * Whether this is in 'canonical mode'. * @@ -381,16 +320,6 @@ function amp_is_legacy() { return ! wp_get_theme( $reader_theme )->exists(); } -/** - * Add frontend actions. - * - * @since 0.2 - * @internal - */ -function amp_add_frontend_actions() { - add_action( 'wp_head', 'amp_add_amphtml_link' ); -} - /** * Determine whether AMP is available for the current URL. * @@ -611,30 +540,6 @@ function _amp_bootstrap_customizer() { add_action( 'after_setup_theme', 'amp_init_customizer', 12 ); } -/** - * 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. - * - * @since 0.5 - * @internal - * - * @param string $link New URL of the post. - * @return string URL to be redirected. - */ -function amp_redirect_old_slug_to_new_url( $link ) { - - if ( amp_is_request() && ! amp_is_canonical() ) { - if ( ! amp_is_legacy() ) { - $link = amp_add_paired_endpoint( $link ); - } else { - $link = trailingslashit( trailingslashit( $link ) . amp_get_slug() ); - } - } - - return $link; -} - /** * Get the slug used in AMP for the query var, endpoint, and post type support. * @@ -709,42 +614,18 @@ function amp_get_current_url() { /** * Retrieves the full AMP-specific permalink for the given post ID. * + * On a site in Standard mode, this is the same as `get_permalink()`. + * * @since 0.1 * * @param int $post_id Post ID. * @return string AMP permalink. */ function amp_get_permalink( $post_id ) { - /** - * Filters the AMP permalink to short-circuit normal generation. - * - * Returning a non-false value in this filter will cause the `get_permalink()` to get called and the `amp_get_permalink` filter to not apply. - * - * @since 0.4 - * @since 1.0 This filter does not apply when 'amp' theme support is present. - * - * @param false $url Short-circuited URL. - * @param int $post_id Post ID. - */ - $pre_url = apply_filters( 'amp_pre_get_permalink', false, $post_id ); - - if ( false !== $pre_url ) { - return $pre_url; + if ( amp_is_canonical() ) { + return get_permalink( $post_id ); } - - $permalink = get_permalink( $post_id ); - $amp_url = amp_is_canonical() ? $permalink : amp_add_paired_endpoint( $permalink ); - - /** - * Filters AMP permalink. - * - * @since 0.2 - * @since 1.0 This filter does not apply when 'amp' theme support is present. - * - * @param false $amp_url AMP URL. - * @param int $post_id Post ID. - */ - return apply_filters( 'amp_get_permalink', $amp_url, $post_id ); + return amp_add_paired_endpoint( get_permalink( $post_id ) ); } /** @@ -808,12 +689,7 @@ function amp_add_amphtml_link() { return; } - if ( AMP_Theme_Support::is_paired_available() ) { - $amp_url = amp_add_paired_endpoint( amp_get_current_url() ); - } else { - $amp_url = amp_get_permalink( get_queried_object_id() ); - } - + $amp_url = amp_add_paired_endpoint( amp_get_current_url() ); if ( $amp_url ) { $amp_url = remove_query_arg( QueryVar::NOAMP, $amp_url ); printf( '', esc_url( $amp_url ) ); @@ -826,7 +702,7 @@ function amp_add_amphtml_link() { * @since 2.0 Formerly known as post_supports_amp(). * @see AMP_Post_Type_Support::get_support_errors() * - * @param WP_Post $post Post. + * @param WP_Post|int $post Post. * @return bool Whether the post supports AMP. */ function amp_is_post_supported( $post ) { @@ -865,10 +741,6 @@ function post_supports_amp( $post ) { function amp_is_request() { global $wp_query; - if ( AMP_Validation_Manager::$is_validate_request ) { - return true; - } - $is_amp_url = ( amp_is_canonical() || @@ -1876,16 +1748,15 @@ function amp_add_admin_bar_view_link( $wp_admin_bar ) { $is_amp_request = amp_is_request(); + $current_url = remove_query_arg( array_merge( wp_removable_query_args(), [ QueryVar::NOAMP ] ), amp_get_current_url() ); if ( $is_amp_request ) { - $href = amp_remove_paired_endpoint( amp_get_current_url() ); - } elseif ( is_singular() ) { - $href = amp_get_permalink( get_queried_object_id() ); // For sake of Reader mode. + $amp_url = $current_url; + $non_amp_url = amp_remove_paired_endpoint( $current_url ); } else { - $href = amp_add_paired_endpoint( amp_get_current_url() ); + $amp_url = amp_add_paired_endpoint( $current_url ); + $non_amp_url = $current_url; } - $href = remove_query_arg( QueryVar::NOAMP, $href ); - $icon = $is_amp_request ? Icon::logo() : Icon::link(); $attr = [ 'id' => 'amp-admin-bar-item-status-icon', @@ -1896,7 +1767,10 @@ function amp_add_admin_bar_view_link( $wp_admin_bar ) { [ 'id' => 'amp', 'title' => $icon->to_html( $attr ) . ' ' . esc_html__( 'AMP', 'amp' ), - 'href' => esc_url( $href ), + 'href' => esc_url( $is_amp_request ? $non_amp_url : $amp_url ), + 'meta' => [ + 'title' => esc_attr( $is_amp_request ? __( 'Validate URL', 'amp' ) : __( 'View AMP version', 'amp' ) ), + ], ] ); @@ -1905,7 +1779,7 @@ function amp_add_admin_bar_view_link( $wp_admin_bar ) { 'parent' => 'amp', 'id' => 'amp-view', 'title' => esc_html( $is_amp_request ? __( 'View non-AMP version', 'amp' ) : __( 'View AMP version', 'amp' ) ), - 'href' => esc_url( $href ), + 'href' => esc_url( $is_amp_request ? $non_amp_url : $amp_url ), ] ); @@ -1916,7 +1790,7 @@ function amp_add_admin_bar_view_link( $wp_admin_bar ) { if ( amp_is_legacy() ) { $args['href'] = add_query_arg( 'autofocus[panel]', AMP_Template_Customizer::PANEL_ID, $args['href'] ); } else { - $args['href'] = amp_add_paired_endpoint( $args['href'] ); + $args['href'] = add_query_arg( amp_get_slug(), '1', $args['href'] ); } $wp_admin_bar->add_node( $args ); } @@ -1960,7 +1834,7 @@ function amp_generate_script_hash( $script ) { * @return string AMP URL. */ function amp_add_paired_endpoint( $url ) { - return add_query_arg( amp_get_slug(), '1', $url ); + return Services::get( 'paired_routing' )->add_endpoint( $url ); } /** @@ -1970,45 +1844,9 @@ function amp_add_paired_endpoint( $url ) { * * @param string $url URL to examine. If empty, will use the current URL. * @return bool True if the AMP query parameter is set with the required value, false if not. - * @global WP_Query $wp_query */ function amp_has_paired_endpoint( $url = '' ) { - $slug = amp_get_slug(); - - // If the URL was not provided, then use the environment which is already parsed. - if ( empty( $url ) ) { - global $wp_query; - return ( - isset( $_GET[ $slug ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended - || - ( - $wp_query instanceof WP_Query - && - false !== $wp_query->get( $slug, false ) - ) - ); - } - - $parsed_url = wp_parse_url( $url ); - if ( ! empty( $parsed_url['query'] ) ) { - $query_vars = []; - wp_parse_str( $parsed_url['query'], $query_vars ); - if ( isset( $query_vars[ $slug ] ) ) { - return true; - } - } - - if ( ! empty( $parsed_url['path'] ) ) { - $pattern = sprintf( - '#/%s(/[^/^])?/?$#', - preg_quote( $slug, '#' ) - ); - if ( preg_match( $pattern, $parsed_url['path'] ) ) { - return true; - } - } - - return false; + return Services::get( 'paired_routing' )->has_endpoint( $url ); } /** @@ -2020,20 +1858,5 @@ function amp_has_paired_endpoint( $url = '' ) { * @return string URL with AMP stripped. */ function amp_remove_paired_endpoint( $url ) { - $slug = amp_get_slug(); - - // Strip endpoint, including /amp/, /amp/amp/, /amp/foo/. - $url = preg_replace( - sprintf( - ':(/%s(/[^/?#]+)?)+(?=/?(\?|#|$)):', - preg_quote( $slug, ':' ) - ), - '', - $url - ); - - // Strip query var, including ?amp, ?amp=1, etc. - $url = remove_query_arg( $slug, $url ); - - return $url; + return Services::get( 'paired_routing' )->remove_endpoint( $url ); } diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 0ff216d7c57..80a7f4ddc40 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -6,7 +6,6 @@ */ use AmpProject\Amp; -use AmpProject\AmpWP\DevTools\ErrorPage; use AmpProject\AmpWP\ExtraThemeAndPluginHeaders; use AmpProject\AmpWP\Option; use AmpProject\AmpWP\QueryVar; @@ -81,13 +80,6 @@ class AMP_Theme_Support { */ const READER_MODE_TEMPLATE_DIRECTORY = 'amp'; - /** - * Query var for requests to open the paired browsing interface. - * - * @var string - */ - const PAIRED_BROWSING_QUERY_VAR = 'amp-paired-browsing'; - /** * Sanitizers, with keys as class names and values as arguments. * @@ -322,29 +314,10 @@ public static function supports_reader_mode() { * @since 0.7 */ public static function finish_init() { - if ( self::is_paired_available() ) { - self::setup_paired_browsing_client(); - add_action( 'template_redirect', [ __CLASS__, 'sanitize_url_for_paired_browsing' ] ); - add_filter( 'template_include', [ __CLASS__, 'serve_paired_browsing_experience' ], PHP_INT_MAX ); - } - if ( ! amp_is_request() ) { - /* - * Redirect to AMP-less URL if AMP is not available for this URL and yet the query var is present. - * Temporary redirect is used for admin users because implied transitional mode and template support can be - * enabled by user ay any time, so they will be able to make AMP available for this URL and see the change - * without wrestling with the redirect cache. - */ - if ( amp_has_paired_endpoint() ) { - self::redirect_non_amp_url( current_user_can( 'manage_options' ) ? 302 : 301 ); - } - - amp_add_frontend_actions(); return; } - self::ensure_proper_amp_location(); - if ( amp_is_legacy() ) { // Make sure there is no confusion when serving the legacy Reader template that the normal theme hooks should not be used. remove_theme_support( self::SLUG ); @@ -380,87 +353,17 @@ static function() { } } - /** - * Ensure that the current AMP location is correct. - * - * @since 1.0 - * @since 2.0 Removed $exit param. - * @since 2.1 Remove obsolete redirection from /amp/ to ?amp when on non-legacy Reader mode. - * - * @return bool Whether redirection should have been done. - */ - public static function ensure_proper_amp_location() { - if ( amp_is_canonical() ) { - /* - * When AMP-first/canonical, then when there is an /amp/ endpoint or ?amp URL param, - * then a redirect needs to be done to the URL without any AMP indicator in the URL. - * Permanent redirect is used for unauthenticated users since switching between modes - * should happen infrequently. For admin users, this is kept temporary to allow them - * to not be hampered by browser remembering permanent redirects and preventing test. - */ - if ( amp_has_paired_endpoint() ) { - return self::redirect_non_amp_url( current_user_can( 'manage_options' ) ? 302 : 301 ); - } - } elseif ( amp_has_paired_endpoint() ) { - /* - * Prevent infinite URL space under /amp/ endpoint. Note that WordPress allows endpoints to have a value, - * such as the case of /feed/ where /feed/atom/ is the same as saying ?feed=atom. In this case, we need to - * check for /amp/x/ to protect against links like `AMP!`. - * See https://github.com/ampproject/amp-wp/pull/1846. - */ - global $wp; - $path_args = []; - wp_parse_str( $wp->matched_query, $path_args ); - if ( isset( $path_args[ amp_get_slug() ] ) && '' !== $path_args[ amp_get_slug() ] ) { - $current_url = amp_get_current_url(); - $redirect_url = amp_add_paired_endpoint( amp_remove_paired_endpoint( $current_url ) ); - if ( $current_url !== $redirect_url && wp_safe_redirect( $redirect_url, 301 ) ) { - // @codeCoverageIgnoreStart - exit; - // @codeCoverageIgnoreEnd - } - return true; - } - } - return false; - } - - /** - * Redirect to non-AMP version of the current URL, such as because AMP is canonical or there are unaccepted validation errors. - * - * If the current URL is already AMP-less then do nothing. - * - * @since 0.7 - * @since 1.0 Added $exit param. - * @since 1.0 Renamed from redirect_canonical_amp(). - * @since 2.0 Removed $exit param. - * - * @param int $status Status code (301 or 302). - * @return bool Whether redirection should have be done. - */ - public static function redirect_non_amp_url( $status = 302 ) { - $current_url = amp_get_current_url(); - $non_amp_url = amp_remove_paired_endpoint( $current_url ); - if ( $non_amp_url === $current_url ) { - return false; - } - - if ( wp_safe_redirect( $non_amp_url, $status ) ) { - // @codeCoverageIgnoreStart - exit; - // @codeCoverageIgnoreEnd - } - return true; - } - /** * Determines whether transitional mode is available. * * When 'amp' theme support has not been added or canonical mode is enabled, then this returns false. * * @since 0.7 + * @deprecated No longer used. Consider instead `! amp_is_canonical() && amp_is_available()`. + * @todo There are ecosystem plugins which are still using this method. See . * * @see amp_is_canonical() + * @see amp_is_available() * @return bool Whether available. */ public static function is_paired_available() { @@ -987,10 +890,7 @@ static function( $html ) { add_filter( 'comment_form_defaults', [ __CLASS__, 'filter_comment_form_defaults' ], PHP_INT_MAX ); add_filter( 'comment_reply_link', [ __CLASS__, 'filter_comment_reply_link' ], 10, 4 ); add_filter( 'cancel_comment_reply_link', [ __CLASS__, 'filter_cancel_comment_reply_link' ], 10, 3 ); - add_action( 'comment_form', [ __CLASS__, 'amend_comment_form' ], 100 ); remove_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' ); - add_filter( 'get_comments_link', [ __CLASS__, 'amend_comments_link' ] ); - add_filter( 'respond_link', [ __CLASS__, 'amend_comments_link' ] ); add_filter( 'wp_kses_allowed_html', [ __CLASS__, 'include_layout_in_wp_kses_allowed_html' ], 10 ); add_filter( 'get_header_image_tag', [ __CLASS__, 'amend_header_image_with_video_header' ], PHP_INT_MAX ); add_action( @@ -1102,34 +1002,6 @@ public static function set_comments_walker( $args ) { return $args; } - /** - * Amend the comment form with the redirect_to field to persist the AMP page after submission. - */ - public static function amend_comment_form() { - ?> - - - - query_vars; - if ( ! $wp_rewrite->permalink_structure || empty( $wp->request ) ) { - $url = home_url( '/' ); - } else { - $url = home_url( user_trailingslashit( $wp->request ) ); - parse_str( $wp->matched_query, $matched_query_vars ); - foreach ( $wp->query_vars as $key => $value ) { - - // Remove query vars that were matched in the rewrite rules for the request. - if ( isset( $matched_query_vars[ $key ] ) ) { - unset( $added_query_vars[ $key ] ); - } - } - } - } - - if ( ! empty( $added_query_vars ) ) { - $url = add_query_arg( $added_query_vars, $url ); + $current_url = amp_get_current_url(); + if ( ! amp_is_canonical() ) { + $current_url = amp_remove_paired_endpoint( $current_url ); } - - return amp_remove_paired_endpoint( $url ); + return $current_url; } /** @@ -2331,160 +2179,6 @@ public static function enqueue_assets() { wp_enqueue_style( 'amp-default' ); } - /** - * Setup pages to have the paired browsing client script so that the app can interact with it. - * - * @since 1.5.0 - * - * @return void - */ - public static function setup_paired_browsing_client() { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( isset( $_GET[ self::PAIRED_BROWSING_QUERY_VAR ] ) ) { - return; - } - - // Paired browsing requires a custom script which in turn requires dev mode. - if ( ! amp_is_dev_mode() ) { - return; - } - - /** - * Fires before registering plugin assets that may require core asset polyfills. - * - * @internal - */ - do_action( 'amp_register_polyfills' ); - - $asset_file = AMP__DIR__ . '/assets/js/amp-paired-browsing-client.asset.php'; - $asset = require $asset_file; - $dependencies = $asset['dependencies']; - $version = $asset['version']; - - wp_enqueue_script( - 'amp-paired-browsing-client', - amp_get_asset_url( '/js/amp-paired-browsing-client.js' ), - $dependencies, - $version, - true - ); - - // Mark enqueued script for AMP dev mode so that it is not removed. - // @todo Revisit with . - add_filter( - 'script_loader_tag', - static function( $tag, $handle ) { - if ( amp_is_request() && self::has_dependency( wp_scripts(), 'amp-paired-browsing-client', $handle ) ) { - $tag = preg_replace( '/(?<=)/i', ' ' . AMP_Rule_Spec::DEV_MODE_ATTRIBUTE, $tag ); - } - return $tag; - }, - 10, - 2 - ); - } - - /** - * Get paired browsing URL for a given URL. - * - * @since 1.5.0 - * - * @param string $url URL. - * @return string Paired browsing URL. - */ - public static function get_paired_browsing_url( $url = null ) { - if ( ! $url ) { - $url = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated - } - $url = remove_query_arg( - [ amp_get_slug(), QueryVar::NOAMP, AMP_Validated_URL_Post_Type::VALIDATE_ACTION, AMP_Validation_Manager::VALIDATION_ERROR_TERM_STATUS_QUERY_VAR ], - $url - ); - $url = add_query_arg( self::PAIRED_BROWSING_QUERY_VAR, '1', $url ); - return $url; - } - - /** - * Remove any unnecessary query vars that could hamper the paired browsing experience. - * - * @since 1.5.0 - */ - public static function sanitize_url_for_paired_browsing() { - if ( isset( $_GET[ self::PAIRED_BROWSING_QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $original_url = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated - $updated_url = self::get_paired_browsing_url( $original_url ); - if ( $updated_url !== $original_url ) { - wp_safe_redirect( $updated_url ); - exit; - } - } - } - - /** - * Serve paired browsing experience if it is being requested. - * - * Includes a custom template that acts as an interface to facilitate a side-by-side comparison of a - * non-AMP page and its AMP version to review any discrepancies. - * - * @since 1.5.0 - * - * @param string $template Path of the template to include. - * @return string Custom template if in paired browsing mode, else the supplied template. - */ - public static function serve_paired_browsing_experience( $template ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( ! isset( $_GET[ self::PAIRED_BROWSING_QUERY_VAR ] ) ) { - return $template; - } - - if ( ! amp_is_dev_mode() ) { - wp_die( - esc_html__( 'Paired browsing is only available when AMP dev mode is enabled (e.g. when logged-in and admin bar is showing).', 'amp' ), - esc_html__( 'AMP Paired Browsing Unavailable', 'amp' ), - [ 'response' => 403 ] - ); - } - - /** This action is documented in includes/class-amp-theme-support.php */ - do_action( 'amp_register_polyfills' ); - - wp_enqueue_style( - 'amp-paired-browsing-app', - amp_get_asset_url( '/css/amp-paired-browsing-app.css' ), - [ 'dashicons' ], - AMP__VERSION - ); - - wp_styles()->add_data( 'amp-paired-browsing-app', 'rtl', 'replace' ); - - $asset_file = AMP__DIR__ . '/assets/js/amp-paired-browsing-app.asset.php'; - $asset = require $asset_file; - $dependencies = $asset['dependencies']; - $version = $asset['version']; - - wp_enqueue_script( - 'amp-paired-browsing-app', - amp_get_asset_url( '/js/amp-paired-browsing-app.js' ), - $dependencies, - $version, - true - ); - - wp_localize_script( - 'amp-paired-browsing-app', - 'app', - [ - 'ampSlug' => amp_get_slug(), - 'ampPairedBrowsingQueryVar' => self::PAIRED_BROWSING_QUERY_VAR, - 'noampQueryVar' => QueryVar::NOAMP, - 'noampMobile' => QueryVar::NOAMP_MOBILE, - 'documentTitlePrefix' => __( 'AMP Paired Browsing:', 'amp' ), - ] - ); - - return AMP__DIR__ . '/includes/templates/amp-paired-browsing.php'; - } - /** * Print the important emoji-related styles. * diff --git a/includes/deprecated.php b/includes/deprecated.php index 070a7181c1b..ef47a7393c2 100644 --- a/includes/deprecated.php +++ b/includes/deprecated.php @@ -5,6 +5,8 @@ * @package AMP */ +use AmpProject\AmpWP\Services; + /** * Load classes. * @@ -284,6 +286,53 @@ function _amp_xdebug_admin_notice() { maybe_add_paired_endpoint( $link ); +} + +/** + * Fix up WP_Query for front page when amp query var is present. + * + * Normally the front page would not get served if a query var is present other than preview, page, paged, and cpage. + * + * @since 0.6 + * @internal + * @see WP_Query::parse_query() + * @link https://github.com/WordPress/wordpress-develop/blob/0baa8ae85c670d338e78e408f8d6e301c6410c86/src/wp-includes/class-wp-query.php#L951-L971 + * @deprecated + * + * @param WP_Query $query Query. + */ +function amp_correct_query_when_is_front_page( WP_Query $query ) { + _deprecated_function( __FUNCTION__, '2.1' ); + Services::get( 'paired_routing' )->correct_query_when_is_front_page( $query ); +} + +/** + * Add frontend actions. + * + * @since 0.2 + * @deprecated Since 2.1, moved to PairedRouting. + * @internal + */ +function amp_add_frontend_actions() { + _deprecated_function( __FUNCTION__, '2.1' ); + add_action( 'wp_head', 'amp_add_amphtml_link' ); +} + /** * Add analytics scripts. * diff --git a/includes/options/class-amp-options-manager.php b/includes/options/class-amp-options-manager.php index d590b9a6337..fc52242b234 100644 --- a/includes/options/class-amp-options-manager.php +++ b/includes/options/class-amp-options-manager.php @@ -35,6 +35,7 @@ class AMP_Options_Manager { Option::SUPPORTED_TEMPLATES => [ 'is_singular' ], Option::VERSION => AMP__VERSION, Option::READER_THEME => ReaderThemes::DEFAULT_READER_THEME, + Option::PAIRED_URL_STRUCTURE => Option::PAIRED_URL_STRUCTURE_QUERY_VAR, Option::PLUGIN_CONFIGURED => false, ]; @@ -159,9 +160,10 @@ static function ( $supported ) { * Filters default options. * * @internal - * @param array $defaults Default options. + * @param array $defaults Default options. + * @param array $current_options Current options. */ - (array) apply_filters( 'amp_default_options', $defaults ), + (array) apply_filters( 'amp_default_options', $defaults, $options ), $options ); @@ -171,7 +173,7 @@ static function ( $supported ) { && get_stylesheet() === $options[ Option::READER_THEME ] && - ! amp_has_paired_endpoint() + ! amp_has_paired_endpoint() // @todo Beware infinite recursion, particularly when deciding among custom endpoints! ) { /* * When Reader mode is selected and a Reader theme has been chosen, if the active theme switches to be the diff --git a/includes/sanitizers/class-amp-comments-sanitizer.php b/includes/sanitizers/class-amp-comments-sanitizer.php index cf590eba2f7..ef597a2611c 100644 --- a/includes/sanitizers/class-amp-comments-sanitizer.php +++ b/includes/sanitizers/class-amp-comments-sanitizer.php @@ -44,7 +44,7 @@ public function sanitize() { $action = $comment_form->getAttribute( 'action' ); } $action_path = wp_parse_url( $action, PHP_URL_PATH ); - if ( preg_match( '#/wp-comments-post\.php$#', $action_path ) ) { + if ( 'wp-comments-post.php' === basename( $action_path ) ) { $this->process_comment_form( $comment_form ); } } diff --git a/includes/sanitizers/class-amp-form-sanitizer.php b/includes/sanitizers/class-amp-form-sanitizer.php index 7c019ab886b..b1c48c06a4c 100644 --- a/includes/sanitizers/class-amp-form-sanitizer.php +++ b/includes/sanitizers/class-amp-form-sanitizer.php @@ -86,9 +86,6 @@ public function sanitize() { if ( ! $xhr_action ) { // Record that action was converted to action-xhr. $action_url = add_query_arg( AMP_HTTP::ACTION_XHR_CONVERTED_QUERY_VAR, 1, $action_url ); - if ( ! amp_is_canonical() ) { - $action_url = amp_add_paired_endpoint( $action_url ); - } $node->setAttribute( 'action-xhr', $action_url ); // Append success/error handlers if not found. $this->ensure_response_message_elements( $node ); diff --git a/includes/sanitizers/class-amp-link-sanitizer.php b/includes/sanitizers/class-amp-link-sanitizer.php index dc50477744c..0652bcbbfbe 100644 --- a/includes/sanitizers/class-amp-link-sanitizer.php +++ b/includes/sanitizers/class-amp-link-sanitizer.php @@ -129,7 +129,19 @@ public function process_links() { $this->process_element( $link, Attribute::HREF ); } - $form_query = $this->dom->xpath->query( '//form[ @action and translate( @method, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") = "get" ]' ); + $form_query = $this->dom->xpath->query( + ' + //form[ + @action + and + ( + not( @method ) + or + translate( @method, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") = "get" + ) + ] + ' + ); foreach ( $form_query as $form ) { $this->process_element( $form, Attribute::ACTION ); } diff --git a/includes/templates/amp-paired-browsing.php b/includes/templates/amp-paired-browsing.php index 461fcd5b576..a2da4fb6fb9 100644 --- a/includes/templates/amp-paired-browsing.php +++ b/includes/templates/amp-paired-browsing.php @@ -6,8 +6,9 @@ */ use AmpProject\AmpWP\QueryVar; +use AmpProject\AmpWP\Admin\PairedBrowsing; -$url = remove_query_arg( [ AMP_Theme_Support::PAIRED_BROWSING_QUERY_VAR, QueryVar::NOAMP ] ); +$url = remove_query_arg( [ PairedBrowsing::APP_QUERY_VAR, QueryVar::NOAMP ], amp_get_current_url() ); $non_amp_url = add_query_arg( QueryVar::NOAMP, QueryVar::NOAMP_MOBILE, $url ); $amp_url = amp_add_paired_endpoint( $url ); ?> @@ -43,19 +44,11 @@ - - - -
- - + +
diff --git a/includes/validation/class-amp-validated-url-post-type.php b/includes/validation/class-amp-validated-url-post-type.php index 2707dddbbbb..27044b55e2c 100644 --- a/includes/validation/class-amp-validated-url-post-type.php +++ b/includes/validation/class-amp-validated-url-post-type.php @@ -765,7 +765,9 @@ protected static function get_markup_status_preview_url( $url ) { */ protected static function normalize_url_for_storage( $url ) { // Only ever store the canonical version. - $url = amp_remove_paired_endpoint( $url ); + if ( ! amp_is_canonical() ) { + $url = amp_remove_paired_endpoint( $url ); + } // Remove fragment identifier in the rare case it could be provided. It is irrelevant for validation. $url = strtok( $url, '#' ); @@ -2201,41 +2203,47 @@ public static function print_status_meta_box( $post ) {
ID, self::QUERIED_OBJECT_POST_META_KEY, true ); if ( isset( $queried_object['id'], $queried_object['type'] ) ) { - $after = ' | '; if ( 'post' === $queried_object['type'] && get_post( $queried_object['id'] ) && post_type_exists( get_post( $queried_object['id'] )->post_type ) ) { $post_type_object = get_post_type_object( get_post( $queried_object['id'] )->post_type ); - edit_post_link( $post_type_object->labels->edit_item, '', $after, $queried_object['id'] ); + edit_post_link( $post_type_object->labels->edit_item, '', '', $queried_object['id'] ); $view_label = $post_type_object->labels->view_item; } elseif ( 'term' === $queried_object['type'] && get_term( $queried_object['id'] ) && taxonomy_exists( get_term( $queried_object['id'] )->taxonomy ) ) { $taxonomy_object = get_taxonomy( get_term( $queried_object['id'] )->taxonomy ); - edit_term_link( $taxonomy_object->labels->edit_item, '', $after, get_term( $queried_object['id'] ) ); + edit_term_link( $taxonomy_object->labels->edit_item, '', '', get_term( $queried_object['id'] ) ); $view_label = $taxonomy_object->labels->view_item; } elseif ( 'user' === $queried_object['type'] ) { $link = get_edit_user_link( $queried_object['id'] ); if ( $link ) { - printf( '%s%s', esc_url( $link ), esc_html__( 'Edit User', 'amp' ), esc_html( $after ) ); + printf( '%s', esc_url( $link ), esc_html__( 'Edit User', 'amp' ) ); } $view_label = __( 'View User', 'amp' ); } } - printf( '%s', esc_url( self::get_url_from_post( $post ) ), esc_html( $view_label ) ); - - if ( - $is_amp_enabled - && - AMP_Theme_Support::TRANSITIONAL_MODE_SLUG === AMP_Options_Manager::get_option( Option::THEME_SUPPORT ) - && - AMP_Theme_Support::is_paired_available() - ) { - printf( - ' | %s', - esc_url( AMP_Theme_Support::get_paired_browsing_url( self::get_url_from_post( $post ) ) ), - esc_html__( 'Paired Browsing', 'amp' ) - ); - } + $actions['edit'] = ob_get_clean(); + $actions['view'] = sprintf( '%s', esc_url( self::get_url_from_post( $post ) ), esc_html( $view_label ) ); + + /** + * Filters the array of action links shown in the status metabox. + * + * @since 2.1 + * @internal + * + * @param string[] $actions Action links. + */ + $actions = apply_filters( 'amp_validated_url_status_actions', $actions, $post ); + + echo wp_kses( + implode( ' | ', array_filter( $actions ) ), + [ + 'a' => array_fill_keys( [ 'href' ], true ), + ] + ); ?>
diff --git a/includes/validation/class-amp-validation-manager.php b/includes/validation/class-amp-validation-manager.php index 30ba3aff54a..c0218c40e1f 100644 --- a/includes/validation/class-amp-validation-manager.php +++ b/includes/validation/class-amp-validation-manager.php @@ -336,21 +336,28 @@ public static function add_admin_bar_menu_items( $wp_admin_bar ) { } $is_amp_request = amp_is_request(); - - $current_url = amp_get_current_url(); - $non_amp_url = amp_remove_paired_endpoint( $current_url ); - $non_amp_url = add_query_arg( - QueryVar::NOAMP, - amp_is_canonical() ? QueryVar::NOAMP_AVAILABLE : QueryVar::NOAMP_MOBILE, - $non_amp_url - ); - - $amp_url = remove_query_arg( + $current_url = remove_query_arg( array_merge( wp_removable_query_args(), [ QueryVar::NOAMP ] ), - $current_url + amp_get_current_url() ); - if ( ! amp_is_canonical() ) { - $amp_url = amp_add_paired_endpoint( $amp_url ); + + if ( amp_is_canonical() ) { + $amp_url = $current_url; + $non_amp_url = add_query_arg( + QueryVar::NOAMP, + QueryVar::NOAMP_AVAILABLE, + $current_url + ); + } elseif ( $is_amp_request ) { + $amp_url = $current_url; + $non_amp_url = add_query_arg( + QueryVar::NOAMP, + QueryVar::NOAMP_MOBILE, + amp_remove_paired_endpoint( $current_url ) + ); + } else { + $amp_url = amp_add_paired_endpoint( $current_url ); + $non_amp_url = $current_url; } $validate_url = AMP_Validated_URL_Post_Type::get_recheck_url( AMP_Validated_URL_Post_Type::get_invalid_url_post( $amp_url ) ?: $amp_url ); @@ -413,24 +420,6 @@ public static function add_admin_bar_menu_items( $wp_admin_bar ) { $wp_admin_bar->add_node( $validate_item ); } - if ( - AMP_Theme_Support::TRANSITIONAL_MODE_SLUG === AMP_Options_Manager::get_option( Option::THEME_SUPPORT ) - && - AMP_Theme_Support::is_paired_available() - && - amp_is_dev_mode() - ) { - // Construct admin bar item to link to paired browsing experience. - $paired_browsing_item = [ - 'parent' => 'amp', - 'id' => 'amp-paired-browsing', - 'title' => esc_html__( 'Paired Browsing', 'amp' ), - 'href' => AMP_Theme_Support::get_paired_browsing_url(), - ]; - - $wp_admin_bar->add_node( $paired_browsing_item ); - } - // Add settings link to admin bar. if ( current_user_can( 'manage_options' ) ) { $wp_admin_bar->add_node( diff --git a/src/Admin/PairedBrowsing.php b/src/Admin/PairedBrowsing.php new file mode 100644 index 00000000000..0bca06675e2 --- /dev/null +++ b/src/Admin/PairedBrowsing.php @@ -0,0 +1,320 @@ +dev_tools_user_access = $dev_tools_user_access; + $this->paired_routing = $paired_routing; + } + + /** + * Adds the filters. + */ + public function register() { + add_action( 'wp', [ $this, 'init_frontend' ], PHP_INT_MAX ); + add_filter( 'amp_dev_mode_element_xpaths', [ $this, 'filter_dev_mode_element_xpaths' ] ); + add_filter( 'amp_validated_url_status_actions', [ $this, 'filter_validated_url_status_actions' ], 10, 2 ); + } + + /** + * Filter Dev Mode XPaths to include the inline script used by the client. + * + * @param string[] $xpaths Element XPaths. + * @return string[] XPaths. + */ + public function filter_dev_mode_element_xpaths( $xpaths ) { + $xpaths[] = '//script[ @id = "amp-paired-browsing-client-js-before" ]'; + return $xpaths; + } + + /** + * Filter the status actions for a validated URL to add the paired browsing link. + * + * @param string[] $actions Action links. + * @param WP_Post $post AMP Validated URL post. + * @return string[] Actions. + */ + public function filter_validated_url_status_actions( $actions, WP_Post $post ) { + $actions['paired_browsing'] = sprintf( + '%s', + esc_url( $this->get_paired_browsing_url( AMP_Validated_URL_Post_Type::get_url_from_post( $post ) ) ), + esc_html__( 'Paired Browsing', 'amp' ) + ); + return $actions; + } + + /** + * Initialize frontend. + */ + public function init_frontend() { + if ( ! amp_is_available() || ! amp_is_dev_mode() ) { + return; + } + + if ( isset( $_GET[ self::APP_QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $this->init_app(); + } else { + $this->init_client(); + } + } + + /** + * Set up app. + * + * This is the parent request that has the iframes for both AMP and non-AMP. + */ + public function init_app() { + add_action( 'template_redirect', [ $this, 'ensure_app_location' ] ); + add_filter( 'template_include', [ $this, 'filter_template_include_for_app' ], PHP_INT_MAX ); + } + + /** + * Set up client. + * + * Make sure pages have the paired browsing client script so that the app can interact with it. + */ + public function init_client() { + add_action( 'admin_bar_menu', [ $this, 'add_admin_bar_menu_item' ], 102 ); + + /** + * Fires before registering plugin assets that may require core asset polyfills. + * + * @internal + */ + do_action( 'amp_register_polyfills' ); + + $handle = 'amp-paired-browsing-client'; + $asset = require AMP__DIR__ . '/assets/js/amp-paired-browsing-client.asset.php'; + $dependencies = $asset['dependencies']; + $version = $asset['version']; + + wp_enqueue_script( + $handle, + amp_get_asset_url( '/js/amp-paired-browsing-client.js' ), + $dependencies, + $version, + true + ); + + $is_amp_request = amp_is_request(); + $current_url = amp_get_current_url(); + $amp_url = $is_amp_request ? $current_url : $this->paired_routing->add_endpoint( $current_url ); + $non_amp_url = ! $is_amp_request ? $current_url : $this->paired_routing->remove_endpoint( $current_url ); + + wp_add_inline_script( + $handle, + sprintf( + 'var ampPairedBrowsingClientData = %s;', + wp_json_encode( + [ + 'isAmpDocument' => $is_amp_request, + 'ampUrl' => $amp_url, + 'nonAmpUrl' => $non_amp_url, + ] + ) + ), + 'before' + ); + + // Mark enqueued script for AMP dev mode so that it is not removed. + // @todo Revisit with . + $dev_mode_handles = array_merge( + [ $handle, 'wp-i18n' ], + $dependencies + ); + add_filter( + 'script_loader_tag', + static function ( $tag, $script_handle ) use ( $dev_mode_handles ) { + if ( amp_is_request() && in_array( $script_handle, $dev_mode_handles, true ) ) { + $tag = preg_replace( '/(?<=)/i', ' ' . DevMode::DEV_MODE_ATTRIBUTE, $tag ); + } + return $tag; + }, + 10, + 2 + ); + } + + /** + * Add paired browsing menu item to admin bar for AMP. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar. + */ + public function add_admin_bar_menu_item( WP_Admin_Bar $wp_admin_bar ) { + if ( $this->dev_tools_user_access->is_user_enabled() ) { + $wp_admin_bar->add_node( + [ + 'parent' => 'amp', + 'id' => 'amp-paired-browsing', + 'title' => esc_html__( 'Paired Browsing', 'amp' ), + 'href' => esc_url( $this->get_paired_browsing_url() ), + ] + ); + } + } + + /** + * Get paired browsing URL for a given URL. + * + * @param string $url URL. + * @return string Paired browsing URL. + */ + public function get_paired_browsing_url( $url = null ) { + if ( ! $url ) { + $url = amp_get_current_url(); + } + $url = $this->paired_routing->remove_endpoint( $url ); + $url = remove_query_arg( + [ QueryVar::NOAMP, AMP_Validated_URL_Post_Type::VALIDATE_ACTION, AMP_Validation_Manager::VALIDATION_ERROR_TERM_STATUS_QUERY_VAR ], + $url + ); + $url = add_query_arg( self::APP_QUERY_VAR, '1', $url ); + return $url; + } + + /** + * Remove any unnecessary query vars that could hamper the paired browsing experience. + * + * When a redirect is successfully done, this method will exit and not return anything. Exiting is prevented by + * filtering `wp_redirect` to be `false`. + * + * @return bool Whether redirection was needed. + */ + public function ensure_app_location() { + $original_url = amp_get_current_url(); + $updated_url = $this->get_paired_browsing_url( $original_url ); + if ( $updated_url === $original_url ) { + return false; + } + + if ( wp_safe_redirect( $updated_url ) ) { + exit; // @codeCoverageIgnore + } + return true; + } + + /** + * Serve paired browsing experience if it is being requested. + * + * Includes a custom template that acts as an interface to facilitate a side-by-side comparison of a + * non-AMP page and its AMP version to review any discrepancies. + * + * @return string Custom template if in paired browsing mode, else the supplied template. + */ + public function filter_template_include_for_app() { + if ( ! amp_is_dev_mode() ) { + wp_die( + esc_html__( 'Paired browsing is only available when AMP dev mode is enabled (e.g. when logged-in and admin bar is showing).', 'amp' ), + esc_html__( 'AMP Paired Browsing Unavailable', 'amp' ), + [ 'response' => 403 ] + ); + } + + /** This action is documented in includes/class-amp-theme-support.php */ + do_action( 'amp_register_polyfills' ); + + $handle = 'amp-paired-browsing-app'; + wp_enqueue_style( + $handle, + amp_get_asset_url( '/css/amp-paired-browsing-app.css' ), + [ 'dashicons' ], + AMP__VERSION + ); + + wp_styles()->add_data( $handle, 'rtl', 'replace' ); + + $handle = 'amp-paired-browsing-app'; + $asset = require AMP__DIR__ . '/assets/js/amp-paired-browsing-app.asset.php'; + $dependencies = $asset['dependencies']; + $version = $asset['version']; + + wp_enqueue_script( + $handle, + amp_get_asset_url( '/js/amp-paired-browsing-app.js' ), + $dependencies, + $version, + true + ); + + $data = [ + 'ampPairedBrowsingQueryVar' => self::APP_QUERY_VAR, + 'noampQueryVar' => QueryVar::NOAMP, + 'noampMobile' => QueryVar::NOAMP_MOBILE, + 'documentTitlePrefix' => __( 'AMP Paired Browsing:', 'amp' ), + ]; + wp_add_inline_script( + $handle, + sprintf( + 'var ampPairedBrowsingAppData = %s;', + wp_json_encode( $data ) + ), + 'before' + ); + + return AMP__DIR__ . '/includes/templates/amp-paired-browsing.php'; + } +} diff --git a/src/AmpWpPlugin.php b/src/AmpWpPlugin.php index 5aa189ab7b6..e3f9d79d2a3 100644 --- a/src/AmpWpPlugin.php +++ b/src/AmpWpPlugin.php @@ -9,6 +9,7 @@ use AmpProject\AmpWP\Admin; use AmpProject\AmpWP\BackgroundTask; +use AmpProject\AmpWP\Infrastructure\Injector; use AmpProject\AmpWP\Infrastructure\ServiceBasedPlugin; use AmpProject\AmpWP\Instrumentation; use AmpProject\AmpWP\Validation\SavePostValidationEvent; @@ -64,6 +65,7 @@ final class AmpWpPlugin extends ServiceBasedPlugin { 'admin.onboarding_wizard' => Admin\OnboardingWizardSubmenuPage::class, 'admin.options_menu' => Admin\OptionsMenu::class, 'admin.polyfills' => Admin\Polyfills::class, + 'admin.paired_browsing' => Admin\PairedBrowsing::class, 'amp_slug_customization_watcher' => AmpSlugCustomizationWatcher::class, 'css_transient_cache.ajax_handler' => Admin\ReenableCssTransientCachingAjaxAction::class, 'css_transient_cache.monitor' => BackgroundTask\MonitorCssTransientCaching::class, @@ -88,6 +90,8 @@ final class AmpWpPlugin extends ServiceBasedPlugin { 'url_validation_cron' => URLValidationCron::class, 'save_post_validation_event' => SavePostValidationEvent::class, 'background_task_deactivator' => BackgroundTaskDeactivator::class, + 'paired_routing' => PairedRouting::class, + 'paired_url' => PairedUrl::class, ]; /** @@ -170,6 +174,8 @@ protected function get_shared_instances() { DevTools\FileReflection::class, ReaderThemeLoader::class, BackgroundTask\BackgroundTaskDeactivator::class, + PairedRouting::class, + Injector::class, ]; } @@ -185,6 +191,10 @@ protected function get_shared_instances() { * @return array Associative array of callables. */ protected function get_delegations() { - return []; + return [ + Injector::class => static function () { + return Services::get( 'injector' ); + }, + ]; } } diff --git a/src/MobileRedirection.php b/src/MobileRedirection.php index 1b92aefbfce..47bf3162688 100644 --- a/src/MobileRedirection.php +++ b/src/MobileRedirection.php @@ -12,6 +12,7 @@ use AmpProject\AmpWP\Infrastructure\Service; use AmpProject\Attribute; use AMP_Theme_Support; +use AMP_HTTP; /** * Service for redirecting mobile users to the AMP version of a page. @@ -40,6 +41,22 @@ final class MobileRedirection implements Service, Registerable { */ const DISABLED_STORAGE_KEY = 'amp_mobile_redirect_disabled'; + /** + * PairedRouting instance. + * + * @var PairedRouting + */ + private $paired_routing; + + /** + * MobileRedirection constructor. + * + * @param PairedRouting $paired_routing Paired Routing. + */ + public function __construct( PairedRouting $paired_routing ) { + $this->paired_routing = $paired_routing; + } + /** * Register. */ @@ -47,12 +64,20 @@ public function register() { add_filter( 'amp_default_options', [ $this, 'filter_default_options' ] ); add_filter( 'amp_options_updating', [ $this, 'sanitize_options' ], 10, 2 ); - if ( AMP_Options_Manager::get_option( Option::MOBILE_REDIRECT ) ) { + if ( AMP_Options_Manager::get_option( Option::MOBILE_REDIRECT ) && ! amp_is_canonical() ) { add_action( 'template_redirect', [ $this, 'redirect' ], PHP_INT_MAX ); // Enable AMP-to-AMP linking by default to avoid redirecting to AMP version when navigating. // A low priority is used so that sites can continue overriding this if they have done so. add_filter( 'amp_to_amp_linking_enabled', '__return_true', 0 ); + + add_filter( 'comment_post_redirect', [ $this, 'filter_comment_post_redirect' ] ); + + // Amend the comments/respond links to go to non-AMP page when in legacy Reader mode. + if ( amp_is_legacy() ) { + add_filter( 'get_comments_link', [ $this, 'add_noamp_mobile_query_var' ] ); // For get_comments_link(). + add_filter( 'respond_link', [ $this, 'add_noamp_mobile_query_var' ] ); // For comments_popup_link(). + } } } @@ -88,7 +113,7 @@ public function sanitize_options( $options, $new_options ) { * @return string AMP URL. */ public function get_current_amp_url() { - $url = amp_add_paired_endpoint( amp_get_current_url() ); + $url = $this->paired_routing->add_endpoint( amp_get_current_url() ); $url = remove_query_arg( QueryVar::NOAMP, $url ); return $url; } @@ -406,6 +431,35 @@ public function add_mobile_alternative_link() { ); } + /** + * Redirect to AMP page after submitting comment if the URL is on this site. + * + * This avoids the need for a secondary redirect after having been redirected to the non-AMP URL. + * + * @param string $url URL. + * @return string Amended URL. + */ + public function filter_comment_post_redirect( $url ) { + if ( + isset( AMP_HTTP::$purged_amp_query_vars[ AMP_HTTP::ACTION_XHR_CONVERTED_QUERY_VAR ] ) + && + wp_parse_url( home_url(), PHP_URL_HOST ) === wp_parse_url( $url, PHP_URL_HOST ) + ) { + $url = $this->paired_routing->add_endpoint( $url ); + } + return $url; + } + + /** + * Add `?noamp=mobile` to a given URL. + * + * @param string $url URL. + * @return string Amended URL. + */ + public function add_noamp_mobile_query_var( $url ) { + return add_query_arg( QueryVar::NOAMP, QueryVar::NOAMP_MOBILE, $url ); + } + /** * Print the styles for the mobile version switcher. */ @@ -444,7 +498,7 @@ public function add_mobile_version_switcher_link() { $is_amp = amp_is_request(); if ( $is_amp ) { $rel = [ Attribute::REL_NOAMPHTML, Attribute::REL_NOFOLLOW ]; - $url = add_query_arg( QueryVar::NOAMP, QueryVar::NOAMP_MOBILE, amp_remove_paired_endpoint( amp_get_current_url() ) ); + $url = add_query_arg( QueryVar::NOAMP, QueryVar::NOAMP_MOBILE, $this->paired_routing->remove_endpoint( amp_get_current_url() ) ); $text = __( 'Exit mobile version', 'amp' ); } else { $rel = [ Attribute::REL_AMPHTML ]; diff --git a/src/Option.php b/src/Option.php index 3416f38cb21..9df7ae348c3 100644 --- a/src/Option.php +++ b/src/Option.php @@ -43,6 +43,53 @@ interface Option { */ const DISABLE_CSS_TRANSIENT_CACHING = 'amp_css_transient_monitor_disable_caching'; + /** + * Indicate the structure for paired AMP URLs. + * + * Default value: 'query_var' + * + * @var string + */ + const PAIRED_URL_STRUCTURE = 'paired_url_structure'; + + /** + * Query var paired URL structure. + * + * This is the default, where all AMP URLs end in `?amp=1`. + * + * @var string + */ + const PAIRED_URL_STRUCTURE_QUERY_VAR = 'query_var'; + + /** + * Path suffix paired URL structure. + * + * This adds `/amp/` to all URLs, even pages and archives. This is a popular option for those who feel query params + * are bad for SEO. + * + * @var string + */ + const PAIRED_URL_STRUCTURE_PATH_SUFFIX = 'path_suffix'; + + /** + * Legacy transitional paired URL structure. + * + * This involves using `?amp` for all paired AMP URLs. + * + * @var string + */ + const PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL = 'legacy_transitional'; + + /** + * Legacy transitional paired URL structure. + * + * This involves using `/amp/` for all non-hierarchical post URLs which lack endpoints or query vars, or else using + * the same `?amp` as used by legacy transitional. + * + * @var string + */ + const PAIRED_URL_STRUCTURE_LEGACY_READER = 'legacy_reader'; + /** * Redirect mobile visitors to the AMP version of a page when the site is in Transitional or Reader mode. * diff --git a/src/OptionsRESTController.php b/src/OptionsRESTController.php index 2cfa9dce37a..9fd1bfd4332 100644 --- a/src/OptionsRESTController.php +++ b/src/OptionsRESTController.php @@ -194,6 +194,23 @@ static function( $slug ) { $options[ self::CUSTOMIZER_LINK ] = amp_get_customizer_url(); + /** + * Filters options for services to add additional REST items. + * + * @internal + * + * @param array $service_options REST Options for Services. + */ + $service_options = apply_filters( 'amp_rest_options', [] ); + if ( ! is_array( $service_options ) ) { + $service_options = []; + } + + $options = array_merge( + $options, + $service_options + ); + return rest_ensure_response( $options ); } @@ -338,6 +355,23 @@ public function get_item_schema() { ], ], ]; + + /** + * Filters schema for services to add additional items. + * + * @internal + * + * @param array $schema Schema. + */ + $services_schema = apply_filters( 'amp_rest_options_schema', [] ); + if ( ! is_array( $services_schema ) ) { + $services_schema = []; + } + + $this->schema['properties'] = array_merge( + $this->schema['properties'], + $services_schema + ); } return $this->schema; diff --git a/src/PairedRouting.php b/src/PairedRouting.php new file mode 100644 index 00000000000..20c52720720 --- /dev/null +++ b/src/PairedRouting.php @@ -0,0 +1,908 @@ + QueryVarUrlStructure::class, + Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX => PathSuffixUrlStructure::class, + Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL => LegacyTransitionalUrlStructure::class, + Option::PAIRED_URL_STRUCTURE_LEGACY_READER => LegacyReaderUrlStructure::class, + ]; + + /** + * Custom paired URL structure. + * + * This involves a site adding the necessary filters to implement their own paired URL structure. + * + * @var string + */ + const PAIRED_URL_STRUCTURE_CUSTOM = 'custom'; + + /** + * Key for AMP paired examples. + * + * @see amp_get_slug() + * @var string + */ + const PAIRED_URL_EXAMPLES = 'paired_url_examples'; + + /** + * Key for the AMP slug. + * + * @see amp_get_slug() + * @var string + */ + const AMP_SLUG = 'amp_slug'; + + /** + * REST API field name for entities already using the AMP slug as name. + * + * @see amp_get_slug() + * @var string + */ + const ENDPOINT_PATH_SLUG_CONFLICTS = 'endpoint_path_slug_conflicts'; + + /** + * REST API field name for whether permalinks are being used in rewrite rules. + * + * @see WP_Rewrite::using_permalinks() + * @var string + */ + const REWRITE_USING_PERMALINKS = 'rewrite_using_permalinks'; + + /** + * Key for the custom paired structure sources. + * + * @var string + */ + const CUSTOM_PAIRED_ENDPOINT_SOURCES = 'custom_paired_endpoint_sources'; + + /** + * Paired URL service. + * + * @var PairedUrl + */ + private $paired_url; + + /** + * Paired URL structure. + * + * @var PairedUrlStructure + */ + private $paired_url_structure; + + /** + * Callback reflection. + * + * @var CallbackReflection + */ + private $callback_reflection; + + /** + * Plugin registry. + * + * @var PluginRegistry + */ + private $plugin_registry; + + /** + * Injector. + * + * @var Injector + */ + private $injector; + + /** + * Whether the request had the /amp/ endpoint suffix. + * + * @var bool + */ + private $did_request_endpoint; + + /** + * Original environment variables that were rewritten before parsing the request. + * + * @see PairedRouting::detect_endpoint_in_environment() + * @see PairedRouting::restore_path_endpoint_in_environment() + * @var array + */ + private $suspended_environment_variables = []; + + /** + * PairedRouting constructor. + * + * @param Injector $injector Injector. + * @param CallbackReflection $callback_reflection Callback reflection. + * @param PluginRegistry $plugin_registry Plugin registry. + * @param PairedUrl $paired_url Paired URL service. + */ + public function __construct( Injector $injector, CallbackReflection $callback_reflection, PluginRegistry $plugin_registry, PairedUrl $paired_url ) { + $this->injector = $injector; + $this->callback_reflection = $callback_reflection; + $this->plugin_registry = $plugin_registry; + $this->paired_url = $paired_url; + } + + /** + * Register. + */ + public function register() { + add_filter( 'amp_rest_options_schema', [ $this, 'filter_rest_options_schema' ] ); + add_filter( 'amp_rest_options', [ $this, 'filter_rest_options' ] ); + + add_filter( 'amp_default_options', [ $this, 'filter_default_options' ], 10, 2 ); + add_filter( 'amp_options_updating', [ $this, 'sanitize_options' ], 10, 2 ); + + add_action( 'template_redirect', [ $this, 'redirect_extraneous_paired_endpoint' ], 9 ); + + // Priority 7 needed to run before PluginSuppression::initialize() at priority 8. + add_action( 'plugins_loaded', [ $this, 'initialize_paired_request' ], 7 ); + } + + /** + * Get the paired URL structure. + * + * @return PairedUrlStructure Paired URL structure. + */ + public function get_paired_url_structure() { + if ( ! $this->paired_url_structure instanceof PairedUrlStructure ) { + /** + * Filters to allow a custom paired URL structure to be used. + * + * @param string $structure_class Paired URL structure class. + */ + $structure_class = apply_filters( 'amp_custom_paired_url_structure', null ); + + if ( ! $structure_class || ! is_subclass_of( $structure_class, PairedUrlStructure::class ) ) { + $structure_slug = AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ); + if ( array_key_exists( $structure_slug, self::PAIRED_URL_STRUCTURES ) ) { + $structure_class = self::PAIRED_URL_STRUCTURES[ $structure_slug ]; + } else { + $structure_class = QueryVarUrlStructure::class; + } + } + + $this->paired_url_structure = $this->injector->make( $structure_class ); + } + return $this->paired_url_structure; + } + + /** + * Filter the REST options schema to add items. + * + * @param array $schema Schema. + * @return array Schema. + */ + public function filter_rest_options_schema( $schema ) { + return array_merge( + $schema, + [ + Option::PAIRED_URL_STRUCTURE => [ + 'type' => 'string', + 'enum' => array_keys( self::PAIRED_URL_STRUCTURES ), + ], + self::PAIRED_URL_EXAMPLES => [ + 'type' => 'object', + 'readonly' => true, + ], + self::AMP_SLUG => [ + 'type' => 'string', + 'readonly' => true, + ], + self::ENDPOINT_PATH_SLUG_CONFLICTS => [ + 'type' => 'array', + 'readonly' => true, + ], + self::REWRITE_USING_PERMALINKS => [ + 'type' => 'boolean', + 'readonly' => true, + ], + ] + ); + } + + /** + * Filter the REST options to add items. + * + * @param array $options Options. + * @return array Options. + */ + public function filter_rest_options( $options ) { + $options[ self::AMP_SLUG ] = amp_get_slug(); + + if ( $this->has_custom_paired_url_structure() ) { + $options[ Option::PAIRED_URL_STRUCTURE ] = self::PAIRED_URL_STRUCTURE_CUSTOM; + } else { + $options[ Option::PAIRED_URL_STRUCTURE ] = AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ); + + // Handle edge case where an unrecognized paired URL structure was saved. + if ( ! in_array( $options[ Option::PAIRED_URL_STRUCTURE ], array_keys( self::PAIRED_URL_STRUCTURES ), true ) ) { + $defaults = $this->filter_default_options( [], $options ); + + $options[ Option::PAIRED_URL_STRUCTURE ] = $defaults[ Option::PAIRED_URL_STRUCTURE ]; + } + } + + $options[ self::PAIRED_URL_EXAMPLES ] = $this->get_paired_url_examples(); + + $options[ self::CUSTOM_PAIRED_ENDPOINT_SOURCES ] = $this->get_custom_paired_structure_sources(); + + $options[ self::ENDPOINT_PATH_SLUG_CONFLICTS ] = $this->get_endpoint_path_slug_conflicts(); + + $options[ self::REWRITE_USING_PERMALINKS ] = $this->is_using_permalinks(); + + return $options; + } + + /** + * Get the entities that are already using the AMP slug. + * + * @return array Conflict data. + */ + public function get_endpoint_path_slug_conflicts() { + $conflicts = []; + $amp_slug = amp_get_slug(); + + $post_query = new WP_Query( + [ + 'post_type' => 'any', + 'name' => $amp_slug, + 'fields' => 'ids', + 'posts_per_page' => 100, + ] + ); + if ( $post_query->post_count > 0 ) { + $conflicts['posts'] = $post_query->posts; + } + + $term_query = new WP_Term_Query( + [ + 'slug' => $amp_slug, + 'fields' => 'ids', + 'hide_empty' => false, + ] + ); + if ( $term_query->terms ) { + $conflicts['terms'] = $term_query->terms; + } + + $user = get_user_by( 'slug', $amp_slug ); + if ( $user ) { + $conflicts['users'] = [ $user->ID ]; + } + + foreach ( get_post_types( [], 'objects' ) as $post_type ) { + if ( + $amp_slug === $post_type->query_var + || + isset( $post_type->rewrite['slug'] ) && $post_type->rewrite['slug'] === $amp_slug + ) { + $conflicts['post_types'][] = $post_type->name; + } + } + + foreach ( get_taxonomies( [], 'objects' ) as $taxonomy ) { + if ( + $amp_slug === $taxonomy->query_var + || + isset( $taxonomy->rewrite['slug'] ) && $taxonomy->rewrite['slug'] === $amp_slug + ) { + $conflicts['taxonomies'][] = $taxonomy->name; + } + } + + return $conflicts; + } + + /** + * Add paired hooks. + */ + public function initialize_paired_request() { + if ( amp_is_canonical() ) { + return; + } + + // Run necessary logic to properly route a request using the registered paired URL structures. + $this->detect_endpoint_in_environment(); + add_filter( 'do_parse_request', [ $this, 'extract_endpoint_from_environment_before_parse_request' ] ); + add_filter( 'request', [ $this, 'filter_request_after_endpoint_extraction' ] ); + add_action( 'parse_request', [ $this, 'restore_path_endpoint_in_environment' ] ); + + // Reserve the 'amp' slug for paired URL structures that use paths. + if ( + in_array( + AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ), + [ + Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX, + Option::PAIRED_URL_STRUCTURE_LEGACY_READER, + ], + true + ) + ) { + // Note that the wp_unique_term_slug filter does not work in the same way. It will only be applied if there + // is actually a duplicate, whereas the wp_unique_post_slug filter applies regardless. + add_filter( 'wp_unique_post_slug', [ $this, 'filter_unique_post_slug' ], 10, 4 ); + } + + add_action( 'parse_query', [ $this, 'correct_query_when_is_front_page' ] ); + add_action( 'wp', [ $this, 'add_paired_request_hooks' ] ); + + add_action( 'admin_notices', [ $this, 'add_permalink_settings_notice' ] ); + } + + /** + * Detect the paired endpoint from the PATH_INFO or REQUEST_URI. + * + * This is necessary to avoid needing to rely on WordPress's rewrite rules to identify AMP requests. + * Rewrite rules are not suitable because rewrite endpoints can't be used across all URLs, + * and the request is parsed too late in order to switch to the Reader theme. + * + * The environment variables containing the endpoint are scrubbed of it during `WP::parse_request()` + * by means of the `PairedRouting::extract_endpoint_from_environment_before_parse_request()` method which runs + * at the `do_parse_request` filter. + * + * @see PairedRouting::extract_endpoint_from_environment_before_parse_request() + */ + public function detect_endpoint_in_environment() { + $this->did_request_endpoint = false; + + // Detect and purge the AMP endpoint from the request. + foreach ( [ 'REQUEST_URI', 'PATH_INFO' ] as $var_name ) { + if ( empty( $_SERVER[ $var_name ] ) ) { + continue; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $old_path = wp_unslash( $_SERVER[ $var_name ] ); // Because of wp_magic_quotes(). + $new_path = $this->get_paired_url_structure()->remove_endpoint( $old_path ); + if ( $old_path === $new_path ) { + continue; + } + + $this->suspended_environment_variables[ $var_name ] = [ $old_path, $new_path ]; + + $this->did_request_endpoint = true; + } + } + + /** + * Override environment before parsing the request. + * + * This happens at the beginning of `WP::parse_request()` and then it is reset when it finishes + * via the `PairedRouting::restore_path_endpoint_in_environment()` method at the `parse_request` + * action. + * + * @see WP::parse_request() + * + * @param bool $do_parse_request Whether or not to parse the request. + * @return bool Passed-through argument. + */ + public function extract_endpoint_from_environment_before_parse_request( $do_parse_request ) { + if ( $this->did_request_endpoint ) { + foreach ( $this->suspended_environment_variables as $var_name => list( , $new_path ) ) { + $_SERVER[ $var_name ] = wp_slash( $new_path ); // Because of wp_magic_quotes(). + } + } + return $do_parse_request; + } + + /** + * Filter the request to add the AMP query var if endpoint was detected in the environment. + * + * @param array $query_vars Query vars. + * @return array Query vars. + */ + public function filter_request_after_endpoint_extraction( $query_vars ) { + if ( $this->did_request_endpoint ) { + $query_vars[ amp_get_slug() ] = true; + } + return $query_vars; + } + + /** + * Restore the path endpoint in environment. + * + * @see PairedRouting::detect_endpoint_in_environment() + * + * @param WP $wp WP object. + */ + public function restore_path_endpoint_in_environment( WP $wp ) { + if ( ! $this->did_request_endpoint ) { + return; + } + foreach ( $this->suspended_environment_variables as $var_name => list( $old_path, ) ) { + $_SERVER[ $var_name ] = wp_slash( $old_path ); // Because of wp_magic_quotes(). + } + $this->suspended_environment_variables = []; + + // In case a plugin is looking at $wp->request to see if it is AMP, ensure the path endpoint is added. + // WordPress is not including it because it was removed in extract_endpoint_from_environment_before_parse_request. + + $request_path = '/'; + if ( $wp->request ) { + $request_path .= trailingslashit( $wp->request ); + } + $endpoint_url = $this->add_endpoint( $request_path ); + $request_path = wp_parse_url( $endpoint_url, PHP_URL_PATH ); + $wp->request = trim( $request_path, '/' ); + } + + /** + * Filters the post slug to prevent conflicting with the 'amp' slug. + * + * @see wp_unique_post_slug() + * + * @param string $slug Slug. + * @param int $post_id Post ID. + * @param string $post_status The post status. + * @param string $post_type Post type. + * @return string Slug. + * @global \wpdb $wpdb WP DB. + */ + public function filter_unique_post_slug( $slug, $post_id, /** @noinspection PhpUnusedParameterInspection */ $post_status, $post_type ) { + global $wpdb; + + $amp_slug = amp_get_slug(); + if ( $amp_slug !== $slug ) { + return $slug; + } + + $suffix = 2; + do { + $alt_slug = "$slug-$suffix"; + $slug_check = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Logic adapted from wp_unique_post_slug(). + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->posts WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1", + $alt_slug, + $post_type, + $post_id + ) + ); + $suffix++; + } while ( $slug_check ); + $slug = $alt_slug; + + return $slug; + } + + /** + * Add hooks based for AMP pages and other hooks for non-AMP pages. + */ + public function add_paired_request_hooks() { + if ( $this->has_endpoint() ) { + add_filter( 'old_slug_redirect_url', [ $this, 'maybe_add_paired_endpoint' ], 1000 ); + add_filter( 'redirect_canonical', [ $this, 'maybe_add_paired_endpoint' ], 1000 ); + } else { + add_action( 'wp_head', 'amp_add_amphtml_link' ); + } + } + + /** + * Add notice to permalink settings screen for where to customize the paired URL structure. + */ + public function add_permalink_settings_notice() { + if ( 'options-permalink' !== get_current_screen()->id ) { + return; + } + ?> +
+

+ Paired URL Structure section on the AMP settings screen.', 'amp' ), + esc_url( admin_url( add_query_arg( 'page', AMP_Options_Manager::OPTION_NAME, 'admin.php' ) ) . '#paired-url-structure' ) + ), + [ 'a' => array_fill_keys( [ 'href' ], true ) ] + ); + ?> +

+
+ is_using_permalinks() + ) { + $value = Option::PAIRED_URL_STRUCTURE_LEGACY_READER; + } elseif ( AMP_Theme_Support::STANDARD_MODE_SLUG !== $options[ Option::THEME_SUPPORT ] ) { + $value = Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL; + } + } + + $defaults[ Option::PAIRED_URL_STRUCTURE ] = $value; + + return $defaults; + } + + /** + * Sanitize options. + * + * Note that in a REST API context this is redundant with the enum defined in the schema. + * + * @param array $options Existing options with already-sanitized values for updating. + * @param array $new_options Unsanitized options being submitted for updating. + * @return array Sanitized options. + */ + public function sanitize_options( $options, $new_options ) { + if ( + isset( $new_options[ Option::PAIRED_URL_STRUCTURE ] ) + && + in_array( $new_options[ Option::PAIRED_URL_STRUCTURE ], array_keys( self::PAIRED_URL_STRUCTURES ), true ) + ) { + $options[ Option::PAIRED_URL_STRUCTURE ] = $new_options[ Option::PAIRED_URL_STRUCTURE ]; + } + return $options; + } + + /** + * Determine a given URL is for a paired AMP request. + * + * If no URL is provided, then it will check whether WordPress has already parsed the AMP + * query var as part of the request. If still not present, then it will get the current URL + * and check if it has an endpoint. + * + * @param string $url URL to examine. If empty, will use the current URL. + * @return bool True if the AMP query parameter is set with the required value, false if not. + * @global WP_Query $wp_the_query + */ + public function has_endpoint( $url = '' ) { + if ( empty( $url ) ) { + // This is a shortcut to avoid needing to re-parse the current URL. + if ( $this->did_request_endpoint ) { + return true; + } + + $slug = amp_get_slug(); + + // On frontend, continue support case where the query var has been (manually) set. + global $wp_the_query; + if ( + $wp_the_query instanceof WP_Query + && + false !== $wp_the_query->get( $slug, false ) + ) { + return true; + } + + // When not in a frontend context (e.g. the Customizer), the query var is the only possibility. + if ( + is_admin() + && + isset( $_GET[ $slug ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ) { + return true; + } + + $url = amp_get_current_url(); + } + return $this->get_paired_url_structure()->has_endpoint( $url ); + } + + /** + * Turn a given URL into a paired AMP URL. + * + * @param string $url URL. + * @return string AMP URL. + */ + public function add_endpoint( $url ) { + return $this->get_paired_url_structure()->add_endpoint( $url ); + } + + /** + * Remove the paired AMP endpoint from a given URL. + * + * @param string $url URL. + * @return string URL with AMP stripped. + */ + public function remove_endpoint( $url ) { + return $this->get_paired_url_structure()->remove_endpoint( $url ); + } + + /** + * Determine whether a custom paired URL structure is being used. + * + * @return bool Whether custom paired URL structure is used. + */ + public function has_custom_paired_url_structure() { + return has_filter( 'amp_custom_paired_url_structure' ); + } + + /** + * Get paired URLs for all available structures. + * + * @param string $url URL. + * @return array Paired URLs keyed by structure. + */ + public function get_all_structure_paired_urls( $url ) { + $paired_urls = []; + foreach ( self::PAIRED_URL_STRUCTURES as $structure_slug => $structure_class ) { + /** @var PairedUrlStructure $structure */ + $structure = $this->injector->make( $structure_class ); + + $paired_urls[ $structure_slug ] = $structure->add_endpoint( $url ); + } + + if ( $this->has_custom_paired_url_structure() ) { + $paired_urls[ self::PAIRED_URL_STRUCTURE_CUSTOM ] = $this->add_endpoint( $url ); + } + + return $paired_urls; + } + + /** + * Get paired URL examples. + * + * @return array[] Keys are the structures, values are arrays of paired URLs using the structure. + */ + public function get_paired_url_examples() { + $supported_post_types = AMP_Post_Type_Support::get_supported_post_types(); + $hierarchical_post_types = array_intersect( + $supported_post_types, + get_post_types( [ 'hierarchical' => true ] ) + ); + $chronological_post_types = array_intersect( + $supported_post_types, + get_post_types( [ 'hierarchical' => false ] ) + ); + + $examples = []; + foreach ( [ $chronological_post_types, $hierarchical_post_types ] as $post_types ) { + if ( empty( $post_types ) ) { + continue; + } + $posts = get_posts( + [ + 'post_type' => $post_types, + 'post_status' => 'publish', + ] + ); + foreach ( $posts as $post ) { + if ( count( AMP_Post_Type_Support::get_support_errors( $post ) ) !== 0 ) { + continue; + } + $paired_urls = $this->get_all_structure_paired_urls( get_permalink( $post ) ); + foreach ( $paired_urls as $structure => $paired_url ) { + $examples[ $structure ][] = $paired_url; + } + continue 2; + } + } + return $examples; + } + + /** + * Get sources for the custom paired URL structure (if any). + * + * @return array Sources. Each item is an array with keys for type, slug, and name. + * @global WP_Hook[] $wp_filter Filter registry. + */ + public function get_custom_paired_structure_sources() { + global $wp_filter; + if ( ! $this->has_custom_paired_url_structure() ) { + return []; + } + + if ( ! isset( $wp_filter['amp_custom_paired_url_structure'] ) ) { + return []; // @codeCoverageIgnore + } + $hook = $wp_filter['amp_custom_paired_url_structure']; + if ( ! $hook instanceof WP_Hook ) { + return []; // @codeCoverageIgnore + } + + $sources = []; + foreach ( $hook->callbacks as $callbacks ) { + foreach ( $callbacks as $callback ) { + $source = $this->callback_reflection->get_source( $callback['function'] ); + if ( ! $source ) { + continue; + } + + $type = $source['type']; + $slug = $source['name']; + $name = null; + + if ( 'plugin' === $type ) { + $plugin = $this->plugin_registry->get_plugin_from_slug( $slug ); + if ( isset( $plugin['data']['Name'] ) ) { + $name = $plugin['data']['Name']; + } + } elseif ( 'theme' === $type ) { + $theme = wp_get_theme( $slug ); + if ( ! $theme->errors() ) { + $name = $theme->get( 'Name' ); + } + } + + $source = compact( 'type', 'slug', 'name' ); + if ( in_array( $source, $sources, true ) ) { + continue; + } + + $sources[] = $source; + } + } + + return $sources; + } + + /** + * Fix up WP_Query for front page when amp query var is present. + * + * Normally the front page would not get served if a query var is present other than preview, page, paged, and cpage. + * + * @see WP_Query::parse_query() + * @link https://github.com/WordPress/wordpress-develop/blob/0baa8ae85c670d338e78e408f8d6e301c6410c86/src/wp-includes/class-wp-query.php#L951-L971 + * + * @param WP_Query $query Query. + */ + public function correct_query_when_is_front_page( WP_Query $query ) { + $is_front_page_query = ( + $query->is_main_query() + && + $query->is_home() + && + // Is AMP endpoint. + false !== $query->get( amp_get_slug(), false ) + && + // Is query not yet fixed up to be front page. + ! $query->is_front_page() + && + // Is showing pages on front. + 'page' === get_option( 'show_on_front' ) + && + // Has page on front set. + get_option( 'page_on_front' ) + && + // See line in WP_Query::parse_query() at . + 0 === count( array_diff( array_keys( wp_parse_args( $query->query ) ), [ amp_get_slug(), 'preview', 'page', 'paged', 'cpage' ] ) ) + ); + if ( $is_front_page_query ) { + $query->is_home = false; + $query->is_page = true; + $query->is_singular = true; + $query->set( 'page_id', get_option( 'page_on_front' ) ); + } + } + + /** + * Add the paired endpoint to a URL. + * + * This is used with the `redirect_canonical` and `old_slug_redirect_url` filters to prevent removal of the `/amp/` + * endpoint. + * + * @param string|false $url URL. This may be false if another filter is attempting to stop redirection. + * @return string Resulting URL with AMP endpoint added if needed. + */ + public function maybe_add_paired_endpoint( $url ) { + if ( $url ) { + $url = $this->add_endpoint( $url ); + } + return $url; + } + + /** + * Redirect to remove the extraneous/erroneous paired endpoint from the requested URI. + * + * When in Standard mode, the behavior is to strip off /amp/ if it is present on the requested URL when it is a 404. + * This ensures that sites switching to AMP-first will have their /amp/ URLs redirecting to the non-AMP, rather than + * attempting to redirect to some post that has 'amp' beginning their post slug. Otherwise, in Standard mode a + * redirect happens to remove the 'amp' query var if present. + * + * When in a Paired AMP mode, this handles a case where an AMP page that has a link to `./amp/` can inadvertently + * cause an infinite URL space such as `./amp/amp/amp/amp/ā€¦`. It also handles the case where the AMP endpoint is + * requested but AMP is not available. + */ + public function redirect_extraneous_paired_endpoint() { + $requested_url = amp_get_current_url(); + $redirect_url = null; + + $endpoint_suffix_removed = $this->paired_url->remove_path_suffix( $requested_url ); + $query_var_removed = $this->paired_url->remove_query_var( $requested_url ); + if ( amp_is_canonical() ) { + if ( is_404() && $endpoint_suffix_removed !== $requested_url ) { + // Always redirect to strip off /amp/ in the case of a 404. + $redirect_url = $endpoint_suffix_removed; + } elseif ( $query_var_removed !== $requested_url ) { + // Strip extraneous query var from AMP-first sites. + $redirect_url = $query_var_removed; + } + } else { + // Calling wp_old_slug_redirect() here is to account for a site that does not have AMP enabled for the 404 template. + // This method is running at template_redirect priority 9 in order to run before redirect_canonical() which runs at + // priority 10. However, wp_old_slug_redirect() also runs at priority 10 (normally), and it needs to run before the + // redirection happens here since it could be that the 404 template would actually not be getting served but rather + // the user should be getting redirected to the new permalink where a singular template is served. For this reason, + // wp_old_slug_redirect() is called just-in-time, and maybe_add_paired_endpoint is added as a filter for + // old_slug_redirect_url which ensures that the AMP endpoint will persist the slug redirect. + wp_old_slug_redirect(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_old_slug_redirect_wp_old_slug_redirect + + if ( is_404() && $endpoint_suffix_removed !== $requested_url ) { + // To account for switching the paired URL structure from `/amp/` to `?amp=1`, add the query var if in Paired + // AMP mode. Note this is not necessary to do when sites have switched from a query var to an endpoint suffix + // because the query var will always be recognized whereas the reverse is not always true. + // This also prevents an infinite URL space under /amp/ endpoint. + $redirect_url = $this->add_endpoint( $endpoint_suffix_removed ); + } elseif ( $this->has_endpoint() && ! amp_is_available() ) { + // Redirect to non-AMP URL if AMP is not available. + $redirect_url = $this->remove_endpoint( $requested_url ); + } + } + + if ( $redirect_url && $redirect_url !== $requested_url ) { + $status_code = current_user_can( 'manage_options' ) ? 302 : 301; + if ( wp_safe_redirect( $redirect_url, $status_code ) ) { + exit; // @codeCoverageIgnore + } + } + } +} diff --git a/src/PairedUrl.php b/src/PairedUrl.php new file mode 100644 index 00000000000..09f2faa574e --- /dev/null +++ b/src/PairedUrl.php @@ -0,0 +1,146 @@ +remove_path_suffix( $url ); + + $parsed_url = wp_parse_url( $url ); + if ( false === $parsed_url ) { + $parsed_url = []; + } + + $parsed_url = array_merge( + wp_parse_url( home_url( '/' ) ), + $parsed_url + ); + + if ( empty( $parsed_url['scheme'] ) ) { + $parsed_url['scheme'] = is_ssl() ? 'https' : 'http'; + } + if ( empty( $parsed_url['host'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $parsed_url['host'] = ! empty( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : 'localhost'; + } + + $parsed_url['path'] = trailingslashit( $parsed_url['path'] ); + $parsed_url['path'] .= user_trailingslashit( amp_get_slug(), 'amp' ); + + $amp_url = $parsed_url['scheme'] . '://'; + if ( ! empty( $parsed_url['user'] ) ) { + $amp_url .= $parsed_url['user']; + if ( ! empty( $parsed_url['pass'] ) ) { + $amp_url .= ':' . $parsed_url['pass']; + } + $amp_url .= '@'; + } + $amp_url .= $parsed_url['host']; + if ( ! empty( $parsed_url['port'] ) ) { + $amp_url .= ':' . $parsed_url['port']; + } + $amp_url .= $parsed_url['path']; + if ( ! empty( $parsed_url['query'] ) ) { + $amp_url .= '?' . $parsed_url['query']; + } + if ( ! empty( $parsed_url['fragment'] ) ) { + $amp_url .= '#' . $parsed_url['fragment']; + } + + return $amp_url; + } +} diff --git a/src/PairedUrlStructure.php b/src/PairedUrlStructure.php new file mode 100644 index 00000000000..3a83743efc0 --- /dev/null +++ b/src/PairedUrlStructure.php @@ -0,0 +1,59 @@ +paired_url = $paired_url; + } + + /** + * Determine a given URL is for a paired AMP request. + * + * @param string $url URL (or REQUEST_URI). + * @return bool True if the URL has the paired endpoint. + */ + public function has_endpoint( $url ) { + return $url !== $this->remove_endpoint( $url ); + } + + /** + * Turn a given URL into a paired AMP URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string AMP URL. + */ + abstract public function add_endpoint( $url ); + + /** + * Remove the paired AMP endpoint from a given URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string URL with AMP stripped. + */ + abstract public function remove_endpoint( $url ); +} diff --git a/src/PairedUrlStructure/LegacyReaderUrlStructure.php b/src/PairedUrlStructure/LegacyReaderUrlStructure.php new file mode 100644 index 00000000000..4da2bed89e9 --- /dev/null +++ b/src/PairedUrlStructure/LegacyReaderUrlStructure.php @@ -0,0 +1,149 @@ +url_to_postid( $url ); + + if ( $post_id ) { + /** + * Filters the AMP permalink to short-circuit normal generation. + * + * Returning a string value in this filter will bypass the `get_permalink()` from being called and the `amp_get_permalink` filter will not apply. + * + * @since 0.4 + * @since 1.0 This filter only applies when using the legacy reader paired URL structure. + * + * @param false $url Short-circuited URL. + * @param int $post_id Post ID. + */ + $pre_url = apply_filters( 'amp_pre_get_permalink', false, $post_id ); + + if ( is_string( $pre_url ) ) { + return $pre_url; + } + } + + // Make sure any existing AMP endpoint is removed. + $url = $this->paired_url->remove_path_suffix( $url ); + $url = $this->paired_url->remove_query_var( $url ); + + $parsed_url = wp_parse_url( $url ); + $use_query_var = ( + // If there are existing query vars, then always use the amp query var as well. + ! empty( $parsed_url['query'] ) + || + // If no post was found for the URL. + ! $post_id + || + // If the post type is hierarchical then the /amp/ endpoint isn't available. + is_post_type_hierarchical( get_post_type( $post_id ) ) + || + // Attachment pages don't accept the /amp/ endpoint. + 'attachment' === get_post_type( $post_id ) + ); + if ( $use_query_var ) { + $amp_url = $this->paired_url->add_query_var( $url, '' ); + } else { + $amp_url = $this->paired_url->add_path_suffix( $url ); + } + + if ( $post_id ) { + /** + * Filters AMP permalink. + * + * @since 0.2 + * @since 1.0 This filter only applies when using the legacy reader paired URL structure. + * + * @param string $amp_url AMP URL. + * @param int $post_id Post ID. + */ + $amp_url = apply_filters( 'amp_get_permalink', $amp_url, $post_id ); + } + + return $amp_url; + } + + /** + * Determine a given URL is for a paired AMP request. + * + * @param string $url URL (or REQUEST_URI). + * @return bool True if the AMP query parameter is set with the required value, false if not. + */ + public function has_endpoint( $url ) { + return $this->paired_url->has_query_var( $url ) || $this->paired_url->has_path_suffix( $url ); + } + + /** + * Remove the paired AMP endpoint from a given URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string URL with AMP stripped. + */ + public function remove_endpoint( $url ) { + $url = $this->paired_url->remove_query_var( $url ); + $url = $this->paired_url->remove_path_suffix( $url ); + return $url; + } + + /** + * Cached version of url_to_postid(), which can be expensive. + * + * Examine a url and try to determine the post ID it represents. + * + * This is copied from the WordPress.com VIP implementation. + * + * @link https://github.com/svn2github/wordpress-vip-plugins/blob/4d6f59f9839167d1c11f550610012493c7380dfe/vip-do-not-include-on-wpcom/wpcom-caching.php#L300-L331 + * @see wpcom_vip_url_to_postid() + * + * @param string $url Permalink to check. + * @return int Post ID, or 0 on failure. + */ + private function url_to_postid( $url ) { + // Can only run after init, since home_url() has not been filtered to the mapped domain prior to that, + // which will cause url_to_postid() to fail. + // See . + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( __METHOD__, 'must be called after the init action, as home_url() has not yet been filtered', '' ); + return 0; + } + + // Sanity check; no URLs not from this site. + $host = wp_parse_url( $url, PHP_URL_HOST ); + if ( $host && wp_parse_url( home_url(), PHP_URL_HOST ) !== $host ) { + return 0; + } + + $cache_key = md5( $url ); + $post_id = wp_cache_get( $cache_key, 'url_to_postid' ); + + if ( false === $post_id ) { + $post_id = url_to_postid( $url ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid -- This method implements the caching. + wp_cache_set( $cache_key, $post_id, 'url_to_postid', 3 * HOUR_IN_SECONDS ); + } + + return $post_id; + } +} diff --git a/src/PairedUrlStructure/LegacyTransitionalUrlStructure.php b/src/PairedUrlStructure/LegacyTransitionalUrlStructure.php new file mode 100644 index 00000000000..a05a4ffb83d --- /dev/null +++ b/src/PairedUrlStructure/LegacyTransitionalUrlStructure.php @@ -0,0 +1,50 @@ +paired_url->add_query_var( $url, '' ); + } + + /** + * Determine a given URL is for a paired AMP request. + * + * @param string $url URL (or REQUEST_URI). + * @return bool True if the AMP query parameter is set with the required value, false if not. + */ + public function has_endpoint( $url ) { + return $this->paired_url->has_query_var( $url ); + } + + /** + * Remove the paired AMP endpoint from a given URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string URL with AMP stripped. + */ + public function remove_endpoint( $url ) { + return $this->paired_url->remove_query_var( $url ); + } +} diff --git a/src/PairedUrlStructure/PathSuffixUrlStructure.php b/src/PairedUrlStructure/PathSuffixUrlStructure.php new file mode 100644 index 00000000000..0059eb97903 --- /dev/null +++ b/src/PairedUrlStructure/PathSuffixUrlStructure.php @@ -0,0 +1,52 @@ +paired_url->add_path_suffix( $url ); + } + + /** + * Determine a given URL is for a paired AMP request. + * + * @param string $url URL (or REQUEST_URI). + * @return bool True if the AMP query parameter is set with the required value, false if not. + */ + public function has_endpoint( $url ) { + return $this->paired_url->has_path_suffix( $url ) || $this->paired_url->has_query_var( $url ); + } + + /** + * Remove the paired AMP endpoint from a given URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string URL with AMP stripped. + */ + public function remove_endpoint( $url ) { + $url = $this->paired_url->remove_path_suffix( $url ); + $url = $this->paired_url->remove_query_var( $url ); + return $url; + } +} diff --git a/src/PairedUrlStructure/QueryVarUrlStructure.php b/src/PairedUrlStructure/QueryVarUrlStructure.php new file mode 100644 index 00000000000..8e94de2eb3f --- /dev/null +++ b/src/PairedUrlStructure/QueryVarUrlStructure.php @@ -0,0 +1,53 @@ +paired_url->add_query_var( $url ); + } + + /** + * Determine a given URL is for a paired AMP request. + * + * @param string $url URL (or REQUEST_URI). + * @return bool True if the AMP query parameter is set with the required value, false if not. + */ + public function has_endpoint( $url ) { + return $this->paired_url->has_query_var( $url ); + } + + /** + * Remove the paired AMP endpoint from a given URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string URL with AMP stripped. + */ + public function remove_endpoint( $url ) { + return $this->paired_url->remove_query_var( $url ); + } +} diff --git a/src/PluginSuppression.php b/src/PluginSuppression.php index f43b16f8537..2b0388635d5 100644 --- a/src/PluginSuppression.php +++ b/src/PluginSuppression.php @@ -45,6 +45,13 @@ final class PluginSuppression implements Service, Registerable { */ private $callback_reflection; + /** + * Paired Routing. + * + * @var PairedRouting + */ + private $paired_routing; + /** * Original render callbacks for blocks. * @@ -62,10 +69,12 @@ final class PluginSuppression implements Service, Registerable { * * @param PluginRegistry $plugin_registry Plugin registry to use. * @param CallbackReflection $callback_reflection Callback reflector to use. + * @param PairedRouting $paired_routing Paired routing service to use. */ - public function __construct( PluginRegistry $plugin_registry, CallbackReflection $callback_reflection ) { + public function __construct( PluginRegistry $plugin_registry, CallbackReflection $callback_reflection, PairedRouting $paired_routing ) { $this->plugin_registry = $plugin_registry; $this->callback_reflection = $callback_reflection; + $this->paired_routing = $paired_routing; } /** @@ -87,6 +96,14 @@ function ( $props, $block_name ) { 2 ); + // Priority 8 needed to run before ReaderThemeLoader::override_theme() at priority 9. + add_action( 'plugins_loaded', [ $this, 'initialize' ], 8 ); + } + + /** + * Initialize. + */ + public function initialize() { // When a Reader theme is selected and an AMP request is being made, start suppressing as early as possible. // This can be done because we know it is an AMP page due to the query parameter, but it also _has_ to be done // specifically for the case of accessing the AMP Customizer (in which customize.php is requested with the query @@ -94,6 +111,7 @@ function ( $props, $block_name ) { // could be done early for Transitional mode as well since a query parameter is also used for frontend requests // but there is no similar need to suppress the registration of Customizer controls in Transitional mode since // there is no separate Customizer for AMP in Transitional mode (or legacy Reader mode). + // @todo This check could be replaced with ( ! amp_is_canonical() && $this->paired_routing->has_endpoint() ). if ( $this->is_reader_theme_request() ) { $this->suppress_plugins(); } else { @@ -116,7 +134,7 @@ public function is_reader_theme_request() { && ReaderThemes::DEFAULT_READER_THEME !== AMP_Options_Manager::get_option( Option::READER_THEME ) && - amp_has_paired_endpoint() + $this->paired_routing->has_endpoint() ); } diff --git a/src/ReaderThemeLoader.php b/src/ReaderThemeLoader.php index 6d029821e48..24fe607fa15 100644 --- a/src/ReaderThemeLoader.php +++ b/src/ReaderThemeLoader.php @@ -27,6 +27,13 @@ */ final class ReaderThemeLoader implements Service, Registerable { + /** + * Paired routing service. + * + * @var PairedRouting + */ + private $paired_routing; + /** * Reader theme. * @@ -50,6 +57,15 @@ final class ReaderThemeLoader implements Service, Registerable { */ private $theme_overridden = false; + /** + * ReaderThemeLoader constructor. + * + * @param PairedRouting $paired_routing Paired routing service. + */ + public function __construct( PairedRouting $paired_routing ) { + $this->paired_routing = $paired_routing; + } + /** * Is Reader mode with a Reader theme selected. * @@ -302,7 +318,8 @@ public function get_active_theme() { */ public function override_theme() { $this->theme_overridden = false; - if ( ! $this->is_enabled() || ! amp_has_paired_endpoint() ) { + + if ( ! $this->is_enabled() || ! $this->paired_routing->has_endpoint() ) { return; } diff --git a/tests/e2e/specs/admin/analytics-options.js b/tests/e2e/specs/admin/analytics-options.js index 92e33761c18..4b5ea325420 100644 --- a/tests/e2e/specs/admin/analytics-options.js +++ b/tests/e2e/specs/admin/analytics-options.js @@ -3,6 +3,11 @@ */ import { visitAdminPage } from '@wordpress/e2e-test-utils'; +/** + * Internal dependencies + */ +import { scrollToElement } from '../../utils/onboarding-wizard-utils'; + describe( 'AMP analytics options', () => { beforeEach( async () => { await visitAdminPage( 'admin.php', 'page=amp-options' ); @@ -13,6 +18,8 @@ describe( 'AMP analytics options', () => { await expect( page ).toClick( '#analytics-options .components-panel__body-toggle' ); await expect( page ).not.toMatchElement( '.amp-analytics-entry' ); + await scrollToElement( { selector: '#amp-analytics-add-entry' } ); + // Add entry. await expect( page ).toClick( '#amp-analytics-add-entry' ); await expect( '.amp-analytics-entry' ).countToBe( 1 ); @@ -26,9 +33,7 @@ describe( 'AMP analytics options', () => { // Save. await expect( page ).toClick( '.amp-settings-nav button[type="submit"]' ); - // Wait for the success notice. Note: This might not be reliable and should be removed if it causes problems. - await page.waitForTimeout( 2000 ); - await expect( page ).toMatchElement( '.amp .amp-save-success-notice.amp-notice' ); + await page.waitForSelector( '.amp .amp-save-success-notice.amp-notice' ); // Delete entries. await expect( page ).toClick( '.amp-analytics__delete-button' ); diff --git a/tests/php/src/Admin/PairedBrowsingTest.php b/tests/php/src/Admin/PairedBrowsingTest.php new file mode 100644 index 00000000000..8b67d050001 --- /dev/null +++ b/tests/php/src/Admin/PairedBrowsingTest.php @@ -0,0 +1,247 @@ +instance = $this->injector->make( PairedBrowsing::class ); + } + + /** @covers ::is_needed() */ + public function test_is_needed() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); + $this->assertFalse( PairedBrowsing::is_needed() ); + + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::READER_MODE_SLUG ); + $this->assertFalse( PairedBrowsing::is_needed() ); + + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + $this->assertTrue( PairedBrowsing::is_needed() ); + } + + /** @covers ::__construct() */ + public function test__construct() { + $this->assertInstanceOf( PairedBrowsing::class, $this->instance ); + $this->assertInstanceOf( Service::class, $this->instance ); + $this->assertInstanceOf( Registerable::class, $this->instance ); + $this->assertInstanceOf( Conditional::class, $this->instance ); + } + + /** @covers ::register() */ + public function test_register() { + $this->instance->register(); + $this->assertEquals( PHP_INT_MAX, has_action( 'wp', [ $this->instance, 'init_frontend' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_dev_mode_element_xpaths', [ $this->instance, 'filter_dev_mode_element_xpaths' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_validated_url_status_actions', [ $this->instance, 'filter_validated_url_status_actions' ] ) ); + } + + /** @covers ::filter_dev_mode_element_xpaths() */ + public function test_filter_dev_mode_element_xpaths() { + $xpaths = $this->instance->filter_dev_mode_element_xpaths( [ '//div' ] ); + $this->assertCount( 2, $xpaths ); + } + + /** @covers ::filter_validated_url_status_actions() */ + public function test_filter_validated_url_status_actions() { + $post = self::factory()->post->create_and_get(); + $actions = $this->instance->filter_validated_url_status_actions( [ 'foo' => 'bar' ], $post ); + $this->assertCount( 2, $actions ); + $this->assertArrayHasKey( 'foo', $actions ); + $this->assertArrayHasKey( 'paired_browsing', $actions ); + } + + /** @covers ::init_frontend() */ + public function test_init_frontend_short_circuited() { + $post = self::factory()->post->create_and_get(); + + $assert_short_circuited = function () { + $this->assertFalse( has_action( 'template_redirect', [ $this->instance, 'ensure_app_location' ] ) ); + $this->assertFalse( has_action( 'admin_bar_menu', [ $this->instance, 'add_admin_bar_menu_item' ] ) ); + }; + + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + $this->go_to( get_permalink( $post ) ); + + // Check first short-circuit condition. + add_filter( 'amp_skip_post', '__return_true' ); + add_filter( 'amp_dev_mode_enabled', '__return_true' ); + $this->assertFalse( amp_is_available() ); + $this->assertTrue( amp_is_dev_mode() ); + $this->instance->init_frontend(); + $assert_short_circuited(); + remove_all_filters( 'amp_skip_post' ); + remove_all_filters( 'amp_dev_mode_enabled' ); + + // Check second short-circuit condition. + add_filter( 'amp_skip_post', '__return_false' ); + add_filter( 'amp_dev_mode_enabled', '__return_false' ); + $this->assertTrue( amp_is_available() ); + $this->assertFalse( amp_is_dev_mode() ); + $this->instance->init_frontend(); + $assert_short_circuited(); + remove_all_filters( 'amp_skip_post' ); + remove_all_filters( 'amp_dev_mode_enabled' ); + + // Check condition for + $this->assertTrue( amp_is_available() ); + } + + /** + * @covers ::init_frontend() + * @covers ::init_app() + */ + public function test_init_frontend_app() { + $post = self::factory()->post->create_and_get(); + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + $this->go_to( add_query_arg( PairedBrowsing::APP_QUERY_VAR, '1', get_permalink( $post ) ) ); + + add_filter( 'amp_skip_post', '__return_false' ); + add_filter( 'amp_dev_mode_enabled', '__return_true' ); + $this->instance->init_frontend(); + + // Check that init_app() was called. + $this->assertEquals( 10, has_action( 'template_redirect', [ $this->instance, 'ensure_app_location' ] ) ); + $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', [ $this->instance, 'filter_template_include_for_app' ] ) ); + + // Check that init_client() was not called. + $this->assertFalse( has_action( 'admin_bar_menu', [ $this->instance, 'add_admin_bar_menu_item' ] ) ); + $this->assertEquals( 0, did_action( 'amp_register_polyfills' ) ); + } + + /** + * @covers ::init_frontend() + * @covers ::init_client() + */ + public function test_init_frontend_client() { + $post = self::factory()->post->create_and_get(); + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + + add_filter( 'amp_skip_post', '__return_false' ); + add_filter( 'amp_dev_mode_enabled', '__return_true' ); + $this->go_to( $this->instance->paired_routing->add_endpoint( get_permalink( $post ) ) ); + $this->assertTrue( amp_is_request() ); + $this->instance->init_frontend(); + + // Check that init_client() was called. + $this->assertEquals( 102, has_action( 'admin_bar_menu', [ $this->instance, 'add_admin_bar_menu_item' ] ) ); + $this->assertEquals( 1, did_action( 'amp_register_polyfills' ) ); + $this->assertTrue( wp_script_is( 'amp-paired-browsing-client' ) ); + $printed_scripts = get_echo( 'wp_print_scripts' ); + $this->assertStringContains( DevMode::DEV_MODE_ATTRIBUTE, $printed_scripts ); + $this->assertStringContains( 'ampPairedBrowsingClientData', $printed_scripts ); + $this->assertStringContains( 'isAmpDocument', $printed_scripts ); + $this->assertStringContains( 'amp-paired-browsing-client.js', $printed_scripts ); + + // Check that init_app() was not called. + $this->assertFalse( has_action( 'template_redirect', [ $this->instance, 'ensure_app_location' ] ) ); + } + + /** @covers ::add_admin_bar_menu_item() */ + public function test_add_admin_bar_menu_item() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + add_filter( 'show_admin_bar', '__return_true' ); + $wp_admin_bar = new WP_Admin_Bar(); + + // Test when DevTools not enabled. + $this->assertFalse( $this->instance->dev_tools_user_access->is_user_enabled() ); + $this->instance->add_admin_bar_menu_item( $wp_admin_bar ); + $this->assertEmpty( $wp_admin_bar->get_node( 'amp-paired-browsing' ) ); + + // Test when DevTools enabled. + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->assertTrue( $this->instance->dev_tools_user_access->is_user_enabled() ); + $this->instance->add_admin_bar_menu_item( $wp_admin_bar ); + $this->assertNotEmpty( $wp_admin_bar->get_node( 'amp-paired-browsing' ) ); + } + + /** @covers ::get_paired_browsing_url() */ + public function test_get_paired_browsing_url() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + $post_id = self::factory()->post->create(); + $this->go_to( amp_get_permalink( $post_id ) ); + + $this->assertStringContains( PairedBrowsing::APP_QUERY_VAR . '=1', $this->instance->get_paired_browsing_url() ); + $this->assertStringNotContains( amp_get_slug() . '=1', $this->instance->get_paired_browsing_url() ); + $this->assertEquals( + $this->instance->get_paired_browsing_url(), + $this->instance->get_paired_browsing_url( amp_get_current_url() ) + ); + } + + /** @covers ::ensure_app_location() */ + public function test_ensure_app_location() { + $redirected = false; + add_filter( + 'wp_redirect', + function () use ( &$redirected ) { + $redirected = true; + return false; + } + ); + + // Test that redirection is not needed. + $this->go_to( $this->instance->get_paired_browsing_url( home_url( '/' ) ) ); + $this->instance->ensure_app_location(); + $this->assertFalse( $redirected ); + + // Test that redirection is needed. + $this->go_to( add_query_arg( QueryVar::NOAMP, $this->instance->get_paired_browsing_url( home_url( '/' ) ) ) ); + $this->instance->ensure_app_location(); + $this->assertTrue( $redirected ); + } + + /** @covers ::filter_template_include_for_app() */ + public function test_filter_template_include_for_app_when_no_dev_mode() { + add_filter( 'amp_dev_mode_enabled', '__return_false' ); + $this->setExpectedException( WPDieException::class, 'Paired browsing is only available when AMP dev mode is enabled (e.g. when logged-in and admin bar is showing).' ); + $this->instance->filter_template_include_for_app(); + } + + /** @covers ::filter_template_include_for_app() */ + public function test_filter_template_include_for_app_when_allowed() { + add_filter( 'amp_dev_mode_enabled', '__return_true' ); + $this->assertEquals( 0, did_action( 'amp_register_polyfills' ) ); + + $include_path = $this->instance->filter_template_include_for_app(); + $this->assertEquals( 1, did_action( 'amp_register_polyfills' ) ); + $this->assertTrue( wp_style_is( 'amp-paired-browsing-app' ) ); + $this->assertTrue( wp_script_is( 'amp-paired-browsing-app' ) ); + + ob_start(); + load_template( $include_path ); + $template = ob_get_clean(); + + $this->assertStringContains( 'amp-paired-browsing-app.css', $template ); + $this->assertStringContains( 'amp-paired-browsing-app.js', $template ); + $this->assertStringContains( 'ampPairedBrowsingAppData', $template ); + $this->assertStringContains( 'ampPairedBrowsingQueryVar', $template ); + } +} diff --git a/tests/php/src/DependencyInjectedTestCase.php b/tests/php/src/DependencyInjectedTestCase.php index 9015f29d6ea..cadf0a5f4ea 100644 --- a/tests/php/src/DependencyInjectedTestCase.php +++ b/tests/php/src/DependencyInjectedTestCase.php @@ -64,5 +64,9 @@ public function tearDown() { $this->set_private_property( Services::class, 'plugin', null ); $this->set_private_property( Services::class, 'container', null ); $this->set_private_property( Services::class, 'injector', null ); + + // WordPress core fails to do this. + $GLOBALS['wp_the_query'] = $GLOBALS['wp_query']; + unset( $GLOBALS['current_screen'] ); } } diff --git a/tests/php/src/Fixture/DummyPairedUrlStructure.php b/tests/php/src/Fixture/DummyPairedUrlStructure.php new file mode 100644 index 00000000000..860e0a5ef24 --- /dev/null +++ b/tests/php/src/Fixture/DummyPairedUrlStructure.php @@ -0,0 +1,43 @@ +has_endpoint( $url ) ) { + return $url; + } + $slug = amp_get_slug(); + return preg_replace( + '#^((\w+:)?//[^/]+)?/#', + "$1/{$slug}/", + $url + ); + } + + /** + * Remove AMP path prefix from a URL. + * + * @param string $url URL (or REQUEST_URI). + * @return string URL without amp subdomain. + */ + public function remove_endpoint( $url ) { + return preg_replace( + sprintf( + '#^((\w+:)?//[^/]+)?/%s/#', + preg_quote( amp_get_slug(), '#' ) + ), + '$1/', + $url + ); + } +} diff --git a/tests/php/src/MobileRedirectionTest.php b/tests/php/src/MobileRedirectionTest.php index a5a43a6f566..8f2b356dce2 100644 --- a/tests/php/src/MobileRedirectionTest.php +++ b/tests/php/src/MobileRedirectionTest.php @@ -2,34 +2,42 @@ namespace AmpProject\AmpWP\Tests; +use AmpProject\AmpWP\Admin\ReaderThemes; use AmpProject\AmpWP\Infrastructure\Registerable; use AmpProject\AmpWP\Infrastructure\Service; use AmpProject\AmpWP\Option; +use AmpProject\AmpWP\PairedRouting; use AmpProject\AmpWP\QueryVar; use AmpProject\AmpWP\MobileRedirection; use AmpProject\AmpWP\Tests\Helpers\AssertContainsCompatibility; use AMP_Options_Manager; use AMP_Theme_Support; -use WP_UnitTestCase; use WP_Customize_Manager; +use AMP_HTTP; /** @coversDefaultClass \AmpProject\AmpWP\MobileRedirection */ -final class MobileRedirectionTest extends WP_UnitTestCase { +final class MobileRedirectionTest extends DependencyInjectedTestCase { use AssertContainsCompatibility; /** @var MobileRedirection */ private $instance; + /** @var PairedRouting */ + private $paired_routing; + public function setUp() { parent::setUp(); - $this->instance = new MobileRedirection(); + $this->paired_routing = $this->injector->make( PairedRouting::class ); + $this->instance = new MobileRedirection( $this->paired_routing ); } public function tearDown() { parent::tearDown(); $_COOKIE = []; unset( $GLOBALS['wp_customize'] ); + AMP_HTTP::$purged_amp_query_vars = []; + $GLOBALS['wp_the_query'] = $GLOBALS['wp_query']; // This is missing in core. } public function test__construct() { @@ -39,13 +47,44 @@ public function test__construct() { } /** @covers ::register() */ - public function test_register() { - AMP_Options_Manager::update_option( Option::MOBILE_REDIRECT, true ); + public function test_register_legacy_reader_mode() { + AMP_Options_Manager::update_options( + [ + Option::MOBILE_REDIRECT => true, + Option::THEME_SUPPORT => AMP_Theme_Support::READER_MODE_SLUG, + Option::READER_THEME => ReaderThemes::DEFAULT_READER_THEME, + ] + ); $this->instance->register(); $this->assertSame( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); $this->assertSame( 10, has_filter( 'amp_options_updating', [ $this->instance, 'sanitize_options' ] ) ); $this->assertSame( PHP_INT_MAX, has_action( 'template_redirect', [ $this->instance, 'redirect' ] ) ); $this->assertSame( 0, has_filter( 'amp_to_amp_linking_enabled', '__return_true' ) ); + $this->assertSame( 10, has_filter( 'comment_post_redirect', [ $this->instance, 'filter_comment_post_redirect' ] ) ); + + $this->assertTrue( amp_is_legacy() ); + $this->assertSame( 10, has_filter( 'get_comments_link', [ $this->instance, 'add_noamp_mobile_query_var' ] ) ); + $this->assertSame( 10, has_filter( 'respond_link', [ $this->instance, 'add_noamp_mobile_query_var' ] ) ); + } + + /** @covers ::register() */ + public function test_register_transitional_mode() { + AMP_Options_Manager::update_options( + [ + Option::MOBILE_REDIRECT => true, + Option::THEME_SUPPORT => AMP_Theme_Support::TRANSITIONAL_MODE_SLUG, + ] + ); + $this->instance->register(); + $this->assertSame( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); + $this->assertSame( 10, has_filter( 'amp_options_updating', [ $this->instance, 'sanitize_options' ] ) ); + $this->assertSame( PHP_INT_MAX, has_action( 'template_redirect', [ $this->instance, 'redirect' ] ) ); + $this->assertSame( 0, has_filter( 'amp_to_amp_linking_enabled', '__return_true' ) ); + $this->assertSame( 10, has_filter( 'comment_post_redirect', [ $this->instance, 'filter_comment_post_redirect' ] ) ); + + $this->assertFalse( amp_is_legacy() ); + $this->assertFalse( has_filter( 'get_comments_link', [ $this->instance, 'add_noamp_mobile_query_var' ] ) ); + $this->assertFalse( has_filter( 'respond_link', [ $this->instance, 'add_noamp_mobile_query_var' ] ) ); } /** @covers ::register() */ @@ -54,8 +93,32 @@ public function test_register_not_enabled() { $this->instance->register(); $this->assertSame( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); $this->assertSame( 10, has_filter( 'amp_options_updating', [ $this->instance, 'sanitize_options' ] ) ); + $this->assert_hooks_not_added(); + } + + /** @covers ::register() */ + public function test_register_enabled_but_standard_mode() { + AMP_Options_Manager::update_options( + [ + Option::MOBILE_REDIRECT => true, + Option::THEME_SUPPORT => AMP_Theme_Support::STANDARD_MODE_SLUG, + ] + ); + $this->instance->register(); + $this->assertSame( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); + $this->assertSame( 10, has_filter( 'amp_options_updating', [ $this->instance, 'sanitize_options' ] ) ); + $this->assert_hooks_not_added(); + } + + /** + * Assert the service hooks were not added. + */ + private function assert_hooks_not_added() { $this->assertFalse( has_action( 'template_redirect', [ $this->instance, 'redirect' ] ) ); $this->assertFalse( has_filter( 'amp_to_amp_linking_enabled', '__return_true' ) ); + $this->assertFalse( has_filter( 'comment_post_redirect', [ $this->instance, 'filter_comment_post_redirect' ] ) ); + $this->assertFalse( has_filter( 'get_comments_link', [ $this->instance, 'add_noamp_mobile_query_var' ] ) ); + $this->assertFalse( has_filter( 'respond_link', [ $this->instance, 'add_noamp_mobile_query_var' ] ) ); } /** @covers ::filter_default_options() */ @@ -119,7 +182,7 @@ public function test_sanitize_options() { public function test_get_current_amp_url() { $this->go_to( add_query_arg( QueryVar::NOAMP, QueryVar::NOAMP_MOBILE, '/foo/' ) ); $this->assertEquals( - amp_add_paired_endpoint( home_url( '/foo/' ) ), + $this->paired_routing->add_endpoint( home_url( '/foo/' ) ), $this->instance->get_current_amp_url() ); } @@ -151,7 +214,7 @@ public function test_redirect_on_transitional_and_not_available() { AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); AMP_Options_Manager::update_option( Option::ALL_TEMPLATES_SUPPORTED, false ); AMP_Options_Manager::update_option( Option::SUPPORTED_TEMPLATES, [ 'is_author' ] ); - $this->go_to( amp_add_paired_endpoint( '/' ) ); + $this->go_to( '/' ); $this->assertFalse( amp_is_canonical() ); $this->assertFalse( amp_is_available() ); $this->instance->redirect(); @@ -185,7 +248,8 @@ public function test_redirect_when_server_side_and_not_applicable() { add_filter( 'amp_mobile_client_side_redirection', '__return_false' ); add_filter( 'amp_pre_is_mobile', '__return_false' ); - $this->go_to( amp_add_paired_endpoint( '/' ) ); + $this->go_to( '/' ); + $this->assertFalse( amp_is_request() ); $this->assertFalse( $this->instance->is_mobile_request() ); @@ -226,7 +290,7 @@ static function ( $redirect_url ) use ( &$redirected_url ) { $this->instance->redirect(); $this->assertNotNull( $redirected_url ); $this->assertEquals( - amp_add_paired_endpoint( home_url( '/' ) ), + $this->paired_routing->add_endpoint( home_url( '/' ) ), $redirected_url ); } @@ -422,6 +486,36 @@ public function test_add_mobile_redirect_script() { $this->assertStringContains( 'noampQueryVarName', $output ); } + /** @covers ::filter_comment_post_redirect() */ + public function test_filter_comment_post_redirect() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + + $post_id = self::factory()->post->create(); + $comment_link = get_permalink( $post_id ) . '#comment-123'; + + $this->assertEquals( $comment_link, $this->instance->filter_comment_post_redirect( $comment_link ) ); + + AMP_HTTP::$purged_amp_query_vars[ AMP_HTTP::ACTION_XHR_CONVERTED_QUERY_VAR ] = 1; + + $filtered_comment_link = $this->instance->filter_comment_post_redirect( $comment_link ); + $this->assertNotEquals( $comment_link, $filtered_comment_link ); + $this->assertStringContains( QueryVar::AMP . '=1', $filtered_comment_link ); + + $external_url = 'https://external.example.com/'; + $this->assertEquals( $external_url, $this->instance->filter_comment_post_redirect( $external_url ) ); + } + + /** @covers ::add_noamp_mobile_query_var() */ + public function test_add_noamp_mobile_query_var() { + $post_id = self::factory()->post->create(); + $comments_link = get_comments_link( $post_id ); + + $this->assertStringEndsWith( + QueryVar::NOAMP . '=' . QueryVar::NOAMP_MOBILE . '#respond', + $this->instance->add_noamp_mobile_query_var( $comments_link ) + ); + } + /** @covers ::add_mobile_alternative_link() */ public function test_add_mobile_alternative_link() { ob_start(); diff --git a/tests/php/src/OptionsRESTControllerTest.php b/tests/php/src/OptionsRESTControllerTest.php index 54590f493ce..36c58a0729c 100644 --- a/tests/php/src/OptionsRESTControllerTest.php +++ b/tests/php/src/OptionsRESTControllerTest.php @@ -66,7 +66,7 @@ public function test_get_items_permissions_check() { */ public function test_get_items() { $data = $this->controller->get_items( new WP_REST_Request( 'GET', '/amp/v1/options' ) )->get_data(); - $this->assertEquals( + $this->assertEqualSets( [ 'theme_support', 'reader_theme', @@ -83,6 +83,12 @@ public function test_get_items() { 'supportable_templates', 'onboarding_wizard_link', 'customizer_link', + 'paired_url_structure', + 'paired_url_examples', + 'amp_slug', + 'custom_paired_endpoint_sources', + 'endpoint_path_slug_conflicts', + 'rewrite_using_permalinks', ], array_keys( $data ) ); diff --git a/tests/php/src/PairedRoutingTest.php b/tests/php/src/PairedRoutingTest.php new file mode 100644 index 00000000000..8abdf79742f --- /dev/null +++ b/tests/php/src/PairedRoutingTest.php @@ -0,0 +1,950 @@ +instance = $this->injector->make( PairedRouting::class ); + } + + public function tearDown() { + unset( $_SERVER['REQUEST_URI'] ); + parent::tearDown(); + unregister_taxonomy( amp_get_slug() ); + unregister_post_type( amp_get_slug() ); + } + + /** @covers ::__construct() */ + public function test__construct() { + $this->assertInstanceOf( PairedRouting::class, $this->instance ); + $this->assertInstanceOf( Service::class, $this->instance ); + $this->assertInstanceOf( Registerable::class, $this->instance ); + } + + /** + * @covers ::register() + */ + public function test_register() { + remove_all_actions( 'plugins_loaded' ); // @todo This is needed because the instance already got registered. + $this->instance->register(); + $this->assertEquals( 10, has_filter( 'amp_rest_options_schema', [ $this->instance, 'filter_rest_options_schema' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_rest_options', [ $this->instance, 'filter_rest_options' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_options_updating', [ $this->instance, 'sanitize_options' ] ) ); + $this->assertEquals( 9, has_action( 'template_redirect', [ $this->instance, 'redirect_extraneous_paired_endpoint' ] ) ); + $this->assertEquals( 7, has_action( 'plugins_loaded', [ $this->instance, 'initialize_paired_request' ] ) ); + } + + /** @return array */ + public function get_data_for_test_get_paired_url_structure() { + return [ + 'query_var' => [ + Option::PAIRED_URL_STRUCTURE_QUERY_VAR, + QueryVarUrlStructure::class, + ], + 'path_suffix' => [ + Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX, + PathSuffixUrlStructure::class, + ], + 'legacy_transitional' => [ + Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL, + LegacyTransitionalUrlStructure::class, + ], + 'legacy_reader' => [ + Option::PAIRED_URL_STRUCTURE_LEGACY_READER, + LegacyReaderUrlStructure::class, + ], + 'bogus' => [ + 'bogus', + QueryVarUrlStructure::class, + ], + ]; + } + + /** + * @covers ::get_paired_url_structure() + * @dataProvider get_data_for_test_get_paired_url_structure + * @param string $option_value Option value. + * @param string $structure_class Expected structure. + */ + public function test_get_paired_url_structure( $option_value, $structure_class ) { + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, $option_value ); + $structure = $this->instance->get_paired_url_structure(); + $this->assertInstanceOf( $structure_class, $structure ); + } + + /** @covers ::get_paired_url_structure() */ + public function test_get_paired_url_structure_custom_filtered() { + add_filter( + 'amp_custom_paired_url_structure', + static function () { + return DummyPairedUrlStructure::class; + } + ); + $structure = $this->instance->get_paired_url_structure(); + $this->assertInstanceOf( DummyPairedUrlStructure::class, $structure ); + } + + /** @covers ::filter_rest_options_schema() */ + public function test_filter_rest_options_schema() { + $existing = [ + 'foo' => [ + 'type' => 'string', + ], + ]; + + $filtered = $this->instance->filter_rest_options_schema( $existing ); + $this->assertArrayHasKey( 'foo', $filtered ); + $this->assertArrayHasKey( Option::PAIRED_URL_STRUCTURE, $filtered ); + $this->assertArrayHasKey( PairedRouting::PAIRED_URL_EXAMPLES, $filtered ); + $this->assertArrayHasKey( PairedRouting::AMP_SLUG, $filtered ); + $this->assertArrayHasKey( PairedRouting::ENDPOINT_PATH_SLUG_CONFLICTS, $filtered ); + $this->assertArrayHasKey( PairedRouting::REWRITE_USING_PERMALINKS, $filtered ); + } + + /** @covers ::filter_rest_options() */ + public function test_filter_rest_options() { + $existing = [ + 'foo' => 'bar', + ]; + + $options = $this->instance->filter_rest_options( $existing ); + + $this->assertEquals( amp_get_slug(), $options[ PairedRouting::AMP_SLUG ] ); + $this->assertEquals( + AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ), + $options[ Option::PAIRED_URL_STRUCTURE ] + ); + $this->assertEquals( $this->instance->get_paired_url_examples(), $options[ PairedRouting::PAIRED_URL_EXAMPLES ] ); + $this->assertEquals( $this->instance->get_custom_paired_structure_sources(), $options[ PairedRouting::CUSTOM_PAIRED_ENDPOINT_SOURCES ] ); + $this->assertEquals( $this->instance->get_endpoint_path_slug_conflicts(), $options[ PairedRouting::ENDPOINT_PATH_SLUG_CONFLICTS ] ); + $this->assertEquals( $this->instance->is_using_permalinks(), $options[ PairedRouting::REWRITE_USING_PERMALINKS ] ); + } + + /** @covers ::get_endpoint_path_slug_conflicts() */ + public function test_get_endpoint_path_slug_conflicts() { + $this->assertCount( 0, $this->instance->get_endpoint_path_slug_conflicts() ); + + // Posts. + self::factory()->post->create( [ 'post_name' => amp_get_slug() ] ); + $this->assertEquals( + [ 'posts' ], + array_keys( $this->instance->get_endpoint_path_slug_conflicts() ) + ); + + // Terms. + self::factory()->term->create( + [ + 'taxonomy' => 'category', + 'name' => amp_get_slug(), + ] + ); + $this->assertEquals( + [ 'posts', 'terms' ], + array_keys( $this->instance->get_endpoint_path_slug_conflicts() ) + ); + + // Users. + self::factory()->user->create( + [ + 'user_login' => 'amp', + ] + ); + $this->assertEquals( + [ 'posts', 'terms', 'users' ], + array_keys( $this->instance->get_endpoint_path_slug_conflicts() ) + ); + + // Post types. + register_post_type( amp_get_slug() ); + $this->assertEquals( + [ 'posts', 'terms', 'users', 'post_types' ], + array_keys( $this->instance->get_endpoint_path_slug_conflicts() ) + ); + + // Taxonomies. + register_taxonomy( amp_get_slug(), 'post' ); + $this->assertEquals( + [ 'posts', 'terms', 'users', 'post_types', 'taxonomies' ], + array_keys( $this->instance->get_endpoint_path_slug_conflicts() ) + ); + } + + /** @return array */ + public function get_data_for_test_paired_requests() { + return [ + 'query_var_reader_mode_amp' => [ + AMP_Theme_Support::READER_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_QUERY_VAR, + '/?amp=1', + true, + ], + 'query_var_reader_mode_non_amp' => [ + AMP_Theme_Support::READER_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_QUERY_VAR, + '/', + false, + ], + 'path_suffix_transitional_mode_amp' => [ + AMP_Theme_Support::TRANSITIONAL_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX, + '/amp/', + true, + ], + 'path_suffix_transitional_mode_non_amp' => [ + AMP_Theme_Support::TRANSITIONAL_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX, + '/', + false, + ], + 'legacy_reader_mode_amp' => [ + AMP_Theme_Support::READER_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_LEGACY_READER, + '/amp/', + true, + ], + 'legacy_transitional_mode_amp' => [ + AMP_Theme_Support::TRANSITIONAL_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL, + '/?amp=1', + true, + ], + 'standard_mode' => [ + AMP_Theme_Support::STANDARD_MODE_SLUG, + Option::PAIRED_URL_STRUCTURE_QUERY_VAR, + '/', + null, + ], + ]; + } + + /** + * Test initialize_paired_request, integrated with other methods. + * + * @covers ::initialize_paired_request() + * @covers ::detect_endpoint_in_environment() + * @covers ::extract_endpoint_from_environment_before_parse_request() + * @covers ::filter_request_after_endpoint_extraction() + * @covers ::restore_path_endpoint_in_environment() + * + * @dataProvider get_data_for_test_paired_requests + * + * @param string $mode + * @param string $structure + * @param string $request_uri + * @param bool $did_request_endpoint + */ + public function test_initialize_paired_request_integration( $mode, $structure, $request_uri, $did_request_endpoint ) { + global $wp; + $post_id = self::factory()->post->create(); + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, $mode ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, $structure ); + + $permalink = get_permalink( $post_id ); + $request_uri = rtrim( wp_parse_url( $permalink, PHP_URL_PATH ), '/' ) . $request_uri; + + $request_uri_during_parse_request = null; + add_filter( + 'request', + function ( $query_vars ) use ( &$request_uri_during_parse_request ) { + $request_uri_during_parse_request = $_SERVER['REQUEST_URI']; + return $query_vars; + } + ); + + $_SERVER['REQUEST_URI'] = $request_uri; + $this->instance->initialize_paired_request(); + $this->go_to( $request_uri ); + + if ( $did_request_endpoint ) { + $this->assertNotEmpty( $request_uri_during_parse_request ); + $this->assertNotEquals( $request_uri_during_parse_request, $request_uri ); + $this->assertEquals( + $this->instance->get_paired_url_structure()->remove_endpoint( $request_uri ), + $request_uri_during_parse_request + ); + } + + $this->assertSame( $did_request_endpoint, $this->get_private_property( $this->instance, 'did_request_endpoint' ) ); + $this->assertSame( $request_uri, $_SERVER['REQUEST_URI'] ); + $this->assertEquals( + trim( strtok( $request_uri, '?' ), '/' ), + $wp->request + ); + if ( $did_request_endpoint ) { + $this->assertTrue( get_query_var( amp_get_slug() ) ); + } else { + $this->assertEquals( '', get_query_var( amp_get_slug() ) ); + } + } + + /** @return array */ + public function get_data_for_test_initialize_paired_request() { + return [ + 'query_var' => [ + Option::PAIRED_URL_STRUCTURE_QUERY_VAR, + false, + ], + 'path_suffix' => [ + Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX, + true, + ], + ]; + } + + /** + * @covers ::initialize_paired_request() + * + * @dataProvider get_data_for_test_initialize_paired_request + * @param string $structure + * @param bool $filtering_unique_post_slug + */ + public function test_initialize_paired_request( $structure, $filtering_unique_post_slug ) { + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, $structure ); + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + $this->instance->initialize_paired_request(); + $this->assertFalse( $this->get_private_property( $this->instance, 'did_request_endpoint' ) ); + $this->assertEquals( 10, has_filter( 'do_parse_request', [ $this->instance, 'extract_endpoint_from_environment_before_parse_request' ] ) ); + $this->assertEquals( 10, has_filter( 'request', [ $this->instance, 'filter_request_after_endpoint_extraction' ] ) ); + $this->assertEquals( 10, has_action( 'parse_request', [ $this->instance, 'restore_path_endpoint_in_environment' ] ) ); + if ( $filtering_unique_post_slug ) { + $this->assertEquals( 10, has_filter( 'wp_unique_post_slug', [ $this->instance, 'filter_unique_post_slug' ] ) ); + } else { + $this->assertFalse( has_filter( 'wp_unique_post_slug', [ $this->instance, 'filter_unique_post_slug' ] ) ); + } + + $this->assertEquals( 10, has_action( 'parse_query', [ $this->instance, 'correct_query_when_is_front_page' ] ) ); + $this->assertEquals( 10, has_action( 'wp', [ $this->instance, 'add_paired_request_hooks' ] ) ); + $this->assertEquals( 10, has_action( 'admin_notices', [ $this->instance, 'add_permalink_settings_notice' ] ) ); + } + + /** @covers ::initialize_paired_request() */ + public function test_initialize_paired_request_in_standard_mode() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_QUERY_VAR ); + $this->instance->initialize_paired_request(); + $this->assertNull( $this->get_private_property( $this->instance, 'did_request_endpoint' ) ); + $this->assertFalse( has_filter( 'do_parse_request', [ $this->instance, 'extract_endpoint_from_environment_before_parse_request' ] ) ); + } + + /** @covers ::detect_endpoint_in_environment() */ + public function test_detect_endpoint_in_environment() { + unset( $_SERVER['REQUEST_URI'] ); + $this->instance->detect_endpoint_in_environment(); + $this->assertFalse( $this->get_private_property( $this->instance, 'did_request_endpoint' ) ); + + $_SERVER['REQUEST_URI'] = $this->instance->remove_endpoint( '/' ); + $this->instance->detect_endpoint_in_environment(); + $this->assertFalse( $this->get_private_property( $this->instance, 'did_request_endpoint' ) ); + + $_SERVER['REQUEST_URI'] = $this->instance->add_endpoint( '/' ); + $this->instance->detect_endpoint_in_environment(); + $this->assertTrue( $this->get_private_property( $this->instance, 'did_request_endpoint' ) ); + } + + /** @return array */ + public function get_data_for_test_filter_unique_post_slug() { + return [ + 'foo' => [ + 'foo', + [], + 'foo', + ], + 'amp' => [ + amp_get_slug(), + [], + amp_get_slug() . '-2', + ], + 'amp3' => [ + amp_get_slug(), + [ + amp_get_slug() . '-2', + amp_get_slug() . '-3', + ], + amp_get_slug() . '-4', + ], + ]; + } + + /** + * @covers ::filter_unique_post_slug() + * @dataProvider get_data_for_test_filter_unique_post_slug + * @param string $post_name + * @param string[] $other_existing_post_names + * @param string $expected_slug + */ + public function test_filter_unique_post_slug( $post_name, $other_existing_post_names, $expected_slug ) { + $post = self::factory()->post->create_and_get( [ 'post_name' => $post_name ] ); + foreach ( $other_existing_post_names as $other_existing_post_name ) { + self::factory()->post->create( [ 'post_name' => $other_existing_post_name ] ); + } + + $actual_slug = $this->instance->filter_unique_post_slug( + $post_name, + $post->ID, + $post->post_status, + $post->post_type + ); + + $this->assertSame( $expected_slug, $actual_slug ); + } + + /** @covers ::add_paired_request_hooks() */ + public function test_add_paired_request_hooks_when_does_have_endpoint() { + $post = self::factory()->post->create(); + $this->go_to( amp_get_permalink( $post ) ); + $this->instance->add_paired_request_hooks(); + $this->assertTrue( $this->instance->has_endpoint() ); + $this->assertEquals( 1000, has_filter( 'old_slug_redirect_url', [ $this->instance, 'maybe_add_paired_endpoint' ] ) ); + $this->assertEquals( 1000, has_filter( 'redirect_canonical', [ $this->instance, 'maybe_add_paired_endpoint' ] ) ); + $this->assertFalse( has_action( 'wp_head', 'amp_add_amphtml_link' ) ); + } + + /** @covers ::add_paired_request_hooks() */ + public function test_add_paired_request_hooks_when_not_has_endpoint() { + $post = self::factory()->post->create(); + $this->go_to( get_permalink( $post ) ); + $this->assertFalse( $this->instance->has_endpoint() ); + $this->instance->add_paired_request_hooks(); + $this->assertEquals( 10, has_action( 'wp_head', 'amp_add_amphtml_link' ) ); + $this->assertFalse( has_filter( 'old_slug_redirect_url', [ $this->instance, 'maybe_add_paired_endpoint' ] ) ); + $this->assertFalse( has_filter( 'redirect_canonical', [ $this->instance, 'maybe_add_paired_endpoint' ] ) ); + } + + /** @covers ::add_permalink_settings_notice() */ + public function test_add_permalink_settings_notice() { + set_current_screen( 'options' ); + $this->assertEmpty( get_echo( [ $this->instance, 'add_permalink_settings_notice' ] ) ); + + set_current_screen( 'options-permalink' ); + $this->assertStringContains( 'notice-info', get_echo( [ $this->instance, 'add_permalink_settings_notice' ] ) ); + } + + /** @covers ::is_using_permalinks() */ + public function test_is_using_permalinks() { + $this->set_permalink_structure( '' ); + $this->assertFalse( $this->instance->is_using_permalinks() ); + + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + $this->assertTrue( $this->instance->is_using_permalinks() ); + } + + /** @return array */ + public function get_data_for_test_filter_default_options() { + return [ + 'default' => [ + [ + Option::VERSION => AMP__VERSION, + Option::THEME_SUPPORT => AMP_Theme_Support::TRANSITIONAL_MODE_SLUG, + Option::READER_THEME => ReaderThemes::DEFAULT_READER_THEME, + ], + Option::PAIRED_URL_STRUCTURE_QUERY_VAR, + ], + 'old_version_transitional' => [ + [ + Option::VERSION => '2.0.0', + Option::THEME_SUPPORT => AMP_Theme_Support::TRANSITIONAL_MODE_SLUG, + Option::READER_THEME => ReaderThemes::DEFAULT_READER_THEME, + ], + Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL, + ], + 'old_version_reader_legacy' => [ + [ + Option::VERSION => '2.0.0', + Option::THEME_SUPPORT => AMP_Theme_Support::READER_MODE_SLUG, + Option::READER_THEME => ReaderThemes::DEFAULT_READER_THEME, + ], + Option::PAIRED_URL_STRUCTURE_LEGACY_READER, + ], + 'old_version_reader_theme' => [ + [ + Option::VERSION => '2.0.0', + Option::THEME_SUPPORT => AMP_Theme_Support::READER_MODE_SLUG, + Option::READER_THEME => 'twentytwenty', + ], + Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL, + ], + ]; + } + + /** + * @covers ::filter_default_options() + * @dataProvider get_data_for_test_filter_default_options + * + * @param array $options + * @param string $expected_structure + */ + public function test_filter_default_options( $options, $expected_structure ) { + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + $this->assertEquals( + $expected_structure, + $this->instance->filter_default_options( + [], + $options + )[ Option::PAIRED_URL_STRUCTURE ] + ); + } + + /** @covers ::sanitize_options() */ + public function test_sanitize_options() { + $this->assertEmpty( + $this->instance->sanitize_options( + [], + [ Option::PAIRED_URL_STRUCTURE => 'bogus' ] + ) + ); + + foreach ( array_keys( PairedRouting::PAIRED_URL_STRUCTURES ) as $paired_url_structure ) { + $this->assertEquals( + [ Option::PAIRED_URL_STRUCTURE => $paired_url_structure ], + $this->instance->sanitize_options( + [], + [ Option::PAIRED_URL_STRUCTURE => $paired_url_structure ] + ) + ); + } + } + + /** @return array */ + public function get_data_for_test_has_endpoint() { + return [ + 'provided_non_amp_url' => [ + null, + false, + static function () { + return home_url( '/' ); + }, + ], + + 'provided_amp_url' => [ + null, + true, + function ( PairedRouting $instance ) { + return $instance->add_endpoint( home_url( '/' ) ); + }, + ], + + 'non_amp_page_requested' => [ + function () { + $this->go_to( home_url( '/' ) ); + }, + false, + '', + ], + + 'yes_amp_page_requested' => [ + function ( PairedRouting $instance ) { + $this->go_to( $instance->add_endpoint( home_url( '/' ) ) ); + }, + true, + '', + ], + + 'did_request_endpoint' => [ + function ( PairedRouting $instance ) { + $this->set_private_property( $instance, 'did_request_endpoint', true ); + }, + true, + '', + ], + + 'has_query_var_set' => [ + function () { + set_query_var( amp_get_slug(), true ); + }, + true, + '', + ], + + 'is_admin_without_query_param' => [ + function () { + set_current_screen( 'index' ); + }, + false, + '', + ], + + 'is_admin_with_query_param' => [ + function () { + set_current_screen( 'index' ); + $_GET[ amp_get_slug() ] = 1; + }, + true, + '', + ], + ]; + } + + /** + * @covers ::has_endpoint() + * @dataProvider get_data_for_test_has_endpoint + * + * @param callable|null $setup_callback + * @param bool $expected_has_endpoint + * @param callable|null $url_callback + */ + public function test_has_endpoint( $setup_callback, $expected_has_endpoint, $url_callback ) { + if ( $setup_callback ) { + $setup_callback( $this->instance ); + } + $url = $url_callback ? $url_callback( $this->instance ) : ''; + $this->assertEquals( $expected_has_endpoint, $this->instance->has_endpoint( $url ) ); + } + + /** + * @covers ::add_endpoint() + * @covers ::remove_endpoint() + */ + public function test_add_has_remove_endpoint() { + $base = home_url( '/' ); + $added = $this->instance->add_endpoint( $base ); + $this->assertNotEquals( $base, $added ); + $removed = $this->instance->remove_endpoint( $added ); + $this->assertEquals( $base, $removed ); + } + + /** @covers ::has_custom_paired_url_structure() */ + public function test_has_custom_paired_url_structure() { + $this->assertFalse( $this->instance->has_custom_paired_url_structure() ); + add_filter( + 'amp_custom_paired_url_structure', + static function () { + return DummyPairedUrlStructure::class; + } + ); + $this->assertTrue( $this->instance->has_custom_paired_url_structure() ); + } + + /** @covers ::get_all_structure_paired_urls() */ + public function test_get_all_structure_paired_urls() { + $urls = $this->instance->get_all_structure_paired_urls( home_url( '/foo/' ) ); + $this->assertEqualSets( + array_keys( PairedRouting::PAIRED_URL_STRUCTURES ), + array_keys( $urls ) + ); + + add_filter( + 'amp_custom_paired_url_structure', + static function () { + return DummyPairedUrlStructure::class; + } + ); + $urls = $this->instance->get_all_structure_paired_urls( home_url( '/bar/' ) ); + $this->assertEqualSets( + array_merge( + array_keys( PairedRouting::PAIRED_URL_STRUCTURES ), + [ 'custom' ] + ), + array_keys( $urls ) + ); + } + + /** @covers ::get_paired_url_examples() */ + public function test_get_paired_url_examples() { + $this->factory()->post->create( [ 'post_type' => 'post' ] ); + $this->factory()->post->create( [ 'post_type' => 'page' ] ); + + add_filter( + 'amp_custom_paired_url_structure', + static function () { + return DummyPairedUrlStructure::class; + } + ); + + $examples = $this->instance->get_paired_url_examples(); + + $this->assertEqualSets( + array_merge( + array_keys( PairedRouting::PAIRED_URL_STRUCTURES ), + [ 'custom' ] + ), + array_keys( $examples ) + ); + + foreach ( $examples as $example_set ) { + $this->assertCount( 2, $example_set ); + } + } + + /** @covers ::get_custom_paired_structure_sources() */ + public function test_get_custom_paired_structure_sources() { + $this->assertEquals( [], $this->instance->get_custom_paired_structure_sources() ); + + add_filter( + 'amp_custom_paired_url_structure', + static function () { + return DummyPairedUrlStructure::class; + } + ); + + $sources = $this->instance->get_custom_paired_structure_sources(); + + $this->assertCount( 1, $sources ); + $this->assertEquals( + [ + 'type' => 'plugin', + 'slug' => 'amp', + 'name' => 'AMP', + ], + current( $sources ) + ); + } + + /** @return array */ + public function get_data_for_test_correct_query_when_is_front_page() { + return [ + 'non_amp_blog_request' => [ + null, + false, + ], + 'amp_front_page' => [ + function ( WP_Query $query ) { + $query->set( amp_get_slug(), true ); + }, + true, + ], + 'amp_front_page_with_other_query' => [ + function ( WP_Query $query ) { + $query->set( amp_get_slug(), true ); + $query->query = [ 'foo' => 'bar' ]; + }, + false, + ], + ]; + } + + /** + * @covers ::correct_query_when_is_front_page() + * @dataProvider get_data_for_test_correct_query_when_is_front_page + * @param callable $setup_callback + * @param bool $expected_is_front_page + */ + public function test_correct_query_when_is_front_page( $setup_callback, $expected_is_front_page ) { + $page_id = self::factory()->post->create( [ 'post_type' => 'page' ] ); + update_option( 'show_on_front', 'page' ); + update_option( 'page_on_front', $page_id ); + + global $wp_the_query, $wp_query; + $wp_query = new WP_Query(); + $wp_the_query = $wp_query; + $this->assertTrue( $wp_query->is_main_query() ); + $wp_query->is_home = true; + + if ( $setup_callback ) { + $setup_callback( $wp_query ); + } + + $this->instance->correct_query_when_is_front_page( $wp_query ); + + if ( $expected_is_front_page ) { + $this->assertFalse( $wp_query->is_home ); + $this->assertTrue( $wp_query->is_page ); + $this->assertTrue( $wp_query->is_singular ); + $this->assertEquals( $page_id, $wp_query->get( 'page_id' ) ); + } else { + $this->assertTrue( $wp_query->is_home ); + $this->assertFalse( $wp_query->is_page ); + $this->assertFalse( $wp_query->is_singular ); + $this->assertNotEquals( $page_id, $wp_query->get( 'page_id' ) ); + } + } + + /** @covers ::maybe_add_paired_endpoint() */ + public function test_maybe_add_paired_endpoint() { + $this->assertSame( '', $this->instance->maybe_add_paired_endpoint( '' ) ); + + $home_url = home_url( '/' ); + $this->assertSame( + $this->instance->add_endpoint( $home_url ), + $this->instance->maybe_add_paired_endpoint( $home_url ) + ); + } + + /** @covers ::redirect_extraneous_paired_endpoint() */ + public function test_redirect_extraneous_paired_endpoint_canonical_404_due_to_suffix() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + $path_suffix_structure = $this->injector->make( PathSuffixUrlStructure::class ); + + $permalink_url = get_permalink( self::factory()->post->create() ); + $amp_endpoint_url = $path_suffix_structure->add_endpoint( $permalink_url ); + $this->go_to( $amp_endpoint_url ); + + $this->assertTrue( amp_is_canonical() ); + $this->assertTrue( is_404() ); + + $redirected_url = null; + add_filter( + 'wp_redirect', + static function ( $url ) use ( &$redirected_url ) { + $redirected_url = $url; + return false; + } + ); + $this->instance->redirect_extraneous_paired_endpoint(); + $this->assertEquals( $permalink_url, $redirected_url ); + } + + /** @covers ::redirect_extraneous_paired_endpoint() */ + public function test_redirect_extraneous_paired_endpoint_canonical_extraneous_query_var() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + $query_var_structure = $this->injector->make( QueryVarUrlStructure::class ); + + $permalink_url = get_permalink( self::factory()->post->create() ); + $amp_endpoint_url = $query_var_structure->add_endpoint( $permalink_url ); + $this->go_to( $amp_endpoint_url ); + + $this->assertTrue( amp_is_canonical() ); + $this->assertTrue( ! is_404() ); + + $redirected_url = null; + add_filter( + 'wp_redirect', + static function ( $url ) use ( &$redirected_url ) { + $redirected_url = $url; + return false; + } + ); + $this->instance->redirect_extraneous_paired_endpoint(); + $this->assertEquals( $permalink_url, $redirected_url ); + } + + /** @covers ::redirect_extraneous_paired_endpoint() */ + public function test_redirect_extraneous_paired_endpoint_path_suffix_404() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_QUERY_VAR ); + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + $path_suffix_structure = $this->injector->make( PathSuffixUrlStructure::class ); + $paired_url = $this->injector->make( PairedUrl::class ); + + $permalink_url = get_permalink( self::factory()->post->create() ); + $amp_endpoint_url = $path_suffix_structure->add_endpoint( $permalink_url ); + $this->go_to( $amp_endpoint_url ); + + $this->assertFalse( amp_is_canonical() ); + $this->assertTrue( is_404() ); + + $redirected_url = null; + add_filter( + 'wp_redirect', + static function ( $url ) use ( &$redirected_url ) { + $redirected_url = $url; + return false; + } + ); + $this->instance->redirect_extraneous_paired_endpoint(); + $this->assertEquals( + $paired_url->add_query_var( $path_suffix_structure->remove_endpoint( $amp_endpoint_url ) ), + $redirected_url + ); + } + + /** @covers ::redirect_extraneous_paired_endpoint() */ + public function test_redirect_extraneous_paired_endpoint_slug_redirect() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_QUERY_VAR ); + AMP_Options_Manager::update_option( Option::ALL_TEMPLATES_SUPPORTED, false ); + AMP_Options_Manager::update_option( Option::SUPPORTED_TEMPLATES, [ 'is_singular' ] ); + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + + $post_id = self::factory()->post->create( [ 'post_name' => 'first' ] ); + $first_permalink_url = get_permalink( $post_id ); + + wp_update_post( + [ + 'ID' => $post_id, + 'post_name' => 'second', + ] + ); + $second_permalink_url = get_permalink( $post_id ); + + $this->assertNotEquals( $first_permalink_url, $second_permalink_url ); + + $this->go_to( $this->instance->add_endpoint( $first_permalink_url ) ); + + $this->assertTrue( is_404() ); + + $redirected_url = null; + try { + // Throwing an exception si needed because wp_old_slug_redirect() does exit after wp_redirect(). + add_filter( + 'wp_redirect', + static function ( $url ) { + throw new Exception( $url ); + } + ); + $this->instance->redirect_extraneous_paired_endpoint(); + } catch ( Exception $exception ) { + $redirected_url = $exception->getMessage(); + } + + $this->assertEquals( + $this->instance->add_endpoint( $second_permalink_url ), + $redirected_url + ); + } + + /** @covers ::redirect_extraneous_paired_endpoint() */ + public function test_redirect_extraneous_paired_endpoint_unavailable_template() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_QUERY_VAR ); + AMP_Options_Manager::update_option( Option::ALL_TEMPLATES_SUPPORTED, false ); + AMP_Options_Manager::update_option( Option::SUPPORTED_TEMPLATES, [ 'is_singular' ] ); + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); + + $post_id = self::factory()->post->create(); + $date_archive_url = trailingslashit( dirname( get_permalink( $post_id ) ) ); + + $amp_endpoint_url = $this->instance->add_endpoint( $date_archive_url ); + $this->go_to( $amp_endpoint_url ); + + $this->assertFalse( amp_is_canonical() ); + $this->assertTrue( is_date() ); + $this->assertFalse( amp_is_available() ); + + $redirected_url = null; + add_filter( + 'wp_redirect', + static function ( $url ) use ( &$redirected_url ) { + $redirected_url = $url; + return false; + } + ); + $this->instance->redirect_extraneous_paired_endpoint(); + $this->assertEquals( + $date_archive_url, + $redirected_url + ); + } +} diff --git a/tests/php/src/PairedUrlStructure/LegacyReaderUrlStructureTest.php b/tests/php/src/PairedUrlStructure/LegacyReaderUrlStructureTest.php new file mode 100644 index 00000000000..9e3ddc78896 --- /dev/null +++ b/tests/php/src/PairedUrlStructure/LegacyReaderUrlStructureTest.php @@ -0,0 +1,189 @@ +set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); // Needed for user_trailingslashit(). + $this->instance = $this->injector->make( LegacyReaderUrlStructure::class ); + } + + /** @covers ::add_endpoint() */ + public function test_add_endpoint_non_post_and_post() { + $slug = amp_get_slug(); + + $post_id = self::factory()->post->create(); + + $amp_pre_get_permalink_count = 0; + add_filter( + 'amp_pre_get_permalink', + static function ( $pre ) use ( &$amp_pre_get_permalink_count ) { + $amp_pre_get_permalink_count++; + return $pre; + } + ); + + $amp_get_permalink_count = 0; + add_filter( + 'amp_get_permalink', + static function ( $pre ) use ( &$amp_get_permalink_count ) { + $amp_get_permalink_count++; + return $pre; + } + ); + + // Non-post URL. + $year_archive_url = home_url( '/2020/' ); + $amp_year_archive_url = $this->instance->add_endpoint( $year_archive_url ); + $this->assertEquals( 0, $amp_pre_get_permalink_count ); + $this->assertEquals( 0, $amp_get_permalink_count ); + $this->assertEquals( home_url( "/2020/?$slug" ), $amp_year_archive_url ); + + // Post URL without filtering. + $post_permalink_url = get_permalink( $post_id ); + $this->assertEquals( + trailingslashit( trailingslashit( $post_permalink_url ) . $slug ), + $this->instance->add_endpoint( $post_permalink_url ) + ); + $this->assertEquals( 1, $amp_pre_get_permalink_count ); + $this->assertEquals( 1, $amp_get_permalink_count ); + + // Try overriding a post URL with pre-filter. + $pre_post_permalink_url = $post_permalink_url . 'pre-filter-amp/'; + $filter_amp_pre_get_permalink = function ( $pre, $filtered_post_id ) use ( $post_id, $pre_post_permalink_url ) { + $this->assertEquals( null, $pre ); + $this->assertEquals( $post_id, $filtered_post_id ); + return $pre_post_permalink_url; + }; + add_filter( + 'amp_pre_get_permalink', + $filter_amp_pre_get_permalink, + 10, + 2 + ); + $this->assertEquals( + $pre_post_permalink_url, + $this->instance->add_endpoint( $post_permalink_url ) + ); + $this->assertEquals( 2, $amp_pre_get_permalink_count ); + $this->assertEquals( 1, $amp_get_permalink_count ); + remove_filter( 'amp_pre_get_permalink', $filter_amp_pre_get_permalink, 10 ); + + // Try overriding a post URL with post-filter. + $expected_original_amp_url = trailingslashit( trailingslashit( $post_permalink_url ) . $slug ); + $return_post_permalink_url = $post_permalink_url . 'post-filter-amp/'; + $filter_amp_get_permalink = function ( $amp_url, $filtered_post_id ) use ( $post_id, $return_post_permalink_url, $expected_original_amp_url ) { + $this->assertEquals( $expected_original_amp_url, $amp_url ); + $this->assertEquals( $post_id, $filtered_post_id ); + return $return_post_permalink_url; + }; + add_filter( + 'amp_get_permalink', + $filter_amp_get_permalink, + 10, + 2 + ); + $this->assertEquals( + $return_post_permalink_url, + $this->instance->add_endpoint( $post_permalink_url ) + ); + $this->assertEquals( 3, $amp_pre_get_permalink_count ); + $this->assertEquals( 2, $amp_get_permalink_count ); + } + + /** @covers ::add_endpoint() */ + public function test_add_endpoint_for_page_and_attachment() { + $slug = amp_get_slug(); + + $page_id = self::factory()->post->create( [ 'post_type' => 'page' ] ); + $this->assertEquals( + get_permalink( $page_id ) . "?{$slug}", + $this->instance->add_endpoint( get_permalink( $page_id ) ) + ); + + $attachment_id = self::factory()->post->create( [ 'post_type' => 'attachment' ] ); + $this->assertEquals( + get_permalink( $attachment_id ) . "?{$slug}", + $this->instance->add_endpoint( get_permalink( $attachment_id ) ) + ); + } + + /** @covers ::add_endpoint() */ + public function test_add_endpoint_when_permalink_has_query_parameter() { + $slug = amp_get_slug(); + $post_id = self::factory()->post->create(); + $permalink = get_permalink( $post_id ); + + add_filter( + 'post_link', + static function ( $url ) { + return add_query_arg( 'foo', 'bar', $url ); + } + ); + + $this->assertEquals( + "$permalink?foo=bar&{$slug}", + $this->instance->add_endpoint( get_permalink( $post_id ) ) + ); + } + + /** @covers ::has_endpoint() */ + public function test_has_endpoint() { + $slug = amp_get_slug(); + $this->assertFalse( $this->instance->has_endpoint( home_url( '/foo/' ) ) ); + $this->assertTrue( $this->instance->has_endpoint( home_url( "/foo/?{$slug}" ) ) ); + $this->assertTrue( $this->instance->has_endpoint( home_url( "/foo/$slug/" ) ) ); + } + + /** @covers ::remove_endpoint() */ + public function test_remove_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( '/foo/' ), + $this->instance->remove_endpoint( home_url( "/foo/?{$slug}" ) ) + ); + $this->assertEquals( + home_url( '/foo/' ), + $this->instance->remove_endpoint( home_url( "/foo/$slug/" ) ) + ); + } + + /** @covers ::url_to_postid() */ + public function test_url_to_postid() { + $this->assertEquals( + 0, + $this->call_private_method( $this->instance, 'url_to_postid', [ 'https://external.example.com/' ] ) + ); + + $url_to_postid_filter_count = 0; + add_filter( + 'url_to_postid', + static function ( $url ) use ( &$url_to_postid_filter_count ) { + $url_to_postid_filter_count++; + return $url; + } + ); + + $post_id = self::factory()->post->create(); + + for ( $i = 0; $i < 5; $i++ ) { + $this->assertEquals( + $post_id, + $this->call_private_method( $this->instance, 'url_to_postid', [ get_permalink( $post_id ) ] ) + ); + } + $this->assertEquals( 1, $url_to_postid_filter_count ); + } +} diff --git a/tests/php/src/PairedUrlStructure/LegacyTransitionalUrlStructureTest.php b/tests/php/src/PairedUrlStructure/LegacyTransitionalUrlStructureTest.php new file mode 100644 index 00000000000..a829298ec41 --- /dev/null +++ b/tests/php/src/PairedUrlStructure/LegacyTransitionalUrlStructureTest.php @@ -0,0 +1,43 @@ +instance = $this->injector->make( LegacyTransitionalUrlStructure::class ); + } + + /** @covers ::add_endpoint() */ + public function test_add_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( "/foo/?{$slug}" ), + $this->instance->add_endpoint( home_url( '/foo/' ) ) + ); + } + + /** @covers ::has_endpoint() */ + public function test_has_endpoint() { + $slug = amp_get_slug(); + $this->assertFalse( $this->instance->has_endpoint( home_url( '/foo/' ) ) ); + $this->assertTrue( $this->instance->has_endpoint( home_url( "/foo/?{$slug}" ) ) ); + } + + /** @covers ::remove_endpoint() */ + public function test_remove_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( '/foo/' ), + $this->instance->remove_endpoint( home_url( "/foo/?{$slug}" ) ) + ); + } +} diff --git a/tests/php/src/PairedUrlStructure/PathSuffixUrlStructureTest.php b/tests/php/src/PairedUrlStructure/PathSuffixUrlStructureTest.php new file mode 100644 index 00000000000..fb6e6c2e4a1 --- /dev/null +++ b/tests/php/src/PairedUrlStructure/PathSuffixUrlStructureTest.php @@ -0,0 +1,44 @@ +set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); // Needed for user_trailingslashit(). + $this->instance = $this->injector->make( PathSuffixUrlStructure::class ); + } + + /** @covers ::add_endpoint() */ + public function test_add_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( "/foo/$slug/" ), + $this->instance->add_endpoint( home_url( '/foo/' ) ) + ); + } + + /** @covers ::has_endpoint() */ + public function test_has_endpoint() { + $slug = amp_get_slug(); + $this->assertFalse( $this->instance->has_endpoint( home_url( '/foo/' ) ) ); + $this->assertTrue( $this->instance->has_endpoint( home_url( "/foo/$slug/" ) ) ); + } + + /** @covers ::remove_endpoint() */ + public function test_remove_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( '/foo/' ), + $this->instance->remove_endpoint( home_url( "/foo/$slug/" ) ) + ); + } +} diff --git a/tests/php/src/PairedUrlStructure/QueryVarUrlStructureTest.php b/tests/php/src/PairedUrlStructure/QueryVarUrlStructureTest.php new file mode 100644 index 00000000000..38c908cec39 --- /dev/null +++ b/tests/php/src/PairedUrlStructure/QueryVarUrlStructureTest.php @@ -0,0 +1,43 @@ +instance = $this->injector->make( QueryVarUrlStructure::class ); + } + + /** @covers ::add_endpoint() */ + public function test_add_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( "/foo/?{$slug}=1" ), + $this->instance->add_endpoint( home_url( '/foo/' ) ) + ); + } + + /** @covers ::has_endpoint() */ + public function test_has_endpoint() { + $slug = amp_get_slug(); + $this->assertFalse( $this->instance->has_endpoint( home_url( '/foo/' ) ) ); + $this->assertTrue( $this->instance->has_endpoint( home_url( "/foo/?{$slug}=1" ) ) ); + } + + /** @covers ::remove_endpoint() */ + public function test_remove_endpoint() { + $slug = amp_get_slug(); + $this->assertEquals( + home_url( '/foo/' ), + $this->instance->remove_endpoint( home_url( "/foo/?{$slug}=1" ) ) + ); + } +} diff --git a/tests/php/src/PairedUrlStructureTest.php b/tests/php/src/PairedUrlStructureTest.php new file mode 100644 index 00000000000..8c8685a1a3d --- /dev/null +++ b/tests/php/src/PairedUrlStructureTest.php @@ -0,0 +1,27 @@ +instance = $this->injector->make( DummyPairedUrlStructure::class ); + } + + /** @covers ::has_endpoint() */ + public function test_has_endpoint() { + $removed = home_url( '/' ); + $added = $this->instance->add_endpoint( $removed ); + + $this->assertFalse( $this->instance->has_endpoint( $removed ) ); + $this->assertTrue( $this->instance->has_endpoint( $added ) ); + } +} diff --git a/tests/php/src/PairedUrlTest.php b/tests/php/src/PairedUrlTest.php new file mode 100644 index 00000000000..a402b301147 --- /dev/null +++ b/tests/php/src/PairedUrlTest.php @@ -0,0 +1,93 @@ +instance = $this->injector->make( PairedUrl::class ); + } + + /** @covers ::__construct() */ + public function test__construct() { + $this->assertInstanceOf( PairedUrl::class, $this->instance ); + $this->assertInstanceOf( Service::class, $this->instance ); + } + + /** @covers ::remove_query_var() */ + public function test_remove_query_var() { + $slug = amp_get_slug(); + + $this->assertEquals( + '/foo/?bar=1', + $this->instance->remove_query_var( "/foo/?bar=1&{$slug}=1" ) + ); + + $this->assertEquals( + '/foo/', + $this->instance->remove_query_var( "/foo/?{$slug}=1" ) + ); + + $this->assertEquals( + '/foo/', + $this->instance->remove_query_var( "/foo/?{$slug}" ) + ); + } + + /** @covers ::has_path_suffix() */ + public function test_has_path_suffix() { + $slug = amp_get_slug(); + $this->assertFalse( $this->instance->has_path_suffix( '/foo/' ) ); + $this->assertTrue( $this->instance->has_path_suffix( "/foo/$slug/" ) ); + $this->assertTrue( $this->instance->has_path_suffix( "/foo/$slug/?bar=1" ) ); + $this->assertTrue( $this->instance->has_path_suffix( "/foo/$slug/#bar" ) ); + $this->assertTrue( $this->instance->has_path_suffix( "/foo/$slug" ) ); + $this->assertTrue( $this->instance->has_path_suffix( "/foo/$slug?bar=1" ) ); + $this->assertTrue( $this->instance->has_path_suffix( "/foo/$slug#bar" ) ); + } + + /** @covers ::remove_path_suffix() */ + public function test_remove_path_suffix() { + $slug = amp_get_slug(); + $this->assertEquals( '/foo/', $this->instance->remove_path_suffix( '/foo/' ) ); + $this->assertEquals( '/foo/', $this->instance->remove_path_suffix( "/foo/$slug/" ) ); + $this->assertEquals( '/foo', $this->instance->remove_path_suffix( "/foo/$slug" ) ); + $this->assertEquals( '/foo/#bar', $this->instance->remove_path_suffix( "/foo/$slug/#bar" ) ); + $this->assertEquals( '/foo/?bar=1', $this->instance->remove_path_suffix( "/foo/$slug/?bar=1" ) ); + } + + /** @covers ::has_query_var() */ + public function test_has_query_var() { + $slug = amp_get_slug(); + $this->assertTrue( $this->instance->has_query_var( home_url( "/foo/?$slug=1" ) ) ); + $this->assertTrue( $this->instance->has_query_var( "/foo/?bar=1&$slug=1" ) ); + $this->assertFalse( $this->instance->has_query_var( '/foo/?bar=1' ) ); + $this->assertFalse( $this->instance->has_query_var( '/foo/' ) ); + $this->assertFalse( $this->instance->has_query_var( "/foo/#$slug=1" ) ); + } + + /** @covers ::add_query_var() */ + public function test_add_query_var() { + $slug = amp_get_slug(); + $this->assertEquals( "/foo/?$slug=1", $this->instance->add_query_var( '/foo/' ) ); + $this->assertEquals( "/foo/?bar=1&$slug=1", $this->instance->add_query_var( '/foo/?bar=1' ) ); + $this->assertEquals( "/foo/?$slug=1#bar", $this->instance->add_query_var( '/foo/#bar' ) ); + } + + /** @covers ::add_path_suffix() */ + public function test_add_path_suffix() { + $this->set_permalink_structure( '/%year%/%monthnum%/%day%/%postname%/' ); // Needed for user_trailingslashit(). + $slug = amp_get_slug(); + $this->assertEquals( home_url( "/foo/$slug/" ), $this->instance->add_path_suffix( home_url( '/foo/' ) ) ); + $this->assertEquals( home_url( "/foo/$slug/?bar=1" ), $this->instance->add_path_suffix( home_url( '/foo/?bar=1' ) ) ); + $this->assertEquals( home_url( "/foo/$slug/#bar" ), $this->instance->add_path_suffix( home_url( '/foo/#bar' ) ) ); + } +} diff --git a/tests/php/src/PluginSuppressionTest.php b/tests/php/src/PluginSuppressionTest.php index d83f9388d9d..086c92ef547 100644 --- a/tests/php/src/PluginSuppressionTest.php +++ b/tests/php/src/PluginSuppressionTest.php @@ -204,23 +204,34 @@ public function test_register_standard_mode() { $this->assertFalse( $this->instance->is_reader_theme_request() ); $this->instance->register(); + + $this->assertEquals( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); + $this->assertSame( + 8, + has_action( 'plugins_loaded', [ $this->instance, 'initialize' ] ) + ); + } + + /** @covers ::initialize() */ + public function test_initialize() { + $this->instance->initialize(); $this->assertEquals( defined( 'PHP_INT_MIN' ) ? PHP_INT_MIN : ~PHP_INT_MAX, // phpcs:ignore PHPCompatibility.Constants.NewConstants.php_int_minFound has_action( 'wp', [ $this->instance, 'maybe_suppress_plugins' ] ) ); - $this->assertEquals( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); } /** @covers ::register() */ public function test_register_reader_theme_mode() { AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::READER_MODE_SLUG ); AMP_Options_Manager::update_option( Option::READER_THEME, 'twentynineteen' ); - $_GET[ amp_get_slug() ] = 1; + $this->go_to( amp_get_permalink( self::factory()->post->create() ) ); $this->assertTrue( $this->instance->is_reader_theme_request() ); $this->init_plugins(); $this->update_suppressed_plugins_option( array_fill_keys( $this->get_bad_plugin_file_slugs(), true ) ); $this->instance->register(); + $this->instance->initialize(); $this->assertFalse( has_action( 'plugins_loaded', [ $this->instance, 'suppress_plugins' ] ), 'Expected suppression to happen immediately.' ); $this->assertEquals( '', do_shortcode( '[bad]' ), 'Expected suppression to happen immediately.' ); $this->assertEquals( 10, has_filter( 'amp_default_options', [ $this->instance, 'filter_default_options' ] ) ); @@ -228,20 +239,22 @@ public function test_register_reader_theme_mode() { /** @covers ::is_reader_theme_request() */ public function test_is_reader_theme_request() { + $post_id = self::factory()->post->create(); + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); $this->assertFalse( $this->instance->is_reader_theme_request() ); AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::READER_MODE_SLUG ); AMP_Options_Manager::update_option( Option::READER_THEME, ReaderThemes::DEFAULT_READER_THEME ); $this->assertFalse( $this->instance->is_reader_theme_request() ); - $_GET[ amp_get_slug() ] = 1; + $this->go_to( amp_get_permalink( $post_id ) ); $this->assertFalse( $this->instance->is_reader_theme_request() ); AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::READER_MODE_SLUG ); AMP_Options_Manager::update_option( Option::READER_THEME, 'twentynineteen' ); - unset( $_GET[ amp_get_slug() ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $this->go_to( get_permalink( $post_id ) ); $this->assertFalse( $this->instance->is_reader_theme_request() ); - $_GET[ amp_get_slug() ] = 1; + $this->go_to( amp_get_permalink( $post_id ) ); $this->assertTrue( $this->instance->is_reader_theme_request() ); } @@ -278,11 +291,12 @@ public function test_maybe_suppress_plugins_not_amp_endpoint() { public function test_maybe_suppress_plugins_yes_amp_endpoint() { $url = home_url( '/' ); AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); + $this->instance->register(); + $this->instance->initialize(); $this->init_plugins(); $bad_plugin_file_slugs = $this->get_bad_plugin_file_slugs(); $this->populate_validation_errors( $url, $bad_plugin_file_slugs ); $this->update_suppressed_plugins_option( array_fill_keys( $bad_plugin_file_slugs, true ) ); - $this->instance->register(); $this->go_to( $url ); $this->assertTrue( amp_is_request() ); @@ -299,6 +313,7 @@ public function test_suppress_plugins_none_suppressible() { $bad_plugin_file_slugs = $this->get_bad_plugin_file_slugs(); $this->populate_validation_errors( $url, $bad_plugin_file_slugs ); $this->instance->register(); + $this->instance->initialize(); $this->go_to( $url ); $this->assertTrue( amp_is_request() ); diff --git a/tests/php/src/ReaderThemeLoaderTest.php b/tests/php/src/ReaderThemeLoaderTest.php index fb039d96dc8..a166df011c2 100644 --- a/tests/php/src/ReaderThemeLoaderTest.php +++ b/tests/php/src/ReaderThemeLoaderTest.php @@ -57,7 +57,7 @@ public function test_is_enabled() { $this->assertTrue( $this->instance->is_enabled() ); $this->assertNotEquals( get_template(), $reader_theme_slug ); - $_GET[ amp_get_slug() ] = true; + set_query_var( amp_get_slug(), 1 ); $this->instance->override_theme(); $this->assertEquals( get_template(), $reader_theme_slug ); $this->assertTrue( $this->instance->is_enabled() ); @@ -181,7 +181,7 @@ public function test_override_theme() { $this->assertEquals( 'Twenty Twenty', get_option( 'current_theme' ) ); $this->assertFalse( has_filter( 'sidebars_widgets' ) ); AMP_Options_Manager::update_option( Option::READER_THEME, $reader_theme_slug ); - $_GET[ amp_get_slug() ] = 1; + set_query_var( amp_get_slug(), 1 ); $this->instance->override_theme(); $this->assertTrue( $this->instance->is_theme_overridden() ); $active_theme = $this->instance->get_active_theme(); diff --git a/tests/php/static-analysis-stubs/wordpress-defines.php b/tests/php/static-analysis-stubs/wordpress-defines.php index 1b1180bcece..ee98688c490 100644 --- a/tests/php/static-analysis-stubs/wordpress-defines.php +++ b/tests/php/static-analysis-stubs/wordpress-defines.php @@ -15,6 +15,7 @@ define( 'WPINC', 'some_string' ); define( 'EMPTY_TRASH_DAYS', 30 * 86400 ); define( 'EP_PERMALINK', 1 ); +define( 'EP_ALL', 8191 ); define( 'COOKIE_DOMAIN', false ); // Constants for expressing human-readable intervals. diff --git a/tests/php/test-amp-helper-functions.php b/tests/php/test-amp-helper-functions.php index dd2e32c3b66..f41d0be43ab 100644 --- a/tests/php/test-amp-helper-functions.php +++ b/tests/php/test-amp-helper-functions.php @@ -10,11 +10,12 @@ use AmpProject\AmpWP\Tests\Helpers\AssertContainsCompatibility; use AmpProject\AmpWP\Tests\Helpers\HandleValidation; use AmpProject\AmpWP\Tests\Helpers\LoadsCoreThemes; +use AmpProject\AmpWP\Tests\DependencyInjectedTestCase; /** * Class Test_AMP_Helper_Functions */ -class Test_AMP_Helper_Functions extends WP_UnitTestCase { +class Test_AMP_Helper_Functions extends DependencyInjectedTestCase { use AssertContainsCompatibility; use HandleValidation; @@ -438,9 +439,10 @@ public function test_amp_get_current_url( $assert ) { * * @covers ::amp_get_permalink() */ - public function test_amp_get_permalink_without_pretty_permalinks() { + public function test_amp_get_permalink_without_pretty_permalinks_for_legacy_reader_structure() { delete_option( 'permalink_structure' ); flush_rewrite_rules(); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_LEGACY_READER ); $drafted_post = self::factory()->post->create( [ @@ -464,9 +466,9 @@ public function test_amp_get_permalink_without_pretty_permalinks() { ] ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $published_post ) ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $drafted_post ) ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $published_page ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $published_post ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $drafted_post ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $published_page ) ); add_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); add_filter( 'amp_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); @@ -480,6 +482,7 @@ public function test_amp_get_permalink_without_pretty_permalinks() { remove_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ] ); remove_filter( 'amp_get_permalink', [ $this, 'return_example_url' ] ); + // Test that amp_get_permalink() is alias for get_permalink() when in Standard mode. AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); $this->assertEquals( get_permalink( $published_post ), amp_get_permalink( $published_post ) ); @@ -495,12 +498,13 @@ public function test_amp_get_permalink_without_pretty_permalinks() { foreach ( $argses as $args ) { delete_option( AMP_Options_Manager::OPTION_NAME ); // To specify the defaults. add_theme_support( AMP_Theme_Support::SLUG, $args ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_LEGACY_READER ); remove_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ] ); remove_filter( 'amp_get_permalink', [ $this, 'return_example_url' ] ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $published_post ) ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $drafted_post ) ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $published_page ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $published_post ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $drafted_post ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $published_page ) ); add_filter( 'amp_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); $this->assertStringEndsWith( 'current_filter=amp_get_permalink', amp_get_permalink( $published_post ) ); add_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); @@ -513,13 +517,15 @@ public function test_amp_get_permalink_without_pretty_permalinks() { * * @covers ::amp_get_permalink() */ - public function test_amp_get_permalink_with_pretty_permalinks() { + public function test_amp_get_permalink_with_pretty_permalinks_and_legacy_reader_permalink_structure() { global $wp_rewrite; update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' ); + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_LEGACY_READER ); $wp_rewrite->use_trailing_slashes = true; $wp_rewrite->init(); $wp_rewrite->flush_rules(); + // @todo This should also add a query param to see how it behaves. $add_anchor_fragment = static function( $url ) { return $url . '#anchor'; }; @@ -543,12 +549,12 @@ public function test_amp_get_permalink_with_pretty_permalinks() { 'post_type' => 'page', ] ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $drafted_post ) ); - $this->assertStringEndsWith( '?amp=1', amp_get_permalink( $published_post ) ); - $this->assertStringEndsWith( '?amp=1', amp_get_permalink( $published_page ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $drafted_post ) ); + $this->assertStringEndsWith( '/amp/', amp_get_permalink( $published_post ) ); + $this->assertStringEndsWith( '?amp', amp_get_permalink( $published_page ) ); add_filter( 'post_link', $add_anchor_fragment ); - $this->assertStringEndsWith( '?amp=1#anchor', amp_get_permalink( $published_post ) ); + $this->assertStringEndsWith( '/amp/#anchor', amp_get_permalink( $published_post ) ); remove_filter( 'post_link', $add_anchor_fragment ); add_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); @@ -564,9 +570,9 @@ public function test_amp_get_permalink_with_pretty_permalinks() { // Now check with theme support added (in transitional mode). add_theme_support( AMP_Theme_Support::SLUG, [ 'template_dir' => './' ] ); - $this->assertStringEndsWith( '&=1', amp_get_permalink( $drafted_post ) ); - $this->assertStringEndsWith( '?amp=1', amp_get_permalink( $published_post ) ); - $this->assertStringEndsWith( '?amp=1', amp_get_permalink( $published_page ) ); + $this->assertStringEndsWith( '&', amp_get_permalink( $drafted_post ) ); + $this->assertStringEndsWith( '/amp/', amp_get_permalink( $published_post ) ); + $this->assertStringEndsWith( '?amp', amp_get_permalink( $published_page ) ); add_filter( 'amp_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); $this->assertStringEndsWith( 'current_filter=amp_get_permalink', amp_get_permalink( $published_post ) ); add_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ], 10, 2 ); @@ -576,7 +582,7 @@ public function test_amp_get_permalink_with_pretty_permalinks() { remove_filter( 'amp_pre_get_permalink', [ $this, 'return_example_url' ] ); remove_filter( 'amp_get_permalink', [ $this, 'return_example_url' ] ); add_filter( 'post_link', $add_anchor_fragment ); - $this->assertStringEndsWith( '/?amp=1#anchor', amp_get_permalink( $published_post ) ); + $this->assertStringEndsWith( '/amp/#anchor', amp_get_permalink( $published_post ) ); } /** @@ -610,25 +616,13 @@ public function test_amp_get_permalink_with_theme_support() { * @covers ::amp_remove_paired_endpoint() */ public function test_amp_remove_paired_endpoint() { + AMP_Options_Manager::update_option( Option::PAIRED_URL_STRUCTURE, Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX ); $this->assertEquals( 'https://example.com/foo/', amp_remove_paired_endpoint( 'https://example.com/foo/?amp' ) ); $this->assertEquals( 'https://example.com/foo/', amp_remove_paired_endpoint( 'https://example.com/foo/?amp=1' ) ); $this->assertEquals( 'https://example.com/foo/', amp_remove_paired_endpoint( 'https://example.com/foo/amp/?amp=1' ) ); $this->assertEquals( 'https://example.com/foo/?#bar', amp_remove_paired_endpoint( 'https://example.com/foo/?amp#bar' ) ); $this->assertEquals( 'https://example.com/foo/', amp_remove_paired_endpoint( 'https://example.com/foo/amp/' ) ); $this->assertEquals( 'https://example.com/foo/?blaz', amp_remove_paired_endpoint( 'https://example.com/foo/amp/?blaz' ) ); - $this->assertEquals( 'https://example.com/foo/?blaz', amp_remove_paired_endpoint( 'https://example.com/foo/amp/amp/?blaz' ) ); - $this->assertEquals( 'https://example.com/foo/?blaz', amp_remove_paired_endpoint( 'https://example.com/foo/amp/foo/amp/bar/?blaz' ) ); - } - - /** - * Test that hook is added. - * - * @covers ::amp_add_frontend_actions() - */ - public function test_amp_add_frontend_actions() { - $this->assertFalse( has_action( 'wp_head', 'amp_add_amphtml_link' ) ); - amp_add_frontend_actions(); - $this->assertEquals( 10, has_action( 'wp_head', 'amp_add_amphtml_link' ) ); } /** @@ -809,12 +803,13 @@ public function test_amp_add_amphtml_link_transitional_mode( $data_provider ) { $assert_amphtml_link_present = function() use ( $amphtml_url, $get_amp_html_link, $available ) { if ( $available ) { - $this->assertTrue( AMP_Theme_Support::is_paired_available() ); + $this->assertTrue( amp_is_available() ); $this->assertEquals( sprintf( '', esc_url( $amphtml_url ) ), $get_amp_html_link() ); } else { + $this->assertFalse( amp_is_available() ); $this->assertStringStartsWith( '