--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/wp/wp-includes/class-wp-speculation-rules.php Fri Sep 05 18:52:52 2025 +0200
@@ -0,0 +1,293 @@
+<?php
+/**
+ * Class 'WP_Speculation_Rules'.
+ *
+ * @package WordPress
+ * @subpackage Speculative Loading
+ * @since 6.8.0
+ */
+
+/**
+ * Class representing a set of speculation rules.
+ *
+ * @since 6.8.0
+ * @access private
+ */
+final class WP_Speculation_Rules implements JsonSerializable {
+
+ /**
+ * Stored rules, as a map of `$mode => $rules` pairs.
+ *
+ * Every `$rules` value is a map of `$id => $rule` pairs.
+ *
+ * @since 6.8.0
+ * @var array<string, array<string, mixed>>
+ */
+ private $rules_by_mode = array();
+
+ /**
+ * The allowed speculation rules modes as a map, used for validation.
+ *
+ * @since 6.8.0
+ * @var array<string, bool>
+ */
+ 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<string, bool>
+ */
+ 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<string, bool>
+ */
+ 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<string, mixed> $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<string, array<string, mixed>> 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 ] );
+ }
+}