Skip to content

Commit

Permalink
Replace regex tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
timkelty committed Jan 8, 2025
1 parent 4f02d02 commit 5075dc1
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 23 deletions.
28 changes: 28 additions & 0 deletions src/helpers/StringHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class StringHelper extends \yii\helpers\StringHelper
* @since 3.0.37
*/
public const UUID_PATTERN = '[A-Za-z0-9]{8}-[A-Za-z0-9]{4}-4[A-Za-z0-9]{3}-[89abAB][A-Za-z0-9]{3}-[A-Za-z0-9]{12}';
public const HANDLE_PATTERN = '(?:[a-zA-Z][a-zA-Z0-9_]*)';

/**
* @var array Character mappings
Expand Down Expand Up @@ -1359,6 +1360,33 @@ public static function slugify(string $str, string $replacement = '-', ?string $
return (string)BaseStringy::create($str)->slugify($replacement, $language);
}

/**
* @return string The regex pattern for a slug.
*/
public static function slugPattern(): string
{
$slugChars = ['.', '_', '-'];
$slugWordSeparator = Craft::$app->getConfig()->getGeneral()->slugWordSeparator;

if ($slugWordSeparator !== '/' && !in_array($slugWordSeparator, $slugChars, true)) {
$slugChars[] = $slugWordSeparator;
}

return '(?:[\p{L}\p{N}\p{M}' . preg_quote(implode($slugChars), '/') . ']+)';
}

/**
* @return array An array of regex tokens as keys and patterns as values.
*/
public static function regexTokens(): array
{
return [
'{handle}' => self::HANDLE_PATTERN,
'{slug}' => self::slugPattern(),
'{uid}' => self::UUID_PATTERN,
];
}

/**
* Splits a string into chunks on a given delimiter.
*
Expand Down
32 changes: 25 additions & 7 deletions src/web/RedirectRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace craft\web;

use Craft;
use craft\helpers\StringHelper;
use Illuminate\Support\Collection;
use League\Uri\Http;

Expand All @@ -18,6 +19,7 @@ class RedirectRule extends \yii\base\BaseObject
public int $statusCode = 302;
public bool $caseSensitive = false;
private \Closure $_match;
private array $regexTokens = [];

public function __invoke(?callable $callback = null): void
{
Expand Down Expand Up @@ -80,13 +82,29 @@ private function replaceParams(string $value, array $params): string

private function toRegexPattern(string $from): string
{
$regexFlags = $this->caseSensitive ? 'u' : 'iu';
$pattern = "`^{$from}$`{$regexFlags}";

return preg_replace_callback('/<([\w._-]+):?([^>]+)?>/', function($match) {
// Tokenize the patterns first, so we only escape regex chars outside of patterns
$tokenizedPattern = preg_replace_callback('/<([\w._-]+):?([^>]+)?>/', function($match) {
$name = $match[1];
$pattern = $match[2] ?? '[^\/]+';
return "(?P<$name>$pattern)";
}, $pattern);
$pattern = strtr($match[2] ?? '[^\/]+', StringHelper::regexTokens());
$token = "<$name>";
$this->regexTokens[$token] = "(?P<$name>$pattern)";

return $token;
}, $from);

$replacements = array_merge($this->regexTokens, [
'.' => '\\.',
'*' => '\\*',
'$' => '\\$',
'[' => '\\[',
']' => '\\]',
'(' => '\\(',
')' => '\\)',
]);

$pattern = strtr($tokenizedPattern, $replacements);
$flags = $this->caseSensitive ? 'u' : 'iu';

return "`^{$pattern}$`{$flags}";
}
}
14 changes: 1 addition & 13 deletions src/web/UrlRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

namespace craft\web;

use Craft;
use craft\helpers\ArrayHelper;
use craft\helpers\StringHelper;

Expand Down Expand Up @@ -50,19 +49,8 @@ public function __construct(array $config = [])
if (isset($config['pattern'])) {
// Swap out any regex tokens in the pattern
if (!isset(self::$_regexTokens)) {
$slugChars = ['.', '_', '-'];
$slugWordSeparator = Craft::$app->getConfig()->getGeneral()->slugWordSeparator;

if ($slugWordSeparator !== '/' && !in_array($slugWordSeparator, $slugChars, true)) {
$slugChars[] = $slugWordSeparator;
}

// Reference: http://www.regular-expressions.info/unicode.html
self::$_regexTokens = [
'{handle}' => '(?:[a-zA-Z][a-zA-Z0-9_]*)',
'{slug}' => '(?:[\p{L}\p{N}\p{M}' . preg_quote(implode($slugChars), '/') . ']+)',
'{uid}' => StringHelper::UUID_PATTERN,
];
self::$_regexTokens = StringHelper::regexTokens();
}

$config['pattern'] = strtr($config['pattern'], self::$_regexTokens);
Expand Down
9 changes: 7 additions & 2 deletions tests/_craft/config/redirects.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
// Path match (case-insensitive by default)
'redirect/from' => 'redirect/to',

// Path match using Yii's URL rule pattern matching:
// https://www.yiiframework.com/doc/guide/2.0/en/runtime-routing#url-rules
// Path match with Yii URL Rule named parameters
// https://www.yiiframework.com/doc/guide/2.0/en/runtime-routing#named-parameters
'redirect/from/foo/<bar:{slug}>' => 'redirect/to/<bar>',

'redirect/from/$special.chars' => 'redirect/to',

// Path match (case-sensitive)
[
'from' => 'redirect/FROM/<year:\d{4}>/<month>',
'to' => 'https://redirect.to/<year>/<month>',
Expand Down
7 changes: 6 additions & 1 deletion tests/api/RedirectCest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ private function redirectDataProvider(): array
'to' => 'https://craft-5-project.ddev.site/redirect/to',
'statusCode' => 302,
],
[
'fromPath' => '/redirect/from/$special.chars',
'to' => 'https://craft-5-project.ddev.site/redirect/to',
'statusCode' => 302,
],
[
'fromPath' => '/redirect/from/1234/56',
'statusCode' => 404,
Expand All @@ -50,7 +55,7 @@ private function redirectDataProvider(): array
'statusCode' => 302,
],
[
'fromPath' => '/redirect/from/foo',
'fromPath' => '/foo',
'fromParams' => ['bar' => 'baz'],
'to' => 'https://craft-5-project.ddev.site/redirect/to/baz',
'statusCode' => 301,
Expand Down

0 comments on commit 5075dc1

Please sign in to comment.