1<?php
2/**
3 * Class 'WP_Speculation_Rules'.
4 *
5 * @package WordPress
6 * @subpackage Speculative Loading
7 * @since 6.8.0
8 */
9
10/**
11 * Class representing a set of speculation rules.
12 *
13 * @since 6.8.0
14 * @access private
15 */
16final class WP_Speculation_Rules implements JsonSerializable {
17
18 /**
19 * Stored rules, as a map of `$mode => $rules` pairs.
20 *
21 * Every `$rules` value is a map of `$id => $rule` pairs.
22 *
23 * @since 6.8.0
24 * @var array<string, array<string, mixed>>
25 */
26 private $rules_by_mode = array();
27
28 /**
29 * The allowed speculation rules modes as a map, used for validation.
30 *
31 * @since 6.8.0
32 * @var array<string, bool>
33 */
34 private static $mode_allowlist = array(
35 'prefetch' => true,
36 'prerender' => true,
37 );
38
39 /**
40 * The allowed speculation rules eagerness levels as a map, used for validation.
41 *
42 * @since 6.8.0
43 * @var array<string, bool>
44 */
45 private static $eagerness_allowlist = array(
46 'immediate' => true,
47 'eager' => true,
48 'moderate' => true,
49 'conservative' => true,
50 );
51
52 /**
53 * The allowed speculation rules sources as a map, used for validation.
54 *
55 * @since 6.8.0
56 * @var array<string, bool>
57 */
58 private static $source_allowlist = array(
59 'list' => true,
60 'document' => true,
61 );
62
63 /**
64 * Adds a speculation rule to the speculation rules to consider.
65 *
66 * @since 6.8.0
67 *
68 * @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'.
69 * @param string $id Unique string identifier for the speculation rule.
70 * @param array<string, mixed> $rule Associative array of rule arguments.
71 * @return bool True on success, false if invalid parameters are provided.
72 */
73 public function add_rule( string $mode, string $id, array $rule ): bool {
74 if ( ! self::is_valid_mode( $mode ) ) {
75 _doing_it_wrong(
76 __METHOD__,
77 sprintf(
78 /* translators: %s: invalid mode value */
79 __( 'The value "%s" is not a valid speculation rules mode.' ),
80 esc_html( $mode )
81 ),
82 '6.8.0'
83 );
84 return false;
85 }
86
87 if ( ! $this->is_valid_id( $id ) ) {
88 _doing_it_wrong(
89 __METHOD__,
90 sprintf(
91 /* translators: %s: invalid ID value */
92 __( 'The value "%s" is not a valid ID for a speculation rule.' ),
93 esc_html( $id )
94 ),
95 '6.8.0'
96 );
97 return false;
98 }
99
100 if ( $this->has_rule( $mode, $id ) ) {
101 _doing_it_wrong(
102 __METHOD__,
103 sprintf(
104 /* translators: %s: invalid ID value */
105 __( 'A speculation rule with ID "%s" already exists.' ),
106 esc_html( $id )
107 ),
108 '6.8.0'
109 );
110 return false;
111 }
112
113 /*
114 * Perform some basic speculation rule validation.
115 * Every rule must have either a 'where' key or a 'urls' key, but not both.
116 * The presence of a 'where' key implies a 'source' of 'document', while the presence of a 'urls' key implies
117 * a 'source' of 'list'.
118 */
119 if (
120 ( ! isset( $rule['where'] ) && ! isset( $rule['urls'] ) ) ||
121 ( isset( $rule['where'] ) && isset( $rule['urls'] ) )
122 ) {
123 _doing_it_wrong(
124 __METHOD__,
125 sprintf(
126 /* translators: 1: allowed key, 2: alternative allowed key */
127 __( 'A speculation rule must include either a "%1$s" key or a "%2$s" key, but not both.' ),
128 'where',
129 'urls'
130 ),
131 '6.8.0'
132 );
133 return false;
134 }
135 if ( isset( $rule['source'] ) ) {
136 if ( ! self::is_valid_source( $rule['source'] ) ) {
137 _doing_it_wrong(
138 __METHOD__,
139 sprintf(
140 /* translators: %s: invalid source value */
141 __( 'The value "%s" is not a valid source for a speculation rule.' ),
142 esc_html( $rule['source'] )
143 ),
144 '6.8.0'
145 );
146 return false;
147 }
148
149 if ( 'list' === $rule['source'] && isset( $rule['where'] ) ) {
150 _doing_it_wrong(
151 __METHOD__,
152 sprintf(
153 /* translators: 1: source value, 2: forbidden key */
154 __( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ),
155 'list',
156 'where'
157 ),
158 '6.8.0'
159 );
160 return false;
161 }
162
163 if ( 'document' === $rule['source'] && isset( $rule['urls'] ) ) {
164 _doing_it_wrong(
165 __METHOD__,
166 sprintf(
167 /* translators: 1: source value, 2: forbidden key */
168 __( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ),
169 'document',
170 'urls'
171 ),
172 '6.8.0'
173 );
174 return false;
175 }
176 }
177
178 // If there is an 'eagerness' key specified, make sure it's valid.
179 if ( isset( $rule['eagerness'] ) ) {
180 if ( ! self::is_valid_eagerness( $rule['eagerness'] ) ) {
181 _doing_it_wrong(
182 __METHOD__,
183 sprintf(
184 /* translators: %s: invalid eagerness value */
185 __( 'The value "%s" is not a valid eagerness for a speculation rule.' ),
186 esc_html( $rule['eagerness'] )
187 ),
188 '6.8.0'
189 );
190 return false;
191 }
192
193 if ( isset( $rule['where'] ) && 'immediate' === $rule['eagerness'] ) {
194 _doing_it_wrong(
195 __METHOD__,
196 sprintf(
197 /* translators: %s: forbidden eagerness value */
198 __( 'The eagerness value "%s" is forbidden for document-level speculation rules.' ),
199 'immediate'
200 ),
201 '6.8.0'
202 );
203 return false;
204 }
205 }
206
207 if ( ! isset( $this->rules_by_mode[ $mode ] ) ) {
208 $this->rules_by_mode[ $mode ] = array();
209 }
210
211 $this->rules_by_mode[ $mode ][ $id ] = $rule;
212 return true;
213 }
214
215 /**
216 * Checks whether a speculation rule for the given mode and ID already exists.
217 *
218 * @since 6.8.0
219 *
220 * @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'.
221 * @param string $id Unique string identifier for the speculation rule.
222 * @return bool True if the rule already exists, false otherwise.
223 */
224 public function has_rule( string $mode, string $id ): bool {
225 return isset( $this->rules_by_mode[ $mode ][ $id ] );
226 }
227
228 /**
229 * Returns the speculation rules data ready to be JSON-encoded.
230 *
231 * @since 6.8.0
232 *
233 * @return array<string, array<string, mixed>> Speculation rules data.
234 */
235 #[ReturnTypeWillChange]
236 public function jsonSerialize() {
237 // Strip the IDs for JSON output, since they are not relevant for the Speculation Rules API.
238 return array_map(
239 static function ( array $rules ) {
240 return array_values( $rules );
241 },
242 array_filter( $this->rules_by_mode )
243 );
244 }
245
246 /**
247 * Checks whether the given ID is valid.
248 *
249 * @since 6.8.0
250 *
251 * @param string $id Unique string identifier for the speculation rule.
252 * @return bool True if the ID is valid, false otherwise.
253 */
254 private function is_valid_id( string $id ): bool {
255 return (bool) preg_match( '/^[a-z][a-z0-9_-]+$/', $id );
256 }
257
258 /**
259 * Checks whether the given speculation rules mode is valid.
260 *
261 * @since 6.8.0
262 *
263 * @param string $mode Speculation rules mode.
264 * @return bool True if valid, false otherwise.
265 */
266 public static function is_valid_mode( string $mode ): bool {
267 return isset( self::$mode_allowlist[ $mode ] );
268 }
269
270 /**
271 * Checks whether the given speculation rules eagerness is valid.
272 *
273 * @since 6.8.0
274 *
275 * @param string $eagerness Speculation rules eagerness.
276 * @return bool True if valid, false otherwise.
277 */
278 public static function is_valid_eagerness( string $eagerness ): bool {
279 return isset( self::$eagerness_allowlist[ $eagerness ] );
280 }
281
282 /**
283 * Checks whether the given speculation rules source is valid.
284 *
285 * @since 6.8.0
286 *
287 * @param string $source Speculation rules source.
288 * @return bool True if valid, false otherwise.
289 */
290 public static function is_valid_source( string $source ): bool {
291 return isset( self::$source_allowlist[ $source ] );
292 }
293}
294