Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions src/Routing/UrlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Statamic\Facades\Parse;
use Statamic\Facades\URL;
use Statamic\Support\Str;
use Statamic\View\Antlers\Language\Runtime\EnvironmentDetails;

class UrlBuilder implements UrlBuilderContract
{
Expand All @@ -17,6 +18,17 @@ class UrlBuilder implements UrlBuilderContract

protected $merged = [];

/**
* Names that mean something other than a variable to Antlers.
*/
private const ANTLERS_KEYWORDS = [
'true', 'false', 'null', 'void', 'list', 'as', 'and', 'or', 'xor', 'not',
'if', 'elseif', 'else', 'unless', 'elseunless', 'endif', 'endunless',
'switch', 'once', 'endonce', 'noparse', 'endnoparse', 'stop', 'recursive', 'new',
'bwa', 'bwo', 'bxor', 'bnot', 'bsl', 'bsr',
'arr', 'merge', 'pluck', 'where', 'take', 'skip', 'orderby', 'groupby',
];

/**
* @param \Statamic\Contracts\Entries\Entry|\Statamic\Taxonomies\Term $content
* @return $this
Expand Down Expand Up @@ -48,11 +60,13 @@ public function build($route)
// or they can be an array with a route for each locale.
$route = (is_array($route)) ? $route[$this->content->locale()] : $route;

$route = $this->convertToAntlers($route);
if (($url = $this->fillPlaceholders($route)) === null) {
$route = $this->convertToAntlers($route);

$route = Parse::config($route);
$route = Parse::config($route);

$url = Antlers::parse($route, $this->routeData());
$url = Antlers::parse($route, $this->routeData());
}

// Slugify it because we're dealing with URLs after all.
$url = $this->slugify($url);
Expand All @@ -63,6 +77,62 @@ public function build($route)
return URL::tidy($url, withTrailingSlash: false);
}

/**
* Most routes are only placeholders, like "/blog/{slug}" or "{parent_uri}/{slug}".
* Their values can be put straight in, without creating an Antlers parser for every URL.
*
* Returns null when Antlers is needed: the route is written in Antlers, a placeholder
* isn't a plain variable name, or its value isn't a plain string or integer.
*/
private function fillPlaceholders($route): ?string
{
// Antlers also compiles component tags (<s:partial />), so anything that might be one is left to it.
if (! is_string($route) || Str::contains($route, ['{{', '<'])) {
return null;
}

$data = null;
$filled = true;

$url = preg_replace_callback('/{\s*([a-zA-Z0-9_\-]+)\s*}/', function ($match) use (&$data, &$filled) {
$name = $match[1];

if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name) || in_array(strtolower($name), self::ANTLERS_KEYWORDS, true)) {
$filled = false;

return '';
}

$data ??= $this->routeData();
$value = $data[$name] ?? null;

if (is_int($value)) {
return (string) $value;
}

// A value with braces in it would read as another placeholder or as Antlers.
if (is_string($value) && strpbrk($value, '{}') === false) {
return $value;
}

$filled = false;

return '';
}, $route);

// Any braces left over aren't placeholders, so Antlers decides what they mean.
if (! $filled || $url === null || strpbrk($url, '{}') !== false) {
return null;
}

// Creating an Antlers parser also records which tags and modifiers exist at that
// moment, and a tag registered after the view engine was created is only seen once
// that happens again. Building a URL has always done it, so it still does.
app(EnvironmentDetails::class);

return $url;
}

private function convertToAntlers($route)
{
if (Str::contains($route, '{{')) {
Expand Down
100 changes: 100 additions & 0 deletions tests/Routing/UrlBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Tests\Routing;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Contracts\Routing\UrlBuilder;
use Statamic\Facades\Antlers;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

Expand Down Expand Up @@ -130,6 +132,104 @@ public function it_removes_consecutive_slashes_left_by_null_values()
);
}

#[Test]
public function it_builds_placeholder_routes_without_antlers()
{
Antlers::shouldReceive('parse')->never();

$this->assertEquals('/blog/post', $this->builder->build('/blog/{slug}'));
$this->assertEquals('/blog/2015/01/02/post', $this->builder->build('/blog/{year}/{month}/{ day }/{slug}'));
$this->assertEquals('/blog/foo/bar/post.html', $this->builder->build('blog/{slashed}/{slug}.html/'));
$this->assertEquals('/test/bar-baz', $this->builder->merge(['foo' => 'Bar baz', 'number' => 5])->build('/test/{foo}'));
$this->assertEquals('/test/5', $this->builder->build('/test/{number}'));
$this->assertEquals('/blog/test', $this->builder->build('/blog/test'));
}

