diff --git a/src/Routing/UrlBuilder.php b/src/Routing/UrlBuilder.php
index f2044eef79e..142e42d8c06 100644
--- a/src/Routing/UrlBuilder.php
+++ b/src/Routing/UrlBuilder.php
@@ -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
{
@@ -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
@@ -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);
@@ -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 (), 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, '{{')) {
diff --git a/tests/Routing/UrlBuilderTest.php b/tests/Routing/UrlBuilderTest.php
index 5699802c42f..0dc5507fd5b 100644
--- a/tests/Routing/UrlBuilderTest.php
+++ b/tests/Routing/UrlBuilderTest.php
@@ -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;
@@ -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//{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()
{