diff --git a/.circleci/config.yml b/.circleci/config.yml index b066d16..4ef9114 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -40,6 +40,7 @@ jobs: shopt -u dotglob cp seo-routing/composer.json.ci composer.json cp seo-routing/phpcs.xml.dist phpcs.xml.dist + cp seo-routing/unitTests.xml unitTests.xml composer update - save_cache: *save_composer_cache - persist_to_workspace: *persist_to_workspace @@ -52,6 +53,14 @@ jobs: - attach_workspace: *attach_workspace - run: bin/phpcs seo-routing/Classes + unit-tests: + working_directory: *workspace_root + docker: + - image: *ci-build-image + steps: + - attach_workspace: *attach_workspace + - run: bin/phpunit -c unitTests.xml + workflows: version: 2 build_and_test: @@ -60,3 +69,6 @@ workflows: - lint: requires: - checkout + - unit-tests: + requires: + - checkout diff --git a/Classes/BlacklistTrait.php b/Classes/BlacklistTrait.php index 06cda37..64912bf 100644 --- a/Classes/BlacklistTrait.php +++ b/Classes/BlacklistTrait.php @@ -28,6 +28,10 @@ trait BlacklistTrait protected function matchesBlacklist(UriInterface $uri): bool { + if (! is_array($this->blacklist)) { + return false; + } + $path = $uri->getPath(); foreach ($this->blacklist as $rawPattern => $active) { $pattern = '/' . str_replace('/', '\/', $rawPattern) . '/'; diff --git a/Classes/RoutingComponent.php b/Classes/RoutingComponent.php index 82cfee8..687fe9f 100644 --- a/Classes/RoutingComponent.php +++ b/Classes/RoutingComponent.php @@ -18,6 +18,7 @@ use Neos\Flow\Http\Component\ComponentChain; use Neos\Flow\Http\Component\ComponentContext; use Neos\Flow\Mvc\Routing\RouterInterface; +use Psr\Http\Message\UriInterface; class RoutingComponent extends \Neos\Flow\Mvc\Routing\RoutingComponent { @@ -41,26 +42,80 @@ class RoutingComponent extends \Neos\Flow\Mvc\Routing\RoutingComponent protected $configuration; /** - * Redirect automatically to the trailing slash url + * @param mixed[] $options + */ + public function __construct(array $options = []) + { + parent::__construct($options); + + if (! is_array($this->configuration)) { + $this->configuration = []; + } + } + + /** + * Redirect automatically to the trailing slash url or lowered url if activated */ public function handle(ComponentContext $componentContext): void { + $trailingSlashIsEnabled = isset($this->configuration['enable']['trailingSlash']) ? $this->configuration['enable']['trailingSlash'] === true : false; + $toLowerCaseIsEnabled = isset($this->configuration['enable']['toLowerCase']) ? $this->configuration['enable']['toLowerCase'] === true : false; + $uri = $componentContext->getHttpRequest()->getUri(); - $path = $uri->getPath(); - - if ($this->configuration['enable'] === true && $path[-1] !== '/') { - if ($this->matchesBlacklist($uri) === false && isset(pathinfo((string) $uri)['extension']) === false) { - $newUri = $uri->withPath($path . '/'); - $newResponse = $componentContext->getHttpResponse() - ->withStatus($this->configuration['statusCode']) - ->withHeader('Location', (string) $newUri); - - $componentContext->replaceHttpResponse($newResponse); - $componentContext->setParameter(ComponentChain::class, 'cancel', true); - return; - } + $oldPath = $uri->getPath(); + + if ($trailingSlashIsEnabled) { + $uri = $this->handleTrailingSlash($uri); } + if ($toLowerCaseIsEnabled) { + $uri = $this->handleToLowerCase($uri); + } + + $this->redirectIfNecessary($componentContext, $uri, $oldPath); + parent::handle($componentContext); } + + public function handleTrailingSlash(UriInterface $uri): UriInterface + { + if (strlen($uri->getPath()) === 0 || $uri->getPath()[-1] === '/') { + return $uri; + } + + if ($this->matchesBlacklist($uri) === false && ! array_key_exists('extension', pathinfo($uri->getPath()))) { + $uri->setPath($uri->getPath() . '/'); + } + + return $uri; + } + + public function handleToLowerCase(UriInterface $uri): UriInterface + { + $loweredPath = strtolower($uri->getPath()); + + if ($uri->getPath() !== $loweredPath) { + $uri->setPath($loweredPath); + } + + return $uri; + } + + protected function redirectIfNecessary(ComponentContext $componentContext, UriInterface $uri, string $oldPath): void + { + if ($uri->getPath() === $oldPath) { + return; + } + + //set default redirect statusCode if configuration is not set + $statusCode = array_key_exists('statusCode', $this->configuration) ? $this->configuration['statusCode'] : 301; + + $response = $componentContext->getHttpResponse(); + $response->withStatus((int) $statusCode); + $response->withHeader('Location', (string) $uri); + + $componentContext->setParameter(ComponentChain::class, 'cancel', true); + + return; + } } diff --git a/Configuration/NodeTypes.Document.yaml b/Configuration/NodeTypes.Document.yaml new file mode 100644 index 0000000..0c24122 --- /dev/null +++ b/Configuration/NodeTypes.Document.yaml @@ -0,0 +1,6 @@ +'Neos.Neos:Document': + properties: + uriPathSegment: + validation: + 'Neos.Neos/Validation/RegularExpressionValidator': + regularExpression: '/^[a-z0-9\-]+$' #override original regex (removes insensitive) diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 7a33b71..9f80d77 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -2,8 +2,10 @@ t3n: SEO: Routing: redirect: - enable: true - statusCode: 303 + enable: + trailingSlash: true + toLowerCase: false + statusCode: 301 blacklist: '/neos/.*': true diff --git a/README.md b/README.md index 001fc3e..08ebcf0 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,52 @@ [![CircleCI](https://circleci.com/gh/t3n/seo-routing.svg?style=svg)](https://circleci.com/gh/t3n/seo-routing) [![Latest Stable Version](https://poser.pugx.org/t3n/seo-routing/v/stable)](https://packagist.org/packages/t3n/seo-routing) [![Total Downloads](https://poser.pugx.org/t3n/seo-routing/downloads)](https://packagist.org/packages/t3n/seo-routing) [![License](https://poser.pugx.org/t3n/seo-routing/license)](https://packagist.org/packages/t3n/seo-routing) # t3n.SEO.Routing -Package to ensure that all links end with a trailing slash, e.g. `example.com/test/` instead of `example.com/test. + +This package has 2 main features: +- **trailingSlash**: ensure that all links ends with a trailing slash (e.g. `example.com/test/ instead of `example.com/test) +- **toLowerCase**: ensure that camelCase links gets redirected to lowercase (e.g. `exmaple.com/lowercase` instead of `exmaple.com/lowerCase` ) + +You can de- and activate both of them. + +Another small feature is to restrict all _new_ neos pages to have a lowercased `uriPathSegment`. This is done by extending the `NodeTypes.Document.yaml`. + +## Installation + +Just require it via composer:` + +```composer require t3n/seo-routing``` ## Configuration -By default, all `/neos/` URLs are ignored. You can extend the blacklist array with regex as you like. +### Standard Configuration -```yaml +In the standard configuration we have activated the trailingSlash (to redirect all uris without a / at the and to an uri with / at the end) and do all redirects with a 301 http status. + +*Note: The lowercase redirect is deactivated by default, cause you have to make sure, that there is no `uriPathSegment` with camelCase or upperspace letters - this would lead to redirects in the neverland.* + +``` t3n: SEO: Routing: redirect: - enable: true - statusCode: 303 + enable: + trailingSlash: true + toLowerCase: false + statusCode: 301 + blacklist: + '/neos/.*': true +``` + +### Blacklist for redirects + +By default, all `/neos/` URLs are ignored for redirects. You can extend the blacklist array with regex as you like: + +```yaml +t3n: + SEO: + Routing: + #redirect: + #... blacklist: '/neos/.*': true ``` \ No newline at end of file diff --git a/Tests/Unit/RoutingComponentTest.php b/Tests/Unit/RoutingComponentTest.php new file mode 100644 index 0000000..a4a19a0 --- /dev/null +++ b/Tests/Unit/RoutingComponentTest.php @@ -0,0 +1,171 @@ +handleTrailingSlash($uri); + + $this->assertEquals($uri, $newUri); + } + + /** + * @test + */ + public function uriWithOutSlashGetsModifiedForTrailingSlash(): void + { + $routingComponent = new RoutingComponent(); + + $uri = new Uri('http://dev.local/testpath'); + $newUri = $routingComponent->handleTrailingSlash($uri); + + $this->assertStringEndsWith('/', (string) $newUri); + } + + /** + * @test + */ + public function uriWithLoweredPathGetsNotModified(): void + { + $routingComponent = new RoutingComponent(); + + $uri = new Uri('http://dev.local/testpath/'); + $newUri = $routingComponent->handleToLowerCase($uri); + + $this->assertEquals($uri, $newUri); + } + + /** + * @test + */ + public function uriWithCamelCasePathGetsModifiedToLowereCase(): void + { + $routingComponent = new RoutingComponent(); + + $camelCasePath = '/testPath/'; + $uri = new Uri('http://dev.local' . $camelCasePath); + $newUri = $routingComponent->handleToLowerCase($uri); + + $this->assertNotEquals($camelCasePath, $newUri->getPath()); + $this->assertEquals(strtolower($camelCasePath), $newUri->getPath()); + } + + /** + * @test + */ + public function uriWithSpecialCharsDoesNotThrowAnException(): void + { + $routingComponent = new RoutingComponent(); + + $uri = new Uri('http://dev.local/äß&/'); + $newUri = $routingComponent->handleToLowerCase($uri); + $newUri = $routingComponent->handleTrailingSlash($newUri); + + $this->assertEquals($uri, $newUri); + } + + /** + * @test + * @dataProvider invalidUrisWithConfig + */ + public function ifPathHasChangesRedirect(string $invalidUrl, string $validUrl, bool $trailingSlash, bool $toLowerCase): void + { + $configuration = [ + 'enable' => [ + 'trailingSlash' => $trailingSlash, + 'toLowerCase' => $toLowerCase + ], + ]; + + + $httpRequest = $this->getMockBuilder(Request::class)->disableOriginalConstructor()->setMethods(['getUri', 'withStatus'])->getMock(); + $httpRequest->method('getUri')->willReturn(new Uri($invalidUrl)); + + $httpResponse = $this->getMockBuilder(Response::class)->disableOriginalConstructor()->setMethods(['withStatus', 'withHeader'])->getMock(); + $httpResponse->expects($this->once())->method('withStatus')->with(301); + $httpResponse->expects($this->once())->method('withHeader')->with('Location', $validUrl); + + /** @var ComponentContext $componentContext */ + $componentContext = $this->getMockBuilder(ComponentContext::class)->disableOriginalConstructor()->setMethods(['getHttpRequest', 'getHttpResponse'])->getMock(); + $componentContext->method('getHttpRequest')->willReturn($httpRequest); + $componentContext->method('getHttpResponse')->willReturn($httpResponse); + + $routerMock = $this->getMockBuilder(Router::class)->disableOriginalConstructor()->setMethods(['route'])->getMock(); + $routerMock->method('route')->willReturn([]); + + $routingComponent = new RoutingComponent(); + + $this->inject($routingComponent, 'router', $routerMock); + $this->inject($routingComponent, 'configuration', $configuration); + + $routingComponent->handle($componentContext); + } + + /** + * @test + */ + public function ifPathHasNoChangesDoNotRedirect(): void + { + $configuration = [ + 'enable' => [ + 'trailingSlash' => true, + 'toLowerCase' => true + ], + ]; + + $validPath = 'http://dev.local/validpath/'; + + $httpRequest = $this->getMockBuilder(Request::class)->disableOriginalConstructor()->setMethods(['getUri', 'withStatus'])->getMock(); + $httpRequest->method('getUri')->willReturn(new Uri($validPath)); + + $httpResponse = $this->getMockBuilder(Response::class)->disableOriginalConstructor()->setMethods(['withStatus', 'withHeader'])->getMock(); + $httpResponse->expects($this->never())->method('withStatus'); + $httpResponse->expects($this->never())->method('withHeader'); + + /** @var ComponentContext $componentContext */ + $componentContext = $this->getMockBuilder(ComponentContext::class)->disableOriginalConstructor()->setMethods(['getHttpRequest', 'getHttpResponse'])->getMock(); + $componentContext->method('getHttpRequest')->willReturn($httpRequest); + $componentContext->method('getHttpResponse')->willReturn($httpResponse); + + $routerMock = $this->getMockBuilder(Router::class)->disableOriginalConstructor()->setMethods(['route'])->getMock(); + $routerMock->method('route')->willReturn([]); + + $routingComponent = new RoutingComponent(); + + $this->inject($routingComponent, 'router', $routerMock); + $this->inject($routingComponent, 'configuration', $configuration); + + $routingComponent->handle($componentContext); + } +} diff --git a/composer.json.ci b/composer.json.ci index 56ccf35..24cfb73 100644 --- a/composer.json.ci +++ b/composer.json.ci @@ -1,6 +1,18 @@ { "name": "t3n/test-setup", "description": "Test setup for flow packages", + "repositories": [ + { + "type": "git", + "url": "git@github.com:t3n/seo-routing.git" + }, + { + "type": "path", + "url": "./seo-routing" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, "config": { "vendor-dir": "Packages/Libraries", "bin-dir": "bin" @@ -8,24 +20,22 @@ "require": { "neos/flow": "~5.2.0", "neos/buildessentials": "~5.2.0", - "t3n/seo-routing": "@dev", - "t3n/coding-standard": "~1.0.0" + "t3n/seo-routing": "@dev" }, "require-dev": { - "squizlabs/php_codesniffer": "3.3.2", + "t3n/coding-standard": "~1.0.0", "phpunit/phpunit": "~7.1", "mikey179/vfsstream": "~1.6" }, - "repositories": { - "srcPackage": { - "type": "path", - "url": "./seo-routing" - } - }, "scripts": { "post-update-cmd": "Neos\\Flow\\Composer\\InstallerScripts::postUpdateAndInstall", "post-install-cmd": "Neos\\Flow\\Composer\\InstallerScripts::postUpdateAndInstall", "post-package-update": "Neos\\Flow\\Composer\\InstallerScripts::postPackageUpdateAndInstall", "post-package-install": "Neos\\Flow\\Composer\\InstallerScripts::postPackageUpdateAndInstall" + }, + "autoload": { + "psr-4": { + "t3n\\SEO\\Routing\\": "seo-routing/Classes/" + } } } diff --git a/unitTests.xml b/unitTests.xml new file mode 100644 index 0000000..963af42 --- /dev/null +++ b/unitTests.xml @@ -0,0 +1,15 @@ + + + + + seo-routing/Tests/Unit + + +