You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2578 lines
87 KiB

<?php
/**
* Class AMP_Theme_Support
*
* @package AMP
*/
/**
* Class AMP_Theme_Support
*
* Callbacks for adding AMP-related things when theme support is added.
*/
class AMP_Theme_Support {
/**
* Theme support slug.
*
* @var string
*/
const SLUG = 'amp';
/**
* Response cache group name.
*
* @var string
*/
const RESPONSE_CACHE_GROUP = 'amp-response';
/**
* Post-processor cache effectiveness group name.
*
* @var string
*/
const POST_PROCESSOR_CACHE_EFFECTIVENESS_GROUP = 'post_processor_cache_effectiveness_group';
/**
* Post-processor cache effectiveness key name.
*
* @var string
*/
const POST_PROCESSOR_CACHE_EFFECTIVENESS_KEY = 'post_processor_cache_effectiveness';
/**
* Cache miss threshold for determining when to disable post-processor cache.
*
* @var int
*/
const CACHE_MISS_THRESHOLD = 20;
/**
* Cache miss URL option name.
*
* @var string
*/
const CACHE_MISS_URL_OPTION = 'amp_cache_miss_url';
/**
* Slug identifying standard website mode.
*
* @since 1.2
* @var string
*/
const STANDARD_MODE_SLUG = 'standard';
/**
* Slug identifying transitional website mode.
*
* @since 1.2
* @var string
*/
const TRANSITIONAL_MODE_SLUG = 'transitional';
/**
* Slug identifying reader website mode.
*
* @since 1.2
* @var string
*/
const READER_MODE_SLUG = 'reader';
/**
* Flag used in args passed to add_theme_support('amp') to indicate transitional mode supported.
*
* @since 1.2
* @var string
*/
const PAIRED_FLAG = 'paired';
/**
* The directory name in a theme where Reader Mode templates can be.
*
* For example, this could be at your-theme-name/amp.
*
* @var string
*/
const READER_MODE_TEMPLATE_DIRECTORY = 'amp';
/**
* Sanitizer classes.
*
* @var array
*/
protected static $sanitizer_classes = [];
/**
* Embed handlers.
*
* @var AMP_Base_Embed_Handler[]
*/
protected static $embed_handlers = [];
/**
* Template types.
*
* @var array
*/
protected static $template_types = [
'paged', // Deprecated.
'index',
'404',
'archive',
'author',
'category',
'tag',
'taxonomy',
'date',
'home',
'front_page',
'page',
'search',
'single',
'embed',
'singular',
'attachment',
];
/**
* Start time when init was called.
*
* @since 1.0
* @var float
*/
public static $init_start_time;
/**
* Whether output buffering has started.
*
* @since 0.7
* @var bool
*/
protected static $is_output_buffering = false;
/**
* Theme support mode that was added via option.
*
* This should be either null (reader), 'standard', or 'transitional'.
*
* @since 1.0
* @var null|string
*/
protected static $support_added_via_option;
/**
* Theme support mode which was added via the theme.
*
* This should be either null (reader), 'standard', or 'transitional'.
*
* @var null|string
*/
protected static $support_added_via_theme;
/**
* Initialize.
*
* @since 0.7
*/
public static function init() {
self::read_theme_support();
self::$init_start_time = microtime( true );
if ( AMP_Options_Manager::is_website_experience_enabled() && current_theme_supports( self::SLUG ) ) {
// Ensure extra theme support for core themes is in place.
AMP_Core_Theme_Sanitizer::extend_theme_support();
require_once AMP__DIR__ . '/includes/amp-post-template-functions.php';
add_action( 'widgets_init', [ __CLASS__, 'register_widgets' ] );
/*
* Note that wp action is use instead of template_redirect because some themes/plugins output
* the response at this action and then short-circuit with exit. So this is why the the preceding
* action to template_redirect--the wp action--is used instead.
*/
add_action( 'wp', [ __CLASS__, 'finish_init' ], PHP_INT_MAX );
} elseif ( AMP_Options_Manager::is_stories_experience_enabled() ) {
add_action(
'wp',
static function () {
if ( is_singular( AMP_Story_Post_Type::POST_TYPE_SLUG ) ) {
self::finish_init();
}
},
PHP_INT_MAX
);
}
}
/**
* Determine whether theme support was added via admin option.
*
* @since 1.0
* @see AMP_Theme_Support::read_theme_support()
* @see AMP_Theme_Support::get_support_mode()
* @deprecated Use AMP_Theme_Support::get_support_mode_added_via_option().
*
* @return bool Support added via option.
*/
public static function is_support_added_via_option() {
_deprecated_function( __METHOD__, '1.2', 'AMP_Theme_Support::get_support_mode_added_via_option' );
return null !== self::$support_added_via_option;
}
/**
* Get the theme support mode added via admin option.
*
* @return null|string Support added via option, with null meaning Reader, and otherwise being 'standard' or 'transitional'.
* @see AMP_Theme_Support::read_theme_support()
* @see AMP_Theme_Support::TRANSITIONAL_MODE_SLUG
* @see AMP_Theme_Support::STANDARD_MODE_SLUG
*
* @since 1.2
*/
public static function get_support_mode_added_via_option() {
return self::$support_added_via_option;
}
/**
* Get the theme support mode added via admin option.
*
* @return null|string Support added via option, with null meaning Reader, and otherwise being 'standard' or 'transitional'.
* @see AMP_Theme_Support::read_theme_support()
* @see AMP_Theme_Support::TRANSITIONAL_MODE_SLUG
* @see AMP_Theme_Support::STANDARD_MODE_SLUG
*
* @since 1.2
*/
public static function get_support_mode_added_via_theme() {
return self::$support_added_via_theme;
}
/**
* Get theme support mode.
*
* @return string Theme support mode.
* @see AMP_Theme_Support::read_theme_support()
* @see AMP_Theme_Support::TRANSITIONAL_MODE_SLUG
* @see AMP_Theme_Support::STANDARD_MODE_SLUG
*
* @since 1.2
*/
public static function get_support_mode() {
$theme_support = self::get_support_mode_added_via_option();
if ( ! $theme_support ) {
$theme_support = self::get_support_mode_added_via_theme();
}
if ( ! $theme_support ) {
$theme_support = self::READER_MODE_SLUG;
}
return $theme_support;
}
/**
* Check theme support args or add theme support if option is set in the admin.
*
* The DB option is only considered if the theme does not already explicitly support AMP.
*
* @see AMP_Theme_Support::get_support_mode_added_via_theme()
* @see AMP_Theme_Support::get_support_mode_added_via_option()
* @see AMP_Post_Type_Support::add_post_type_support() For where post type support is added, since it is irrespective of theme support.
*/
public static function read_theme_support() {
self::$support_added_via_theme = null;
self::$support_added_via_option = null;
$theme_support_option = AMP_Options_Manager::get_option( 'theme_support' );
if ( current_theme_supports( self::SLUG ) ) {
$args = self::get_theme_support_args();
// Validate theme support usage.
$keys = [ 'template_dir', 'comments_live_list', self::PAIRED_FLAG, 'templates_supported', 'available_callback', 'service_worker', 'nav_menu_toggle', 'nav_menu_dropdown' ];
if ( count( array_diff( array_keys( $args ), $keys ) ) !== 0 ) {
_doing_it_wrong(
'add_theme_support',
esc_html(
sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
/* translators: 1: comma-separated list of expected keys, 2: comma-separated list of actual keys */
__( 'Expected AMP theme support to keys (%1$s) but saw (%2$s)', 'amp' ),
implode( ', ', $keys ),
implode( ', ', array_keys( $args ) )
)
),
'1.0'
);
}
if ( isset( $args['available_callback'] ) ) {
_doing_it_wrong(
'add_theme_support',
sprintf(
/* translators: 1: available_callback. 2: supported_templates */
esc_html__( 'The %1$s is deprecated when adding amp theme support in favor of declaratively setting the %2$s.', 'amp' ),
'available_callback',
'supported_templates'
),
'1.0'
);
}
// See amp_is_canonical().
$is_paired = isset( $args[ self::PAIRED_FLAG ] ) ? $args[ self::PAIRED_FLAG ] : ! empty( $args['template_dir'] );
self::$support_added_via_theme = $is_paired ? self::TRANSITIONAL_MODE_SLUG : self::STANDARD_MODE_SLUG;
self::$support_added_via_option = $theme_support_option;
// Make sure the user option can override what the theme has specified.
if ( $is_paired && self::STANDARD_MODE_SLUG === $theme_support_option ) {
$args[ self::PAIRED_FLAG ] = false;
add_theme_support( self::SLUG, $args );
} elseif ( ! $is_paired && self::TRANSITIONAL_MODE_SLUG === $theme_support_option ) {
$args[ self::PAIRED_FLAG ] = true;
add_theme_support( self::SLUG, $args );
} elseif ( self::READER_MODE_SLUG === $theme_support_option ) {
remove_theme_support( self::SLUG );
}
} elseif ( self::READER_MODE_SLUG !== $theme_support_option ) {
$is_paired = ( self::TRANSITIONAL_MODE_SLUG === $theme_support_option );
add_theme_support(
self::SLUG,
[
self::PAIRED_FLAG => $is_paired,
]
);
self::$support_added_via_option = $is_paired ? self::TRANSITIONAL_MODE_SLUG : self::STANDARD_MODE_SLUG;
} elseif ( AMP_Validation_Manager::is_theme_support_forced() ) {
self::$support_added_via_option = self::STANDARD_MODE_SLUG;
add_theme_support( self::SLUG );
}
}
/**
* Get the theme support args.
*
* This avoids having to repeatedly call `get_theme_support()`, check the args, shift an item off the array, and so on.
*
* @since 1.0
*
* @return array|false Theme support args, or false if theme support is not present.
*/
public static function get_theme_support_args() {
if ( ! current_theme_supports( self::SLUG ) ) {
return false;
}
$support = get_theme_support( self::SLUG );
if ( true === $support ) {
return [
self::PAIRED_FLAG => false,
];
}
if ( ! isset( $support[0] ) || ! is_array( $support[0] ) ) {
return [];
}
return $support[0];
}
/**
* Gets whether the parent or child theme supports Reader Mode.
*
* True if the theme does not call add_theme_support( 'amp' ) at all,
* and it has an amp/ directory for templates.
*
* @return bool Whether the theme supports Reader Mode.
*/
public static function supports_reader_mode() {
return (
! self::get_support_mode_added_via_theme()
&&
(
is_dir( trailingslashit( get_template_directory() ) . self::READER_MODE_TEMPLATE_DIRECTORY )
||
is_dir( trailingslashit( get_stylesheet_directory() ) . self::READER_MODE_TEMPLATE_DIRECTORY )
)
);
}
/**
* Finish initialization once query vars are set.
*
* @since 0.7
*/
public static function finish_init() {
if ( ! is_amp_endpoint() ) {
/*
* Redirect to AMP-less variable 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 ( isset( $_GET[ amp_get_slug() ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
self::redirect_non_amp_url( current_user_can( 'manage_options' ) ? 302 : 301, true );
}
amp_add_frontend_actions();
return;
}
self::ensure_proper_amp_location();
$theme_support = self::get_theme_support_args();
if ( ! empty( $theme_support['template_dir'] ) ) {
self::add_amp_template_filters();
}
self::add_hooks();
self::$sanitizer_classes = amp_get_content_sanitizers();
self::$sanitizer_classes = AMP_Validation_Manager::filter_sanitizer_args( self::$sanitizer_classes );
self::$embed_handlers = self::register_content_embed_handlers();
self::$sanitizer_classes['AMP_Embed_Sanitizer']['embed_handlers'] = self::$embed_handlers;
foreach ( self::$sanitizer_classes as $sanitizer_class => $args ) {
if ( method_exists( $sanitizer_class, 'add_buffering_hooks' ) ) {
call_user_func( [ $sanitizer_class, 'add_buffering_hooks' ], $args );
}
}
}
/**
* Ensure that the current AMP location is correct.
*
* @since 1.0
*
* @param bool $exit Whether to exit after redirecting.
* @return bool Whether redirection was done. Naturally this is irrelevant if $exit is true.
*/
public static function ensure_proper_amp_location( $exit = true ) {
$has_query_var = false !== get_query_var( amp_get_slug(), false ); // May come from URL param or endpoint slug.
$has_url_param = isset( $_GET[ amp_get_slug() ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( amp_is_canonical() || is_singular( AMP_Story_Post_Type::POST_TYPE_SLUG ) ) {
/*
* 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 ( $has_query_var || $has_url_param ) {
return self::redirect_non_amp_url( current_user_can( 'manage_options' ) ? 302 : 301, $exit );
}
} else {
/*
* When in AMP transitional mode *with* theme support, then the proper AMP URL has the 'amp' URL param
* and not the /amp/ endpoint. The URL param is now the exclusive way to mark AMP in transitional mode
* when amp theme support present. This is important for plugins to be able to reliably call
* is_amp_endpoint() before the parse_query action.
*/
if ( $has_query_var && ! $has_url_param ) {
$old_url = amp_get_current_url();
$new_url = add_query_arg( amp_get_slug(), '', amp_remove_endpoint( $old_url ) );
if ( $old_url !== $new_url ) {
// A temporary redirect is used for admin users to allow them to see changes between reader mode and transitional modes.
wp_safe_redirect( $new_url, current_user_can( 'manage_options' ) ? 302 : 301 );
// @codeCoverageIgnoreStart
if ( $exit ) {
exit;
}
return true;
// @codeCoverageIgnoreEnd
}
}
}
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().
*
* @param int $status Status code (301 or 302).
* @param bool $exit Whether to exit after redirecting.
* @return bool Whether redirection was done. Naturally this is irrelevant if $exit is true.
*/
public static function redirect_non_amp_url( $status = 302, $exit = true ) {
$current_url = amp_get_current_url();
$non_amp_url = amp_remove_endpoint( $current_url );
if ( $non_amp_url === $current_url ) {
return false;
}
wp_safe_redirect( $non_amp_url, $status );
// @codeCoverageIgnoreStart
if ( $exit ) {
exit;
}
return true;
// @codeCoverageIgnoreEnd
}
/**
* 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
*
* @see amp_is_canonical()
* @return bool Whether available.
*/
public static function is_paired_available() {
if ( ! current_theme_supports( self::SLUG ) ) {
return false;
}
if ( amp_is_canonical() ) {
return false;
}
$availability = self::get_template_availability();
return $availability['supported'];
}
/**
* Determine whether the user is in the Customizer preview iframe.
*
* @since 0.7
*
* @return bool Whether in Customizer preview iframe.
*/
public static function is_customize_preview_iframe() {
global $wp_customize;
return is_customize_preview() && $wp_customize->get_messenger_channel();
}
/**
* Register filters for loading AMP-specific templates.
*/
public static function add_amp_template_filters() {
foreach ( self::$template_types as $template_type ) {
// See get_query_template().
$template_type = preg_replace( '|[^a-z0-9-]+|', '', $template_type );
add_filter( "{$template_type}_template_hierarchy", [ __CLASS__, 'filter_amp_template_hierarchy' ] );
}
}
/**
* Determine template availability of AMP for the given query.
*
* This is not intended to return whether AMP is available for a _specific_ post. For that, use `post_supports_amp()`.
*
* @since 1.0
* @global WP_Query $wp_query
* @see post_supports_amp()
*
* @param WP_Query|WP_Post|null $query Query or queried post. If null then the global query will be used.
* @return array {
* Template availability.
*
* @type bool $supported Whether the template is supported in AMP.
* @type bool|null $immutable Whether the supported status is known to be unchangeable.
* @type string|null $template The ID of the matched template (conditional), such as 'is_singular', or null if nothing was matched.
* @type string[] $errors List of the errors or reasons for why the template is not available.
* }
*/
public static function get_template_availability( $query = null ) {
global $wp_query;
if ( ! $query ) {
$query = $wp_query;
} elseif ( $query instanceof WP_Post ) {
$post = $query;
$query = new WP_Query();
if ( 'page' === $post->post_type ) {
$query->set( 'page_id', $post->ID );
} else {
$query->set( 'p', $post->ID );
}
$query->queried_object = $post;
$query->queried_object_id = $post->ID;
$query->parse_query_vars();
}
$default_response = [
'errors' => [],
'supported' => false,
'immutable' => null,
'template' => null,
];
if ( ! ( $query instanceof WP_Query ) ) {
_doing_it_wrong( __METHOD__, esc_html__( 'No WP_Query available.', 'amp' ), '1.0' );
return array_merge(
$default_response,
[ 'errors' => [ 'no_query_available' ] ]
);
}
$theme_support_args = self::get_theme_support_args();
if ( false === $theme_support_args ) {
return array_merge(
$default_response,
[ 'errors' => [ 'no_theme_support' ] ]
);
}
// Support available_callback from 0.7, though it is deprecated.
if ( isset( $theme_support_args['available_callback'] ) && is_callable( $theme_support_args['available_callback'] ) ) {
/**
* Queried object.
*
* @var WP_Post $queried_object
*/
$queried_object = $query->get_queried_object();
if ( ( is_singular() || $query->is_posts_page ) && ! post_supports_amp( $queried_object ) ) {
return array_merge(
$default_response,
[
'errors' => [ 'no-post-support' ],
'supported' => false,
'immutable' => true,
]
);
}
$response = array_merge(
$default_response,
[
'supported' => call_user_func( $theme_support_args['available_callback'] ),
'immutable' => true,
]
);
if ( ! $response['supported'] ) {
$response['errors'][] = 'available_callback';
}
return $response;
}
$all_templates_supported_by_theme_support = false;
if ( isset( $theme_support_args['templates_supported'] ) ) {
$all_templates_supported_by_theme_support = 'all' === $theme_support_args['templates_supported'];
}
$all_templates_supported = (
$all_templates_supported_by_theme_support || AMP_Options_Manager::get_option( 'all_templates_supported' )
);
// Make sure global $wp_query is set in case of conditionals that unfortunately look at global scope.
$prev_query = $wp_query;
$wp_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$matching_templates = [];
$supportable_templates = self::get_supportable_templates();
foreach ( $supportable_templates as $id => $supportable_template ) {
if ( empty( $supportable_template['callback'] ) ) {
$callback = $id;
} else {
$callback = $supportable_template['callback'];
}
// If the callback is a method on the query, then call the method on the query itself.
if ( is_string( $callback ) && 'is_' === substr( $callback, 0, 3 ) && method_exists( $query, $callback ) ) {
$is_match = call_user_func( [ $query, $callback ] );
} elseif ( is_callable( $callback ) ) {
$is_match = $callback( $query );
} else {
/* translators: %s: the supportable template ID. */
_doing_it_wrong( __FUNCTION__, esc_html( sprintf( __( 'Supportable template "%s" does not have a callable callback.', 'amp' ), $id ) ), '1.0' );
$is_match = false;
}
if ( $is_match ) {
$matching_templates[ $id ] = [
'template' => $id,
'supported' => ! empty( $supportable_template['supported'] ),
'immutable' => ! empty( $supportable_template['immutable'] ),
];
}
}
// Restore previous $wp_query (if any).
$wp_query = $prev_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// Make sure children override their parents.
$matching_template_ids = array_keys( $matching_templates );
foreach ( array_diff( array_keys( $supportable_templates ), $matching_template_ids ) as $template_id ) {
unset( $supportable_templates[ $template_id ] );
}
foreach ( $matching_template_ids as $id ) {
$has_children = false;
foreach ( $supportable_templates as $other_id => $supportable_template ) {
if ( $other_id === $id ) {
continue;
}
if ( isset( $supportable_template['parent'] ) && $id === $supportable_template['parent'] ) {
$has_children = true;
break;
}
}
// Delete all matching parent templates since the child will override them.
if ( ! $has_children ) {
$supportable_template = $supportable_templates[ $id ];
while ( ! empty( $supportable_template['parent'] ) ) {
$parent = $supportable_template['parent'];
/*
* If the parent is not amongst the supportable templates, then something is off in terms of hierarchy.
* Either the matching is off-track, or the template is badly configured.
*/
if ( ! array_key_exists( $parent, $supportable_templates ) ) {
_doing_it_wrong(
__METHOD__,
esc_html(
sprintf(
/* translators: %s: amp_supportable_templates */
__( 'An expected parent was not found. Did you filter %s to not honor the template hierarchy?', 'amp' ),
'amp_supportable_templates'
)
),
'1.4'
);
break;
}
$supportable_template = $supportable_templates[ $parent ];
// Let the child supported status override the parent's supported status.
unset( $matching_templates[ $parent ] );
}
}
}
// If there is more than 1 matching template, the is_home() condition is the default so discard it if there are other matching templates.
if ( count( $matching_templates ) > 1 && isset( $matching_templates['is_home'] ) ) {
unset( $matching_templates['is_home'] );
}
/*
* When there is still more than one matching template, account for ambiguous cases, informed by the order in template-loader.php.
* See <https://github.com/WordPress/wordpress-develop/blob/5.1.0/src/wp-includes/template-loader.php#L49-L68>.
*/
if ( count( $matching_templates ) > 1 ) {
$template_conditional_priority_order = [
'is_embed',
'is_404',
'is_search',
'is_front_page',
'is_home',
'is_post_type_archive',
'is_tax',
'is_attachment',
'is_single',
'is_page',
'is_singular',
'is_category',
'is_tag',
'is_author',
'is_date',
'is_archive',
];
// Obtain the template conditionals for each matching template ID (e.g. 'is_post_type_archive[product]' => 'is_post_type_archive').
$template_conditional_id_mapping = [];
foreach ( array_keys( $matching_templates ) as $template_id ) {
$template_conditional_id_mapping[ strtok( $template_id, '[' ) ] = $template_id;
}
// If there are any custom supportable templates, only consider them since they would override the conditional logic in core.
$custom_template_conditions = array_diff(
array_keys( $template_conditional_id_mapping ),
$template_conditional_priority_order
);
if ( ! empty( $custom_template_conditions ) ) {
$matching_templates = wp_array_slice_assoc(
$matching_templates,
array_values( wp_array_slice_assoc( $template_conditional_id_mapping, $custom_template_conditions ) )
);
} else {
/*
* Otherwise, iterate over the template conditionals in the order they occur in the if/elseif/else conditional chain.
* to then populate $matching_templates with just this one entry.
*/
foreach ( $template_conditional_priority_order as $template_conditional ) {
if ( isset( $template_conditional_id_mapping[ $template_conditional ] ) ) {
$template_id = $template_conditional_id_mapping[ $template_conditional ];
$matching_templates = [
$template_id => $matching_templates[ $template_id ],
];
break;
}
}
}
}
/*
* If there are more than one matching templates, then something is probably not right.
* Template conditions need to be set up properly to prevent this from happening.
*/
if ( count( $matching_templates ) > 1 ) {
_doing_it_wrong(
__METHOD__,
esc_html(
sprintf(
/* translators: %s: amp_supportable_templates */
__( 'Did not expect there to be more than one matching template. Did you filter %s to not honor the template hierarchy?', 'amp' ),
'amp_supportable_templates'
)
),
'1.0'
);
}
$matching_template = array_shift( $matching_templates );
// If there aren't any matching templates left that are supported, then we consider it to not be available.
if ( ! $matching_template ) {
if ( $all_templates_supported ) {
return array_merge(
$default_response,
[
'supported' => true,
]
);
}
return array_merge(
$default_response,
[ 'errors' => [ 'no_matching_template' ] ]
);
}
$matching_template = array_merge( $default_response, $matching_template );
// If there aren't any matching templates left that are supported, then we consider it to not be available.
if ( empty( $matching_template['supported'] ) ) {
$matching_template['errors'][] = 'template_unsupported';
}
// For singular queries, post_supports_amp() is given the final say.
if ( $query->is_singular() || $query->is_posts_page ) {
/**
* Queried object.
*
* @var WP_Post $queried_object
*/
$queried_object = $query->get_queried_object();
if ( $queried_object instanceof WP_Post ) {
$support_errors = AMP_Post_Type_Support::get_support_errors( $queried_object );
if ( ! empty( $support_errors ) ) {
$matching_template['errors'] = array_merge( $matching_template['errors'], $support_errors );
$matching_template['supported'] = false;
}
}
}
return $matching_template;
}
/**
* Get the templates which can be supported.
*
* @return array Supportable templates.
*/
public static function get_supportable_templates() {
$templates = [
'is_singular' => [
'label' => __( 'Singular', 'amp' ),
'description' => __( 'Required for the above content types.', 'amp' ),
],
];
if ( 'page' === get_option( 'show_on_front' ) ) {
$templates['is_front_page'] = [
'label' => __( 'Homepage', 'amp' ),
'parent' => 'is_singular',
];
if ( AMP_Post_Meta_Box::DISABLED_STATUS === get_post_meta( get_option( 'page_on_front' ), AMP_Post_Meta_Box::STATUS_POST_META_KEY, true ) ) {
/* translators: %s: the URL to the edit post screen. */
$templates['is_front_page']['description'] = sprintf( __( 'Currently disabled at the <a href="%s">page level</a>.', 'amp' ), esc_url( get_edit_post_link( get_option( 'page_on_front' ) ) ) );
}
// In other words, same as is_posts_page, *but* it not is_singular.
$templates['is_home'] = [
'label' => __( 'Blog', 'amp' ),
];
if ( AMP_Post_Meta_Box::DISABLED_STATUS === get_post_meta( get_option( 'page_for_posts' ), AMP_Post_Meta_Box::STATUS_POST_META_KEY, true ) ) {
/* translators: %s: the URL to the edit post screen. */
$templates['is_home']['description'] = sprintf( __( 'Currently disabled at the <a href="%s">page level</a>.', 'amp' ), esc_url( get_edit_post_link( get_option( 'page_for_posts' ) ) ) );
}
} else {
$templates['is_home'] = [
'label' => __( 'Homepage', 'amp' ),
];
}
$templates = array_merge(
$templates,
[
'is_archive' => [
'label' => __( 'Archives', 'amp' ),
],
'is_author' => [
'label' => __( 'Author', 'amp' ),
'parent' => 'is_archive',
],
'is_date' => [
'label' => __( 'Date', 'amp' ),
'parent' => 'is_archive',
],
'is_search' => [
'label' => __( 'Search', 'amp' ),
],
'is_404' => [
'label' => __( 'Not Found (404)', 'amp' ),
],
]
);
if ( taxonomy_exists( 'category' ) ) {
$templates['is_category'] = [
'label' => get_taxonomy( 'category' )->labels->name,
'parent' => 'is_archive',
];
}
if ( taxonomy_exists( 'post_tag' ) ) {
$templates['is_tag'] = [
'label' => get_taxonomy( 'post_tag' )->labels->name,
'parent' => 'is_archive',
];
}
$taxonomy_args = [
'_builtin' => false,
'public' => true,
];
foreach ( get_taxonomies( $taxonomy_args, 'objects' ) as $taxonomy ) {
$templates[ sprintf( 'is_tax[%s]', $taxonomy->name ) ] = [
'label' => $taxonomy->labels->name,
'parent' => 'is_archive',
'callback' => static function ( WP_Query $query ) use ( $taxonomy ) {
return $query->is_tax( $taxonomy->name );
},
];
}
$post_type_args = [
'has_archive' => true,
'public' => true,
];
foreach ( get_post_types( $post_type_args, 'objects' ) as $post_type ) {
$templates[ sprintf( 'is_post_type_archive[%s]', $post_type->name ) ] = [
'label' => $post_type->labels->archives,
'parent' => 'is_archive',
'callback' => static function ( WP_Query $query ) use ( $post_type ) {
return $query->is_post_type_archive( $post_type->name );
},
];
}
/**
* Filters list of supportable templates.
*
* A theme or plugin can force a given template to be supported or not by preemptively
* setting the 'supported' flag for a given template. Otherwise, if the flag is undefined
* then the user will be able to toggle it themselves in the admin. Each array item should
* have a key that corresponds to a template conditional function. If the key is such a
* function, then the key is used to evaluate whether the given template entry is a match.
* Otherwise, a supportable template item can include a callback value which is used instead.
* Each item needs a 'label' value. Additionally, if the supportable template is a subset of
* another condition (e.g. is_singular > is_single) then this relationship needs to be
* indicated via the 'parent' value.
*
* @since 1.0
*
* @param array $templates Supportable templates.
*/
$templates = apply_filters( 'amp_supportable_templates', $templates );
$theme_support_args = self::get_theme_support_args();
$theme_supported_templates = [];
if ( isset( $theme_support_args['templates_supported'] ) ) {
$theme_supported_templates = $theme_support_args['templates_supported'];
}
$supported_templates = AMP_Options_Manager::get_option( 'supported_templates' );
foreach ( $templates as $id => &$template ) {
// Capture user-elected support from options. This allows us to preserve the original user selection through programmatic overrides.
$template['user_supported'] = in_array( $id, $supported_templates, true );
// Consider supported templates from theme support args.
if ( ! isset( $template['supported'] ) ) {
if ( 'all' === $theme_supported_templates ) {
$template['supported'] = true;
} elseif ( is_array( $theme_supported_templates ) && isset( $theme_supported_templates[ $id ] ) ) {
$template['supported'] = $theme_supported_templates[ $id ];
}
}
// Make supported state immutable if it was programmatically set.
$template['immutable'] = isset( $template['supported'] );
// Set supported state from user preference.
if ( ! $template['immutable'] ) {
$template['supported'] = AMP_Options_Manager::get_option( 'all_templates_supported' ) || $template['user_supported'];
}
}
return $templates;
}
/**
* Register hooks.
*/
public static function add_hooks() {
// Remove core actions which are invalid AMP.
remove_action( 'wp_head', 'wp_post_preview_js', 1 );
remove_action( 'wp_head', 'wp_oembed_add_host_js' );
// Replace JS-based emoji with PHP-based, if the JS-based emoji replacement was not already removed.
if ( has_action( 'wp_head', 'print_emoji_detection_script' ) ) {
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'wp_print_styles', 'print_emoji_styles' );
add_action( 'wp_print_styles', [ __CLASS__, 'print_emoji_styles' ] );
add_filter( 'the_title', 'wp_staticize_emoji' );
add_filter( 'the_excerpt', 'wp_staticize_emoji' );
add_filter( 'the_content', 'wp_staticize_emoji' );
add_filter( 'comment_text', 'wp_staticize_emoji' );
add_filter( 'widget_text', 'wp_staticize_emoji' );
}
// @todo The wp_mediaelement_fallback() should still run to be injected inside of the audio/video generated by wp_audio_shortcode()/wp_video_shortcode() respectively.
// Prevent MediaElement.js scripts/styles from being enqueued.
add_filter(
'wp_video_shortcode_library',
static function() {
return 'amp';
}
);
add_filter(
'wp_audio_shortcode_library',
static function() {
return 'amp';
}
);
// Don't show loading indicator on custom logo since it makes most sense for larger images.
add_filter(
'get_custom_logo',
static function( $html ) {
return preg_replace( '/(?<=<img\s)/', ' data-amp-noloading="" ', $html );
},
1
);
add_action( 'admin_bar_init', [ __CLASS__, 'init_admin_bar' ] );
add_action( 'wp_head', 'amp_add_generator_metadata', 20 );
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ], 0 ); // Enqueue before theme's styles.
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'dequeue_customize_preview_scripts' ], 1000 );
add_filter( 'customize_partial_render', [ __CLASS__, 'filter_customize_partial_render' ] );
add_action( 'wp_footer', 'amp_print_analytics' );
/*
* Start output buffering at very low priority for sake of plugins and themes that use template_redirect
* instead of template_include.
*/
$priority = defined( 'PHP_INT_MIN' ) ? PHP_INT_MIN : ~PHP_INT_MAX; // phpcs:ignore PHPCompatibility.Constants.NewConstants.php_int_minFound
add_action( 'template_redirect', [ __CLASS__, 'start_output_buffering' ], $priority );
// Commenting hooks.
add_filter( 'comment_form_defaults', [ __CLASS__, 'filter_comment_form_defaults' ] );
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( 'wp_kses_allowed_html', [ __CLASS__, 'whitelist_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(
'wp_print_footer_scripts',
static function() {
wp_dequeue_script( 'wp-custom-header' );
},
0
);
add_action(
'wp_enqueue_scripts',
static function() {
wp_dequeue_script( 'comment-reply' ); // Handled largely by AMP_Comments_Sanitizer and *reply* methods in this class.
}
);
// @todo Add character conversion.
}
/**
* Register/override widgets.
*
* @global WP_Widget_Factory
* @return void
*/
public static function register_widgets() {
global $wp_widget_factory;
foreach ( $wp_widget_factory->widgets as $registered_widget ) {
$registered_widget_class_name = get_class( $registered_widget );
if ( ! preg_match( '/^WP_Widget_(.+)$/', $registered_widget_class_name, $matches ) ) {
continue;
}
$amp_class_name = 'AMP_Widget_' . $matches[1];
if ( ! class_exists( $amp_class_name ) || is_a( $amp_class_name, $registered_widget_class_name ) ) {
continue;
}
unregister_widget( $registered_widget_class_name );
register_widget( $amp_class_name );
}
}
/**
* Register content embed handlers.
*
* This was copied from `AMP_Content::register_embed_handlers()` due to being a private method
* and due to `AMP_Content` not being well suited for use in AMP canonical.
*
* @see AMP_Content::register_embed_handlers()
* @global int $content_width
* @return AMP_Base_Embed_Handler[] Handlers.
*/
public static function register_content_embed_handlers() {
global $content_width;
$embed_handlers = [];
foreach ( amp_get_content_embed_handlers() as $embed_handler_class => $args ) {
/**
* Embed handler.
*
* @type AMP_Base_Embed_Handler $embed_handler
*/
$embed_handler = new $embed_handler_class(
array_merge(
[
'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat.
],
$args
)
);
if ( ! $embed_handler instanceof AMP_Base_Embed_Handler ) {
_doing_it_wrong(
__METHOD__,
esc_html(
sprintf(
/* translators: 1: embed handler. 2: AMP_Embed_Handler */
__( 'Embed Handler (%1$s) must extend `%2$s`', 'amp' ),
esc_html( $embed_handler_class ),
'AMP_Embed_Handler'
)
),
'0.1'
);
continue;
}
$embed_handler->register_embed();
$embed_handlers[] = $embed_handler;
}
return $embed_handlers;
}
/**
* Add the comments template placeholder marker
*
* @deprecated 1.1.0 This functionality was moved to AMP_Comments_Sanitizer
*
* @param array $args the args for the comments list.
* @return array Args to return.
*/
public static function set_comments_walker( $args ) {
_deprecated_function( __METHOD__, '1.1' );
$amp_walker = new AMP_Comment_Walker();
$args['walker'] = $amp_walker;
return $args;
}
/**
* Amend the comment form with the redirect_to field to persist the AMP page after submission.
*/
public static function amend_comment_form() {
?>
<?php if ( is_singular() && ! amp_is_canonical() ) : ?>
<input type="hidden" name="redirect_to" value="<?php echo esc_url( amp_get_permalink( get_the_ID() ) ); ?>">
<?php endif; ?>
<?php
}
/**
* Prepends template hierarchy with template_dir for AMP transitional mode templates.
*
* @param array $templates Template hierarchy.
* @return array Templates.
*/
public static function filter_amp_template_hierarchy( $templates ) {
$args = self::get_theme_support_args();
if ( isset( $args['template_dir'] ) ) {
$amp_templates = [];
foreach ( $templates as $template ) {
$amp_templates[] = $args['template_dir'] . '/' . $template; // Let template_dir have precedence.
$amp_templates[] = $template;
}
$templates = $amp_templates;
}
return $templates;
}
/**
* Get canonical URL for current request.
*
* @see rel_canonical()
* @global WP $wp
* @global WP_Rewrite $wp_rewrite
* @link https://www.ampproject.org/docs/reference/spec#canon.
* @link https://core.trac.wordpress.org/ticket/18660
*
* @return string Canonical non-AMP URL.
*/
public static function get_current_canonical_url() {
global $wp, $wp_rewrite;
$url = null;
if ( is_singular() ) {
$url = wp_get_canonical_url();
}
// For non-singular queries, make use of the request URI and public query vars to determine canonical URL.
if ( empty( $url ) ) {
$added_query_vars = $wp->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 );
}
return amp_remove_endpoint( $url );
}
/**
* Get the ID for the amp-state.
*
* @since 0.7
*
* @param int $post_id Post ID.
* @return string ID for amp-state.
*/
public static function get_comment_form_state_id( $post_id ) {
return sprintf( 'commentform_post_%d', $post_id );
}
/**
* Filter comment form args to an element with [text] AMP binding wrap the title reply.
*
* @since 0.7
* @see comment_form()
*
* @param array $args Comment form args.
* @return array Filtered comment form args.
*/
public static function filter_comment_form_defaults( $args ) {
$state_id = self::get_comment_form_state_id( get_the_ID() );
$text_binding = sprintf(
'%s.replyToName ? %s : %s',
$state_id,
str_replace(
'%s',
sprintf( '" + %s.replyToName + "', $state_id ),
wp_json_encode( $args['title_reply_to'], JSON_UNESCAPED_UNICODE )
),
wp_json_encode( $args['title_reply'], JSON_UNESCAPED_UNICODE )
);
$args['title_reply_before'] .= sprintf(
'<span [text]="%s">',
esc_attr( $text_binding )
);
$args['cancel_reply_before'] = '</span>' . $args['cancel_reply_before'];
return $args;
}
/**
* Modify the comment reply link for AMP.
*
* @since 0.7
* @see get_comment_reply_link()
*
* @param string $link The HTML markup for the comment reply link.
* @param array $args An array of arguments overriding the defaults.
* @param WP_Comment $comment The object of the comment being replied.
* @return string Comment reply link.
*/
public static function filter_comment_reply_link( $link, $args, $comment ) {
// Continue to show default link to wp-login when user is not logged-in.
if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) {
return $args['before'] . $link . $args['after'];
}
$state_id = self::get_comment_form_state_id( get_the_ID() );
$tap_state = [
$state_id => [
'replyToName' => $comment->comment_author,
'values' => [
'comment_parent' => (string) $comment->comment_ID,
],
],
];
// @todo Figure out how to support add_below. Instead of moving the form, what about letting the form get a fixed position?
$link = sprintf(
'<a rel="nofollow" class="comment-reply-link" href="%s" on="%s" aria-label="%s">%s</a>',
esc_attr( '#' . $args['respond_id'] ),
esc_attr( sprintf( 'tap:AMP.setState( %s )', wp_json_encode( $tap_state, JSON_UNESCAPED_UNICODE ) ) ),
esc_attr( sprintf( $args['reply_to_text'], $comment->comment_author ) ),
$args['reply_text']
);
return $args['before'] . $link . $args['after'];
}
/**
* Filters the cancel comment reply link HTML.
*
* @since 0.7
* @see get_cancel_comment_reply_link()
*
* @param string $formatted_link The HTML-formatted cancel comment reply link.
* @param string $link Cancel comment reply link URL.
* @param string $text Cancel comment reply link text.
* @return string Cancel reply link.
*/
public static function filter_cancel_comment_reply_link( $formatted_link, $link, $text ) {
if ( empty( $text ) ) {
$text = __( 'Click here to cancel reply.', 'default' );
}
$state_id = self::get_comment_form_state_id( get_the_ID() );
$tap_state = [
$state_id => [
'replyToName' => '',
'values' => [
'comment_parent' => '0',
],
],
];
$respond_id = 'respond'; // Hard-coded in comment_form() and default value in get_comment_reply_link().
return sprintf(
'<a id="cancel-comment-reply-link" href="%s" %s [hidden]="%s" on="%s">%s</a>',
esc_url( remove_query_arg( 'replytocom' ) . '#' . $respond_id ),
isset( $_GET['replytocom'] ) ? '' : ' hidden', // phpcs:ignore
esc_attr( sprintf( '%s.values.comment_parent == "0"', self::get_comment_form_state_id( get_the_ID() ) ) ),
esc_attr( sprintf( 'tap:AMP.setState( %s )', wp_json_encode( $tap_state, JSON_UNESCAPED_UNICODE ) ) ),
esc_html( $text )
);
}
/**
* Configure the admin bar for AMP.
*
* @since 1.0
*/
public static function init_admin_bar() {
add_filter( 'style_loader_tag', [ __CLASS__, 'filter_admin_bar_style_loader_tag' ], 10, 2 );
add_filter( 'script_loader_tag', [ __CLASS__, 'filter_admin_bar_script_loader_tag' ], 10, 2 );
// Inject the data-ampdevmode attribute into the admin bar bump style. See \WP_Admin_Bar::initialize().
if ( current_theme_supports( 'admin-bar' ) ) {
$admin_bar_args = get_theme_support( 'admin-bar' );
$header_callback = $admin_bar_args[0]['callback'];
} else {
$header_callback = '_admin_bar_bump_cb';
}
remove_action( 'wp_head', $header_callback );
if ( '__return_false' !== $header_callback ) {
ob_start();
$header_callback();
$style = ob_get_clean();
$data = trim( preg_replace( '#<style[^>]*>(.*)</style>#is', '$1', $style ) ); // See wp_add_inline_style().
// Override AMP's position:relative on the body for the sake of the AMP viewer, which is not relevant an an Admin Bar context.
if ( amp_is_dev_mode() ) {
$data .= 'html:not(#_) > body { position:unset !important; }';
}
wp_add_inline_style( 'admin-bar', $data );
}
// Emulate customize support script in PHP, to assume Customizer.
add_action(
'admin_bar_menu',
static function() {
remove_action( 'wp_before_admin_bar_render', 'wp_customize_support_script' );
},
41
);
add_filter(
'body_class',
static function( $body_classes ) {
return array_merge(
array_diff(
$body_classes,
[ 'no-customize-support' ]
),
[ 'customize-support' ]
);
}
);
}
/**
* Recursively determine if a given dependency depends on another.
*
* @since 1.3
*
* @param WP_Dependencies $dependencies Dependencies.
* @param string $current_handle Current handle.
* @param string $dependency_handle Dependency handle.
* @return bool Whether the current handle is a dependency of the dependency handle.
*/
protected static function has_dependency( WP_Dependencies $dependencies, $current_handle, $dependency_handle ) {
if ( $current_handle === $dependency_handle ) {
return true;
}
if ( ! isset( $dependencies->registered[ $current_handle ] ) ) {
return false;
}
foreach ( $dependencies->registered[ $current_handle ]->deps as $handle ) {
if ( self::has_dependency( $dependencies, $handle, $dependency_handle ) ) {
return true;
}
}
return false;
}
/**
* Add data-ampdevmode attribute to any enqueued style that depends on the admin-bar.
*
* @since 1.3
*
* @param string $tag The link tag for the enqueued style.
* @param string $handle The style's registered handle.
* @return string Tag.
*/
public static function filter_admin_bar_style_loader_tag( $tag, $handle ) {
if ( 'dashicons' === $handle ) {
// Conditionally include Dashicons in dev mode only if was included because it is a dependency of admin-bar.
$needs_dev_mode = true;
foreach ( wp_styles()->queue as $queued_handle ) {
if (
// If a theme or plugin directly enqueued dashicons, then it is not added via admin-bar dependency and it is not part of dev mode.
'dashicons' === $queued_handle
||
// If a stylesheet has dashicons as a dependency without also having admin-bar as a dependency, then no dev mode.
(
self::has_dependency( wp_styles(), $queued_handle, 'dashicons' )
&&
! self::has_dependency( wp_styles(), $queued_handle, 'admin-bar' )
)
) {
$needs_dev_mode = false;
break;
}
}
} else {
$needs_dev_mode = self::has_dependency( wp_styles(), $handle, 'admin-bar' );
}
if ( $needs_dev_mode ) {
$tag = preg_replace( '/(?<=<link)(?=\s|>)/i', ' ' . AMP_Rule_Spec::DEV_MODE_ATTRIBUTE, $tag );
}
return $tag;
}
/**
* Add data-ampdevmode attribute to any enqueued script that depends on the admin-bar.
*
* @since 1.3
*
* @param string $tag The `<script>` tag for the enqueued script.
* @param string $handle The script's registered handle.
* @return string Tag.
*/
public static function filter_admin_bar_script_loader_tag( $tag, $handle ) {
if ( self::has_dependency( wp_scripts(), $handle, 'admin-bar' ) ) {
$tag = preg_replace( '/(?<=<script)(?=\s|>)/i', ' ' . AMP_Rule_Spec::DEV_MODE_ATTRIBUTE, $tag );
}
return $tag;
}
/**
* Ensure the markup exists as required by AMP and elements are in the optimal loading order.
*
* Ensure meta[charset], meta[name=viewport], and link[rel=canonical] exist, as the whitelist sanitizer
* may have removed an illegal meta[http-equiv] or meta[name=viewport]. For a singular post, core only outputs a
* canonical URL by default. Adds the preload links.
*
* @since 0.7
* @link https://www.ampproject.org/docs/reference/spec#required-markup
* @link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/
* @todo All of this might be better placed inside of a sanitizer.
* @todo Consider removing any scripts that are not among the $script_handles.
*
* @param DOMDocument $dom Document.
* @param string[] $script_handles AMP script handles for components identified during output buffering.
*/
public static function ensure_required_markup( DOMDocument $dom, $script_handles = [] ) {
/**
* Elements.
*
* @var DOMElement $meta
* @var DOMElement $script
* @var DOMElement $link
* @var DOMElement $style
* @var DOMElement $noscript
*/
$xpath = new DOMXPath( $dom );
// Make sure the HEAD element is in the doc.
$head = $dom->getElementsByTagName( 'head' )->item( 0 );
if ( ! $head ) {
$head = $dom->createElement( 'head' );
$dom->documentElement->insertBefore( $head, $dom->documentElement->firstChild );
}
// Ensure there is a schema.org script in the document.
// @todo Consider applying the amp_schemaorg_metadata filter on the contents when a script is already present.
$schema_org_meta_script = $xpath->query( '//script[ @type = "application/ld+json" ][ contains( ./text(), "schema.org" ) ]' )->item( 0 );
if ( ! $schema_org_meta_script ) {
$script = $dom->createElement( 'script' );
$script->setAttribute( 'type', 'application/ld+json' );
$script->appendChild( $dom->createTextNode( wp_json_encode( amp_get_schemaorg_metadata(), JSON_UNESCAPED_UNICODE ) ) );
$head->appendChild( $script );
}
// Gather all links.
$links = [
'preconnect' => [
// Include preconnect link for AMP CDN for browsers that don't support preload.
AMP_DOM_Utils::create_node(
$dom,
'link',
[
'rel' => 'preconnect',
'href' => 'https://cdn.ampproject.org',
]
),
],
];
$link_elements = $head->getElementsByTagName( 'link' );
foreach ( $link_elements as $link ) {
if ( $link->hasAttribute( 'rel' ) ) {
$links[ $link->getAttribute( 'rel' ) ][] = $link;
}
}
// Ensure rel=canonical link.
$rel_canonical = null;
if ( empty( $links['canonical'] ) ) {
$rel_canonical = AMP_DOM_Utils::create_node(
$dom,
'link',
[
'rel' => 'canonical',
'href' => self::get_current_canonical_url(),
]
);
$head->appendChild( $rel_canonical );
}
/*
* Ensure meta charset and meta viewport are present.
*
* "AMP is already quite restrictive about which markup is allowed in the <head> section. However,
* there are a few basic optimizations that you can apply. The key is to structure the <head> section
* in a way so that all render-blocking scripts and custom fonts load as fast as possible."
*
* "1. The first tag should be the meta charset tag, followed by any remaining meta tags."
*
* {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ Optimize the AMP Runtime loading}
*/
$meta_charset = null;
$meta_viewport = null;
$meta_amp_script_srcs = [];
$meta_elements = [];
foreach ( $head->getElementsByTagName( 'meta' ) as $meta ) {
if ( $meta->hasAttribute( 'charset' ) ) { // There will not be a meta[http-equiv] because the sanitizer removed it.
$meta_charset = $meta;
} elseif ( 'viewport' === $meta->getAttribute( 'name' ) ) {
$meta_viewport = $meta;
} elseif ( 'amp-script-src' === $meta->getAttribute( 'name' ) ) {
$meta_amp_script_srcs[] = $meta;
} else {
$meta_elements[] = $meta;
}
}
// Handle meta charset.
if ( ! $meta_charset ) {
// Warning: This probably means the character encoding needs to be converted.
$meta_charset = AMP_DOM_Utils::create_node(
$dom,
'meta',
[
'charset' => 'utf-8',
]
);
} else {
$head->removeChild( $meta_charset ); // So we can move it.
}
$head->insertBefore( $meta_charset, $head->firstChild );
// Handle meta viewport.
if ( ! $meta_viewport ) {
$meta_viewport = AMP_DOM_Utils::create_node(
$dom,
'meta',
[
'name' => 'viewport',
'content' => 'width=device-width',
]
);
} else {
$head->removeChild( $meta_viewport ); // So we can move it.
}
$head->insertBefore( $meta_viewport, $meta_charset->nextSibling );
// Handle meta amp-script-src elements.
$first_meta_amp_script_src = array_shift( $meta_amp_script_srcs );
if ( $first_meta_amp_script_src ) {
$meta_elements[] = $first_meta_amp_script_src;
// Merge (and remove) any subsequent meta amp-script-src elements.
if ( ! empty( $meta_amp_script_srcs ) ) {
$content_values = [ $first_meta_amp_script_src->getAttribute( 'content' ) ];
foreach ( $meta_amp_script_srcs as $meta_amp_script_src ) {
$meta_amp_script_src->parentNode->removeChild( $meta_amp_script_src );
$content_values[] = $meta_amp_script_src->getAttribute( 'content' );
}
$first_meta_amp_script_src->setAttribute( 'content', implode( ' ', $content_values ) );
unset( $meta_amp_script_src, $content_values );
}
}
unset( $meta_amp_script_srcs, $first_meta_amp_script_src );
// Insert all the the meta elements next in the head.
$previous_node = $meta_viewport;
foreach ( $meta_elements as $meta_element ) {
$meta_element->parentNode->removeChild( $meta_element );
$head->insertBefore( $meta_element, $previous_node->nextSibling );
$previous_node = $meta_element;
}
// Handle the title.
$title = $head->getElementsByTagName( 'title' )->item( 0 );
if ( $title ) {
$title->parentNode->removeChild( $title ); // So we can move it.
$head->insertBefore( $title, $previous_node->nextSibling );
$previous_node = $title;
}
// @see https://github.com/ampproject/amphtml/blob/2fd30ca984bceac05905bd5b17f9e0010629d719/src/render-delaying-services.js#L39-L43 AMPHTML Render Delaying Services SERVICES definition.
$render_delaying_extensions = [
'amp-experiment',
'amp-dynamic-css-classes',
'amp-story',
];
// Obtain the existing AMP scripts.
$amp_scripts = [];
$ordered_scripts = [];
$head_scripts = [];
$runtime_src = wp_scripts()->registered['amp-runtime']->src;
foreach ( $head->getElementsByTagName( 'script' ) as $script ) { // Note that prepare_response() already moved body scripts to head.
$head_scripts[] = $script;
}
foreach ( $head_scripts as $script ) {
$src = $script->getAttribute( 'src' );
if ( ! $src || 'https://cdn.ampproject.org/' !== substr( $src, 0, 27 ) ) {
continue;
}
if ( $runtime_src === $src ) {
$amp_scripts['amp-runtime'] = $script;
} elseif ( $script->hasAttribute( 'custom-element' ) ) {
$amp_scripts[ $script->getAttribute( 'custom-element' ) ] = $script;
} elseif ( $script->hasAttribute( 'custom-template' ) ) {
$amp_scripts[ $script->getAttribute( 'custom-template' ) ] = $script;
} else {
continue;
}
$script->parentNode->removeChild( $script ); // So we can move it.
}
// Create scripts for any components discovered from output buffering.
foreach ( array_diff( $script_handles, array_keys( $amp_scripts ) ) as $missing_script_handle ) {
if ( ! wp_script_is( $missing_script_handle, 'registered' ) ) {
continue;
}
$attrs = [
'src' => wp_scripts()->registered[ $missing_script_handle ]->src,
'async' => '',
];
if ( 'amp-mustache' === $missing_script_handle ) {
$attrs['custom-template'] = $missing_script_handle;
} else {
$attrs['custom-element'] = $missing_script_handle;
}
$amp_scripts[ $missing_script_handle ] = AMP_DOM_Utils::create_node( $dom, 'script', $attrs );
}
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
*
* "2. Next, preload the AMP runtime v0.js <script> tag with <link as=script href=https://cdn.ampproject.org/v0.js rel=preload>.
* The AMP runtime should start downloading as soon as possible because the AMP boilerplate hides the document via body { visibility:hidden }
* until the AMP runtime has loaded. Preloading the AMP runtime tells the browser to download the script with a higher priority."
* {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ Optimize the AMP Runtime loading}
*/
$prioritized_preloads = [];
if ( ! isset( $links['preload'] ) ) {
$links['preload'] = [];
}
$prioritized_preloads[] = AMP_DOM_Utils::create_node(
$dom,
'link',
[
'rel' => 'preload',
'as' => 'script',
'href' => $runtime_src,
]
);
/*
* "3. If your page includes render-delaying extensions (e.g., amp-experiment, amp-dynamic-css-classes, amp-story),
* preload those extensions as they're required by the AMP runtime for rendering the page."
*/
$amp_script_handles = array_keys( $amp_scripts );
foreach ( array_intersect( $render_delaying_extensions, $amp_script_handles ) as $script_handle ) {
if ( ! in_array( $script_handle, $render_delaying_extensions, true ) ) {
continue;
}
$prioritized_preloads[] = AMP_DOM_Utils::create_node(
$dom,
'link',
[
'rel' => 'preload',
'as' => 'script',
'href' => $amp_scripts[ $script_handle ]->getAttribute( 'src' ),
]
);
}
$links['preload'] = array_merge( $prioritized_preloads, $links['preload'] );
/*
* "4. Use preconnect to speedup the connection to other origin where the full resource URL is not known ahead of time,
* for example, when using Google Fonts."
*
* Note that \AMP_Style_Sanitizer::process_link_element() will ensure preconnect links for Google Fonts are present.
*/
$link_relations = [ 'preconnect', 'dns-prefetch', 'preload', 'prerender', 'prefetch' ];
foreach ( $link_relations as $rel ) {
if ( ! isset( $links[ $rel ] ) ) {
continue;
}
foreach ( $links[ $rel ] as $link ) {
if ( $link->parentNode ) {
$link->parentNode->removeChild( $link ); // So we can move it.
}
$head->insertBefore( $link, $previous_node->nextSibling );
$previous_node = $link;
}
}
// "5. Load the AMP runtime."
if ( isset( $amp_scripts['amp-runtime'] ) ) {
$ordered_scripts['amp-runtime'] = $amp_scripts['amp-runtime'];
unset( $amp_scripts['amp-runtime'] );
} else {
$script = $dom->createElement( 'script' );
$script->setAttribute( 'async', '' );
$script->setAttribute( 'src', $runtime_src );
$ordered_scripts['amp-runtime'] = $script;
}
/*
* "6. Specify the <script> tags for render-delaying extensions (e.g., amp-experiment amp-dynamic-css-classes and amp-story"
*
* {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ AMP Hosting Guide}
*/
foreach ( $render_delaying_extensions as $extension ) {
if ( isset( $amp_scripts[ $extension ] ) ) {
$ordered_scripts[ $extension ] = $amp_scripts[ $extension ];
unset( $amp_scripts[ $extension ] );
}
}
/*
* "7. Specify the <script> tags for remaining extensions (e.g., amp-bind ...). These extensions are not render-delaying
* and therefore should not be preloaded as they might take away important bandwidth for the initial render."
*/
$ordered_scripts = array_merge( $ordered_scripts, $amp_scripts );
foreach ( $ordered_scripts as $ordered_script ) {
$head->insertBefore( $ordered_script, $previous_node->nextSibling );
$previous_node = $ordered_script;
}
/*
* "8. Specify any custom styles by using the <style amp-custom> tag."
*/
$style = $xpath->query( './style[ @amp-custom ]', $head )->item( 0 );
if ( $style ) {
// Ensure the CSS manifest comment remains before style[amp-custom].
if ( $style->previousSibling instanceof DOMComment ) {
$comment = $style->previousSibling;
$comment->parentNode->removeChild( $comment );
$head->insertBefore( $comment, $previous_node->nextSibling );
$previous_node = $comment;
}
$style->parentNode->removeChild( $style );
$head->insertBefore( $style, $previous_node->nextSibling );
$previous_node = $style;
}
/*
* "9. Add any other tags allowed in the <head> section. In particular, any external fonts should go last since
* they block rendering."
*/
/*
* "10. Finally, specify the AMP boilerplate code. By putting the boilerplate code last, it prevents custom styles
* from accidentally overriding the boilerplate css rules."
*/
$style = $xpath->query( './style[ @amp-boilerplate ]', $head )->item( 0 );
if ( ! $style ) {
$style = $dom->createElement( 'style' );
$style->setAttribute( 'amp-boilerplate', '' );
$style->appendChild( $dom->createTextNode( amp_get_boilerplate_stylesheets()[0] ) );
} else {
$style->parentNode->removeChild( $style ); // So we can move it.
}
$head->appendChild( $style );
$noscript = $xpath->query( './noscript[ style[ @amp-boilerplate ] ]', $head )->item( 0 );
if ( ! $noscript ) {
$noscript = $dom->createElement( 'noscript' );
$style = $dom->createElement( 'style' );
$style->setAttribute( 'amp-boilerplate', '' );
$style->appendChild( $dom->createTextNode( amp_get_boilerplate_stylesheets()[1] ) );
$noscript->appendChild( $style );
} else {
$noscript->parentNode->removeChild( $noscript ); // So we can move it.
}
$head->appendChild( $noscript );
unset( $previous_node );
}
/**
* Dequeue Customizer assets which are not necessary outside the preview iframe.
*
* Prevent enqueueing customize-preview styles if not in customizer preview iframe.
* These are only needed for when there is live editing of content, such as selective refresh.
*
* @since 0.7
*/
public static function dequeue_customize_preview_scripts() {
// Dequeue styles unnecessary unless in customizer preview iframe when editing (such as for edit shortcuts).
if ( ! self::is_customize_preview_iframe() ) {
wp_dequeue_style( 'customize-preview' );
foreach ( wp_styles()->registered as $handle => $dependency ) {
if ( in_array( 'customize-preview', $dependency->deps, true ) ) {
wp_dequeue_style( $handle );
}
}
}
}
/**
* Start output buffering.
*
* @since 0.7
* @see AMP_Theme_Support::finish_output_buffering()
*/
public static function start_output_buffering() {
/*
* Disable the New Relic Browser agent on AMP responses.
* This prevents the New Relic from causing invalid AMP responses due the NREUM script it injects after the meta charset:
* https://docs.newrelic.com/docs/browser/new-relic-browser/troubleshooting/google-amp-validator-fails-due-3rd-party-script
* Sites with New Relic will need to specially configure New Relic for AMP:
* https://docs.newrelic.com/docs/browser/new-relic-browser/installation/monitor-amp-pages-new-relic-browser
*/
if ( function_exists( 'newrelic_disable_autorum' ) ) {
newrelic_disable_autorum();
}
ob_start( [ __CLASS__, 'finish_output_buffering' ] );
self::$is_output_buffering = true;
}
/**
* Determine whether output buffering has started.
*
* @since 0.7
* @see AMP_Theme_Support::start_output_buffering()
* @see AMP_Theme_Support::finish_output_buffering()
*
* @return bool Whether output buffering has started.
*/
public static function is_output_buffering() {
return self::$is_output_buffering;
}
/**
* Finish output buffering.
*
* @since 0.7
* @see AMP_Theme_Support::start_output_buffering()
*
* @param string $response Buffered Response.
* @return string Processed Response.
*/
public static function finish_output_buffering( $response ) {
self::$is_output_buffering = false;
return self::prepare_response( $response );
}
/**
* Filter rendered partial to convert to AMP.
*
* @see WP_Customize_Partial::render()
*
* @param string|mixed $partial Rendered partial.
* @return string|mixed Filtered partial.
* @global int $content_width
*/
public static function filter_customize_partial_render( $partial ) {
global $content_width;
if ( is_string( $partial ) && preg_match( '/<\w/', $partial ) ) {
$dom = AMP_DOM_Utils::get_dom_from_content( $partial );
$args = [
'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat.
'use_document_element' => false,
'allow_dirty_styles' => true,
'allow_dirty_scripts' => false,
];
AMP_Content_Sanitizer::sanitize_document( $dom, self::$sanitizer_classes, $args ); // @todo Include script assets in response?
$partial = AMP_DOM_Utils::get_content_from_dom( $dom );
}
return $partial;
}
/**
* Process response to ensure AMP validity.
*
* @since 0.7
*
* @param string $response HTML document response. By default it expects a complete document.
* @param array $args Args to send to the preprocessor/sanitizer.
* @return string AMP document response.
* @global int $content_width
*/
public static function prepare_response( $response, $args = [] ) {
global $content_width;
$prepare_response_start = microtime( true );
if ( isset( $args['validation_error_callback'] ) ) {
_doing_it_wrong( __METHOD__, 'Do not supply validation_error_callback arg.', '1.0' );
unset( $args['validation_error_callback'] );
}
$status_code = http_response_code();
/*
* Send a JSON response when the site is failing to handle AMP form submissions with a JSON response as required
* or an AMP-Redirect-To response header was not sent. This is a common scenario for plugins that handle form
* submissions and show the success page via the POST request's response body instead of invoking wp_redirect(),
* in which case AMP_HTTP::intercept_post_request_redirect() will automatically send the AMP-Redirect-To header.
* If the POST response is an HTML document then the form submission will appear to not have worked since there
* is no success or failure message shown. By catching the case where HTML is sent in the response, we can
* automatically send a generic success message when a 200 status is returned or a failure message when a 400+
* response code is sent.
*/
$is_form_submission = (
isset( AMP_HTTP::$purged_amp_query_vars[ AMP_HTTP::ACTION_XHR_CONVERTED_QUERY_VAR ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
&&
isset( $_SERVER['REQUEST_METHOD'] )
&&
'POST' === $_SERVER['REQUEST_METHOD']
);
if ( $is_form_submission && null === json_decode( $response ) && json_last_error() && ( is_bool( $status_code ) || ( $status_code >= 200 && $status_code < 300 ) || $status_code >= 400 ) ) {
if ( is_bool( $status_code ) ) {
$status_code = 200; // Not a web server environment.
}
return wp_json_encode(
[
'status_code' => $status_code,
'status_text' => get_status_header_desc( $status_code ),
]
);
}
/*
* Abort if the response was not HTML. To be post-processed as an AMP page, the output-buffered document must
* have the HTML mime type and it must start with <html> followed by <head> tag (with whitespace, doctype, and comments optionally interspersed).
*/
if ( 'text/html' !== substr( AMP_HTTP::get_response_content_type(), 0, 9 ) || ! preg_match( '#^(?:<!.*?>|\s+)*<html.*?>(?:<!.*?>|\s+)*<head\b(.*?)>#is', $response ) ) {
return $response;
}
/**
* Filters whether response (post-processor) caching is enabled.
*
* When enabled and when an external object cache is present, the output of the post-processor phase is stored in
* in the object cache. When another request is made that generates the same HTML output, the previously-cached
* post-processor output will then be served immediately and bypass needlessly re-running the sanitizers.
* This does not apply when:
*
* - AMP validation is being performed.
* - The response is in the Customizer preview.
* - Response caching is disabled due to a high-rate of cache misses.
*
* @param bool $enable_response_caching Whether response caching is enabled.
*/
$enable_response_caching = apply_filters( 'amp_response_caching_enabled', ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ! empty( $args['enable_response_caching'] ) );
$enable_response_caching = (
$enable_response_caching
&&
! AMP_Validation_Manager::should_validate_response()
&&
! is_customize_preview()
);
// When response caching is enabled, determine if it should be turned off for cache misses.
$caches_for_url = null;
if ( $enable_response_caching ) {
list( $disable_response_caching, $caches_for_url ) = self::check_for_cache_misses();
$enable_response_caching = ! $disable_response_caching;
}
// @todo Both allow_dirty_styles and allow_dirty_scripts should eventually use AMP dev mode instead.
$args = array_merge(
[
'content_max_width' => ! empty( $content_width ) ? $content_width : AMP_Post_Template::CONTENT_MAX_WIDTH, // Back-compat.
'use_document_element' => true,
'allow_dirty_styles' => self::is_customize_preview_iframe(), // Dirty styles only needed when editing (e.g. for edit shortcuts).
'allow_dirty_scripts' => is_customize_preview(), // Scripts are always needed to inject changeset UUID.
'user_can_validate' => AMP_Validation_Manager::has_cap(),
],
$args,
compact( 'enable_response_caching' )
);
$current_url = amp_get_current_url();
$non_amp_url = amp_remove_endpoint( $current_url );
/*
* Set response cache hash, the data values dictates whether a new hash key should be generated or not.
* This is also used as the ETag.
*/
$response_cache_key = md5(
wp_json_encode(
[
$args,
$response,
self::$sanitizer_classes,
self::$embed_handlers,
AMP__VERSION,
]
)
);
/*
* Per rfc7232:
* "The server generating a 304 response MUST generate any of the
* following header fields that would have been sent in a 200 (OK)
* response to the same request: Cache-Control, Content-Location, Date,
* ETag, Expires, and Vary." The only one of these headers which would
* not have been set yet during the WordPress template generation is
* the ETag. The AMP plugin sends a Vary header at amp_init.
*/
AMP_HTTP::send_header( 'ETag', '"' . $response_cache_key . '"' );
/*
* Handle responses that are cached by the browser, returning 304 response if the response cache key
* matches any ETags mentioned in If-None-Match request header. Note that if the client request indicates a
* weak validator (prefixed by W/) then this will be ignored. The MD5 strings will be extracted from the
* If-None-Match request header and if any of them match the $response_cache_key then a 304 Not Modified
* response is returned.
*
* Such 304 Not Modified responses are only enabled when using a stable release. This is not enabled for
* non-stable releases (like 1.2-beta2) because the plugin would be under active development and such caching
* would make it more difficult to see changes applied to the sanitizers. (A browser's cache would have to be
* disabled or the developer would have to always do hard reloads.)
*/
$has_matching_etag = (
false === strpos( AMP__VERSION, '-' )
&&
isset( $_SERVER['HTTP_IF_NONE_MATCH'] )
&&
preg_match_all( '#\b[0-9a-f]{32}\b#', wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ), $etag_match_candidates )
&&
in_array( $response_cache_key, $etag_match_candidates[0], true )
);
if ( $has_matching_etag ) {
status_header( 304 );
return '';
}
// Return cache if enabled and found.
$cache_response = null;
if ( true === $args['enable_response_caching'] ) {
$response_cache = wp_cache_get( $response_cache_key, self::RESPONSE_CACHE_GROUP );
// Make sure that all of the validation errors should be sanitized in the same way; if not, then the cached body should be discarded.
$blocking_error_count = 0;
if ( isset( $response_cache['validation_results'] ) ) {
foreach ( $response_cache['validation_results'] as $validation_result ) {
if ( ! $validation_result['sanitized'] ) {
$blocking_error_count++;
}
$should_sanitize = AMP_Validation_Error_Taxonomy::is_validation_error_sanitized( $validation_result['error'] );
if ( $should_sanitize !== $validation_result['sanitized'] ) {
unset( $response_cache['body'] );
break;
}
}
}
// Short-circuit response with cached body.
if ( isset( $response_cache['body'] ) ) {
// Re-send the headers that were sent before when the response was first cached.
if ( isset( $response_cache['headers'] ) ) {
foreach ( $response_cache['headers'] as $header ) {
if ( in_array( $header, AMP_HTTP::$headers_sent, true ) ) {
continue; // Skip sending headers that were already sent prior to post-processing.
}
AMP_HTTP::send_header( $header['name'], $header['value'], wp_array_slice_assoc( $header, [ 'replace', 'status_code' ] ) );
}
}
AMP_HTTP::send_server_timing( 'amp_processor_cache_hit', -$prepare_response_start );
// Redirect to non-AMP version.
if ( ! amp_is_canonical() && ! is_singular( AMP_Story_Post_Type::POST_TYPE_SLUG ) && $blocking_error_count > 0 ) {
if ( AMP_Validation_Manager::has_cap() ) {
$non_amp_url = add_query_arg( AMP_Validation_Manager::VALIDATION_ERRORS_QUERY_VAR, $blocking_error_count, $non_amp_url );
}
/*
* Temporary redirect because AMP page may return with blocking validation errors when auto-accepting sanitization
* is not enabled. A 302 will allow the errors to be fixed without needing to bust any redirect caches.
*/
wp_safe_redirect( $non_amp_url, 302 );
}
return $response_cache['body'];
}
$cache_response = static function( $body, $validation_results ) use ( $response_cache_key, $caches_for_url ) {
$caches_for_url[] = $response_cache_key;
wp_cache_set(
AMP_Theme_Support::POST_PROCESSOR_CACHE_EFFECTIVENESS_KEY,
$caches_for_url,
AMP_Theme_Support::POST_PROCESSOR_CACHE_EFFECTIVENESS_GROUP,
600 // 10 minute cache.
);
return wp_cache_set(
$response_cache_key,
[
'headers' => AMP_HTTP::$headers_sent,
'body' => $body,
'validation_results' => $validation_results,
],
AMP_Theme_Support::RESPONSE_CACHE_GROUP,
MONTH_IN_SECONDS
);
};
}
AMP_HTTP::send_server_timing( 'amp_output_buffer', -self::$init_start_time, 'AMP Output Buffer' );
$dom_parse_start = microtime( true );
/*
* Make sure that <meta charset> is present in output prior to parsing.
* Note that the meta charset is supposed to appear within the first 1024 bytes.
* See <https://www.w3.org/International/questions/qa-html-encoding-declarations>.
*/
if ( ! preg_match( '#<meta[^>]+charset=#i', substr( $response, 0, 1024 ) ) ) {
$meta_charset = sprintf( '<meta charset="%s">', esc_attr( get_bloginfo( 'charset' ) ) );
$response = preg_replace(
'/(<head\b.*?>)/is',
'$1' . $meta_charset,
$response,
1,
$count
);
}
$dom = AMP_DOM_Utils::get_dom( $response );
$xpath = new DOMXPath( $dom );
$head = $dom->getElementsByTagName( 'head' )->item( 0 );
// Move anything after </html>, such as Query Monitor output added at shutdown, to be moved before </body>.
$body = $dom->getElementsByTagName( 'body' )->item( 0 );
if ( $body ) {
while ( $dom->documentElement->nextSibling ) {
// Trailing elements after </html> will get wrapped in additional <html> elements.
if ( 'html' === $dom->documentElement->nextSibling->nodeName ) {
while ( $dom->documentElement->nextSibling->firstChild ) {
$body->appendChild( $dom->documentElement->nextSibling->firstChild );
}
$dom->removeChild( $dom->documentElement->nextSibling );
} else {
$body->appendChild( $dom->documentElement->nextSibling );
}
}
}
AMP_HTTP::send_server_timing( 'amp_dom_parse', -$dom_parse_start, 'AMP DOM Parse' );
// Make sure scripts from the body get moved to the head.
if ( isset( $head ) ) {
foreach ( $xpath->query( '//body//script[ @custom-element or @custom-template or @src = "https://cdn.ampproject.org/v0.js" ]' ) as $script ) {
$head->appendChild( $script->parentNode->removeChild( $script ) );
}
}
// Ensure the mandatory amp attribute is present on the html element.
if ( ! $dom->documentElement->hasAttribute( 'amp' ) && ! $dom->documentElement->hasAttribute( '⚡' ) ) {
$dom->documentElement->setAttribute( 'amp', '' );
}
$assets = AMP_Content_Sanitizer::sanitize_document( $dom, self::$sanitizer_classes, $args );
// Determine what the validation errors are.
$blocking_error_count = 0;
$validation_results = [];
foreach ( AMP_Validation_Manager::$validation_results as $validation_result ) {
if ( ! $validation_result['sanitized'] ) {
$blocking_error_count++;
}
unset( $validation_result['error']['sources'] );
$validation_results[] = $validation_result;
}
$dom_serialize_start = microtime( true );
// Gather all component scripts that are used in the document and then render any not already printed.
$amp_scripts = $assets['scripts'];
foreach ( self::$embed_handlers as $embed_handler ) {
$amp_scripts = array_merge(
$amp_scripts,
$embed_handler->get_scripts()
);
}
foreach ( $amp_scripts as $handle => $src ) {
/*
* Make sure the src is up-to-date. This allows for embed handlers to override the
* default extension version by defining a different URL.
*/
if ( is_string( $src ) && wp_script_is( $handle, 'registered' ) ) {
wp_scripts()->registered[ $handle ]->src = $src;
}
}
self::ensure_required_markup( $dom, array_keys( $amp_scripts ) );
if ( $blocking_error_count > 0 && ! AMP_Validation_Manager::should_validate_response() ) {
/*
* In AMP-first, strip html@amp attribute to prevent GSC from complaining about a validation error
* already surfaced inside of WordPress. This is intended to not serve dirty AMP, but rather a
* non-AMP document (intentionally not valid AMP) that contains the AMP runtime and AMP components.
*/
if ( amp_is_canonical() || is_singular( AMP_Story_Post_Type::POST_TYPE_SLUG ) ) {
$dom->documentElement->removeAttribute( 'amp' );
$dom->documentElement->removeAttribute( '⚡' );
/*
* Make sure that document.write() is disabled to prevent dynamically-added content (such as added
* via amp-live-list) from wiping out the page by introducing any scripts that call this function.
*/
if ( $head ) {
$script = $dom->createElement( 'script' );
$script->appendChild( $dom->createTextNode( 'document.addEventListener( "DOMContentLoaded", function() { document.write = function( text ) { throw new Error( "[AMP-WP] Prevented document.write() call with: " + text ); }; } );' ) );
$head->appendChild( $script );
}
} elseif ( ! self::is_customize_preview_iframe() ) {
$response = esc_html__( 'Redirecting to non-AMP version.', 'amp' );
if ( $cache_response ) {
$cache_response( $response, $validation_results );
}
// Indicate the number of validation errors detected at runtime in a query var on the non-AMP page for display in the admin bar.
if ( AMP_Validation_Manager::has_cap() ) {
$non_amp_url = add_query_arg( AMP_Validation_Manager::VALIDATION_ERRORS_QUERY_VAR, $blocking_error_count, $non_amp_url );
}
/*
* Temporary redirect because AMP page may return with blocking validation errors when auto-accepting sanitization
* is not enabled. A 302 will allow the errors to be fixed without needing to bust any redirect caches.
*/
wp_safe_redirect( $non_amp_url, 302 );
return $response;
}
}
// @todo If 'utf-8' is not the blog charset, then we'll need to do some character encoding conversation or "entityification".
if ( 'utf-8' !== strtolower( get_bloginfo( 'charset' ) ) ) {
/* translators: %s: the charset of the current site. */
trigger_error( esc_html( sprintf( __( 'The database has the %s encoding when it needs to be utf-8 to work with AMP.', 'amp' ), get_bloginfo( 'charset' ) ) ), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
}
AMP_Validation_Manager::finalize_validation(
$dom,
[
'remove_source_comments' => ! isset( $_GET['amp_preserve_source_comments'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
]
);
$response = "<!DOCTYPE html>\n";
$response .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement );
AMP_HTTP::send_server_timing( 'amp_dom_serialize', -$dom_serialize_start, 'AMP DOM Serialize' );
// Cache response if enabled.
if ( $cache_response ) {
$cache_response( $response, $validation_results );
}
return $response;
}
/**
* Check for cache misses. When found, store in an option to retain the URL.
*
* @since 1.0
*
* @return array {
* State.
*
* @type bool Flag indicating if the threshold has been exceeded.
* @type string[] Collection of URLs.
* }
*/
private static function check_for_cache_misses() {
// If the cache miss threshold is exceeded, return true.
if ( self::exceeded_cache_miss_threshold() ) {
return [ true, null ];
}
// Get the cache miss URLs.
$cache_miss_urls = wp_cache_get( self::POST_PROCESSOR_CACHE_EFFECTIVENESS_KEY, self::POST_PROCESSOR_CACHE_EFFECTIVENESS_GROUP );
$cache_miss_urls = is_array( $cache_miss_urls ) ? $cache_miss_urls : [];
$exceeded_threshold = (
! empty( $cache_miss_urls )
&&
count( $cache_miss_urls ) >= self::CACHE_MISS_THRESHOLD
);
if ( ! $exceeded_threshold ) {
return [ $exceeded_threshold, $cache_miss_urls ];
}
// When the threshold is exceeded, store the URL for cache miss and turn off response caching.
update_option( self::CACHE_MISS_URL_OPTION, amp_get_current_url() );
AMP_Options_Manager::update_option( 'enable_response_caching', false );
return [ true, null ];
}
/**
* Reset the cache miss URL option.
*
* @since 1.0
*/
public static function reset_cache_miss_url_option() {
if ( get_option( self::CACHE_MISS_URL_OPTION ) ) {
delete_option( self::CACHE_MISS_URL_OPTION );
}
}
/**
* Checks if cache miss threshold has been exceeded.
*
* @since 1.0
*
* @return bool
*/
public static function exceeded_cache_miss_threshold() {
$url = get_option( self::CACHE_MISS_URL_OPTION, false );
return ! empty( $url );
}
/**
* Adds 'data-amp-layout' to the allowed <img> attributes for wp_kses().
*
* @since 0.7
*
* @param array $context Allowed tags and their allowed attributes.
* @return array $context Filtered allowed tags and attributes.
*/
public static function whitelist_layout_in_wp_kses_allowed_html( $context ) {
if ( ! empty( $context['img']['width'] ) && ! empty( $context['img']['height'] ) ) {
$context['img']['data-amp-layout'] = true;
}
return $context;
}
/**
* Enqueue AMP assets if this is an AMP endpoint.
*
* @since 0.7
*
* @return void
*/
public static function enqueue_assets() {
// Enqueue default styles expected by sanitizer.
wp_enqueue_style( 'amp-default', amp_get_asset_url( 'css/amp-default.css' ), [], AMP__VERSION );
wp_styles()->add_data( 'amp-default', 'rtl', 'replace' );
}
/**
* Print the important emoji-related styles.
*
* @see print_emoji_styles()
* @staticvar bool $printed
*/
public static function print_emoji_styles() {
static $printed = false;
if ( $printed ) {
return;
}
$printed = true;
?>
<style type="text/css">
img.wp-smiley,
img.emoji {
display: inline-block !important; /* Patched from core, which had display:inline */
border: none !important;
box-shadow: none !important;
height: 1em !important;
width: 1em !important;
margin: 0 .07em !important;
vertical-align: -0.1em !important;
background: none !important;
padding: 0 !important;
}
</style>
<?php
}
/**
* Conditionally replace the header image markup with a header video or image.
*
* This is JS-driven in Core themes like Twenty Sixteen and Twenty Seventeen.
* So in order for the header video to display, this replaces the markup of the header image.
*
* @since 1.0
* @link https://github.com/WordPress/wordpress-develop/blob/d002fde80e5e3a083e5f950313163f566561517f/src/wp-includes/js/wp-custom-header.js#L54
* @link https://github.com/WordPress/wordpress-develop/blob/d002fde80e5e3a083e5f950313163f566561517f/src/wp-includes/js/wp-custom-header.js#L78
*
* @param string $image_markup The image markup to filter.
* @return string $html Filtered markup.
*/
public static function amend_header_image_with_video_header( $image_markup ) {
// If there is no video, just pass the image through.
if ( ! has_header_video() || ! is_header_video_active() ) {
return $image_markup;
}
$video_settings = get_header_video_settings();
$parsed_url = wp_parse_url( $video_settings['videoUrl'] );
$query = isset( $parsed_url['query'] ) ? wp_parse_args( $parsed_url['query'] ) : [];
$video_attributes = [
'media' => '(min-width: ' . $video_settings['minWidth'] . 'px)',
'width' => $video_settings['width'],
'height' => $video_settings['height'],
'layout' => 'responsive',
'autoplay' => '',
'loop' => '',
'id' => 'wp-custom-header-video',
];
$youtube_id = null;
if ( isset( $parsed_url['host'] ) && preg_match( '/(^|\.)(youtube\.com|youtu\.be)$/', $parsed_url['host'] ) ) {
if ( 'youtu.be' === $parsed_url['host'] && ! empty( $parsed_url['path'] ) ) {
$youtube_id = trim( $parsed_url['path'], '/' );
} elseif ( isset( $query['v'] ) ) {
$youtube_id = $query['v'];
}
}
// If the video URL is for YouTube, return an <amp-youtube> element.
if ( ! empty( $youtube_id ) ) {
$video_markup = AMP_HTML_Utils::build_tag(
'amp-youtube',
array_merge(
$video_attributes,
[
'data-videoid' => $youtube_id,
// For documentation on the params, see <https://developers.google.com/youtube/player_parameters>.
'data-param-rel' => '0', // Don't show related videos.
'data-param-showinfo' => '0', // Don't show video title at the top.
'data-param-controls' => '0', // Don't show video controls.
'data-param-iv_load_policy' => '3', // Suppress annotations.
'data-param-modestbranding' => '1', // Show modest branding.
'data-param-playsinline' => '1', // Prevent fullscreen playback on iOS.
'data-param-disablekb' => '1', // Disable keyboard conttrols.
'data-param-fs' => '0', // Suppress full screen button.
]
)
);
// Hide equalizer video animation.
$video_markup .= '<style>#wp-custom-header-video .amp-video-eq { display:none; }</style>';
} else {
$video_markup = AMP_HTML_Utils::build_tag(
'amp-video',
array_merge(
$video_attributes,
[
'src' => $video_settings['videoUrl'],
]
)
);
}
return $image_markup . $video_markup;
}
}