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( '