diff --git a/wp-includes/class-wp-speculation-rules.php b/wp-includes/class-wp-speculation-rules.php new file mode 100644 index 0000000000..8d23eefe12 --- /dev/null +++ b/wp-includes/class-wp-speculation-rules.php @@ -0,0 +1,293 @@ + $rules` pairs. + * + * Every `$rules` value is a map of `$id => $rule` pairs. + * + * @since 6.8.0 + * @var array> + */ + private $rules_by_mode = array(); + + /** + * The allowed speculation rules modes as a map, used for validation. + * + * @since 6.8.0 + * @var array + */ + private static $mode_allowlist = array( + 'prefetch' => true, + 'prerender' => true, + ); + + /** + * The allowed speculation rules eagerness levels as a map, used for validation. + * + * @since 6.8.0 + * @var array + */ + private static $eagerness_allowlist = array( + 'immediate' => true, + 'eager' => true, + 'moderate' => true, + 'conservative' => true, + ); + + /** + * The allowed speculation rules sources as a map, used for validation. + * + * @since 6.8.0 + * @var array + */ + private static $source_allowlist = array( + 'list' => true, + 'document' => true, + ); + + /** + * Adds a speculation rule to the speculation rules to consider. + * + * @since 6.8.0 + * + * @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'. + * @param string $id Unique string identifier for the speculation rule. + * @param array $rule Associative array of rule arguments. + * @return bool True on success, false if invalid parameters are provided. + */ + public function add_rule( string $mode, string $id, array $rule ): bool { + if ( ! self::is_valid_mode( $mode ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid mode value */ + __( 'The value "%s" is not a valid speculation rules mode.' ), + esc_html( $mode ) + ), + '6.8.0' + ); + return false; + } + + if ( ! $this->is_valid_id( $id ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid ID value */ + __( 'The value "%s" is not a valid ID for a speculation rule.' ), + esc_html( $id ) + ), + '6.8.0' + ); + return false; + } + + if ( $this->has_rule( $mode, $id ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid ID value */ + __( 'A speculation rule with ID "%s" already exists.' ), + esc_html( $id ) + ), + '6.8.0' + ); + return false; + } + + /* + * Perform some basic speculation rule validation. + * Every rule must have either a 'where' key or a 'urls' key, but not both. + * The presence of a 'where' key implies a 'source' of 'document', while the presence of a 'urls' key implies + * a 'source' of 'list'. + */ + if ( + ( ! isset( $rule['where'] ) && ! isset( $rule['urls'] ) ) || + ( isset( $rule['where'] ) && isset( $rule['urls'] ) ) + ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: allowed key, 2: alternative allowed key */ + __( 'A speculation rule must include either a "%1$s" key or a "%2$s" key, but not both.' ), + 'where', + 'urls' + ), + '6.8.0' + ); + return false; + } + if ( isset( $rule['source'] ) ) { + if ( ! self::is_valid_source( $rule['source'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid source value */ + __( 'The value "%s" is not a valid source for a speculation rule.' ), + esc_html( $rule['source'] ) + ), + '6.8.0' + ); + return false; + } + + if ( 'list' === $rule['source'] && isset( $rule['where'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: source value, 2: forbidden key */ + __( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ), + 'list', + 'where' + ), + '6.8.0' + ); + return false; + } + + if ( 'document' === $rule['source'] && isset( $rule['urls'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: source value, 2: forbidden key */ + __( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ), + 'document', + 'urls' + ), + '6.8.0' + ); + return false; + } + } + + // If there is an 'eagerness' key specified, make sure it's valid. + if ( isset( $rule['eagerness'] ) ) { + if ( ! self::is_valid_eagerness( $rule['eagerness'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid eagerness value */ + __( 'The value "%s" is not a valid eagerness for a speculation rule.' ), + esc_html( $rule['eagerness'] ) + ), + '6.8.0' + ); + return false; + } + + if ( isset( $rule['where'] ) && 'immediate' === $rule['eagerness'] ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: forbidden eagerness value */ + __( 'The eagerness value "%s" is forbidden for document-level speculation rules.' ), + 'immediate' + ), + '6.8.0' + ); + return false; + } + } + + if ( ! isset( $this->rules_by_mode[ $mode ] ) ) { + $this->rules_by_mode[ $mode ] = array(); + } + + $this->rules_by_mode[ $mode ][ $id ] = $rule; + return true; + } + + /** + * Checks whether a speculation rule for the given mode and ID already exists. + * + * @since 6.8.0 + * + * @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'. + * @param string $id Unique string identifier for the speculation rule. + * @return bool True if the rule already exists, false otherwise. + */ + public function has_rule( string $mode, string $id ): bool { + return isset( $this->rules_by_mode[ $mode ][ $id ] ); + } + + /** + * Returns the speculation rules data ready to be JSON-encoded. + * + * @since 6.8.0 + * + * @return array> Speculation rules data. + */ + #[ReturnTypeWillChange] + public function jsonSerialize() { + // Strip the IDs for JSON output, since they are not relevant for the Speculation Rules API. + return array_map( + static function ( array $rules ) { + return array_values( $rules ); + }, + array_filter( $this->rules_by_mode ) + ); + } + + /** + * Checks whether the given ID is valid. + * + * @since 6.8.0 + * + * @param string $id Unique string identifier for the speculation rule. + * @return bool True if the ID is valid, false otherwise. + */ + private function is_valid_id( string $id ): bool { + return (bool) preg_match( '/^[a-z][a-z0-9_-]+$/', $id ); + } + + /** + * Checks whether the given speculation rules mode is valid. + * + * @since 6.8.0 + * + * @param string $mode Speculation rules mode. + * @return bool True if valid, false otherwise. + */ + public static function is_valid_mode( string $mode ): bool { + return isset( self::$mode_allowlist[ $mode ] ); + } + + /** + * Checks whether the given speculation rules eagerness is valid. + * + * @since 6.8.0 + * + * @param string $eagerness Speculation rules eagerness. + * @return bool True if valid, false otherwise. + */ + public static function is_valid_eagerness( string $eagerness ): bool { + return isset( self::$eagerness_allowlist[ $eagerness ] ); + } + + /** + * Checks whether the given speculation rules source is valid. + * + * @since 6.8.0 + * + * @param string $source Speculation rules source. + * @return bool True if valid, false otherwise. + */ + public static function is_valid_source( string $source ): bool { + return isset( self::$source_allowlist[ $source ] ); + } +} diff --git a/wp-includes/class-wp-url-pattern-prefixer.php b/wp-includes/class-wp-url-pattern-prefixer.php new file mode 100644 index 0000000000..a79e6e10c8 --- /dev/null +++ b/wp-includes/class-wp-url-pattern-prefixer.php @@ -0,0 +1,135 @@ + $base_path` pairs. + * + * @since 6.8.0 + * @var array + */ + private $contexts; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param array $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the + * contexts returned by the + * {@see WP_URL_Pattern_Prefixer::get_default_contexts()} method. + */ + public function __construct( array $contexts = array() ) { + if ( count( $contexts ) > 0 ) { + $this->contexts = array_map( + static function ( string $str ): string { + return self::escape_pattern_string( trailingslashit( $str ) ); + }, + $contexts + ); + } else { + $this->contexts = self::get_default_contexts(); + } + } + + /** + * Prefixes the given URL path pattern with the base path for the given context. + * + * This ensures that these path patterns work correctly on WordPress subdirectory sites, for example in a multisite + * network, or when WordPress itself is installed in a subdirectory of the hostname. + * + * The given URL path pattern is only prefixed if it does not already include the expected prefix. + * + * @since 6.8.0 + * + * @param string $path_pattern URL pattern starting with the path segment. + * @param string $context Optional. Context to use for prefixing the path pattern. Default 'home'. + * @return string URL pattern, prefixed as necessary. + */ + public function prefix_path_pattern( string $path_pattern, string $context = 'home' ): string { + // If context path does not exist, the context is invalid. + if ( ! isset( $this->contexts[ $context ] ) ) { + _doing_it_wrong( + __FUNCTION__, + esc_html( + sprintf( + /* translators: %s: context string */ + __( 'Invalid URL pattern context %s.' ), + $context + ) + ), + '6.8.0' + ); + return $path_pattern; + } + + /* + * In the event that the context path contains a :, ? or # (which can cause the URL pattern parser to switch to + * another state, though only the latter two should be percent encoded anyway), it additionally needs to be + * enclosed in grouping braces. The final forward slash (trailingslashit ensures there is one) affects the + * meaning of the * wildcard, so is left outside the braces. + */ + $context_path = $this->contexts[ $context ]; + $escaped_context_path = $context_path; + if ( strcspn( $context_path, ':?#' ) !== strlen( $context_path ) ) { + $escaped_context_path = '{' . substr( $context_path, 0, -1 ) . '}/'; + } + + /* + * If the path already starts with the context path (including '/'), remove it first + * since it is about to be added back. + */ + if ( str_starts_with( $path_pattern, $context_path ) ) { + $path_pattern = substr( $path_pattern, strlen( $context_path ) ); + } + + return $escaped_context_path . ltrim( $path_pattern, '/' ); + } + + /** + * Returns the default contexts used by the class. + * + * @since 6.8.0 + * + * @return array Map of `$context_string => $base_path` pairs. + */ + public static function get_default_contexts(): array { + return array( + 'home' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) ), + 'site' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ) ), + 'uploads' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( wp_upload_dir( null, false )['baseurl'], PHP_URL_PATH ) ) ), + 'content' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( content_url(), PHP_URL_PATH ) ) ), + 'plugins' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( plugins_url(), PHP_URL_PATH ) ) ), + 'template' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_stylesheet_directory_uri(), PHP_URL_PATH ) ) ), + 'stylesheet' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_template_directory_uri(), PHP_URL_PATH ) ) ), + ); + } + + /** + * Escapes a string for use in a URL pattern component. + * + * @since 6.8.0 + * @see https://urlpattern.spec.whatwg.org/#escape-a-pattern-string + * + * @param string $str String to be escaped. + * @return string String with backslashes added where required. + */ + private static function escape_pattern_string( string $str ): string { + return addcslashes( $str, '+*?:{}()\\' ); + } +} diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index 54883b840d..3a11e94a85 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -358,6 +358,7 @@ add_action( 'wp_head', 'rel_canonical' ); add_action( 'wp_head', 'wp_shortlink_wp_head', 10, 0 ); add_action( 'wp_head', 'wp_custom_css_cb', 101 ); add_action( 'wp_head', 'wp_site_icon', 99 ); +add_action( 'wp_footer', 'wp_print_speculation_rules' ); add_action( 'wp_footer', 'wp_print_footer_scripts', 20 ); add_action( 'template_redirect', 'wp_shortlink_header', 11, 0 ); add_action( 'wp_print_footer_scripts', '_wp_footer_scripts' ); diff --git a/wp-includes/speculative-loading.php b/wp-includes/speculative-loading.php new file mode 100644 index 0000000000..ee9d4b6381 --- /dev/null +++ b/wp-includes/speculative-loading.php @@ -0,0 +1,254 @@ +|null Associative array with 'mode' and 'eagerness' keys, or null if speculative + * loading is disabled. + */ +function wp_get_speculation_rules_configuration(): ?array { + // By default, speculative loading is only enabled for sites with pretty permalinks when no user is logged in. + if ( ! is_user_logged_in() && get_option( 'permalink_structure' ) ) { + $config = array( + 'mode' => 'auto', + 'eagerness' => 'auto', + ); + } else { + $config = null; + } + + /** + * Filters the way that speculation rules are configured. + * + * The Speculation Rules API is a web API that allows to automatically prefetch or prerender certain URLs on the + * page, which can lead to near-instant page load times. This is also referred to as speculative loading. + * + * There are two aspects to the configuration: + * * The "mode" (whether to "prefetch" or "prerender" URLs). + * * The "eagerness" (whether to speculatively load URLs in an "eager", "moderate", or "conservative" way). + * + * By default, the speculation rules configuration is decided by WordPress Core ("auto"). This filter can be used + * to force a certain configuration, which could for instance load URLs more or less eagerly. + * + * For logged-in users or for sites that are not configured to use pretty permalinks, the default value is `null`, + * indicating that speculative loading is entirely disabled. + * + * @since 6.8.0 + * @see https://developer.chrome.com/docs/web-platform/prerender-pages + * + * @param array|null $config Associative array with 'mode' and 'eagerness' keys, or `null`. The + * default value for both of the keys is 'auto'. Other possible values + * for 'mode' are 'prefetch' and 'prerender'. Other possible values for + * 'eagerness' are 'eager', 'moderate', and 'conservative'. The value + * `null` is used to disable speculative loading entirely. + */ + $config = apply_filters( 'wp_speculation_rules_configuration', $config ); + + // Allow the value `null` to indicate that speculative loading is disabled. + if ( null === $config ) { + return null; + } + + // Sanitize the configuration and replace 'auto' with current defaults. + $default_mode = 'prefetch'; + $default_eagerness = 'conservative'; + if ( ! is_array( $config ) ) { + return array( + 'mode' => $default_mode, + 'eagerness' => $default_eagerness, + ); + } + if ( + ! isset( $config['mode'] ) || + 'auto' === $config['mode'] || + ! WP_Speculation_Rules::is_valid_mode( $config['mode'] ) + ) { + $config['mode'] = $default_mode; + } + if ( + ! isset( $config['eagerness'] ) || + 'auto' === $config['eagerness'] || + ! WP_Speculation_Rules::is_valid_eagerness( $config['eagerness'] ) || + // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules. + 'immediate' === $config['eagerness'] + ) { + $config['eagerness'] = $default_eagerness; + } + + return array( + 'mode' => $config['mode'], + 'eagerness' => $config['eagerness'], + ); +} + +/** + * Returns the full speculation rules data based on the configuration. + * + * Plugins with features that rely on frontend URLs to exclude from prefetching or prerendering should use the + * {@see 'wp_speculation_rules_href_exclude_paths'} filter to ensure those URL patterns are excluded. + * + * Additional speculation rules other than the default rule from WordPress Core can be provided by using the + * {@see 'wp_load_speculation_rules'} action and amending the passed WP_Speculation_Rules object. + * + * @since 6.8.0 + * @access private + * + * @return WP_Speculation_Rules|null Object representing the speculation rules to use, or null if speculative loading + * is disabled in the current context. + */ +function wp_get_speculation_rules(): ?WP_Speculation_Rules { + $configuration = wp_get_speculation_rules_configuration(); + if ( null === $configuration ) { + return null; + } + + $mode = $configuration['mode']; + $eagerness = $configuration['eagerness']; + + $prefixer = new WP_URL_Pattern_Prefixer(); + + $base_href_exclude_paths = array( + $prefixer->prefix_path_pattern( '/wp-*.php', 'site' ), + $prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ), + $prefixer->prefix_path_pattern( '/*', 'uploads' ), + $prefixer->prefix_path_pattern( '/*', 'content' ), + $prefixer->prefix_path_pattern( '/*', 'plugins' ), + $prefixer->prefix_path_pattern( '/*', 'template' ), + $prefixer->prefix_path_pattern( '/*', 'stylesheet' ), + ); + + /* + * If pretty permalinks are enabled, exclude any URLs with query parameters. + * Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter or any other query parameter + * containing the word `nonce`. + */ + if ( get_option( 'permalink_structure' ) ) { + $base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' ); + } else { + $base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)*nonce*=*', 'home' ); + } + + /** + * Filters the paths for which speculative loading should be disabled. + * + * All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard. + * If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary. + * + * Note that WordPress always excludes certain path patterns such as `/wp-login.php` and `/wp-admin/*`, and those + * cannot be modified using the filter. + * + * @since 6.8.0 + * + * @param string[] $href_exclude_paths Additional path patterns to disable speculative loading for. + * @param string $mode Mode used to apply speculative loading. Either 'prefetch' or 'prerender'. + */ + $href_exclude_paths = (array) apply_filters( 'wp_speculation_rules_href_exclude_paths', array(), $mode ); + + // Ensure that: + // 1. There are no duplicates. + // 2. The base paths cannot be removed. + // 3. The array has sequential keys (i.e. array_is_list()). + $href_exclude_paths = array_values( + array_unique( + array_merge( + $base_href_exclude_paths, + array_map( + static function ( string $href_exclude_path ) use ( $prefixer ): string { + return $prefixer->prefix_path_pattern( $href_exclude_path ); + }, + $href_exclude_paths + ) + ) + ) + ); + + $speculation_rules = new WP_Speculation_Rules(); + + $main_rule_conditions = array( + // Include any URLs within the same site. + array( + 'href_matches' => $prefixer->prefix_path_pattern( '/*' ), + ), + // Except for excluded paths. + array( + 'not' => array( + 'href_matches' => $href_exclude_paths, + ), + ), + // Also exclude rel=nofollow links, as certain plugins use that on their links that perform an action. + array( + 'not' => array( + 'selector_matches' => 'a[rel~="nofollow"]', + ), + ), + // Also exclude links that are explicitly marked to opt out. + array( + 'not' => array( + 'selector_matches' => ".no-{$mode}", + ), + ), + ); + + // If using 'prerender', also exclude links that opt-out of 'prefetch' because it's part of 'prerender'. + if ( 'prerender' === $mode ) { + $main_rule_conditions[] = array( + 'not' => array( + 'selector_matches' => '.no-prefetch', + ), + ); + } + + $speculation_rules->add_rule( + $mode, + 'main', + array( + 'source' => 'document', + 'where' => array( + 'and' => $main_rule_conditions, + ), + 'eagerness' => $eagerness, + ) + ); + + /** + * Fires when speculation rules data is loaded, allowing to amend the rules. + * + * @since 6.8.0 + * + * @param WP_Speculation_Rules $speculation_rules Object representing the speculation rules to use. + */ + do_action( 'wp_load_speculation_rules', $speculation_rules ); + + return $speculation_rules; +} + +/** + * Prints the speculation rules. + * + * For browsers that do not support speculation rules yet, the `script[type="speculationrules"]` tag will be ignored. + * + * @since 6.8.0 + * @access private + */ +function wp_print_speculation_rules(): void { + $speculation_rules = wp_get_speculation_rules(); + if ( null === $speculation_rules ) { + return; + } + + wp_print_inline_script_tag( + (string) wp_json_encode( + $speculation_rules + ), + array( 'type' => 'speculationrules' ) + ); +} diff --git a/wp-includes/version.php b/wp-includes/version.php index 29ce0cb197..ab3bd1d797 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.8-alpha-59836'; +$wp_version = '6.8-alpha-59837'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/wp-settings.php b/wp-settings.php index 05a13f21a7..8f835bc31b 100644 --- a/wp-settings.php +++ b/wp-settings.php @@ -405,6 +405,9 @@ require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api.php'; require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php'; require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php'; require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php'; +require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php'; +require ABSPATH . WPINC . '/class-wp-speculation-rules.php'; +require ABSPATH . WPINC . '/speculative-loading.php'; add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) ); add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) );