diff --git a/Classes/Enum/TrailingSlashModeEnum.php b/Classes/Enum/TrailingSlashModeEnum.php new file mode 100644 index 0000000..2028872 --- /dev/null +++ b/Classes/Enum/TrailingSlashModeEnum.php @@ -0,0 +1,11 @@ +configuration['enable']['trailingSlash'] ?? false; } + public function getTrailingSlashMode(): TrailingSlashModeEnum + { + return TrailingSlashModeEnum::tryFrom($this->configuration['trailingSlashMode']) ?? TrailingSlashModeEnum::ADD; + } + public function isToLowerCaseEnabled(): bool { return $this->configuration['enable']['toLowerCase'] ?? false; diff --git a/Classes/Helper/TrailingSlashHelper.php b/Classes/Helper/TrailingSlashHelper.php index d7b5ac1..6442d08 100644 --- a/Classes/Helper/TrailingSlashHelper.php +++ b/Classes/Helper/TrailingSlashHelper.php @@ -11,22 +11,40 @@ class TrailingSlashHelper { public function appendTrailingSlash(UriInterface $uri): UriInterface + { + if (! $this->shouldUriByHandled($uri)) { + return $uri; + } + + return $uri->withPath(rtrim($uri->getPath(), '/') . '/'); + } + + public function removeTrailingSlash(UriInterface $uri): UriInterface + { + if (! $this->shouldUriByHandled($uri)) { + return $uri; + } + + return $uri->withPath(rtrim($uri->getPath(), '/')); + } + + private function shouldUriByHandled(UriInterface $uri): bool { // bypass links without path if (strlen($uri->getPath()) === 0) { - return $uri; + return false; } // bypass links to files if (array_key_exists('extension', pathinfo($uri->getPath()))) { - return $uri; + return false; } // bypass mailto and tel links if (in_array($uri->getScheme(), ['mailto', 'tel'], true)) { - return $uri; + return false; } - return $uri->withPath(rtrim($uri->getPath(), '/') . '/'); + return true; } } diff --git a/Classes/LinkingServiceAspect.php b/Classes/LinkingServiceAspect.php index 198168e..66b6bf2 100644 --- a/Classes/LinkingServiceAspect.php +++ b/Classes/LinkingServiceAspect.php @@ -4,6 +4,7 @@ namespace Flowpack\SeoRouting; +use Flowpack\SeoRouting\Enum\TrailingSlashModeEnum; use Flowpack\SeoRouting\Helper\BlocklistHelper; use Flowpack\SeoRouting\Helper\ConfigurationHelper; use Flowpack\SeoRouting\Helper\TrailingSlashHelper; @@ -26,10 +27,10 @@ class LinkingServiceAspect protected BlocklistHelper $blocklistHelper; /** - * This ensures that all internal links are rendered with a trailing slash. + * This ensures that all internal links are rendered with/without a trailing slash, depending on configuration. */ #[Flow\Around('method(' . LinkingService::class . '->createNodeUri())')] - public function appendTrailingSlashToNodeUri(JoinPointInterface $joinPoint): string + public function handleTrailingSlashForNodeUri(JoinPointInterface $joinPoint): string { /** @var string $result */ $result = $joinPoint->getAdviceChain()->proceed($joinPoint); @@ -48,6 +49,10 @@ public function appendTrailingSlashToNodeUri(JoinPointInterface $joinPoint): str return $result; } - return (string)$this->trailingSlashHelper->appendTrailingSlash($uri); + if ($this->configurationHelper->getTrailingSlashMode() === TrailingSlashModeEnum::ADD) { + return (string)$this->trailingSlashHelper->appendTrailingSlash($uri); + } + + return (string)$this->trailingSlashHelper->removeTrailingSlash($uri); } } diff --git a/Classes/RoutingMiddleware.php b/Classes/RoutingMiddleware.php index 6714ce4..59e9776 100644 --- a/Classes/RoutingMiddleware.php +++ b/Classes/RoutingMiddleware.php @@ -4,6 +4,7 @@ namespace Flowpack\SeoRouting; +use Flowpack\SeoRouting\Enum\TrailingSlashModeEnum; use Flowpack\SeoRouting\Helper\BlocklistHelper; use Flowpack\SeoRouting\Helper\ConfigurationHelper; use Flowpack\SeoRouting\Helper\LowerCaseHelper; @@ -50,7 +51,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $oldPath = $uri->getPath(); if ($isTrailingSlashEnabled) { - $uri = $this->trailingSlashHelper->appendTrailingSlash($uri); + match ($this->configurationHelper->getTrailingSlashMode()) { + TrailingSlashModeEnum::ADD => $uri = $this->trailingSlashHelper->appendTrailingSlash($uri), + TrailingSlashModeEnum::REMOVE => $uri = $this->trailingSlashHelper->removeTrailingSlash($uri), + }; } if ($isToLowerCaseEnabled) { diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index d8ea3eb..77ff9d2 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -4,6 +4,7 @@ Flowpack: enable: trailingSlash: true toLowerCase: false + trailingSlashMode: 'add' statusCode: 301 blocklist: '/neos.*': true diff --git a/README.md b/README.md index 7d91d23..5770e5f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ * [Installation](#installation) * [Configuration](#configuration) * [Standard Configuration](#standard-configuration) + * [Trailing slash mode](#trailing-slash-mode) * [Blocklist for redirects](#blocklist-for-redirects) * [Thank you](#thank-you) @@ -20,15 +21,15 @@ Thank you [Biallo & Team GmbH](https://www.biallo.de/) for sponsoring the work f ## Introduction -This package allows you to enforce a trailing slash and/or lower case urls in Flow/Neos. +This package allows you to enforce a trailing slash or enforce no trailing slash and/or lower case urls in Flow/Neos. ## Features -This package has 2 main features: +Main features: - **trailingSlash**: ensure that all rendered internal links in the frontend end with a trailing slash (e.g. `example. com/test/` instead of `example.com/test`) and all called URLs without trailing slash will be redirected to the same - page with a trailing slash + page with a trailing slash or the opposite (e.g. `example.com/test` instead of `example.com/test/`) - **toLowerCase**: ensure that camelCase links gets redirected to lowercase (e.g. `example.com/lowercase` instead of `example.com/lowerCase`) @@ -68,11 +69,19 @@ Flowpack: enable: trailingSlash: true toLowerCase: false + trailingSlashMode: 'add' statusCode: 301 blocklist: '/neos.*': true ``` +### Trailing slash mode + +You can set the `trailingSlashMode` to `add` or `remove`. For this setting to have an effect you have to set +`trailingSlash` to true. + +This effects redirects and all rendered internal urls. + ### Blocklist for redirects By default, all `/neos` URLs are ignored for redirects. You can extend the blocklist array with regex as you like: diff --git a/Tests/Unit/Helper/ConfigurationHelperTest.php b/Tests/Unit/Helper/ConfigurationHelperTest.php index 1d741b1..7a99cd5 100644 --- a/Tests/Unit/Helper/ConfigurationHelperTest.php +++ b/Tests/Unit/Helper/ConfigurationHelperTest.php @@ -4,6 +4,7 @@ namespace Flowpack\SeoRouting\Tests\Unit\Helper; +use Flowpack\SeoRouting\Enum\TrailingSlashModeEnum; use Flowpack\SeoRouting\Helper\ConfigurationHelper; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -38,6 +39,20 @@ public function testIsTrailingSlashEnabledShouldReturnFalse(): void self::assertFalse($this->configurationHelper->isTrailingSlashEnabled()); } + public function testGetTrailingSlashModeShouldReturnGivenMode(): void + { + $this->injectConfiguration(['trailingSlashMode' => 'remove']); + + self::assertSame(TrailingSlashModeEnum::REMOVE, $this->configurationHelper->getTrailingSlashMode()); + } + + public function testGetTrailingSlashModeShouldReturnDefaultMode(): void + { + $this->injectConfiguration(['trailingSlashMode' => 'foo']); + + self::assertSame(TrailingSlashModeEnum::ADD, $this->configurationHelper->getTrailingSlashMode()); + } + public function testIsToLowerCaseEnabledShouldReturnTrue(): void { $this->injectConfiguration(['enable' => ['trailingSlash' => false, 'toLowerCase' => true]]); @@ -76,7 +91,7 @@ public function testGetStatusCodeShouldReturnConfiguredValue(): void } /** - * @param array{enable: array{trailingSlash: bool, toLowerCase: bool}, statusCode?: int}|array{} $configuration + * @param array{enable?: array{trailingSlash: bool, toLowerCase: bool}, statusCode?: int, trailingSlashMode?: string}|array{} $configuration */ private function injectConfiguration(array $configuration): void { diff --git a/Tests/Unit/Helper/TrailingSlashHelperTest.php b/Tests/Unit/Helper/TrailingSlashHelperTest.php index 1c7b019..053477a 100644 --- a/Tests/Unit/Helper/TrailingSlashHelperTest.php +++ b/Tests/Unit/Helper/TrailingSlashHelperTest.php @@ -13,7 +13,7 @@ #[CoversClass(TrailingSlashHelper::class)] class TrailingSlashHelperTest extends TestCase { - #[DataProvider('urlDataProvider')] + #[DataProvider('urlDataProviderForAppendTrailingSlash')] public function testAppendTrailingSlash(string $input, string $output): void { $uri = new Uri($input); @@ -21,10 +21,18 @@ public function testAppendTrailingSlash(string $input, string $output): void self::assertSame($output, (string)(new TrailingSlashHelper())->appendTrailingSlash($uri)); } + #[DataProvider('urlDataProviderForRemoveTrailingSlash')] + public function testRemoveTrailingSlash(string $input, string $output): void + { + $uri = new Uri($input); + + self::assertSame($output, (string)(new TrailingSlashHelper())->removeTrailingSlash($uri)); + } + /** * @return array{string[]} */ - public static function urlDataProvider(): array + public static function urlDataProviderForAppendTrailingSlash(): array { return [ ['', ''], @@ -47,4 +55,31 @@ public static function urlDataProvider(): array ['https://test.de/foo/bar.css', 'https://test.de/foo/bar.css'], ]; } + + /** + * @return array{string[]} + */ + public static function urlDataProviderForRemoveTrailingSlash(): array + { + return [ + ['', ''], + ['/', ''], + ['/foo/', '/foo'], + ['/foo/bar/', '/foo/bar'], + ['https://test.de', 'https://test.de'], + ['https://test.de', 'https://test.de'], + ['https://test.de/foo/bar/', 'https://test.de/foo/bar'], + ['https://test.de/foo/bar', 'https://test.de/foo/bar'], + ['/foo/bar/?some-query=foo%20bar', '/foo/bar?some-query=foo%20bar'], + ['/foo/bar/#some-fragment', '/foo/bar#some-fragment'], + ['/foo/bar/?some-query=foo%20bar#some-fragment', '/foo/bar?some-query=foo%20bar#some-fragment'], + [ + 'https://test.de/foo/bar/?some-query=foo%20bar#some-fragment', + 'https://test.de/foo/bar?some-query=foo%20bar#some-fragment', + ], + ['mailto:some.email@foo.bar', 'mailto:some.email@foo.bar'], + ['tel:+4906516564', 'tel:+4906516564'], + ['https://test.de/foo/bar.css', 'https://test.de/foo/bar.css'], + ]; + } } diff --git a/Tests/Unit/LinkingServiceAspectTest.php b/Tests/Unit/LinkingServiceAspectTest.php index 4f1dee3..e75815e 100644 --- a/Tests/Unit/LinkingServiceAspectTest.php +++ b/Tests/Unit/LinkingServiceAspectTest.php @@ -4,6 +4,7 @@ namespace Flowpack\SeoRouting\Tests\Unit; +use Flowpack\SeoRouting\Enum\TrailingSlashModeEnum; use Flowpack\SeoRouting\Helper\BlocklistHelper; use Flowpack\SeoRouting\Helper\ConfigurationHelper; use Flowpack\SeoRouting\Helper\TrailingSlashHelper; @@ -61,7 +62,7 @@ public function testAppendTrailingSlashToNodeUriShouldNotChangeResultIfTrailingS assertSame( $result, - $this->linkingServiceAspect->appendTrailingSlashToNodeUri($this->joinPointMock) + $this->linkingServiceAspect->handleTrailingSlashForNodeUri($this->joinPointMock) ); } @@ -74,7 +75,7 @@ public function testAppendTrailingSlashToNodeUriShouldNotChangeResultIfUriIsMalf assertSame( $result, - $this->linkingServiceAspect->appendTrailingSlashToNodeUri($this->joinPointMock) + $this->linkingServiceAspect->handleTrailingSlashForNodeUri($this->joinPointMock) ); } @@ -89,16 +90,19 @@ public function testAppendTrailingSlashToNodeUriShouldNotChangeResultIfUriIsInBl assertSame( $result, - $this->linkingServiceAspect->appendTrailingSlashToNodeUri($this->joinPointMock) + $this->linkingServiceAspect->handleTrailingSlashForNodeUri($this->joinPointMock) ); } - public function testAppendTrailingSlashToNodeUriShouldChangeResult(): void + public function testAppendTrailingSlashToNodeUriShouldAppendTrailingSlash(): void { $result = 'foo/'; $this->adviceChainMock->expects($this->once())->method('proceed')->willReturn('foo'); $this->configurationHelperMock->expects($this->once())->method('isTrailingSlashEnabled')->willReturn(true); + $this->configurationHelperMock->expects($this->once())->method('getTrailingSlashMode')->willReturn( + TrailingSlashModeEnum::ADD + ); $this->blocklistHelperMock->expects($this->once())->method('isUriInBlocklist')->willReturn(false); $this->trailingSlashHelperMock->expects($this->once())->method('appendTrailingSlash')->willReturn( new Uri($result) @@ -106,7 +110,27 @@ public function testAppendTrailingSlashToNodeUriShouldChangeResult(): void assertSame( $result, - $this->linkingServiceAspect->appendTrailingSlashToNodeUri($this->joinPointMock) + $this->linkingServiceAspect->handleTrailingSlashForNodeUri($this->joinPointMock) + ); + } + + public function testAppendTrailingSlashToNodeUriShouldRemoveTrailingSlash(): void + { + $result = 'foo/'; + $this->adviceChainMock->expects($this->once())->method('proceed')->willReturn('foo'); + + $this->configurationHelperMock->expects($this->once())->method('isTrailingSlashEnabled')->willReturn(true); + $this->configurationHelperMock->expects($this->once())->method('getTrailingSlashMode')->willReturn( + TrailingSlashModeEnum::REMOVE + ); + $this->blocklistHelperMock->expects($this->once())->method('isUriInBlocklist')->willReturn(false); + $this->trailingSlashHelperMock->expects($this->once())->method('removeTrailingSlash')->willReturn( + new Uri($result) + ); + + assertSame( + $result, + $this->linkingServiceAspect->handleTrailingSlashForNodeUri($this->joinPointMock) ); } } diff --git a/Tests/Unit/RoutingMiddlewareTest.php b/Tests/Unit/RoutingMiddlewareTest.php index 8281926..b9d23ea 100644 --- a/Tests/Unit/RoutingMiddlewareTest.php +++ b/Tests/Unit/RoutingMiddlewareTest.php @@ -4,6 +4,7 @@ namespace Flowpack\SeoRouting\Tests\Unit; +use Flowpack\SeoRouting\Enum\TrailingSlashModeEnum; use Flowpack\SeoRouting\Helper\BlocklistHelper; use Flowpack\SeoRouting\Helper\ConfigurationHelper; use Flowpack\SeoRouting\Helper\LowerCaseHelper; @@ -78,7 +79,8 @@ public function testProcessShouldHandleUrlsCorrectly( bool $isTrailingSlashEnabledResult, bool $isToLowerCaseEnabledResult, bool $isUriInBlocklistResult, - int $statusCode + int $statusCode, + TrailingSlashModeEnum $trailingSlashMode, ): void { $originalUri = new Uri($originalUrl); $expectedUri = new Uri($expectedUrl); @@ -87,8 +89,10 @@ public function testProcessShouldHandleUrlsCorrectly( $this->configurationHelperMock->method('isToLowerCaseEnabled')->willReturn($isToLowerCaseEnabledResult); $this->blocklistHelperMock->method('isUriInBlocklist')->willReturn($isUriInBlocklistResult); $this->trailingSlashHelperMock->method('appendTrailingSlash')->willReturn($expectedUri); + $this->trailingSlashHelperMock->method('removeTrailingSlash')->willReturn($expectedUri); $this->lowerCaseHelperMock->method('convertPathToLowerCase')->willReturn($expectedUri); $this->configurationHelperMock->method('getStatusCode')->willReturn($statusCode); + $this->configurationHelperMock->method('getTrailingSlashMode')->willReturn($trailingSlashMode); $this->requestMock->expects($this->once())->method('getUri')->willReturn($originalUri); @@ -127,6 +131,7 @@ public static function urlsDataProvider(): array 'isToLowerCaseEnabledResult' => false, 'isUriInBlocklistResult' => false, 'statusCode' => 301, + 'trailingSlashMode' => TrailingSlashModeEnum::ADD, ], [ 'originalUrl' => 'https://local.dev', @@ -135,6 +140,7 @@ public static function urlsDataProvider(): array 'isToLowerCaseEnabledResult' => false, 'isUriInBlocklistResult' => false, 'statusCode' => 302, + 'trailingSlashMode' => TrailingSlashModeEnum::ADD, ], [ 'originalUrl' => 'https://local.dev/test/test2', @@ -143,6 +149,7 @@ public static function urlsDataProvider(): array 'isToLowerCaseEnabledResult' => true, 'isUriInBlocklistResult' => true, 'statusCode' => 301, + 'trailingSlashMode' => TrailingSlashModeEnum::ADD, ], [ 'originalUrl' => 'https://local.dev/test/test2', @@ -151,6 +158,16 @@ public static function urlsDataProvider(): array 'isToLowerCaseEnabledResult' => true, 'isUriInBlocklistResult' => false, 'statusCode' => 301, + 'trailingSlashMode' => TrailingSlashModeEnum::ADD, + ], + [ + 'originalUrl' => 'https://local.dev/test/test2/', + 'expectedUrl' => 'https://local.dev/test/test2', + 'isTrailingSlashEnabledResult' => true, + 'isToLowerCaseEnabledResult' => false, + 'isUriInBlocklistResult' => false, + 'statusCode' => 301, + 'trailingSlashMode' => TrailingSlashModeEnum::REMOVE, ], ]; }