diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d2939e8d47..b8ae808fdb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -53,3 +53,8 @@ /modules/images/dominant-color-images @pbearne @spacedmonkey /tests/modules/images/dominant-color-images @pbearne @spacedmonkey /tests/testdata/modules/images/dominant-color-images @pbearne @spacedmonkey + +# Module: Speculation Rules +/modules/js-and-css/speculation-rules @felixarntz +/tests/modules/js-and-css/speculation-rules @felixarntz +/tests/testdata/modules/js-and-css/speculation-rules @felixarntz diff --git a/modules/js-and-css/speculation-rules/class-plsr-url-pattern-prefixer.php b/modules/js-and-css/speculation-rules/class-plsr-url-pattern-prefixer.php new file mode 100644 index 0000000000..7f4bd2ea8a --- /dev/null +++ b/modules/js-and-css/speculation-rules/class-plsr-url-pattern-prefixer.php @@ -0,0 +1,93 @@ + $base_path` pairs. + * + * @since n.e.x.t + * @var array + */ + private $contexts; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param array $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the contexts returned + * by the {@see PLSR_URL_Pattern_Prefixer::get_default_contexts()} method. + */ + public function __construct( array $contexts = array() ) { + if ( $contexts ) { + $this->contexts = array_map( 'trailingslashit', $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 n.e.x.t + * + * @param string $path_pattern URL pattern starting with the path segment. + * @param string $context Optional. Either 'home' (any frontend content) or 'site' (content relative to the + * directory that WordPress is installed in). 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 context %s.', 'performance-lab' ), + $context + ) + ), + 'Performance Lab n.e.x.t' + ); + return $path_pattern; + } + + // If the path already starts with the context path (including '/'), there is nothing to prefix. + if ( str_starts_with( $path_pattern, $this->contexts[ $context ] ) ) { + return $path_pattern; + } + + return $this->contexts[ $context ] . ltrim( $path_pattern, '/' ); + } + + /** + * Returns the default contexts used by the class. + * + * @since n.e.x.t + * + * @return array Map of `$context_string => $base_path` pairs. + */ + public static function get_default_contexts(): array { + return array( + 'home' => trailingslashit( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ), + 'site' => trailingslashit( wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ), + ); + } +} diff --git a/modules/js-and-css/speculation-rules/helper.php b/modules/js-and-css/speculation-rules/helper.php new file mode 100644 index 0000000000..ffdc19a1ad --- /dev/null +++ b/modules/js-and-css/speculation-rules/helper.php @@ -0,0 +1,91 @@ +prefix_path_pattern( '/wp-login.php', 'site' ), + $prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ), + ); + $href_exclude_paths = $base_href_exclude_paths; + + /** + * Filters the paths for which speculative prerendering should be disabled. + * + * All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard. + * By default, the array includes `/wp-login.php` and `/wp-admin/*`. + * + * If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary. + * + * @since n.e.x.t + * + * @param array $href_exclude_paths Paths to disable speculative prerendering for. + */ + $href_exclude_paths = (array) apply_filters( 'plsr_speculation_rules_href_exclude_paths', $href_exclude_paths ); + + // Ensure that there are no duplicates and that the base paths cannot be removed. + $href_exclude_paths = array_unique( + array_map( + static function ( $exclude_path ) use ( $prefixer ) { + $exclude_path = $prefixer->prefix_path_pattern( $exclude_path ); + + /* + * TODO: Remove this eventually as it's no longer needed in Chrome 121+. + * See: + * * https://github.com/whatwg/urlpattern/issues/179 + * * https://chromium-review.googlesource.com/c/chromium/src/+/4975595 + */ + return $exclude_path . '\\?*#*'; + }, + array_merge( + $base_href_exclude_paths, + $href_exclude_paths + ) + ) + ); + + $prerender_rules = array( + array( + 'source' => 'document', + 'where' => array( + 'and' => array( + // Prerender any URLs within the same site. + array( + 'href_matches' => $prefixer->prefix_path_pattern( '/*\\?*' ), + ), + // Except for WP login and admin URLs. + array( + 'not' => array( + 'href_matches' => $href_exclude_paths, + ), + ), + // And except for any links marked with a class to not prerender. + array( + 'not' => array( + 'selector_matches' => '.no-prerender', + ), + ), + ), + ), + 'eagerness' => 'moderate', + ), + ); + + return array( 'prerender' => $prerender_rules ); +} diff --git a/modules/js-and-css/speculation-rules/hooks.php b/modules/js-and-css/speculation-rules/hooks.php new file mode 100644 index 0000000000..ab78e0992b --- /dev/null +++ b/modules/js-and-css/speculation-rules/hooks.php @@ -0,0 +1,49 @@ + 'speculationrules' ) + ); +} +add_action( 'wp_footer', 'plsr_print_speculation_rules' ); + +/** + * Prints the tag to opt in to the Chrome origin trial if the token constant is defined. + * + * After opting in to the origin trial via https://github.com/WICG/nav-speculation/blob/main/chrome-2023q1-experiment-overview.md, + * please set your token in a `PLSR_ORIGIN_TRIAL_TOKEN` constant, e.g. in `wp-config.php`. + * + * This function is here temporarily and will eventually be removed. + * + * @since n.e.x.t + * @access private + * @ignore + */ +function plsr_print_origin_trial_opt_in() { + if ( ! defined( 'PLSR_ORIGIN_TRIAL_TOKEN' ) || ! PLSR_ORIGIN_TRIAL_TOKEN ) { + return; + } + ?> + + $base_path ) ); + + $this->assertSame( + $expected, + $p->prefix_path_pattern( $path_pattern, 'demo' ) + ); + } + + public function data_prefix_path_pattern() { + return array( + array( '/', '/my-page/', '/my-page/' ), + array( '/', 'my-page/', '/my-page/' ), + array( '/wp/', '/my-page/', '/wp/my-page/' ), + array( '/wp/', 'my-page/', '/wp/my-page/' ), + array( '/wp/', '/blog/2023/11/new-post/', '/wp/blog/2023/11/new-post/' ), + array( '/wp/', 'blog/2023/11/new-post/', '/wp/blog/2023/11/new-post/' ), + array( '/subdir', '/my-page/', '/subdir/my-page/' ), + array( '/subdir', 'my-page/', '/subdir/my-page/' ), + // Missing trailing slash still works, does not consider "cut-off" directory names. + array( '/subdir', '/subdirectory/my-page/', '/subdir/subdirectory/my-page/' ), + array( '/subdir', 'subdirectory/my-page/', '/subdir/subdirectory/my-page/' ), + ); + } + + public function test_get_default_contexts() { + $contexts = PLSR_URL_Pattern_Prefixer::get_default_contexts(); + + $this->assertArrayHasKey( 'home', $contexts ); + $this->assertArrayHasKey( 'site', $contexts ); + $this->assertSame( '/', $contexts['home'] ); + $this->assertSame( '/', $contexts['site'] ); + } + + public function test_get_default_contexts_with_subdirectories() { + add_filter( + 'home_url', + static function () { + return 'https://example.com/subdir/'; + } + ); + add_filter( + 'site_url', + static function () { + return 'https://example.com/subdir/wp/'; + } + ); + + $contexts = PLSR_URL_Pattern_Prefixer::get_default_contexts(); + + $this->assertArrayHasKey( 'home', $contexts ); + $this->assertArrayHasKey( 'site', $contexts ); + $this->assertSame( '/subdir/', $contexts['home'] ); + $this->assertSame( '/subdir/wp/', $contexts['site'] ); + } +} diff --git a/tests/modules/js-and-css/speculation-rules/speculation-rules-helper-test.php b/tests/modules/js-and-css/speculation-rules/speculation-rules-helper-test.php new file mode 100644 index 0000000000..7889835985 --- /dev/null +++ b/tests/modules/js-and-css/speculation-rules/speculation-rules-helper-test.php @@ -0,0 +1,57 @@ +assertIsArray( $rules ); + $this->assertArrayHasKey( 'prerender', $rules ); + $this->assertIsArray( $rules['prerender'] ); + foreach ( $rules['prerender'] as $entry ) { + $this->assertIsArray( $entry ); + $this->assertArrayHasKey( 'source', $entry ); + $this->assertTrue( in_array( $entry['source'], array( 'list', 'document' ), true ) ); + } + } + + public function test_plsr_get_speculation_rules_href_exclude_paths() { + $rules = plsr_get_speculation_rules(); + $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches']; + + $this->assertSameSets( + array( + '/wp-login.php\\?*#*', + '/wp-admin/*\\?*#*', + ), + $href_exclude_paths + ); + + // Add filter that attempts to replace base exclude paths with a custom path to exclude. + add_filter( + 'plsr_speculation_rules_href_exclude_paths', + static function () { + return array( 'custom-file.php' ); + } + ); + + $rules = plsr_get_speculation_rules(); + $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches']; + + // Ensure the base exclude paths are still present and that the custom path was formatted correctly. + $this->assertSameSets( + array( + '/wp-login.php\\?*#*', + '/wp-admin/*\\?*#*', + '/custom-file.php\\?*#*' + ), + $href_exclude_paths + ); + } +} diff --git a/tests/modules/js-and-css/speculation-rules/speculation-rules-test.php b/tests/modules/js-and-css/speculation-rules/speculation-rules-test.php new file mode 100644 index 0000000000..8696e4b26f --- /dev/null +++ b/tests/modules/js-and-css/speculation-rules/speculation-rules-test.php @@ -0,0 +1,20 @@ +assertStringContainsString( '