diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index 29765fce49..ca3e9cd3be 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -140,7 +140,7 @@ public function url() $url = rtrim($this->disk()->url('/'), '/'); - return ($url === '') ? '/' : $url; + return URL::tidy($url); } /** diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 037a1bc7cf..4ab45d9cbd 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -2,10 +2,9 @@ namespace Statamic\Facades\Endpoint; -use Statamic\Data\Services\ContentService; +use Illuminate\Support\Collection; use Statamic\Facades\Config; use Statamic\Facades\Path; -use Statamic\Facades\Pattern; use Statamic\Facades\Site; use Statamic\Support\Str; @@ -14,122 +13,129 @@ */ class URL { - private static $externalUriCache = []; + private static $enforceTrailingSlashes = false; + private static $absoluteSiteUrlsCache; + private static $externalSiteUrlsCache; + private static $externalAppUrlsCache; /** - * Removes occurrences of "//" in a $path (except when part of a protocol) - * Alias of Path::tidy(). - * - * @param string $url URL to remove "//" from - * @return string + * Configure whether or not to enforce trailing slashes when normalizing URL output throughout this class. */ - public function tidy($url) + public function enforceTrailingSlashes(bool $bool = true): void { - return Path::tidy($url); + self::$enforceTrailingSlashes = $bool; } /** - * Assembles a URL from an ordered list of segments. - * - * @param mixed string Open ended number of arguments - * @return string + * Tidy a URL (normalize slashes). */ - public function assemble($args) + public function tidy(?string $url, bool $force = false): string { - $args = func_get_args(); + // Remove occurrences of '//', except when part of protocol. + $url = Path::tidy($url); - return Path::assemble($args); + // 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; + } + + // 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; } /** - * Get the slug of a URL. - * - * @param string $url URL to parse - * @return string + * Assemble a URL from an ordered list of segments. */ - public function slug($url) + public function assemble(?string ...$segments): string { - return basename($url); + return self::tidy(Path::assemble($segments)); } /** - * Swaps the slug of a $url with the $slug provided. - * - * @param string $url URL to modify - * @param string $slug New slug to use - * @return string + * Get the slug at the end of a URL. */ - public function replaceSlug($url, $slug) + public function slug(?string $url): ?string { - return Path::replaceSlug($url, $slug); + $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); + + if (parse_url($url)['path'] === '/') { + return null; + } + + return basename(self::removeQueryAndFragment($url)); } /** - * Get the parent URL. - * - * @param string $url - * @return string + * Replace the slug at the end of a URL with the provided slug. */ - public function parent($url) + public function replaceSlug(?string $url, string $slug): string { - $url_array = explode('/', $url); - array_pop($url_array); + 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 = implode('/', $url_array); + $url = self::tidy(Path::replaceSlug($url, $slug)); - return ($url == '') ? '/' : $url; + return $url.$queryAndFragments; } /** - * Checks if one URL is an ancestor of another. + * Get the parent URL. */ - public function isAncestorOf($child, $ancestor) + public function parent(?string $url): string { - $child = Str::before($child, '?'); - $child = Str::ensureRight($child, '/'); - $ancestor = Str::ensureRight($ancestor, '/'); + $trailingSlash = self::isAbsolute($url) && self::isExternalToApplication($url) + ? self::hasTrailingSlash($url) + : self::$enforceTrailingSlashes; - if ($child === $ancestor) { - return false; + $strMethod = $trailingSlash + ? 'ensureRight' + : 'removeRight'; + + $url = Str::ensureRight(self::removeQueryAndFragment($url), '/'); + + if (parse_url($url)['path'] !== '/') { + $url = preg_replace('/[^\/]*\/$/', '', $url); } - return Str::startsWith($child, $ancestor); + return Str::$strMethod(self::tidy($url), '/') ?: '/'; } /** - * Make sure the site root is prepended to a URL. - * - * @param string $url - * @param string|null $locale - * @param bool $controller - * @return string + * Check if one URL is an ancestor of another. */ - public function prependSiteRoot($url, $locale = null, $controller = true) + public function isAncestorOf(?string $child, ?string $ancestor): bool { - // 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; + $child = Str::ensureRight(self::removeQueryAndFragment($child), '/'); + $ancestor = Str::ensureRight(self::removeQueryAndFragment($ancestor), '/'); + + if ($child === $ancestor) { + return false; } - return self::makeRelative( - self::prependSiteUrl($url, $locale, $controller) - ); + return Str::startsWith($child, $ancestor); } /** - * Make sure the site root url is prepended to a URL. - * - * @param string $url - * @param string|null $locale - * @param bool $controller - * @return string + * Prepend site URL to a URL. */ - public function prependSiteUrl($url, $locale = null, $controller = true) + public function prependSiteUrl(?string $url, ?string $locale = null, bool $controller = true): string { - $prepend = rtrim(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. @@ -139,29 +145,21 @@ 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); } /** - * Removes the site root url from the beginning of a URL. - * - * @param string $url - * @return string + * Remove current site URL from the beginning of a URL. */ - public function removeSiteUrl($url) + public function removeSiteUrl(?string $url): string { - return preg_replace('#^'.Config::getSiteUrl().'#', '/', $url); + return self::tidy(preg_replace('#^'.Config::getSiteUrl().'#', '/', $url)); } /** - * Make an absolute URL relative. - * - * @param string $url - * @return string + * Make an absolute URL relative (with leading slash). */ - public function makeRelative($url) + public function makeRelative(?string $url): string { $parsed = parse_url($url); @@ -175,102 +173,103 @@ public function makeRelative($url) $url .= '#'.$parsed['fragment']; } - return $url; + return self::tidy($url); } /** - * Make a relative URL absolute. - * - * @param string $url - * @return string + * Make a relative URL absolute (prepends domain if not already absolute). */ - public function makeAbsolute($url) + 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, '/')) { + // If URL is external to this Statamic application, we'll just leave it as-is. + if (self::isAbsolute($url) && self::isExternalToApplication($url)) { return $url; } - return self::tidy(Str::ensureLeft($url, self::getSiteUrl())); + if (self::isAbsolute($url)) { + return self::tidy($url); + } + + $url = Str::ensureLeft($url, '/'); + $url = Str::ensureLeft($url, self::getRequestRootUrl()); + + return self::tidy($url); } /** * Get the current URL. - * - * @return string */ - public function getCurrent() + public function getCurrent(): string { - return self::format(app('request')->path()); + return self::tidy(request()->path()); } /** - * Formats a URL properly. - * - * @param string $url - * @return string + * Check whether a URL is absolute. */ - public function format($url) + public function isAbsolute(?string $url): bool { - return self::tidy('/'.trim($url, '/')); + return Str::startsWith($url, ['http:', 'https:']); } /** - * Checks whether a URL is external or not. - * - * @param string $url - * @return bool + * Check whether a URL is external to current site. */ - public function isExternal($url) + public function isExternal(?string $url): bool { - if (isset(self::$externalUriCache[$url])) { - return self::$externalUriCache[$url]; + if (isset(self::$externalSiteUrlsCache[$url])) { + return self::$externalSiteUrlsCache[$url]; } if (! $url) { return false; } - if (Str::startsWith($url, ['/', '#'])) { - return self::$externalUriCache[$url] = false; - } - - $isExternal = ! Pattern::startsWith( - Str::ensureRight($url, '/'), - Site::current()->absoluteUrl() - ); + $url = Str::ensureRight($url, '/'); - self::$externalUriCache[$url] = $isExternal; + if (Str::startsWith($url, ['/', '?', '#'])) { + return self::$externalSiteUrlsCache[$url] = false; + } - return $isExternal; - } + $isExternal = ! Str::startsWith($url, Str::ensureRight(Site::current()->absoluteUrl(), '/')); - public function clearExternalUrlCache() - { - self::$externalUriCache = []; + return self::$externalSiteUrlsCache[$url] = $isExternal; } /** - * Get the current site url from Apache headers. - * - * @return string + * Check whether a URL is external to whole Statamic application. */ - public function getSiteUrl() + public function isExternalToApplication(?string $url): bool { - $rootUrl = url()->to('/'); + if (isset(self::$externalAppUrlsCache[$url])) { + return self::$externalAppUrlsCache[$url]; + } + + if (! $url) { + return false; + } + + $url = Str::ensureRight($url, '/'); + + if (Str::startsWith($url, ['/', '?', '#'])) { + return self::$externalAppUrlsCache[$url] = false; + } + + $isExternalToSites = self::getAbsoluteSiteUrls() + ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) + ->isEmpty(); - return Str::ensureRight($rootUrl, '/'); + $isExternalToCurrentRequestDomain = ! Str::startsWith($url, self::getDomainFromAbsolute(url()->to('/'))); + + return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } /** * Encode a URL. - * - * @param string $url - * @return string */ - public function encode($url) + public function encode(?string $url): string { - $dont_encode = [ + $dontEncode = [ '%2F' => '/', '%40' => '@', '%3A' => ':', @@ -287,30 +286,13 @@ public function encode($url) '%25' => '%', ]; - return strtr(rawurlencode($url), $dont_encode); + return self::tidy(strtr(rawurlencode($url), $dontEncode)); } /** - * 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 + * Return a gravatar image URL for an email address. */ - public function getDefaultUri($locale, $uri) - { - return $uri; // TODO - - return app(ContentService::class)->defaultUri($locale, $uri); - } - - /** - * Return a gravatar image. - * - * @param string $email - * @param int $size - * @return string - */ - public function gravatar($email, $size = null) + public function gravatar(string $email, ?int $size = null): string { $url = 'https://www.gravatar.com/avatar/'.e(md5(strtolower($email))); @@ -323,15 +305,86 @@ 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(?string $url): ?string { $url = Str::before($url, '?'); // Remove query params $url = Str::before($url, '#'); // Remove anchor fragment - return $url; + return self::tidy($url); + } + + /** + * Clear URL property caches. + */ + public function clearUrlCache(): void + { + self::$absoluteSiteUrlsCache = null; + self::$externalSiteUrlsCache = null; + self::$externalAppUrlsCache = null; + } + + /** + * Normalize trailing slash before query and fragment (trims by default, but can be enforced). + */ + private function normalizeTrailingSlash(?string $url): string + { + $parts = str($url) + ->split(pattern: '/([?#])/', flags: PREG_SPLIT_DELIM_CAPTURE) + ->all(); + + $url = array_shift($parts); + $queryAndFragments = implode($parts); + + if (in_array($url, ['', '/'])) { + $url = '/'; + } elseif (self::$enforceTrailingSlashes) { + $url = Str::ensureRight($url, '/'); + } else { + $url = Str::removeRight($url, '/'); + } + + 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(fn ($site) => $site->rawConfig()['url'] ?? null) + ->filter(fn ($siteUrl) => self::isAbsolute($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); + } + + /** + * 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. + */ + private function getRequestRootUrl(): string + { + $rootUrl = url()->to('/'); + + return self::tidy($rootUrl); } } diff --git a/src/Facades/URL.php b/src/Facades/URL.php index a32c15e446..b970fdee5e 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) @@ -19,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/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)) + ); } } 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(); } } 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); } } diff --git a/src/Sites/Site.php b/src/Sites/Site.php index cc1f6f365a..75597284fe 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'], true); } 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) 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) 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/Tags/Structure.php b/src/Tags/Structure.php index 9ce1809039..727e4dec3f 100644 --- a/src/Tags/Structure.php +++ b/src/Tags/Structure.php @@ -137,8 +137,8 @@ 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_parent' => ! is_null($url) && $this->siteAbsoluteUrl !== $absoluteUrl && URL::isAncestorOf($this->currentUrl, $url), + 'is_current' => ! is_null($url) && $url === $this->currentUrl, + '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(); 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/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] diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index fbfb07328c..45cbbd2591 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -5,10 +5,19 @@ 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 { + public function tearDown(): void + { + URL::enforceTrailingSlashes(false); + URL::clearUrlCache(); + + parent::tearDown(); + } + protected function resolveApplicationConfiguration($app) { parent::resolveApplicationConfiguration($app); @@ -16,45 +25,165 @@ protected function resolveApplicationConfiguration($app) $app['config']->set('app.url', 'http://absolute-url-resolved-from-request.com'); } - public function testPrependsSiteUrl() + #[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(); + + 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)); + } + + 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'], + + '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_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() { $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')); } - public function testPrependsSiteUrlWithController() + #[Test] + 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')); } - 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'); $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)); } - public function testDeterminesExternalUrl() + #[Test] + public function it_removes_site_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->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://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] + 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() + { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + + $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')); @@ -64,12 +193,15 @@ 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')); - $this->assertTrue(URL::isExternal('http://that-site.com/')); - $this->assertTrue(URL::isExternal('http://that-site.com/some-slug')); + $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')); @@ -79,6 +211,225 @@ public function testDeterminesExternalUrlWhenUsingRelativeInConfig() $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')); + + $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) + { + $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) + { + $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('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) + { + $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(); + + if (! Str::contains($parent, 'external')) { + $parent = Str::ensureRight($parent, '/'); + } + + $this->assertSame($parent, URL::parent($child)); + } + + public static function parentProvider() + { + return [ + 'relative homepage to homepage' => ['/', '/'], + '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://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://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://this-site.com/?alpha', 'http://this-site.com'], + 'removes anchor fragment from relative url' => ['/#alpha', '/'], + '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://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 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/'], + ]; + } + #[Test] #[DataProvider('ancestorProvider')] public function it_checks_whether_a_url_is_an_ancestor_of_another($child, $parent, $isAncestor) @@ -108,136 +459,420 @@ 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], ]; } - #[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) { + $this->setSiteValue('en', 'url', 'http://this-site.com/'); + if ($forceScheme) { \Illuminate\Support\Facades\URL::forceScheme($forceScheme); } $this->assertSame($expected, URL::makeAbsolute($url)); + + URL::enforceTrailingSlashes(); + + $expected = Str::contains($url, 'external-site.com') + ? $url + : Str::ensureRight($expected, '/'); + + $this->assertSame($expected, URL::makeAbsolute($url)); } public static function absoluteProvider() { return [ - ['http://example.com', 'http://example.com'], - ['http://example.com/', 'http://example.com/'], - ['/', '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 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'], - ['/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'], - ['/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'], ]; } #[Test] #[DataProvider('relativeProvider')] - public function makes_urls_relative($url, $expected) + 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/'], - - ['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'], + ]; + } + + #[Test] + #[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(); + + $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://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'], + ]; + } + + #[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] + #[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(); + + $expected = preg_replace('/this-site\.com$/', 'this-site.com/', $expected); + $expected = str_replace('page', 'page/', $expected); + + $this->assertSame($expected, URL::removeQueryAndFragment($url)); + } + + public static function removeQueryAndFragmentProvider() + { + return [ + 'null case tidies to relative homepage' => [null, '/'], + + '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://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/'], + ]; + } + + #[Test] + #[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)); + } + + public static function enforceTrailingSlashesProvider() + { + return [ + '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'], ]; } #[Test] - public function it_can_remove_query_and_fragment() + #[DataProvider('removeTrailingSlashesProvider')] + public function removes_trailing_slashes($url, $expected) { - $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')); + $this->setSiteValue('en', 'url', 'http://this-site.com/'); - $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')); + $this->assertSame($expected, URL::tidy($url)); + } - $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')); + public static function removeTrailingSlashesProvider() + { + return [ + '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'], + ]; + } + + #[Test] + public function it_can_configure_and_unconfigure_enforcing_of_trailing_slashes() + { + $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('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(false); + + $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')); } } 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') ); } 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)