Skip to content

Instantly share code, notes, and snippets.

@westonruter
Last active June 2, 2020 06:13
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save westonruter/f9ee9ea717d52471bae092879e3d52b0 to your computer and use it in GitHub Desktop.
Save westonruter/f9ee9ea717d52471bae092879e3d52b0 to your computer and use it in GitHub Desktop.
<?php
/**
* AMP-To-AMP plugin initialization file.
*
* @package AMP_To_AMP
* @author Weston Ruter, Google
* @link https://gist.github.com/westonruter/f9ee9ea717d52471bae092879e3d52b0
* @license GPL-2.0-or-later
* @copyright 2019 Google Inc.
*
* @wordpress-plugin
* Plugin Name: AMP-to-AMP Linking
* Plugin URI: https://gist.github.com/westonruter/f9ee9ea717d52471bae092879e3d52b0
* Description: Make all inter-site URLs on AMP pages link to other AMP pages. In paired/classic modes, the ?amp query var is added to all links (in classic, only in the content). Additionally, initial support for <a href="https://github.com/ampproject/amphtml/issues/12496">AMP-to-AMP (A2A) linking</a> is implemented: in all modes, the <code>amphtml</code> relation is added to all frontend links (though just for the content in classic mode), and native/paired mode includes the A2A meta tag is added. Nevertheless, do note that A2A is not known to be implemented yet in any AMP viewer.
* Version: 0.2.5
* Author: Weston Ruter, Google
* Author URI: https://weston.ruter.net/
* License: GNU General Public License v2 (or later)
* License URI: http://www.gnu.org/licenses/gpl-2.0.html
*/
namespace AMP_To_AMP;
/**
* Filter content sanitizers to include the A2A sanitizer.
*
* @param array $sanitizers Sanitizers.
* @return array Sanitizers.
*/
function filter_content_sanitizers( $sanitizers ) {
if ( ! class_exists( 'AMP_Link_Sanitizer' ) ) { // Support AMP v1.4
require_once __DIR__ . '/class-sanitizer.php';
$sanitizers[ __NAMESPACE__ . '\Sanitizer' ] = array(
'add_query_vars' => ! amp_is_canonical(),
'has_theme_support' => current_theme_supports( 'amp' ),
);
}
return $sanitizers;
}
add_filter( 'amp_content_sanitizers', __NAMESPACE__ . '\filter_content_sanitizers' );
/**
* Paired Browsing.
*/
require_once __DIR__ . '/paired-browsing.php';
<?php
/**
* Class Sanitizer
*
* @package AMP_To_AMP
* @link https://gist.github.com/westonruter/f9ee9ea717d52471bae092879e3d52b0
* @license GPL-2.0-or-later
* @copyright 2019 Google Inc.
*/
namespace AMP_To_AMP;
/**
* Class Sanitizer
*/
class Sanitizer extends \AMP_Base_Sanitizer {
/**
* Default A2A meta tag content.
*
* @var string
*/
const DEFAULT_A2A_META_CONTENT = 'AMP-Redirect-To; AMP.navigateTo';
/**
* Placeholder for default args, to be set in child classes.
*
* @var array
*/
protected $DEFAULT_ARGS = array( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
'add_amphtml_rel' => true,
'add_query_vars' => true, // Set to false when in native mode. Overridden in \AMP_To_AMP\filter_content_sanitizers().
'has_theme_support' => false, // Set to true when theme has 'amp' support. Overridden in \AMP_To_AMP\filter_content_sanitizers().
'add_a2a_meta' => self::DEFAULT_A2A_META_CONTENT, // Only relevant when theme support is present.
);
/**
* Home host.
*
* @var string
*/
protected $home_host;
/**
* Content path.
*
* @var string
*/
protected $content_path;
/**
* Admin path.
*
* @var string
*/
protected $admin_path;
/**
* Sanitizer constructor.
*
* @param \DOMDocument $dom Document.
* @param array $args Args.
*/
public function __construct( \DOMDocument $dom, array $args = array() ) {
parent::__construct( $dom, $args );
$this->home_host = wp_parse_url( home_url(), PHP_URL_HOST );
$this->content_path = wp_parse_url( content_url( '/' ), PHP_URL_PATH );
$this->admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
}
/**
* Sanitize.
*/
public function sanitize() {
if ( $this->args['has_theme_support'] && $this->args['add_a2a_meta'] ) {
$this->add_a2a_meta( $this->args['add_a2a_meta'] );
}
$this->process_links();
}
/**
* Add the amp-to-amp-navigation meta tag.
*
* @param string $content The content for the meta tag, for example 'AMP-Redirect-To; AMP.navigateTo'.
* @return \DOMElement|null The added meta element if successful.
*/
public function add_a2a_meta( $content = self::DEFAULT_A2A_META_CONTENT ) {
$head = $this->dom->documentElement->getElementsByTagName( 'head' )->item( 0 );
if ( ! $head || ! $content ) {
return null;
}
$meta = $this->dom->createElement( 'meta' );
$meta->setAttribute( 'name', 'amp-to-amp-navigation' );
$meta->setAttribute( 'content', $content );
$head->appendChild( $meta );
return $meta;
}
/**
* Process links by adding rel=amphtml and AMP query var.
*/
public function process_links() {
/**
* Element.
*
* @var \DOMElement $element
*/
$xpath = new \DOMXPath( $this->dom );
// Remove admin bar from DOM to prevent mutating it.
$admin_bar_container = $this->dom->getElementById( 'wpadminbar' );
$admin_bar_placeholder = null;
if ( $admin_bar_container ) {
$admin_bar_placeholder = $this->dom->createComment( 'wpadminbar' );
$admin_bar_container->parentNode->replaceChild( $admin_bar_placeholder, $admin_bar_container );
}
foreach ( $xpath->query( '//*[ local-name() = "a" or local-name() = "area"][ @href ][ substring( @href, 1, 1 ) != "#" ]' ) as $element ) {
$href = $element->getAttribute( 'href' );
if ( $this->is_frontend_url( $href ) ) {
if ( $this->args['add_amphtml_rel'] ) {
$rel = $element->hasAttribute( 'rel' ) ? $element->getAttribute( 'rel' ) . ' ' : '';
$rel .= 'amphtml';
$element->setAttribute( 'rel', $rel );
}
if ( $this->args['add_query_vars'] ) {
$href = add_query_arg( amp_get_slug(), '', $href );
$element->setAttribute( 'href', $href );
$element->setAttribute( 'data-href', $href );
}
}
}
foreach ( $xpath->query( '//form[ @action ][ translate( @method, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") = "get" ]' ) as $element ) {
if ( $this->is_frontend_url( $element->getAttribute( 'action' ) ) ) {
$input = $this->dom->createElement( 'input' );
$input->setAttribute( 'name', amp_get_slug() );
$input->setAttribute( 'value', '' );
$input->setAttribute( 'type', 'hidden' );
$element->appendChild( $input );
}
}
// Replace the admin bar after mutations are done.
if ( $admin_bar_container && $admin_bar_placeholder ) {
$admin_bar_placeholder->parentNode->replaceChild( $admin_bar_container, $admin_bar_placeholder );
}
}
/**
* Determine whether a URL is for the frontend.
*
* @param string $url URL.
* @return bool Whether it is a frontend URL.
*/
public function is_frontend_url( $url ) {
$parsed_url = wp_parse_url( $url );
// Skip adding query var to links on other URLs.
if ( ! empty( $parsed_url['host'] ) && $this->home_host !== $parsed_url['host'] ) {
return false;
}
// Skip adding query var to PHP files (e.g. wp-login.php).
if ( ! empty( $parsed_url['path'] ) && preg_match( '/\.php$/', $parsed_url['path'] ) ) {
return false;
}
// Skip adding query var to feed URLs.
if ( ! empty( $parsed_url['path'] ) && preg_match( ':/feed/(\w+/)?$:', $parsed_url['path'] ) ) {
return false;
}
// Skip adding query var to the admin.
if ( ! empty( $parsed_url['path'] ) && false !== strpos( $parsed_url['path'], $this->admin_path ) ) {
return false;
}
// Skip adding query var to content links (e.g. images).
if ( ! empty( $parsed_url['path'] ) && false !== strpos( $parsed_url['path'], $this->content_path ) ) {
return false;
}
return true;
}
}
/* global ampSlug, ampPairedBrowsingQueryVar */
let ampLastVisitedUrl;
let ampWindow, nonAmpWindow;
const nonAmpIframe = document.getElementById( 'non-amp' );
const ampIframe = document.getElementById( 'amp' );
const iframeLoadedPromises = [
new Promise( ( resolve, reject ) => {
nonAmpIframe.addEventListener( 'load', resolve );
setTimeout( reject, 10000 );
} ),
new Promise( ( resolve, reject ) => {
ampIframe.addEventListener( 'load', resolve );
setTimeout( reject, 10000 );
} )
];
function windowIsAmpEndpoint( win ) {
return win.ampPairedBrowsingClientData.is_amp_endpoint;
}
const checkConnectedIframes = () => {
ampIframe.classList.toggle(
'disconnected',
! ( nonAmpIframe.contentWindow && nonAmpIframe.contentWindow.ampPairedBrowsingClientData )
);
nonAmpIframe.classList.toggle(
'disconnected',
! ( ampIframe.contentWindow && ampIframe.contentWindow.ampPairedBrowsingClientData )
);
};
Promise.all( iframeLoadedPromises ).then( () => {
setInterval( checkConnectedIframes, 1000 );
} );
function removeAmpQueryVar( url ) {
const modifiedUrl = new URL( url );
modifiedUrl.searchParams.delete( ampSlug );
modifiedUrl.searchParams.delete( 'amp_validate' );
return modifiedUrl.href;
}
function addAmpQueryVar( url ) {
const modifiedUrl = new URL( url );
modifiedUrl.searchParams.set( ampSlug, '' );
return modifiedUrl.href;
}
function addPairedBrowsingQueryVar( url ) {
const modifiedUrl = new URL( url );
modifiedUrl.searchParams.set( ampPairedBrowsingQueryVar, '1' );
return modifiedUrl.href;
}
function hasAmpQueryVar( url ) {
const parsedUrl = new URL( url );
return parsedUrl.searchParams.has( ampSlug );
}
function removeUrlHash( url ) {
const parsedUrl = new URL( url );
parsedUrl.hash = '';
return parsedUrl.href;
}
function registerClientWindow( win ) {
const url = new URL( win.location );
// @todo De-duplicate code.
if ( win === ampIframe.contentWindow ) {
// @todo Handle case where AMP is disabled. Ensure disabled iframe.
// @todo Handle case where there are rejected validation errors in Transitional mode, in which an infinite redirect/reload loop occurs.
// Force the AMP iframe to always have an AMP URL, if an AMP version is available.
if ( ! windowIsAmpEndpoint( win ) && win.document.querySelector( 'head > link[rel=amphtml]' ) ) {
win.location.replace( addAmpQueryVar( win.location ) );
return;
}
ampWindow = win;
ampWindow.addEventListener(
'scroll',
() => {
if ( nonAmpWindow && nonAmpWindow.scrollTo ) {
nonAmpWindow.scrollTo( ampWindow.scrollX, ampWindow.scrollY );
}
},
{ passive: true }
);
checkConnectedIframes();
// Make sure the non-AMP iframe is set to match.
if ( nonAmpWindow && nonAmpWindow.location && removeAmpQueryVar( removeUrlHash( nonAmpWindow.location ) ) !== removeAmpQueryVar( removeUrlHash( ampWindow.location ) ) ) {
nonAmpWindow.location.replace( removeAmpQueryVar( ampWindow.location ) );
nonAmpWindow = null;
}
document.title = '🔄 ' + ampWindow.document.title;
history.replaceState( {}, "", addPairedBrowsingQueryVar( removeAmpQueryVar( ampWindow.location ) ) );
// Set the initial scroll position to match the other window's scroll position.
if ( nonAmpWindow ) {
ampWindow.scrollTo( nonAmpWindow.scrollX, nonAmpWindow.scrollY );
}
ampLastVisitedUrl = ampWindow.location.href;
} else if ( win === nonAmpIframe.contentWindow ) {
// Force the non-AMP iframe to always have a non-AMP URL.
if ( windowIsAmpEndpoint( win ) ) {
win.location.replace( removeAmpQueryVar( win.location ) );
return;
}
nonAmpWindow = win;
nonAmpWindow.addEventListener(
'scroll',
() => {
if ( ampWindow && ampWindow.scrollTo ) {
ampWindow.scrollTo( nonAmpWindow.scrollX, nonAmpWindow.scrollY );
}
},
{ passive: true }
);
checkConnectedIframes();
// Make sure the AMP iframe is set to match.
if ( ampWindow && ampWindow.location && removeAmpQueryVar( removeUrlHash( ampWindow.location ) ) !== removeAmpQueryVar( removeUrlHash( nonAmpWindow.location ) ) ) {
ampWindow.location.replace( addAmpQueryVar( nonAmpWindow.location ) );
ampWindow = null;
}
document.title = '🔄 ' + nonAmpWindow.document.title;
history.replaceState( {}, "", addPairedBrowsingQueryVar( removeAmpQueryVar( nonAmpWindow.location ) ) );
// Set the initial scroll position to match the other window's scroll position.
if ( ampWindow ) {
nonAmpWindow.scrollTo( ampWindow.scrollX, ampWindow.scrollY );
}
}
}
/* global ampPairedBrowsingClientData */
if ( parent.registerClientWindow ) {
parent.registerClientWindow( window );
document.addEventListener( 'DOMContentLoaded', () => {
if ( ampPairedBrowsingClientData.is_amp_endpoint ) {
// Hide the paired browsing menu item if in the paired browsing interface.
const pairedBrowsingMenuItem = document.getElementById( 'wp-admin-bar-amp-paired-browsing' );
if ( pairedBrowsingMenuItem ) {
pairedBrowsingMenuItem.remove();
}
const ampViewBrowsingItem = document.getElementById( 'wp-admin-bar-amp-view' );
if ( ampViewBrowsingItem ) {
ampViewBrowsingItem.remove();
}
} else {
// Override the entire AMP menu item with just "Non-AMP". There should be no link to the AMP version since it is already being shown.
const ampMenuItem = document.getElementById( 'wp-admin-bar-amp' );
if ( ampMenuItem ) {
ampMenuItem.innerHTML = 'Non-AMP';
}
}
} );
}
// If AMP, make sure that the ?amp query var is injected.
// Send a message to the parent to say that we have loaded? Tell the parent whether it has the
<?php
/**
* Paired Browsing.
*
* @package AMP_To_AMP
* @author Weston Ruter, Google
* @link https://gist.github.com/westonruter/f9ee9ea717d52471bae092879e3d52b0
* @license GPL-2.0-or-later
* @copyright 2019 Google Inc.
*/
namespace AMP_To_AMP;
use AMP_Theme_Support, WP_Admin_Bar;
const PAIRED_BROWSING_QUERY_VAR = 'amp-paired-browsing';
/**
* Initialize.
*/
function init() {
// Abort if the required version of AMP is not installed.
if ( ! defined( 'AMP_Theme_Support::TRANSITIONAL_MODE_SLUG' ) || ! method_exists( 'AMP_Theme_Support', 'get_support_mode' ) ) {
return;
}
// Abort if not in transitional mode or there is no paired available.
if ( AMP_Theme_Support::TRANSITIONAL_MODE_SLUG !== AMP_Theme_Support::get_support_mode() || ! AMP_Theme_Support::is_paired_available() ) {
return;
}
add_action( 'admin_bar_menu', __NAMESPACE__ . '\add_menu_bar_item', 101 );
add_action( 'admin_bar_init', __NAMESPACE__ . '\enqueue_script' );
add_action( 'parse_request', __NAMESPACE__ . '\serve_paired_browsing_experience' );
}
add_action( 'init', __NAMESPACE__ . '\init' );
/**
* Add menu bar item.
*
* @param WP_Admin_Bar $wp_admin_bar Admin bar.
* @see amp_add_admin_bar_view_link()
*/
function add_menu_bar_item( WP_Admin_Bar $wp_admin_bar ) {
$wp_admin_bar->add_node(
[
'id' => 'amp-paired-browsing',
'parent' => 'amp',
'title' => __( 'Start paired browsing', 'amp-to-amp' ),
'href' => remove_query_arg( amp_get_slug(), add_query_arg( PAIRED_BROWSING_QUERY_VAR, '1' ) ),
'meta' => [
'target' => 'amp-paired-browsing',
],
]
);
}
/**
* Enqueue script.
*/
function enqueue_script() {
if ( is_admin() ) {
return;
}
wp_enqueue_script(
'amp-paired-browsing',
plugin_dir_url( __FILE__ ) . '/paired-browsing-client.js',
[ 'admin-bar' ],
'0.1',
true
);
$data = [
'is_amp_endpoint' => \is_amp_endpoint(),
];
wp_add_inline_script(
'amp-paired-browsing',
sprintf( 'var ampPairedBrowsingClientData = %s;', wp_json_encode( $data ) ),
'before'
);
// @todo Make it so that AMP is shown in admin bar on mobile.
// wp_add_inline_style( 'admin-bar', '@media screen and (max-width: 782px) { #wpadminbar #wp-admin-bar-amp { display:block; position:static; } }' );
}
/**
* Serve paired browsing experience.
*/
function serve_paired_browsing_experience() {
if ( ! isset( $_GET[ PAIRED_BROWSING_QUERY_VAR ] ) ) {
return;
}
if ( ! is_user_logged_in() ) {
auth_redirect();
}
if ( ! is_admin_bar_showing() ) {
wp_die( esc_html__( 'The admin bar must be showing to use paired browsing mode.' ) );
}
$url = remove_query_arg( PAIRED_BROWSING_QUERY_VAR );
$amp_url = add_query_arg( amp_get_slug(), '1', $url );
$url = remove_query_arg( 'amp_validate', $url );
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<title><?php esc_html_e( 'Loading...', 'amp-to-amp' ); ?></title>
<style>
html,body {
margin: 0;
padding: 0;
}
iframe {
border: 0;
position: absolute;
width: calc( 50% - 1px );
height: 100%;
top: 0;
bottom: 0;
}
iframe.disconnected {
opacity: 0.5;
}
#non-amp {
left: 0;
right: auto;
border-right: solid 1px #DDD;
background-color: red;
}
#amp {
border-left: solid 1px #DDD;
left: auto;
right: 0;
background-color: green;
}
</style>
</head>
<body>
<iframe src="<?php echo esc_url( $url ); ?>" id="non-amp" sandbox="allow-forms allow-scripts allow-same-origin allow-popups"></iframe>
<iframe src="<?php echo esc_url( $amp_url ); ?>" id="amp" sandbox="allow-forms allow-scripts allow-same-origin allow-popups"></iframe>
<script>
const ampSlug = <?php echo wp_json_encode( amp_get_slug() ); ?>;
const ampPairedBrowsingQueryVar = <?php echo wp_json_encode( PAIRED_BROWSING_QUERY_VAR ); ?>;
</script>
<script src="<?php echo esc_url( plugin_dir_url( __FILE__ ) . 'paired-browsing-app.js' ); ?>"></script>
</body>
</html>
<?php
exit;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment