Skip to content

Commit e389646

Browse files
authored
Merge pull request #3 from Flowpack/1-links-in-texts-are-rendered-without-trailing-slash
TASK: render links to nodes with trailing slash
2 parents 8e8a0f4 + f554a75 commit e389646

14 files changed

+623
-187
lines changed

Classes/Helper/BlocklistHelper.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flowpack\SeoRouting\Helper;
6+
7+
use Neos\Flow\Annotations as Flow;
8+
use Psr\Http\Message\UriInterface;
9+
10+
#[Flow\Scope('singleton')]
11+
class BlocklistHelper
12+
{
13+
#[Flow\Inject]
14+
protected ConfigurationHelper $configurationHelper;
15+
16+
public function isUriInBlocklist(UriInterface $uri): bool
17+
{
18+
$path = $uri->getPath();
19+
foreach ($this->configurationHelper->getBlocklist() as $rawPattern => $active) {
20+
$pattern = '/' . str_replace('/', '\/', $rawPattern) . '/';
21+
22+
if (! $active) {
23+
continue;
24+
}
25+
26+
if (preg_match($pattern, $path) === 1) {
27+
return true;
28+
}
29+
}
30+
31+
return false;
32+
}
33+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flowpack\SeoRouting\Helper;
6+
7+
use Neos\Flow\Annotations as Flow;
8+
9+
#[Flow\Scope('singleton')]
10+
class ConfigurationHelper
11+
{
12+
/** @var array{enable: array{trailingSlash: bool, toLowerCase: bool}, statusCode?: int} */
13+
#[Flow\InjectConfiguration(path: 'redirect')]
14+
protected array $configuration;
15+
16+
/** @var array{string: bool} */
17+
#[Flow\InjectConfiguration(path: 'blocklist')]
18+
protected array $blocklist;
19+
20+
public function isTrailingSlashEnabled(): bool
21+
{
22+
return $this->configuration['enable']['trailingSlash'] ?? false;
23+
}
24+
25+
public function isToLowerCaseEnabled(): bool
26+
{
27+
return $this->configuration['enable']['toLowerCase'] ?? false;
28+
}
29+
30+
public function getStatusCode(): int
31+
{
32+
return $this->configuration['statusCode'] ?? 301;
33+
}
34+
35+
/**
36+
* @return array{string: bool}
37+
*/
38+
public function getBlocklist(): array
39+
{
40+
return $this->blocklist;
41+
}
42+
}

Classes/Helper/LowerCaseHelper.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flowpack\SeoRouting\Helper;
6+
7+
use Neos\Flow\Annotations\Scope;
8+
use Psr\Http\Message\UriInterface;
9+
10+
#[Scope('singleton')]
11+
class LowerCaseHelper
12+
{
13+
public function convertPathToLowerCase(UriInterface $uri): UriInterface
14+
{
15+
$loweredPath = strtolower($uri->getPath());
16+
17+
if ($uri->getPath() === $loweredPath) {
18+
return $uri;
19+
}
20+
21+
// bypass links to files
22+
if (array_key_exists('extension', pathinfo($uri->getPath()))) {
23+
return $uri;
24+
}
25+
26+
return $uri->withPath($loweredPath);
27+
}
28+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flowpack\SeoRouting\Helper;
6+
7+
use Neos\Flow\Annotations\Scope;
8+
use Psr\Http\Message\UriInterface;
9+
10+
#[Scope('singleton')]
11+
class TrailingSlashHelper
12+
{
13+
public function appendTrailingSlash(UriInterface $uri): UriInterface
14+
{
15+
// bypass links without path
16+
if (strlen($uri->getPath()) === 0) {
17+
return $uri;
18+
}
19+
20+
// bypass links to files
21+
if (array_key_exists('extension', pathinfo($uri->getPath()))) {
22+
return $uri;
23+
}
24+
25+
// bypass mailto and tel links
26+
if (in_array($uri->getScheme(), ['mailto', 'tel'], true)) {
27+
return $uri;
28+
}
29+
30+
return $uri->withPath(rtrim($uri->getPath(), '/') . '/');
31+
}
32+
}

Classes/LinkingServiceAspect.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flowpack\SeoRouting;
6+
7+
use Flowpack\SeoRouting\Helper\BlocklistHelper;
8+
use Flowpack\SeoRouting\Helper\ConfigurationHelper;
9+
use Flowpack\SeoRouting\Helper\TrailingSlashHelper;
10+
use GuzzleHttp\Psr7\Exception\MalformedUriException;
11+
use GuzzleHttp\Psr7\Uri;
12+
use Neos\Flow\Annotations as Flow;
13+
use Neos\Flow\Aop\JoinPointInterface;
14+
use Neos\Neos\Service\LinkingService;
15+
16+
#[Flow\Aspect]
17+
class LinkingServiceAspect
18+
{
19+
#[Flow\Inject]
20+
protected TrailingSlashHelper $trailingSlashHelper;
21+
22+
#[Flow\Inject]
23+
protected ConfigurationHelper $configurationHelper;
24+
25+
#[Flow\Inject]
26+
protected BlocklistHelper $blocklistHelper;
27+
28+
/**
29+
* This ensures that all internal links are rendered with a trailing slash.
30+
*/
31+
#[Flow\Around('method(' . LinkingService::class . '->createNodeUri())')]
32+
public function appendTrailingSlashToNodeUri(JoinPointInterface $joinPoint): string
33+
{
34+
/** @var string $result */
35+
$result = $joinPoint->getAdviceChain()->proceed($joinPoint);
36+
37+
if (! $this->configurationHelper->isTrailingSlashEnabled()) {
38+
return $result;
39+
}
40+
41+
try {
42+
$uri = new Uri($result);
43+
} catch (MalformedUriException) {
44+
return $result;
45+
}
46+
47+
if ($this->blocklistHelper->isUriInBlocklist($uri)) {
48+
return $result;
49+
}
50+
51+
return (string)$this->trailingSlashHelper->appendTrailingSlash($uri);
52+
}
53+
}

Classes/RoutingMiddleware.php

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,108 +4,65 @@
44

55
namespace Flowpack\SeoRouting;
66

7+
use Flowpack\SeoRouting\Helper\BlocklistHelper;
8+
use Flowpack\SeoRouting\Helper\ConfigurationHelper;
9+
use Flowpack\SeoRouting\Helper\LowerCaseHelper;
10+
use Flowpack\SeoRouting\Helper\TrailingSlashHelper;
711
use Neos\Flow\Annotations as Flow;
812
use Psr\Http\Message\ResponseFactoryInterface;
913
use Psr\Http\Message\ResponseInterface;
1014
use Psr\Http\Message\ServerRequestInterface;
11-
use Psr\Http\Message\UriFactoryInterface;
12-
use Psr\Http\Message\UriInterface;
1315
use Psr\Http\Server\MiddlewareInterface;
1416
use Psr\Http\Server\RequestHandlerInterface;
1517

16-
final class RoutingMiddleware implements MiddlewareInterface
18+
class RoutingMiddleware implements MiddlewareInterface
1719
{
1820
#[Flow\Inject]
1921
protected ResponseFactoryInterface $responseFactory;
2022

2123
#[Flow\Inject]
22-
protected UriFactoryInterface $uriFactory;
24+
protected ConfigurationHelper $configurationHelper;
2325

24-
/** @var array{enable: array{trailingSlash: bool, toLowerCase: bool}, statusCode?: int} */
25-
#[Flow\InjectConfiguration(path: 'redirect')]
26-
protected array $configuration;
26+
#[Flow\Inject]
27+
protected BlocklistHelper $blocklistHelper;
28+
29+
#[Flow\Inject]
30+
protected TrailingSlashHelper $trailingSlashHelper;
2731

28-
/** @var array{string: bool} */
29-
#[Flow\InjectConfiguration(path: 'blocklist')]
30-
protected array $blocklist;
32+
#[Flow\Inject]
33+
protected LowerCaseHelper $lowerCaseHelper;
3134

3235
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
3336
{
34-
$isTrailingSlashEnabled = $this->configuration['enable']['trailingSlash'] ?? false;
35-
$isToLowerCaseEnabled = $this->configuration['enable']['toLowerCase'] ?? false;
37+
$isTrailingSlashEnabled = $this->configurationHelper->isTrailingSlashEnabled();
38+
$isToLowerCaseEnabled = $this->configurationHelper->isToLowerCaseEnabled();
3639

3740
$uri = $request->getUri();
3841

3942
if (! $isTrailingSlashEnabled && ! $isToLowerCaseEnabled) {
4043
return $handler->handle($request);
4144
}
4245

43-
if ($this->matchesBlocklist($uri)) {
46+
if ($this->blocklistHelper->isUriInBlocklist($uri)) {
4447
return $handler->handle($request);
4548
}
4649

4750
$oldPath = $uri->getPath();
4851

4952
if ($isTrailingSlashEnabled) {
50-
$uri = $this->handleTrailingSlash($uri);
53+
$uri = $this->trailingSlashHelper->appendTrailingSlash($uri);
5154
}
5255

5356
if ($isToLowerCaseEnabled) {
54-
$uri = $this->handleToLowerCase($uri);
57+
$uri = $this->lowerCaseHelper->convertPathToLowerCase($uri);
5558
}
5659

5760
if ($uri->getPath() === $oldPath) {
5861
return $handler->handle($request);
5962
}
6063

61-
$response = $this->responseFactory->createResponse($this->configuration['statusCode'] ?? 301);
64+
$response = $this->responseFactory->createResponse($this->configurationHelper->getStatusCode());
6265

6366
return $response->withAddedHeader('Location', (string)$uri);
6467
}
65-
66-
private function handleTrailingSlash(UriInterface $uri): UriInterface
67-
{
68-
if (strlen($uri->getPath()) === 0) {
69-
return $uri;
70-
}
71-
72-
if (array_key_exists('extension', pathinfo($uri->getPath()))) {
73-
return $uri;
74-
}
75-
76-
return $uri->withPath(rtrim($uri->getPath(), '/') . '/')
77-
->withQuery($uri->getQuery())
78-
->withFragment($uri->getFragment());
79-
}
80-
81-
private function handleToLowerCase(UriInterface $uri): UriInterface
82-
{
83-
$loweredPath = strtolower($uri->getPath());
84-
85-
if ($uri->getPath() === $loweredPath) {
86-
return $uri;
87-
}
88-
89-
$newUri = str_replace($uri->getPath(), $loweredPath, (string)$uri);
90-
91-
return $this->uriFactory->createUri($newUri);
92-
}
93-
94-
private function matchesBlocklist(UriInterface $uri): bool
95-
{
96-
$path = $uri->getPath();
97-
foreach ($this->blocklist as $rawPattern => $active) {
98-
$pattern = '/' . str_replace('/', '\/', $rawPattern) . '/';
99-
100-
if (! $active) {
101-
continue;
102-
}
103-
104-
if (preg_match($pattern, $path) === 1) {
105-
return true;
106-
}
107-
}
108-
109-
return false;
110-
}
11168
}

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ This package allows you to enforce a trailing slash and/or lower case urls in Fl
2626

2727
This package has 2 main features:
2828

29-
- **trailingSlash**: ensure that all links ends with a trailing slash (e.g. `example.com/test/` instead of
30-
`example.com/test`)
29+
- **trailingSlash**: ensure that all rendered internal links in the frontend end with a trailing slash (e.g. `example.
30+
com/test/` instead of `example.com/test`) and all called URLs without trailing slash will be redirected to the same
31+
page with a trailing slash
3132
- **toLowerCase**: ensure that camelCase links gets redirected to lowercase (e.g. `example.com/lowercase` instead of
3233
`example.com/lowerCase`)
3334

3435
You can de- and activate both of them.
3536

36-
Another small feature is to restrict all _new_ neos pages to have a lowercased `uriPathSegment`. This is done by
37+
Another small feature is to restrict all _new_ Neos pages to have a lowercased `uriPathSegment`. This is done by
3738
extending the `NodeTypes.Document.yaml`.
3839

3940
## Installation
@@ -50,8 +51,8 @@ In the standard configuration we have activated the trailingSlash (to redirect a
5051
with / at the end) and do all redirects with a 301 http status.
5152

5253
*Note: The lowercase redirect is deactivated by default, because you have to make sure, that there is
53-
no `uriPathSegment`
54-
with camelCase or upperspace letters - this would lead to redirects in the neverland.*
54+
no Neos page with an `uriPathSegment` with camelCase or upperspace letters - this would lead to redirects in the
55+
neverland.*
5556

5657
```
5758
Flowpack:
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flowpack\SeoRouting\Tests\Unit\Helper;
6+
7+
use Flowpack\SeoRouting\Helper\BlocklistHelper;
8+
use Flowpack\SeoRouting\Helper\ConfigurationHelper;
9+
use GuzzleHttp\Psr7\Uri;
10+
use PHPUnit\Framework\Attributes\CoversClass;
11+
use PHPUnit\Framework\Attributes\DataProvider;
12+
use PHPUnit\Framework\TestCase;
13+
use ReflectionClass;
14+
15+
#[CoversClass(BlocklistHelper::class)]
16+
class BlocklistHelperTest extends TestCase
17+
{
18+
#[DataProvider('urlDataProvider')]
19+
public function testIsUriInBlocklist(string $input, bool $expected): void
20+
{
21+
$blocklistHelper = new BlocklistHelper();
22+
$configurationHelperMock = $this->createMock(ConfigurationHelper::class);
23+
24+
$configurationHelperMock->expects($this->once())->method('getBlocklist')->willReturn(
25+
['/neos.*' => false, '.*test.*' => true]
26+
);
27+
28+
$reflection = new ReflectionClass($blocklistHelper);
29+
$property = $reflection->getProperty('configurationHelper');
30+
$property->setValue($blocklistHelper, $configurationHelperMock);
31+
32+
$uri = new Uri($input);
33+
34+
self::assertSame($expected, $blocklistHelper->isUriInBlocklist($uri));
35+
}
36+
37+
/**
38+
* @return array{array{string, bool}}
39+
*/
40+
public static function urlDataProvider(): array
41+
{
42+
return [
43+
['https://test.de/neos', false],
44+
['https://test.de/neos/test', true],
45+
['https://neos.de/foo', false],
46+
];
47+
}
48+
}

0 commit comments

Comments
 (0)