1<?php
2/**
3 * Speculative loading functions.
4 *
5 * @package WordPress
6 * @subpackage Speculative Loading
7 * @since 6.8.0
8 */
9
10/**
11 * Returns the speculation rules configuration.
12 *
13 * @since 6.8.0
14 *
15 * @return array<string, string>|null Associative array with 'mode' and 'eagerness' keys, or null if speculative
16 * loading is disabled.
17 */
18function wp_get_speculation_rules_configuration(): ?array {
19 // By default, speculative loading is only enabled for sites with pretty permalinks when no user is logged in.
20 if ( ! is_user_logged_in() && get_option( 'permalink_structure' ) ) {
21 $config = array(
22 'mode' => 'auto',
23 'eagerness' => 'auto',
24 );
25 } else {
26 $config = null;
27 }
28
29 /**
30 * Filters the way that speculation rules are configured.
31 *
32 * The Speculation Rules API is a web API that allows to automatically prefetch or prerender certain URLs on the
33 * page, which can lead to near-instant page load times. This is also referred to as speculative loading.
34 *
35 * There are two aspects to the configuration:
36 * * The "mode" (whether to "prefetch" or "prerender" URLs).
37 * * The "eagerness" (whether to speculatively load URLs in an "eager", "moderate", or "conservative" way).
38 *
39 * By default, the speculation rules configuration is decided by WordPress Core ("auto"). This filter can be used
40 * to force a certain configuration, which could for instance load URLs more or less eagerly.
41 *
42 * For logged-in users or for sites that are not configured to use pretty permalinks, the default value is `null`,
43 * indicating that speculative loading is entirely disabled.
44 *
45 * @since 6.8.0
46 * @see https://developer.chrome.com/docs/web-platform/prerender-pages
47 *
48 * @param array<string, string>|null $config Associative array with 'mode' and 'eagerness' keys, or `null`. The
49 * default value for both of the keys is 'auto'. Other possible values
50 * for 'mode' are 'prefetch' and 'prerender'. Other possible values for
51 * 'eagerness' are 'eager', 'moderate', and 'conservative'. The value
52 * `null` is used to disable speculative loading entirely.
53 */
54 $config = apply_filters( 'wp_speculation_rules_configuration', $config );
55
56 // Allow the value `null` to indicate that speculative loading is disabled.
57 if ( null === $config ) {
58 return null;
59 }
60
61 // Sanitize the configuration and replace 'auto' with current defaults.
62 $default_mode = 'prefetch';
63 $default_eagerness = 'conservative';
64 if ( ! is_array( $config ) ) {
65 return array(
66 'mode' => $default_mode,
67 'eagerness' => $default_eagerness,
68 );
69 }
70 if (
71 ! isset( $config['mode'] ) ||
72 'auto' === $config['mode'] ||
73 ! WP_Speculation_Rules::is_valid_mode( $config['mode'] )
74 ) {
75 $config['mode'] = $default_mode;
76 }
77 if (
78 ! isset( $config['eagerness'] ) ||
79 'auto' === $config['eagerness'] ||
80 ! WP_Speculation_Rules::is_valid_eagerness( $config['eagerness'] ) ||
81 // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules.
82 'immediate' === $config['eagerness']
83 ) {
84 $config['eagerness'] = $default_eagerness;
85 }
86
87 return array(
88 'mode' => $config['mode'],
89 'eagerness' => $config['eagerness'],
90 );
91}
92
93/**
94 * Returns the full speculation rules data based on the configuration.
95 *
96 * Plugins with features that rely on frontend URLs to exclude from prefetching or prerendering should use the
97 * {@see 'wp_speculation_rules_href_exclude_paths'} filter to ensure those URL patterns are excluded.
98 *
99 * Additional speculation rules other than the default rule from WordPress Core can be provided by using the
100 * {@see 'wp_load_speculation_rules'} action and amending the passed WP_Speculation_Rules object.
101 *
102 * @since 6.8.0
103 * @access private
104 *
105 * @return WP_Speculation_Rules|null Object representing the speculation rules to use, or null if speculative loading
106 * is disabled in the current context.
107 */
108function wp_get_speculation_rules(): ?WP_Speculation_Rules {
109 $configuration = wp_get_speculation_rules_configuration();
110 if ( null === $configuration ) {
111 return null;
112 }
113
114 $mode = $configuration['mode'];
115 $eagerness = $configuration['eagerness'];
116
117 $prefixer = new WP_URL_Pattern_Prefixer();
118
119 $base_href_exclude_paths = array(
120 $prefixer->prefix_path_pattern( '/wp-*.php', 'site' ),
121 $prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
122 $prefixer->prefix_path_pattern( '/*', 'uploads' ),
123 $prefixer->prefix_path_pattern( '/*', 'content' ),
124 $prefixer->prefix_path_pattern( '/*', 'plugins' ),
125 $prefixer->prefix_path_pattern( '/*', 'template' ),
126 $prefixer->prefix_path_pattern( '/*', 'stylesheet' ),
127 );
128
129 /*
130 * If pretty permalinks are enabled, exclude any URLs with query parameters.
131 * Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter or any other query parameter
132 * containing the word `nonce`.
133 */
134 if ( get_option( 'permalink_structure' ) ) {
135 $base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' );
136 } else {
137 $base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)*nonce*=*', 'home' );
138 }
139
140 /**
141 * Filters the paths for which speculative loading should be disabled.
142 *
143 * All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard.
144 * If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary.
145 *
146 * Note that WordPress always excludes certain path patterns such as `/wp-login.php` and `/wp-admin/*`, and those
147 * cannot be modified using the filter.
148 *
149 * @since 6.8.0
150 *
151 * @param string[] $href_exclude_paths Additional path patterns to disable speculative loading for.
152 * @param string $mode Mode used to apply speculative loading. Either 'prefetch' or 'prerender'.
153 */
154 $href_exclude_paths = (array) apply_filters( 'wp_speculation_rules_href_exclude_paths', array(), $mode );
155
156 // Ensure that:
157 // 1. There are no duplicates.
158 // 2. The base paths cannot be removed.
159 // 3. The array has sequential keys (i.e. array_is_list()).
160 $href_exclude_paths = array_values(
161 array_unique(
162 array_merge(
163 $base_href_exclude_paths,
164 array_map(
165 static function ( string $href_exclude_path ) use ( $prefixer ): string {
166 return $prefixer->prefix_path_pattern( $href_exclude_path );
167 },
168 $href_exclude_paths
169 )
170 )
171 )
172 );
173
174 $speculation_rules = new WP_Speculation_Rules();
175
176 $main_rule_conditions = array(
177 // Include any URLs within the same site.
178 array(
179 'href_matches' => $prefixer->prefix_path_pattern( '/*' ),
180 ),
181 // Except for excluded paths.
182 array(
183 'not' => array(
184 'href_matches' => $href_exclude_paths,
185 ),
186 ),
187 // Also exclude rel=nofollow links, as certain plugins use that on their links that perform an action.
188 array(
189 'not' => array(
190 'selector_matches' => 'a[rel~="nofollow"]',
191 ),
192 ),
193 // Also exclude links that are explicitly marked to opt out, either directly or via a parent element.
194 array(
195 'not' => array(
196 'selector_matches' => ".no-{$mode}, .no-{$mode} a",
197 ),
198 ),
199 );
200
201 // If using 'prerender', also exclude links that opt out of 'prefetch' because it's part of 'prerender'.
202 if ( 'prerender' === $mode ) {
203 $main_rule_conditions[] = array(
204 'not' => array(
205 'selector_matches' => '.no-prefetch, .no-prefetch a',
206 ),
207 );
208 }
209
210 $speculation_rules->add_rule(
211 $mode,
212 'main',
213 array(
214 'source' => 'document',
215 'where' => array(
216 'and' => $main_rule_conditions,
217 ),
218 'eagerness' => $eagerness,
219 )
220 );
221
222 /**
223 * Fires when speculation rules data is loaded, allowing to amend the rules.
224 *
225 * @since 6.8.0
226 *
227 * @param WP_Speculation_Rules $speculation_rules Object representing the speculation rules to use.
228 */
229 do_action( 'wp_load_speculation_rules', $speculation_rules );
230
231 return $speculation_rules;
232}
233
234/**
235 * Prints the speculation rules.
236 *
237 * For browsers that do not support speculation rules yet, the `script[type="speculationrules"]` tag will be ignored.
238 *
239 * @since 6.8.0
240 * @access private
241 */
242function wp_print_speculation_rules(): void {
243 $speculation_rules = wp_get_speculation_rules();
244 if ( null === $speculation_rules ) {
245 return;
246 }
247
248 wp_print_inline_script_tag(
249 (string) wp_json_encode(
250 $speculation_rules,
251 JSON_HEX_TAG | JSON_UNESCAPED_SLASHES
252 ),
253 array( 'type' => 'speculationrules' )
254 );
255}
256