From a3c66f2605ec937aa8e2d27202722b28e1d3b90b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 16:30:17 -0400 Subject: [PATCH 01/78] Add ability to enforce trailing slashes. --- src/Facades/Endpoint/URL.php | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 037a1bc7cf..e1cfb6f88d 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -14,10 +14,19 @@ */ class URL { + private static $enforceTrailingSlashes = false; private static $externalUriCache = []; /** - * Removes occurrences of "//" in a $path (except when part of a protocol) + * Enforce trailing slashes service provider helper. + */ + public function enforceTrailingSlashes(bool $bool = true): void + { + static::$enforceTrailingSlashes = $bool; + } + + /** + * Removes occurrences of "//" in a $path (except when part of a protocol). * Alias of Path::tidy(). * * @param string $url URL to remove "//" from @@ -334,4 +343,18 @@ public function removeQueryAndFragment($url) return $url; } + + /** + * Normalize trailing slashes (trims by default, but can be enforced onto end). + */ + public function normalizeTrailingSlashes(string $url): string + { + if ($url === '/') { + return $url; + } + + return static::$enforceTrailingSlashes + ? Str::ensureRight($url, '/') + : rtrim($url, '/'); + } } From c5b3c1a008ebf23d2437371903104a67ebcdb457 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 17:44:06 -0400 Subject: [PATCH 02/78] Add test coverage when enforcing trailing slashes. --- tests/Facades/UrlTest.php | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index fbfb07328c..d96eef168f 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -240,4 +240,51 @@ public function it_can_remove_query_and_fragment() $this->assertEquals('https://example.com/about', URL::removeQueryAndFragment('https://example.com/about?foo=bar&baz=qux')); $this->assertEquals('https://example.com/about', URL::removeQueryAndFragment('https://example.com/about?foo=bar&baz=qux#anchor')); } + + #[Test] + #[DataProvider('enforceTrailingSlashesProvider')] + public function enforces_trailing_slashes($url, $expected) + { + URL::enforceTrailingSlashes(); + + $this->assertSame($expected, URL::normalizeTrailingSlashes($url)); + } + + public static function enforceTrailingSlashesProvider() + { + return [ + ['', '/'], + ['/', '/'], + + ['?query', '/?query'], + ['#anchor', '/#anchor'], + ['?foo=bar&baz=qux', '/?foo=bar&baz=qux'], + ['?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + + ['/?query', '/?query'], + ['/#anchor', '/#anchor'], + ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], + ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + + ['https://example.com?query', 'https://example.com/?query'], + ['https://example.com#anchor', 'https://example.com/#anchor'], + ['https://example.com?foo=bar&baz=qux', 'https://example.com/?foo=bar&baz=qux'], + ['https://example.com?foo=bar&baz=qux#anchor', 'https://example.com/?foo=bar&baz=qux#anchor'], + + ['https://example.com/?query', 'https://example.com/?query'], + ['https://example.com/#anchor', 'https://example.com/#anchor'], + ['https://example.com/?foo=bar&baz=qux', 'https://example.com/?foo=bar&baz=qux'], + ['https://example.com/?foo=bar&baz=qux#anchor', 'https://example.com/?foo=bar&baz=qux#anchor'], + + ['https://example.com/about?query', 'https://example.com/about/?query'], + ['https://example.com/about#anchor', 'https://example.com/about/#anchor'], + ['https://example.com/about?foo=bar&baz=qux', 'https://example.com/about/?foo=bar&baz=qux'], + ['https://example.com/about?foo=bar&baz=qux#anchor', 'https://example.com/about/?foo=bar&baz=qux#anchor'], + + ['https://example.com/about/?query', 'https://example.com/about/?query'], + ['https://example.com/about/#anchor', 'https://example.com/about/#anchor'], + ['https://example.com/about/?foo=bar&baz=qux', 'https://example.com/about/?foo=bar&baz=qux'], + ['https://example.com/about/?foo=bar&baz=qux#anchor', 'https://example.com/about/?foo=bar&baz=qux#anchor'], + ]; + } } From c402a04502c35d9e674d625566a4ca83a7bf1b09 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 17:44:21 -0400 Subject: [PATCH 03/78] Improve logic to pass tests. --- src/Facades/Endpoint/URL.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index e1cfb6f88d..fc2cb07155 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -345,16 +345,20 @@ public function removeQueryAndFragment($url) } /** - * Normalize trailing slashes (trims by default, but can be enforced onto end). + * Normalize trailing slashes before query and fragment (trims by default, but can be enforced). */ public function normalizeTrailingSlashes(string $url): string { - if ($url === '/') { - return $url; + $parts = str($url)->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE)->all(); + + $url = array_shift($parts); + + $queryAndFragments = implode($parts); + + if (static::$enforceTrailingSlashes) { + $url = Str::ensureRight($url, '/'); } - return static::$enforceTrailingSlashes - ? Str::ensureRight($url, '/') - : rtrim($url, '/'); + return $url.$queryAndFragments; } } From 9dcc3b2558d4995190fb2198958d6269e8974b86 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 17:58:42 -0400 Subject: [PATCH 04/78] Make sure we tearDown the static property. --- tests/Facades/UrlTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index d96eef168f..2e7c07aee8 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -9,6 +9,13 @@ class UrlTest extends TestCase { + public function tearDown(): void + { + URL::enforceTrailingSlashes(false); + + parent::tearDown(); + } + protected function resolveApplicationConfiguration($app) { parent::resolveApplicationConfiguration($app); From 30ae95e2ebd6ae7bc00d28c6fa93894eacdbe04f Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 17:59:12 -0400 Subject: [PATCH 05/78] Add test coverage for removing trailing slashes. --- tests/Facades/UrlTest.php | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 2e7c07aee8..1b605c4b15 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -294,4 +294,49 @@ public static function enforceTrailingSlashesProvider() ['https://example.com/about/?foo=bar&baz=qux#anchor', 'https://example.com/about/?foo=bar&baz=qux#anchor'], ]; } + + #[Test] + #[DataProvider('removeTrailingSlashesProvider')] + public function removes_trailing_slashes($url, $expected) + { + $this->assertSame($expected, URL::normalizeTrailingSlashes($url)); + } + + public static function removeTrailingSlashesProvider() + { + return [ + ['', '/'], + ['/', '/'], + + ['?query', '?query'], + ['#anchor', '#anchor'], + ['?foo=bar&baz=qux', '?foo=bar&baz=qux'], + ['?foo=bar&baz=qux#anchor', '?foo=bar&baz=qux#anchor'], + + ['/?query', '?query'], + ['/#anchor', '#anchor'], + ['/?foo=bar&baz=qux', '?foo=bar&baz=qux'], + ['/?foo=bar&baz=qux#anchor', '?foo=bar&baz=qux#anchor'], + + ['https://example.com?query', 'https://example.com?query'], + ['https://example.com#anchor', 'https://example.com#anchor'], + ['https://example.com?foo=bar&baz=qux', 'https://example.com?foo=bar&baz=qux'], + ['https://example.com?foo=bar&baz=qux#anchor', 'https://example.com?foo=bar&baz=qux#anchor'], + + ['https://example.com/?query', 'https://example.com?query'], + ['https://example.com/#anchor', 'https://example.com#anchor'], + ['https://example.com/?foo=bar&baz=qux', 'https://example.com?foo=bar&baz=qux'], + ['https://example.com/?foo=bar&baz=qux#anchor', 'https://example.com?foo=bar&baz=qux#anchor'], + + ['https://example.com/about?query', 'https://example.com/about?query'], + ['https://example.com/about#anchor', 'https://example.com/about#anchor'], + ['https://example.com/about?foo=bar&baz=qux', 'https://example.com/about?foo=bar&baz=qux'], + ['https://example.com/about?foo=bar&baz=qux#anchor', 'https://example.com/about?foo=bar&baz=qux#anchor'], + + ['https://example.com/about/?query', 'https://example.com/about?query'], + ['https://example.com/about/#anchor', 'https://example.com/about#anchor'], + ['https://example.com/about/?foo=bar&baz=qux', 'https://example.com/about?foo=bar&baz=qux'], + ['https://example.com/about/?foo=bar&baz=qux#anchor', 'https://example.com/about?foo=bar&baz=qux#anchor'], + ]; + } } From b5474b2373bc7fb96680acbd478fc05b92d5814a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 17:59:20 -0400 Subject: [PATCH 06/78] Pass tests again. --- src/Facades/Endpoint/URL.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index fc2cb07155..621dd53533 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -355,8 +355,14 @@ public function normalizeTrailingSlashes(string $url): string $queryAndFragments = implode($parts); + if (in_array($url, ['', '/']) && ! $queryAndFragments) { + return '/'; + } + if (static::$enforceTrailingSlashes) { $url = Str::ensureRight($url, '/'); + } else { + $url = Str::removeRight($url, '/'); } return $url.$queryAndFragments; From a8036415680af731316535c46f0f6fd94d7ea619 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 17:59:54 -0400 Subject: [PATCH 07/78] Cleanup a bit. --- src/Facades/Endpoint/URL.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 621dd53533..02b6bb3a65 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -349,10 +349,11 @@ public function removeQueryAndFragment($url) */ public function normalizeTrailingSlashes(string $url): string { - $parts = str($url)->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE)->all(); + $parts = str($url) + ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) + ->all(); $url = array_shift($parts); - $queryAndFragments = implode($parts); if (in_array($url, ['', '/']) && ! $queryAndFragments) { From a42432ed38f47369c78acf0860a43bb74dfb8b0f Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 18:03:56 -0400 Subject: [PATCH 08/78] Test that you can pass bool to unenforce trailing slashes. --- tests/Facades/UrlTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 1b605c4b15..ce3892eca0 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -339,4 +339,18 @@ public static function removeTrailingSlashesProvider() ['https://example.com/about/?foo=bar&baz=qux#anchor', 'https://example.com/about?foo=bar&baz=qux#anchor'], ]; } + + #[Test] + public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() + { + $this->assertSame('https://example.com?query', URL::normalizeTrailingSlashes('https://example.com?query')); + + URL::enforceTrailingSlashes(); + + $this->assertSame('https://example.com/?query', URL::normalizeTrailingSlashes('https://example.com?query')); + + URL::enforceTrailingSlashes(false); + + $this->assertSame('https://example.com?query', URL::normalizeTrailingSlashes('https://example.com?query')); + } } From 44b02c9f4e9c81d1276f34e08f51cb7cae00194c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 3 Jun 2025 18:29:07 -0400 Subject: [PATCH 09/78] Singular. --- src/Facades/Endpoint/URL.php | 4 ++-- tests/Facades/UrlTest.php | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 02b6bb3a65..c12df23532 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -345,9 +345,9 @@ public function removeQueryAndFragment($url) } /** - * Normalize trailing slashes before query and fragment (trims by default, but can be enforced). + * Normalize trailing slash before query and fragment (trims by default, but can be enforced). */ - public function normalizeTrailingSlashes(string $url): string + public function normalizeTrailingSlash(string $url): string { $parts = str($url) ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index ce3892eca0..92bc5d6cb6 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -254,7 +254,7 @@ public function enforces_trailing_slashes($url, $expected) { URL::enforceTrailingSlashes(); - $this->assertSame($expected, URL::normalizeTrailingSlashes($url)); + $this->assertSame($expected, URL::normalizeTrailingSlash($url)); } public static function enforceTrailingSlashesProvider() @@ -299,7 +299,7 @@ public static function enforceTrailingSlashesProvider() #[DataProvider('removeTrailingSlashesProvider')] public function removes_trailing_slashes($url, $expected) { - $this->assertSame($expected, URL::normalizeTrailingSlashes($url)); + $this->assertSame($expected, URL::normalizeTrailingSlash($url)); } public static function removeTrailingSlashesProvider() @@ -343,14 +343,14 @@ public static function removeTrailingSlashesProvider() #[Test] public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() { - $this->assertSame('https://example.com?query', URL::normalizeTrailingSlashes('https://example.com?query')); + $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); URL::enforceTrailingSlashes(); - $this->assertSame('https://example.com/?query', URL::normalizeTrailingSlashes('https://example.com?query')); + $this->assertSame('https://example.com/?query', URL::normalizeTrailingSlash('https://example.com?query')); URL::enforceTrailingSlashes(false); - $this->assertSame('https://example.com?query', URL::normalizeTrailingSlashes('https://example.com?query')); + $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); } } From 11ed64bb2a4f418b9c73369c3140f3b5d8acdf62 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 14:36:19 -0400 Subject: [PATCH 10/78] Flesh out tests a bit more around relative URLs. --- tests/Facades/UrlTest.php | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 92bc5d6cb6..1a608a3bec 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -273,6 +273,16 @@ public static function enforceTrailingSlashesProvider() ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + ['/about?query', '/about/?query'], + ['/about#anchor', '/about/#anchor'], + ['/about?foo=bar&baz=qux', '/about/?foo=bar&baz=qux'], + ['/about?foo=bar&baz=qux#anchor', '/about/?foo=bar&baz=qux#anchor'], + + ['/about/?query', '/about/?query'], + ['/about/#anchor', '/about/#anchor'], + ['/about/?foo=bar&baz=qux', '/about/?foo=bar&baz=qux'], + ['/about/?foo=bar&baz=qux#anchor', '/about/?foo=bar&baz=qux#anchor'], + ['https://example.com?query', 'https://example.com/?query'], ['https://example.com#anchor', 'https://example.com/#anchor'], ['https://example.com?foo=bar&baz=qux', 'https://example.com/?foo=bar&baz=qux'], @@ -308,15 +318,25 @@ public static function removeTrailingSlashesProvider() ['', '/'], ['/', '/'], - ['?query', '?query'], - ['#anchor', '#anchor'], - ['?foo=bar&baz=qux', '?foo=bar&baz=qux'], - ['?foo=bar&baz=qux#anchor', '?foo=bar&baz=qux#anchor'], + ['?query', '/?query'], + ['#anchor', '/#anchor'], + ['?foo=bar&baz=qux', '/?foo=bar&baz=qux'], + ['?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + + ['/?query', '/?query'], + ['/#anchor', '/#anchor'], + ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], + ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + + ['/about?query', '/about?query'], + ['/about#anchor', '/about#anchor'], + ['/about?foo=bar&baz=qux', '/about?foo=bar&baz=qux'], + ['/about?foo=bar&baz=qux#anchor', '/about?foo=bar&baz=qux#anchor'], - ['/?query', '?query'], - ['/#anchor', '#anchor'], - ['/?foo=bar&baz=qux', '?foo=bar&baz=qux'], - ['/?foo=bar&baz=qux#anchor', '?foo=bar&baz=qux#anchor'], + ['/about/?query', '/about?query'], + ['/about/#anchor', '/about#anchor'], + ['/about/?foo=bar&baz=qux', '/about?foo=bar&baz=qux'], + ['/about/?foo=bar&baz=qux#anchor', '/about?foo=bar&baz=qux#anchor'], ['https://example.com?query', 'https://example.com?query'], ['https://example.com#anchor', 'https://example.com#anchor'], From 5fca6b8b34b98152c7d5f8e153f7cd4ec67893d7 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 14:36:34 -0400 Subject: [PATCH 11/78] Pass tests again. --- src/Facades/Endpoint/URL.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index c12df23532..b68b6247ac 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -356,11 +356,9 @@ public function normalizeTrailingSlash(string $url): string $url = array_shift($parts); $queryAndFragments = implode($parts); - if (in_array($url, ['', '/']) && ! $queryAndFragments) { - return '/'; - } - - if (static::$enforceTrailingSlashes) { + if (in_array($url, ['', '/'])) { + $url = '/'; + } elseif (static::$enforceTrailingSlashes) { $url = Str::ensureRight($url, '/'); } else { $url = Str::removeRight($url, '/'); From c6951ddae7ba822e768e039b0a00b4d85070136b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 14:47:23 -0400 Subject: [PATCH 12/78] Test that `URL::tidy()` normalizes trailing slashes as well. --- tests/Facades/UrlTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 1a608a3bec..33f9c394d7 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -255,6 +255,7 @@ public function enforces_trailing_slashes($url, $expected) URL::enforceTrailingSlashes(); $this->assertSame($expected, URL::normalizeTrailingSlash($url)); + $this->assertSame($expected, URL::tidy($url)); } public static function enforceTrailingSlashesProvider() @@ -310,6 +311,7 @@ public static function enforceTrailingSlashesProvider() public function removes_trailing_slashes($url, $expected) { $this->assertSame($expected, URL::normalizeTrailingSlash($url)); + $this->assertSame($expected, URL::tidy($url)); } public static function removeTrailingSlashesProvider() @@ -364,13 +366,16 @@ public static function removeTrailingSlashesProvider() public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() { $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); + $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); URL::enforceTrailingSlashes(); $this->assertSame('https://example.com/?query', URL::normalizeTrailingSlash('https://example.com?query')); + $this->assertSame('https://example.com/?query', URL::tidy('https://example.com?query')); URL::enforceTrailingSlashes(false); $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); + $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); } } From e5ddae83a58dafd97157ea7ffb2d72bbd7da74b5 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 14:47:42 -0400 Subject: [PATCH 13/78] Implement normalization on `tidy()`. --- src/Facades/Endpoint/URL.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index b68b6247ac..912c56f91c 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -27,14 +27,15 @@ public function enforceTrailingSlashes(bool $bool = true): void /** * Removes occurrences of "//" in a $path (except when part of a protocol). - * Alias of Path::tidy(). + * + * Also normalizes trailing slash (configurable via `enforceTrailingSlashes()` function). * * @param string $url URL to remove "//" from * @return string */ public function tidy($url) { - return Path::tidy($url); + return self::normalizeTrailingSlash(Path::tidy($url)); } /** From eeaa3ae9cf04f6372eb33b8fb0e8d0f8a83c8afc Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 14:48:13 -0400 Subject: [PATCH 14/78] Update failing tests around `makeAbsolute()` calling `tidy()`. --- tests/Facades/UrlTest.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 33f9c394d7..d212d948d9 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -149,23 +149,23 @@ public function it_makes_urls_absolute($url, $expected, $forceScheme = false) public static function absoluteProvider() { return [ - ['http://example.com', 'http://example.com'], - ['http://example.com/', 'http://example.com/'], - ['/', 'http://absolute-url-resolved-from-request.com/'], + ['http://example.com', 'http://example.com'], // absolute url provided, so url is left alone. + ['http://example.com/', 'http://example.com/'], // absolute url provided, so url is left alone. + ['/', 'http://absolute-url-resolved-from-request.com'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], - ['/foo/', 'http://absolute-url-resolved-from-request.com/foo/'], + ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], - ['http://example.com', 'http://example.com', 'https'], // absolute url provided, so scheme is left alone. - ['http://example.com/', 'http://example.com/', 'https'], // absolute url provided, so scheme is left alone. - ['/', 'https://absolute-url-resolved-from-request.com/', 'https'], + ['http://example.com', 'http://example.com', 'https'], // absolute url provided, so scheme and trailing slash are left alone. + ['http://example.com/', 'http://example.com/', 'https'], // absolute url provided, so scheme and trailing slash are left alone. + ['/', 'https://absolute-url-resolved-from-request.com', 'https'], ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - ['/foo/', 'https://absolute-url-resolved-from-request.com/foo/', 'https'], + ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - ['https://example.com', 'https://example.com', 'http'], // absolute url provided, so scheme is left alone. - ['https://example.com/', 'https://example.com/', 'http'], // absolute url provided, so scheme is left alone. - ['/', 'http://absolute-url-resolved-from-request.com/', 'http'], + ['https://example.com', 'https://example.com', 'http'], // absolute url provided, so scheme and trailing slash are left alone. + ['https://example.com/', 'https://example.com/', 'http'], // absolute url provided, so scheme and trailing slash are left alone. + ['/', 'http://absolute-url-resolved-from-request.com', 'http'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], - ['/foo/', 'http://absolute-url-resolved-from-request.com/foo/', 'http'], + ['/foo/', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ]; } From 32526f2cdde041d8b198298b953a9857e0d6049d Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 15:17:34 -0400 Subject: [PATCH 15/78] =?UTF-8?q?If=20`makeAbsolute()`=20tidy=E2=80=99s,?= =?UTF-8?q?=20then=20`makeRelative()`=20should=20as=20well.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Facades/Endpoint/URL.php | 2 +- tests/Facades/UrlTest.php | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 912c56f91c..2e85f27258 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -185,7 +185,7 @@ public function makeRelative($url) $url .= '#'.$parsed['fragment']; } - return $url; + return self::tidy($url); } /** diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index d212d948d9..289ec664ea 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -182,50 +182,50 @@ public static function relativeProvider() ['http://example.com', '/'], ['http://example.com/', '/'], ['http://example.com/foo', '/foo'], - ['http://example.com/foo/', '/foo/'], + ['http://example.com/foo/', '/foo'], ['http://example.com/foo/bar', '/foo/bar'], - ['http://example.com/foo/bar/', '/foo/bar/'], + ['http://example.com/foo/bar/', '/foo/bar'], ['/', '/'], ['/foo', '/foo'], - ['/foo/', '/foo/'], + ['/foo/', '/foo'], ['/foo/bar', '/foo/bar'], - ['/foo/bar/', '/foo/bar/'], + ['/foo/bar/', '/foo/bar'], ['http://example.com?bar=baz', '/?bar=baz'], ['http://example.com/?bar=baz', '/?bar=baz'], ['http://example.com/foo?bar=baz', '/foo?bar=baz'], - ['http://example.com/foo/?bar=baz', '/foo/?bar=baz'], + ['http://example.com/foo/?bar=baz', '/foo?bar=baz'], ['http://example.com/foo/bar?bar=baz', '/foo/bar?bar=baz'], - ['http://example.com/foo/bar/?bar=baz', '/foo/bar/?bar=baz'], + ['http://example.com/foo/bar/?bar=baz', '/foo/bar?bar=baz'], ['/?bar=baz', '/?bar=baz'], ['/foo?bar=baz', '/foo?bar=baz'], - ['/foo/?bar=baz', '/foo/?bar=baz'], + ['/foo/?bar=baz', '/foo?bar=baz'], ['/foo/bar?bar=baz', '/foo/bar?bar=baz'], - ['/foo/bar/?bar=baz', '/foo/bar/?bar=baz'], + ['/foo/bar/?bar=baz', '/foo/bar?bar=baz'], ['http://example.com#fragment', '/#fragment'], ['http://example.com/#fragment', '/#fragment'], ['http://example.com/foo#fragment', '/foo#fragment'], - ['http://example.com/foo/#fragment', '/foo/#fragment'], + ['http://example.com/foo/#fragment', '/foo#fragment'], ['http://example.com/foo/bar#fragment', '/foo/bar#fragment'], - ['http://example.com/foo/bar/#fragment', '/foo/bar/#fragment'], + ['http://example.com/foo/bar/#fragment', '/foo/bar#fragment'], ['/#fragment', '/#fragment'], ['/foo#fragment', '/foo#fragment'], - ['/foo/#fragment', '/foo/#fragment'], + ['/foo/#fragment', '/foo#fragment'], ['/foo/bar#fragment', '/foo/bar#fragment'], - ['/foo/bar/#fragment', '/foo/bar/#fragment'], + ['/foo/bar/#fragment', '/foo/bar#fragment'], ['http://example.com?bar=baz#fragment', '/?bar=baz#fragment'], ['http://example.com/?bar=baz#fragment', '/?bar=baz#fragment'], ['http://example.com/foo?bar=baz#fragment', '/foo?bar=baz#fragment'], - ['http://example.com/foo/?bar=baz#fragment', '/foo/?bar=baz#fragment'], + ['http://example.com/foo/?bar=baz#fragment', '/foo?bar=baz#fragment'], ['http://example.com/foo/bar?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], - ['http://example.com/foo/bar/?bar=baz#fragment', '/foo/bar/?bar=baz#fragment'], + ['http://example.com/foo/bar/?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], ['/?bar=baz#fragment', '/?bar=baz#fragment'], ['/foo?bar=baz#fragment', '/foo?bar=baz#fragment'], - ['/foo/?bar=baz#fragment', '/foo/?bar=baz#fragment'], + ['/foo/?bar=baz#fragment', '/foo?bar=baz#fragment'], ['/foo/bar?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], - ['/foo/bar/?bar=baz#fragment', '/foo/bar/?bar=baz#fragment'], + ['/foo/bar/?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], ]; } From 60cf351f1374ba19ce5549acf6f4b6f0903de4cc Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 15:21:56 -0400 Subject: [PATCH 16/78] These should always reliably return strings now. --- src/Facades/Endpoint/URL.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 2e85f27258..b7b8ef8a40 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -31,9 +31,8 @@ public function enforceTrailingSlashes(bool $bool = true): void * Also normalizes trailing slash (configurable via `enforceTrailingSlashes()` function). * * @param string $url URL to remove "//" from - * @return string */ - public function tidy($url) + public function tidy($url): string { return self::normalizeTrailingSlash(Path::tidy($url)); } @@ -169,9 +168,8 @@ public function removeSiteUrl($url) * Make an absolute URL relative. * * @param string $url - * @return string */ - public function makeRelative($url) + public function makeRelative($url): string { $parsed = parse_url($url); @@ -192,9 +190,8 @@ public function makeRelative($url) * Make a relative URL absolute. * * @param string $url - * @return string */ - public function makeAbsolute($url) + public function makeAbsolute($url): string { // If it doesn't start with a slash, we'll just leave it as-is. if (! Str::startsWith($url, '/')) { From 4dd4de4688ffb319a7b0ab422d6abf17f0ed3ca6 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 4 Jun 2025 15:35:32 -0400 Subject: [PATCH 17/78] =?UTF-8?q?Use=20`URL`=20helpers=20in=20`Site`=20cla?= =?UTF-8?q?ss=20=F0=9F=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Sites/Site.php | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/Sites/Site.php b/src/Sites/Site.php index cc1f6f365a..506df2f976 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -4,6 +4,7 @@ use Statamic\Contracts\Data\Augmentable; use Statamic\Data\HasAugmentedData; +use Statamic\Facades\URL; use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\TextDirection; @@ -51,13 +52,7 @@ public function lang() public function url() { - $url = $this->config['url']; - - if ($url === '/') { - return '/'; - } - - return Str::removeRight($url, '/'); + return URL::tidy($this->config['url']); } public function direction() @@ -77,22 +72,12 @@ public function attribute($key, $default = null) public function absoluteUrl() { - if (Str::startsWith($url = $this->url(), '/')) { - $url = Str::ensureLeft($url, request()->getSchemeAndHttpHost()); - } - - return Str::removeRight($url, '/'); + return URL::makeAbsolute($this->url()); } public function relativePath($url) { - $url = Str::ensureRight($url, '/'); - - $path = Str::removeLeft($url, $this->absoluteUrl()); - - $path = Str::removeRight(Str::ensureLeft($path, '/'), '/'); - - return $path === '' ? '/' : $path; + return URL::makeRelative(Str::removeLeft($url, $this->absoluteUrl())); } public function set($key, $value) From 9a7ebea8536362e50bd9bad57e3154017afa60b3 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 5 Jun 2025 13:31:10 -0400 Subject: [PATCH 18/78] More `tidy()` cleanup. --- src/Routing/Routable.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Routing/Routable.php b/src/Routing/Routable.php index b5f09c6f3b..31b9a7472a 100644 --- a/src/Routing/Routable.php +++ b/src/Routing/Routable.php @@ -103,11 +103,6 @@ private function makeAbsolute($url) return $url; } - $url = vsprintf('%s/%s', [ - rtrim($this->site()->absoluteUrl(), '/'), - ltrim($url, '/'), - ]); - - return $url === '/' ? $url : rtrim($url, '/'); + return URL::tidy($this->site()->absoluteUrl().'/'.$url); } } From b89947d354d7b3956ba1564191487b2c04456b2f Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 5 Jun 2025 13:41:10 -0400 Subject: [PATCH 19/78] Flesh out ancestor tests a bit more. --- tests/Facades/UrlTest.php | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 289ec664ea..ed73f2f11f 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -115,11 +115,25 @@ public static function ancestorProvider() 'directory with trailing slashes to nested directory' => ['/foo/', '/foo/bar', false], 'homepage with query string to homepage' => ['/?baz=qux', '/', false], - 'directory with query string to homepage' => ['/foo?baz=qux', '/', true], - 'nested directory with query string to homepage' => ['/foo/bar?baz=qux', '/', true], - 'nested directory with query string to directory' => ['/foo/bar?baz=qux', '/foo', true], - 'directory with query string to nested directory' => ['/foo?baz=qux', '/foo/bar', false], - 'homepage with query string to nested directory' => ['/?baz=qux', '/foo', false], + 'directory with query string to homepage' => ['/foo?baz=qux', '/', true], + 'nested directory with query string to homepage' => ['/foo/bar?baz=qux', '/', true], + 'nested directory with query string to directory' => ['/foo/bar?baz=qux', '/foo', true], + 'directory with query string to nested directory' => ['/foo?baz=qux', '/foo/bar', false], + 'homepage with query string to nested directory' => ['/?baz=qux', '/foo', false], + + 'homepage with query string to homepage with query string' => ['/?baz=qux', '/?alpha=true', false], + 'directory with query string to homepage with query string' => ['/foo?baz=qux', '/?alpha=true', true], + 'nested directory with query string to homepage with query string' => ['/foo/bar?baz=qux', '/?alpha=true', true], + 'nested directory with query string to directory with query string' => ['/foo/bar?baz=qux', '/foo?alpha=true', true], + 'directory with query string to nested directory with query string' => ['/foo?baz=qux', '/foo/bar?alpha=true', false], + 'homepage with query string to nested directory with query string' => ['/?baz=qux', '/foo?alpha=true', false], + + 'homepage with anchor fragment to homepage with anchor fragment' => ['/#alpha', '/#beta', false], + 'directory with anchor fragment to homepage with anchor fragment' => ['/foo#alpha', '/#beta', true], + 'nested directory with anchor fragment to homepage with anchor fragment' => ['/foo/bar#alpha', '/#beta', true], + 'nested directory with anchor fragment to directory with anchor fragment' => ['/foo/bar#alpha', '/foo#beta', true], + 'directory with anchor fragment to nested directory with anchor fragment' => ['/foo#alpha', '/foo/bar#beta', false], + 'homepage with anchor fragment to nested directory with anchor fragment' => ['/#alpha', '/foo#beta', false], ]; } From 42024281981d41e0019e345cf395b04d04b7cdf9 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 5 Jun 2025 13:41:28 -0400 Subject: [PATCH 20/78] Pass failing tests. --- src/Facades/Endpoint/URL.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index b7b8ef8a40..bde88422a0 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -94,9 +94,8 @@ public function parent($url) */ public function isAncestorOf($child, $ancestor) { - $child = Str::before($child, '?'); - $child = Str::ensureRight($child, '/'); - $ancestor = Str::ensureRight($ancestor, '/'); + $child = Str::ensureRight(self::removeQueryAndFragment($child), '/'); + $ancestor = Str::ensureRight(self::removeQueryAndFragment($ancestor), '/'); if ($child === $ancestor) { return false; From 8ab16b49aaf3c60278f31bc6c319d8176546bbdf Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 5 Jun 2025 13:58:56 -0400 Subject: [PATCH 21/78] =?UTF-8?q?These=20should=20already=20by=20tidy?= =?UTF-8?q?=E2=80=99d.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Tags/Structure.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tags/Structure.php b/src/Tags/Structure.php index 9ce1809039..7129143ccc 100644 --- a/src/Tags/Structure.php +++ b/src/Tags/Structure.php @@ -137,7 +137,7 @@ public function toArray($tree, $parent = null, $depth = 1) 'count' => $index + 1, 'first' => $index === 0, 'last' => $index === count($tree) - 1, - 'is_current' => ! is_null($url) && rtrim($url, '/') === rtrim($this->currentUrl, '/'), + 'is_current' => ! is_null($url) && $url === $this->currentUrl, 'is_parent' => ! is_null($url) && $this->siteAbsoluteUrl !== $absoluteUrl && URL::isAncestorOf($this->currentUrl, $url), 'is_external' => URL::isExternal((string) $absoluteUrl), ], $this->params->bool('include_parents', true) ? ['parent' => $parent] : []); From 4ef1331a19dac2b2852f5371d980e9cc6aa63b65 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 5 Jun 2025 13:59:37 -0400 Subject: [PATCH 22/78] =?UTF-8?q?I=20think=20we=E2=80=99re=20using=20wrong?= =?UTF-8?q?=20var=20here,=20and=20it=20worked=20by=20accident=20because=20?= =?UTF-8?q?of=20a=20trailing=20slash=20=F0=9F=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Tags/Structure.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tags/Structure.php b/src/Tags/Structure.php index 7129143ccc..727e4dec3f 100644 --- a/src/Tags/Structure.php +++ b/src/Tags/Structure.php @@ -138,7 +138,7 @@ public function toArray($tree, $parent = null, $depth = 1) 'first' => $index === 0, 'last' => $index === count($tree) - 1, 'is_current' => ! is_null($url) && $url === $this->currentUrl, - 'is_parent' => ! is_null($url) && $this->siteAbsoluteUrl !== $absoluteUrl && URL::isAncestorOf($this->currentUrl, $url), + 'is_parent' => ! is_null($url) && $this->siteAbsoluteUrl !== $url && URL::isAncestorOf($this->currentUrl, $url), 'is_external' => URL::isExternal((string) $absoluteUrl), ], $this->params->bool('include_parents', true) ? ['parent' => $parent] : []); })->filter()->values(); From 602bbd8594bd3eb11150b9c5ae51e324962bc38e Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 5 Jun 2025 16:21:26 -0400 Subject: [PATCH 23/78] We can tidy slashes here too. --- src/Facades/Endpoint/URL.php | 8 +++----- tests/Facades/UrlTest.php | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index bde88422a0..461ce69a44 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -137,7 +137,7 @@ public function prependSiteRoot($url, $locale = null, $controller = true) */ public function prependSiteUrl($url, $locale = null, $controller = true) { - $prepend = rtrim(Config::getSiteUrl($locale), '/'); + $prepend = Config::getSiteUrl($locale); // If we don't want the front controller, we'll have to strip // it out since it should be in the site URL already. @@ -147,9 +147,7 @@ public function prependSiteUrl($url, $locale = null, $controller = true) $prepend = Str::removeRight($prepend, $file); } - $prepend = Str::ensureRight($prepend, '/'); - - return Str::ensureLeft(ltrim($url, '/'), $prepend); + return self::tidy($prepend.'/'.$url); } /** @@ -265,7 +263,7 @@ public function getSiteUrl() { $rootUrl = url()->to('/'); - return Str::ensureRight($rootUrl, '/'); + return self::tidy($rootUrl, '/'); } /** diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index ed73f2f11f..4a43ac402a 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -140,13 +140,13 @@ public static function ancestorProvider() #[Test] public function gets_site_url() { - $this->assertEquals('http://absolute-url-resolved-from-request.com/', URL::getSiteUrl()); + $this->assertEquals('http://absolute-url-resolved-from-request.com', URL::getSiteUrl()); \Illuminate\Support\Facades\URL::forceScheme('https'); - $this->assertEquals('https://absolute-url-resolved-from-request.com/', URL::getSiteUrl()); + $this->assertEquals('https://absolute-url-resolved-from-request.com', URL::getSiteUrl()); \Illuminate\Support\Facades\URL::forceScheme('http'); - $this->assertEquals('http://absolute-url-resolved-from-request.com/', URL::getSiteUrl()); + $this->assertEquals('http://absolute-url-resolved-from-request.com', URL::getSiteUrl()); } #[Test] From 60bc63b614e0678f9a86b5abb6033b11c5cc0b18 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 10:46:37 -0400 Subject: [PATCH 24/78] Add test coverage for `parent()` method. --- tests/Facades/UrlTest.php | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 4a43ac402a..a1d488b5fc 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\URL; +use Statamic\Support\Str; use Tests\TestCase; class UrlTest extends TestCase @@ -86,6 +87,45 @@ public function testDeterminesExternalUrlWhenUsingRelativeInConfig() $this->assertFalse(URL::isExternal(null)); } + #[Test] + #[DataProvider('parentProvider')] + public function it_gets_the_parent_url($child, $parent) + { + $this->assertSame($parent, URL::parent($child)); + + URL::enforceTrailingSlashes(); + + $this->assertSame(Str::ensureRight($parent, '/'), URL::parent($child)); + } + + public static function parentProvider() + { + return [ + 'relative homepage to homepage' => ['/', '/'], + 'absolute homepage to homepage' => ['http://localhost', 'http://localhost'], + 'absolute homepage to homepage with trailing slash' => ['http://localhost/', 'http://localhost'], + + 'relative route to parent homepage' => ['/foo', '/'], + 'relative route to parent homepage with trailing slash' => ['/foo/', '/'], + 'absolute route to parent homepage' => ['http://localhost/foo', 'http://localhost'], + 'absolute route to parent homepage with trailing slash' => ['http://localhost/foo/', 'http://localhost'], + + 'relative nested route to parent homepage' => ['/foo/bar', '/foo'], + 'relative nested route to parent homepage with trailing slash' => ['/foo/bar/', '/foo'], + 'absolute nested route to parent homepage' => ['http://localhost/foo/bar', 'http://localhost/foo'], + 'absolute nested route to parent homepage with trailing slash' => ['http://localhost/foo/bar/', 'http://localhost/foo'], + + 'removes query from relative url' => ['/?alpha', '/'], + 'removes query from absolute url' => ['http://localhost/?alpha', 'http://localhost'], + 'removes anchor fragment from relative url' => ['/#alpha', '/'], + 'removes anchor fragment from absolute url' => ['http://localhost/#alpha', 'http://localhost'], + 'removes query and anchor fragment from relative url' => ['/?alpha#beta', '/'], + 'removes query and anchor fragment from absolute url' => ['http://localhost/?alpha#beta', 'http://localhost'], + + 'preserves scheme and host' => ['https://example.com/foo/bar/', 'https://example.com/foo'], + ]; + } + #[Test] #[DataProvider('ancestorProvider')] public function it_checks_whether_a_url_is_an_ancestor_of_another($child, $parent, $isAncestor) From 813a829117471382bef52c78c3e327dc3a7dfd40 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 10:47:00 -0400 Subject: [PATCH 25/78] Make `parent()` tests pass. --- src/Facades/Endpoint/URL.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 461ce69a44..7f3659b103 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -81,12 +81,15 @@ public function replaceSlug($url, $slug) */ public function parent($url) { - $url_array = explode('/', $url); - array_pop($url_array); + $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); - $url = implode('/', $url_array); + if (parse_url($url)['path'] === '/') { + return self::tidy($url); + } + + $url = preg_replace('/[^\/]*\/$/', '', $url); - return ($url == '') ? '/' : $url; + return self::tidy($url); } /** From 5e1140dfac02ef6c97a6e93e30600fd58489f54a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 11:16:36 -0400 Subject: [PATCH 26/78] Cleanup. --- tests/Facades/UrlTest.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index a1d488b5fc..19cc8d7dd9 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -24,7 +24,8 @@ protected function resolveApplicationConfiguration($app) $app['config']->set('app.url', 'http://absolute-url-resolved-from-request.com'); } - public function testPrependsSiteUrl() + #[Test] + public function it_prepends_site_url() { $this->setSiteValue('en', 'url', 'http://site.com/'); @@ -34,7 +35,8 @@ public function testPrependsSiteUrl() ); } - public function testPrependsSiteUrlWithController() + #[Test] + public function it_prepends_site_url_with_controller() { $this->setSiteValue('en', 'url', 'http://site.com/index.php/'); @@ -44,7 +46,8 @@ public function testPrependsSiteUrlWithController() ); } - public function testPrependsSiteUrlWithoutController() + #[Test] + public function it_prepends_site_url_without_controller() { // Override with what would be used on a normal request. request()->server->set('SCRIPT_NAME', '/index.php'); @@ -57,7 +60,8 @@ public function testPrependsSiteUrlWithoutController() ); } - public function testDeterminesExternalUrl() + #[Test] + public function it_determines_external_url() { $this->setSiteValue('en', 'url', 'http://this-site.com/'); $this->assertTrue(URL::isExternal('http://that-site.com')); @@ -72,7 +76,8 @@ public function testDeterminesExternalUrl() $this->assertFalse(URL::isExternal(null)); } - public function testDeterminesExternalUrlWhenUsingRelativeInConfig() + #[Test] + public function it_determines_external_url_when_using_relative_in_config() { $this->setSiteValue('en', 'url', '/'); $this->assertTrue(URL::isExternal('http://that-site.com')); From fd7750cde9405260536d0c128b7ca0e093f2ae61 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 11:28:03 -0400 Subject: [PATCH 27/78] =?UTF-8?q?Test=20that=20`prependSiteUrl()`=20tidy?= =?UTF-8?q?=E2=80=99s=20trailing=20slashes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Facades/UrlTest.php | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 19cc8d7dd9..cac222fb10 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -29,10 +29,11 @@ public function it_prepends_site_url() { $this->setSiteValue('en', 'url', 'http://site.com/'); - $this->assertEquals( - 'http://site.com/foo', - URL::prependSiteUrl('/foo') - ); + $this->assertEquals('http://site.com/foo', URL::prependSiteUrl('/foo')); + + URL::enforceTrailingSlashes(); + + $this->assertEquals('http://site.com/foo/', URL::prependSiteUrl('/foo')); } #[Test] @@ -40,10 +41,11 @@ public function it_prepends_site_url_with_controller() { $this->setSiteValue('en', 'url', 'http://site.com/index.php/'); - $this->assertEquals( - 'http://site.com/index.php/foo', - URL::prependSiteUrl('/foo') - ); + $this->assertEquals('http://site.com/index.php/foo', URL::prependSiteUrl('/foo')); + + URL::enforceTrailingSlashes(); + + $this->assertEquals('http://site.com/index.php/foo/', URL::prependSiteUrl('/foo')); } #[Test] @@ -54,10 +56,11 @@ public function it_prepends_site_url_without_controller() $this->setSiteValue('en', 'url', 'http://site.com/index.php/'); - $this->assertEquals( - 'http://site.com/foo', - URL::prependSiteUrl('/foo', null, false) - ); + $this->assertEquals('http://site.com/foo', URL::prependSiteUrl('/foo', null, false)); + + URL::enforceTrailingSlashes(); + + $this->assertEquals('http://site.com/foo/', URL::prependSiteUrl('/foo', null, false)); } #[Test] From ef39c1daf4fd854fc12d09d4d658ed41aa48a3d3 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 11:33:35 -0400 Subject: [PATCH 28/78] Pass failing `prependSiteUrl()` test. --- src/Facades/Endpoint/URL.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 7f3659b103..26219efecb 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -140,7 +140,7 @@ public function prependSiteRoot($url, $locale = null, $controller = true) */ public function prependSiteUrl($url, $locale = null, $controller = true) { - $prepend = Config::getSiteUrl($locale); + $prepend = Str::removeRight(Config::getSiteUrl($locale), '/'); // If we don't want the front controller, we'll have to strip // it out since it should be in the site URL already. From 822edb76b2af126122cb7e6ecd9fb579963985ac Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 11:35:48 -0400 Subject: [PATCH 29/78] Ensure these still work too. --- tests/Facades/UrlTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index cac222fb10..abfe6e5367 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -70,6 +70,8 @@ public function it_determines_external_url() $this->assertTrue(URL::isExternal('http://that-site.com')); $this->assertTrue(URL::isExternal('http://that-site.com/')); $this->assertTrue(URL::isExternal('http://that-site.com/some-slug')); + $this->assertTrue(URL::isExternal('http://that-site.com/some-slug?foo')); + $this->assertTrue(URL::isExternal('http://that-site.com/some-slug#anchor')); $this->assertFalse(URL::isExternal('http://this-site.com')); $this->assertFalse(URL::isExternal('http://this-site.com/')); $this->assertFalse(URL::isExternal('http://this-site.com/some-slug')); @@ -86,6 +88,8 @@ public function it_determines_external_url_when_using_relative_in_config() $this->assertTrue(URL::isExternal('http://that-site.com')); $this->assertTrue(URL::isExternal('http://that-site.com/')); $this->assertTrue(URL::isExternal('http://that-site.com/some-slug')); + $this->assertTrue(URL::isExternal('http://that-site.com/some-slug?foo')); + $this->assertTrue(URL::isExternal('http://that-site.com/some-slug#anchor')); $this->assertFalse(URL::isExternal('http://absolute-url-resolved-from-request.com')); $this->assertFalse(URL::isExternal('http://absolute-url-resolved-from-request.com/')); $this->assertFalse(URL::isExternal('http://absolute-url-resolved-from-request.com/some-slug')); From bbe4c24bb6cd9e653f211c63df5479f4a31f2323 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 12:23:35 -0400 Subject: [PATCH 30/78] Add test coverage for `slug()` method. --- tests/Facades/UrlTest.php | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index abfe6e5367..981a76db95 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -99,6 +99,45 @@ public function it_determines_external_url_when_using_relative_in_config() $this->assertFalse(URL::isExternal(null)); } + #[Test] + #[DataProvider('slugProvider')] + public function it_gets_the_slug_at_the_end_of_a_url($url, $slug) + { + $this->assertSame($slug, URL::slug($url)); + } + + public static function slugProvider() + { + return [ + 'relative homepage should have no slug' => ['/', null], + 'absolute homepage should have no slug' => ['http://localhost', null], + 'absolute homepage with trailing slash should have no slug' => ['http://localhost/', null], + + 'relative route to slug' => ['/foo', 'foo'], + 'relative route to slug with trailing slash' => ['/foo/', 'foo'], + 'absolute route to slug' => ['http://localhost/foo', 'foo'], + 'absolute route to slug with trailing slash' => ['http://localhost/foo/', 'foo'], + + 'relative nested route to slug' => ['/entries/foo', 'foo'], + 'relative nested route to slug with trailing slash' => ['/entries/foo/', 'foo'], + 'absolute nested route to slug' => ['http://localhost/entries/foo', 'foo'], + 'absolute nested route to slug with trailing slash' => ['http://localhost/entries/foo/', 'foo'], + + 'removes query from relative url' => ['/entries/foo?alpha', 'foo'], + 'removes query from relative url with trailing slash' => ['/entries/foo/?alpha', 'foo'], + 'removes query from absolute url' => ['http://localhost/entries/foo?alpha', 'foo'], + 'removes query from absolute url with trailing slash' => ['http://localhost/entries/foo/?alpha', 'foo'], + 'removes anchor fragment from relative url' => ['/entries/foo#alpha', 'foo'], + 'removes anchor fragment from relative url with trailing slash' => ['/entries/foo/#alpha', 'foo'], + 'removes anchor fragment from absolute url' => ['http://localhost/entries/foo#alpha', 'foo'], + 'removes anchor fragment from absolute url with trailing slash' => ['http://localhost/entries/foo/#alpha', 'foo'], + 'removes query and anchor fragment from relative url' => ['/entries/foo?alpha#beta', 'foo'], + 'removes query and anchor fragment from relative url with trailing slash' => ['/entries/foo/?alpha#beta', 'foo'], + 'removes query and anchor fragment from absolute url' => ['http://localhost/entries/foo?alpha#beta', 'foo'], + 'removes query and anchor fragment from absolute url with trailing slash' => ['http://localhost/entries/foo/?alpha#beta', 'foo'], + ]; + } + #[Test] #[DataProvider('parentProvider')] public function it_gets_the_parent_url($child, $parent) From 00a2be1c28f8117370e93d6aa0e706e1a72ebfdd Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 12:24:03 -0400 Subject: [PATCH 31/78] Pass failing `slug()` tests. --- src/Facades/Endpoint/URL.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 26219efecb..1fa94a448e 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -51,14 +51,20 @@ public function assemble($args) } /** - * Get the slug of a URL. + * Get the slug at the end of a URL. * * @param string $url URL to parse * @return string */ public function slug($url) { - return basename($url); + $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); + + if (parse_url($url)['path'] === '/') { + return null; + } + + return basename(self::removeQueryAndFragment($url)); } /** From 6633b6507fe4cd621e4065f4a2eed503b9a3cd5a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 12:43:50 -0400 Subject: [PATCH 32/78] Add test coverage for `replaceSlug()` method. --- tests/Facades/UrlTest.php | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 981a76db95..6b645d00e9 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -138,6 +138,52 @@ public static function slugProvider() ]; } + #[Test] + #[DataProvider('replaceSlugProvider')] + public function it_replaces_the_slug_at_the_end_of_a_url($url, $replaced) + { + $this->assertSame($replaced, URL::replaceSlug($url, 'bar')); + + URL::enforceTrailingSlashes(); + + $replaced = preg_replace('/localhost$/', 'localhost/', $replaced); + $replaced = str_replace('bar', 'bar/', $replaced); + + $this->assertSame($replaced, URL::replaceSlug($url, 'bar')); + } + + public static function replaceSlugProvider() + { + return [ + 'relative homepage should have no slug' => ['/', '/'], + 'absolute homepage should have no slug' => ['http://localhost', 'http://localhost'], + 'absolute homepage with trailing slash should have no slug' => ['http://localhost/', 'http://localhost'], + + 'relative route to slug' => ['/foo', '/bar'], + 'relative route to slug with trailing slash' => ['/foo/', '/bar'], + 'absolute route to slug' => ['http://localhost/foo', 'http://localhost/bar'], + 'absolute route to slug with trailing slash' => ['http://localhost/foo/', 'http://localhost/bar'], + + 'relative nested route to slug' => ['/entries/foo', '/entries/bar'], + 'relative nested route to slug with trailing slash' => ['/entries/foo/', '/entries/bar'], + 'absolute nested route to slug' => ['http://localhost/entries/foo', 'http://localhost/entries/bar'], + 'absolute nested route to slug with trailing slash' => ['http://localhost/entries/bar', 'http://localhost/entries/bar'], + + 'removes query from relative url' => ['/entries/foo?alpha', '/entries/bar?alpha'], + 'removes query from relative url with trailing slash' => ['/entries/foo/?alpha', '/entries/bar?alpha'], + 'removes query from absolute url' => ['http://localhost/entries/foo?alpha', 'http://localhost/entries/bar?alpha'], + 'removes query from absolute url with trailing slash' => ['http://localhost/entries/foo/?alpha', 'http://localhost/entries/bar?alpha'], + 'removes anchor fragment from relative url' => ['/entries/foo#alpha', '/entries/bar#alpha'], + 'removes anchor fragment from relative url with trailing slash' => ['/entries/foo/#alpha', '/entries/bar#alpha'], + 'removes anchor fragment from absolute url' => ['http://localhost/entries/foo#alpha', 'http://localhost/entries/bar#alpha'], + 'removes anchor fragment from absolute url with trailing slash' => ['http://localhost/entries/foo/#alpha', 'http://localhost/entries/bar#alpha'], + 'removes query and anchor fragment from relative url' => ['/entries/foo?alpha#beta', '/entries/bar?alpha#beta'], + 'removes query and anchor fragment from relative url with trailing slash' => ['/entries/foo/?alpha#beta', '/entries/bar?alpha#beta'], + 'removes query and anchor fragment from absolute url' => ['http://localhost/entries/foo?alpha#beta', 'http://localhost/entries/bar?alpha#beta'], + 'removes query and anchor fragment from absolute url with trailing slash' => ['http://localhost/entries/foo/?alpha#beta', 'http://localhost/entries/bar?alpha#beta'], + ]; + } + #[Test] #[DataProvider('parentProvider')] public function it_gets_the_parent_url($child, $parent) From 20c179d2ac0491606ffc81f519e63012ff43ace6 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 12:44:02 -0400 Subject: [PATCH 33/78] Pass failing `replaceSlug()` tests. --- src/Facades/Endpoint/URL.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 1fa94a448e..d2f4b90607 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -68,7 +68,7 @@ public function slug($url) } /** - * Swaps the slug of a $url with the $slug provided. + * Replace the slug at the end of a URL with the provided slug. * * @param string $url URL to modify * @param string $slug New slug to use @@ -76,7 +76,20 @@ public function slug($url) */ public function replaceSlug($url, $slug) { - return Path::replaceSlug($url, $slug); + if (parse_url(Str::ensureRight($url, '/'))['path'] === '/') { + return self::tidy($url); + } + + $parts = str($url) + ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) + ->all(); + + $url = Str::removeRight(array_shift($parts), '/'); + $queryAndFragments = implode($parts); + + $url = self::tidy(Path::replaceSlug($url, $slug)); + + return $url.$queryAndFragments; } /** From cd390e1af3d05d3d4fffce9a2bae43f65e09256e Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 12:49:55 -0400 Subject: [PATCH 34/78] Remove `getDefaultUri()` method that never did anything useful. --- src/Facades/Endpoint/URL.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index d2f4b90607..2a12759c9d 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -2,7 +2,6 @@ namespace Statamic\Facades\Endpoint; -use Statamic\Data\Services\ContentService; use Statamic\Facades\Config; use Statamic\Facades\Path; use Statamic\Facades\Pattern; @@ -316,19 +315,6 @@ public function encode($url) return strtr(rawurlencode($url), $dont_encode); } - /** - * Given a localized URI, get the default URI. - * - * @param string $locale The locale of the provided URI - * @param string $uri The URI from which to find the default - */ - public function getDefaultUri($locale, $uri) - { - return $uri; // TODO - - return app(ContentService::class)->defaultUri($locale, $uri); - } - /** * Return a gravatar image. * From 9dc3d88770a4b1345428f4d3921566bb8b6407cc Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 13:06:17 -0400 Subject: [PATCH 35/78] More return types. --- src/Facades/Endpoint/URL.php | 46 ++++++++++++------------------------ 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 2a12759c9d..54bd9eb3e0 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -40,9 +40,8 @@ public function tidy($url): string * Assembles a URL from an ordered list of segments. * * @param mixed string Open ended number of arguments - * @return string */ - public function assemble($args) + public function assemble($args): string { $args = func_get_args(); @@ -53,9 +52,8 @@ public function assemble($args) * Get the slug at the end of a URL. * * @param string $url URL to parse - * @return string */ - public function slug($url) + public function slug($url): ?string { $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); @@ -71,9 +69,8 @@ public function slug($url) * * @param string $url URL to modify * @param string $slug New slug to use - * @return string */ - public function replaceSlug($url, $slug) + public function replaceSlug($url, $slug): string { if (parse_url(Str::ensureRight($url, '/'))['path'] === '/') { return self::tidy($url); @@ -95,9 +92,8 @@ public function replaceSlug($url, $slug) * Get the parent URL. * * @param string $url - * @return string */ - public function parent($url) + public function parent($url): string { $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); @@ -113,7 +109,7 @@ public function parent($url) /** * Checks if one URL is an ancestor of another. */ - public function isAncestorOf($child, $ancestor) + public function isAncestorOf($child, $ancestor): bool { $child = Str::ensureRight(self::removeQueryAndFragment($child), '/'); $ancestor = Str::ensureRight(self::removeQueryAndFragment($ancestor), '/'); @@ -131,9 +127,8 @@ public function isAncestorOf($child, $ancestor) * @param string $url * @param string|null $locale * @param bool $controller - * @return string */ - public function prependSiteRoot($url, $locale = null, $controller = true) + public function prependSiteRoot($url, $locale = null, $controller = true): string { // Backwards compatibility fix: // 2.1 added the $locale argument in the second position to match prependSiteurl. @@ -154,9 +149,8 @@ public function prependSiteRoot($url, $locale = null, $controller = true) * @param string $url * @param string|null $locale * @param bool $controller - * @return string */ - public function prependSiteUrl($url, $locale = null, $controller = true) + public function prependSiteUrl($url, $locale = null, $controller = true): string { $prepend = Str::removeRight(Config::getSiteUrl($locale), '/'); @@ -175,9 +169,8 @@ public function prependSiteUrl($url, $locale = null, $controller = true) * Removes the site root url from the beginning of a URL. * * @param string $url - * @return string */ - public function removeSiteUrl($url) + public function removeSiteUrl($url): string { return preg_replace('#^'.Config::getSiteUrl().'#', '/', $url); } @@ -221,10 +214,8 @@ public function makeAbsolute($url): string /** * Get the current URL. - * - * @return string */ - public function getCurrent() + public function getCurrent(): string { return self::format(app('request')->path()); } @@ -233,9 +224,8 @@ public function getCurrent() * Formats a URL properly. * * @param string $url - * @return string */ - public function format($url) + public function format($url): string { return self::tidy('/'.trim($url, '/')); } @@ -244,9 +234,8 @@ public function format($url) * Checks whether a URL is external or not. * * @param string $url - * @return bool */ - public function isExternal($url) + public function isExternal($url): bool { if (isset(self::$externalUriCache[$url])) { return self::$externalUriCache[$url]; @@ -277,10 +266,8 @@ public function clearExternalUrlCache() /** * Get the current site url from Apache headers. - * - * @return string */ - public function getSiteUrl() + public function getSiteUrl(): string { $rootUrl = url()->to('/'); @@ -291,9 +278,8 @@ public function getSiteUrl() * Encode a URL. * * @param string $url - * @return string */ - public function encode($url) + public function encode($url): string { $dont_encode = [ '%2F' => '/', @@ -320,9 +306,8 @@ public function encode($url) * * @param string $email * @param int $size - * @return string */ - public function gravatar($email, $size = null) + public function gravatar($email, $size = null): string { $url = 'https://www.gravatar.com/avatar/'.e(md5(strtolower($email))); @@ -337,9 +322,8 @@ public function gravatar($email, $size = null) * Remove query and fragment from end of URL. * * @param string $url - * @return string */ - public function removeQueryAndFragment($url) + public function removeQueryAndFragment($url): string { $url = Str::before($url, '?'); // Remove query params $url = Str::before($url, '#'); // Remove anchor fragment From a35b064a499ada4c4cbfbd8475aca27a76232ad6 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 13:45:35 -0400 Subject: [PATCH 36/78] Add test coverage for `assemble()` method. --- tests/Facades/UrlTest.php | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 6b645d00e9..9f79ca3ad6 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -99,6 +99,57 @@ public function it_determines_external_url_when_using_relative_in_config() $this->assertFalse(URL::isExternal(null)); } + #[Test] + #[DataProvider('assembleProvider')] + public function it_can_assemble_urls($segments, $assembled) + { + $this->assertSame($assembled, URL::assemble(...$segments)); + + URL::enforceTrailingSlashes(); + + $parts = str($assembled) + ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) + ->all(); + + $url = array_shift($parts); + $queryAndFragments = implode($parts); + $assembledWithTrailingSlash = Str::ensureRight($url, '/').$queryAndFragments; + + $this->assertSame($assembledWithTrailingSlash, URL::assemble(...$segments)); + } + + public static function assembleProvider() + { + return [ + 'relative homepage' => [['/'], '/'], + 'absolute homepage' => [['http://localhost'], 'http://localhost'], + 'absolute homepage with trailing slash' => [['http://localhost/'], 'http://localhost'], + + 'relative route' => [['/', 'foo'], '/foo'], + 'relative route with trailing slash' => [['/', 'foo/'], '/foo'], + 'absolute route' => [['http://localhost', 'foo'], 'http://localhost/foo'], + 'absolute route with trailing slashes' => [['http://localhost/', 'foo/'], 'http://localhost/foo'], + + 'relative nested route' => [['/', 'foo', 'bar'], '/foo/bar'], + 'relative nested route with trailing slashes' => [['/', 'foo/', 'bar/'], '/foo/bar'], + 'absolute nested route' => [['http://localhost', 'foo', 'bar'], 'http://localhost/foo/bar'], + 'absolute nested route with trailing slashes' => [['http://localhost/', 'foo/', 'bar/'], 'http://localhost/foo/bar'], + + 'with query from relative url' => [['/', 'entries', 'foo', '?alpha'], '/entries/foo?alpha'], + 'with query from relative url with trailing slashes' => [['/', 'entries/', 'foo/', '?alpha'], '/entries/foo?alpha'], + 'with query from absolute url' => [['http://localhost', 'entries', 'foo', '?alpha'], 'http://localhost/entries/foo?alpha'], + 'with query from absolute url with trailing slashes' => [['http://localhost/', 'entries/', 'foo/', '?alpha'], 'http://localhost/entries/foo?alpha'], + 'with anchor fragment from relative url' => [['/', 'entries', 'foo', '#alpha'], '/entries/foo#alpha'], + 'with anchor fragment from relative url with trailing slashes' => [['/', 'entries/', 'foo/', '#alpha'], '/entries/foo#alpha'], + 'with anchor fragment from absolute url' => [['http://localhost', 'entries', 'foo', '#alpha'], 'http://localhost/entries/foo#alpha'], + 'with anchor fragment from absolute url with trailing slashes' => [['http://localhost/', 'entries/', 'foo/', '#alpha'], 'http://localhost/entries/foo#alpha'], + 'with query and anchor fragment from relative url' => [['/', 'entries', 'foo', '?alpha#beta'], '/entries/foo?alpha#beta'], + 'with query and anchor fragment from relative url with trailing slashes' => [['/', 'entries/', 'foo/', '?alpha#beta'], '/entries/foo?alpha#beta'], + 'with query and anchor fragment from absolute url' => [['http://localhost', 'entries', 'foo', '?alpha#beta'], 'http://localhost/entries/foo?alpha#beta'], + 'with query and anchor fragment from absolute url with trailing slashes' => [['http://localhost/', 'entries/', 'foo/', '?alpha#beta'], 'http://localhost/entries/foo?alpha#beta'], + ]; + } + #[Test] #[DataProvider('slugProvider')] public function it_gets_the_slug_at_the_end_of_a_url($url, $slug) @@ -518,15 +569,24 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() { $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); + $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); + $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); + $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); URL::enforceTrailingSlashes(); $this->assertSame('https://example.com/?query', URL::normalizeTrailingSlash('https://example.com?query')); $this->assertSame('https://example.com/?query', URL::tidy('https://example.com?query')); + $this->assertSame('https://example.com/foo/', URL::parent('https://example.com/foo/bar')); + $this->assertSame('http://localhost/foo/', URL::prependSiteUrl('/foo')); + $this->assertSame('https://example.com/bar/', URL::replaceSlug('https://example.com/foo', 'bar')); URL::enforceTrailingSlashes(false); $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); + $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); + $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); + $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); } } From 983d28a30f89e17ad3393e83942ea6c170531a62 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 13:46:17 -0400 Subject: [PATCH 37/78] Pass failing `assemble()` tests. --- src/Facades/Endpoint/URL.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 54bd9eb3e0..b531ee632e 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -41,11 +41,9 @@ public function tidy($url): string * * @param mixed string Open ended number of arguments */ - public function assemble($args): string + public function assemble(...$segments): string { - $args = func_get_args(); - - return Path::assemble($args); + return self::tidy(Path::assemble($segments)); } /** From 3f1a6899a11c00d1bd1654d795f403b202a51f55 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 13:52:16 -0400 Subject: [PATCH 38/78] Allow null case here. --- src/Facades/Endpoint/URL.php | 2 +- tests/Facades/UrlTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index b531ee632e..176a93c8de 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -321,7 +321,7 @@ public function gravatar($email, $size = null): string * * @param string $url */ - public function removeQueryAndFragment($url): string + public function removeQueryAndFragment($url): ?string { $url = Str::before($url, '?'); // Remove query params $url = Str::before($url, '#'); // Remove anchor fragment diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 9f79ca3ad6..3671b0b531 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -434,6 +434,8 @@ public static function relativeProvider() #[Test] public function it_can_remove_query_and_fragment() { + $this->assertEquals(null, URL::removeQueryAndFragment(null)); + $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com?query')); $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com#anchor')); $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com?foo=bar&baz=qux')); From 7648dac79c04ab10a5539865b4631e858bca8b81 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 14:51:35 -0400 Subject: [PATCH 39/78] Add test coverage for `removeSiteUrl()` method. --- tests/Facades/UrlTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 3671b0b531..247398d944 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -63,6 +63,24 @@ public function it_prepends_site_url_without_controller() $this->assertEquals('http://site.com/foo/', URL::prependSiteUrl('/foo', null, false)); } + #[Test] + public function it_removes_site_url() + { + $this->setSiteValue('en', 'url', 'http://site.com/'); + + $this->assertEquals('/', URL::removeSiteUrl('http://site.com')); + $this->assertEquals('/foo', URL::removeSiteUrl('http://site.com/foo')); + $this->assertEquals('/foo', URL::removeSiteUrl('http://site.com/foo/')); + $this->assertEquals('http://not-site.com/foo', URL::removeSiteUrl('http://not-site.com/foo/')); + + URL::enforceTrailingSlashes(); + + $this->assertEquals('/', URL::removeSiteUrl('http://site.com/')); + $this->assertEquals('/foo/', URL::removeSiteUrl('http://site.com/foo')); + $this->assertEquals('/foo/', URL::removeSiteUrl('http://site.com/foo/')); + $this->assertEquals('http://not-site.com/foo/', URL::removeSiteUrl('http://not-site.com/foo/')); + } + #[Test] public function it_determines_external_url() { From 36bf7354b2f9a63d97b17ce8895a65111a657266 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 14:51:54 -0400 Subject: [PATCH 40/78] Pass failing `removeSiteUrl()` tests. --- src/Facades/Endpoint/URL.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 176a93c8de..ffa00a49c0 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -170,7 +170,7 @@ public function prependSiteUrl($url, $locale = null, $controller = true): string */ public function removeSiteUrl($url): string { - return preg_replace('#^'.Config::getSiteUrl().'#', '/', $url); + return self::tidy(preg_replace('#^'.Config::getSiteUrl().'#', '/', $url)); } /** From 3031ce8cbc2493a45ea2982af2b88dc6b0122781 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 15:25:56 -0400 Subject: [PATCH 41/78] More assertions, we love assertions. --- tests/Facades/UrlTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 247398d944..6d18574bef 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -591,6 +591,10 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); + $this->assertSame('/foo', URL::removeSiteUrl('http://localhost/foo')); + $this->assertSame('http://absolute-url-resolved-from-request.com/foo?query', URL::makeAbsolute('/foo?query')); + $this->assertSame('/foo?query', URL::makeRelative('https://example.com/foo?query')); + $this->assertSame('https://example.com/bar?query', URL::assemble('https://example.com', 'bar', '?query')); $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); URL::enforceTrailingSlashes(); @@ -599,6 +603,10 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() $this->assertSame('https://example.com/?query', URL::tidy('https://example.com?query')); $this->assertSame('https://example.com/foo/', URL::parent('https://example.com/foo/bar')); $this->assertSame('http://localhost/foo/', URL::prependSiteUrl('/foo')); + $this->assertSame('/foo/', URL::removeSiteUrl('http://localhost/foo')); + $this->assertSame('http://absolute-url-resolved-from-request.com/foo/?query', URL::makeAbsolute('/foo?query')); + $this->assertSame('/foo/?query', URL::makeRelative('https://example.com/foo?query')); + $this->assertSame('https://example.com/bar/?query', URL::assemble('https://example.com', 'bar', '?query')); $this->assertSame('https://example.com/bar/', URL::replaceSlug('https://example.com/foo', 'bar')); URL::enforceTrailingSlashes(false); @@ -607,6 +615,10 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); + $this->assertSame('/foo', URL::removeSiteUrl('http://localhost/foo')); + $this->assertSame('http://absolute-url-resolved-from-request.com/foo?query', URL::makeAbsolute('/foo?query')); + $this->assertSame('/foo?query', URL::makeRelative('https://example.com/foo?query')); + $this->assertSame('https://example.com/bar?query', URL::assemble('https://example.com', 'bar', '?query')); $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); } } From 5b299bc19222bd1ed69eb45ed8f89b3b975b5770 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 16:23:23 -0400 Subject: [PATCH 42/78] Add test coverage for checking external urls w/ `makeAbsolute()`. --- src/Facades/URL.php | 2 ++ tests/Facades/UrlTest.php | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Facades/URL.php b/src/Facades/URL.php index a32c15e446..cadd68cff9 100644 --- a/src/Facades/URL.php +++ b/src/Facades/URL.php @@ -5,6 +5,8 @@ use Illuminate\Support\Facades\Facade; /** + * TODO: Update these... + * * @method static string tidy($url) * @method static string assemble($args) * @method static string slug($url) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 6d18574bef..1010177662 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -364,25 +364,30 @@ public function it_makes_urls_absolute($url, $expected, $forceScheme = false) } $this->assertSame($expected, URL::makeAbsolute($url)); + + URL::enforceTrailingSlashes(); + + $this->assertSame(Str::ensureRight($expected, '/'), URL::makeAbsolute($url)); } public static function absoluteProvider() { return [ - ['http://example.com', 'http://example.com'], // absolute url provided, so url is left alone. - ['http://example.com/', 'http://example.com/'], // absolute url provided, so url is left alone. + ['http://external.com', 'http://external.com'], // external absolute url provided, so url is left alone. + ['http://external.com/', 'http://external.com'], // external absolute url provided, so url is left alone. + ['http://absolute-url-resolved-from-request.com/foo', 'http://absolute-url-resolved-from-request.com/foo'], // already absolute, but we can still normalize trailing slashes and scheme ['/', 'http://absolute-url-resolved-from-request.com'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], - ['http://example.com', 'http://example.com', 'https'], // absolute url provided, so scheme and trailing slash are left alone. - ['http://example.com/', 'http://example.com/', 'https'], // absolute url provided, so scheme and trailing slash are left alone. + ['http://external.com', 'http://external.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. + ['http://external.com/', 'http://external.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'https://absolute-url-resolved-from-request.com', 'https'], ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - ['https://example.com', 'https://example.com', 'http'], // absolute url provided, so scheme and trailing slash are left alone. - ['https://example.com/', 'https://example.com/', 'http'], // absolute url provided, so scheme and trailing slash are left alone. + ['https://external.com', 'https://external.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. + ['https://external.com/', 'https://external.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'http://absolute-url-resolved-from-request.com', 'http'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo', 'http'], From 4b40d5aeef7629f4a55a54244d29c0e187cbd38b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 16:24:23 -0400 Subject: [PATCH 43/78] Pass failing tests for `makeAbsolute()`. --- src/Facades/Endpoint/URL.php | 58 +++++++++++++++++++++++++++++------- src/Listeners/ClearState.php | 2 +- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index ffa00a49c0..40a59b3d90 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -14,7 +14,9 @@ class URL { private static $enforceTrailingSlashes = false; - private static $externalUriCache = []; + private static $siteUrlsCache; + private static $externalSiteUriCache = []; + private static $externalAppUriCache = []; /** * Enforce trailing slashes service provider helper. @@ -203,10 +205,14 @@ public function makeRelative($url): string public function makeAbsolute($url): string { // If it doesn't start with a slash, we'll just leave it as-is. - if (! Str::startsWith($url, '/')) { + if (Str::startsWith($url, ['http:', 'https:']) && self::isExternalToApplication($url)) { return $url; } + if (! Str::startsWith($url, '/')) { + return self::tidy($url); + } + return self::tidy(Str::ensureLeft($url, self::getSiteUrl())); } @@ -229,22 +235,22 @@ public function format($url): string } /** - * Checks whether a URL is external or not. + * Checks whether a URL is external to current site. * * @param string $url */ public function isExternal($url): bool { - if (isset(self::$externalUriCache[$url])) { - return self::$externalUriCache[$url]; + if (isset(self::$externalSiteUriCache[$url])) { + return self::$externalSiteUriCache[$url]; } if (! $url) { return false; } - if (Str::startsWith($url, ['/', '#'])) { - return self::$externalUriCache[$url] = false; + if (Str::startsWith($url, ['/', '?', '#'])) { + return self::$externalSiteUriCache[$url] = false; } $isExternal = ! Pattern::startsWith( @@ -252,14 +258,44 @@ public function isExternal($url): bool Site::current()->absoluteUrl() ); - self::$externalUriCache[$url] = $isExternal; + return self::$externalSiteUriCache[$url] = $isExternal; + } + + /** + * Checks whether a URL is external to whole Statamic application. + * + * @param string $url + */ + public function isExternalToApplication($url): bool + { + if (isset(self::$externalAppUriCache[$url])) { + return self::$externalAppUriCache[$url]; + } + + if (! $url) { + return false; + } + + if (Str::startsWith($url, ['/', '?', '#'])) { + return self::$externalAppUriCache[$url] = false; + } + + self::$siteUrlsCache ??= Site::all() + ->map->url() + ->filter(fn ($siteUrl) => Str::startsWith($siteUrl, ['http:', 'https:'])); + + $isExternal = self::$siteUrlsCache + ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) + ->isNotEmpty(); - return $isExternal; + return self::$externalAppUriCache[$url] = $isExternal; } - public function clearExternalUrlCache() + public function clearUrlCache() { - self::$externalUriCache = []; + self::$siteUrlsCache = null; + self::$externalSiteUriCache = []; + self::$externalAppUriCache = []; } /** diff --git a/src/Listeners/ClearState.php b/src/Listeners/ClearState.php index 2b0fbf7148..5832245d67 100644 --- a/src/Listeners/ClearState.php +++ b/src/Listeners/ClearState.php @@ -10,6 +10,6 @@ class ClearState public function handle() { StateManager::resetState(); - URL::clearExternalUrlCache(); + URL::clearUrlCache(); } } From e2fb5a49590b163be149cdfc6420b35dbce302d6 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 17:04:45 -0400 Subject: [PATCH 44/78] Flush url cache in test helpers after setting site(s). --- tests/TestCase.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index e8c5e54d68..2dedc5b788 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Assert; use Statamic\Facades\Config; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\Http\Middleware\CP\AuthenticateSession; abstract class TestCase extends \Orchestra\Testbench\TestCase @@ -138,6 +139,8 @@ protected function setSites($sites) Site::setSites($sites); Config::set('statamic.system.multisite', Site::hasMultiple()); + + URL::clearUrlCache(); } protected function setSiteValue($site, $key, $value) @@ -145,6 +148,8 @@ protected function setSiteValue($site, $key, $value) Site::setSiteValue($site, $key, $value); Config::set('statamic.system.multisite', Site::hasMultiple()); + + URL::clearUrlCache(); } protected function assertEveryItem($items, $callback) From b25509f8e17f4d1bef7c906fe3288fb7a5b3d42e Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 17:04:54 -0400 Subject: [PATCH 45/78] Fix assertions and do the right thing. --- src/Facades/Endpoint/URL.php | 2 +- tests/Facades/UrlTest.php | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 40a59b3d90..e036f07a54 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -286,7 +286,7 @@ public function isExternalToApplication($url): bool $isExternal = self::$siteUrlsCache ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) - ->isNotEmpty(); + ->isEmpty(); return self::$externalAppUriCache[$url] = $isExternal; } diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 1010177662..c0e9313067 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -359,6 +359,8 @@ public function gets_site_url() #[DataProvider('absoluteProvider')] public function it_makes_urls_absolute($url, $expected, $forceScheme = false) { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + if ($forceScheme) { \Illuminate\Support\Facades\URL::forceScheme($forceScheme); } @@ -367,27 +369,31 @@ public function it_makes_urls_absolute($url, $expected, $forceScheme = false) URL::enforceTrailingSlashes(); - $this->assertSame(Str::ensureRight($expected, '/'), URL::makeAbsolute($url)); + $expected = Str::contains($url, 'external.com') + ? $url + : Str::ensureRight($expected, '/'); + + $this->assertSame($expected, URL::makeAbsolute($url)); } public static function absoluteProvider() { return [ ['http://external.com', 'http://external.com'], // external absolute url provided, so url is left alone. - ['http://external.com/', 'http://external.com'], // external absolute url provided, so url is left alone. - ['http://absolute-url-resolved-from-request.com/foo', 'http://absolute-url-resolved-from-request.com/foo'], // already absolute, but we can still normalize trailing slashes and scheme + ['http://external.com/', 'http://external.com/'], // external absolute url provided, so url is left alone. + ['http://this-site.com/foo/', 'http://this-site.com/foo'], // already absolute, but we can still normalize trailing slashes ['/', 'http://absolute-url-resolved-from-request.com'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], ['http://external.com', 'http://external.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. - ['http://external.com/', 'http://external.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. + ['http://external.com/', 'http://external.com/', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'https://absolute-url-resolved-from-request.com', 'https'], ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['https://external.com', 'https://external.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. - ['https://external.com/', 'https://external.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. + ['https://external.com/', 'https://external.com/', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'http://absolute-url-resolved-from-request.com', 'http'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo', 'http'], From 298a89fcde68e3845326cea6d18a89817930317c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 17:53:11 -0400 Subject: [PATCH 46/78] Add test coverage for `isExternalToApplication()` method. --- src/Facades/URL.php | 1 + tests/Facades/UrlTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/Facades/URL.php b/src/Facades/URL.php index cadd68cff9..b970fdee5e 100644 --- a/src/Facades/URL.php +++ b/src/Facades/URL.php @@ -21,6 +21,7 @@ * @method static string getCurrent() * @method static string format($url) * @method static bool isExternal($url) + * @method static bool isExternalToApplication($url) * @method static string getSiteUrl() * @method static string encode($url) * @method static mixed getDefaultUri($locale, $uri) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index c0e9313067..48b0fc7335 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -85,6 +85,7 @@ public function it_removes_site_url() public function it_determines_external_url() { $this->setSiteValue('en', 'url', 'http://this-site.com/'); + $this->assertTrue(URL::isExternal('http://that-site.com')); $this->assertTrue(URL::isExternal('http://that-site.com/')); $this->assertTrue(URL::isExternal('http://that-site.com/some-slug')); @@ -117,6 +118,37 @@ public function it_determines_external_url_when_using_relative_in_config() $this->assertFalse(URL::isExternal(null)); } + #[Test] + public function it_determines_if_external_url_to_application() + { + $this->setSites([ + 'first' => ['name' => 'First', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + 'third' => ['name' => 'Third', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], + 'second' => ['name' => 'Second', 'locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $this->assertTrue(URL::isExternalToApplication('http://that-site.com')); + $this->assertTrue(URL::isExternalToApplication('http://that-site.com/')); + $this->assertTrue(URL::isExternalToApplication('http://that-site.com/some-slug')); + $this->assertTrue(URL::isExternalToApplication('http://that-site.com/some-slug?foo')); + $this->assertTrue(URL::isExternalToApplication('http://that-site.com/some-slug#anchor')); + + $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com')); + $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com/')); + $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com/some-slug')); + $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com/some-slug?foo')); + $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com/some-slug#anchor')); + + // TODO... + // $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com')); + // $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/')); + // $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/some-slug')); + // $this->assertFalse(URL::isExternalToApplication('/foo')); + // $this->assertFalse(URL::isExternalToApplication('#anchor')); + // $this->assertFalse(URL::isExternalToApplication('')); + // $this->assertFalse(URL::isExternalToApplication(null)); + } + #[Test] #[DataProvider('assembleProvider')] public function it_can_assemble_urls($segments, $assembled) From b59693a3641e6856be0eadf9a84042777a56fa48 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 18:06:09 -0400 Subject: [PATCH 47/78] =?UTF-8?q?If=20url=20is=20same=20as=20current=20req?= =?UTF-8?q?uest=20domain,=20it=20can=E2=80=99t=20be=20external=20to=20appl?= =?UTF-8?q?ication.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Facades/Endpoint/URL.php | 19 +++++++++++-------- tests/Facades/UrlTest.php | 15 +++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index e036f07a54..3c7c7e23ee 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -4,7 +4,6 @@ use Statamic\Facades\Config; use Statamic\Facades\Path; -use Statamic\Facades\Pattern; use Statamic\Facades\Site; use Statamic\Support\Str; @@ -253,10 +252,9 @@ public function isExternal($url): bool return self::$externalSiteUriCache[$url] = false; } - $isExternal = ! Pattern::startsWith( - Str::ensureRight($url, '/'), - Site::current()->absoluteUrl() - ); + $url = Str::ensureRight($url, '/'); + + $isExternal = ! Str::startsWith($url, Str::ensureRight(Site::current()->absoluteUrl(), '/')); return self::$externalSiteUriCache[$url] = $isExternal; } @@ -280,15 +278,20 @@ public function isExternalToApplication($url): bool return self::$externalAppUriCache[$url] = false; } + $url = Str::ensureRight($url, '/'); + self::$siteUrlsCache ??= Site::all() ->map->url() - ->filter(fn ($siteUrl) => Str::startsWith($siteUrl, ['http:', 'https:'])); + ->filter(fn ($siteUrl) => Str::startsWith($siteUrl, ['http:', 'https:'])) + ->map(fn ($siteUrl) => Str::ensureRight($siteUrl, '/')); - $isExternal = self::$siteUrlsCache + $isExternalToSites = self::$siteUrlsCache ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) ->isEmpty(); - return self::$externalAppUriCache[$url] = $isExternal; + $isExternalToCurrentRequestDomain = ! Str::startsWith($url, Str::ensureRight(url()->to('/'), '/')); + + return self::$externalAppUriCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } public function clearUrlCache() diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 48b0fc7335..dc2e5a5727 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -139,14 +139,13 @@ public function it_determines_if_external_url_to_application() $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com/some-slug?foo')); $this->assertFalse(URL::isExternalToApplication('http://subdomain.this-site.com/some-slug#anchor')); - // TODO... - // $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com')); - // $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/')); - // $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/some-slug')); - // $this->assertFalse(URL::isExternalToApplication('/foo')); - // $this->assertFalse(URL::isExternalToApplication('#anchor')); - // $this->assertFalse(URL::isExternalToApplication('')); - // $this->assertFalse(URL::isExternalToApplication(null)); + $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com')); + $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/')); + $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/some-slug')); + $this->assertFalse(URL::isExternalToApplication('/foo')); + $this->assertFalse(URL::isExternalToApplication('#anchor')); + $this->assertFalse(URL::isExternalToApplication('')); + $this->assertFalse(URL::isExternalToApplication(null)); } #[Test] From 42905179fbeb21ca873c8647f64dc030cc867016 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 18:14:17 -0400 Subject: [PATCH 48/78] Types! --- src/Facades/Endpoint/URL.php | 65 ++++++++++-------------------------- tests/Facades/UrlTest.php | 2 ++ 2 files changed, 20 insertions(+), 47 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 3c7c7e23ee..998c28120d 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -32,7 +32,7 @@ public function enforceTrailingSlashes(bool $bool = true): void * * @param string $url URL to remove "//" from */ - public function tidy($url): string + public function tidy(?string $url): string { return self::normalizeTrailingSlash(Path::tidy($url)); } @@ -42,7 +42,7 @@ public function tidy($url): string * * @param mixed string Open ended number of arguments */ - public function assemble(...$segments): string + public function assemble(?string ...$segments): string { return self::tidy(Path::assemble($segments)); } @@ -52,7 +52,7 @@ public function assemble(...$segments): string * * @param string $url URL to parse */ - public function slug($url): ?string + public function slug(?string $url): ?string { $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); @@ -69,7 +69,7 @@ public function slug($url): ?string * @param string $url URL to modify * @param string $slug New slug to use */ - public function replaceSlug($url, $slug): string + public function replaceSlug(?string $url, string $slug): string { if (parse_url(Str::ensureRight($url, '/'))['path'] === '/') { return self::tidy($url); @@ -89,10 +89,8 @@ public function replaceSlug($url, $slug): string /** * Get the parent URL. - * - * @param string $url */ - public function parent($url): string + public function parent(?string $url): string { $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); @@ -108,7 +106,7 @@ public function parent($url): string /** * Checks if one URL is an ancestor of another. */ - public function isAncestorOf($child, $ancestor): bool + public function isAncestorOf(?string $child, ?string $ancestor): bool { $child = Str::ensureRight(self::removeQueryAndFragment($child), '/'); $ancestor = Str::ensureRight(self::removeQueryAndFragment($ancestor), '/'); @@ -122,12 +120,8 @@ public function isAncestorOf($child, $ancestor): bool /** * Make sure the site root is prepended to a URL. - * - * @param string $url - * @param string|null $locale - * @param bool $controller */ - public function prependSiteRoot($url, $locale = null, $controller = true): string + public function prependSiteRoot(?string $url, ?string $locale = null, bool $controller = true): string { // Backwards compatibility fix: // 2.1 added the $locale argument in the second position to match prependSiteurl. @@ -144,12 +138,8 @@ public function prependSiteRoot($url, $locale = null, $controller = true): strin /** * Make sure the site root url is prepended to a URL. - * - * @param string $url - * @param string|null $locale - * @param bool $controller */ - public function prependSiteUrl($url, $locale = null, $controller = true): string + public function prependSiteUrl(?string $url, ?string $locale = null, bool $controller = true): string { $prepend = Str::removeRight(Config::getSiteUrl($locale), '/'); @@ -166,20 +156,16 @@ public function prependSiteUrl($url, $locale = null, $controller = true): string /** * Removes the site root url from the beginning of a URL. - * - * @param string $url */ - public function removeSiteUrl($url): string + public function removeSiteUrl(?string $url): string { return self::tidy(preg_replace('#^'.Config::getSiteUrl().'#', '/', $url)); } /** * Make an absolute URL relative. - * - * @param string $url */ - public function makeRelative($url): string + public function makeRelative(?string $url): string { $parsed = parse_url($url); @@ -198,10 +184,8 @@ public function makeRelative($url): string /** * Make a relative URL absolute. - * - * @param string $url */ - public function makeAbsolute($url): string + public function makeAbsolute(?string $url): string { // If it doesn't start with a slash, we'll just leave it as-is. if (Str::startsWith($url, ['http:', 'https:']) && self::isExternalToApplication($url)) { @@ -225,20 +209,16 @@ public function getCurrent(): string /** * Formats a URL properly. - * - * @param string $url */ - public function format($url): string + public function format(?string $url): string { return self::tidy('/'.trim($url, '/')); } /** * Checks whether a URL is external to current site. - * - * @param string $url */ - public function isExternal($url): bool + public function isExternal(?string $url): bool { if (isset(self::$externalSiteUriCache[$url])) { return self::$externalSiteUriCache[$url]; @@ -261,10 +241,8 @@ public function isExternal($url): bool /** * Checks whether a URL is external to whole Statamic application. - * - * @param string $url */ - public function isExternalToApplication($url): bool + public function isExternalToApplication(?string $url): bool { if (isset(self::$externalAppUriCache[$url])) { return self::$externalAppUriCache[$url]; @@ -313,10 +291,8 @@ public function getSiteUrl(): string /** * Encode a URL. - * - * @param string $url */ - public function encode($url): string + public function encode(?string $url): string { $dont_encode = [ '%2F' => '/', @@ -340,11 +316,8 @@ public function encode($url): string /** * Return a gravatar image. - * - * @param string $email - * @param int $size */ - public function gravatar($email, $size = null): string + public function gravatar(string $email, ?int $size = null): string { $url = 'https://www.gravatar.com/avatar/'.e(md5(strtolower($email))); @@ -357,10 +330,8 @@ public function gravatar($email, $size = null): string /** * Remove query and fragment from end of URL. - * - * @param string $url */ - public function removeQueryAndFragment($url): ?string + public function removeQueryAndFragment(?string $url): ?string { $url = Str::before($url, '?'); // Remove query params $url = Str::before($url, '#'); // Remove anchor fragment @@ -371,7 +342,7 @@ public function removeQueryAndFragment($url): ?string /** * Normalize trailing slash before query and fragment (trims by default, but can be enforced). */ - public function normalizeTrailingSlash(string $url): string + public function normalizeTrailingSlash(?string $url): string { $parts = str($url) ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index dc2e5a5727..980651de43 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -525,6 +525,7 @@ public function enforces_trailing_slashes($url, $expected) public static function enforceTrailingSlashesProvider() { return [ + [null, '/'], ['', '/'], ['/', '/'], @@ -581,6 +582,7 @@ public function removes_trailing_slashes($url, $expected) public static function removeTrailingSlashesProvider() { return [ + [null, '/'], ['', '/'], ['/', '/'], From 7867ab220a4fdfdfd3d25fb9b947b2bd14b03c9f Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 18:24:58 -0400 Subject: [PATCH 49/78] This doesn't affect tests, but affects property caches. --- src/Facades/Endpoint/URL.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 998c28120d..50d53f6e66 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -228,12 +228,12 @@ public function isExternal(?string $url): bool return false; } + $url = Str::ensureRight($url, '/'); + if (Str::startsWith($url, ['/', '?', '#'])) { return self::$externalSiteUriCache[$url] = false; } - $url = Str::ensureRight($url, '/'); - $isExternal = ! Str::startsWith($url, Str::ensureRight(Site::current()->absoluteUrl(), '/')); return self::$externalSiteUriCache[$url] = $isExternal; @@ -252,12 +252,12 @@ public function isExternalToApplication(?string $url): bool return false; } + $url = Str::ensureRight($url, '/'); + if (Str::startsWith($url, ['/', '?', '#'])) { return self::$externalAppUriCache[$url] = false; } - $url = Str::ensureRight($url, '/'); - self::$siteUrlsCache ??= Site::all() ->map->url() ->filter(fn ($siteUrl) => Str::startsWith($siteUrl, ['http:', 'https:'])) From ccc4e68509d791fbd721512c2737a7b18db7c706 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 18:26:19 -0400 Subject: [PATCH 50/78] Move to bottom. --- src/Facades/Endpoint/URL.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 50d53f6e66..883ca53ac7 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -272,13 +272,6 @@ public function isExternalToApplication(?string $url): bool return self::$externalAppUriCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } - public function clearUrlCache() - { - self::$siteUrlsCache = null; - self::$externalSiteUriCache = []; - self::$externalAppUriCache = []; - } - /** * Get the current site url from Apache headers. */ @@ -361,4 +354,14 @@ public function normalizeTrailingSlash(?string $url): string return $url.$queryAndFragments; } + + /** + * Clear URL property caches. + */ + public function clearUrlCache(): void + { + self::$siteUrlsCache = null; + self::$externalSiteUriCache = []; + self::$externalAppUriCache = []; + } } From 4e6174ba70a15c3fe2b1deab35121bc72ecfaf7f Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Wed, 18 Jun 2025 18:36:40 -0400 Subject: [PATCH 51/78] This is not even a param. --- src/Facades/Endpoint/URL.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 883ca53ac7..1bbade1a00 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -279,7 +279,7 @@ public function getSiteUrl(): string { $rootUrl = url()->to('/'); - return self::tidy($rootUrl, '/'); + return self::tidy($rootUrl); } /** From 7ebcfc42827e6ace715968d3bff99b2570935e35 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 16:18:53 -0400 Subject: [PATCH 52/78] =?UTF-8?q?We=E2=80=99re=20thinking=20`makeRelative(?= =?UTF-8?q?)`=20should=20always=20include=20leading=20slash.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Facades/Endpoint/URL.php | 4 ++-- tests/Facades/UrlTest.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 1bbade1a00..60bdc759b2 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -163,13 +163,13 @@ public function removeSiteUrl(?string $url): string } /** - * Make an absolute URL relative. + * Make an absolute URL relative (with leading slash). */ public function makeRelative(?string $url): string { $parsed = parse_url($url); - $url = $parsed['path'] ?? '/'; + $url = Str::ensureLeft($parsed['path'] ?? '/', '/'); if (isset($parsed['query'])) { $url .= '?'.$parsed['query']; diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 980651de43..6adb65236a 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -452,6 +452,8 @@ public static function relativeProvider() ['/foo/', '/foo'], ['/foo/bar', '/foo/bar'], ['/foo/bar/', '/foo/bar'], + ['foo', '/foo'], + ['foo/bar', '/foo/bar'], ['http://example.com?bar=baz', '/?bar=baz'], ['http://example.com/?bar=baz', '/?bar=baz'], From c4591ca37088a90e237c4b30b4484fd5dc47a8b8 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 16:29:15 -0400 Subject: [PATCH 53/78] Also `makeAbsolute()` should handle case if no leading slash is passed. --- src/Facades/Endpoint/URL.php | 9 ++++++--- tests/Facades/UrlTest.php | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 60bdc759b2..3a50cccb7b 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -183,7 +183,7 @@ public function makeRelative(?string $url): string } /** - * Make a relative URL absolute. + * Make a relative URL absolute (prepends domain if not already absolute). */ public function makeAbsolute(?string $url): string { @@ -192,11 +192,14 @@ public function makeAbsolute(?string $url): string return $url; } - if (! Str::startsWith($url, '/')) { + if (Str::startsWith($url, ['http:', 'https:'])) { return self::tidy($url); } - return self::tidy(Str::ensureLeft($url, self::getSiteUrl())); + $url = Str::ensureLeft($url, '/'); + $url = Str::ensureLeft($url, self::getSiteUrl()); + + return self::tidy($url); } /** diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 6adb65236a..23f35bc443 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -414,18 +414,21 @@ public static function absoluteProvider() ['http://external.com/', 'http://external.com/'], // external absolute url provided, so url is left alone. ['http://this-site.com/foo/', 'http://this-site.com/foo'], // already absolute, but we can still normalize trailing slashes ['/', 'http://absolute-url-resolved-from-request.com'], + ['foo', 'http://absolute-url-resolved-from-request.com/foo'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], ['http://external.com', 'http://external.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. ['http://external.com/', 'http://external.com/', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'https://absolute-url-resolved-from-request.com', 'https'], + ['foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['https://external.com', 'https://external.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. ['https://external.com/', 'https://external.com/', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'http://absolute-url-resolved-from-request.com', 'http'], + ['foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ]; From 7c79e3b1ddab7b2aa20873569e6d9aa2c7af3ec1 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 16:32:53 -0400 Subject: [PATCH 54/78] Remove confusing public `getSiteUrl()` method, since it was only used internally. --- src/Facades/Endpoint/URL.php | 22 +++++++++++----------- tests/Facades/UrlTest.php | 12 ------------ 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 3a50cccb7b..f365db757a 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -197,7 +197,7 @@ public function makeAbsolute(?string $url): string } $url = Str::ensureLeft($url, '/'); - $url = Str::ensureLeft($url, self::getSiteUrl()); + $url = Str::ensureLeft($url, self::getRequestRootUrl()); return self::tidy($url); } @@ -275,16 +275,6 @@ public function isExternalToApplication(?string $url): bool return self::$externalAppUriCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } - /** - * Get the current site url from Apache headers. - */ - public function getSiteUrl(): string - { - $rootUrl = url()->to('/'); - - return self::tidy($rootUrl); - } - /** * Encode a URL. */ @@ -367,4 +357,14 @@ public function clearUrlCache(): void self::$externalSiteUriCache = []; self::$externalAppUriCache = []; } + + /** + * Get the current site url from Apache headers. + */ + private function getRequestRootUrl(): string + { + $rootUrl = url()->to('/'); + + return self::tidy($rootUrl); + } } diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 23f35bc443..9f114adeb1 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -374,18 +374,6 @@ public static function ancestorProvider() ]; } - #[Test] - public function gets_site_url() - { - $this->assertEquals('http://absolute-url-resolved-from-request.com', URL::getSiteUrl()); - - \Illuminate\Support\Facades\URL::forceScheme('https'); - $this->assertEquals('https://absolute-url-resolved-from-request.com', URL::getSiteUrl()); - - \Illuminate\Support\Facades\URL::forceScheme('http'); - $this->assertEquals('http://absolute-url-resolved-from-request.com', URL::getSiteUrl()); - } - #[Test] #[DataProvider('absoluteProvider')] public function it_makes_urls_absolute($url, $expected, $forceScheme = false) From cd367cb797d6fe6445e96f210389b19da7ba5fe0 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 16:35:31 -0400 Subject: [PATCH 55/78] Remove confusing `prependSiteRoot()` method (which is basically a duplicate of `prependSiteUrl()`). --- src/Facades/Endpoint/URL.php | 18 ------------------ src/Imaging/GlideUrlBuilder.php | 4 +++- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index f365db757a..54b93e8d82 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -118,24 +118,6 @@ public function isAncestorOf(?string $child, ?string $ancestor): bool return Str::startsWith($child, $ancestor); } - /** - * Make sure the site root is prepended to a URL. - */ - public function prependSiteRoot(?string $url, ?string $locale = null, bool $controller = true): string - { - // Backwards compatibility fix: - // 2.1 added the $locale argument in the second position to match prependSiteurl. - // Before 2.1, the second argument was controller. We'll handle that here. - if ($locale === true || $locale === false) { - $controller = $locale; - $locale = null; - } - - return self::makeRelative( - self::prependSiteUrl($url, $locale, $controller) - ); - } - /** * Make sure the site root url is prepended to a URL. */ diff --git a/src/Imaging/GlideUrlBuilder.php b/src/Imaging/GlideUrlBuilder.php index 5b34bf9e1d..0db9d402db 100644 --- a/src/Imaging/GlideUrlBuilder.php +++ b/src/Imaging/GlideUrlBuilder.php @@ -64,6 +64,8 @@ public function build($item, $params) $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); } - return URL::prependSiteRoot($builder->getUrl($path, $params)); + return URL::makeRelative( + URL::prependSiteUrl($builder->getUrl($path, $params)) + ); } } From e59d59b58ee6615d2dc57ea0feddf51f4f08b474 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 17:04:59 -0400 Subject: [PATCH 56/78] Add `isAbsolute()` helper method. --- src/Facades/Endpoint/URL.php | 14 +++++++++++--- tests/Facades/UrlTest.php | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 54b93e8d82..9868a0a03f 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -170,11 +170,11 @@ public function makeRelative(?string $url): string public function makeAbsolute(?string $url): string { // If it doesn't start with a slash, we'll just leave it as-is. - if (Str::startsWith($url, ['http:', 'https:']) && self::isExternalToApplication($url)) { + if (self::isAbsolute($url) && self::isExternalToApplication($url)) { return $url; } - if (Str::startsWith($url, ['http:', 'https:'])) { + if (self::isAbsolute($url)) { return self::tidy($url); } @@ -200,6 +200,14 @@ public function format(?string $url): string return self::tidy('/'.trim($url, '/')); } + /** + * Check whether a URL is absolute. + */ + public function isAbsolute(?string $url): bool + { + return Str::startsWith($url, ['http:', 'https:']); + } + /** * Checks whether a URL is external to current site. */ @@ -245,7 +253,7 @@ public function isExternalToApplication(?string $url): bool self::$siteUrlsCache ??= Site::all() ->map->url() - ->filter(fn ($siteUrl) => Str::startsWith($siteUrl, ['http:', 'https:'])) + ->filter(fn ($siteUrl) => self::isAbsolute($siteUrl)) ->map(fn ($siteUrl) => Str::ensureRight($siteUrl, '/')); $isExternalToSites = self::$siteUrlsCache diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 9f114adeb1..6a9f00daf3 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -81,6 +81,28 @@ public function it_removes_site_url() $this->assertEquals('http://not-site.com/foo/', URL::removeSiteUrl('http://not-site.com/foo/')); } + #[Test] + public function it_determines_absolute_url() + { + $this->assertTrue(URL::isAbsolute('http://example.com')); + $this->assertTrue(URL::isAbsolute('http://example.com/')); + $this->assertTrue(URL::isAbsolute('http://example.com/some-slug')); + $this->assertTrue(URL::isAbsolute('http://example.com/some-slug?foo')); + $this->assertTrue(URL::isAbsolute('http://example.com/some-slug#anchor')); + $this->assertTrue(URL::isAbsolute('http://example.com')); + $this->assertTrue(URL::isAbsolute('http://example.com/')); + $this->assertTrue(URL::isAbsolute('http://example.com/some-slug')); + $this->assertFalse(URL::isAbsolute('/')); + $this->assertFalse(URL::isAbsolute('/foo')); + $this->assertFalse(URL::isAbsolute('/foo/bar?query')); + $this->assertFalse(URL::isAbsolute('foo')); + $this->assertFalse(URL::isAbsolute('image.png')); + $this->assertFalse(URL::isAbsolute('?query')); + $this->assertFalse(URL::isAbsolute('#anchor')); + $this->assertFalse(URL::isAbsolute('')); + $this->assertFalse(URL::isAbsolute(null)); + } + #[Test] public function it_determines_external_url() { From 6641b288b149446d5c52278c6db436ac490cb8bd Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 17:20:24 -0400 Subject: [PATCH 57/78] It. --- tests/Facades/UrlTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 6a9f00daf3..fdd2735162 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -446,7 +446,7 @@ public static function absoluteProvider() #[Test] #[DataProvider('relativeProvider')] - public function makes_urls_relative($url, $expected) + public function it_makes_urls_relative($url, $expected) { $this->assertSame($expected, URL::makeRelative($url)); } From 5bc8120adce4997461e21a4b0c9fc26198d035a4 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 17:26:02 -0400 Subject: [PATCH 58/78] Tweak comment. --- src/Facades/Endpoint/URL.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 9868a0a03f..88d00abbdb 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -169,7 +169,7 @@ public function makeRelative(?string $url): string */ public function makeAbsolute(?string $url): string { - // If it doesn't start with a slash, we'll just leave it as-is. + // If URL is external to this Statamic application, we'll just leave it as-is. if (self::isAbsolute($url) && self::isExternalToApplication($url)) { return $url; } From 084c3155de79c87e7e7cd47f67300d26ecfd9f33 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 18:10:20 -0400 Subject: [PATCH 59/78] Remove `format()` in favour of our improved `tidy()` method. --- src/Facades/Endpoint/URL.php | 29 +++++++++-------- src/Support/FileCollection.php | 2 +- src/View/Cascade.php | 2 +- tests/Facades/UrlTest.php | 57 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 88d00abbdb..d42d8fa68b 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -26,15 +26,22 @@ public function enforceTrailingSlashes(bool $bool = true): void } /** - * Removes occurrences of "//" in a $path (except when part of a protocol). - * - * Also normalizes trailing slash (configurable via `enforceTrailingSlashes()` function). - * - * @param string $url URL to remove "//" from + * Tidies a URL. */ public function tidy(?string $url): string { - return self::normalizeTrailingSlash(Path::tidy($url)); + // Remove occurrences of '//', except when part of protocol. + $url = Path::tidy($url); + + // If not an absolute URL, enforce leading slash. + if (! self::isAbsolute($url)) { + $url = Str::ensureLeft($url, '/'); + } + + // Trim trailing slash, unless enforced with `enforceTrailingSlashes()`. + $url = self::normalizeTrailingSlash($url); + + return $url; } /** @@ -189,15 +196,7 @@ public function makeAbsolute(?string $url): string */ public function getCurrent(): string { - return self::format(app('request')->path()); - } - - /** - * Formats a URL properly. - */ - public function format(?string $url): string - { - return self::tidy('/'.trim($url, '/')); + return self::tidy(request()->path()); } /** diff --git a/src/Support/FileCollection.php b/src/Support/FileCollection.php index 4aebd51509..5c6378b403 100644 --- a/src/Support/FileCollection.php +++ b/src/Support/FileCollection.php @@ -198,7 +198,7 @@ public function toArray() $gb = number_format($size / 1073741824, 2); $data[] = [ - 'file' => URL::format($path), // Todo: This will only work when using the local file adapter + 'file' => URL::tidy($path), // Todo: This will only work when using the local file adapter 'filename' => $pathinfo['filename'], 'extension' => Arr::get($pathinfo, 'extension'), 'basename' => Arr::get($pathinfo, 'basename'), diff --git a/src/View/Cascade.php b/src/View/Cascade.php index e0d2d094ed..aa0f57bd14 100644 --- a/src/View/Cascade.php +++ b/src/View/Cascade.php @@ -195,7 +195,7 @@ private function contextualVariables() // Request 'current_url' => $this->request->url(), 'current_full_url' => $this->request->fullUrl(), - 'current_uri' => URL::format($this->request->path()), + 'current_uri' => URL::tidy($this->request->path()), 'get_post' => Arr::sanitize($this->request->all()), 'get' => Arr::sanitize($this->request->query->all()), 'post' => $this->request->isMethod('post') ? Arr::sanitize($this->request->request->all()) : [], diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index fdd2735162..0cdbd2f8f4 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -24,6 +24,63 @@ protected function resolveApplicationConfiguration($app) $app['config']->set('app.url', 'http://absolute-url-resolved-from-request.com'); } + #[Test] + #[DataProvider('tidyProvider')] + public function it_can_tidy_urls($url, $expected) + { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + + $this->assertSame($expected, URL::tidy($url)); + + URL::enforceTrailingSlashes(); + + $expected = preg_replace('/this-site\.com$/', 'this-site.com/', $expected); + $expected = str_replace('page', 'page/', $expected); + + $this->assertSame($expected, URL::tidy($url)); + } + + public static function tidyProvider() + { + return [ + 'null case tidies to relative homepage' => [null, '/'], + 'relative homepage' => ['/', '/'], + 'relative homepage enforce slash' => ['', '/'], + + 'relative route' => ['/page', '/page'], + 'relative route enforce leading slash' => ['page', '/page'], + 'relative route normalizes trailing slash' => ['/page/', '/page'], + 'relative nested route enforce leading slash' => ['foo/page', '/foo/page'], + 'relative nested route enforce leading slash with query param' => ['foo/page?query', '/foo/page?query'], + 'relative nested route enforce leading slash with anchor fragment' => ['foo/page#anchor', '/foo/page#anchor'], + 'relative nested route enforce leading slash with qauery and anchor fragment' => ['foo/page?query#anchor', '/foo/page?query#anchor'], + 'relative nested route normalizes trailing slash' => ['foo/page/', '/foo/page'], + 'relative nested route normalizes trailing slash with query param' => ['/foo/page?query', '/foo/page?query'], + 'relative nested route normalizes trailing slash with anchor fragment' => ['/foo/page#anchor', '/foo/page#anchor'], + 'relative nested route normalizes trailing slash with query and anchor fragment' => ['/foo/page?query#anchor', '/foo/page?query#anchor'], + + 'absolute url homepage' => ['http://this-site.com', 'http://this-site.com'], + 'absolute url route' => ['http://this-site.com/page', 'http://this-site.com/page'], + 'absolute url query' => ['http://this-site.com/page?query', 'http://this-site.com/page?query'], + 'absolute url anchor' => ['http://this-site.com/page#anchor', 'http://this-site.com/page#anchor'], + 'absolute url homepage normalizes trailing slash' => ['http://this-site.com/', 'http://this-site.com'], + 'absolute url route normalizes trailing slash' => ['http://this-site.com/page/', 'http://this-site.com/page'], + 'absolute url query normalizes trailing slash' => ['http://this-site.com/page/?query', 'http://this-site.com/page?query'], + 'absolute url anchor normalizes trailing slash' => ['http://this-site.com/page/#anchor', 'http://this-site.com/page#anchor'], + + 'fix multiple slashes' => ['////foo///bar////page', '/foo/bar/page'], + 'fix multiple slashes and enforce leading slash' => ['foo///bar////page', '/foo/bar/page'], + 'fix multiple slashes and normalize trailing slash' => ['////foo///bar////page///', '/foo/bar/page'], + 'fixing multiple slashes on absolute url tidies to double slash protocol' => ['http:////this-site.com/foo///bar////page', 'http://this-site.com/foo/bar/page'], + 'fixing multiple slashes on external url tidies to double slash protocol' => ['http:////external-site.com/foo///bar////page', 'http://external-site.com/foo/bar/page'], + + // TODO: Don't touch trailing slashes on external URLs... + // 'external url doesnt touch trailing slash' => ['http://external-site.com/', 'http://external-site.com/'], + // 'external nested url doesnt touch trailing slash' => ['http://external-site.com/page/', 'http://external-site.com/page/'], + // 'external nested url doesnt touch trailing slash or query fragment' => ['http://external-site.com/page/?query#fragment', 'http://external-site.com/page/?query#fragment'], + ]; + } + #[Test] public function it_prepends_site_url() { From ed5c4fa80dafc391f63ace0ad9068f72ba24a386 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 18:41:30 -0400 Subject: [PATCH 60/78] Pass failing asset repository test by tidying asset container URLs. --- src/Assets/AssetContainer.php | 2 +- tests/Assets/AssetRepositoryTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index 86263865e7..59d7f02bfc 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -143,7 +143,7 @@ public function url() ->rtrim('/') ->after(config('app.url')); - return ($url === '') ? '/' : $url; + return URL::tidy($url); } /** diff --git a/tests/Assets/AssetRepositoryTest.php b/tests/Assets/AssetRepositoryTest.php index b1b1635ec8..37e89e4235 100644 --- a/tests/Assets/AssetRepositoryTest.php +++ b/tests/Assets/AssetRepositoryTest.php @@ -76,11 +76,11 @@ public function it_resolves_the_correct_disk_from_similar_names() $foundAssetShortUrl = Asset::findByUrl($assetShortUrl->url()); $this->assertInstanceOf(\Statamic\Contracts\Assets\Asset::class, $foundAssetShortUrl); - $this->assertEquals('test/foo/image_in_short.jpg', $foundAssetShortUrl->url()); + $this->assertEquals('/test/foo/image_in_short.jpg', $foundAssetShortUrl->url()); $foundAssetLongUrl = Asset::findByUrl($assetLongUrl->url()); $this->assertInstanceOf(\Statamic\Contracts\Assets\Asset::class, $foundAssetLongUrl); - $this->assertEquals('test_long_url_same_beginning/foo/image_in_long.jpg', $foundAssetLongUrl->url()); + $this->assertEquals('/test_long_url_same_beginning/foo/image_in_long.jpg', $foundAssetLongUrl->url()); } #[Test] From 8ceebfcfb0f3e3a34922e380502761d0e3404b30 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 18:51:30 -0400 Subject: [PATCH 61/78] WIP (Why is this slow!?) --- src/Facades/Endpoint/URL.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index d42d8fa68b..3c607c8ff3 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -33,6 +33,11 @@ public function tidy(?string $url): string // Remove occurrences of '//', except when part of protocol. $url = Path::tidy($url); + // If URL is external to this Statamic application, we'll avoid normalizing leading/trailing slashes. + // if (self::isAbsolute($url) && self::isExternalToApplication($url)) { + // return $url; + // } + // If not an absolute URL, enforce leading slash. if (! self::isAbsolute($url)) { $url = Str::ensureLeft($url, '/'); From 3329ed2ddd055997bb70acfb524cc6c12609221e Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 18:52:46 -0400 Subject: [PATCH 62/78] The `tidy()` call at the end does this now. --- src/Facades/Endpoint/URL.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 3c607c8ff3..a7f89aa403 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -163,7 +163,7 @@ public function makeRelative(?string $url): string { $parsed = parse_url($url); - $url = Str::ensureLeft($parsed['path'] ?? '/', '/'); + $url = $parsed['path'] ?? '/'; if (isset($parsed['query'])) { $url .= '?'.$parsed['query']; From fdce7d30a7c11c792affb14e23f258b494a96c58 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 19:06:13 -0400 Subject: [PATCH 63/78] Fix failing tests. --- tests/Imaging/StaticUrlBuilderTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Imaging/StaticUrlBuilderTest.php b/tests/Imaging/StaticUrlBuilderTest.php index a89d95cf87..6f7651a808 100644 --- a/tests/Imaging/StaticUrlBuilderTest.php +++ b/tests/Imaging/StaticUrlBuilderTest.php @@ -44,7 +44,7 @@ public function testPath() $this->generator->shouldReceive('generateByPath')->andReturn($path); $this->assertEquals( - '/img/'.$path, + '/img/'.ltrim($path, '/'), $this->builder->build('/foo.jpg', ['w' => '100']) ); } @@ -56,7 +56,7 @@ public function testFilenameHasNoAffect() $this->generator->shouldReceive('generateByPath')->andReturn($path); $this->assertEquals( - '/img/'.$path, + '/img/'.ltrim($path, '/'), $this->builder->build('/foo.jpg', ['w' => '100'], 'custom.jpg') ); } From 76c1f35b5a5eb14326eaf1a10e13c44ce0405d8c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 19:21:45 -0400 Subject: [PATCH 64/78] Tidy `encode()` output and add test coverage. --- src/Facades/Endpoint/URL.php | 4 ++-- tests/Facades/UrlTest.php | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index a7f89aa403..e97774d5f0 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -274,7 +274,7 @@ public function isExternalToApplication(?string $url): bool */ public function encode(?string $url): string { - $dont_encode = [ + $dontEncode = [ '%2F' => '/', '%40' => '@', '%3A' => ':', @@ -291,7 +291,7 @@ public function encode(?string $url): string '%25' => '%', ]; - return strtr(rawurlencode($url), $dont_encode); + return self::tidy(strtr(rawurlencode($url), $dontEncode)); } /** diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 0cdbd2f8f4..c27bfd7cc9 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -563,6 +563,38 @@ public static function relativeProvider() ]; } + #[Test] + #[DataProvider('encodeProvider')] + public function it_can_encode_urls($url, $expected) + { + $this->assertSame($expected, URL::encode($url)); + + URL::enforceTrailingSlashes(); + + $expected = str_replace('page', 'page/', $expected); + + $this->assertSame($expected, URL::encode($url)); + } + + public static function encodeProvider() + { + $encodable = '\'"$^<>[](){}'; + $encoded = '%27%22%24%5E%3C%3E%5B%5D%28%29%7B%7D'; + + return [ + 'null case tidies to relative homepage' => [null, '/'], + 'relative homepage' => ['/', '/'], + 'relative homepage enforce slash' => ['', '/'], + + 'relative route with encodable' => ["/{$encodable}/page", "/{$encoded}/page"], + 'relative route with encodable enforce leading slash' => ["{$encodable}/page", "/{$encoded}/page"], + 'relative route with encodable normalize trailing slash' => ["/{$encodable}/page/", "/{$encoded}/page"], + + 'doesnt encode specific characters' => ['http://example.com/page?param&characters=-/@:;,+!*|%#fragment', 'http://example.com/page?param&characters=-/@:;,+!*|%#fragment'], + 'doesnt encode specific characters but still can normalize trailing slash' => ['http://example.com/page/?param&characters=-/@:;,+!*|%#fragment', 'http://example.com/page?param&characters=-/@:;,+!*|%#fragment'], + ]; + } + #[Test] public function it_can_remove_query_and_fragment() { @@ -712,6 +744,7 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() $this->assertSame('/foo?query', URL::makeRelative('https://example.com/foo?query')); $this->assertSame('https://example.com/bar?query', URL::assemble('https://example.com', 'bar', '?query')); $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); + $this->assertSame('https://example.com/foo%24bar', URL::encode('https://example.com/foo$bar')); URL::enforceTrailingSlashes(); @@ -724,6 +757,7 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() $this->assertSame('/foo/?query', URL::makeRelative('https://example.com/foo?query')); $this->assertSame('https://example.com/bar/?query', URL::assemble('https://example.com', 'bar', '?query')); $this->assertSame('https://example.com/bar/', URL::replaceSlug('https://example.com/foo', 'bar')); + $this->assertSame('https://example.com/foo%24bar/', URL::encode('https://example.com/foo$bar')); URL::enforceTrailingSlashes(false); @@ -736,5 +770,6 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() $this->assertSame('/foo?query', URL::makeRelative('https://example.com/foo?query')); $this->assertSame('https://example.com/bar?query', URL::assemble('https://example.com', 'bar', '?query')); $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); + $this->assertSame('https://example.com/foo%24bar', URL::encode('https://example.com/foo$bar')); } } From 73d2d82911397dbf2148dc8292080c1b051a05a4 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 19:59:21 -0400 Subject: [PATCH 65/78] Add test coverage for `gravatar()` method. --- tests/Facades/UrlTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index c27bfd7cc9..b5a19237f8 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -595,6 +595,24 @@ public static function encodeProvider() ]; } + #[Test] + public function it_can_get_gravatar_image_urls_from_email() + { + $hashGravatarEmail = function ($email) { + return e(md5(strtolower($email))); + }; + + $this->assertSame( + 'https://www.gravatar.com/avatar/'.$hashGravatarEmail('Jeremy@pearl.jam'), + URL::gravatar('Jeremy@pearl.jam'), + ); + + $this->assertSame( + 'https://www.gravatar.com/avatar/'.$hashGravatarEmail('Jeremy@pearl.jam').'?s=32', + URL::gravatar('Jeremy@pearl.jam', 32), + ); + } + #[Test] public function it_can_remove_query_and_fragment() { From 5007d740d84edba11d82e9f054d100ad6188e2cb Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 20:09:02 -0400 Subject: [PATCH 66/78] Fix taxonomy repository not finding when trailing slashes are enforced. --- src/Stache/Repositories/TermRepository.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Stache/Repositories/TermRepository.php b/src/Stache/Repositories/TermRepository.php index 2e4fb08ed3..a79f79b9ad 100644 --- a/src/Stache/Repositories/TermRepository.php +++ b/src/Stache/Repositories/TermRepository.php @@ -9,6 +9,7 @@ use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\Taxonomy; +use Statamic\Facades\URL; use Statamic\Query\Scopes\AllowsScopes; use Statamic\Stache\Query\TermQueryBuilder; use Statamic\Stache\Stache; @@ -188,7 +189,7 @@ public static function bindings(): array private function findTaxonomyHandleByUri($uri) { - return $this->stache->store('taxonomies')->index('uri')->items()->flip()->get(Str::ensureLeft($uri, '/')); + return $this->stache->store('taxonomies')->index('uri')->items()->flip()->get(URL::tidy($uri)); } public function substitute($item) From 01111a94770f4e786c2714d3bc16e07dde90517f Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 20:29:10 -0400 Subject: [PATCH 67/78] Docblock cleanup. --- src/Facades/Endpoint/URL.php | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index e97774d5f0..aaa5464ec5 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -18,7 +18,7 @@ class URL private static $externalAppUriCache = []; /** - * Enforce trailing slashes service provider helper. + * Configure whether or not to enforce trailing slashes when normalizing URL output throughout this class. */ public function enforceTrailingSlashes(bool $bool = true): void { @@ -26,7 +26,7 @@ public function enforceTrailingSlashes(bool $bool = true): void } /** - * Tidies a URL. + * Tidy a URL (normalize slashes). */ public function tidy(?string $url): string { @@ -50,9 +50,7 @@ public function tidy(?string $url): string } /** - * Assembles a URL from an ordered list of segments. - * - * @param mixed string Open ended number of arguments + * Assemble a URL from an ordered list of segments. */ public function assemble(?string ...$segments): string { @@ -61,8 +59,6 @@ public function assemble(?string ...$segments): string /** * Get the slug at the end of a URL. - * - * @param string $url URL to parse */ public function slug(?string $url): ?string { @@ -77,9 +73,6 @@ public function slug(?string $url): ?string /** * Replace the slug at the end of a URL with the provided slug. - * - * @param string $url URL to modify - * @param string $slug New slug to use */ public function replaceSlug(?string $url, string $slug): string { @@ -116,7 +109,7 @@ public function parent(?string $url): string } /** - * Checks if one URL is an ancestor of another. + * Check if one URL is an ancestor of another. */ public function isAncestorOf(?string $child, ?string $ancestor): bool { @@ -131,7 +124,7 @@ public function isAncestorOf(?string $child, ?string $ancestor): bool } /** - * Make sure the site root url is prepended to a URL. + * Prepend site URL to a URL. */ public function prependSiteUrl(?string $url, ?string $locale = null, bool $controller = true): string { @@ -149,7 +142,7 @@ public function prependSiteUrl(?string $url, ?string $locale = null, bool $contr } /** - * Removes the site root url from the beginning of a URL. + * Remove current site URL from the beginning of a URL. */ public function removeSiteUrl(?string $url): string { @@ -213,7 +206,7 @@ public function isAbsolute(?string $url): bool } /** - * Checks whether a URL is external to current site. + * Check whether a URL is external to current site. */ public function isExternal(?string $url): bool { @@ -237,7 +230,7 @@ public function isExternal(?string $url): bool } /** - * Checks whether a URL is external to whole Statamic application. + * Check whether a URL is external to whole Statamic application. */ public function isExternalToApplication(?string $url): bool { @@ -295,7 +288,7 @@ public function encode(?string $url): string } /** - * Return a gravatar image. + * Return a gravatar image URL for an email address. */ public function gravatar(string $email, ?int $size = null): string { @@ -353,7 +346,7 @@ public function clearUrlCache(): void } /** - * Get the current site url from Apache headers. + * Get the current root URL from request headers. */ private function getRequestRootUrl(): string { From 947dc0df6a2ea98d124b279e00556d7d7fb20945 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 21:21:56 -0400 Subject: [PATCH 68/78] Make `normalizeTrailingSlash()` private; People should just use `tidy()`. --- src/Facades/Endpoint/URL.php | 22 +++++++++++----------- tests/Facades/UrlTest.php | 5 ----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index aaa5464ec5..1d98999031 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -312,10 +312,20 @@ public function removeQueryAndFragment(?string $url): ?string return $url; } + /** + * Clear URL property caches. + */ + public function clearUrlCache(): void + { + self::$siteUrlsCache = null; + self::$externalSiteUriCache = []; + self::$externalAppUriCache = []; + } + /** * Normalize trailing slash before query and fragment (trims by default, but can be enforced). */ - public function normalizeTrailingSlash(?string $url): string + private function normalizeTrailingSlash(?string $url): string { $parts = str($url) ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) @@ -335,16 +345,6 @@ public function normalizeTrailingSlash(?string $url): string return $url.$queryAndFragments; } - /** - * Clear URL property caches. - */ - public function clearUrlCache(): void - { - self::$siteUrlsCache = null; - self::$externalSiteUriCache = []; - self::$externalAppUriCache = []; - } - /** * Get the current root URL from request headers. */ diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index b5a19237f8..50e91cc4e5 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -640,7 +640,6 @@ public function enforces_trailing_slashes($url, $expected) { URL::enforceTrailingSlashes(); - $this->assertSame($expected, URL::normalizeTrailingSlash($url)); $this->assertSame($expected, URL::tidy($url)); } @@ -697,7 +696,6 @@ public static function enforceTrailingSlashesProvider() #[DataProvider('removeTrailingSlashesProvider')] public function removes_trailing_slashes($url, $expected) { - $this->assertSame($expected, URL::normalizeTrailingSlash($url)); $this->assertSame($expected, URL::tidy($url)); } @@ -753,7 +751,6 @@ public static function removeTrailingSlashesProvider() #[Test] public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() { - $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); @@ -766,7 +763,6 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() URL::enforceTrailingSlashes(); - $this->assertSame('https://example.com/?query', URL::normalizeTrailingSlash('https://example.com?query')); $this->assertSame('https://example.com/?query', URL::tidy('https://example.com?query')); $this->assertSame('https://example.com/foo/', URL::parent('https://example.com/foo/bar')); $this->assertSame('http://localhost/foo/', URL::prependSiteUrl('/foo')); @@ -779,7 +775,6 @@ public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() URL::enforceTrailingSlashes(false); - $this->assertSame('https://example.com?query', URL::normalizeTrailingSlash('https://example.com?query')); $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); From 394c9e81b4a380a92515c6766b846973fc9e305c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 21:39:44 -0400 Subject: [PATCH 69/78] Clean up property caching a bit. --- src/Facades/Endpoint/URL.php | 52 ++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 1d98999031..4428b80361 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -2,6 +2,7 @@ namespace Statamic\Facades\Endpoint; +use Illuminate\Support\Collection; use Statamic\Facades\Config; use Statamic\Facades\Path; use Statamic\Facades\Site; @@ -13,9 +14,9 @@ class URL { private static $enforceTrailingSlashes = false; - private static $siteUrlsCache; - private static $externalSiteUriCache = []; - private static $externalAppUriCache = []; + private static $absoluteSiteUrlsCache; + private static $externalSiteUrlsCache; + private static $externalAppUrlsCache; /** * Configure whether or not to enforce trailing slashes when normalizing URL output throughout this class. @@ -210,8 +211,8 @@ public function isAbsolute(?string $url): bool */ public function isExternal(?string $url): bool { - if (isset(self::$externalSiteUriCache[$url])) { - return self::$externalSiteUriCache[$url]; + if (isset(self::$externalSiteUrlsCache[$url])) { + return self::$externalSiteUrlsCache[$url]; } if (! $url) { @@ -221,12 +222,12 @@ public function isExternal(?string $url): bool $url = Str::ensureRight($url, '/'); if (Str::startsWith($url, ['/', '?', '#'])) { - return self::$externalSiteUriCache[$url] = false; + return self::$externalSiteUrlsCache[$url] = false; } $isExternal = ! Str::startsWith($url, Str::ensureRight(Site::current()->absoluteUrl(), '/')); - return self::$externalSiteUriCache[$url] = $isExternal; + return self::$externalSiteUrlsCache[$url] = $isExternal; } /** @@ -234,8 +235,8 @@ public function isExternal(?string $url): bool */ public function isExternalToApplication(?string $url): bool { - if (isset(self::$externalAppUriCache[$url])) { - return self::$externalAppUriCache[$url]; + if (isset(self::$externalAppUrlsCache[$url])) { + return self::$externalAppUrlsCache[$url]; } if (! $url) { @@ -245,21 +246,16 @@ public function isExternalToApplication(?string $url): bool $url = Str::ensureRight($url, '/'); if (Str::startsWith($url, ['/', '?', '#'])) { - return self::$externalAppUriCache[$url] = false; + return self::$externalAppUrlsCache[$url] = false; } - self::$siteUrlsCache ??= Site::all() - ->map->url() - ->filter(fn ($siteUrl) => self::isAbsolute($siteUrl)) - ->map(fn ($siteUrl) => Str::ensureRight($siteUrl, '/')); - - $isExternalToSites = self::$siteUrlsCache + $isExternalToSites = self::getAbsoluteSiteUrls() ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) ->isEmpty(); $isExternalToCurrentRequestDomain = ! Str::startsWith($url, Str::ensureRight(url()->to('/'), '/')); - return self::$externalAppUriCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; + return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } /** @@ -317,9 +313,9 @@ public function removeQueryAndFragment(?string $url): ?string */ public function clearUrlCache(): void { - self::$siteUrlsCache = null; - self::$externalSiteUriCache = []; - self::$externalAppUriCache = []; + self::$absoluteSiteUrlsCache = null; + self::$externalSiteUrlsCache = null; + self::$externalAppUrlsCache = null; } /** @@ -345,6 +341,22 @@ private function normalizeTrailingSlash(?string $url): string return $url.$queryAndFragments; } + /** + * Get and cache absolute site URLs for external checks. + */ + private function getAbsoluteSiteUrls(): Collection + { + if (self::$absoluteSiteUrlsCache) { + return self::$absoluteSiteUrlsCache; + } + + return self::$absoluteSiteUrlsCache = Site::all() + ->map + ->url() + ->filter(fn ($siteUrl) => self::isAbsolute($siteUrl)) + ->map(fn ($siteUrl) => Str::ensureRight($siteUrl, '/')); + } + /** * Get the current root URL from request headers. */ From b9f97a1e5a8510a821412d0e0a0cacc0672a229b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 21:47:59 -0400 Subject: [PATCH 70/78] Fix these assertions. --- tests/Facades/UrlTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 50e91cc4e5..ce9e7948ac 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -54,10 +54,10 @@ public static function tidyProvider() 'relative nested route enforce leading slash with query param' => ['foo/page?query', '/foo/page?query'], 'relative nested route enforce leading slash with anchor fragment' => ['foo/page#anchor', '/foo/page#anchor'], 'relative nested route enforce leading slash with qauery and anchor fragment' => ['foo/page?query#anchor', '/foo/page?query#anchor'], - 'relative nested route normalizes trailing slash' => ['foo/page/', '/foo/page'], - 'relative nested route normalizes trailing slash with query param' => ['/foo/page?query', '/foo/page?query'], - 'relative nested route normalizes trailing slash with anchor fragment' => ['/foo/page#anchor', '/foo/page#anchor'], - 'relative nested route normalizes trailing slash with query and anchor fragment' => ['/foo/page?query#anchor', '/foo/page?query#anchor'], + 'relative nested route normalizes trailing slash' => ['/foo/page/', '/foo/page'], + 'relative nested route normalizes trailing slash with query param' => ['/foo/page/?query', '/foo/page?query'], + 'relative nested route normalizes trailing slash with anchor fragment' => ['/foo/page/#anchor', '/foo/page#anchor'], + 'relative nested route normalizes trailing slash with query and anchor fragment' => ['/foo/page/?query#anchor', '/foo/page?query#anchor'], 'absolute url homepage' => ['http://this-site.com', 'http://this-site.com'], 'absolute url route' => ['http://this-site.com/page', 'http://this-site.com/page'], From 6810065e6af1b255094f6a481340f05f2083731e Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 21:52:35 -0400 Subject: [PATCH 71/78] Tidy output of `removeQueryAndFragment()` for consistency with other URL methods. --- src/Facades/Endpoint/URL.php | 2 +- tests/Facades/UrlTest.php | 47 +++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 4428b80361..608e91008a 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -305,7 +305,7 @@ public function removeQueryAndFragment(?string $url): ?string $url = Str::before($url, '?'); // Remove query params $url = Str::before($url, '#'); // Remove anchor fragment - return $url; + return self::tidy($url); } /** diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index ce9e7948ac..e7b3849e82 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -614,24 +614,43 @@ public function it_can_get_gravatar_image_urls_from_email() } #[Test] - public function it_can_remove_query_and_fragment() + #[DataProvider('removeQueryAndFragmentProvider')] + public function it_can_remove_query_and_fragment($url, $expected) { - $this->assertEquals(null, URL::removeQueryAndFragment(null)); + $this->assertSame($expected, URL::removeQueryAndFragment($url)); - $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com?query')); - $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com#anchor')); - $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com?foo=bar&baz=qux')); - $this->assertEquals('https://example.com', URL::removeQueryAndFragment('https://example.com?foo=bar&baz=qux#anchor')); + URL::enforceTrailingSlashes(); + + $expected = preg_replace('/this-site\.com$/', 'this-site.com/', $expected); + $expected = str_replace('page', 'page/', $expected); + + $this->assertSame($expected, URL::removeQueryAndFragment($url)); + } - $this->assertEquals('https://example.com/', URL::removeQueryAndFragment('https://example.com/?query')); - $this->assertEquals('https://example.com/', URL::removeQueryAndFragment('https://example.com/#anchor')); - $this->assertEquals('https://example.com/', URL::removeQueryAndFragment('https://example.com/?foo=bar&baz=qux')); - $this->assertEquals('https://example.com/', URL::removeQueryAndFragment('https://example.com/?foo=bar&baz=qux#anchor')); + public static function removeQueryAndFragmentProvider() + { + return [ + 'null case tidies to relative homepage' => [null, '/'], - $this->assertEquals('https://example.com/about', URL::removeQueryAndFragment('https://example.com/about?query')); - $this->assertEquals('https://example.com/about', URL::removeQueryAndFragment('https://example.com/about#anchor')); - $this->assertEquals('https://example.com/about', URL::removeQueryAndFragment('https://example.com/about?foo=bar&baz=qux')); - $this->assertEquals('https://example.com/about', URL::removeQueryAndFragment('https://example.com/about?foo=bar&baz=qux#anchor')); + 'relative homepage with query param' => ['/?query', '/'], + 'relative homepage with anchor fragment' => ['/#anchor', '/'], + 'relative homepage with query and anchor fragment' => ['/?query#anchor', '/'], + 'relative homepage enforce slash with query param' => ['?query', '/'], + 'relative homepage enforce slash with anchor fragment' => ['#anchor', '/'], + 'relative homepage enforce slash with query and anchor fragment' => ['?query#anchor', '/'], + + 'relative route enforce leading slash with query param' => ['foo/page?query', '/foo/page'], + 'relative route enforce leading slash with anchor fragment' => ['foo/page#anchor', '/foo/page'], + 'relative route enforce leading slash with query and anchor fragment' => ['foo/page?query#anchor', '/foo/page'], + 'relative route normalizes trailing slash with query param' => ['/foo/page/?query', '/foo/page'], + 'relative route normalizes trailing slash with anchor fragment' => ['/foo/page/#anchor', '/foo/page'], + 'relative route normalizes trailing slash with query and anchor fragment' => ['/foo/page/?query#anchor', '/foo/page'], + + 'absolute url query' => ['http://example.com/page?query', 'http://example.com/page'], + 'absolute url anchor' => ['http://example.com/page#anchor', 'http://example.com/page'], + 'absolute url query normalizes trailing slash' => ['http://example.com/page/?query', 'http://example.com/page'], + 'absolute url anchor normalizes trailing slash' => ['http://example.com/page/#anchor', 'http://example.com/page'], + ]; } #[Test] From 4e0a63a9dd35ddc96aef69eb1afa7317a5f45641 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 19 Jun 2025 22:26:30 -0400 Subject: [PATCH 72/78] Fix recursion issue with site urls so that we can pass those WIP assertions. --- src/Facades/Endpoint/URL.php | 9 ++++----- tests/Facades/UrlTest.php | 13 +++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 608e91008a..03ea27df75 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -35,9 +35,9 @@ public function tidy(?string $url): string $url = Path::tidy($url); // If URL is external to this Statamic application, we'll avoid normalizing leading/trailing slashes. - // if (self::isAbsolute($url) && self::isExternalToApplication($url)) { - // return $url; - // } + if (self::isAbsolute($url) && self::isExternalToApplication($url)) { + return $url; + } // If not an absolute URL, enforce leading slash. if (! self::isAbsolute($url)) { @@ -351,8 +351,7 @@ private function getAbsoluteSiteUrls(): Collection } return self::$absoluteSiteUrlsCache = Site::all() - ->map - ->url() + ->map(fn ($site) => $site->rawConfig()['url'] ?? null) ->filter(fn ($siteUrl) => self::isAbsolute($siteUrl)) ->map(fn ($siteUrl) => Str::ensureRight($siteUrl, '/')); } diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index e7b3849e82..b9f085e25c 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -34,8 +34,10 @@ public function it_can_tidy_urls($url, $expected) URL::enforceTrailingSlashes(); - $expected = preg_replace('/this-site\.com$/', 'this-site.com/', $expected); - $expected = str_replace('page', 'page/', $expected); + if (! Str::contains($url, 'external-site.com')) { + $expected = preg_replace('/this-site\.com$/', 'this-site.com/', $expected); + $expected = str_replace('page', 'page/', $expected); + } $this->assertSame($expected, URL::tidy($url)); } @@ -74,10 +76,9 @@ public static function tidyProvider() 'fixing multiple slashes on absolute url tidies to double slash protocol' => ['http:////this-site.com/foo///bar////page', 'http://this-site.com/foo/bar/page'], 'fixing multiple slashes on external url tidies to double slash protocol' => ['http:////external-site.com/foo///bar////page', 'http://external-site.com/foo/bar/page'], - // TODO: Don't touch trailing slashes on external URLs... - // 'external url doesnt touch trailing slash' => ['http://external-site.com/', 'http://external-site.com/'], - // 'external nested url doesnt touch trailing slash' => ['http://external-site.com/page/', 'http://external-site.com/page/'], - // 'external nested url doesnt touch trailing slash or query fragment' => ['http://external-site.com/page/?query#fragment', 'http://external-site.com/page/?query#fragment'], + 'external url doesnt touch trailing slash' => ['http://external-site.com/', 'http://external-site.com/'], + 'external nested url doesnt touch trailing slash' => ['http://external-site.com/page/', 'http://external-site.com/page/'], + 'external nested url doesnt touch trailing slash or query fragment' => ['http://external-site.com/page/?query#fragment', 'http://external-site.com/page/?query#fragment'], ]; } From 1075b538ab5bcf8058d6faf4c60a6ffa984b922a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 20 Jun 2025 10:16:03 -0400 Subject: [PATCH 73/78] Self. --- src/Facades/Endpoint/URL.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 03ea27df75..258fc8e49f 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -23,7 +23,7 @@ class URL */ public function enforceTrailingSlashes(bool $bool = true): void { - static::$enforceTrailingSlashes = $bool; + self::$enforceTrailingSlashes = $bool; } /** @@ -332,7 +332,7 @@ private function normalizeTrailingSlash(?string $url): string if (in_array($url, ['', '/'])) { $url = '/'; - } elseif (static::$enforceTrailingSlashes) { + } elseif (self::$enforceTrailingSlashes) { $url = Str::ensureRight($url, '/'); } else { $url = Str::removeRight($url, '/'); From d70f87a0a12ca23bdcd0348fbec6634f1c5ad6c0 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 20 Jun 2025 11:23:54 -0400 Subject: [PATCH 74/78] =?UTF-8?q?Fix=20to=20pass=20newly=20failing=20asser?= =?UTF-8?q?tions=20now=20that=20we=E2=80=99re=20checking=20for=20external?= =?UTF-8?q?=20URLs=20more=20often.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Facades/Endpoint/URL.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 258fc8e49f..ae92b0bf75 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -253,7 +253,7 @@ public function isExternalToApplication(?string $url): bool ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) ->isEmpty(); - $isExternalToCurrentRequestDomain = ! Str::startsWith($url, Str::ensureRight(url()->to('/'), '/')); + $isExternalToCurrentRequestDomain = ! Str::startsWith($url, self::getDomainFromAbsolute(url()->to('/'))); return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } @@ -353,7 +353,15 @@ private function getAbsoluteSiteUrls(): Collection return self::$absoluteSiteUrlsCache = Site::all() ->map(fn ($site) => $site->rawConfig()['url'] ?? null) ->filter(fn ($siteUrl) => self::isAbsolute($siteUrl)) - ->map(fn ($siteUrl) => Str::ensureRight($siteUrl, '/')); + ->map(fn ($siteUrl) => self::getDomainFromAbsolute($siteUrl)); + } + + /** + * Get the domain of an absolute URL for external checks. + */ + private function getDomainFromAbsolute(string $url): string + { + return preg_replace('/(https*:\/\/[^\/]+)(.*)/', '$1', $url); } /** From c87f6dbb6f02f213ced4c3049e5381103360402a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 20 Jun 2025 11:59:16 -0400 Subject: [PATCH 75/78] Flesh out and fix failing tests since adding external check to `tidy()`. --- tests/Facades/UrlTest.php | 258 +++++++++++++++++++++++--------------- 1 file changed, 154 insertions(+), 104 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index b9f085e25c..552a263b0f 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -13,6 +13,7 @@ class UrlTest extends TestCase public function tearDown(): void { URL::enforceTrailingSlashes(false); + URL::clearUrlCache(); parent::tearDown(); } @@ -124,19 +125,19 @@ public function it_prepends_site_url_without_controller() #[Test] public function it_removes_site_url() { - $this->setSiteValue('en', 'url', 'http://site.com/'); + $this->setSiteValue('en', 'url', 'http://this-site.com/'); - $this->assertEquals('/', URL::removeSiteUrl('http://site.com')); - $this->assertEquals('/foo', URL::removeSiteUrl('http://site.com/foo')); - $this->assertEquals('/foo', URL::removeSiteUrl('http://site.com/foo/')); - $this->assertEquals('http://not-site.com/foo', URL::removeSiteUrl('http://not-site.com/foo/')); + $this->assertEquals('/', URL::removeSiteUrl('http://this-site.com')); + $this->assertEquals('/foo', URL::removeSiteUrl('http://this-site.com/foo')); + $this->assertEquals('/foo', URL::removeSiteUrl('http://this-site.com/foo/')); + $this->assertEquals('http://external-site.com/foo/', URL::removeSiteUrl('http://external-site.com/foo/')); URL::enforceTrailingSlashes(); - $this->assertEquals('/', URL::removeSiteUrl('http://site.com/')); - $this->assertEquals('/foo/', URL::removeSiteUrl('http://site.com/foo')); - $this->assertEquals('/foo/', URL::removeSiteUrl('http://site.com/foo/')); - $this->assertEquals('http://not-site.com/foo/', URL::removeSiteUrl('http://not-site.com/foo/')); + $this->assertEquals('/', URL::removeSiteUrl('http://this-site.com/')); + $this->assertEquals('/foo/', URL::removeSiteUrl('http://this-site.com/foo')); + $this->assertEquals('/foo/', URL::removeSiteUrl('http://this-site.com/foo/')); + $this->assertEquals('http://external-site.com/foo', URL::removeSiteUrl('http://external-site.com/foo')); } #[Test] @@ -166,11 +167,11 @@ public function it_determines_external_url() { $this->setSiteValue('en', 'url', 'http://this-site.com/'); - $this->assertTrue(URL::isExternal('http://that-site.com')); - $this->assertTrue(URL::isExternal('http://that-site.com/')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug?foo')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug#anchor')); + $this->assertTrue(URL::isExternal('http://external-site.com')); + $this->assertTrue(URL::isExternal('http://external-site.com/')); + $this->assertTrue(URL::isExternal('http://external-site.com/some-slug')); + $this->assertTrue(URL::isExternal('http://external-site.com/some-slug?foo')); + $this->assertTrue(URL::isExternal('http://external-site.com/some-slug#anchor')); $this->assertFalse(URL::isExternal('http://this-site.com')); $this->assertFalse(URL::isExternal('http://this-site.com/')); $this->assertFalse(URL::isExternal('http://this-site.com/some-slug')); @@ -184,11 +185,11 @@ public function it_determines_external_url() public function it_determines_external_url_when_using_relative_in_config() { $this->setSiteValue('en', 'url', '/'); - $this->assertTrue(URL::isExternal('http://that-site.com')); - $this->assertTrue(URL::isExternal('http://that-site.com/')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug?foo')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug#anchor')); + $this->assertTrue(URL::isExternal('http://external-site.com')); + $this->assertTrue(URL::isExternal('http://external-site.com/')); + $this->assertTrue(URL::isExternal('http://external-site.com/some-slug')); + $this->assertTrue(URL::isExternal('http://external-site.com/some-slug?foo')); + $this->assertTrue(URL::isExternal('http://external-site.com/some-slug#anchor')); $this->assertFalse(URL::isExternal('http://absolute-url-resolved-from-request.com')); $this->assertFalse(URL::isExternal('http://absolute-url-resolved-from-request.com/')); $this->assertFalse(URL::isExternal('http://absolute-url-resolved-from-request.com/some-slug')); @@ -368,6 +369,11 @@ public static function replaceSlugProvider() #[DataProvider('parentProvider')] public function it_gets_the_parent_url($child, $parent) { + $this->setSites([ + 'en' => ['url' => 'http://this-site.com/', 'locale' => 'en_US', 'name' => 'English'], + 'fr' => ['url' => 'https://secure-site.com/', 'locale' => 'fr_FR', 'name' => 'French'], + ]); + $this->assertSame($parent, URL::parent($child)); URL::enforceTrailingSlashes(); @@ -379,27 +385,31 @@ public static function parentProvider() { return [ 'relative homepage to homepage' => ['/', '/'], - 'absolute homepage to homepage' => ['http://localhost', 'http://localhost'], - 'absolute homepage to homepage with trailing slash' => ['http://localhost/', 'http://localhost'], + 'absolute homepage to homepage' => ['http://this-site.com', 'http://this-site.com'], + 'absolute homepage to homepage with trailing slash' => ['http://this-site.com/', 'http://this-site.com'], 'relative route to parent homepage' => ['/foo', '/'], 'relative route to parent homepage with trailing slash' => ['/foo/', '/'], - 'absolute route to parent homepage' => ['http://localhost/foo', 'http://localhost'], - 'absolute route to parent homepage with trailing slash' => ['http://localhost/foo/', 'http://localhost'], + 'absolute route to parent homepage' => ['http://this-site.com/foo', 'http://this-site.com'], + 'absolute route to parent homepage with trailing slash' => ['http://this-site.com/foo/', 'http://this-site.com'], 'relative nested route to parent homepage' => ['/foo/bar', '/foo'], 'relative nested route to parent homepage with trailing slash' => ['/foo/bar/', '/foo'], - 'absolute nested route to parent homepage' => ['http://localhost/foo/bar', 'http://localhost/foo'], - 'absolute nested route to parent homepage with trailing slash' => ['http://localhost/foo/bar/', 'http://localhost/foo'], + 'absolute nested route to parent homepage' => ['http://this-site.com/foo/bar', 'http://this-site.com/foo'], + 'absolute nested route to parent homepage with trailing slash' => ['http://this-site.com/foo/bar/', 'http://this-site.com/foo'], 'removes query from relative url' => ['/?alpha', '/'], - 'removes query from absolute url' => ['http://localhost/?alpha', 'http://localhost'], + 'removes query from absolute url' => ['http://this-site.com/?alpha', 'http://this-site.com'], 'removes anchor fragment from relative url' => ['/#alpha', '/'], - 'removes anchor fragment from absolute url' => ['http://localhost/#alpha', 'http://localhost'], + 'removes anchor fragment from absolute url' => ['http://this-site.com/#alpha', 'http://this-site.com'], 'removes query and anchor fragment from relative url' => ['/?alpha#beta', '/'], - 'removes query and anchor fragment from absolute url' => ['http://localhost/?alpha#beta', 'http://localhost'], + 'removes query and anchor fragment from absolute url' => ['http://this-site.com/?alpha#beta', 'http://this-site.com'], + + 'preserves scheme and host' => ['https://secure-site.com/foo/bar/', 'https://secure-site.com/foo'], - 'preserves scheme and host' => ['https://example.com/foo/bar/', 'https://example.com/foo'], + // TODO... + // 'preserves lack of trailing slash on external site' => ['https://secure-site.com/foo/bar', 'https://secure-site.com/foo'], + // 'preserves trailing slash on external site' => ['https://secure-site.com/foo/bar/', 'https://secure-site.com/foo/'], ]; } @@ -468,7 +478,7 @@ public function it_makes_urls_absolute($url, $expected, $forceScheme = false) URL::enforceTrailingSlashes(); - $expected = Str::contains($url, 'external.com') + $expected = Str::contains($url, 'external-site.com') ? $url : Str::ensureRight($expected, '/'); @@ -478,23 +488,23 @@ public function it_makes_urls_absolute($url, $expected, $forceScheme = false) public static function absoluteProvider() { return [ - ['http://external.com', 'http://external.com'], // external absolute url provided, so url is left alone. - ['http://external.com/', 'http://external.com/'], // external absolute url provided, so url is left alone. + ['http://external-site.com', 'http://external-site.com'], // external absolute url provided, so url is left alone. + ['http://external-site.com/', 'http://external-site.com/'], // external absolute url provided, so url is left alone. ['http://this-site.com/foo/', 'http://this-site.com/foo'], // already absolute, but we can still normalize trailing slashes ['/', 'http://absolute-url-resolved-from-request.com'], ['foo', 'http://absolute-url-resolved-from-request.com/foo'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], - ['http://external.com', 'http://external.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. - ['http://external.com/', 'http://external.com/', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. + ['http://external-site.com', 'http://external-site.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. + ['http://external-site.com/', 'http://external-site.com/', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'https://absolute-url-resolved-from-request.com', 'https'], ['foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - ['https://external.com', 'https://external.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. - ['https://external.com/', 'https://external.com/', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. + ['https://external-site.com', 'https://external-site.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. + ['https://external-site.com/', 'https://external-site.com/', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. ['/', 'http://absolute-url-resolved-from-request.com', 'http'], ['foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], @@ -568,6 +578,8 @@ public static function relativeProvider() #[DataProvider('encodeProvider')] public function it_can_encode_urls($url, $expected) { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + $this->assertSame($expected, URL::encode($url)); URL::enforceTrailingSlashes(); @@ -591,8 +603,11 @@ public static function encodeProvider() 'relative route with encodable enforce leading slash' => ["{$encodable}/page", "/{$encoded}/page"], 'relative route with encodable normalize trailing slash' => ["/{$encodable}/page/", "/{$encoded}/page"], - 'doesnt encode specific characters' => ['http://example.com/page?param&characters=-/@:;,+!*|%#fragment', 'http://example.com/page?param&characters=-/@:;,+!*|%#fragment'], - 'doesnt encode specific characters but still can normalize trailing slash' => ['http://example.com/page/?param&characters=-/@:;,+!*|%#fragment', 'http://example.com/page?param&characters=-/@:;,+!*|%#fragment'], + 'doesnt encode specific characters' => ['http://this-site.com/page?param&characters=-/@:;,+!*|%#fragment', 'http://this-site.com/page?param&characters=-/@:;,+!*|%#fragment'], + 'doesnt encode specific characters but still can normalize trailing slash' => ['http://this-site.com/page/?param&characters=-/@:;,+!*|%#fragment', 'http://this-site.com/page?param&characters=-/@:;,+!*|%#fragment'], + + 'absolute external url doesnt enforce trailing slash' => ['http://external-site.com.com/foo?param&characters=-/@:;,+!*|%#fragment', 'http://external-site.com.com/foo?param&characters=-/@:;,+!*|%#fragment'], + 'absolute external url doesnt remove trailing slash' => ['http://external-site.com.com/foo/?param&characters=-/@:;,+!*|%#fragment', 'http://external-site.com.com/foo/?param&characters=-/@:;,+!*|%#fragment'], ]; } @@ -618,6 +633,8 @@ public function it_can_get_gravatar_image_urls_from_email() #[DataProvider('removeQueryAndFragmentProvider')] public function it_can_remove_query_and_fragment($url, $expected) { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + $this->assertSame($expected, URL::removeQueryAndFragment($url)); URL::enforceTrailingSlashes(); @@ -647,10 +664,15 @@ public static function removeQueryAndFragmentProvider() 'relative route normalizes trailing slash with anchor fragment' => ['/foo/page/#anchor', '/foo/page'], 'relative route normalizes trailing slash with query and anchor fragment' => ['/foo/page/?query#anchor', '/foo/page'], - 'absolute url query' => ['http://example.com/page?query', 'http://example.com/page'], - 'absolute url anchor' => ['http://example.com/page#anchor', 'http://example.com/page'], - 'absolute url query normalizes trailing slash' => ['http://example.com/page/?query', 'http://example.com/page'], - 'absolute url anchor normalizes trailing slash' => ['http://example.com/page/#anchor', 'http://example.com/page'], + 'absolute url query' => ['http://this-site.com/page?query', 'http://this-site.com/page'], + 'absolute url anchor' => ['http://this-site.com/page#anchor', 'http://this-site.com/page'], + 'absolute url query normalizes trailing slash' => ['http://this-site.com/page/?query', 'http://this-site.com/page'], + 'absolute url anchor normalizes trailing slash' => ['http://this-site.com/page/#anchor', 'http://this-site.com/page'], + + 'absolute external url query doesnt enforce trailing slash' => ['http://external-site.com/foo?query', 'http://external-site.com/foo'], + 'absolute external url anchor doesnt enforce trailing slash' => ['http://external-site.com/foo#anchor', 'http://external-site.com/foo'], + 'absolute external url query doesnt remove trailing slash' => ['http://external-site.com/foo/?query', 'http://external-site.com/foo/'], + 'absolute external url anchor doesnt remove trailing slash' => ['http://external-site.com/foo/#anchor', 'http://external-site.com/foo/'], ]; } @@ -658,6 +680,8 @@ public static function removeQueryAndFragmentProvider() #[DataProvider('enforceTrailingSlashesProvider')] public function enforces_trailing_slashes($url, $expected) { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + URL::enforceTrailingSlashes(); $this->assertSame($expected, URL::tidy($url)); @@ -680,35 +704,47 @@ public static function enforceTrailingSlashesProvider() ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + ['/about', '/about/'], ['/about?query', '/about/?query'], ['/about#anchor', '/about/#anchor'], ['/about?foo=bar&baz=qux', '/about/?foo=bar&baz=qux'], ['/about?foo=bar&baz=qux#anchor', '/about/?foo=bar&baz=qux#anchor'], + ['/about/', '/about/'], ['/about/?query', '/about/?query'], ['/about/#anchor', '/about/#anchor'], ['/about/?foo=bar&baz=qux', '/about/?foo=bar&baz=qux'], ['/about/?foo=bar&baz=qux#anchor', '/about/?foo=bar&baz=qux#anchor'], - ['https://example.com?query', 'https://example.com/?query'], - ['https://example.com#anchor', 'https://example.com/#anchor'], - ['https://example.com?foo=bar&baz=qux', 'https://example.com/?foo=bar&baz=qux'], - ['https://example.com?foo=bar&baz=qux#anchor', 'https://example.com/?foo=bar&baz=qux#anchor'], - - ['https://example.com/?query', 'https://example.com/?query'], - ['https://example.com/#anchor', 'https://example.com/#anchor'], - ['https://example.com/?foo=bar&baz=qux', 'https://example.com/?foo=bar&baz=qux'], - ['https://example.com/?foo=bar&baz=qux#anchor', 'https://example.com/?foo=bar&baz=qux#anchor'], - - ['https://example.com/about?query', 'https://example.com/about/?query'], - ['https://example.com/about#anchor', 'https://example.com/about/#anchor'], - ['https://example.com/about?foo=bar&baz=qux', 'https://example.com/about/?foo=bar&baz=qux'], - ['https://example.com/about?foo=bar&baz=qux#anchor', 'https://example.com/about/?foo=bar&baz=qux#anchor'], - - ['https://example.com/about/?query', 'https://example.com/about/?query'], - ['https://example.com/about/#anchor', 'https://example.com/about/#anchor'], - ['https://example.com/about/?foo=bar&baz=qux', 'https://example.com/about/?foo=bar&baz=qux'], - ['https://example.com/about/?foo=bar&baz=qux#anchor', 'https://example.com/about/?foo=bar&baz=qux#anchor'], + ['http://this-site.com', 'http://this-site.com/'], + ['http://this-site.com?query', 'http://this-site.com/?query'], + ['http://this-site.com#anchor', 'http://this-site.com/#anchor'], + ['http://this-site.com?foo=bar&baz=qux', 'http://this-site.com/?foo=bar&baz=qux'], + ['http://this-site.com?foo=bar&baz=qux#anchor', 'http://this-site.com/?foo=bar&baz=qux#anchor'], + + ['http://this-site.com/', 'http://this-site.com/'], + ['http://this-site.com/?query', 'http://this-site.com/?query'], + ['http://this-site.com/#anchor', 'http://this-site.com/#anchor'], + ['http://this-site.com/?foo=bar&baz=qux', 'http://this-site.com/?foo=bar&baz=qux'], + ['http://this-site.com/?foo=bar&baz=qux#anchor', 'http://this-site.com/?foo=bar&baz=qux#anchor'], + + ['http://this-site.com/about', 'http://this-site.com/about/'], + ['http://this-site.com/about?query', 'http://this-site.com/about/?query'], + ['http://this-site.com/about#anchor', 'http://this-site.com/about/#anchor'], + ['http://this-site.com/about?foo=bar&baz=qux', 'http://this-site.com/about/?foo=bar&baz=qux'], + ['http://this-site.com/about?foo=bar&baz=qux#anchor', 'http://this-site.com/about/?foo=bar&baz=qux#anchor'], + + ['http://this-site.com/about/', 'http://this-site.com/about/'], + ['http://this-site.com/about/?query', 'http://this-site.com/about/?query'], + ['http://this-site.com/about/#anchor', 'http://this-site.com/about/#anchor'], + ['http://this-site.com/about/?foo=bar&baz=qux', 'http://this-site.com/about/?foo=bar&baz=qux'], + ['http://this-site.com/about/?foo=bar&baz=qux#anchor', 'http://this-site.com/about/?foo=bar&baz=qux#anchor'], + + ['http://external-site.com', 'http://external-site.com'], + ['http://external-site.com/about', 'http://external-site.com/about'], + ['http://external-site.com/about?query', 'http://external-site.com/about?query'], + ['http://external-site.com/about#anchor', 'http://external-site.com/about#anchor'], + ['http://external-site.com/about?query#anchor', 'http://external-site.com/about?query#anchor'], ]; } @@ -716,6 +752,8 @@ public static function enforceTrailingSlashesProvider() #[DataProvider('removeTrailingSlashesProvider')] public function removes_trailing_slashes($url, $expected) { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + $this->assertSame($expected, URL::tidy($url)); } @@ -736,73 +774,85 @@ public static function removeTrailingSlashesProvider() ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], + ['/about', '/about'], ['/about?query', '/about?query'], ['/about#anchor', '/about#anchor'], ['/about?foo=bar&baz=qux', '/about?foo=bar&baz=qux'], ['/about?foo=bar&baz=qux#anchor', '/about?foo=bar&baz=qux#anchor'], + ['/about/', '/about'], ['/about/?query', '/about?query'], ['/about/#anchor', '/about#anchor'], ['/about/?foo=bar&baz=qux', '/about?foo=bar&baz=qux'], ['/about/?foo=bar&baz=qux#anchor', '/about?foo=bar&baz=qux#anchor'], - ['https://example.com?query', 'https://example.com?query'], - ['https://example.com#anchor', 'https://example.com#anchor'], - ['https://example.com?foo=bar&baz=qux', 'https://example.com?foo=bar&baz=qux'], - ['https://example.com?foo=bar&baz=qux#anchor', 'https://example.com?foo=bar&baz=qux#anchor'], - - ['https://example.com/?query', 'https://example.com?query'], - ['https://example.com/#anchor', 'https://example.com#anchor'], - ['https://example.com/?foo=bar&baz=qux', 'https://example.com?foo=bar&baz=qux'], - ['https://example.com/?foo=bar&baz=qux#anchor', 'https://example.com?foo=bar&baz=qux#anchor'], - - ['https://example.com/about?query', 'https://example.com/about?query'], - ['https://example.com/about#anchor', 'https://example.com/about#anchor'], - ['https://example.com/about?foo=bar&baz=qux', 'https://example.com/about?foo=bar&baz=qux'], - ['https://example.com/about?foo=bar&baz=qux#anchor', 'https://example.com/about?foo=bar&baz=qux#anchor'], - - ['https://example.com/about/?query', 'https://example.com/about?query'], - ['https://example.com/about/#anchor', 'https://example.com/about#anchor'], - ['https://example.com/about/?foo=bar&baz=qux', 'https://example.com/about?foo=bar&baz=qux'], - ['https://example.com/about/?foo=bar&baz=qux#anchor', 'https://example.com/about?foo=bar&baz=qux#anchor'], + ['http://this-site.com', 'http://this-site.com'], + ['http://this-site.com?query', 'http://this-site.com?query'], + ['http://this-site.com#anchor', 'http://this-site.com#anchor'], + ['http://this-site.com?foo=bar&baz=qux', 'http://this-site.com?foo=bar&baz=qux'], + ['http://this-site.com?foo=bar&baz=qux#anchor', 'http://this-site.com?foo=bar&baz=qux#anchor'], + + ['http://this-site.com/', 'http://this-site.com'], + ['http://this-site.com/?query', 'http://this-site.com?query'], + ['http://this-site.com/#anchor', 'http://this-site.com#anchor'], + ['http://this-site.com/?foo=bar&baz=qux', 'http://this-site.com?foo=bar&baz=qux'], + ['http://this-site.com/?foo=bar&baz=qux#anchor', 'http://this-site.com?foo=bar&baz=qux#anchor'], + + ['http://this-site.com/about', 'http://this-site.com/about'], + ['http://this-site.com/about?query', 'http://this-site.com/about?query'], + ['http://this-site.com/about#anchor', 'http://this-site.com/about#anchor'], + ['http://this-site.com/about?foo=bar&baz=qux', 'http://this-site.com/about?foo=bar&baz=qux'], + ['http://this-site.com/about?foo=bar&baz=qux#anchor', 'http://this-site.com/about?foo=bar&baz=qux#anchor'], + + ['http://this-site.com/about/', 'http://this-site.com/about'], + ['http://this-site.com/about/?query', 'http://this-site.com/about?query'], + ['http://this-site.com/about/#anchor', 'http://this-site.com/about#anchor'], + ['http://this-site.com/about/?foo=bar&baz=qux', 'http://this-site.com/about?foo=bar&baz=qux'], + ['http://this-site.com/about/?foo=bar&baz=qux#anchor', 'http://this-site.com/about?foo=bar&baz=qux#anchor'], + + ['http://external-site.com/', 'http://external-site.com/'], + ['http://external-site.com/about/', 'http://external-site.com/about/'], + ['http://external-site.com/about/?query', 'http://external-site.com/about/?query'], + ['http://external-site.com/about/#anchor', 'http://external-site.com/about/#anchor'], + ['http://external-site.com/about/?query#anchor', 'http://external-site.com/about/?query#anchor'], ]; } #[Test] public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() { - $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); - $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); - $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); - $this->assertSame('/foo', URL::removeSiteUrl('http://localhost/foo')); - $this->assertSame('http://absolute-url-resolved-from-request.com/foo?query', URL::makeAbsolute('/foo?query')); - $this->assertSame('/foo?query', URL::makeRelative('https://example.com/foo?query')); - $this->assertSame('https://example.com/bar?query', URL::assemble('https://example.com', 'bar', '?query')); - $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); - $this->assertSame('https://example.com/foo%24bar', URL::encode('https://example.com/foo$bar')); + $this->assertSame('http://localhost?query', URL::tidy('http://localhost/?query')); + $this->assertSame('http://localhost/foo', URL::parent('http://localhost/foo/bar/')); + $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo/')); + $this->assertSame('/foo', URL::removeSiteUrl('http://localhost/foo/')); + $this->assertSame('http://absolute-url-resolved-from-request.com/foo?query', URL::makeAbsolute('/foo/?query')); + $this->assertSame('/foo?query', URL::makeRelative('http://localhost/foo/?query')); + $this->assertSame('http://localhost/bar?query', URL::assemble('http://localhost', 'bar', '?query')); + $this->assertSame('http://localhost/bar', URL::replaceSlug('http://localhost/foo/', 'bar')); + $this->assertSame('http://localhost/foo%24bar', URL::encode('http://localhost/foo$bar')); URL::enforceTrailingSlashes(); - $this->assertSame('https://example.com/?query', URL::tidy('https://example.com?query')); - $this->assertSame('https://example.com/foo/', URL::parent('https://example.com/foo/bar')); + $this->assertSame('http://localhost/?query', URL::tidy('http://localhost?query')); + $this->assertSame('http://localhost/foo/', URL::parent('http://localhost/foo/bar')); $this->assertSame('http://localhost/foo/', URL::prependSiteUrl('/foo')); $this->assertSame('/foo/', URL::removeSiteUrl('http://localhost/foo')); $this->assertSame('http://absolute-url-resolved-from-request.com/foo/?query', URL::makeAbsolute('/foo?query')); - $this->assertSame('/foo/?query', URL::makeRelative('https://example.com/foo?query')); - $this->assertSame('https://example.com/bar/?query', URL::assemble('https://example.com', 'bar', '?query')); - $this->assertSame('https://example.com/bar/', URL::replaceSlug('https://example.com/foo', 'bar')); - $this->assertSame('https://example.com/foo%24bar/', URL::encode('https://example.com/foo$bar')); + $this->assertSame('/foo/?query', URL::makeRelative('http://localhost/foo?query')); + $this->assertSame('http://localhost/bar/?query', URL::assemble('http://localhost', 'bar', '?query')); + $this->assertSame('http://localhost/bar/', URL::replaceSlug('http://localhost/foo', 'bar')); + $this->assertSame('http://localhost/foo%24bar/', URL::encode('http://localhost/foo$bar')); URL::enforceTrailingSlashes(false); - $this->assertSame('https://example.com?query', URL::tidy('https://example.com?query')); - $this->assertSame('https://example.com/foo', URL::parent('https://example.com/foo/bar')); - $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo')); - $this->assertSame('/foo', URL::removeSiteUrl('http://localhost/foo')); - $this->assertSame('http://absolute-url-resolved-from-request.com/foo?query', URL::makeAbsolute('/foo?query')); - $this->assertSame('/foo?query', URL::makeRelative('https://example.com/foo?query')); - $this->assertSame('https://example.com/bar?query', URL::assemble('https://example.com', 'bar', '?query')); - $this->assertSame('https://example.com/bar', URL::replaceSlug('https://example.com/foo', 'bar')); - $this->assertSame('https://example.com/foo%24bar', URL::encode('https://example.com/foo$bar')); + $this->assertSame('http://localhost?query', URL::tidy('http://localhost/?query')); + $this->assertSame('http://localhost/foo', URL::parent('http://localhost/foo/bar/')); + $this->assertSame('http://localhost/foo', URL::prependSiteUrl('/foo/')); + $this->assertSame('/foo', URL::removeSiteUrl('http://localhost/foo/')); + $this->assertSame('http://absolute-url-resolved-from-request.com/foo?query', URL::makeAbsolute('/foo/?query')); + $this->assertSame('/foo?query', URL::makeRelative('http://localhost/foo/?query')); + $this->assertSame('http://localhost/bar?query', URL::assemble('http://localhost', 'bar', '?query')); + $this->assertSame('http://localhost/bar', URL::replaceSlug('http://localhost/foo/', 'bar')); + $this->assertSame('http://localhost/foo%24bar', URL::encode('http://localhost/foo$bar')); } } From 21f203a90eab8ae8b59dc0599685b56f615a2119 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 20 Jun 2025 12:55:29 -0400 Subject: [PATCH 76/78] Pass failing tests around yet-unconfigured site URLs not properly being tidied. --- src/Facades/Endpoint/URL.php | 6 +++--- src/Sites/Site.php | 2 +- tests/Facades/UrlTest.php | 12 ++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index ae92b0bf75..8910f96ce5 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -29,13 +29,13 @@ public function enforceTrailingSlashes(bool $bool = true): void /** * Tidy a URL (normalize slashes). */ - public function tidy(?string $url): string + public function tidy(?string $url, bool $force = false): string { // Remove occurrences of '//', except when part of protocol. $url = Path::tidy($url); - // If URL is external to this Statamic application, we'll avoid normalizing leading/trailing slashes. - if (self::isAbsolute($url) && self::isExternalToApplication($url)) { + // If URL is external to this Statamic application, we'll leave leading/trailing slashes by default. + if (! $force && self::isAbsolute($url) && self::isExternalToApplication($url)) { return $url; } diff --git a/src/Sites/Site.php b/src/Sites/Site.php index 506df2f976..75597284fe 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -52,7 +52,7 @@ public function lang() public function url() { - return URL::tidy($this->config['url']); + return URL::tidy($this->config['url'], true); } public function direction() diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 552a263b0f..5de41d6283 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -83,6 +83,18 @@ public static function tidyProvider() ]; } + #[Test] + public function it_can_force_tidy_unconfigured_external_urls() + { + $this->assertSame('http://external.com/', URL::tidy('http://external.com/')); + $this->assertSame('http://external.com', URL::tidy('http://external.com/', force: true)); + + URL::enforceTrailingSlashes(); + + $this->assertSame('http://external.com', URL::tidy('http://external.com')); + $this->assertSame('http://external.com/', URL::tidy('http://external.com', force: true)); + } + #[Test] public function it_prepends_site_url() { From 7377b95e1cd7f59d727c359148c885d1197851cf Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 20 Jun 2025 14:21:55 -0400 Subject: [PATCH 77/78] Match trailing slash convention on external URLs when getting `parent()`. --- src/Facades/Endpoint/URL.php | 24 +++++++++++++++++++----- tests/Facades/UrlTest.php | 13 +++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 8910f96ce5..4ab45d9cbd 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -98,15 +98,21 @@ public function replaceSlug(?string $url, string $slug): string */ public function parent(?string $url): string { + $trailingSlash = self::isAbsolute($url) && self::isExternalToApplication($url) + ? self::hasTrailingSlash($url) + : self::$enforceTrailingSlashes; + + $strMethod = $trailingSlash + ? 'ensureRight' + : 'removeRight'; + $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); - if (parse_url($url)['path'] === '/') { - return self::tidy($url); + if (parse_url($url)['path'] !== '/') { + $url = preg_replace('/[^\/]*\/$/', '', $url); } - $url = preg_replace('/[^\/]*\/$/', '', $url); - - return self::tidy($url); + return Str::$strMethod(self::tidy($url), '/') ?: '/'; } /** @@ -364,6 +370,14 @@ private function getDomainFromAbsolute(string $url): string return preg_replace('/(https*:\/\/[^\/]+)(.*)/', '$1', $url); } + /** + * Get the current root URL from request headers. + */ + private function hasTrailingSlash(string $url): string + { + return substr(preg_replace('/([^?#]*)(.*)/', '$1', $url), -1) === '/'; + } + /** * Get the current root URL from request headers. */ diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 5de41d6283..8347f20150 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -390,7 +390,11 @@ public function it_gets_the_parent_url($child, $parent) URL::enforceTrailingSlashes(); - $this->assertSame(Str::ensureRight($parent, '/'), URL::parent($child)); + if (! Str::contains($parent, 'external')) { + $parent = Str::ensureRight($parent, '/'); + } + + $this->assertSame($parent, URL::parent($child)); } public static function parentProvider() @@ -419,9 +423,10 @@ public static function parentProvider() 'preserves scheme and host' => ['https://secure-site.com/foo/bar/', 'https://secure-site.com/foo'], - // TODO... - // 'preserves lack of trailing slash on external site' => ['https://secure-site.com/foo/bar', 'https://secure-site.com/foo'], - // 'preserves trailing slash on external site' => ['https://secure-site.com/foo/bar/', 'https://secure-site.com/foo/'], + 'preserves lack of trailing slash on external site' => ['https://external-site.com/foo', 'https://external-site.com'], + 'preserves trailing slash on external site' => ['https://external-site.com/foo/', 'https://external-site.com/'], + 'preserves lack of trailing slash on external site on nested route' => ['https://external-site.com/foo/bar', 'https://external-site.com/foo'], + 'preserves trailing slash on external site on nested route' => ['https://external-site.com/foo/bar/', 'https://external-site.com/foo/'], ]; } From 945b227dd7baa66853d07fbb51d6f91d47886eb5 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 20 Jun 2025 15:39:41 -0400 Subject: [PATCH 78/78] Flesh out data providers a bit more for consistency. --- tests/Facades/UrlTest.php | 363 +++++++++++++++++++------------------- 1 file changed, 183 insertions(+), 180 deletions(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 8347f20150..45cbbd2591 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -505,27 +505,28 @@ public function it_makes_urls_absolute($url, $expected, $forceScheme = false) public static function absoluteProvider() { return [ - ['http://external-site.com', 'http://external-site.com'], // external absolute url provided, so url is left alone. - ['http://external-site.com/', 'http://external-site.com/'], // external absolute url provided, so url is left alone. - ['http://this-site.com/foo/', 'http://this-site.com/foo'], // already absolute, but we can still normalize trailing slashes - ['/', 'http://absolute-url-resolved-from-request.com'], - ['foo', 'http://absolute-url-resolved-from-request.com/foo'], - ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], - ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], - - ['http://external-site.com', 'http://external-site.com', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. - ['http://external-site.com/', 'http://external-site.com/', 'https'], // external absolute url provided, so scheme and trailing slash are left alone. - ['/', 'https://absolute-url-resolved-from-request.com', 'https'], - ['foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], - - ['https://external-site.com', 'https://external-site.com', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. - ['https://external-site.com/', 'https://external-site.com/', 'http'], // external absolute url provided, so scheme and trailing slash are left alone. - ['/', 'http://absolute-url-resolved-from-request.com', 'http'], - ['foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], - ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], - ['/foo/', 'http://absolute-url-resolved-from-request.com/foo', 'http'], + 'absolute homepage' => ['/', 'http://absolute-url-resolved-from-request.com'], + 'absolute route' => ['/foo', 'http://absolute-url-resolved-from-request.com/foo'], + 'absolute route without leading slash' => ['foo', 'http://absolute-url-resolved-from-request.com/foo'], + 'absolute route with trailing slash' => ['/foo/', 'http://absolute-url-resolved-from-request.com/foo'], + 'already absolute request url but normalize trailing slash' => ['http://absolute-url-resolved-from-request.com/foo/', 'http://absolute-url-resolved-from-request.com/foo'], + 'already absolute site url but normalize trailing slash' => ['http://this-site.com/foo/', 'http://this-site.com/foo'], + 'leave external url without trailing slash' => ['http://external-site.com', 'http://external-site.com'], + 'leave external url with trailing slash' => ['http://external-site.com/', 'http://external-site.com/'], + + 'absolute homepage and force https scheme' => ['/', 'https://absolute-url-resolved-from-request.com', 'https'], + 'absolute route and force https scheme' => ['/foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], + 'absolute route without leading slash and force https scheme' => ['foo', 'https://absolute-url-resolved-from-request.com/foo', 'https'], + 'absolute route with trailing slash and force https scheme' => ['/foo/', 'https://absolute-url-resolved-from-request.com/foo', 'https'], + 'leave external url without trailing slash and force https scheme' => ['http://external-site.com', 'http://external-site.com', 'https'], + 'leave external url with trailing slash and force https scheme' => ['http://external-site.com/', 'http://external-site.com/', 'https'], + + 'absolute homepage and force http scheme' => ['/', 'http://absolute-url-resolved-from-request.com', 'http'], + 'absolute route and force http scheme' => ['/foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], + 'absolute route without leading slash and force http scheme' => ['foo', 'http://absolute-url-resolved-from-request.com/foo', 'http'], + 'absolute route with trailing slash and force http scheme' => ['/foo/', 'http://absolute-url-resolved-from-request.com/foo', 'http'], + 'leave external url without trailing slash and force http scheme' => ['https://external-site.com', 'https://external-site.com', 'http'], + 'leave external url with trailing slash and force http scheme' => ['https://external-site.com/', 'https://external-site.com/', 'http'], ]; } @@ -534,60 +535,68 @@ public static function absoluteProvider() public function it_makes_urls_relative($url, $expected) { $this->assertSame($expected, URL::makeRelative($url)); + + URL::enforceTrailingSlashes(); + + $expected = str_replace('page', 'page/', $expected); + + $this->assertSame($expected, URL::makeRelative($url)); } public static function relativeProvider() { return [ - ['http://example.com', '/'], - ['http://example.com/', '/'], - ['http://example.com/foo', '/foo'], - ['http://example.com/foo/', '/foo'], - ['http://example.com/foo/bar', '/foo/bar'], - ['http://example.com/foo/bar/', '/foo/bar'], - ['/', '/'], - ['/foo', '/foo'], - ['/foo/', '/foo'], - ['/foo/bar', '/foo/bar'], - ['/foo/bar/', '/foo/bar'], - ['foo', '/foo'], - ['foo/bar', '/foo/bar'], - - ['http://example.com?bar=baz', '/?bar=baz'], - ['http://example.com/?bar=baz', '/?bar=baz'], - ['http://example.com/foo?bar=baz', '/foo?bar=baz'], - ['http://example.com/foo/?bar=baz', '/foo?bar=baz'], - ['http://example.com/foo/bar?bar=baz', '/foo/bar?bar=baz'], - ['http://example.com/foo/bar/?bar=baz', '/foo/bar?bar=baz'], - ['/?bar=baz', '/?bar=baz'], - ['/foo?bar=baz', '/foo?bar=baz'], - ['/foo/?bar=baz', '/foo?bar=baz'], - ['/foo/bar?bar=baz', '/foo/bar?bar=baz'], - ['/foo/bar/?bar=baz', '/foo/bar?bar=baz'], - - ['http://example.com#fragment', '/#fragment'], - ['http://example.com/#fragment', '/#fragment'], - ['http://example.com/foo#fragment', '/foo#fragment'], - ['http://example.com/foo/#fragment', '/foo#fragment'], - ['http://example.com/foo/bar#fragment', '/foo/bar#fragment'], - ['http://example.com/foo/bar/#fragment', '/foo/bar#fragment'], - ['/#fragment', '/#fragment'], - ['/foo#fragment', '/foo#fragment'], - ['/foo/#fragment', '/foo#fragment'], - ['/foo/bar#fragment', '/foo/bar#fragment'], - ['/foo/bar/#fragment', '/foo/bar#fragment'], - - ['http://example.com?bar=baz#fragment', '/?bar=baz#fragment'], - ['http://example.com/?bar=baz#fragment', '/?bar=baz#fragment'], - ['http://example.com/foo?bar=baz#fragment', '/foo?bar=baz#fragment'], - ['http://example.com/foo/?bar=baz#fragment', '/foo?bar=baz#fragment'], - ['http://example.com/foo/bar?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], - ['http://example.com/foo/bar/?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], - ['/?bar=baz#fragment', '/?bar=baz#fragment'], - ['/foo?bar=baz#fragment', '/foo?bar=baz#fragment'], - ['/foo/?bar=baz#fragment', '/foo?bar=baz#fragment'], - ['/foo/bar?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], - ['/foo/bar/?bar=baz#fragment', '/foo/bar?bar=baz#fragment'], + 'null case tidies to homepage' => [null, '/'], + + 'homepage without trailing slash' => ['http://example.com', '/'], + 'homepage with trailing slash' => ['http://example.com/', '/'], + 'route without trailing slash' => ['http://example.com/page', '/page'], + 'route with trailing slash' => ['http://example.com/page/', '/page'], + 'nested route without trailing slash' => ['http://example.com/foo/page', '/foo/page'], + 'nested route with trailing slash' => ['http://example.com/foo/page/', '/foo/page'], + 'already relative homepage' => ['/', '/'], + 'already relative route without trailing slash' => ['/page', '/page'], + 'already relative route with trailing slash' => ['/page/', '/page'], + 'already relative route without leading slash' => ['page', '/page'], + 'already relative nested route without trailing slash' => ['/foo/page', '/foo/page'], + 'already relative nested route with trailing slash' => ['/foo/page/', '/foo/page'], + 'already relative nested route without leading slash' => ['foo/page', '/foo/page'], + + 'homepage without trailing slash and query param' => ['http://example.com?bar=baz', '/?bar=baz'], + 'homepage with trailing slash and query param' => ['http://example.com/?bar=baz', '/?bar=baz'], + 'route without trailing slash and query param' => ['http://example.com/page?bar=baz', '/page?bar=baz'], + 'route with trailing slash and query param' => ['http://example.com/page/?bar=baz', '/page?bar=baz'], + 'nested route without trailing slash and query param' => ['http://example.com/foo/page?bar=baz', '/foo/page?bar=baz'], + 'nested route with trailing slash and query param' => ['http://example.com/foo/page/?bar=baz', '/foo/page?bar=baz'], + 'already relative homepage with query param' => ['/?bar=baz', '/?bar=baz'], + 'already relative route without trailing slash and query param' => ['/page?bar=baz', '/page?bar=baz'], + 'already relative route with trailing slash and query param' => ['/page/?bar=baz', '/page?bar=baz'], + 'already relative nested route without trailing slash and query param' => ['/foo/page?bar=baz', '/foo/page?bar=baz'], + 'already relative nested route with trailing slash and query param' => ['/foo/page/?bar=baz', '/foo/page?bar=baz'], + + 'homepage without trailing slash and anchor fragment' => ['http://example.com#fragment', '/#fragment'], + 'homepage with trailing slash and anchor fragment' => ['http://example.com/#fragment', '/#fragment'], + 'route without trailing slash and anchor fragment' => ['http://example.com/page#fragment', '/page#fragment'], + 'route with trailing slash and anchor fragment' => ['http://example.com/page/#fragment', '/page#fragment'], + 'nested route without trailing slash and anchor fragment' => ['http://example.com/foo/page#fragment', '/foo/page#fragment'], + 'nested route with trailing slash and anchor fragment' => ['http://example.com/foo/page/#fragment', '/foo/page#fragment'], + 'already relative homepage with anchor fragment' => ['/#fragment', '/#fragment'], + 'already relative route without trailing slash and anchor fragment' => ['/page#fragment', '/page#fragment'], + 'already relative route with trailing slash and anchor fragment' => ['/page/#fragment', '/page#fragment'], + 'already relative nested route without trailing slash and anchor fragment' => ['/foo/page#fragment', '/foo/page#fragment'], + 'already relative nested route with trailing slash and anchor fragment' => ['/foo/page/#fragment', '/foo/page#fragment'], + + 'homepage without trailing slash and query with anchor fragment' => ['http://example.com?bar=baz#fragment', '/?bar=baz#fragment'], + 'homepage with trailing slash and query with anchor fragment' => ['http://example.com/?bar=baz#fragment', '/?bar=baz#fragment'], + 'route without trailing slash and query with anchor fragment' => ['http://example.com/page?bar=baz#fragment', '/page?bar=baz#fragment'], + 'route with trailing slash and query with anchor fragment' => ['http://example.com/page/?bar=baz#fragment', '/page?bar=baz#fragment'], + 'nested route without trailing slash and query with anchor fragment' => ['http://example.com/foo/page?bar=baz#fragment', '/foo/page?bar=baz#fragment'], + 'nested route with trailing slash and query with anchor fragment' => ['http://example.com/foo/page/?bar=baz#fragment', '/foo/page?bar=baz#fragment'], + 'already relative homepage with query and anchor fragment' => ['/?bar=baz#fragment', '/?bar=baz#fragment'], + 'already relative route without trailing slash and query with anchor fragment' => ['/page?bar=baz#fragment', '/page?bar=baz#fragment'], + 'already relative route with trailing slash and query with anchor fragment' => ['/page/?bar=baz#fragment', '/page?bar=baz#fragment'], + 'already relative nested route without trailing slash and query with anchor fragment' => ['/foo/page?bar=baz#fragment', '/foo/page?bar=baz#fragment'], + 'already relative nested route with trailing slash and query with anchor fragment' => ['/foo/page/?bar=baz#fragment', '/foo/page?bar=baz#fragment'], ]; } @@ -707,61 +716,58 @@ public function enforces_trailing_slashes($url, $expected) public static function enforceTrailingSlashesProvider() { return [ - [null, '/'], - ['', '/'], - ['/', '/'], - - ['?query', '/?query'], - ['#anchor', '/#anchor'], - ['?foo=bar&baz=qux', '/?foo=bar&baz=qux'], - ['?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], - - ['/?query', '/?query'], - ['/#anchor', '/#anchor'], - ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], - ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], - - ['/about', '/about/'], - ['/about?query', '/about/?query'], - ['/about#anchor', '/about/#anchor'], - ['/about?foo=bar&baz=qux', '/about/?foo=bar&baz=qux'], - ['/about?foo=bar&baz=qux#anchor', '/about/?foo=bar&baz=qux#anchor'], - - ['/about/', '/about/'], - ['/about/?query', '/about/?query'], - ['/about/#anchor', '/about/#anchor'], - ['/about/?foo=bar&baz=qux', '/about/?foo=bar&baz=qux'], - ['/about/?foo=bar&baz=qux#anchor', '/about/?foo=bar&baz=qux#anchor'], - - ['http://this-site.com', 'http://this-site.com/'], - ['http://this-site.com?query', 'http://this-site.com/?query'], - ['http://this-site.com#anchor', 'http://this-site.com/#anchor'], - ['http://this-site.com?foo=bar&baz=qux', 'http://this-site.com/?foo=bar&baz=qux'], - ['http://this-site.com?foo=bar&baz=qux#anchor', 'http://this-site.com/?foo=bar&baz=qux#anchor'], - - ['http://this-site.com/', 'http://this-site.com/'], - ['http://this-site.com/?query', 'http://this-site.com/?query'], - ['http://this-site.com/#anchor', 'http://this-site.com/#anchor'], - ['http://this-site.com/?foo=bar&baz=qux', 'http://this-site.com/?foo=bar&baz=qux'], - ['http://this-site.com/?foo=bar&baz=qux#anchor', 'http://this-site.com/?foo=bar&baz=qux#anchor'], - - ['http://this-site.com/about', 'http://this-site.com/about/'], - ['http://this-site.com/about?query', 'http://this-site.com/about/?query'], - ['http://this-site.com/about#anchor', 'http://this-site.com/about/#anchor'], - ['http://this-site.com/about?foo=bar&baz=qux', 'http://this-site.com/about/?foo=bar&baz=qux'], - ['http://this-site.com/about?foo=bar&baz=qux#anchor', 'http://this-site.com/about/?foo=bar&baz=qux#anchor'], - - ['http://this-site.com/about/', 'http://this-site.com/about/'], - ['http://this-site.com/about/?query', 'http://this-site.com/about/?query'], - ['http://this-site.com/about/#anchor', 'http://this-site.com/about/#anchor'], - ['http://this-site.com/about/?foo=bar&baz=qux', 'http://this-site.com/about/?foo=bar&baz=qux'], - ['http://this-site.com/about/?foo=bar&baz=qux#anchor', 'http://this-site.com/about/?foo=bar&baz=qux#anchor'], - - ['http://external-site.com', 'http://external-site.com'], - ['http://external-site.com/about', 'http://external-site.com/about'], - ['http://external-site.com/about?query', 'http://external-site.com/about?query'], - ['http://external-site.com/about#anchor', 'http://external-site.com/about#anchor'], - ['http://external-site.com/about?query#anchor', 'http://external-site.com/about?query#anchor'], + 'null case always enforces slash' => [null, '/'], + 'empty string always enforces slash' => ['', '/'], + 'relative homepage always enforces slash' => ['/', '/'], + + 'homepage without leading slash and query param enforces slash' => ['?query', '/?query'], + 'homepage without leading slash with anchor fragment enforces slash' => ['#anchor', '/#anchor'], + 'homepage without leading slash and both query and anchor enforces slash' => ['?query#anchor', '/?query#anchor'], + + 'homepage with leading slash and query param enforces slash' => ['/?query', '/?query'], + 'homepage with leading slash with anchor fragment enforces slash' => ['/#anchor', '/#anchor'], + 'homepage with leading slash and both query and anchor enforces slash' => ['/?query#anchor', '/?query#anchor'], + + 'relative route with leading slash enforces trailing slash' => ['/about', '/about/'], + 'relative route with leading slash and query param enforces trailing slash' => ['/about?query', '/about/?query'], + 'relative route with leading slash and anchor fragment enforces trailing slash' => ['/about#anchor', '/about/#anchor'], + 'relative route with leading slash and both query and anchor enforces trailing slash' => ['/about?query#anchor', '/about/?query#anchor'], + + 'relative route without leading slash enforces trailing slash' => ['about', '/about/'], + 'relative route without leading slash and query param enforces trailing slash' => ['about?query', '/about/?query'], + 'relative route without leading slash and anchor fragment enforces trailing slash' => ['about#anchor', '/about/#anchor'], + 'relative route without leading slash and both query and anchor enforces trailing slash' => ['about?query#anchor', '/about/?query#anchor'], + + 'relative route no change needed' => ['/about/', '/about/'], + 'relative route with query param no change needed' => ['/about/?query', '/about/?query'], + 'relative route with anchor fragment no change needed' => ['/about/#anchor', '/about/#anchor'], + 'relative route with both query and anchor no change needed' => ['/about/?query#anchor', '/about/?query#anchor'], + + 'absolute homepage without trailing slash enforces trailing slash' => ['http://this-site.com', 'http://this-site.com/'], + 'absolute homepage without trailing slash and query param enforces trailing slash' => ['http://this-site.com?query', 'http://this-site.com/?query'], + 'absolute homepage without trailing slash and anchor fragment enforces trailing slash' => ['http://this-site.com#anchor', 'http://this-site.com/#anchor'], + 'absolute homepage without trailing slash and both query and anchor enforces trailing slash' => ['http://this-site.com?query#anchor', 'http://this-site.com/?query#anchor'], + + 'absolute homepage with trailing slash no change needed' => ['http://this-site.com/', 'http://this-site.com/'], + 'absolute homepage with trailing slash and query param no change needed' => ['http://this-site.com/?query', 'http://this-site.com/?query'], + 'absolute homepage with trailing slash and anchor fragment no change needed' => ['http://this-site.com/#anchor', 'http://this-site.com/#anchor'], + 'absolute homepage with trailing slash and both query and anchor no change needed' => ['http://this-site.com/?query#anchor', 'http://this-site.com/?query#anchor'], + + 'absolute route without trailing slash enforces trailing slash' => ['http://this-site.com/about', 'http://this-site.com/about/'], + 'absolute route without trailing slash and query param enforces trailing slash' => ['http://this-site.com/about?query', 'http://this-site.com/about/?query'], + 'absolute route without trailing slash and anchor fragment enforces trailing slash' => ['http://this-site.com/about#anchor', 'http://this-site.com/about/#anchor'], + 'absolute route without trailing slash and both query and anchor enforces trailing slash' => ['http://this-site.com/about?query#anchor', 'http://this-site.com/about/?query#anchor'], + + 'absolute route with trailing slash no change needed' => ['http://this-site.com/about/', 'http://this-site.com/about/'], + 'absolute route with trailing slash and query param no change needed' => ['http://this-site.com/about/?query', 'http://this-site.com/about/?query'], + 'absolute route with trailing slash and anchor fragment no change needed' => ['http://this-site.com/about/#anchor', 'http://this-site.com/about/#anchor'], + 'absolute route with trailing slash and both query and anchor no change needed' => ['http://this-site.com/about/?query#anchor', 'http://this-site.com/about/?query#anchor'], + + 'doesnt enforce on external site homepage' => ['http://external-site.com', 'http://external-site.com'], + 'doesnt enforce on external site route' => ['http://external-site.com/about', 'http://external-site.com/about'], + 'doesnt enforce on external site route with query param' => ['http://external-site.com/about?query', 'http://external-site.com/about?query'], + 'doesnt enforce on external site route with anchor fragment' => ['http://external-site.com/about#anchor', 'http://external-site.com/about#anchor'], + 'doesnt enforce on external site route with query and anchor' => ['http://external-site.com/about?query#anchor', 'http://external-site.com/about?query#anchor'], ]; } @@ -777,61 +783,58 @@ public function removes_trailing_slashes($url, $expected) public static function removeTrailingSlashesProvider() { return [ - [null, '/'], - ['', '/'], - ['/', '/'], - - ['?query', '/?query'], - ['#anchor', '/#anchor'], - ['?foo=bar&baz=qux', '/?foo=bar&baz=qux'], - ['?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], - - ['/?query', '/?query'], - ['/#anchor', '/#anchor'], - ['/?foo=bar&baz=qux', '/?foo=bar&baz=qux'], - ['/?foo=bar&baz=qux#anchor', '/?foo=bar&baz=qux#anchor'], - - ['/about', '/about'], - ['/about?query', '/about?query'], - ['/about#anchor', '/about#anchor'], - ['/about?foo=bar&baz=qux', '/about?foo=bar&baz=qux'], - ['/about?foo=bar&baz=qux#anchor', '/about?foo=bar&baz=qux#anchor'], - - ['/about/', '/about'], - ['/about/?query', '/about?query'], - ['/about/#anchor', '/about#anchor'], - ['/about/?foo=bar&baz=qux', '/about?foo=bar&baz=qux'], - ['/about/?foo=bar&baz=qux#anchor', '/about?foo=bar&baz=qux#anchor'], - - ['http://this-site.com', 'http://this-site.com'], - ['http://this-site.com?query', 'http://this-site.com?query'], - ['http://this-site.com#anchor', 'http://this-site.com#anchor'], - ['http://this-site.com?foo=bar&baz=qux', 'http://this-site.com?foo=bar&baz=qux'], - ['http://this-site.com?foo=bar&baz=qux#anchor', 'http://this-site.com?foo=bar&baz=qux#anchor'], - - ['http://this-site.com/', 'http://this-site.com'], - ['http://this-site.com/?query', 'http://this-site.com?query'], - ['http://this-site.com/#anchor', 'http://this-site.com#anchor'], - ['http://this-site.com/?foo=bar&baz=qux', 'http://this-site.com?foo=bar&baz=qux'], - ['http://this-site.com/?foo=bar&baz=qux#anchor', 'http://this-site.com?foo=bar&baz=qux#anchor'], - - ['http://this-site.com/about', 'http://this-site.com/about'], - ['http://this-site.com/about?query', 'http://this-site.com/about?query'], - ['http://this-site.com/about#anchor', 'http://this-site.com/about#anchor'], - ['http://this-site.com/about?foo=bar&baz=qux', 'http://this-site.com/about?foo=bar&baz=qux'], - ['http://this-site.com/about?foo=bar&baz=qux#anchor', 'http://this-site.com/about?foo=bar&baz=qux#anchor'], - - ['http://this-site.com/about/', 'http://this-site.com/about'], - ['http://this-site.com/about/?query', 'http://this-site.com/about?query'], - ['http://this-site.com/about/#anchor', 'http://this-site.com/about#anchor'], - ['http://this-site.com/about/?foo=bar&baz=qux', 'http://this-site.com/about?foo=bar&baz=qux'], - ['http://this-site.com/about/?foo=bar&baz=qux#anchor', 'http://this-site.com/about?foo=bar&baz=qux#anchor'], - - ['http://external-site.com/', 'http://external-site.com/'], - ['http://external-site.com/about/', 'http://external-site.com/about/'], - ['http://external-site.com/about/?query', 'http://external-site.com/about/?query'], - ['http://external-site.com/about/#anchor', 'http://external-site.com/about/#anchor'], - ['http://external-site.com/about/?query#anchor', 'http://external-site.com/about/?query#anchor'], + 'null case always enforces slash' => [null, '/'], + 'empty string always enforces slash' => ['', '/'], + 'relative homepage always enforces slash' => ['/', '/'], + + 'homepage without leading slash and query param enforces slash' => ['?query', '/?query'], + 'homepage without leading slash with anchor fragment enforces slash' => ['#anchor', '/#anchor'], + 'homepage without leading slash and both query and anchor enforces slash' => ['?query#anchor', '/?query#anchor'], + + 'homepage with leading slash and query param enforces slash' => ['/?query', '/?query'], + 'homepage with leading slash with anchor fragment enforces slash' => ['/#anchor', '/#anchor'], + 'homepage with leading slash and both query and anchor enforces slash' => ['/?query#anchor', '/?query#anchor'], + + 'relative route no change needed' => ['/about', '/about'], + 'relative route with query param no change needed' => ['/about?query', '/about?query'], + 'relative route with anchor fragment no change needed' => ['/about#anchor', '/about#anchor'], + 'relative route with both query and anchor no change needed' => ['/about?query#anchor', '/about?query#anchor'], + + 'relative route without leading slash removes trailing slash' => ['about/', '/about'], + 'relative route without leading slash and query param removes trailing slash' => ['about/?query', '/about?query'], + 'relative route without leading slash and anchor fragment removes trailing slash' => ['about/#anchor', '/about#anchor'], + 'relative route without leading slash and both query and anchor removes trailing slash' => ['about/?query#anchor', '/about?query#anchor'], + + 'relative route with leading slash removes trailing slash' => ['/about/', '/about'], + 'relative route with leading slash and query param removes trailing slash' => ['/about/?query', '/about?query'], + 'relative route with leading slash and anchor fragment removes trailing slash' => ['/about/#anchor', '/about#anchor'], + 'relative route with leading slash and both query and anchor removes trailing slash' => ['/about/?query#anchor', '/about?query#anchor'], + + 'absolute homepage without trailing slash no change needed' => ['http://this-site.com', 'http://this-site.com'], + 'absolute homepage without trailing slash and query param no change needed' => ['http://this-site.com?query', 'http://this-site.com?query'], + 'absolute homepage without trailing slash and anchor fragment no change needed' => ['http://this-site.com#anchor', 'http://this-site.com#anchor'], + 'absolute homepage without trailing slash and both query and anchor no change needed' => ['http://this-site.com?query#anchor', 'http://this-site.com?query#anchor'], + + 'absolute homepage with trailing slash removes trailing slash' => ['http://this-site.com/', 'http://this-site.com'], + 'absolute homepage with trailing slash and query param removes trailing slash' => ['http://this-site.com/?query', 'http://this-site.com?query'], + 'absolute homepage with trailing slash and anchor fragment removes trailing slash' => ['http://this-site.com/#anchor', 'http://this-site.com#anchor'], + 'absolute homepage with trailing slash and both query and anchor removes trailing slash' => ['http://this-site.com/?query#anchor', 'http://this-site.com?query#anchor'], + + 'absolute route without trailing slash no change needed' => ['http://this-site.com/about', 'http://this-site.com/about'], + 'absolute route without trailing slash and query param no change needed' => ['http://this-site.com/about?query', 'http://this-site.com/about?query'], + 'absolute route without trailing slash and anchor fragment no change needed' => ['http://this-site.com/about#anchor', 'http://this-site.com/about#anchor'], + 'absolute route without trailing slash and both query and anchor no change needed' => ['http://this-site.com/about?query#anchor', 'http://this-site.com/about?query#anchor'], + + 'absolute route with trailing slash removes trailing slash' => ['http://this-site.com/about/', 'http://this-site.com/about'], + 'absolute route with trailing slash and query param removes trailing slash' => ['http://this-site.com/about/?query', 'http://this-site.com/about?query'], + 'absolute route with trailing slash and anchor fragment removes trailing slash' => ['http://this-site.com/about/#anchor', 'http://this-site.com/about#anchor'], + 'absolute route with trailing slash and both query and anchor removes trailing slash' => ['http://this-site.com/about/?query#anchor', 'http://this-site.com/about?query#anchor'], + + 'doesnt remove on external site homepage' => ['http://external-site.com/', 'http://external-site.com/'], + 'doesnt remove on external site route' => ['http://external-site.com/about/', 'http://external-site.com/about/'], + 'doesnt remove on external site route with query param' => ['http://external-site.com/about/?query', 'http://external-site.com/about/?query'], + 'doesnt remove on external site route with anchor fragment' => ['http://external-site.com/about/#anchor', 'http://external-site.com/about/#anchor'], + 'doesnt remove on external site route with query and anchor' => ['http://external-site.com/about/?query#anchor', 'http://external-site.com/about/?query#anchor'], ]; }