Skip to content

Commit

Permalink
Add tolowercase feature
Browse files Browse the repository at this point in the history
  • Loading branch information
Alex Kleine-Börger authored and johannessteu committed Jul 18, 2019
1 parent f6c17f1 commit 2568a1c
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 30 deletions.
12 changes: 12 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -60,3 +69,6 @@ workflows:
- lint:
requires:
- checkout
- unit-tests:
requires:
- checkout
4 changes: 4 additions & 0 deletions Classes/BlacklistTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) . '/';
Expand Down
83 changes: 69 additions & 14 deletions Classes/RoutingComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
}
6 changes: 6 additions & 0 deletions Configuration/NodeTypes.Document.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'Neos.Neos:Document':
properties:
uriPathSegment:
validation:
'Neos.Neos/Validation/RegularExpressionValidator':
regularExpression: '/^[a-z0-9\-]+$' #override original regex (removes insensitive)
6 changes: 4 additions & 2 deletions Configuration/Settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ t3n:
SEO:
Routing:
redirect:
enable: true
statusCode: 303
enable:
trailingSlash: true
toLowerCase: false
statusCode: 301
blacklist:
'/neos/.*': true

Expand Down
43 changes: 38 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
171 changes: 171 additions & 0 deletions Tests/Unit/RoutingComponentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

declare(strict_types=1);

namespace t3n\SEO\Routing\Tests\Unit;

use Neos\Flow\Http\Component\ComponentContext;
use Neos\Flow\Http\Request;
use Neos\Flow\Http\Response;
use Neos\Flow\Http\Uri;
use Neos\Flow\Mvc\Routing\Router;
use Neos\Flow\Tests\UnitTestCase;
use t3n\SEO\Routing\RoutingComponent;

class RoutingComponentTest extends UnitTestCase
{
/**
* @return mixed[]
*/
public function invalidUrisWithConfig(): array
{
// invalidUrl, validUrl, trailingSlash, toLowerCase
return [
['http://dev.local/invalid', 'http://dev.local/invalid/', true, false],
['http://dev.local/invalId', 'http://dev.local/invalid/', true, true],
['http://dev.local/invalId/', 'http://dev.local/invalid/', false, true]
];
}

/**
* @test
*/
public function uriWithSlashGetsNotModifiedForTrailingSlash(): void
{
$routingComponent = new RoutingComponent();

$uri = new Uri('http://dev.local/testpath/');
$newUri = $routingComponent->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);
}
}
Loading

0 comments on commit 2568a1c

Please sign in to comment.