1<?php
2/**
3 * Class 'WP_URL_Pattern_Prefixer'.
4 *
5 * @package WordPress
6 * @subpackage Speculative Loading
7 * @since 6.8.0
8 */
9
10/**
11 * Class for prefixing URL patterns.
12 *
13 * This class is intended primarily for use as part of the speculative loading feature.
14 *
15 * @since 6.8.0
16 * @access private
17 */
18class WP_URL_Pattern_Prefixer {
19
20 /**
21 * Map of `$context_string => $base_path` pairs.
22 *
23 * @since 6.8.0
24 * @var array<string, string>
25 */
26 private $contexts;
27
28 /**
29 * Constructor.
30 *
31 * @since 6.8.0
32 *
33 * @param array<string, string> $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the
34 * contexts returned by the
35 * {@see WP_URL_Pattern_Prefixer::get_default_contexts()} method.
36 */
37 public function __construct( array $contexts = array() ) {
38 if ( count( $contexts ) > 0 ) {
39 $this->contexts = array_map(
40 static function ( string $str ): string {
41 return self::escape_pattern_string( trailingslashit( $str ) );
42 },
43 $contexts
44 );
45 } else {
46 $this->contexts = self::get_default_contexts();
47 }
48 }
49
50 /**
51 * Prefixes the given URL path pattern with the base path for the given context.
52 *
53 * This ensures that these path patterns work correctly on WordPress subdirectory sites, for example in a multisite
54 * network, or when WordPress itself is installed in a subdirectory of the hostname.
55 *
56 * The given URL path pattern is only prefixed if it does not already include the expected prefix.
57 *
58 * @since 6.8.0
59 *
60 * @param string $path_pattern URL pattern starting with the path segment.
61 * @param string $context Optional. Context to use for prefixing the path pattern. Default 'home'.
62 * @return string URL pattern, prefixed as necessary.
63 */
64 public function prefix_path_pattern( string $path_pattern, string $context = 'home' ): string {
65 // If context path does not exist, the context is invalid.
66 if ( ! isset( $this->contexts[ $context ] ) ) {
67 _doing_it_wrong(
68 __FUNCTION__,
69 esc_html(
70 sprintf(
71 /* translators: %s: context string */
72 __( 'Invalid URL pattern context %s.' ),
73 $context
74 )
75 ),
76 '6.8.0'
77 );
78 return $path_pattern;
79 }
80
81 /*
82 * In the event that the context path contains a :, ? or # (which can cause the URL pattern parser to switch to
83 * another state, though only the latter two should be percent encoded anyway), it additionally needs to be
84 * enclosed in grouping braces. The final forward slash (trailingslashit ensures there is one) affects the
85 * meaning of the * wildcard, so is left outside the braces.
86 */
87 $context_path = $this->contexts[ $context ];
88 $escaped_context_path = $context_path;
89 if ( strcspn( $context_path, ':?#' ) !== strlen( $context_path ) ) {
90 $escaped_context_path = '{' . substr( $context_path, 0, -1 ) . '}/';
91 }
92
93 /*
94 * If the path already starts with the context path (including '/'), remove it first
95 * since it is about to be added back.
96 */
97 if ( str_starts_with( $path_pattern, $context_path ) ) {
98 $path_pattern = substr( $path_pattern, strlen( $context_path ) );
99 }
100
101 return $escaped_context_path . ltrim( $path_pattern, '/' );
102 }
103
104 /**
105 * Returns the default contexts used by the class.
106 *
107 * @since 6.8.0
108 *
109 * @return array<string, string> Map of `$context_string => $base_path` pairs.
110 */
111 public static function get_default_contexts(): array {
112 return array(
113 'home' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) ),
114 'site' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ) ),
115 'uploads' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( wp_upload_dir( null, false )['baseurl'], PHP_URL_PATH ) ) ),
116 'content' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( content_url(), PHP_URL_PATH ) ) ),
117 'plugins' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( plugins_url(), PHP_URL_PATH ) ) ),
118 'template' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_stylesheet_directory_uri(), PHP_URL_PATH ) ) ),
119 'stylesheet' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_template_directory_uri(), PHP_URL_PATH ) ) ),
120 );
121 }
122
123 /**
124 * Escapes a string for use in a URL pattern component.
125 *
126 * @since 6.8.0
127 * @see https://urlpattern.spec.whatwg.org/#escape-a-pattern-string
128 *
129 * @param string $str String to be escaped.
130 * @return string String with backslashes added where required.
131 */
132 private static function escape_pattern_string( string $str ): string {
133 return addcslashes( $str, '+*?:{}()\\' );
134 }
135}
136