#[Test]
#[DataProvider('routesNeedingAntlersProvider')]
public function it_uses_antlers_when_a_route_is_more_than_placeholders($route, $merge)
{
$expected = Antlers::parse(
preg_replace('/{\s*([a-zA-Z0-9_\-]+)\s*}/', '{{ $1 }}', $route),
array_merge($this->entry->routeData(), $merge)
);

Antlers::partialMock()->shouldReceive('parse')->once()->passthru();

$this->builder->merge($merge)->build($route);

$this->assertNotNull($expected);
}

#[Test]
public function it_uses_antlers_when_a_route_might_contain_a_component_tag()
{
Antlers::partialMock()->shouldReceive('parse')->once()->andReturn('/blog/from-antlers');

$this->assertSame('/blog/from-antlers', $this->builder->build('/blog/<s:foo />/{slug}'));
}

public static function routesNeedingAntlersProvider()
{
return [
'written in antlers' => ['/blog/{{ slug }}', []],
'missing value' => ['/blog/{missing}/{slug}', []],
'null value' => ['/blog/{nothing}/{slug}', ['nothing' => null]],
'array value' => ['/blog/{list_of}/{slug}', ['list_of' => ['a', 'b']]],
'boolean value' => ['/blog/{flag}/{slug}', ['flag' => true]],
'float value' => ['/blog/{price}/{slug}', ['price' => 1.5]],
'value with braces' => ['/blog/{foo}/{slug}', ['foo' => 'a{slug}b']],
'value with antlers' => ['/blog/{foo}/{slug}', ['foo' => '{{ slug }}']],
'hyphenated name' => ['/blog/{my-field}/{slug}', ['my-field' => 'x']],
'name starting with a digit' => ['/blog/{2nd}/{slug}', ['2nd' => 'x']],
'antlers keyword' => ['/blog/{true}/{slug}', ['true' => 'x']],
'leftover braces' => ['/blog/{ not a placeholder }/{slug}', []],
];
}

#[Test]
public function placeholder_routes_give_the_same_url_as_antlers_for_names_antlers_knows()
{
// A value in the route data wins over a tag or modifier of the same name in Antlers,
// but not over the language's own words. Either way a placeholder has to give
// whatever Antlers gives, including when Antlers can't parse it.
$words = [
'true', 'TRUE', 'false', 'null', 'Null', 'void', 'list', 'as', 'and', 'And', 'or', 'xor', 'not',
'if', 'elseif', 'else', 'unless', 'elseunless', 'endif', 'endunless', 'switch', 'once', 'endonce',
'noparse', 'endnoparse', 'stop', 'recursive', 'new', 'bwa', 'bwo', 'bxor', 'bnot', 'bsl', 'bsr',
'arr', 'merge', 'pluck', 'where', 'take', 'skip', 'orderby', 'groupby', 'gt', 'gte', 'lt', 'lte', 'eq', 'ne',
'in', 'is', 'isnt', 'view', 'this',
'id', 'slug', 'title', 'year', 'month', 'day', 'mount', 'parent_uri', 'depth', 'collection', 'locale', 'site', 'url', 'uri',
];

$names = collect(app('statamic.tags')->keys())
->merge(app('statamic.modifiers')->keys())
->merge($words)
->unique()
->filter(fn ($name) => preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name))
->values();

$this->assertGreaterThan(300, $names->count());

$build = function ($route) {
try {
return $this->builder->build($route);
} catch (\Throwable $e) {
return get_class($e);
}
};

foreach ($names as $name) {
$this->builder->merge([$name => 'Some Value']);

$this->assertSame(
$build('/x/{{ '.$name.' }}/{{ slug }}'),
$build('/x/{'.$name.'}/{slug}'),
"The route placeholder {{$name}} gave a different URL without Antlers."
);
}
}

#[Test]
public function it_preserves_dots_in_url()
{
Expand Down
Loading