@relvehq/agent-ready 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,334 +1,334 @@
1
- <?php
2
- /**
3
- * Negotiation: does this request want Markdown, and for which path?
4
- *
5
- * PHP port of @relvehq/agent-ready core/accept.ts and core/detect.ts. Keep the
6
- * two in step: the cases in tests/run.php mirror test/detect.test.ts.
7
- *
8
- * Accept rule: serve Markdown only when `text/markdown` is listed explicitly
9
- * and its q-value is greater than or equal to the q-value that applies to
10
- * `text/html`. Ties go to Markdown. Wildcards never select Markdown on their
11
- * own, but they count towards HTML's q-value. The user agent is never an
12
- * input (choosing by user agent is cloaking).
13
- *
14
- * Three ways to ask, in order of precedence: a `.md` suffix, `?format=md`
15
- * (also `format=markdown`), the Accept header. Plus the llms.txt path(s).
16
- * Only GET and HEAD are ever considered.
17
- *
18
- * Pure PHP: no WordPress calls, so it is unit-testable without WordPress.
19
- *
20
- * @package Relve_Agent_Ready
21
- */
22
-
23
- if ( ! defined( 'ABSPATH' ) ) {
24
- exit;
25
- }
26
-
27
- /**
28
- * Accept negotiation and request detection.
29
- */
30
- class Relve_AR_Negotiation {
31
-
32
- /** Longer Accept headers are treated as absent (HTML). */
33
- const MAX_ACCEPT_LENGTH = 4096;
34
-
35
- /** Longer paths are never Markdown requests. */
36
- const MAX_PATH_LENGTH = 1024;
37
-
38
- /**
39
- * Which format does this Accept header prefer?
40
- *
41
- * @param string|null $accept The Accept header, or null when absent.
42
- * @return string "markdown" or "html".
43
- */
44
- public static function choose_format( $accept ) {
45
- if ( ! is_string( $accept ) || '' === $accept || strlen( $accept ) > self::MAX_ACCEPT_LENGTH ) {
46
- return 'html';
47
- }
48
- $ranges = self::parse_accept( $accept );
49
- $markdown_q = self::explicit_q( $ranges, 'text', 'markdown' );
50
- if ( null === $markdown_q || $markdown_q <= 0 ) {
51
- return 'html';
52
- }
53
- return $markdown_q >= self::html_q( $ranges ) ? 'markdown' : 'html';
54
- }
55
-
56
- /**
57
- * Parse an Accept header into media ranges.
58
- *
59
- * @param string $accept Header value.
60
- * @return array[] List of array( 'type' => , 'subtype' => , 'q' => float ).
61
- */
62
- public static function parse_accept( $accept ) {
63
- $ranges = array();
64
- foreach ( explode( ',', $accept ) as $part ) {
65
- $params = explode( ';', $part );
66
- $media_type = strtolower( trim( (string) array_shift( $params ) ) );
67
- $bits = explode( '/', $media_type );
68
- if ( count( $bits ) !== 2 || '' === $bits[0] || '' === $bits[1] ) {
69
- continue;
70
- }
71
- $q = 1.0;
72
- foreach ( $params as $param ) {
73
- $kv = explode( '=', $param );
74
- $name = strtolower( trim( $kv[0] ) );
75
- if ( 'q' !== $name ) {
76
- continue;
77
- }
78
- $value = isset( $kv[1] ) ? trim( $kv[1] ) : '';
79
- $q = self::parse_q( $value );
80
- }
81
- $ranges[] = array(
82
- 'type' => $bits[0],
83
- 'subtype' => $bits[1],
84
- 'q' => $q,
85
- );
86
- }
87
- return $ranges;
88
- }
89
-
90
- /**
91
- * RFC 9110 qvalue. Malformed means unacceptable (0), never preferred.
92
- *
93
- * @param string $value Raw q value.
94
- * @return float
95
- */
96
- public static function parse_q( $value ) {
97
- if ( ! preg_match( '/^(0(\.\d{0,3})?|1(\.0{0,3})?)$/', $value ) ) {
98
- return 0.0;
99
- }
100
- return (float) $value;
101
- }
102
-
103
- /**
104
- * Highest q given to exactly type/subtype, or null if not listed.
105
- *
106
- * @param array[] $ranges Parsed ranges.
107
- * @param string $type Type.
108
- * @param string $subtype Subtype.
109
- * @return float|null
110
- */
111
- private static function explicit_q( $ranges, $type, $subtype ) {
112
- $best = null;
113
- foreach ( $ranges as $r ) {
114
- if ( $r['type'] === $type && $r['subtype'] === $subtype ) {
115
- $best = max( null === $best ? 0.0 : $best, $r['q'] );
116
- }
117
- }
118
- return $best;
119
- }
120
-
121
- /**
122
- * The q that applies to text/html: text/html, then text/*, then *\/*.
123
- *
124
- * @param array[] $ranges Parsed ranges.
125
- * @return float
126
- */
127
- private static function html_q( $ranges ) {
128
- foreach ( array( array( 'text', 'html' ), array( 'text', '*' ), array( '*', '*' ) ) as $pair ) {
129
- $q = self::explicit_q( $ranges, $pair[0], $pair[1] );
130
- if ( null !== $q ) {
131
- return $q;
132
- }
133
- }
134
- return 0.0;
135
- }
136
-
137
- /**
138
- * Leading slash, no trailing slash, no query or hash. Null for anything
139
- * that is not a plain path (traversal, backslashes, "//host", too long).
140
- *
141
- * @param mixed $raw Path.
142
- * @return string|null
143
- */
144
- public static function normalize_path( $raw ) {
145
- if ( ! is_string( $raw ) ) {
146
- return null;
147
- }
148
- $path = explode( '#', explode( '?', $raw, 2 )[0], 2 )[0];
149
- if ( '' === $path || '/' !== $path[0] || strlen( $path ) > self::MAX_PATH_LENGTH ) {
150
- return null;
151
- }
152
- if ( false !== strpos( $path, '..' ) || false !== strpos( $path, '\\' ) || preg_match( '#//.#', $path ) ) {
153
- return null;
154
- }
155
- $trimmed = rtrim( $path, '/' );
156
- return '' === $trimmed ? '/' : $trimmed;
157
- }
158
-
159
- /**
160
- * "/x.md" -> "/x"; "<home>/index.md" -> "<home>/" ("/" when home is the
161
- * root); null when not a .md URL or a dotfile ("/.md", "/docs/.md").
162
- *
163
- * @param string $pathname Request path.
164
- * @param string $home_path Home path without trailing slash ("" or "/blog").
165
- * @return string|null
166
- */
167
- public static function strip_md_suffix( $pathname, $home_path = '' ) {
168
- $lower = strtolower( $pathname );
169
- if ( substr( $lower, -3 ) !== '.md' ) {
170
- return null;
171
- }
172
- if ( strtolower( $home_path . '/index.md' ) === $lower ) {
173
- return $home_path . '/';
174
- }
175
- $base = substr( $pathname, 0, -3 );
176
- if ( '' === $base || '/' === substr( $base, -1 ) ) {
177
- return null;
178
- }
179
- return $base;
180
- }
181
-
182
- /**
183
- * "/x" -> "/x.md"; the home path -> "<home>/index.md".
184
- *
185
- * @param string $path Normalised path.
186
- * @param string $home_path Home path without trailing slash.
187
- * @return string
188
- */
189
- public static function md_url_path( $path, $home_path = '' ) {
190
- $path = rtrim( $path, '/' );
191
- if ( '' === $path || $path === $home_path ) {
192
- return $home_path . '/index.md';
193
- }
194
- return $path . '.md';
195
- }
196
-
197
- /**
198
- * Detect a Markdown request.
199
- *
200
- * @param string $method HTTP method.
201
- * @param string $uri Raw REQUEST_URI (path plus query).
202
- * @param string $accept Accept header ('' when absent).
203
- * @param string $home_path Home path without trailing slash.
204
- * @param string[] $llms_paths Paths llms.txt is answered at (empty = none).
205
- * @return array {
206
- * @type bool $markdown Is this a Markdown request?
207
- * @type string|null $via suffix|query|accept|llms, null when not.
208
- * @type string $path Normalised page path without ".md".
209
- * @type bool $llms Is this the llms.txt request?
210
- * @type string|null $uri REQUEST_URI for WordPress to parse (".md" and format removed), null to keep.
211
- * }
212
- */
213
- public static function detect( $method, $uri, $accept, $home_path = '', $llms_paths = array() ) {
214
- $no = array(
215
- 'markdown' => false,
216
- 'via' => null,
217
- 'path' => '/',
218
- 'llms' => false,
219
- 'uri' => null,
220
- );
221
- $method = strtoupper( (string) $method );
222
- if ( 'GET' !== $method && 'HEAD' !== $method ) {
223
- return $no;
224
- }
225
- $uri = (string) $uri;
226
- $parts = explode( '?', $uri, 2 );
227
- $pathname = explode( '#', $parts[0], 2 )[0];
228
- $query = isset( $parts[1] ) ? explode( '#', $parts[1], 2 )[0] : '';
229
-
230
- $format = '';
231
- $kept = array();
232
- if ( '' !== $query ) {
233
- foreach ( explode( '&', $query ) as $pair ) {
234
- if ( '' === $pair ) {
235
- continue;
236
- }
237
- $kv = explode( '=', $pair, 2 );
238
- $name = urldecode( $kv[0] );
239
- if ( 'format' === $name ) {
240
- $format = strtolower( urldecode( isset( $kv[1] ) ? $kv[1] : '' ) );
241
- continue;
242
- }
243
- $kept[] = $pair;
244
- }
245
- }
246
- $search = $kept ? '?' . implode( '&', $kept ) : '';
247
-
248
- if ( in_array( $pathname, (array) $llms_paths, true ) ) {
249
- return array(
250
- 'markdown' => true,
251
- 'via' => 'llms',
252
- 'path' => $pathname,
253
- 'llms' => true,
254
- 'uri' => null,
255
- );
256
- }
257
-
258
- $stripped = self::strip_md_suffix( $pathname, $home_path );
259
- if ( null !== $stripped ) {
260
- $path = self::normalize_path( $stripped );
261
- if ( null === $path ) {
262
- return $no;
263
- }
264
- // WordPress parses the path without ".md". Keep the slash a
265
- // "<home>/index.md" mapped to, so the home path itself parses.
266
- $wp_path = ( '/' === substr( $stripped, -1 ) ) ? ( '/' === $path ? '/' : $path . '/' ) : $path;
267
- return array(
268
- 'markdown' => true,
269
- 'via' => 'suffix',
270
- 'path' => $path,
271
- 'llms' => false,
272
- 'uri' => $wp_path . $search,
273
- );
274
- }
275
-
276
- $path = self::normalize_path( $pathname );
277
- if ( null === $path ) {
278
- return $no;
279
- }
280
- if ( 'md' === $format || 'markdown' === $format ) {
281
- return array(
282
- 'markdown' => true,
283
- 'via' => 'query',
284
- 'path' => $path,
285
- 'llms' => false,
286
- 'uri' => $pathname . $search,
287
- );
288
- }
289
- if ( 'markdown' === self::choose_format( $accept ) ) {
290
- return array(
291
- 'markdown' => true,
292
- 'via' => 'accept',
293
- 'path' => $path,
294
- 'llms' => false,
295
- 'uri' => null,
296
- );
297
- }
298
- $no['path'] = $path;
299
- return $no;
300
- }
301
-
302
- /**
303
- * Segment-aware prefix match, like the package's isExcluded: "/cart"
304
- * excludes "/cart" and "/cart/x", not "/cartoon". Rules are matched
305
- * against the full path and the path relative to the home path.
306
- *
307
- * @param string $path Normalised path.
308
- * @param string[] $rules Path prefixes.
309
- * @param string $home_path Home path without trailing slash.
310
- * @return bool
311
- */
312
- public static function is_excluded( $path, $rules, $home_path = '' ) {
313
- $candidates = array( $path );
314
- if ( '' !== $home_path && ( $path === $home_path || 0 === strpos( $path, $home_path . '/' ) ) ) {
315
- $rel = substr( $path, strlen( $home_path ) );
316
- $candidates[] = '' === $rel ? '/' : $rel;
317
- }
318
- foreach ( (array) $rules as $rule ) {
319
- $p = rtrim( trim( (string) $rule ), '/' );
320
- if ( '' === $p ) {
321
- continue;
322
- }
323
- if ( '/' !== $p[0] ) {
324
- $p = '/' . $p;
325
- }
326
- foreach ( $candidates as $c ) {
327
- if ( $c === $p || 0 === strpos( $c, $p . '/' ) ) {
328
- return true;
329
- }
330
- }
331
- }
332
- return false;
333
- }
334
- }
1
+ <?php
2
+ /**
3
+ * Negotiation: does this request want Markdown, and for which path?
4
+ *
5
+ * PHP port of @relvehq/agent-ready core/accept.ts and core/detect.ts. Keep the
6
+ * two in step: the cases in tests/run.php mirror test/detect.test.ts.
7
+ *
8
+ * Accept rule: serve Markdown only when `text/markdown` is listed explicitly
9
+ * and its q-value is greater than or equal to the q-value that applies to
10
+ * `text/html`. Ties go to Markdown. Wildcards never select Markdown on their
11
+ * own, but they count towards HTML's q-value. The user agent is never an
12
+ * input (choosing by user agent is cloaking).
13
+ *
14
+ * Three ways to ask, in order of precedence: a `.md` suffix, `?format=md`
15
+ * (also `format=markdown`), the Accept header. Plus the llms.txt path(s).
16
+ * Only GET and HEAD are ever considered.
17
+ *
18
+ * Pure PHP: no WordPress calls, so it is unit-testable without WordPress.
19
+ *
20
+ * @package Relve_Agent_Ready
21
+ */
22
+
23
+ if ( ! defined( 'ABSPATH' ) ) {
24
+ exit;
25
+ }
26
+
27
+ /**
28
+ * Accept negotiation and request detection.
29
+ */
30
+ class Relve_AR_Negotiation {
31
+
32
+ /** Longer Accept headers are treated as absent (HTML). */
33
+ const MAX_ACCEPT_LENGTH = 4096;
34
+
35
+ /** Longer paths are never Markdown requests. */
36
+ const MAX_PATH_LENGTH = 1024;
37
+
38
+ /**
39
+ * Which format does this Accept header prefer?
40
+ *
41
+ * @param string|null $accept The Accept header, or null when absent.
42
+ * @return string "markdown" or "html".
43
+ */
44
+ public static function choose_format( $accept ) {
45
+ if ( ! is_string( $accept ) || '' === $accept || strlen( $accept ) > self::MAX_ACCEPT_LENGTH ) {
46
+ return 'html';
47
+ }
48
+ $ranges = self::parse_accept( $accept );
49
+ $markdown_q = self::explicit_q( $ranges, 'text', 'markdown' );
50
+ if ( null === $markdown_q || $markdown_q <= 0 ) {
51
+ return 'html';
52
+ }
53
+ return $markdown_q >= self::html_q( $ranges ) ? 'markdown' : 'html';
54
+ }
55
+
56
+ /**
57
+ * Parse an Accept header into media ranges.
58
+ *
59
+ * @param string $accept Header value.
60
+ * @return array[] List of array( 'type' => , 'subtype' => , 'q' => float ).
61
+ */
62
+ public static function parse_accept( $accept ) {
63
+ $ranges = array();
64
+ foreach ( explode( ',', $accept ) as $part ) {
65
+ $params = explode( ';', $part );
66
+ $media_type = strtolower( trim( (string) array_shift( $params ) ) );
67
+ $bits = explode( '/', $media_type );
68
+ if ( count( $bits ) !== 2 || '' === $bits[0] || '' === $bits[1] ) {
69
+ continue;
70
+ }
71
+ $q = 1.0;
72
+ foreach ( $params as $param ) {
73
+ $kv = explode( '=', $param );
74
+ $name = strtolower( trim( $kv[0] ) );
75
+ if ( 'q' !== $name ) {
76
+ continue;
77
+ }
78
+ $value = isset( $kv[1] ) ? trim( $kv[1] ) : '';
79
+ $q = self::parse_q( $value );
80
+ }
81
+ $ranges[] = array(
82
+ 'type' => $bits[0],
83
+ 'subtype' => $bits[1],
84
+ 'q' => $q,
85
+ );
86
+ }
87
+ return $ranges;
88
+ }
89
+
90
+ /**
91
+ * RFC 9110 qvalue. Malformed means unacceptable (0), never preferred.
92
+ *
93
+ * @param string $value Raw q value.
94
+ * @return float
95
+ */
96
+ public static function parse_q( $value ) {
97
+ if ( ! preg_match( '/^(0(\.\d{0,3})?|1(\.0{0,3})?)$/', $value ) ) {
98
+ return 0.0;
99
+ }
100
+ return (float) $value;
101
+ }
102
+
103
+ /**
104
+ * Highest q given to exactly type/subtype, or null if not listed.
105
+ *
106
+ * @param array[] $ranges Parsed ranges.
107
+ * @param string $type Type.
108
+ * @param string $subtype Subtype.
109
+ * @return float|null
110
+ */
111
+ private static function explicit_q( $ranges, $type, $subtype ) {
112
+ $best = null;
113
+ foreach ( $ranges as $r ) {
114
+ if ( $r['type'] === $type && $r['subtype'] === $subtype ) {
115
+ $best = max( null === $best ? 0.0 : $best, $r['q'] );
116
+ }
117
+ }
118
+ return $best;
119
+ }
120
+
121
+ /**
122
+ * The q that applies to text/html: text/html, then text/*, then *\/*.
123
+ *
124
+ * @param array[] $ranges Parsed ranges.
125
+ * @return float
126
+ */
127
+ private static function html_q( $ranges ) {
128
+ foreach ( array( array( 'text', 'html' ), array( 'text', '*' ), array( '*', '*' ) ) as $pair ) {
129
+ $q = self::explicit_q( $ranges, $pair[0], $pair[1] );
130
+ if ( null !== $q ) {
131
+ return $q;
132
+ }
133
+ }
134
+ return 0.0;
135
+ }
136
+
137
+ /**
138
+ * Leading slash, no trailing slash, no query or hash. Null for anything
139
+ * that is not a plain path (traversal, backslashes, "//host", too long).
140
+ *
141
+ * @param mixed $raw Path.
142
+ * @return string|null
143
+ */
144
+ public static function normalize_path( $raw ) {
145
+ if ( ! is_string( $raw ) ) {
146
+ return null;
147
+ }
148
+ $path = explode( '#', explode( '?', $raw, 2 )[0], 2 )[0];
149
+ if ( '' === $path || '/' !== $path[0] || strlen( $path ) > self::MAX_PATH_LENGTH ) {
150
+ return null;
151
+ }
152
+ if ( false !== strpos( $path, '..' ) || false !== strpos( $path, '\\' ) || preg_match( '#//.#', $path ) ) {
153
+ return null;
154
+ }
155
+ $trimmed = rtrim( $path, '/' );
156
+ return '' === $trimmed ? '/' : $trimmed;
157
+ }
158
+
159
+ /**
160
+ * "/x.md" -> "/x"; "<home>/index.md" -> "<home>/" ("/" when home is the
161
+ * root); null when not a .md URL or a dotfile ("/.md", "/docs/.md").
162
+ *
163
+ * @param string $pathname Request path.
164
+ * @param string $home_path Home path without trailing slash ("" or "/blog").
165
+ * @return string|null
166
+ */
167
+ public static function strip_md_suffix( $pathname, $home_path = '' ) {
168
+ $lower = strtolower( $pathname );
169
+ if ( substr( $lower, -3 ) !== '.md' ) {
170
+ return null;
171
+ }
172
+ if ( strtolower( $home_path . '/index.md' ) === $lower ) {
173
+ return $home_path . '/';
174
+ }
175
+ $base = substr( $pathname, 0, -3 );
176
+ if ( '' === $base || '/' === substr( $base, -1 ) ) {
177
+ return null;
178
+ }
179
+ return $base;
180
+ }
181
+
182
+ /**
183
+ * "/x" -> "/x.md"; the home path -> "<home>/index.md".
184
+ *
185
+ * @param string $path Normalised path.
186
+ * @param string $home_path Home path without trailing slash.
187
+ * @return string
188
+ */
189
+ public static function md_url_path( $path, $home_path = '' ) {
190
+ $path = rtrim( $path, '/' );
191
+ if ( '' === $path || $path === $home_path ) {
192
+ return $home_path . '/index.md';
193
+ }
194
+ return $path . '.md';
195
+ }
196
+
197
+ /**
198
+ * Detect a Markdown request.
199
+ *
200
+ * @param string $method HTTP method.
201
+ * @param string $uri Raw REQUEST_URI (path plus query).
202
+ * @param string $accept Accept header ('' when absent).
203
+ * @param string $home_path Home path without trailing slash.
204
+ * @param string[] $llms_paths Paths llms.txt is answered at (empty = none).
205
+ * @return array {
206
+ * @type bool $markdown Is this a Markdown request?
207
+ * @type string|null $via suffix|query|accept|llms, null when not.
208
+ * @type string $path Normalised page path without ".md".
209
+ * @type bool $llms Is this the llms.txt request?
210
+ * @type string|null $uri REQUEST_URI for WordPress to parse (".md" and format removed), null to keep.
211
+ * }
212
+ */
213
+ public static function detect( $method, $uri, $accept, $home_path = '', $llms_paths = array() ) {
214
+ $no = array(
215
+ 'markdown' => false,
216
+ 'via' => null,
217
+ 'path' => '/',
218
+ 'llms' => false,
219
+ 'uri' => null,
220
+ );
221
+ $method = strtoupper( (string) $method );
222
+ if ( 'GET' !== $method && 'HEAD' !== $method ) {
223
+ return $no;
224
+ }
225
+ $uri = (string) $uri;
226
+ $parts = explode( '?', $uri, 2 );
227
+ $pathname = explode( '#', $parts[0], 2 )[0];
228
+ $query = isset( $parts[1] ) ? explode( '#', $parts[1], 2 )[0] : '';
229
+
230
+ $format = '';
231
+ $kept = array();
232
+ if ( '' !== $query ) {
233
+ foreach ( explode( '&', $query ) as $pair ) {
234
+ if ( '' === $pair ) {
235
+ continue;
236
+ }
237
+ $kv = explode( '=', $pair, 2 );
238
+ $name = urldecode( $kv[0] );
239
+ if ( 'format' === $name ) {
240
+ $format = strtolower( urldecode( isset( $kv[1] ) ? $kv[1] : '' ) );
241
+ continue;
242
+ }
243
+ $kept[] = $pair;
244
+ }
245
+ }
246
+ $search = $kept ? '?' . implode( '&', $kept ) : '';
247
+
248
+ if ( in_array( $pathname, (array) $llms_paths, true ) ) {
249
+ return array(
250
+ 'markdown' => true,
251
+ 'via' => 'llms',
252
+ 'path' => $pathname,
253
+ 'llms' => true,
254
+ 'uri' => null,
255
+ );
256
+ }
257
+
258
+ $stripped = self::strip_md_suffix( $pathname, $home_path );
259
+ if ( null !== $stripped ) {
260
+ $path = self::normalize_path( $stripped );
261
+ if ( null === $path ) {
262
+ return $no;
263
+ }
264
+ // WordPress parses the path without ".md". Keep the slash a
265
+ // "<home>/index.md" mapped to, so the home path itself parses.
266
+ $wp_path = ( '/' === substr( $stripped, -1 ) ) ? ( '/' === $path ? '/' : $path . '/' ) : $path;
267
+ return array(
268
+ 'markdown' => true,
269
+ 'via' => 'suffix',
270
+ 'path' => $path,
271
+ 'llms' => false,
272
+ 'uri' => $wp_path . $search,
273
+ );
274
+ }
275
+
276
+ $path = self::normalize_path( $pathname );
277
+ if ( null === $path ) {
278
+ return $no;
279
+ }
280
+ if ( 'md' === $format || 'markdown' === $format ) {
281
+ return array(
282
+ 'markdown' => true,
283
+ 'via' => 'query',
284
+ 'path' => $path,
285
+ 'llms' => false,
286
+ 'uri' => $pathname . $search,
287
+ );
288
+ }
289
+ if ( 'markdown' === self::choose_format( $accept ) ) {
290
+ return array(
291
+ 'markdown' => true,
292
+ 'via' => 'accept',
293
+ 'path' => $path,
294
+ 'llms' => false,
295
+ 'uri' => null,
296
+ );
297
+ }
298
+ $no['path'] = $path;
299
+ return $no;
300
+ }
301
+
302
+ /**
303
+ * Segment-aware prefix match, like the package's isExcluded: "/cart"
304
+ * excludes "/cart" and "/cart/x", not "/cartoon". Rules are matched
305
+ * against the full path and the path relative to the home path.
306
+ *
307
+ * @param string $path Normalised path.
308
+ * @param string[] $rules Path prefixes.
309
+ * @param string $home_path Home path without trailing slash.
310
+ * @return bool
311
+ */
312
+ public static function is_excluded( $path, $rules, $home_path = '' ) {
313
+ $candidates = array( $path );
314
+ if ( '' !== $home_path && ( $path === $home_path || 0 === strpos( $path, $home_path . '/' ) ) ) {
315
+ $rel = substr( $path, strlen( $home_path ) );
316
+ $candidates[] = '' === $rel ? '/' : $rel;
317
+ }
318
+ foreach ( (array) $rules as $rule ) {
319
+ $p = rtrim( trim( (string) $rule ), '/' );
320
+ if ( '' === $p ) {
321
+ continue;
322
+ }
323
+ if ( '/' !== $p[0] ) {
324
+ $p = '/' . $p;
325
+ }
326
+ foreach ( $candidates as $c ) {
327
+ if ( $c === $p || 0 === strpos( $c, $p . '/' ) ) {
328
+ return true;
329
+ }
330
+ }
331
+ }
332
+ return false;
333
+ }
334
+ }