QueryVarUrlStructure::class, Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX => PathSuffixUrlStructure::class, Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL => LegacyTransitionalUrlStructure::class, Option::PAIRED_URL_STRUCTURE_LEGACY_READER => LegacyReaderUrlStructure::class, ]; /** * Custom paired URL structure. * * This involves a site adding the necessary filters to implement their own paired URL structure. * * @var string */ const PAIRED_URL_STRUCTURE_CUSTOM = 'custom'; /** * Key for AMP paired examples. * * @see amp_get_slug() * @var string */ const PAIRED_URL_EXAMPLES = 'paired_url_examples'; /** * Key for the AMP slug. * * @see amp_get_slug() * @var string */ const AMP_SLUG = 'amp_slug'; /** * REST API field name for entities already using the AMP slug as name. * * @see amp_get_slug() * @var string */ const ENDPOINT_PATH_SLUG_CONFLICTS = 'endpoint_path_slug_conflicts'; /** * REST API field name for whether permalinks are being used in rewrite rules. * * @see WP_Rewrite::using_permalinks() * @var string */ const REWRITE_USING_PERMALINKS = 'rewrite_using_permalinks'; /** * Key for the custom paired structure sources. * * @var string */ const CUSTOM_PAIRED_ENDPOINT_SOURCES = 'custom_paired_endpoint_sources'; /** * Action which is triggered when the late-defined slug needs to be updated in options. * * @var string */ const ACTION_UPDATE_LATE_DEFINED_SLUG_OPTION = 'amp_update_late_defined_slug_option'; /** * Paired URL service. * * @var PairedUrl */ private $paired_url; /** * Paired URL structure. * * @var PairedUrlStructure */ private $paired_url_structure; /** * Callback reflection. * * @var CallbackReflection */ private $callback_reflection; /** * AMP slug customization watcher. * * @var AmpSlugCustomizationWatcher */ private $amp_slug_customization_watcher; /** * Plugin registry. * * @var PluginRegistry */ private $plugin_registry; /** * Injector. * * @var Injector */ private $injector; /** * Whether the request had the /amp/ endpoint suffix. * * @var bool */ private $did_request_endpoint; /** * Current nesting level for the request. * * This is used to capture cases where `WP::parse_request()` is called from inside of a `request` * filter, a case of a nested/recursive request. It is similar in concept to `ob_get_level()` for * output buffering. * * @var int */ private $current_request_nesting_level = 0; /** * Original environment variables that were rewritten before parsing the request. * * @see PairedRouting::detect_endpoint_in_environment() * @see PairedRouting::restore_path_endpoint_in_environment() * @var array */ private $suspended_environment_variables = []; /** * PairedRouting constructor. * * @param Injector $injector Injector. * @param CallbackReflection $callback_reflection Callback reflection. * @param PluginRegistry $plugin_registry Plugin registry. * @param PairedUrl $paired_url Paired URL service. * @param AmpSlugCustomizationWatcher $amp_slug_customization_watcher AMP slug customization watcher. */ public function __construct( Injector $injector, CallbackReflection $callback_reflection, PluginRegistry $plugin_registry, PairedUrl $paired_url, AmpSlugCustomizationWatcher $amp_slug_customization_watcher ) { $this->injector = $injector; $this->callback_reflection = $callback_reflection; $this->plugin_registry = $plugin_registry; $this->paired_url = $paired_url; $this->amp_slug_customization_watcher = $amp_slug_customization_watcher; } /** * Register. */ public function register() { add_filter( 'amp_rest_options_schema', [ $this, 'filter_rest_options_schema' ] ); add_filter( 'amp_rest_options', [ $this, 'filter_rest_options' ] ); add_filter( 'amp_default_options', [ $this, 'filter_default_options' ], 10, 2 ); add_filter( 'amp_options_updating', [ $this, 'sanitize_options' ], 10, 2 ); add_action( self::ACTION_UPDATE_LATE_DEFINED_SLUG_OPTION, [ $this, 'update_late_defined_slug_option' ] ); add_action( AmpSlugCustomizationWatcher::LATE_DETERMINATION_ACTION, [ $this, 'check_stale_late_defined_slug_option' ] ); add_action( 'template_redirect', [ $this, 'redirect_extraneous_paired_endpoint' ], 9 ); // Priority 7 needed to run before PluginSuppression::initialize() at priority 8. add_action( 'plugins_loaded', [ $this, 'initialize_paired_request' ], 7 ); } /** * Get the late defined slug, or null if it was not defined late. * * @return string|null Slug or null. */ public function get_late_defined_slug() { return $this->amp_slug_customization_watcher->did_customize_late() ? amp_get_slug() : null; } /** * Update late-defined slug option. */ public function update_late_defined_slug_option() { AMP_Options_Manager::update_option( Option::LATE_DEFINED_SLUG, $this->get_late_defined_slug() ); } /** * Check whether the late-defined slug option is stale and a single event needs to be scheduled to update it. */ public function check_stale_late_defined_slug_option() { $late_defined_slug = $this->get_late_defined_slug(); if ( AMP_Options_Manager::get_option( Option::LATE_DEFINED_SLUG ) !== $late_defined_slug ) { wp_schedule_single_event( time(), self::ACTION_UPDATE_LATE_DEFINED_SLUG_OPTION ); } } /** * Get the paired URL structure. * * @return PairedUrlStructure Paired URL structure. */ public function get_paired_url_structure() { if ( ! $this->paired_url_structure instanceof PairedUrlStructure ) { /** * Filters to allow a custom paired URL structure to be used. * * @param string $structure_class Paired URL structure class. */ $structure_class = apply_filters( 'amp_custom_paired_url_structure', null ); if ( ! $structure_class || ! is_subclass_of( $structure_class, PairedUrlStructure::class ) ) { $structure_slug = AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ); if ( array_key_exists( $structure_slug, self::PAIRED_URL_STRUCTURES ) ) { $structure_class = self::PAIRED_URL_STRUCTURES[ $structure_slug ]; } else { $structure_class = QueryVarUrlStructure::class; } } $this->paired_url_structure = $this->injector->make( $structure_class ); } return $this->paired_url_structure; } /** * Filter the REST options schema to add items. * * @param array $schema Schema. * @return array Schema. */ public function filter_rest_options_schema( $schema ) { return array_merge( $schema, [ Option::PAIRED_URL_STRUCTURE => [ 'type' => 'string', 'enum' => array_keys( self::PAIRED_URL_STRUCTURES ), ], self::PAIRED_URL_EXAMPLES => [ 'type' => 'object', 'readonly' => true, ], self::AMP_SLUG => [ 'type' => 'string', 'readonly' => true, ], self::ENDPOINT_PATH_SLUG_CONFLICTS => [ 'type' => 'object', 'readonly' => true, ], self::REWRITE_USING_PERMALINKS => [ 'type' => 'boolean', 'readonly' => true, ], ] ); } /** * Filter the REST options to add items. * * @param array $options Options. * @return array Options. */ public function filter_rest_options( $options ) { $options[ self::AMP_SLUG ] = amp_get_slug(); if ( $this->has_custom_paired_url_structure() ) { $options[ Option::PAIRED_URL_STRUCTURE ] = self::PAIRED_URL_STRUCTURE_CUSTOM; } else { $options[ Option::PAIRED_URL_STRUCTURE ] = AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ); // Handle edge case where an unrecognized paired URL structure was saved. if ( ! in_array( $options[ Option::PAIRED_URL_STRUCTURE ], array_keys( self::PAIRED_URL_STRUCTURES ), true ) ) { $defaults = $this->filter_default_options( [], $options ); $options[ Option::PAIRED_URL_STRUCTURE ] = $defaults[ Option::PAIRED_URL_STRUCTURE ]; } } $options[ self::PAIRED_URL_EXAMPLES ] = $this->get_paired_url_examples(); $options[ self::CUSTOM_PAIRED_ENDPOINT_SOURCES ] = $this->get_custom_paired_structure_sources(); $options[ self::ENDPOINT_PATH_SLUG_CONFLICTS ] = $this->get_endpoint_path_slug_conflicts(); $options[ self::REWRITE_USING_PERMALINKS ] = $this->is_using_permalinks(); return $options; } /** * Get the entities that are already using the AMP slug. * * @return array|null Conflict data or null if there are no conflicts. * @global WP_Rewrite $wp_rewrite */ public function get_endpoint_path_slug_conflicts() { global $wp_rewrite; $conflicts = []; $amp_slug = amp_get_slug(); $post_query = new WP_Query( [ 'post_type' => 'any', 'name' => $amp_slug, 'posts_per_page' => 100, ] ); if ( $post_query->post_count > 0 ) { $conflicts['posts'] = array_map( static function ( WP_Post $post ) { $post_type = get_post_type_object( $post->post_type ); return [ 'id' => $post->ID, 'edit_link' => get_edit_post_link( $post->ID, 'raw' ), 'title' => $post->post_title, 'post_type' => $post->post_type, 'label' => isset( $post_type->labels->singular_name ) ? $post_type->labels->singular_name : null, ]; }, $post_query->posts ); } $term_query = new WP_Term_Query( [ 'slug' => $amp_slug, 'hide_empty' => false, ] ); if ( $term_query->terms ) { $conflicts['terms'] = array_map( static function ( WP_Term $term ) { $taxonomy = get_taxonomy( $term->taxonomy ); return [ 'id' => $term->term_id, 'edit_link' => get_edit_term_link( $term->term_id, $term->taxonomy ), 'taxonomy' => $term->taxonomy, 'name' => $term->name, 'label' => isset( $taxonomy->labels->singular_name ) ? $taxonomy->labels->singular_name : null, ]; }, $term_query->terms ); } $user = get_user_by( 'slug', $amp_slug ); if ( $user instanceof WP_User ) { $conflicts['user'] = [ 'id' => $user->ID, 'edit_link' => get_edit_user_link( $user->ID ), 'name' => $user->display_name, ]; } foreach ( get_post_types( [], 'objects' ) as $post_type ) { if ( $amp_slug === $post_type->query_var || ( isset( $post_type->rewrite['slug'] ) && $post_type->rewrite['slug'] === $amp_slug ) ) { $conflicts['post_type'] = [ 'name' => $post_type->name, 'label' => isset( $post_type->labels->name ) ? $post_type->labels->name : null, ]; break; } } foreach ( get_taxonomies( [], 'objects' ) as $taxonomy ) { if ( $amp_slug === $taxonomy->query_var || ( isset( $taxonomy->rewrite['slug'] ) && $taxonomy->rewrite['slug'] === $amp_slug ) ) { $conflicts['taxonomy'] = [ 'name' => $taxonomy->name, 'label' => isset( $taxonomy->labels->name ) ? $taxonomy->labels->name : null, ]; break; } } foreach ( $wp_rewrite->endpoints as $endpoint ) { if ( isset( $endpoint[1] ) && $amp_slug === $endpoint[1] ) { $conflicts['rewrite'][] = 'endpoint'; break; } } foreach ( [ 'author_base', 'comments_base', 'search_base', 'pagination_base', 'feed_base', 'comments_pagination_base', ] as $key ) { if ( isset( $wp_rewrite->$key ) && $amp_slug === $wp_rewrite->$key ) { $conflicts['rewrite'][] = $key; } } if ( empty( $conflicts ) ) { return null; } return $conflicts; } /** * Add paired hooks. */ public function initialize_paired_request() { if ( amp_is_canonical() ) { return; } // Run necessary logic to properly route a request using the registered paired URL structures. $this->detect_endpoint_in_environment(); add_filter( 'do_parse_request', [ $this, 'extract_endpoint_from_environment_before_parse_request' ], PHP_INT_MAX ); add_filter( 'request', [ $this, 'filter_request_after_endpoint_extraction' ] ); add_action( 'parse_request', [ $this, 'restore_path_endpoint_in_environment' ], defined( 'PHP_INT_MIN' ) ? PHP_INT_MIN : ~PHP_INT_MAX ); // phpcs:ignore PHPCompatibility.Constants.NewConstants.php_int_minFound // Reserve the 'amp' slug for paired URL structures that use paths. if ( $this->is_using_path_suffix() ) { // Note that the wp_unique_term_slug filter does not work in the same way. It will only be applied if there // is actually a duplicate, whereas the wp_unique_post_slug filter applies regardless. add_filter( 'wp_unique_post_slug', [ $this, 'filter_unique_post_slug' ], 10, 4 ); } add_action( 'parse_query', [ $this, 'correct_query_when_is_front_page' ] ); add_action( 'wp', [ $this, 'add_paired_request_hooks' ] ); add_action( 'admin_notices', [ $this, 'add_permalink_settings_notice' ] ); } /** * Determine whether the paired URL structure is using a path suffix (including the legacy Reader structure). * * @return bool */ public function is_using_path_suffix() { return in_array( AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ), [ Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX, Option::PAIRED_URL_STRUCTURE_LEGACY_READER, ], true ); } /** * Detect the paired endpoint from the PATH_INFO or REQUEST_URI. * * This is necessary to avoid needing to rely on WordPress's rewrite rules to identify AMP requests. * Rewrite rules are not suitable because rewrite endpoints can't be used across all URLs, * and the request is parsed too late in order to switch to the Reader theme. * * The environment variables containing the endpoint are scrubbed of it during `WP::parse_request()` * by means of the `PairedRouting::extract_endpoint_from_environment_before_parse_request()` method which runs * at the `do_parse_request` filter. * * @see PairedRouting::extract_endpoint_from_environment_before_parse_request() */ public function detect_endpoint_in_environment() { $this->did_request_endpoint = false; // Detect and purge the AMP endpoint from the request. foreach ( [ 'REQUEST_URI', 'PATH_INFO' ] as $var_name ) { if ( empty( $_SERVER[ $var_name ] ) ) { continue; } $paired_url_structure = $this->get_paired_url_structure(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $old_path = wp_unslash( $_SERVER[ $var_name ] ); // Because of wp_magic_quotes(). if ( ! $paired_url_structure->has_endpoint( $old_path ) ) { continue; } $new_path = $paired_url_structure->remove_endpoint( $old_path ); $this->suspended_environment_variables[ $var_name ] = [ $old_path, $new_path ]; $this->did_request_endpoint = true; } } /** * Override environment before parsing the request. * * This happens at the beginning of `WP::parse_request()` and then it is reset when it finishes * via the `PairedRouting::restore_path_endpoint_in_environment()` method at the `parse_request` * action. * * @see WP::parse_request() * * @param bool $do_parse_request Whether or not to parse the request. * @return bool Passed-through argument. */ public function extract_endpoint_from_environment_before_parse_request( $do_parse_request ) { // If request parsing was aborted, then there's nothing for us to do. if ( ! $do_parse_request ) { return $do_parse_request; } // Only do something if doing the outermost request. Note that this function runs with the // latest priority in order to make sure that any other `do_parse_request` filter will have // already applied, and thus we know that the request is indeed going to be parsed. if ( $this->did_request_endpoint && 0 === $this->current_request_nesting_level ) { foreach ( $this->suspended_environment_variables as $var_name => list( , $new_path ) ) { $_SERVER[ $var_name ] = wp_slash( $new_path ); // Because of wp_magic_quotes(). } } // Increase the nesting level so we can prevent calling ourselves for any recursive calls // to `WP::parse_request()` during the `request` filter. $this->current_request_nesting_level++; return $do_parse_request; } /** * Filter the request to add the AMP query var if endpoint was detected in the environment. * * @param array $query_vars Query vars. * @return array Query vars. */ public function filter_request_after_endpoint_extraction( $query_vars ) { if ( $this->did_request_endpoint ) { $query_vars[ amp_get_slug() ] = true; } return $query_vars; } /** * Restore the path endpoint in environment. * * @see PairedRouting::detect_endpoint_in_environment() * * @param WP $wp WP object. */ public function restore_path_endpoint_in_environment( WP $wp ) { // Since the request has finished the parsing which was detected above in the // `PairedRouting::extract_endpoint_from_environment_before_parse_request()` // method, now decrement the level. $this->current_request_nesting_level--; // Only run for the outermost/root request when an AMP endpoint was requested. if ( ! $this->did_request_endpoint || 0 !== $this->current_request_nesting_level ) { return; } foreach ( $this->suspended_environment_variables as $var_name => list( $old_path, ) ) { $_SERVER[ $var_name ] = wp_slash( $old_path ); // Because of wp_magic_quotes(). } $this->suspended_environment_variables = []; // In case a plugin is looking at $wp->request to see if it is AMP, ensure the path endpoint is added. // WordPress is not including it because it was removed in extract_endpoint_from_environment_before_parse_request. $request_path = '/'; if ( $wp->request ) { $request_path .= trailingslashit( $wp->request ); } $endpoint_url = $this->add_endpoint( $request_path ); $request_path = wp_parse_url( $endpoint_url, PHP_URL_PATH ); $wp->request = trim( $request_path, '/' ); } /** * Filters the post slug to prevent conflicting with the 'amp' slug. * * @see wp_unique_post_slug() * * @param string $slug Slug. * @param int $post_id Post ID. * @param string $post_status The post status. * @param string $post_type Post type. * @return string Slug. * @global \wpdb $wpdb WP DB. */ public function filter_unique_post_slug( $slug, $post_id, /** @noinspection PhpUnusedParameterInspection */ $post_status, $post_type ) { global $wpdb; $amp_slug = amp_get_slug(); if ( $amp_slug !== $slug ) { return $slug; } $suffix = 2; do { $alt_slug = "$slug-$suffix"; $slug_check = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Logic adapted from wp_unique_post_slug(). $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1", $alt_slug, $post_type, $post_id ) ); $suffix++; } while ( $slug_check ); $slug = $alt_slug; return $slug; } /** * Add hooks based for AMP pages and other hooks for non-AMP pages. */ public function add_paired_request_hooks() { if ( $this->has_endpoint() ) { add_filter( 'old_slug_redirect_url', [ $this, 'maybe_add_paired_endpoint' ], 1000 ); add_filter( 'redirect_canonical', [ $this, 'maybe_add_paired_endpoint' ], 1000 ); if ( $this->is_using_path_suffix() ) { // Filter priority of 0 to purge /amp/ before other filters manipulate it. add_filter( 'get_pagenum_link', [ $this, 'filter_get_pagenum_link' ], 0 ); add_filter( 'redirect_canonical', [ $this, 'filter_redirect_canonical_to_fix_cpage_requests' ], 0 ); } } else { add_action( 'wp_head', 'amp_add_amphtml_link' ); } } /** * Fix pagenum link when using a path suffix. * * When paired AMP URLs end in /amp/, on the blog index page a call to `the_posts_pagination()` will result in * unexpected links being added to the page. For example, `get_pagenum_link(2)` will result in `/blog/amp/page/2/` * instead of the expected `/blog/page/2/amp/`. And then, when on the 2nd page of results (`/blog/page/2/amp/`), a * call to `get_pagenum_link(3)` will result in an even more unexpected link pointing to `/blog/page/2/amp/page/3/` * instead of `/blog/page/3/amp/`, whereas `get_pagenum_link(1)` will return `/blog/page/2/amp/` as opposed to the * expected `/blog/amp/`. Note that `get_pagenum_link()` is used as the `base` for `paginate_links()` in * `get_the_posts_pagination()`, and it uses as its base `remove_query_arg('paged')` which returns the `REQUEST_URI`. * * @see get_pagenum_link() * @see get_the_posts_pagination() * * @param string $link Pagenum link. * @return string Fixed pagenum link. * @global WP_Rewrite $wp_rewrite */ public function filter_get_pagenum_link( $link ) { global $wp_rewrite; // Only relevant when using permalinks. if ( ! $wp_rewrite instanceof WP_Rewrite || ! $wp_rewrite->using_permalinks() ) { return $link; } $delimiter = ':'; $pattern = $delimiter; // If the current page is a paged request (e.g. /page/2/), then we need to first strip that out from the link. if ( is_paged() ) { $pattern .= sprintf( '/%s/%d', preg_quote( $wp_rewrite->pagination_base, $delimiter ), get_query_var( 'paged' ) ); } // Now we remove the AMP path segment followed by any additional paged path segments, if they are present. // This matches paths like: // * /blog/amp/ // * /blog/amp/page/2/ // As well as allowing for the lack of a trailing slash and/or the presence of query args and/or a hash target. $pattern .= sprintf( '/%s((/%s/\d+)?/?(\?.*?)?(#.*)?)$', preg_quote( amp_get_slug(), ':' ), preg_quote( $wp_rewrite->pagination_base, ':' ) ); $pattern .= $delimiter; return preg_replace( $pattern, '$1', $link ); } /** * Add notice to permalink settings screen for where to customize the paired URL structure. */ public function add_permalink_settings_notice() { if ( 'options-permalink' !== get_current_screen()->id ) { return; } ?>
Paired URL Structure section on the AMP settings screen.', 'amp' ), esc_url( admin_url( add_query_arg( 'page', AMP_Options_Manager::OPTION_NAME, 'admin.php' ) ) . '#paired-url-structure' ) ), [ 'a' => array_fill_keys( [ 'href' ], true ) ] ); ?>